1use std::path::{Path, PathBuf};
2
3use rusqlite::{Connection, OptionalExtension};
4use tracing::info;
5
6use crate::*;
7use coven_protocol::synced_schema::SyncedTable;
8
9use super::*;
10
11pub struct CreatedSnapshot {
12 db_image: SnapshotDatabaseImage,
13 blobs: Vec<SnapshotBlobFact>,
14}
15
16impl CreatedSnapshot {
17 pub fn new(db_image: SnapshotDatabaseImage, blobs: Vec<SnapshotBlobFact>) -> Self {
18 Self { db_image, blobs }
19 }
20
21 pub fn blobs(&self) -> &[SnapshotBlobFact] {
22 &self.blobs
23 }
24
25 pub async fn read_image(&self) -> Result<Vec<u8>, SnapshotImageError> {
26 self.db_image.read().await
27 }
28
29 pub fn into_parts(self) -> (SnapshotDatabaseImage, Vec<SnapshotBlobFact>) {
30 (self.db_image, self.blobs)
31 }
32
33 #[cfg(any(test, feature = "test-utils"))]
34 pub fn image_path_for_test(&self) -> &Path {
35 self.db_image.path()
36 }
37}
38
39#[derive(Debug, Clone)]
40pub struct SnapshotBlobFact {
41 pub fact: crate::StoreWriteBlobFact,
42 pub audience: RemoteAudience,
43}
44
45#[derive(Debug, thiserror::Error)]
46pub enum SnapshotImageError {
47 #[error("IO error: {0}")]
48 Io(#[from] std::io::Error),
49 #[error("no synced tables registered; refusing to emit an all-cleared snapshot")]
50 NoSyncedTables,
51 #[error("failed to scope snapshot down to shareable data: {0}")]
52 Projection(String),
53 #[error("snapshot database: {0}")]
54 Database(#[source] Box<DbError>),
55 #[error("snapshot gate: {0}")]
56 Gate(#[from] crate::GateError),
57 #[error("snapshot row routing key: {0}")]
58 RowRoutingKey(#[from] coven_protocol::circle::RowRoutingKeyError),
59 #[error("snapshot SQLite: {0}")]
60 Sqlite(#[from] rusqlite::Error),
61 #[error("snapshot remote object: {0}")]
62 RemoteObject(#[from] coven_protocol::remote_object::RemoteObjectRecordError),
63 #[error("snapshot blob declarations: {0}")]
64 BlobDecl(#[from] crate::BlobDeclError),
65 #[error("snapshot routing contract: {0}")]
66 RoutingContract(#[from] crate::SyncRoutingContractError),
67 #[error("snapshot projection {operation}: {source}")]
68 ProjectionSqlite {
69 operation: String,
70 #[source]
71 source: rusqlite::Error,
72 },
73 #[error("snapshot projection {operation}: {source}")]
74 ProjectionDatabase {
75 operation: String,
76 #[source]
77 source: Box<DbError>,
78 },
79 #[error("snapshot projection {operation}: {source}")]
80 ProjectionIo {
81 operation: String,
82 #[source]
83 source: std::io::Error,
84 },
85 #[error("snapshot projection {operation}: {source}")]
86 ProjectionPayloadStore {
87 operation: String,
88 #[source]
89 source: crate::PayloadStoreError,
90 },
91 #[error("snapshot blob {namespace}/{id} plaintext hash: {source}")]
92 BlobHash {
93 namespace: String,
94 id: String,
95 #[source]
96 source: coven_foundation::object_hash::InvalidObjectHash,
97 },
98 #[error(
99 "could not remove staged snapshot database {path}: {cleanup}",
100 path = .path.display()
101 )]
102 Cleanup { path: PathBuf, cleanup: String },
103 #[error(
104 "snapshot operation failed and staged database {path} could not be removed: {cleanup} \
105 (operation error: {cause})",
106 path = .path.display()
107 )]
108 CleanupAfterFailure {
109 path: PathBuf,
110 cleanup: String,
111 cause: Box<SnapshotImageError>,
112 },
113}
114
115impl From<DbError> for SnapshotImageError {
116 fn from(error: DbError) -> Self {
117 Self::Database(Box::new(error))
118 }
119}
120
121#[derive(Debug)]
122pub enum SnapshotImageOperationError<E> {
123 Operation(E),
124 Cleanup {
125 path: PathBuf,
126 cleanup: String,
127 },
128 CleanupAfterFailure {
129 path: PathBuf,
130 cleanup: String,
131 cause: E,
132 },
133}
134
135#[derive(Debug)]
140pub struct SnapshotDatabaseImage {
141 path: PathBuf,
142 armed: bool,
143}
144
145impl SnapshotDatabaseImage {
146 pub fn prepare(path: PathBuf) -> Result<Self, SnapshotImageError> {
147 let mut staged = Self { path, armed: true };
148 if let Err(cleanup) = staged.remove_files() {
149 staged.armed = false;
150 return Err(SnapshotImageError::Cleanup {
151 path: staged.path.clone(),
152 cleanup: cleanup.to_string(),
153 });
154 }
155 Ok(staged)
156 }
157
158 pub fn create(path: PathBuf, plaintext: &[u8]) -> Result<Self, SnapshotImageError> {
159 if let Some(parent) = path.parent() {
160 std::fs::create_dir_all(parent)?;
161 }
162 Self { path, armed: false }.write_new(plaintext)
163 }
164
165 pub fn replace(path: PathBuf, plaintext: &[u8]) -> Result<Self, SnapshotImageError> {
166 Self::prepare(path)?.write_new(plaintext)
167 }
168
169 pub(super) fn prepare_snapshot(temp_dir: &Path) -> Result<Self, SnapshotImageError> {
170 Self::prepare(temp_dir.join("snapshot.db"))
171 }
172
173 #[allow(clippy::too_many_arguments)]
174 pub(super) fn capture_on(
175 self,
176 connection: &Connection,
177 store_dir: &coven_foundation::store_dir::StoreDir,
178 mut authority: VerifiedStoreAuthority,
179 root: &coven_protocol::store_commit::StoreRootRef,
180 tables: &[SyncedTable],
181 routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
182 audience: &coven_protocol::circle::Audience,
183 ) -> Result<CreatedSnapshot, SnapshotImageError> {
184 if tables.is_empty() {
185 return self.finish(Err(SnapshotImageError::NoSyncedTables));
186 }
187 let gates = match crate::Gates::from_tables(connection, tables) {
188 Ok(gates) => gates,
189 Err(error) => {
190 return self.finish(Err(SnapshotImageError::from(error)));
191 }
192 };
193 let routing_key = if gates.has_scoped_graph() {
194 let encryption = match routing_encryption {
195 Some(encryption) => encryption,
196 None => {
197 return self.finish(Err(SnapshotImageError::Projection(
198 "scoped snapshot creation requires Store routing encryption".to_string(),
199 )));
200 }
201 };
202 match coven_protocol::circle::derive_row_routing_key(encryption, root.store_root_hash) {
203 Ok(routing_key) => Some(routing_key),
204 Err(error) => {
205 return self.finish(Err(SnapshotImageError::from(error)));
206 }
207 }
208 } else {
209 None
210 };
211
212 let source_image = match crate::connection_io::serialize_database_image(connection) {
213 Ok(image) => image,
214 Err(error) => {
215 return self.finish(Err(SnapshotImageError::from(error)));
216 }
217 };
218 let mut snapshot = match Connection::open_in_memory().map_err(DbError::from) {
219 Ok(snapshot) => snapshot,
220 Err(error) => {
221 return self.finish(Err(SnapshotImageError::from(error)));
222 }
223 };
224 if let Err(error) =
225 crate::connection_io::deserialize_database_image_into(&mut snapshot, &source_image)
226 {
227 return self.finish(Err(SnapshotImageError::from(error)));
228 }
229 if let Err(error) = Self::project(
230 &mut snapshot,
231 store_dir,
232 &mut authority,
233 root,
234 tables,
235 routing_key.as_ref(),
236 audience,
237 ) {
238 return self.finish(Err(error));
239 }
240 let blobs = match Self::blob_facts(connection, &snapshot, tables) {
241 Ok(blobs) => blobs,
242 Err(error) => return self.finish(Err(error)),
243 };
244 if matches!(audience, coven_protocol::circle::Audience::Circle(_)) {
245 if let Err(error) = Self::strip_circle_transport_state(&mut snapshot) {
246 return self.finish(Err(error));
247 }
248 }
249
250 let image = match crate::connection_io::serialize_database_image(&snapshot) {
251 Ok(image) => image,
252 Err(error) => {
253 return self.finish(Err(SnapshotImageError::from(error)));
254 }
255 };
256 drop(snapshot);
257 let snapshot = self.write_new(&image)?;
258
259 let plaintext_size = match std::fs::metadata(snapshot.path()) {
260 Ok(metadata) => metadata.len(),
261 Err(error) => return snapshot.finish(Err(SnapshotImageError::Io(error))),
262 };
263 info!(plaintext_size, "created snapshot");
264 Ok(CreatedSnapshot::new(snapshot, blobs))
265 }
266
267 fn write_new(mut self, plaintext: &[u8]) -> Result<Self, SnapshotImageError> {
268 let mut file = match std::fs::OpenOptions::new()
269 .write(true)
270 .create_new(true)
271 .open(&self.path)
272 {
273 Ok(file) => file,
274 Err(error) => {
275 self.armed = false;
276 return Err(SnapshotImageError::Io(error));
277 }
278 };
279 self.armed = true;
280 if let Err(error) = std::io::Write::write_all(&mut file, plaintext) {
281 drop(file);
282 return self.finish(Err(SnapshotImageError::Io(error)));
283 }
284 drop(file);
285 Ok(self)
286 }
287
288 pub fn read_replay_baseline(
291 plaintext: &[u8],
292 snapshot: PublishedStoreSnapshot,
293 genesis: &coven_protocol::store_commit::ResolvedStoreDeviceState,
294 ) -> Result<InstalledReplayBaseline, SnapshotImageError> {
295 if coven_protocol::store_commit::ObjectHash::digest(plaintext)
296 != snapshot.meta.image.image_hash
297 {
298 return Err(SnapshotImageError::Projection(
299 "snapshot image differs from its signed hash".into(),
300 ));
301 }
302 snapshot
303 .meta
304 .history_summary
305 .validate_snapshot_baseline()
306 .map_err(DbError::from)?;
307 let mut connection = Connection::open_in_memory()?;
308 crate::connection_io::deserialize_database_image_into(&mut connection, plaintext)?;
309 let coverage = snapshot.meta.coverage.clone();
310 let (reference, state) = if coverage.commits().is_empty() {
311 genesis.validate_canonical().map_err(DbError::from)?;
312 (
313 coven_protocol::store_commit::StoreDeviceStateRef::from_resolved(
314 coverage.clone(),
315 genesis,
316 )
317 .map_err(DbError::from)?,
318 genesis.clone(),
319 )
320 } else {
321 crate::store::store_device_state::store_device_state_for_history_cut_on(
322 &connection,
323 &coven_protocol::store_commit::StoreHistoryCut(coverage.commits().clone()),
324 )?
325 };
326 if state != snapshot.meta.state.devices
327 || reference != snapshot.meta.history_summary.post_state
328 {
329 return Err(SnapshotImageError::Projection(
330 "snapshot image device state differs from its signed cut".into(),
331 ));
332 }
333 let states = crate::store::store_device_state::load_covered_store_device_snapshots_on(
334 &connection,
335 &coverage,
336 )?;
337 if states.keys().any(|reference| {
338 snapshot
339 .meta
340 .history_summary
341 .causal_cut
342 .get(&reference.coord)
343 != Some(reference)
344 }) {
345 return Err(SnapshotImageError::Projection(
346 "snapshot image carries a device state outside its exact accepted history".into(),
347 ));
348 }
349 Ok(InstalledReplayBaseline::from_snapshot(snapshot, states))
350 }
351
352 pub fn path(&self) -> &Path {
353 &self.path
354 }
355
356 pub fn contains_reclaimable_store_blob(
359 plaintext: &[u8],
360 snapshot: &coven_protocol::store_commit::SnapshotMeta,
361 stored: &coven_protocol::blob::locator::StoredBlobRef,
362 ) -> Result<bool, SnapshotImageError> {
363 if ObjectHash::digest(plaintext) != snapshot.image.image_hash {
364 return Err(SnapshotImageError::Projection(
365 "snapshot blob inventory differs from its signed image hash".into(),
366 ));
367 }
368 if stored.locator().audience() != RemoteAudience::Store {
369 return Err(SnapshotImageError::Projection(
370 "Store snapshot inventory cannot authorize a Circle blob".into(),
371 ));
372 }
373 let mut connection = Connection::open_in_memory()?;
374 crate::connection_io::deserialize_database_image_into(&mut connection, plaintext)?;
375 let id = coven_protocol::remote_object::remote_object_id(stored.object());
376 let exists: bool = connection.query_row(
377 "SELECT EXISTS(SELECT 1 FROM blob_locators WHERE remote_object_id = ?1)",
378 [id.to_string()],
379 |row| row.get(0),
380 )?;
381 if !exists {
382 return Ok(false);
383 }
384 crate::blob_records::validate_stored_locator_on(&connection, stored)?;
385 let remote = crate::remote_object_records::load_remote_object_on(&connection, id)?;
386 remote.validate_reclaimable_stored_blob(stored)?;
387 let owners = remote.stored_blob_commit_owners();
388 if owners.is_empty()
389 || owners
390 .iter()
391 .any(|owner| snapshot.history_summary.causal_cut.get(&owner.coord) != Some(owner))
392 {
393 return Err(SnapshotImageError::Projection(
394 "snapshot blob inventory has no exact accepted publication owner".into(),
395 ));
396 }
397 let live: bool = connection.query_row(
398 "SELECT EXISTS(SELECT 1 FROM row_blob_locators WHERE remote_object_id = ?1)",
399 [id.to_string()],
400 |row| row.get(0),
401 )?;
402 Ok(!live
403 && remote.snapshot_owners().next().is_none()
404 && remote.retained_replay_owners().next().is_none())
405 }
406
407 pub async fn read(&self) -> Result<Vec<u8>, SnapshotImageError> {
408 tokio::fs::read(&self.path)
409 .await
410 .map_err(|error| SnapshotImageError::ProjectionIo {
411 operation: format!("read staged snapshot database {}", self.path.display()),
412 source: error,
413 })
414 }
415
416 pub fn read_and_discard(self) -> Result<Vec<u8>, SnapshotImageError> {
417 let outcome = std::fs::read(&self.path).map_err(SnapshotImageError::Io);
418 self.finish(outcome)
419 }
420
421 pub fn canonicalize(mut self) -> Result<Self, SnapshotImageError> {
422 match std::fs::canonicalize(&self.path) {
423 Ok(path) => {
424 self.path = path;
425 Ok(self)
426 }
427 Err(error) => self.finish(Err(SnapshotImageError::Io(error))),
428 }
429 }
430
431 pub fn finish<T>(
432 self,
433 outcome: Result<T, SnapshotImageError>,
434 ) -> Result<T, SnapshotImageError> {
435 match self.finish_operation(outcome) {
436 Ok(value) => Ok(value),
437 Err(SnapshotImageOperationError::Operation(cause)) => Err(cause),
438 Err(SnapshotImageOperationError::Cleanup { path, cleanup }) => {
439 Err(SnapshotImageError::Cleanup { path, cleanup })
440 }
441 Err(SnapshotImageOperationError::CleanupAfterFailure {
442 path,
443 cleanup,
444 cause,
445 }) => Err(SnapshotImageError::CleanupAfterFailure {
446 path,
447 cleanup,
448 cause: Box::new(cause),
449 }),
450 }
451 }
452
453 pub fn finish_operation<T, E>(
454 mut self,
455 outcome: Result<T, E>,
456 ) -> Result<T, SnapshotImageOperationError<E>> {
457 let cleanup = self.remove_files();
458 self.armed = false;
459 match (outcome, cleanup) {
460 (Ok(value), Ok(())) => Ok(value),
461 (Err(cause), Ok(())) => Err(SnapshotImageOperationError::Operation(cause)),
462 (Ok(_), Err(cleanup)) => Err(SnapshotImageOperationError::Cleanup {
463 path: self.path.clone(),
464 cleanup: cleanup.to_string(),
465 }),
466 (Err(cause), Err(cleanup)) => Err(SnapshotImageOperationError::CleanupAfterFailure {
467 path: self.path.clone(),
468 cleanup: cleanup.to_string(),
469 cause,
470 }),
471 }
472 }
473
474 pub fn commit(mut self) -> PathBuf {
475 self.armed = false;
476 std::mem::take(&mut self.path)
477 }
478
479 fn project(
480 connection: &mut Connection,
481 store_dir: &coven_foundation::store_dir::StoreDir,
482 authority: &mut VerifiedStoreAuthority,
483 root: &coven_protocol::store_commit::StoreRootRef,
484 synced: &[SyncedTable],
485 routing_key: Option<&coven_protocol::circle::RowRoutingKey>,
486 audience: &coven_protocol::circle::Audience,
487 ) -> Result<(), SnapshotImageError> {
488 let gates =
489 crate::Gates::from_tables(connection, synced).map_err(SnapshotImageError::from)?;
490 if gates.has_scoped_graph() && routing_key.is_none() {
491 return Err(SnapshotImageError::Projection(
492 "scoped snapshot projection requires a row-routing key".to_string(),
493 ));
494 }
495 let transaction = connection
496 .unchecked_transaction()
497 .map_err(SnapshotImageError::from)?;
498 transaction
499 .pragma_update(None, "defer_foreign_keys", "ON")
500 .map_err(SnapshotImageError::from)?;
501 let coverage =
502 crate::store::materialized_commit_index::materialized_frontier_on(&transaction, None)
503 .map_err(SnapshotImageError::from)?;
504 let cleared_materialization_tables = ["materialized_commits"];
505 for table in cleared_materialization_tables {
506 transaction
507 .execute_batch(&format!("DELETE FROM {}", crate::quote_ident(table)))
508 .map_err(|error| SnapshotImageError::ProjectionSqlite {
509 operation: format!("clear {table}"),
510 source: error,
511 })?;
512 }
513 if matches!(audience, coven_protocol::circle::Audience::Store) {
514 let records =
515 crate::store::store_session::StoreTransaction::new(&transaction, store_dir);
516 records
517 .project_shared_snapshot_replay_inputs(authority, root)
518 .map_err(SnapshotImageError::from)?;
519 records
520 .retain_snapshot_replay_inputs(
521 authority,
522 root,
523 &coven_protocol::store_commit::CommitFrontier::from_refs(coverage.clone())
524 .map_err(DbError::from)?,
525 )
526 .map_err(SnapshotImageError::from)?;
527 records
528 .retain_snapshot_device_states(authority, root, coverage)
529 .map_err(SnapshotImageError::from)?;
530 }
531 let preserved_non_synced_tables = match audience {
532 coven_protocol::circle::Audience::Store => SNAPSHOT_PRESERVED_NON_SYNCED_TABLES,
533 coven_protocol::circle::Audience::Circle(_) => CIRCLE_IMAGE_PRESERVED_NON_SYNCED_TABLES,
534 coven_protocol::circle::Audience::Local => {
535 return Err(SnapshotImageError::Projection(
536 "Local rows cannot enter a snapshot".to_string(),
537 ));
538 }
539 };
540 for table in crate::user_table_names(connection).map_err(|error| {
541 SnapshotImageError::ProjectionSqlite {
542 operation: "list user tables".to_string(),
543 source: error,
544 }
545 })? {
546 if synced.iter().any(|synced| synced.name() == table)
547 || preserved_non_synced_tables.contains(&table.as_str())
548 || cleared_materialization_tables.contains(&table.as_str())
549 {
550 continue;
551 }
552 transaction
553 .execute_batch(&format!("DELETE FROM {}", crate::quote_ident(&table)))
554 .map_err(|error| SnapshotImageError::ProjectionSqlite {
555 operation: format!("clear {table}"),
556 source: error,
557 })?;
558 }
559
560 match audience {
561 coven_protocol::circle::Audience::Store => gates
562 .delete_gated_false(&transaction)
563 .map_err(SnapshotImageError::from)?,
564 coven_protocol::circle::Audience::Circle(_) => {
565 crate::retain_snapshot_audience_rows(&transaction, &gates, audience)
566 .map_err(SnapshotImageError::from)?;
567 }
568 coven_protocol::circle::Audience::Local => {
569 return Err(SnapshotImageError::Projection(
570 "Local rows cannot enter a snapshot".to_string(),
571 ));
572 }
573 }
574 if let Some(routing_key) = routing_key {
575 crate::prune_private_routes_without_rows(&transaction, &gates)
576 .map_err(SnapshotImageError::from)?;
577 crate::validate_snapshot_routing_state(&transaction, &gates, routing_key, audience)
578 .map_err(SnapshotImageError::from)?;
579 }
580
581 scope_authenticated_blob_graph(&transaction, synced, audience)?;
582 transaction.commit().map_err(SnapshotImageError::from)?;
583 if matches!(audience, coven_protocol::circle::Audience::Store) {
584 connection.execute_batch("VACUUM").map_err(|error| {
585 SnapshotImageError::ProjectionSqlite {
586 operation: "vacuum".to_string(),
587 source: error,
588 }
589 })?;
590 }
591 Ok(())
592 }
593
594 fn strip_circle_transport_state(connection: &mut Connection) -> Result<(), SnapshotImageError> {
595 connection
596 .pragma_update(None, "foreign_keys", "ON")
597 .map_err(SnapshotImageError::from)?;
598 let transaction = connection.transaction().map_err(SnapshotImageError::from)?;
599 transaction
602 .execute_batch(
603 "DELETE FROM row_blob_locators;
604 DELETE FROM blob_locators;
605 DELETE FROM retained_replay_objects;
606 DELETE FROM remote_objects;
607 DELETE FROM retained_merge_materializations;",
608 )
609 .map_err(|error| SnapshotImageError::ProjectionSqlite {
610 operation: "strip Circle snapshot transport state".to_string(),
611 source: error,
612 })?;
613 transaction.commit().map_err(SnapshotImageError::from)?;
614 connection.execute_batch("VACUUM").map_err(|error| {
615 SnapshotImageError::ProjectionSqlite {
616 operation: "vacuum Circle snapshot transport projection".to_string(),
617 source: error,
618 }
619 })?;
620 Ok(())
621 }
622
623 pub fn install_blob_graph(
624 self,
625 owner: &coven_protocol::remote_object::SnapshotObjectOwner,
626 blobs: &[crate::PreparedSnapshotBlob],
627 pending_store_snapshots: &BTreeSet<coven_protocol::objects::ObjectSlot>,
628 ) -> Result<Self, SnapshotImageError> {
629 let result = (|| {
630 let source = std::fs::read(self.path()).map_err(SnapshotImageError::Io)?;
631 let mut connection = Connection::open_in_memory()
632 .map_err(DbError::from)
633 .map_err(SnapshotImageError::from)?;
634 crate::connection_io::deserialize_database_image_into(&mut connection, &source)
635 .map_err(SnapshotImageError::from)?;
636 connection
637 .pragma_update(None, "foreign_keys", "ON")
638 .map_err(SnapshotImageError::from)?;
639 let transaction = connection.transaction().map_err(SnapshotImageError::from)?;
640 for blob in blobs {
641 blob.remote.validate().map_err(SnapshotImageError::from)?;
642 if blob.remote.snapshot_owners().collect::<Vec<_>>() != [owner]
643 || blob.bindings.is_empty()
644 || blob
645 .bindings
646 .iter()
647 .any(|binding| binding.blob().object() != blob.remote.object())
648 {
649 return Err(SnapshotImageError::Projection(
650 "snapshot blob binding differs from its remote object".to_string(),
651 ));
652 }
653 crate::install_snapshot_blob_plan_on(&transaction, blob).map_err(|error| {
654 SnapshotImageError::ProjectionDatabase {
655 operation: "install snapshot blob".to_string(),
656 source: Box::new(error),
657 }
658 })?;
659 }
660 crate::snapshot_objects::replace_snapshot_object_owners_on(
661 &transaction,
662 owner,
663 blobs,
664 pending_store_snapshots,
665 )?;
666 transaction.commit().map_err(SnapshotImageError::from)?;
667 connection.execute_batch("VACUUM").map_err(|error| {
668 SnapshotImageError::ProjectionSqlite {
669 operation: "vacuum snapshot closure".to_string(),
670 source: error,
671 }
672 })?;
673 let image = crate::connection_io::serialize_database_image(&connection)
674 .map_err(SnapshotImageError::from)?;
675 connection
676 .close()
677 .map_err(|(_, error)| SnapshotImageError::ProjectionSqlite {
678 operation: "close snapshot closure image".to_string(),
679 source: error,
680 })?;
681 let mut file = std::fs::OpenOptions::new()
682 .write(true)
683 .truncate(true)
684 .open(self.path())
685 .map_err(SnapshotImageError::Io)?;
686 std::io::Write::write_all(&mut file, &image).map_err(SnapshotImageError::Io)?;
687 Ok(())
688 })();
689 match result {
690 Ok(()) => Ok(self),
691 Err(error) => self.finish(Err(error)),
692 }
693 }
694
695 fn blob_facts(
696 live: &Connection,
697 snapshot: &Connection,
698 tables: &[SyncedTable],
699 ) -> Result<Vec<SnapshotBlobFact>, SnapshotImageError> {
700 let declarations =
701 crate::BlobDecls::from_tables(snapshot, tables).map_err(SnapshotImageError::from)?;
702 let publications = declarations
703 .publication_blobs_in_db(snapshot)
704 .map_err(SnapshotImageError::from)?;
705 let gates = crate::Gates::from_tables(live, tables).map_err(SnapshotImageError::from)?;
706 let mut facts = Vec::with_capacity(publications.len());
707 for publication in publications {
708 let plaintext_hash = publication.plaintext_hash.parse().map_err(|error| {
709 SnapshotImageError::BlobHash {
710 namespace: publication.blob.namespace.clone(),
711 id: publication.blob.id.clone(),
712 source: error,
713 }
714 })?;
715 let external_path =
716 if publication.blob.provenance == coven_protocol::blob::Provenance::UserProvided {
717 live.query_row(
718 "SELECT path FROM local_blob_refs
719 WHERE table_name = ?1 AND row_id = ?2 AND column_name = ?3
720 AND row_stamp = ?4 AND namespace = ?5 AND blob_id = ?6",
721 rusqlite::params![
722 publication.table,
723 publication.row_id,
724 publication.column,
725 publication.row_stamp,
726 publication.blob.namespace,
727 publication.blob.id,
728 ],
729 |row| row.get::<_, String>(0),
730 )
731 .optional()
732 .map_err(SnapshotImageError::from)?
733 .map(PathBuf::from)
734 } else {
735 None
736 };
737 let previous = crate::previous_row_blob_for_write_on(
738 snapshot,
739 &publication.table,
740 &publication.row_id,
741 &publication.row_stamp,
742 &publication.column,
743 &publication.blob,
744 publication.plaintext_size,
745 plaintext_hash,
746 )
747 .map_err(SnapshotImageError::from)?;
748 let audience = match crate::live_row_audience(
749 live,
750 &gates,
751 &publication.table,
752 &publication.row_id,
753 )
754 .map_err(SnapshotImageError::from)?
755 {
756 coven_protocol::circle::Audience::Store => RemoteAudience::Store,
757 coven_protocol::circle::Audience::Circle(circle_id) => {
758 RemoteAudience::Circle(circle_id)
759 }
760 coven_protocol::circle::Audience::Local => {
761 return Err(SnapshotImageError::Projection(format!(
762 "scoped snapshot retains local blob row {:?}/{:?}",
763 publication.table, publication.row_id
764 )));
765 }
766 };
767 facts.push(SnapshotBlobFact {
768 fact: crate::StoreWriteBlobFact {
769 table: publication.table,
770 row_id: publication.row_id,
771 row_stamp: publication.row_stamp,
772 column: publication.column,
773 blob: publication.blob,
774 plaintext_size: publication.plaintext_size,
775 plaintext_hash,
776 external_path,
777 previous,
778 audience_move: None,
779 },
780 audience,
781 });
782 }
783 Ok(facts)
784 }
785
786 fn remove_files(&self) -> std::io::Result<()> {
787 for candidate in [
788 self.path.clone(),
789 PathBuf::from(format!("{}-wal", self.path.display())),
790 PathBuf::from(format!("{}-shm", self.path.display())),
791 ] {
792 match std::fs::remove_file(candidate) {
793 Ok(()) => {}
794 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
795 Err(error) => return Err(error),
796 }
797 }
798 Ok(())
799 }
800}
801
802impl Drop for SnapshotDatabaseImage {
803 fn drop(&mut self) {
804 if !self.armed {
805 return;
806 }
807 if let Err(error) = self.remove_files() {
808 tracing::warn!(
809 path = %self.path.display(),
810 %error,
811 "could not remove abandoned staged snapshot database"
812 );
813 }
814 }
815}
816
817pub(super) fn snapshot_image_db_error(error: SnapshotImageError) -> DbError {
818 DbError::from(error)
819}
820
821const SNAPSHOT_PRESERVED_NON_SYNCED_TABLES: &[&str] = &[
822 "_coven_audience",
823 "_coven_row_routes",
824 "remote_objects",
825 "blob_locators",
826 "row_blob_locators",
827 "store_device_registration_activations",
828 "store_device_state_snapshots",
829 "store_device_states",
830 "store_author_exclusion_activations",
831 "store_publication_current",
832 "store_publication_entries",
833 "stream_activations",
836 "circle_control_activations",
837 "circle_access_cache",
838 "circle_bootstrap_coverage",
839 "circle_current_state",
840 "retained_merge_materializations",
841 "retained_replay_objects",
842];
843
844const CIRCLE_IMAGE_PRESERVED_NON_SYNCED_TABLES: &[&str] = &[
845 "_coven_audience",
846 "_coven_row_routes",
847 "remote_objects",
848 "blob_locators",
849 "row_blob_locators",
850 "retained_merge_materializations",
851 "retained_replay_objects",
852];
853
854fn scope_authenticated_blob_graph(
855 connection: &Connection,
856 synced: &[SyncedTable],
857 audience: &coven_protocol::circle::Audience,
858) -> Result<(), SnapshotImageError> {
859 connection
860 .execute_batch(
861 "CREATE TEMP TABLE snapshot_live_blob_bindings (
862 table_name TEXT NOT NULL,
863 row_id TEXT NOT NULL,
864 column_name TEXT NOT NULL,
865 row_stamp TEXT NOT NULL,
866 PRIMARY KEY (table_name, row_id, column_name, row_stamp)
867 ) STRICT;",
868 )
869 .map_err(|error| SnapshotImageError::ProjectionSqlite {
870 operation: "create blob scope".to_string(),
871 source: error,
872 })?;
873 for table in synced {
874 let Some(declaration) = table.blob() else {
875 continue;
876 };
877 connection
878 .execute(
879 &format!(
880 "INSERT INTO snapshot_live_blob_bindings
881 (table_name, row_id, column_name, row_stamp)
882 SELECT ?1, id, ?2, _updated_at FROM {}
883 WHERE {} IS NOT NULL",
884 crate::quote_ident(table.name()),
885 crate::quote_ident(&declaration.id_column),
886 ),
887 rusqlite::params![table.name(), &declaration.id_column],
888 )
889 .map_err(|error| SnapshotImageError::ProjectionSqlite {
890 operation: format!("collect live blob bindings for {:?}", table.name()),
891 source: error,
892 })?;
893 }
894 connection.execute_batch(
897 "DELETE FROM row_blob_locators
898 WHERE NOT EXISTS (
899 SELECT 1 FROM snapshot_live_blob_bindings AS live
900 WHERE live.table_name = row_blob_locators.table_name
901 AND live.row_id = row_blob_locators.row_id
902 AND live.column_name = row_blob_locators.column_name
903 AND live.row_stamp = row_blob_locators.row_stamp
904 );",
905 )?;
906 let mut statement = connection.prepare(
911 "SELECT remote_object_id FROM blob_locators
912 WHERE NOT EXISTS (SELECT 1 FROM row_blob_locators AS binding
913 WHERE binding.remote_object_id = blob_locators.remote_object_id)",
914 )?;
915 let orphan_ids = statement
916 .query_map([], |row| row.get::<_, String>(0))?
917 .collect::<Result<Vec<_>, _>>()?;
918 drop(statement);
919 for object_id in orphan_ids {
920 let id = object_id
921 .parse()
922 .map_err(|error| DbError::context("snapshot inventory object id", error))?;
923 let remote = crate::remote_object_records::load_remote_object_on(connection, id)?;
924 let keep = if matches!(audience, coven_protocol::circle::Audience::Store)
925 && remote.is_activated_stored_blob()
926 {
927 let locator =
928 crate::blob_records::carried_blob_locator(&remote, "snapshot blob inventory")?;
929 locator.audience() == RemoteAudience::Store
930 && !remote.stored_blob_commit_owners().is_empty()
931 } else {
932 false
933 };
934 if !keep {
935 connection.execute(
936 "DELETE FROM blob_locators WHERE remote_object_id = ?1",
937 [&object_id],
938 )?;
939 }
940 }
941 connection
942 .execute_batch(
943 "
944 DELETE FROM remote_objects
945 WHERE NOT EXISTS (
946 SELECT 1 FROM blob_locators AS locator
947 WHERE locator.remote_object_id = remote_objects.object_id
948 ) AND NOT EXISTS (
949 SELECT 1 FROM retained_replay_objects AS retained
950 WHERE retained.object_id = remote_objects.object_id
951 );
952 DROP TABLE snapshot_live_blob_bindings;",
953 )
954 .map_err(|error| SnapshotImageError::ProjectionSqlite {
955 operation: "scope blob ownership graph".to_string(),
956 source: error,
957 })?;
958 Ok(())
959}
960
961pub(super) fn verify_circle_bootstrap_image(
962 image: &[u8],
963 reference: &coven_protocol::circle::CircleBootstrapRef,
964 circle_id: coven_protocol::circle::CircleId,
965 tables: &[SyncedTable],
966 routing_key: Option<&coven_protocol::circle::RowRoutingKey>,
967) -> Result<(), SnapshotImageError> {
968 if coven_protocol::store_commit::ObjectHash::digest(image) != reference.image.image_hash {
969 return Err(SnapshotImageError::Projection(
970 "Circle bootstrap image differs from its signed hash".to_string(),
971 ));
972 }
973 let mut connection = Connection::open_in_memory()
974 .map_err(DbError::from)
975 .map_err(SnapshotImageError::from)?;
976 crate::connection_io::deserialize_database_image_into(&mut connection, image)
977 .map_err(SnapshotImageError::from)?;
978 verify_circle_bootstrap_connection(&connection, reference, circle_id, tables, routing_key)
979}
980
981pub(crate) fn verify_circle_bootstrap_connection(
982 connection: &Connection,
983 reference: &coven_protocol::circle::CircleBootstrapRef,
984 circle_id: coven_protocol::circle::CircleId,
985 tables: &[SyncedTable],
986 routing_key: Option<&coven_protocol::circle::RowRoutingKey>,
987) -> Result<(), SnapshotImageError> {
988 connection
989 .pragma_update(None, "foreign_keys", "ON")
990 .map_err(SnapshotImageError::from)?;
991 let schema_version: u32 = connection
992 .pragma_query_value(None, "user_version", |row| row.get(0))
993 .map_err(SnapshotImageError::from)?;
994 if schema_version != reference.schema_version {
995 return Err(SnapshotImageError::Projection(format!(
996 "Circle bootstrap schema is {schema_version}, expected {}",
997 reference.schema_version
998 )));
999 }
1000 let routing_contract = crate::SyncRoutingContract::from_connection(connection, tables)
1001 .map_err(SnapshotImageError::from)?;
1002 if routing_contract.hash() != reference.sync_routing_hash {
1003 return Err(SnapshotImageError::Projection(
1004 "Circle bootstrap routing contract differs from its signed hash".to_string(),
1005 ));
1006 }
1007 let gates = crate::Gates::from_tables(connection, tables).map_err(SnapshotImageError::from)?;
1008 if gates.has_scoped_graph() {
1009 let routing_key = routing_key.ok_or_else(|| {
1010 SnapshotImageError::Projection(
1011 "scoped Circle bootstrap verification requires Store routing authentication"
1012 .to_string(),
1013 )
1014 })?;
1015 crate::validate_snapshot_routing_state(
1016 connection,
1017 &gates,
1018 routing_key,
1019 &coven_protocol::circle::Audience::Circle(circle_id),
1020 )
1021 .map_err(SnapshotImageError::from)?;
1022 }
1023 let declarations =
1024 crate::BlobDecls::from_tables(connection, tables).map_err(SnapshotImageError::from)?;
1025 let rows = declarations
1026 .publication_blobs_in_db(connection)
1027 .map_err(SnapshotImageError::from)?;
1028 if rows.len() != reference.blobs.len() {
1029 return Err(SnapshotImageError::Projection(
1030 "Circle bootstrap blob closure does not exactly cover its image rows".to_string(),
1031 ));
1032 }
1033 for row in &rows {
1034 let mut matching = reference.blobs.iter().filter(|binding| {
1035 row.table == binding.table()
1036 && row.row_id == binding.row_id()
1037 && row.row_stamp == binding.row_stamp()
1038 && row.column == binding.column()
1039 });
1040 let binding = matching.next().ok_or_else(|| {
1041 SnapshotImageError::Projection(
1042 "Circle bootstrap image row has no exact signed blob binding".to_string(),
1043 )
1044 })?;
1045 if matching.next().is_some()
1046 || &row.blob != binding.blob()
1047 || row.plaintext_size != binding.plaintext_size()
1048 || row.plaintext_hash != binding.plaintext_hash().to_string()
1049 || !matches!(
1050 binding.authority(),
1051 coven_protocol::blob::RowBlobAuthority::Remote(
1052 coven_protocol::audience_package::PackageAudience::Circle {
1053 circle_id: binding_circle,
1054 ..
1055 }
1056 ) if *binding_circle == circle_id
1057 )
1058 || binding.stored().is_none()
1059 {
1060 return Err(SnapshotImageError::Projection(
1061 "Circle bootstrap blob closure differs from an exact image row".to_string(),
1062 ));
1063 }
1064 }
1065 for table in crate::user_table_names(connection).map_err(SnapshotImageError::from)? {
1066 if tables.iter().any(|synced| synced.name() == table)
1067 || matches!(table.as_str(), "_coven_audience" | "_coven_row_routes")
1068 {
1069 continue;
1070 }
1071 let count: i64 = connection
1072 .query_row(
1073 &format!("SELECT COUNT(*) FROM {}", crate::quote_ident(&table)),
1074 [],
1075 |row| row.get(0),
1076 )
1077 .map_err(SnapshotImageError::from)?;
1078 if count != 0 {
1079 return Err(SnapshotImageError::Projection(format!(
1080 "Circle bootstrap retains non-projection table {table:?}"
1081 )));
1082 }
1083 }
1084 Ok(())
1085}
1086
1087#[cfg(test)]
1088#[path = "snapshot_image_tests.rs"]
1089mod tests;