diff --git a/crates/tracedecay-graph-db/src/generation.rs b/crates/tracedecay-graph-db/src/generation.rs index 7a78013cdc9..ba64685fd60 100644 --- a/crates/tracedecay-graph-db/src/generation.rs +++ b/crates/tracedecay-graph-db/src/generation.rs @@ -59,7 +59,10 @@ pub use replay::{ pub(crate) use replay::{ checked_decode_replay_source, metadata_manifest_from_source, validate_supplied_rows_binding, }; -pub(crate) use spill::{ENTITIES_FILE, read_spilled_entity}; +pub(crate) use spill::{ + AbandonedRowSpill, ENTITIES_FILE, MAX_ROW_SPILL_OWNER_RECEIPT_BYTES, + ROW_SPILL_OWNER_RECEIPT_FILE, ROW_SPILL_PREFIX, read_spilled_entity, row_spill_owner, +}; pub use spill::{ GRAPH_ROW_SPILL_RUN_BYTES, GraphGenerationRowSpill, GraphGenerationRows, GraphSpillRowFootprint, SpilledGraphGeneration, diff --git a/crates/tracedecay-graph-db/src/generation/spill.rs b/crates/tracedecay-graph-db/src/generation/spill.rs index 92ff10db944..15f501d30c7 100644 --- a/crates/tracedecay-graph-db/src/generation/spill.rs +++ b/crates/tracedecay-graph-db/src/generation/spill.rs @@ -17,6 +17,7 @@ use std::cmp::{Ordering as CmpOrdering, Reverse}; use std::collections::BinaryHeap; +use std::ffi::OsStr; use std::fs::File; use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; @@ -33,6 +34,8 @@ use tracedecay_store::runtime::{ StoreShardIdV1, }; +use tracedecay_domain::canonical_text::{canonical_framed_sha256, is_lowercase_hex}; + use crate::limits::{MAX_VERIFIED_GENERATION_ENTITIES, MAX_VERIFIED_GENERATION_RELATIONS}; use crate::row_index::{ ENTITY_ROW_OFFSETS_FILE, EntityRowOffsetsWriter, ROW_INDEX_FILE, RowIndexBuilder, @@ -59,16 +62,193 @@ const RELATIONS_FILE: &str = "relations.rows"; /// A producer-owned file that travels with the sealed generation, see /// [`GraphGenerationRowSpill::attachment_path`]. const ATTACHMENT_FILE: &str = "attachment"; +/// An abandoned row-spill name is not authority to erase an occupied directory. +/// Persist this marker before any rows; a crash before the marker leaves an +/// unverified directory for inspection rather than authorizing deletion. +pub(crate) const ROW_SPILL_OWNER_RECEIPT_FILE: &str = ".row-spill-owner-v1"; +pub(crate) const MAX_ROW_SPILL_OWNER_RECEIPT_BYTES: usize = 512; + +pub(crate) const ROW_SPILL_PREFIX: &str = ".rows-"; +const RECEIPT_PREFIX: &str = "tracedecay.graph-row-spill-v1:"; + +fn row_spill_owner_receipt(name: &OsStr, owner: u128) -> Option> { + let name = name.to_str()?; + let receipt = format!("{RECEIPT_PREFIX}{owner}:{name}").into_bytes(); + (receipt.len() <= MAX_ROW_SPILL_OWNER_RECEIPT_BYTES).then_some(receipt) +} + +/// The PID changes at fork. The process nonce changes on restart, even when +/// the kernel reuses that PID and the allocation counter starts over. +pub(crate) fn row_spill_owner() -> u128 { + (u128::from(std::process::id()) << 64) + | u128::from(tracedecay_private_fs::process_instance_nonce()) +} + +fn staging_name(owner: u128, receipt: &[u8]) -> String { + let digest = canonical_framed_sha256( + b"tracedecay.graph-row-spill.pending.v1", + &[&owner.to_be_bytes(), receipt], + ); + format!("{ROW_SPILL_PREFIX}{owner}-pending-{digest}") +} + +/// A foreign publisher may have stopped immediately before or after rename. +/// Reclaim either name only when the receipt on the held child proves it. +pub(crate) enum AbandonedRowSpill<'a> { + Published(Vec), + Pending { owner: u128, name: &'a str }, +} + +impl<'a> AbandonedRowSpill<'a> { + pub(crate) fn from_name(name: &'a OsStr, current: u128) -> Option { + let text = name.to_str()?; + let (instance, suffix) = text.strip_prefix(ROW_SPILL_PREFIX)?.split_once('-')?; + let owner = instance.parse::().ok()?; + if current == owner || owner.to_string() != instance { + return None; + } + if let Some(digest) = suffix.strip_prefix("pending-") { + return is_lowercase_hex(digest, 64).then_some(Self::Pending { owner, name: text }); + } + let counter = suffix.parse::().ok()?; + if counter.to_string() != suffix { + return None; + } + row_spill_owner_receipt(name, owner).map(Self::Published) + } + + pub(crate) fn owns(&self, receipt: &[u8]) -> bool { + match self { + Self::Published(expected) => receipt == expected, + Self::Pending { owner, name } => { + let Some(bound_name) = receipt + .strip_prefix(RECEIPT_PREFIX.as_bytes()) + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + else { + return false; + }; + let Some((declared_owner, bound_name)) = bound_name.split_once(':') else { + return false; + }; + // A pending receipt must bind a real published spill name. + // Hashing arbitrary receipt text is not proof that the + // directory was ever produced by a graph row-spill writer. + let Some((published_owner, counter)) = bound_name + .strip_prefix(ROW_SPILL_PREFIX) + .and_then(|suffix| suffix.split_once('-')) + else { + return false; + }; + declared_owner == owner.to_string() + && published_owner == declared_owner + && counter + .parse::() + .is_ok_and(|parsed| parsed.to_string() == counter) + && staging_name(*owner, receipt) == *name + } + } + } +} /// Scratch directory owned by one spill; removed with its owner. struct SpillDirectory(PathBuf); impl SpillDirectory { fn create(path: PathBuf) -> Result { - std::fs::create_dir(&path).map_err(|error| spill_io("directory create", error))?; + let owner = row_spill_owner(); + let mut staging = Self::prepare(&path, owner)?; + Self::install(&mut staging, &path, owner)?; Ok(Self(path)) } + fn install( + staging: &mut tempfile::TempDir, + path: &Path, + owner: u128, + ) -> Result<(), GraphDbError> { + // TempDir's default Drop recursively deletes by pathname. A failed + // no-replace publication must not delete a foreign directory moved + // into that name while we attempted installation. Disable it before + // the rename, including the successful-rename/reused-name window. + staging.disable_cleanup(true); + let name = path + .file_name() + .ok_or_else(|| GraphDbError::invalid("graph row spill directory has no entry name"))?; + let receipt = row_spill_owner_receipt(name, owner) + .ok_or_else(|| GraphDbError::invalid("graph row spill directory name is invalid"))?; + let pending = staging_name(owner, &receipt); + if staging.path().file_name() != Some(OsStr::new(&pending)) { + return Err(GraphDbError::invalid( + "graph row spill staging name does not bind its owner receipt", + )); + } + let installed = tracedecay_private_fs::framed_log::rename_noreplace(staging.path(), path); + if let Err(error) = installed { + let root = staging + .path() + .parent() + .ok_or_else(|| GraphDbError::invalid("graph row spill directory has no parent"))?; + // On failure, only the exact owner receipt read from the opened + // child authorizes recursive removal. A substituted directory, + // even an ordinary real directory, must survive unchanged. + if let Err(cleanup_error) = + tracedecay_private_fs::capability_dir::remove_receipted_child_directory_nofollow( + root, + OsStr::new(&pending), + OsStr::new(ROW_SPILL_OWNER_RECEIPT_FILE), + MAX_ROW_SPILL_OWNER_RECEIPT_BYTES, + |bytes| bytes == receipt, + ) + { + tracing::warn!( + event = "graph_row_spill_prepare_cleanup_refused", + path = %staging.path().display(), + error = %cleanup_error, + "unverified pending row spill preserved for inspection" + ); + } + return Err(spill_io("directory install", error)); + } + Ok(()) + } + + fn prepare(path: &Path, owner: u128) -> Result { + let name = path + .file_name() + .ok_or_else(|| GraphDbError::invalid("graph row spill directory has no entry name"))?; + let receipt = row_spill_owner_receipt(name, owner) + .ok_or_else(|| GraphDbError::invalid("graph row spill directory name is invalid"))?; + let parent = path + .parent() + .ok_or_else(|| GraphDbError::invalid("graph row spill directory has no parent"))?; + // Receipt failures must leave the requested name available to retry. + // Keep the partial marker private until the complete spill can be installed. + let temporary = staging_name(owner, &receipt); + let mut staging = tempfile::Builder::new() + .prefix(&temporary) + .rand_bytes(0) + .tempdir_in(parent) + .map_err(|error| spill_io("directory create", error))?; + Self::persist_pending_receipt(&mut staging, &receipt)?; + Ok(staging) + } + + fn persist_pending_receipt( + staging: &mut tempfile::TempDir, + receipt: &[u8], + ) -> Result<(), GraphDbError> { + // TempDir's Drop is pathname-based even when receipt creation fails. + // Once our name has been replaced, that Drop could erase foreign + // bytes. Disarm it before the first fallible marker operation. + staging.disable_cleanup(true); + tracedecay_private_fs::capability_dir::write_exclusive_pending_receipt_nofollow( + staging.path(), + OsStr::new(ROW_SPILL_OWNER_RECEIPT_FILE), + receipt, + ) + .map_err(|error| spill_io("owner marker persist", error)) + } + fn path(&self) -> &Path { &self.0 } @@ -76,14 +256,27 @@ impl SpillDirectory { impl Drop for SpillDirectory { fn drop(&mut self) { - match std::fs::remove_dir_all(&self.0) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + let Some((root, name)) = self.0.parent().zip(self.0.file_name()) else { + return; + }; + let Some(receipt) = row_spill_owner_receipt(name, row_spill_owner()) else { + return; + }; + // The pathname may have been replaced since this spill was created. + // Read the proof and remove relative to the same held child capability. + match tracedecay_private_fs::capability_dir::remove_receipted_child_directory_nofollow( + root, + name, + OsStr::new(ROW_SPILL_OWNER_RECEIPT_FILE), + MAX_ROW_SPILL_OWNER_RECEIPT_BYTES, + |bytes| bytes == receipt, + ) { + Ok(_) => {} Err(error) => tracing::warn!( event = "graph_row_spill_remove_failed", path = %self.0.display(), error = %error, - "graph row spill directory could not be removed; the next store open sweeps it" + "unverified graph row spill preserved for inspection" ), } } @@ -1037,3 +1230,338 @@ impl GraphGenerationManifestIdentity { .map_err(|error| GraphDbError::invalid(error.to_string())) } } + +#[cfg(test)] +mod owner_receipt_tests { + use super::{ + RECEIPT_PREFIX, ROW_SPILL_OWNER_RECEIPT_FILE, ROW_SPILL_PREFIX, SpillDirectory, + row_spill_owner, row_spill_owner_receipt, staging_name, + }; + + #[cfg(unix)] + #[test] + fn failed_pending_publication_preserves_a_substituted_real_directory() { + let root = tempfile::tempdir().unwrap(); + let owner = row_spill_owner(); + let destination = root.path().join(format!("{ROW_SPILL_PREFIX}{owner}-0")); + let mut staged = SpillDirectory::prepare(&destination, owner).unwrap(); + let pending_path = staged.path().to_path_buf(); + let moved = root.path().join("original-pending-spill"); + std::fs::rename(&pending_path, &moved).unwrap(); + std::fs::create_dir(&pending_path).unwrap(); + std::fs::write(pending_path.join("sentinel"), b"foreign pending contents").unwrap(); + std::fs::create_dir(&destination).unwrap(); + std::fs::write(destination.join("sentinel"), b"occupied destination").unwrap(); + + let refused = SpillDirectory::install(&mut staged, &destination, owner); + assert!( + refused.is_err(), + "occupied publication destination must be refused" + ); + drop(staged); + assert_eq!( + std::fs::read(pending_path.join("sentinel")).unwrap(), + b"foreign pending contents", + "failed install must not recursively delete a substituted real directory" + ); + assert_eq!( + std::fs::read(destination.join("sentinel")).unwrap(), + b"occupied destination" + ); + let original_receipt = std::fs::read(moved.join(ROW_SPILL_OWNER_RECEIPT_FILE)).unwrap(); + assert!( + original_receipt.starts_with(RECEIPT_PREFIX.as_bytes()), + "original owned staging bytes must remain separate from the foreign entry" + ); + } + + #[cfg(unix)] + #[test] + fn failed_pending_receipt_open_preserves_substituted_directory() { + let root = tempfile::tempdir().unwrap(); + let owner = row_spill_owner(); + let destination = root.path().join(format!("{ROW_SPILL_PREFIX}{owner}-9")); + let receipt = row_spill_owner_receipt(destination.file_name().unwrap(), owner).unwrap(); + let name = staging_name(owner, &receipt); + let mut staging = tempfile::Builder::new() + .prefix(&name) + .rand_bytes(0) + .tempdir_in(root.path()) + .unwrap(); + let pending_path = staging.path().to_path_buf(); + let moved = root.path().join("original-pending"); + std::fs::rename(&pending_path, &moved).unwrap(); + std::fs::create_dir(&pending_path).unwrap(); + std::fs::write( + pending_path.join(ROW_SPILL_OWNER_RECEIPT_FILE), + b"foreign receipt", + ) + .unwrap(); + std::fs::write(pending_path.join("sentinel"), b"foreign contents").unwrap(); + + let refused = SpillDirectory::persist_pending_receipt(&mut staging, &receipt); + assert!( + refused + .unwrap_err() + .to_string() + .contains("owner marker persist") + ); + drop(staging); + assert_eq!( + std::fs::read(pending_path.join("sentinel")).unwrap(), + b"foreign contents", + "failed receipt creation must not drop a substituted real directory" + ); + assert_eq!( + std::fs::read(pending_path.join(ROW_SPILL_OWNER_RECEIPT_FILE)).unwrap(), + b"foreign receipt" + ); + assert!( + moved.is_dir(), + "the original staging directory cannot authorize deleting its replacement" + ); + } + + #[cfg(unix)] + #[test] + fn receipt_write_failure_leaves_no_spill_directory() { + const CHILD_ROOT: &str = "TRACEDECAY_TEST_LIMITED_SPILL_ROOT"; + const WRITE_REFUSED: i32 = 42; + if let Some(root) = std::env::var_os(CHILD_ROOT) { + let destination = std::path::PathBuf::from(root).join(".rows-1-0"); + let error = SpillDirectory::create(destination) + .err() + .expect("zero file-size limit"); + assert!( + error.to_string().contains("owner marker persist"), + "{error}" + ); + std::process::exit(WRITE_REFUSED); + } + + // Limit only the child process. Its directory creation succeeds and + // the real filesystem refuses the first receipt write with EFBIG. + let root = tempfile::tempdir().unwrap(); + let child = std::process::Command::new("/bin/sh") + .args(["-c", r#"trap "" XFSZ && ulimit -f 0 && exec "$@""#, "sh"]) + .arg(std::env::current_exe().unwrap()) + .arg("--exact") + .arg(std::thread::current().name().unwrap()) + .env(CHILD_ROOT, root.path()) + .output() + .unwrap(); + assert_eq!(child.status.code(), Some(WRITE_REFUSED), "{child:?}"); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0); + } + + #[test] + fn store_open_reclaims_receipted_staging_after_producer_exits_before_rename() { + const CHILD_ROOT: &str = "TRACEDECAY_TEST_PENDING_SPILL_ROOT"; + const PREPARED: i32 = 42; + if let Some(root) = std::env::var_os(CHILD_ROOT) { + let name = format!(".rows-{}-1", row_spill_owner()); + let _staged = SpillDirectory::prepare( + &std::path::PathBuf::from(root).join(name), + row_spill_owner(), + ) + .unwrap(); + std::process::exit(PREPARED); + } + + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.grafeo"); + let root = temp.path().join("graph.sealed"); + std::fs::create_dir(&root).unwrap(); + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg(std::thread::current().name().unwrap()) + .env(CHILD_ROOT, &root) + .output() + .unwrap(); + assert_eq!(child.status.code(), Some(PREPARED), "{child:?}"); + let interrupted = std::fs::read_dir(&root) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let (owner, _) = interrupted + .file_name() + .unwrap() + .to_str() + .unwrap() + .split_once("-pending-") + .unwrap(); + let mismatched = root.join(format!("{owner}-pending-{}", "0".repeat(64))); + std::fs::create_dir(&mismatched).unwrap(); + let receipt = std::fs::read(interrupted.join(ROW_SPILL_OWNER_RECEIPT_FILE)).unwrap(); + std::fs::write(mismatched.join(ROW_SPILL_OWNER_RECEIPT_FILE), &receipt).unwrap(); + + let active = + SpillDirectory::prepare(&root.join(".rows-active"), row_spill_owner()).unwrap(); + crate::sealed_store::sweep_abandoned_row_spills(&database); + assert!(!interrupted.exists()); + assert!(active.path().is_dir()); + assert_eq!( + std::fs::read(mismatched.join(ROW_SPILL_OWNER_RECEIPT_FILE)).unwrap(), + receipt + ); + } + + #[test] + fn store_open_preserves_pending_spills_with_noncanonical_published_receipts() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.grafeo"); + let root = temp.path().join("graph.sealed"); + std::fs::create_dir(&root).unwrap(); + let previous_owner = row_spill_owner() ^ 1; + let canonical = root.join(format!("{ROW_SPILL_PREFIX}{previous_owner}-7")); + let owned = SpillDirectory::prepare(&canonical, previous_owner) + .unwrap() + .keep(); + + let forged_bound_names = [ + "unrelated".to_owned(), + format!("{ROW_SPILL_PREFIX}{previous_owner}-00"), + format!("{ROW_SPILL_PREFIX}{previous_owner}-not-a-counter"), + format!("{ROW_SPILL_PREFIX}{previous_owner}-18446744073709551616"), + format!("{ROW_SPILL_PREFIX}{}-7", previous_owner ^ 2), + ]; + let mut foreign = Vec::new(); + for (index, bound_name) in forged_bound_names.iter().enumerate() { + // Even a digest-consistent staging name is not ownership proof + // when its claimed published row-spill name is not canonical. + let receipt = format!("{RECEIPT_PREFIX}{previous_owner}:{bound_name}").into_bytes(); + let directory = root.join(staging_name(previous_owner, &receipt)); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join(ROW_SPILL_OWNER_RECEIPT_FILE), &receipt).unwrap(); + let sentinel = format!("foreign-{index}").into_bytes(); + std::fs::write(directory.join("sentinel"), &sentinel).unwrap(); + foreign.push((directory, receipt, sentinel)); + } + let unrelated = root.join("live-generation"); + std::fs::create_dir(&unrelated).unwrap(); + std::fs::write(unrelated.join("sentinel"), b"live").unwrap(); + + crate::sealed_store::sweep_abandoned_row_spills(&database); + + assert!( + !owned.exists(), + "a genuine interrupted pending spill must still be reclaimed" + ); + for (directory, receipt, sentinel) in foreign { + assert_eq!( + std::fs::read(directory.join(ROW_SPILL_OWNER_RECEIPT_FILE)).unwrap(), + receipt, + "a forged receipt cannot authorize pending-spill cleanup" + ); + assert_eq!(std::fs::read(directory.join("sentinel")).unwrap(), sentinel); + } + assert_eq!(std::fs::read(unrelated.join("sentinel")).unwrap(), b"live"); + } + + #[test] + fn a_reused_pid_reclaims_the_prior_incarnation_and_preserves_live_spills() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.grafeo"); + let root = temp.path().join("graph.sealed"); + std::fs::create_dir(&root).unwrap(); + let current = row_spill_owner(); + let previous = current ^ 1; + assert_eq!(current >> 64, previous >> 64, "same numeric process ID"); + let final_path = root.join(format!(".rows-{current}-0")); + let previous_path = root.join(format!(".rows-{previous}-0")); + let stale = SpillDirectory::prepare(&previous_path, previous) + .unwrap() + .keep(); + let mut live = SpillDirectory::prepare(&final_path, current).unwrap(); + let peer_path = root.join(format!(".rows-{current}-1")); + let _peer = SpillDirectory::create(peer_path.clone()).unwrap(); + + crate::sealed_store::sweep_abandoned_row_spills(&database); + assert!(!stale.exists()); + assert!(live.path().is_dir()); + assert!(peer_path.is_dir()); + // A prepared pending spill deliberately survives a drop; the live + // producer completes it by installing under its canonical name. + SpillDirectory::install(&mut live, &final_path, current).unwrap(); + let published = SpillDirectory(final_path.clone()); + assert!(final_path.is_dir()); + drop(published); + assert!(!final_path.exists(), "the owner releases its own spill"); + let _reused = SpillDirectory::create(final_path.clone()).unwrap(); + assert!(final_path.is_dir()); + } + + #[test] + fn occupied_spill_publication_preserves_destination_and_discards_staging() { + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join(".rows-1-0"); + std::fs::create_dir(&destination).unwrap(); + assert!(SpillDirectory::create(destination.clone()).is_err()); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1); + assert_eq!(std::fs::read_dir(&destination).unwrap().count(), 0); + + std::fs::remove_dir(&destination).unwrap(); + let spill = SpillDirectory::create(destination.clone()).unwrap(); + assert!(destination.join(ROW_SPILL_OWNER_RECEIPT_FILE).is_file()); + drop(spill); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0); + } + + #[test] + fn row_spill_recovery_reclaims_only_proven_owners_and_drop_is_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.grafeo"); + let root = temp.path().join("graph.sealed"); + std::fs::create_dir(&root).unwrap(); + let other_owner = row_spill_owner() ^ 1; + let owned = root.join(format!(".rows-{other_owner}-1")); + let mut completed = SpillDirectory::prepare(&owned, other_owner).unwrap(); + tracedecay_private_fs::framed_log::rename_noreplace(completed.path(), &owned).unwrap(); + completed.disable_cleanup(true); + std::fs::write(owned.join("entities-0.run"), b"owned").unwrap(); + + let unreceipted = root.join(format!(".rows-{other_owner}-2")); + std::fs::create_dir(&unreceipted).unwrap(); + std::fs::write(unreceipted.join("foreign"), b"unowned").unwrap(); + let forged = root.join(format!(".rows-{other_owner}-3")); + let interrupted = SpillDirectory::create(forged.clone()).unwrap(); + std::fs::write(forged.join(ROW_SPILL_OWNER_RECEIPT_FILE), b"wrong owner").unwrap(); + std::fs::write(forged.join("foreign"), b"preserve").unwrap(); + std::mem::forget(interrupted); + + crate::sealed_store::sweep_abandoned_row_spills(&database); + assert!( + !owned.exists(), + "the completed, marker-bound spill is reclaimed" + ); + assert_eq!( + std::fs::read(unreceipted.join("foreign")).unwrap(), + b"unowned" + ); + assert_eq!(std::fs::read(forged.join("foreign")).unwrap(), b"preserve"); + + let active = root.join(format!(".rows-{}-4", row_spill_owner())); + let spill = SpillDirectory::create(active.clone()).unwrap(); + drop(spill); + assert!( + !active.exists(), + "a current, proven spill cleans up on drop" + ); + + let substituted = root.join(format!(".rows-{}-5", row_spill_owner())); + let spill = SpillDirectory::create(substituted.clone()).unwrap(); + std::fs::write(substituted.join("foreign"), b"still here").unwrap(); + std::fs::write( + substituted.join(ROW_SPILL_OWNER_RECEIPT_FILE), + b"substituted", + ) + .unwrap(); + drop(spill); + assert_eq!( + std::fs::read(substituted.join("foreign")).unwrap(), + b"still here" + ); + } +} diff --git a/crates/tracedecay-graph-db/src/generation_runtime.rs b/crates/tracedecay-graph-db/src/generation_runtime.rs index d2fdd35dd58..a514bbc76de 100644 --- a/crates/tracedecay-graph-db/src/generation_runtime.rs +++ b/crates/tracedecay-graph-db/src/generation_runtime.rs @@ -1840,6 +1840,11 @@ impl GraphDb { self.retire_sealed_generation_store(locator)?; return Ok(GenerationContentsDeletion::RetentionPending); } + // Native row deletion may already have committed, but the generation + // is not collected until its independently durable sealed artifact is + // gone. If unlink refuses, keep the in-memory retirement state retryable + // instead of advertising collection while bytes still exist on disk. + self.retire_sealed_generation_store(locator)?; let mut state = self.wait_verified_generations_write()?; state.known.remove(locator); state.quarantined.remove(locator); @@ -1858,7 +1863,6 @@ impl GraphDb { state.heads.remove(&locator.projection); } drop(state); - self.retire_sealed_generation_store(locator)?; Ok(GenerationContentsDeletion::Deleted) } @@ -4001,20 +4005,73 @@ mod tests { .delete_generation_contents(&locator, &|| Ok(())) .expect_err("a failed sealed unlink must not complete retention"); assert!( - error - .to_string() - .contains("sealed directory removal failed") + matches!( + &error, + GraphDbError::Unavailable { message } + if message.starts_with("sealed generation store sealed directory removal failed:") + || message.starts_with("sealed generation store sealed generation retirement refused:") + ), + "an unremovable artifact must fail at the sealed-directory unlink boundary: {error}" ); assert_eq!(std::fs::read(&directory).unwrap(), b"not a directory"); + assert!( + !database + .wait_verified_generations_write() + .unwrap() + .collected + .contains(&locator), + "a failed sealed unlink must not mark its generation collected" + ); std::fs::remove_file(&directory).unwrap(); std::fs::create_dir(&directory).unwrap(); + // Retirement requires an exact durable identity from the directory + // being removed. A receipt-free synthetic directory must never confer + // deletion authority when the strict collector is integrated. + let identity = generation.identity(); + let receipt = serde_json::json!({ + "version": 1, + "form": "compact", + "namespace": locator.projection.namespace.as_str(), + "projection": locator.projection.projection.as_str(), + "generation": locator.generation.as_str(), + "physical_namespace": namespace.as_str(), + "recovered_digest": sealed_digest(&generation).as_str(), + "entities": 0, + "relations": 0, + "graph_format": GraphFormatVersion::current().get(), + "row_sum": null, + "base": null, + "source_generation": identity.source_generation.as_str(), + "watermark": identity.watermark.as_str() + }); + std::fs::write( + directory.join("sealed.json"), + serde_json::to_vec(&receipt).unwrap(), + ) + .unwrap(); std::fs::write(directory.join("derived-rows"), b"retired").unwrap(); + let unrelated = directory.parent().unwrap().join("unrelated-entry"); + std::fs::create_dir(&unrelated).unwrap(); + std::fs::write(unrelated.join("sentinel"), b"untouched").unwrap(); assert!(matches!( database.delete_generation_contents(&locator, &|| Ok(())), Ok(GenerationContentsDeletion::Deleted) )); + assert_eq!( + std::fs::read(unrelated.join("sentinel")).unwrap(), + b"untouched", + "retiring one sealed generation must not traverse another child" + ); assert!(!directory.exists()); + assert!( + database + .wait_verified_generations_write() + .unwrap() + .collected + .contains(&locator), + "only successful physical retirement may mark the generation collected" + ); // Idempotent replay after the directory is gone still succeeds. assert!(matches!( database.delete_generation_contents(&locator, &|| Ok(())), diff --git a/crates/tracedecay-graph-db/src/registry.rs b/crates/tracedecay-graph-db/src/registry.rs index 2cb9827ec44..808696092f3 100644 --- a/crates/tracedecay-graph-db/src/registry.rs +++ b/crates/tracedecay-graph-db/src/registry.rs @@ -2806,7 +2806,9 @@ mod tests { registry .retain_verification_fault_for_lease(&lease, &fault) .unwrap(); - let path = corrupt_registration.canonical_path().to_path_buf(); + // TempDir may live under a symlinked temporary root. The registry + // reports the physical canonical path selected at admission. + let path = std::fs::canonicalize(corrupt_registration.canonical_path()).unwrap(); let bytes = std::fs::read(&path).unwrap(); for _ in 0..2 { diff --git a/crates/tracedecay-graph-db/src/runtime.rs b/crates/tracedecay-graph-db/src/runtime.rs index 7c40e5b3573..43ff17f5f56 100644 --- a/crates/tracedecay-graph-db/src/runtime.rs +++ b/crates/tracedecay-graph-db/src/runtime.rs @@ -1015,8 +1015,8 @@ impl GraphDb { /// management, still checkpoints in full before this returns. #[tracing::instrument(name = "graph_db.runtime.close", level = "trace", skip_all)] pub(crate) fn close(&self) -> Result<(), GraphDbError> { - let engine_is_open = match self.inner.database.read() { - Ok(database) => database.is_some(), + let engine = match self.inner.database.read() { + Ok(database) => database, Err(_) => { self.inner.closed.store(true, Ordering::Release); self.inner.poisoned.store(true, Ordering::Release); @@ -1027,12 +1027,23 @@ impl GraphDb { }); } }; - if !engine_is_open { - if self.inner.closed.swap(true, Ordering::AcqRel) { - return Err(GraphDbError::Closed); + if engine.is_none() { + // Keep the absence observation locked until close is published. + // A lazy opener must not install an engine in that interval. + let previously_closed = self.inner.closed.swap(true, Ordering::AcqRel); + // A failed physical close can already have consumed the engine. + // Its absent handle must not turn an uncertain result into a + // successful close (or an idempotent Closed on the next retry). + if self.inner.poisoned.load(Ordering::Acquire) { + return Err(durability_uncertain()); } - return Ok(()); + return if previously_closed { + Err(GraphDbError::Closed) + } else { + Ok(()) + }; } + drop(engine); let _snapshot_gate = self.wait_snapshot_gate_write(); let mut guard = match crate::observe::wait_lock(crate::observe::LOCK_WAIT_DATABASE_WRITE, || { @@ -1713,11 +1724,16 @@ impl GraphDb { return Ok(()); } self.ensure_available()?; + #[cfg(test)] + test_seams::fire(test_seams::Seam::LazyOpenAdmission); let mut database = crate::observe::wait_lock(crate::observe::LOCK_WAIT_DATABASE_WRITE, || { self.inner.database.write() }) .map_err(|_| GraphDbError::unavailable("graph database write lock is poisoned"))?; + // Close or a failed hibernation may have won while this opener + // waited. Revalidate before publishing another native engine. + self.ensure_available()?; if database.is_some() { return Ok(()); } @@ -1965,6 +1981,8 @@ pub(crate) mod test_seams { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum Seam { + /// After lazy-open admission, before acquiring the database write lock. + LazyOpenAdmission, /// Immediately before grafeo opens the container at `site`. EngineOpen(GraphEngineOpenSite), /// After the engine has closed and synced the container, immediately diff --git a/crates/tracedecay-graph-db/src/runtime/tests.rs b/crates/tracedecay-graph-db/src/runtime/tests.rs index e682aad20fa..879e362892b 100644 --- a/crates/tracedecay-graph-db/src/runtime/tests.rs +++ b/crates/tracedecay-graph-db/src/runtime/tests.rs @@ -6,6 +6,7 @@ use std::time::Duration; use grafeo_common::types::Value; +use super::test_seams::{self, Seam}; use super::{GraphDb, sync_wal}; use crate::location::PersistentGraphStoreState; use crate::recovery::set_projection_quarantine; @@ -29,6 +30,78 @@ fn memory_db() -> GraphDbLeaseV1 { .unwrap() } +#[test] +fn lazy_open_rechecks_close_before_materializing_an_engine() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("graph.grafeo"); + let options = || GraphDbOpenOptions { + location: GraphDbLocation::Persistent(path.clone()), + expected_format: GraphFormatVersion::current(), + durability: GraphDurability::WalSync, + cancellation: Arc::new(NeverCancelled), + }; + let seed = GraphDb::open(options()).unwrap(); + seed.apply_unverified(scalar_batch("retained")).unwrap(); + seed.close().unwrap(); + drop(seed); + + // Both a never-materialized mount and a hibernated engine can be absent + // when close races a lazy reader's admission. + for hibernated in [false, true] { + let database = + GraphDb::open_lazy_with_store_state(options(), PersistentGraphStoreState::Existing) + .unwrap(); + if hibernated { + database.ensure_opened().unwrap(); + database.hibernate_if_lazy().unwrap(); + } + assert!(database.inner.database.read().unwrap().is_none()); + + let closing = Arc::clone(&database); + let admissions = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&admissions); + let hook = test_seams::install(move |seam| { + if seam == Seam::LazyOpenAdmission { + observed.fetch_add(1, Ordering::Relaxed); + // The opener has already observed Ready. Complete the real + // close before it takes its database write lock. + closing.close().unwrap(); + } + }); + let result = database.ensure_opened(); + drop(hook); + + assert_eq!(admissions.load(Ordering::Relaxed), 1); + assert_eq!(result, Err(GraphDbError::Closed)); + assert_eq!(database.runtime_state(), GraphDbRuntimeState::Closed); + assert!( + database.inner.database.read().unwrap().is_none(), + "a completed close must not be followed by a resurrected engine" + ); + } + + // Refusing the stale opener must not corrupt the durable store or block + // a new, independently authorized mount from reading the committed row. + let reopened = GraphDb::open(options()).unwrap(); + let snapshot = reopened.snapshot().unwrap(); + let entity = snapshot + .entity( + &GraphNamespace::new("project").unwrap(), + &GraphEntityId::new("a").unwrap(), + Arc::new(NeverCancelled), + ) + .unwrap() + .unwrap(); + assert_eq!( + entity + .properties + .get(&GraphPropertyName::new("name").unwrap()), + Some(&GraphProperty::String("retained".to_owned())) + ); + drop(snapshot); + reopened.close().unwrap(); +} + #[test] fn runtime_state_distinguishes_ready_closed_and_durability_uncertain() { let ready = memory_db(); @@ -63,10 +136,17 @@ fn owner_close_releases_the_physical_database_after_durability_uncertainty() { .to_owned(), }) ); + assert!( + !owner.engine_is_open().unwrap(), + "the poisoned close must release the physical database" + ); assert_eq!( owner.close(), - Err(GraphDbError::Closed), - "the poisoned close must release the physical database before a second close" + Err(GraphDbError::DurabilityUncertain { + message: "the handle was poisoned after an observed post-commit persistence failure" + .to_owned(), + }), + "a retry after a poisoned close must stay uncertain, never idempotent Closed" ); } diff --git a/crates/tracedecay-graph-db/src/sealed_store.rs b/crates/tracedecay-graph-db/src/sealed_store.rs index e6441489824..683a3e14f59 100644 --- a/crates/tracedecay-graph-db/src/sealed_store.rs +++ b/crates/tracedecay-graph-db/src/sealed_store.rs @@ -44,6 +44,7 @@ //! digest before the store serves a read. use std::collections::{BTreeMap, HashMap}; +use std::io::Write; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -54,11 +55,13 @@ use grafeo_core::graph::compact::IncrementalCompactStoreBuilder; use grafeo_engine::GrafeoDB; use rayon::prelude::*; use serde::{Deserialize, Serialize}; +use tracedecay_private_fs::capability_dir::install_child_directory_noreplace_nofollow; use tracedecay_store::runtime::GraphRecoveredGenerationDigestV1; use crate::generation::{ - GraphRowDigestSum, physical_namespace_projection_map, recovered_digest_from_row_sum, - verify_sealed_copy_generation, + AbandonedRowSpill, GraphRowDigestSum, MAX_ROW_SPILL_OWNER_RECEIPT_BYTES, + ROW_SPILL_OWNER_RECEIPT_FILE, ROW_SPILL_PREFIX, physical_namespace_projection_map, + recovered_digest_from_row_sum, row_spill_owner, verify_sealed_copy_generation, }; use crate::lease::GenerationLocator; use crate::location::PersistentGraphStoreState; @@ -112,7 +115,7 @@ pub(crate) fn open_direct_sealed_generation( sealed_generation_directory(&sealed_store_root(database_path), &physical_namespace); let receipt_path = directory.join(SEALED_STORE_RECEIPT_FILE); let sealed_path = directory.join(SEALED_STORE_DATABASE_FILE); - let receipt_bytes = match std::fs::read(&receipt_path) { + let receipt_bytes = match read_sealed_receipt_bytes(&receipt_path) { Ok(bytes) => bytes, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(sealed_store_io_failure("receipt read failed", error)), @@ -207,8 +210,28 @@ pub(crate) fn open_direct_sealed_generation( const SEALED_COPY_GUARD_CHUNK_ROWS: usize = 4096; const SEALED_STORE_RECEIPT_VERSION: u32 = 1; +/// A proof marker contains one canonical recovered digest, never a payload. +const MAX_SEALED_CHECK_BYTES: usize = 256; +/// Identity metadata, never an unbounded content read. +const MAX_SEALED_RECEIPT_BYTES: usize = 64 * 1024; + +fn read_sealed_receipt_bytes(path: &Path) -> std::io::Result> { + // The shared reader opens without following a final symlink, rejects FIFOs + // and other nonregular files, and checks size against the same held handle. + match tracedecay_private_fs::framed_log::read_bounded(path, MAX_SEALED_RECEIPT_BYTES)? { + Some(bytes) => Ok(bytes), + None => Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "sealed generation receipt is missing", + )), + } +} + pub(crate) const SEALED_STORE_DATABASE_FILE: &str = "generation.grafeo"; const SEALED_STORE_RECEIPT_FILE: &str = "sealed.json"; +/// Small, generation-bound deletion authority persisted before any staged rows. +/// Unlike sealed.json, this is never evidence that a container is complete. +const SEALED_STAGING_OWNER_FILE: &str = ".sealed-staging-owner-v1"; /// Digest recorded only after a successful post-reopen proof. A `sealed.json` /// written before that proof is not release authority. const SEALED_STORE_CHECKED_FILE: &str = "sealed.checked"; @@ -254,6 +277,14 @@ struct SealedStoreReceiptV1 { watermark: Option, } +/// An incomplete sealed container belongs to one producer incarnation. +/// A remount in that process must not reclaim its in-flight row stream. +#[derive(Debug, Deserialize, Serialize)] +struct SealedStagingOwnerV1 { + producer: String, + receipt: SealedStoreReceiptV1, +} + impl SealedStoreReceiptV1 { fn binds( &self, @@ -377,13 +408,27 @@ impl SealedGenerationStore { } pub(crate) fn close(&self) -> Result<(), GraphDbError> { - let mut result = Ok(()); + // Closing the same derived reader twice is harmless. A durability + // failure is not: preserve the first real close failure even when a + // later layered-base reader was already closed. + let mut failure = None; for database in self.engines() { - if let Err(error) = database.close() { - result = Err(error); + match database.close() { + Ok(()) | Err(GraphDbError::Closed) => {} + Err(error) => { + failure.get_or_insert(error); + } + } + // An earlier failed engine.close() may have consumed its handle + // and set Closed on the next attempt. Closed is idempotent only + // when the underlying reader is not poisoned. + if database.inner.poisoned.load(AtomicOrdering::Acquire) { + failure.get_or_insert_with(|| GraphDbError::DurabilityUncertain { + message: "sealed reader physical close remains uncertain".to_owned(), + }); } } - result + failure.map_or(Ok(()), Err) } pub(crate) fn hibernate_if_lazy(&self) -> Result<(), GraphDbError> { @@ -476,8 +521,14 @@ impl SealedGenerationStore { /// unlink. A failed unlink remains a typed error for the durable cleanup /// owner to retry; a sealed-only live head never enters this path. fn discard(&self) -> Result<(), GraphDbError> { - let _ = self.close(); - remove_sealed_directory_checked(&self.directory) + // An unconfirmed physical close cannot authorize unlinking the only + // durable sealed copy. Leave its receipt for a later retirement retry. + self.close()?; + retire_sealed_directory_checked( + &self.directory, + &self.locator, + Some(&self.recovered_digest), + ) } } @@ -521,11 +572,12 @@ pub struct SealedStoreCensusV1 { /// retirement has not run since the last publication. pub superseded_count: u64, pub superseded_bytes: u64, - /// `.staging-*` directories left by seals that never installed. + /// Receipt-verified `.staging-*` directories belonging to earlier producer + /// incarnations; current or unproven staging cannot be called abandoned. pub abandoned_staging_count: u64, pub abandoned_staging_bytes: u64, - /// Entries that are neither a readable sealed receipt nor staging: an - /// unrecognized layout is reported, never silently classed as dead. + /// Unrecognized or unproven entries (including live staging) are reported + /// without walking their contents or claiming that they can be reclaimed. pub unrecognized_count: u64, } @@ -535,85 +587,89 @@ pub struct SealedStoreCensusV1 { /// Metadata only: every entry is one receipt read plus a directory size walk, /// never a container open. An unreadable receipt counts as unrecognized so the /// census stays truthful when a receipt is mid-write or corrupt. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum SealedCensusEntry { + Head, + Superseded, + AbandonedStaging, + Unrecognized, +} + pub fn census_sealed_store( database_path: &Path, head_generations: &std::collections::BTreeSet, ) -> std::io::Result { let root = sealed_store_root(database_path); - let entries = match std::fs::read_dir(&root) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(SealedStoreCensusV1::default()); - } - Err(error) => return Err(error), - }; - let mut census = SealedStoreCensusV1::default(); - let mut counted = std::collections::HashSet::new(); - for entry in entries { - let entry = entry?; - if !entry.file_type()?.is_dir() { - census.unrecognized_count += 1; - continue; - } - let path = entry.path(); - let bytes = directory_bytes(&path, &mut counted)?; - let name = entry.file_name(); - if name - .to_str() - .is_some_and(|name| name.starts_with(".staging-")) - { - census.abandoned_staging_count += 1; - census.abandoned_staging_bytes += bytes; - continue; - } - let receipt = std::fs::read(path.join(SEALED_STORE_RECEIPT_FILE)) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); - let Some(receipt) = receipt else { - census.unrecognized_count += 1; - continue; - }; - if head_generations.contains(&receipt.generation) { - census.head_count += 1; - census.head_bytes += bytes; - } else { - census.superseded_count += 1; - census.superseded_bytes += bytes; - } - } - Ok(census) -} - -/// Bytes under `directory` not already counted in `counted`: a layered -/// generation's base container is a hard link to bytes another directory -/// already holds, so each file is charged once per census. -fn directory_bytes( - directory: &Path, - counted: &mut std::collections::HashSet<(u64, u64)>, -) -> std::io::Result { - let mut total = 0u64; - let mut pending = vec![directory.to_path_buf()]; - while let Some(directory) = pending.pop() { - for entry in std::fs::read_dir(&directory)? { - let entry = entry?; - let metadata = entry.metadata()?; - if metadata.is_dir() { - pending.push(entry.path()); - continue; + let current_producer = row_spill_owner().to_string(); + let totals = tracedecay_private_fs::capability_dir::census_receipted_directory_bytes( + &root, + SealedCensusEntry::Unrecognized, + |name| { + if name.to_str().is_some_and(sealed_staging_name) { + (SEALED_STAGING_OWNER_FILE, MAX_SEALED_RECEIPT_BYTES) + } else { + (SEALED_STORE_RECEIPT_FILE, MAX_SEALED_RECEIPT_BYTES) + } + }, + |name, bytes| { + let Some(bytes) = bytes else { + return SealedCensusEntry::Unrecognized; + }; + if name.to_str().is_some_and(sealed_staging_name) { + return if verified_staging_receipt(name, bytes) + .is_some_and(|owner| owner.producer != current_producer) + { + SealedCensusEntry::AbandonedStaging + } else { + SealedCensusEntry::Unrecognized + }; } - #[cfg(unix)] + let Some(name) = name.to_str() else { + return SealedCensusEntry::Unrecognized; + }; + let Ok(receipt) = serde_json::from_slice::(bytes) else { + return SealedCensusEntry::Unrecognized; + }; + let form_valid = match receipt.form.as_str() { + SEALED_STORE_FORM_COMPACT => receipt.base.is_none(), + SEALED_STORE_FORM_LAYERED => receipt.base.is_some(), + _ => false, + }; + if !form_valid + || receipt.version != SEALED_STORE_RECEIPT_VERSION + || receipt.graph_format == 0 + || receipt.graph_format > GraphFormatVersion::current().get() + || receipt.recovered_digest.is_empty() + || !sealed_receipt_matches_generation_entry(&receipt, name) { - use std::os::unix::fs::MetadataExt; - if metadata.nlink() > 1 && !counted.insert((metadata.dev(), metadata.ino())) { - continue; - } + return SealedCensusEntry::Unrecognized; + } + if head_generations.contains(&receipt.generation) { + SealedCensusEntry::Head + } else { + SealedCensusEntry::Superseded + } + }, + )?; + let mut census = SealedStoreCensusV1::default(); + for (kind, (count, bytes)) in totals { + match kind { + SealedCensusEntry::Head => { + census.head_count = count; + census.head_bytes = bytes; + } + SealedCensusEntry::Superseded => { + census.superseded_count = count; + census.superseded_bytes = bytes; + } + SealedCensusEntry::AbandonedStaging => { + census.abandoned_staging_count = count; + census.abandoned_staging_bytes = bytes; } - #[cfg(not(unix))] - let _ = &counted; - total = total.saturating_add(metadata.len()); + SealedCensusEntry::Unrecognized => census.unrecognized_count = count, } } - Ok(total) + Ok(census) } /// Removes every sealed generation beside `database_path` whose receipt does @@ -624,101 +680,207 @@ pub(crate) fn discard_superseded_sealed_generations( database_path: &Path, ) -> Result<(), GraphDbError> { let root = sealed_store_root(database_path); - let entries = match std::fs::read_dir(&root) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(sealed_store_io_failure("sealed root read failed", error)), - }; - for entry in entries { - let entry = - entry.map_err(|error| sealed_store_io_failure("sealed root read failed", error))?; - let path = entry.path(); - if entry - .file_name() - .to_str() - .is_some_and(|name| name.starts_with(".staging-")) - { - continue; - } - let current = load_sealed_store_receipt(&path) - .ok() - .flatten() - .is_some_and(|receipt| receipt.graph_format == GraphFormatVersion::current().get()); - if !current { - match std::fs::remove_dir_all(&path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(sealed_store_io_failure( - "superseded sealed generation removal failed", - error, - )); - } + let reclaimed = tracedecay_private_fs::capability_dir::remove_receipted_directories_nofollow( + &root, + std::ffi::OsStr::new(SEALED_STORE_RECEIPT_FILE), + MAX_SEALED_RECEIPT_BYTES, + |name, bytes| { + let Some(name) = name.to_str() else { + return false; + }; + if name.len() != 64 || !name.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return false; } - } + let Ok(receipt) = serde_json::from_slice::(bytes) else { + return false; + }; + let recognized_form = match receipt.form.as_str() { + SEALED_STORE_FORM_COMPACT => receipt.base.is_none(), + SEALED_STORE_FORM_LAYERED => receipt.base.is_some(), + _ => false, + }; + recognized_form + && receipt.version == SEALED_STORE_RECEIPT_VERSION + && sealed_receipt_matches_generation_entry(&receipt, name) + && !receipt.recovered_digest.is_empty() + && receipt.graph_format > 0 + && receipt.graph_format < GraphFormatVersion::current().get() + }, + ); + match reclaimed { + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(sealed_store_io_failure( + "superseded sealed generation removal failed", + error, + )), + } +} + +/// A physical generation entry is deletable only if the receipt's logical +/// locator derives that exact entry name. A copied receipt with a fabricated +/// physical namespace is not evidence of ownership. +fn sealed_receipt_matches_generation_entry( + receipt: &SealedStoreReceiptV1, + generation_hex: &str, +) -> bool { + let (Ok(namespace), Ok(projection), Ok(generation)) = ( + GraphNamespace::new(receipt.namespace.clone()), + GraphProjectionId::new(receipt.projection.clone()), + crate::GraphGenerationId::new(receipt.generation.clone()), + ) else { + return false; + }; + let locator = GenerationLocator::new( + GraphProjectionIdentity::new(namespace, projection), + generation, + ); + locator.physical_namespace().is_ok_and(|physical| { + physical.as_str() == receipt.physical_namespace + && receipt.physical_namespace == format!("generation:{generation_hex}") + }) +} + +fn sealed_staging_name(name: &str) -> bool { + name.strip_prefix(".staging-").is_some_and(|suffix| { + suffix.len() == 64 + && suffix + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) +} + +fn initial_sealed_staging_owner( + identity: &GraphGenerationManifestIdentity, + expected: &GraphRecoveredGenerationDigestV1, + physical_namespace: &str, +) -> SealedStoreReceiptV1 { + // This receipt authorizes only abandoned-staging cleanup, never serving. + // The completed container receives a separate sealed.json after writing. + SealedStoreReceiptV1 { + version: SEALED_STORE_RECEIPT_VERSION, + form: SEALED_STORE_FORM_COMPACT.to_owned(), + namespace: identity.projection.namespace.as_str().to_owned(), + projection: identity.projection.projection.as_str().to_owned(), + generation: identity.generation.as_str().to_owned(), + physical_namespace: physical_namespace.to_owned(), + recovered_digest: expected.as_str().to_owned(), + entities: 0, + relations: 0, + graph_format: GraphFormatVersion::current().get(), + row_sum: None, + base: None, + source_generation: None, + watermark: None, + } +} + +fn verified_staging_receipt(name: &std::ffi::OsStr, bytes: &[u8]) -> Option { + let name = name.to_str()?; + if !sealed_staging_name(name) { + return None; + } + let generation_hex = name.strip_prefix(".staging-")?; + let owner = serde_json::from_slice::(bytes).ok()?; + let instance = owner.producer.parse::().ok()?; + if instance.to_string() != owner.producer { + return None; + } + let receipt = &owner.receipt; + let recognized_form = match receipt.form.as_str() { + SEALED_STORE_FORM_COMPACT => receipt.base.is_none(), + SEALED_STORE_FORM_LAYERED => receipt.base.is_some(), + _ => false, + }; + (recognized_form + && receipt.version == SEALED_STORE_RECEIPT_VERSION + && receipt.graph_format > 0 + && receipt.graph_format <= GraphFormatVersion::current().get() + && sealed_receipt_matches_generation_entry(receipt, generation_hex) + && !receipt.recovered_digest.is_empty()) + .then_some(owner) +} + +/// A failed builder must never recursively delete a substituted directory +/// merely because its pathname is the deterministic staging name. Only the +/// producer-bound early marker authorizes cleanup, including after sealed.json +/// publication and before installation. +fn discard_failed_sealed_staging( + staging: &Path, + locator: &GenerationLocator, + expected_digest: &str, +) { + let Some((root, name)) = staging.parent().zip(staging.file_name()) else { + return; + }; + let result = tracedecay_private_fs::capability_dir::remove_receipted_child_directory_nofollow( + root, + name, + std::ffi::OsStr::new(SEALED_STAGING_OWNER_FILE), + MAX_SEALED_RECEIPT_BYTES, + |bytes| { + verified_staging_receipt(name, bytes).is_some_and(|owner| { + let receipt = &owner.receipt; + owner.producer == row_spill_owner().to_string() + && receipt.namespace == locator.projection.namespace.as_str() + && receipt.projection == locator.projection.projection.as_str() + && receipt.generation == locator.generation.as_str() + && receipt.recovered_digest == expected_digest + }) + }, + ); + if !matches!(&result, Ok(true)) { + tracing::warn!( + event = "sealed_staging_cleanup_deferred", + path = %staging.display(), + ?result, + "unverified staging preserved instead of deleting an unknown directory" + ); } - Ok(()) } -/// Removes every `.staging-*` directory a seal left behind under the store's -/// sealed root. +/// Reclaims interrupted seals only when the bounded receipt from the same +/// opened staging directory proves its exact generation identity. The staging +/// filename alone is not deletion authority: a missing, malformed, oversized, +/// or foreign receipt leaves the directory intact for inspection. /// -/// A seal builds into `.staging-` and installs by rename, so a staging -/// directory that survives to the next open of the same store belongs to a -/// build the process never finished, a crash, a kill, or an OOM between -/// container write and rename. Nothing reads it and the next seal of that -/// generation starts over, so on the live profile these accumulated to 7.4 GB -/// under one store. Run at open: the exclusive store lock means no seal of -/// this store is in flight. +/// Store open owns exclusive maintenance admission. Capability-relative +/// traversal holds the root and child across verification and recursive removal, +/// so a pathname replacement cannot redirect deletion into a different tree. pub(crate) fn sweep_abandoned_sealed_staging(database_path: &Path) { let root = sealed_store_root(database_path); - let entries = match std::fs::read_dir(&root) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, - Err(error) => { - tracing::warn!( - event = "sealed_staging_sweep_unreadable", - root = %root.display(), - error = %error, - "sealed store root could not be enumerated; abandoned staging stays" - ); - return; - } - }; - let mut removed = 0usize; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name) = name.to_str() else { - continue; - }; - if !name.starts_with(".staging-") { - continue; - } - let path = entry.path(); - match std::fs::remove_dir_all(&path) { - Ok(()) => removed += 1, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => tracing::warn!( - event = "sealed_staging_sweep_failed", - path = %path.display(), - error = %error, - "abandoned sealed staging directory could not be removed" - ), - } - } - if removed > 0 { - tracing::info!( + // Every new sealed build writes this marker before rows and retains it + // through installation. The final sealed.json is not a separate cleanup + // authority: it cannot identify whether a concurrent build is still live. + let reclaimed = + tracedecay_private_fs::capability_dir::remove_matching_receipted_directories_nofollow( + &root, + std::ffi::OsStr::new(SEALED_STAGING_OWNER_FILE), + MAX_SEALED_RECEIPT_BYTES, + |name| name.to_str().is_some_and(sealed_staging_name), + |name, bytes| { + verified_staging_receipt(name, bytes) + .is_some_and(|owner| owner.producer != row_spill_owner().to_string()) + }, + ); + match reclaimed { + Ok(removed) if removed > 0 => tracing::info!( event = "sealed_staging_swept", root = %root.display(), removed, - "removed abandoned sealed staging directories left by interrupted seals" - ); + "removed receipt-verified staging from exited producers" + ), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => tracing::warn!( + event = "sealed_staging_sweep_failed", + root = %root.display(), + %error, + "unverified abandoned staging stays for inspection" + ), } } -/// Prefix of a batch producer's row spill under the sealed root. -const ROW_SPILL_PREFIX: &str = ".rows-"; - /// Removes every row spill another process left under the store's sealed /// root. Run at the eager open, whose exclusive store lock means no other /// process is producing rows for this store. Spills named for this process @@ -726,52 +888,113 @@ const ROW_SPILL_PREFIX: &str = ".rows-"; /// whose spill removes itself when it drops. pub(crate) fn sweep_abandoned_row_spills(database_path: &Path) { let root = sealed_store_root(database_path); - let Ok(entries) = std::fs::read_dir(&root) else { - return; - }; - let own = format!("{ROW_SPILL_PREFIX}{}-", std::process::id()); - for entry in entries.flatten() { - if entry - .file_name() - .to_str() - .is_some_and(|name| name.starts_with(ROW_SPILL_PREFIX) && !name.starts_with(&own)) - { - let path = entry.path(); - if let Err(error) = std::fs::remove_dir_all(&path) - && error.kind() != std::io::ErrorKind::NotFound - { - tracing::warn!( - event = "graph_row_spill_sweep_failed", - path = %path.display(), - error = %error, - "abandoned graph row spill could not be removed" - ); - } - } + let current_owner = row_spill_owner(); + // Enumerate, verify and unlink through one held sealed-root capability: + // a root rename between discovery and cleanup must not redirect deletion. + let result = + tracedecay_private_fs::capability_dir::remove_matching_receipted_directories_nofollow( + &root, + std::ffi::OsStr::new(ROW_SPILL_OWNER_RECEIPT_FILE), + MAX_ROW_SPILL_OWNER_RECEIPT_BYTES, + |name| AbandonedRowSpill::from_name(name, current_owner).is_some(), + |name, receipt| { + AbandonedRowSpill::from_name(name, current_owner) + .is_some_and(|proof| proof.owns(receipt)) + }, + ); + match result { + Ok(removed) if removed > 0 => tracing::info!( + event = "graph_row_spill_swept", + root = %root.display(), + removed, + "reclaimed receipt-verified abandoned graph row spills" + ), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => tracing::warn!( + event = "graph_row_spill_sweep_failed", + root = %root.display(), + %error, + "unverified abandoned graph row spills preserved for inspection" + ), } } -fn remove_sealed_directory_checked(directory: &Path) -> Result<(), GraphDbError> { - match std::fs::remove_dir_all(directory) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(sealed_store_io_failure( - "sealed directory removal failed", - error, - )), +/// Accepted replay retirement is not permission to recursively delete a +/// substituted or unrecognizable artifact. Require the exact durable identity +/// under a held child capability; preserve an unverified directory for retry. +fn retire_sealed_directory_checked( + directory: &Path, + locator: &GenerationLocator, + expected_digest: Option<&str>, +) -> Result<(), GraphDbError> { + let physical = locator.physical_namespace()?; + let root = directory + .parent() + .ok_or_else(|| GraphDbError::unavailable("sealed retirement directory has no parent"))?; + if sealed_generation_directory(root, &physical) != directory { + return Err(GraphDbError::unavailable( + "sealed retirement directory does not match generation identity", + )); + } + let name = directory.file_name().ok_or_else(|| { + GraphDbError::unavailable("sealed retirement directory has no entry name") + })?; + let result = tracedecay_private_fs::capability_dir::remove_receipted_child_directory_nofollow( + root, + name, + std::ffi::OsStr::new(SEALED_STORE_RECEIPT_FILE), + MAX_SEALED_RECEIPT_BYTES, + |bytes| { + let Ok(receipt) = serde_json::from_slice::(bytes) else { + return false; + }; + let form_valid = match receipt.form.as_str() { + SEALED_STORE_FORM_COMPACT => receipt.base.is_none(), + SEALED_STORE_FORM_LAYERED => receipt.base.is_some(), + _ => false, + }; + form_valid + && receipt.version == SEALED_STORE_RECEIPT_VERSION + && receipt.graph_format > 0 + && receipt.graph_format <= GraphFormatVersion::current().get() + && receipt.physical_namespace == physical.as_str() + && receipt.namespace == locator.projection.namespace.as_str() + && receipt.projection == locator.projection.projection.as_str() + && receipt.generation == locator.generation.as_str() + && expected_digest.is_none_or(|digest| receipt.recovered_digest == digest) + }, + ); + let removed = result + .map_err(|error| sealed_store_io_failure("sealed generation retirement refused", error))?; + // A resident reader carries exact proof that this artifact was installed. + // If its root or child disappeared before we acquired a deletion capability, + // physical retirement was not confirmed: the directory may have been moved + // aside and still contain the sole durable generation. Keep the reader + // registered so maintenance can retry rather than mark it collected. + if !removed && expected_digest.is_some() { + return Err(GraphDbError::unavailable( + "registered sealed generation retirement found no owned artifact", + )); } + Ok(()) } /// Staging and invalid-artifact cleanup are best effort. Retiring an accepted /// replay instead uses the checked path, so a failed unlink cannot be reported /// as a completed retirement and lose its durable retry ownership. -fn remove_sealed_directory(directory: &Path) { - if let Err(error) = remove_sealed_directory_checked(directory) { +/// Best-effort disposal after failed adoption/reopen. Only an exact durable +/// generation receipt authorizes removal; foreign bytes remain for diagnosis. +fn discard_failed_sealed_adoption( + directory: &Path, + locator: &GenerationLocator, + expected_digest: &str, +) { + if let Err(error) = retire_sealed_directory_checked(directory, locator, Some(expected_digest)) { tracing::warn!( - event = "sealed_artifact_cleanup_deferred", - path = %directory.display(), %error, - "sealed artifact cleanup did not complete" + path = %directory.display(), + "failed sealed adoption left an unverified artifact for inspection" ); } } @@ -1351,7 +1574,7 @@ impl GraphDb { .map_err(|error| sealed_store_io_failure("row spill root create failed", error))?; Ok(root.join(format!( "{ROW_SPILL_PREFIX}{}-{}", - std::process::id(), + row_spill_owner(), NEXT_SPILL.fetch_add(1, AtomicOrdering::Relaxed) ))) } @@ -1551,10 +1774,16 @@ impl GraphDb { Ok(Some(store)) => self.install_sealed_generation_store(locator, store), Ok(None) => Ok(()), Err(error @ (GraphDbError::Cancelled | GraphDbError::DeadlineExceeded)) => Err(error), - Err(_) => { - // A stale or corrupt artifact never outranks the verified - // staging rows; discard it so a later seal can rebuild. - remove_sealed_directory(&directory); + Err(error) => { + // A failed adoption does not prove this directory belongs to + // the generation. Preserve unknown bytes rather than removing + // an occupied path without a matching durable receipt. + discard_failed_sealed_adoption(&directory, &locator, expected.as_str()); + tracing::warn!( + %error, + path = %directory.display(), + "sealed adoption failed; serving falls back to staging rows" + ); Ok(()) } } @@ -1674,18 +1903,33 @@ impl GraphDb { &self, locator: &GenerationLocator, ) -> Result<(), GraphDbError> { - let removed = self + let registered = self .inner .sealed_generations - .write() + .read() .map_err(|_| GraphDbError::unavailable("sealed generation registry lock poisoned"))? - .remove(locator); - if let Some(store) = removed { - return store.discard(); + .get(locator) + .cloned(); + if let Some(store) = registered { + // Keep the registered reader until its physical close AND sealed + // directory removal both succeed. A failed close may consume the + // native handle; dropping the registry entry would let the next + // tick take the disk-only path and unlink uncertain bytes. + store.discard()?; + let mut sealed = self.inner.sealed_generations.write().map_err(|_| { + GraphDbError::unavailable("sealed generation registry lock poisoned") + })?; + if sealed + .get(locator) + .is_some_and(|current| Arc::ptr_eq(current, &store)) + { + sealed.remove(locator); + } + return Ok(()); } - // A removed in-memory reader is not necessary for the artifact to - // exist on disk. Unlink by exact locator even after a prior failed - // retirement; a durable tombstone can then retry without the reader. + // The artifact may exist on disk without a resident reader, for + // example after reopening a store before generation activation. + // Only receipt-verified, exact-locator disk cleanup is authorized. let Some(reopen) = self.inner.reopen.as_ref() else { return Ok(()); }; @@ -1694,7 +1938,11 @@ impl GraphDb { }; let physical_namespace = locator.physical_namespace()?; let root = sealed_store_root(database_path); - remove_sealed_directory_checked(&sealed_generation_directory(&root, &physical_namespace)) + retire_sealed_directory_checked( + &sealed_generation_directory(&root, &physical_namespace), + locator, + None, + ) } } @@ -1765,6 +2013,7 @@ fn build_or_open_sealed_store( // Idempotent replay: an artifact from an earlier seal of this exact // generation is adopted if its receipt binds the same digest. Adoption // never enumerates `source`'s rows, so it yields no staging proof. + let locator = GenerationLocator::new(identity.projection.clone(), identity.generation.clone()); match open_sealed_store_checked( &directory, identity, @@ -1773,11 +2022,26 @@ fn build_or_open_sealed_store( trusted_base.as_ref(), ) { Ok(Some(store)) => return Ok((store, None)), - Ok(None) => {} - Err(_) => remove_sealed_directory(&directory), - } - if directory.exists() { - remove_sealed_directory(&directory); + Ok(None) => match std::fs::symlink_metadata(&directory) { + Ok(_) => { + return Err(GraphDbError::unavailable( + "unverified sealed generation occupies the artifact directory", + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(sealed_store_io_failure( + "sealed artifact metadata read failed", + error, + )); + } + }, + Err(GraphDbError::Cancelled) => return Err(GraphDbError::Cancelled), + Err(_) => { + // A corrupt owned artifact may be rebuilt, but an unknown receipt + // (or a replacement directory) never grants deletion authority. + retire_sealed_directory_checked(&directory, &locator, Some(expected.as_str()))?; + } } let staging = root.join(format!( ".staging-{}", @@ -1786,18 +2050,70 @@ fn build_or_open_sealed_store( .and_then(|name| name.to_str()) .unwrap_or("sealed") )); - remove_sealed_directory(&staging); - std::fs::create_dir_all(&staging) + // create_dir_all may accept a symlinked sealed root, so verify the actual + // root before creating the staging entry or writing a container. + std::fs::create_dir_all(&root) + .map_err(|error| sealed_store_io_failure("sealed root create failed", error))?; + // create_dir_all accepts symlinked directory components. Refuse a + // preexisting substituted sealed root before writing staging bytes. + let root_metadata = std::fs::symlink_metadata(&root) + .map_err(|error| sealed_store_io_failure("sealed root metadata failed", error))?; + if !root_metadata.is_dir() { + return Err(GraphDbError::unavailable( + "sealed root must be a real directory", + )); + } + let staging_name = staging + .file_name() + .ok_or_else(|| GraphDbError::unavailable("sealed staging name missing"))?; + // A deterministic staging name is not proof of ownership. An earlier + // interrupted build or a foreign directory must not be deleted on retry. + // create_dir is exclusive, so an occupied name fails closed. + std::fs::create_dir(&staging) .map_err(|error| sealed_store_io_failure("staging directory create failed", error))?; + let owner = SealedStagingOwnerV1 { + producer: row_spill_owner().to_string(), + receipt: initial_sealed_staging_owner(identity, expected, physical_namespace.as_str()), + }; + let owner_bytes = serde_json::to_vec(&owner) + .map_err(|error| GraphDbError::unavailable(format!("staging owner encode: {error}")))?; + // Exclusive creation refuses a pre-existing marker. Flush before writing + // rows so a killed producer leaves verifiable deletion authority. + let marker_path = staging.join(SEALED_STAGING_OWNER_FILE); + let mut marker = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&marker_path) + .map_err(|error| sealed_store_io_failure("staging owner create failed", error))?; + marker + .write_all(&owner_bytes) + .and_then(|()| marker.sync_all()) + .map_err(|error| sealed_store_io_failure("staging owner persist failed", error))?; + drop(marker); let receipt = stage_sealed_artifact(rows, identity, expected, &staging, check) - .inspect_err(|_| remove_sealed_directory(&staging))?; + .inspect_err(|_| discard_failed_sealed_staging(&staging, &locator, expected.as_str()))?; let encoded = serde_json::to_vec_pretty(&receipt) - .map_err(|error| GraphDbError::unavailable(format!("sealed receipt encode: {error}")))?; - std::fs::write(staging.join(SEALED_STORE_RECEIPT_FILE), encoded) - .inspect_err(|_| remove_sealed_directory(&staging)) - .map_err(|error| sealed_store_io_failure("receipt write failed", error))?; - if let Err(error) = std::fs::rename(&staging, &directory) { - remove_sealed_directory(&staging); + .map_err(|error| GraphDbError::unavailable(format!("sealed receipt encode: {error}"))) + .inspect_err(|_| discard_failed_sealed_staging(&staging, &locator, expected.as_str()))?; + let mut receipt_file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(staging.join(SEALED_STORE_RECEIPT_FILE)) + .map_err(|error| sealed_store_io_failure("receipt create failed", error)) + .inspect_err(|_| discard_failed_sealed_staging(&staging, &locator, expected.as_str()))?; + let receipt_write = receipt_file + .write_all(&encoded) + .and_then(|()| receipt_file.sync_all()); + drop(receipt_file); + receipt_write + .map_err(|error| sealed_store_io_failure("receipt persist failed", error)) + .inspect_err(|_| discard_failed_sealed_staging(&staging, &locator, expected.as_str()))?; + let artifact_name = directory + .file_name() + .ok_or_else(|| GraphDbError::unavailable("sealed artifact name missing"))?; + let install = install_child_directory_noreplace_nofollow(&root, staging_name, artifact_name); + if let Err(error) = install { + discard_failed_sealed_staging(&staging, &locator, expected.as_str()); // A concurrent seal of the same generation may have installed the // directory first; adopting it below keeps this path idempotent. if !directory.exists() { @@ -1824,13 +2140,13 @@ fn build_or_open_sealed_store( Ok((store, staging_proof)) } Ok(None) => { - remove_sealed_directory(&directory); + discard_failed_sealed_adoption(&directory, &locator, expected.as_str()); Err(GraphDbError::unavailable( "sealed generation store disappeared between install and reopen".to_owned(), )) } Err(error) => { - remove_sealed_directory(&directory); + discard_failed_sealed_adoption(&directory, &locator, expected.as_str()); Err(error) } } @@ -2426,7 +2742,7 @@ fn load_sealed_store_receipt( directory: &Path, ) -> Result, GraphDbError> { let receipt_path = directory.join(SEALED_STORE_RECEIPT_FILE); - let receipt_bytes = match std::fs::read(&receipt_path) { + let receipt_bytes = match read_sealed_receipt_bytes(&receipt_path) { Ok(bytes) => bytes, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(sealed_store_io_failure("receipt read failed", error)), @@ -2441,9 +2757,12 @@ fn sealed_store_check_matches( expected_digest: &str, ) -> Result { let path = directory.join(SEALED_STORE_CHECKED_FILE); - match std::fs::read_to_string(&path) { - Ok(digest) => Ok(digest == expected_digest), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + // This is a rebuildable proof hint. Invalid or overlarge bytes cause a + // fresh proof, never an unbounded allocation or a trusted cache hit. + match tracedecay_private_fs::framed_log::read_bounded(&path, MAX_SEALED_CHECK_BYTES) { + Ok(Some(bytes)) => Ok(bytes == expected_digest.as_bytes()), + Ok(None) => Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::InvalidData => Ok(false), Err(error) => Err(sealed_store_io_failure("sealed check read failed", error)), } } @@ -2452,8 +2771,14 @@ fn persist_sealed_store_check(directory: &Path, expected_digest: &str) -> Result if sealed_store_check_matches(directory, expected_digest)? { return Ok(()); } - std::fs::write(directory.join(SEALED_STORE_CHECKED_FILE), expected_digest) - .map_err(|error| sealed_store_io_failure("sealed check persist failed", error)) + // Never truncate an existing symlink target: the marker is a rebuildable + // cache, so stage a regular file and publish it by atomic replacement. + tracedecay_private_fs::capability_dir::atomic_replace_child_file_nofollow( + directory, + std::ffi::OsStr::new(SEALED_STORE_CHECKED_FILE), + expected_digest.as_bytes(), + ) + .map_err(|error| sealed_store_io_failure("sealed check persist failed", error)) } fn open_sealed_store_checked( @@ -2731,71 +3056,556 @@ pub(crate) fn sealed_copy_proof( } #[cfg(test)] -mod build_tests { - use std::collections::{BTreeMap, BTreeSet}; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - use rayon::ThreadPoolBuilder; - - use super::{ - SEALED_STORE_DATABASE_FILE, SealedRowSource, build_or_open_sealed_store, - build_sealed_container, sealed_artifact_database_options, sealed_copy_proof, - sealed_generation_directory, sealed_store_root, - }; - use crate::location::PersistentGraphStoreState; - use crate::{ - GraphDb, GraphDbError, GraphDbLocation, GraphDbOpenOptions, GraphDbOwner, GraphDurability, - GraphEntity, GraphEntityId, GraphEntityRef, GraphFormatVersion, GraphGenerationId, - GraphGenerationManifest, GraphGenerationRelation, GraphLabel, GraphNamespace, - GraphProjectionId, GraphProjectionIdentity, GraphProperty, GraphPropertyName, - GraphRelationId, GraphRelationKind, GraphWatermark, NeverCancelled, SourceGeneration, - }; +mod receipt_budget_tests { + #[cfg(unix)] + use std::collections::BTreeSet; + use std::path::Path; + + use super::*; + + fn receipt(format: u32, name: &str, generation: &str) -> Vec { + serde_json::to_vec(&SealedStoreReceiptV1 { + version: SEALED_STORE_RECEIPT_VERSION, + form: SEALED_STORE_FORM_COMPACT.to_owned(), + namespace: "test".to_owned(), + projection: "code".to_owned(), + generation: generation.to_owned(), + physical_namespace: format!("generation:{name}"), + recovered_digest: "digest".to_owned(), + entities: 0, + relations: 0, + graph_format: format, + row_sum: None, + base: None, + source_generation: None, + watermark: None, + }) + .unwrap() + } - fn entity_identity(index: usize) -> GraphEntityId { - GraphEntityId::new(format!("symbol:{index:05}")).unwrap() + fn generation(root: &Path, digit: char, contents: &[u8]) -> PathBuf { + let directory = root.join(digit.to_string().repeat(64)); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join(SEALED_STORE_RECEIPT_FILE), contents).unwrap(); + directory } - /// A generation large enough that its copy spans several guard chunks - /// and pager pages, with a Bytes payload so it takes the production - /// (compact-eligible) row shape. - fn manifest(entities: usize, relations: usize) -> GraphGenerationManifest { - let projection = GraphProjectionIdentity::new( - GraphNamespace::new("sealed-build").unwrap(), - GraphProjectionId::new("code").unwrap(), + #[test] + fn sealed_receipt_reads_bound_memory_even_for_json_padded_beyond_the_limit() { + let temp = tempfile::tempdir().unwrap(); + let receipt_path = temp.path().join(SEALED_STORE_RECEIPT_FILE); + let normal = receipt(GraphFormatVersion::current().get(), &"a".repeat(64), "head"); + std::fs::write(&receipt_path, &normal).unwrap(); + assert_eq!(read_sealed_receipt_bytes(&receipt_path).unwrap(), normal); + + let oversized = [normal, vec![b' '; MAX_SEALED_RECEIPT_BYTES]].concat(); + assert!(serde_json::from_slice::(&oversized).is_ok()); + std::fs::write(&receipt_path, oversized).unwrap(); + assert_eq!( + read_sealed_receipt_bytes(&receipt_path).unwrap_err().kind(), + std::io::ErrorKind::InvalidData ); - let entity_rows = (0..entities) - .map(|index| { - GraphEntity::new( - entity_identity(index), - BTreeSet::from([GraphLabel::new("function").unwrap()]), - BTreeMap::from([ - ( - GraphPropertyName::new("name").unwrap(), - GraphProperty::String(format!("fn_{index:05}")), - ), - ( - GraphPropertyName::new("payload").unwrap(), - GraphProperty::Bytes(vec![(index % 251) as u8; 64]), - ), - ]), - ) + // On Unix this is sparse, so the multi-gigabyte bound is exercised + // without allocating a multi-gigabyte test artifact. + #[cfg(unix)] + { + std::fs::File::create(&receipt_path) .unwrap() - }) - .collect(); - let entity_ref = - |index: usize| GraphEntityRef::new(projection.clone(), entity_identity(index)); - let relation_rows = (0..relations) - .map(|index| { - GraphGenerationRelation::new( - GraphRelationId::new(format!("call:{index:05}")).unwrap(), - entity_ref(index % entities), - entity_ref((index + 1) % entities), - GraphRelationKind::new("calls").unwrap(), - BTreeMap::new(), - ) + .set_len(15 * 1024 * 1024 * 1024) + .unwrap(); + assert_eq!( + read_sealed_receipt_bytes(&receipt_path).unwrap_err().kind(), + std::io::ErrorKind::InvalidData + ); + } + } + + #[test] + fn sealed_check_marker_is_bounded_and_never_overwrites_a_foreign_target() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("sealed"); + std::fs::create_dir(&directory).unwrap(); + let marker = directory.join(SEALED_STORE_CHECKED_FILE); + let digest = format!("sha256:{}", "a".repeat(64)); + + assert!(!sealed_store_check_matches(&directory, &digest).unwrap()); + persist_sealed_store_check(&directory, &digest).unwrap(); + assert!(sealed_store_check_matches(&directory, &digest).unwrap()); + assert_eq!(std::fs::read(&marker).unwrap(), digest.as_bytes()); + + let mut oversized = digest.as_bytes().to_vec(); + oversized.extend(vec![b' '; MAX_SEALED_CHECK_BYTES]); + std::fs::write(&marker, oversized).unwrap(); + assert!(!sealed_store_check_matches(&directory, &digest).unwrap()); + persist_sealed_store_check(&directory, &digest).unwrap(); + assert!(sealed_store_check_matches(&directory, &digest).unwrap()); + + #[cfg(unix)] + { + // Sparse bytes establish the large-file refusal without writing + // or allocating a multi-gigabyte test payload. + std::fs::File::create(&marker) .unwrap() - }) + .set_len(15 * 1024 * 1024 * 1024) + .unwrap(); + assert!(!sealed_store_check_matches(&directory, &digest).unwrap()); + persist_sealed_store_check(&directory, &digest).unwrap(); + assert!(sealed_store_check_matches(&directory, &digest).unwrap()); + + let foreign = temp.path().join("foreign"); + std::fs::remove_file(&marker).unwrap(); + std::fs::write(&foreign, digest.as_bytes()).unwrap(); + std::os::unix::fs::symlink(&foreign, &marker).unwrap(); + assert!( + sealed_store_check_matches(&directory, &digest).is_err(), + "a symlink cannot supply proof evidence" + ); + + std::fs::remove_file(&marker).unwrap(); + std::fs::write(&foreign, b"foreign sentinel").unwrap(); + std::os::unix::fs::symlink(&foreign, &marker).unwrap(); + assert!( + persist_sealed_store_check(&directory, &digest).is_err(), + "publishing a proof marker must refuse a foreign symlink" + ); + assert_eq!(std::fs::read(&foreign).unwrap(), b"foreign sentinel"); + assert!( + std::fs::symlink_metadata(&marker) + .unwrap() + .file_type() + .is_symlink(), + "the foreign marker entry itself must survive" + ); + } + } + + #[test] + fn obsolete_format_cleanup_keeps_unknown_receipts_and_other_files() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.grafeo"); + let root = sealed_store_root(&database); + std::fs::create_dir_all(&root).unwrap(); + let current = GraphFormatVersion::current().get(); + assert!(current > 0); + let old_locator = GenerationLocator::new( + GraphProjectionIdentity::new( + GraphNamespace::new("test").unwrap(), + GraphProjectionId::new("code").unwrap(), + ), + crate::GraphGenerationId::new("generation:obsolete").unwrap(), + ); + let old_physical = old_locator.physical_namespace().unwrap(); + let old_name = old_physical.as_str().strip_prefix("generation:").unwrap(); + let old = root.join(old_name); + std::fs::create_dir(&old).unwrap(); + std::fs::write( + old.join(SEALED_STORE_RECEIPT_FILE), + receipt(current - 1, old_name, "generation:obsolete"), + ) + .unwrap(); + // A syntactically plausible old receipt with a different logical + // generation cannot authorize deletion of this occupied directory. + let forged = generation( + &root, + 'a', + &receipt(current - 1, &"a".repeat(64), "generation:foreign"), + ); + let live = generation(&root, 'b', &receipt(current, &"b".repeat(64), "live")); + let invalid = generation(&root, 'c', b"not a receipt"); + // Zero is not a valid GraphFormatVersion, even when the JSON is well formed. + let zero_format = generation(&root, '0', &receipt(0, &"0".repeat(64), "zero")); + let future = generation( + &root, + 'f', + &receipt(current.saturating_add(1), &"f".repeat(64), "future"), + ); + let padded = [ + receipt(current - 1, &"d".repeat(64), "padded"), + vec![b' '; MAX_SEALED_RECEIPT_BYTES], + ] + .concat(); + let oversized = generation(&root, 'd', &padded); + let missing = root.join("e".repeat(64)); + std::fs::create_dir_all(&missing).unwrap(); + let unrelated = root.join("unrelated.txt"); + std::fs::write(&unrelated, b"foreign").unwrap(); + // A symlink to a syntactically valid old receipt must never confer + // deletion authority over the directory that holds the link. + #[cfg(unix)] + let linked_receipt = { + let directory = root.join("1".repeat(64)); + std::fs::create_dir_all(&directory).unwrap(); + let foreign = temp.path().join("foreign-receipt.json"); + std::fs::write(&foreign, receipt(current - 1, &"1".repeat(64), "foreign")).unwrap(); + std::os::unix::fs::symlink(&foreign, directory.join(SEALED_STORE_RECEIPT_FILE)) + .unwrap(); + std::fs::write(directory.join("sentinel"), b"unowned").unwrap(); + (directory, foreign) + }; + for dir in [ + &old, + &forged, + &live, + &invalid, + &zero_format, + &oversized, + &missing, + &future, + ] { + std::fs::write(dir.join("sentinel"), b"owned").unwrap(); + } + + discard_superseded_sealed_generations(&database).unwrap(); + assert!(!old.exists(), "verified old-format receipt is reclaimable"); + for dir in [ + &forged, + &live, + &invalid, + &zero_format, + &oversized, + &missing, + &future, + ] { + assert_eq!(std::fs::read(dir.join("sentinel")).unwrap(), b"owned"); + } + assert_eq!(std::fs::read(&unrelated).unwrap(), b"foreign"); + #[cfg(unix)] + { + assert_eq!( + std::fs::read(linked_receipt.0.join("sentinel")).unwrap(), + b"unowned" + ); + assert!(linked_receipt.1.is_file()); + assert_eq!( + read_sealed_receipt_bytes(&linked_receipt.0.join(SEALED_STORE_RECEIPT_FILE)) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + } + } + + #[test] + fn failed_adoption_cannot_discard_unverified_or_foreign_artifacts() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.grafeo"); + let root = sealed_store_root(&database); + let locator = GenerationLocator::new( + GraphProjectionIdentity::new( + GraphNamespace::new("test").unwrap(), + GraphProjectionId::new("code").unwrap(), + ), + crate::GraphGenerationId::new("generation:retirement").unwrap(), + ); + let directory = sealed_generation_directory(&root, &locator.physical_namespace().unwrap()); + std::fs::create_dir_all(&directory).unwrap(); + let name = directory.file_name().unwrap().to_str().unwrap(); + let sentinel = directory.join("foreign-sentinel"); + std::fs::write(&sentinel, b"preserve").unwrap(); + let sibling = root.join("unrelated"); + std::fs::create_dir(&sibling).unwrap(); + std::fs::write(sibling.join("sentinel"), b"other").unwrap(); + + // Neither a missing nor a malformed receipt proves ownership. + discard_failed_sealed_adoption(&directory, &locator, "digest"); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"preserve"); + let receipt_path = directory.join(SEALED_STORE_RECEIPT_FILE); + std::fs::write(&receipt_path, b"invalid receipt").unwrap(); + discard_failed_sealed_adoption(&directory, &locator, "digest"); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"preserve"); + + // An otherwise recognizable receipt still cannot retire a different + // expected digest. The exact matching receipt allows cleanup. + std::fs::write( + &receipt_path, + receipt( + GraphFormatVersion::current().get(), + name, + "generation:retirement", + ), + ) + .unwrap(); + discard_failed_sealed_adoption(&directory, &locator, "other-digest"); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"preserve"); + discard_failed_sealed_adoption(&directory, &locator, "digest"); + assert!( + !directory.exists(), + "owned invalid artifacts can be reclaimed" + ); + assert_eq!(std::fs::read(sibling.join("sentinel")).unwrap(), b"other"); + } + + #[test] + fn retired_sealed_generation_requires_its_exact_receipt_identity() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.grafeo"); + let root = sealed_store_root(&database); + let locator = GenerationLocator::new( + GraphProjectionIdentity::new( + GraphNamespace::new("test").unwrap(), + GraphProjectionId::new("code").unwrap(), + ), + crate::GraphGenerationId::new("generation:retirement").unwrap(), + ); + let physical = locator.physical_namespace().unwrap(); + let directory = sealed_generation_directory(&root, &physical); + std::fs::create_dir_all(&directory).unwrap(); + let name = directory.file_name().unwrap().to_str().unwrap(); + let receipt_path = directory.join(SEALED_STORE_RECEIPT_FILE); + std::fs::write( + &receipt_path, + receipt( + GraphFormatVersion::current().get(), + name, + "generation:other", + ), + ) + .unwrap(); + std::fs::write(directory.join("sentinel"), b"preserve").unwrap(); + assert!(retire_sealed_directory_checked(&directory, &locator, None).is_err()); + assert_eq!( + std::fs::read(directory.join("sentinel")).unwrap(), + b"preserve" + ); + std::fs::write( + &receipt_path, + receipt( + GraphFormatVersion::current().get(), + name, + locator.generation.as_str(), + ), + ) + .unwrap(); + assert!(retire_sealed_directory_checked(&directory, &locator, Some("wrong")).is_err()); + assert_eq!( + std::fs::read(directory.join("sentinel")).unwrap(), + b"preserve" + ); + retire_sealed_directory_checked(&directory, &locator, Some("digest")).unwrap(); + assert!( + !directory.exists(), + "verified retired generation is physically removed" + ); + // A registered reader proves the artifact was installed, so its + // absence is unconfirmed retirement. Only a disk-only retry with no + // resident reader may treat an already-absent directory as done. + assert!( + retire_sealed_directory_checked(&directory, &locator, Some("digest")).is_err(), + "a registered generation must not report retirement of an absent artifact" + ); + retire_sealed_directory_checked(&directory, &locator, None).unwrap(); + } + + #[cfg(unix)] + #[test] + fn census_handles_seventy_large_sparse_generations_without_loading_containers() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.grafeo"); + let root = sealed_store_root(&database); + std::fs::create_dir_all(&root).unwrap(); + const FILE_BYTES: u64 = 220 * 1024 * 1024; + let mut oldest = None; + for index in 0..70 { + let generation = format!("generation:g{index}"); + let locator = GenerationLocator::new( + GraphProjectionIdentity::new( + GraphNamespace::new("test").unwrap(), + GraphProjectionId::new("code").unwrap(), + ), + crate::GraphGenerationId::new(generation.clone()).unwrap(), + ); + let physical = locator.physical_namespace().unwrap(); + let name = physical.as_str().strip_prefix("generation:").unwrap(); + let dir = root.join(name); + if index == 0 { + oldest = Some(dir.clone()); + } + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join(SEALED_STORE_RECEIPT_FILE), + receipt(GraphFormatVersion::current().get(), name, &generation), + ) + .unwrap(); + std::fs::File::create(dir.join(SEALED_STORE_DATABASE_FILE)) + .unwrap() + .set_len(FILE_BYTES) + .unwrap(); + } + // A sealed directory must never account bytes outside its own tree. + // A missing target must also remain a harmless link, not abort census. + let foreign = temp.path().join("foreign"); + std::fs::create_dir(&foreign).unwrap(); + std::fs::File::create(foreign.join("payload")) + .unwrap() + .set_len(20 * 1024 * 1024 * 1024) + .unwrap(); + let oldest = oldest.unwrap(); + std::os::unix::fs::symlink(&foreign, oldest.join("foreign")).unwrap(); + std::os::unix::fs::symlink(temp.path().join("missing"), oldest.join("dangling")).unwrap(); + let census = + census_sealed_store(&database, &BTreeSet::from(["generation:g69".to_owned()])).unwrap(); + assert_eq!(census.head_count, 1); + assert_eq!(census.superseded_count, 69); + assert_eq!(census.unrecognized_count, 0); + assert!(census.head_bytes >= FILE_BYTES); + assert!(census.superseded_bytes >= 69 * FILE_BYTES); + assert!( + census.superseded_bytes < 70 * FILE_BYTES, + "foreign link targets must not contribute to sealed generation bytes" + ); + } + + #[cfg(unix)] + #[test] + fn census_does_not_classify_copied_or_foreign_receipts_as_live_heads() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.grafeo"); + let root = sealed_store_root(&database); + std::fs::create_dir_all(&root).unwrap(); + let generation = "generation:head"; + let locator = GenerationLocator::new( + GraphProjectionIdentity::new( + GraphNamespace::new("test").unwrap(), + GraphProjectionId::new("code").unwrap(), + ), + crate::GraphGenerationId::new(generation).unwrap(), + ); + let physical = locator.physical_namespace().unwrap(); + let owned_name = physical.as_str().strip_prefix("generation:").unwrap(); + let owned = root.join(owned_name); + std::fs::create_dir(&owned).unwrap(); + let owned_receipt = receipt(GraphFormatVersion::current().get(), owned_name, generation); + std::fs::write(owned.join(SEALED_STORE_RECEIPT_FILE), &owned_receipt).unwrap(); + std::fs::write(owned.join(SEALED_STORE_DATABASE_FILE), b"owned payload").unwrap(); + + // The foreign directory carries the *exact* valid head receipt, but + // its physical entry is not the one derived from the logical identity. + let foreign_name = if owned_name == "f".repeat(64) { + "e".repeat(64) + } else { + "f".repeat(64) + }; + let foreign = root.join(foreign_name); + std::fs::create_dir(&foreign).unwrap(); + std::fs::write(foreign.join(SEALED_STORE_RECEIPT_FILE), &owned_receipt).unwrap(); + std::fs::File::create(foreign.join(SEALED_STORE_DATABASE_FILE)) + .unwrap() + .set_len(5 * 1024 * 1024 * 1024) + .unwrap(); + + let census = + census_sealed_store(&database, &BTreeSet::from([generation.to_owned()])).unwrap(); + assert_eq!(census.head_count, 1); + assert_eq!(census.superseded_count, 0); + assert_eq!(census.unrecognized_count, 1); + assert_eq!(census.head_bytes, owned_receipt.len() as u64 + 13); + assert!(foreign.join(SEALED_STORE_DATABASE_FILE).exists()); + } +} + +#[cfg(test)] +mod build_tests { + use std::collections::{BTreeMap, BTreeSet}; + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use rayon::ThreadPoolBuilder; + + use super::{ + MAX_SEALED_RECEIPT_BYTES, SEALED_STAGING_OWNER_FILE, SEALED_STORE_DATABASE_FILE, + SEALED_STORE_RECEIPT_FILE, SealedRowSource, SealedStagingOwnerV1, + build_or_open_sealed_store, build_sealed_container, census_sealed_store, + initial_sealed_staging_owner, persist_sealed_store_check, retire_sealed_directory_checked, + sealed_artifact_database_options, sealed_copy_proof, sealed_generation_directory, + sealed_store_root, sweep_abandoned_sealed_staging, + }; + use crate::location::PersistentGraphStoreState; + use crate::{ + GraphDb, GraphDbError, GraphDbLocation, GraphDbOpenOptions, GraphDbOwner, GraphDurability, + GraphEntity, GraphEntityId, GraphEntityRef, GraphFormatVersion, GraphGenerationId, + GraphGenerationManifest, GraphGenerationRelation, GraphLabel, GraphNamespace, + GraphProjectionId, GraphProjectionIdentity, GraphProperty, GraphPropertyName, + GraphRelationId, GraphRelationKind, GraphWatermark, NeverCancelled, SourceGeneration, + }; + + #[cfg(unix)] + #[test] + fn sealed_check_persistence_refuses_a_substituted_generation_directory() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let owned = temp.path().join("owned-generation"); + let foreign = temp.path().join("foreign-generation"); + std::fs::create_dir(&owned).unwrap(); + std::fs::create_dir(&foreign).unwrap(); + std::fs::write(foreign.join("sealed.checked"), b"foreign marker").unwrap(); + std::fs::remove_dir(&owned).unwrap(); + symlink(&foreign, &owned).unwrap(); + + assert!(persist_sealed_store_check(&owned, "accepted-digest").is_err()); + assert_eq!( + std::fs::read(foreign.join("sealed.checked")).unwrap(), + b"foreign marker", + ); + std::fs::remove_file(&owned).unwrap(); + std::fs::create_dir(&owned).unwrap(); + persist_sealed_store_check(&owned, "accepted-digest").unwrap(); + assert_eq!( + std::fs::read(owned.join("sealed.checked")).unwrap(), + b"accepted-digest", + ); + assert_eq!( + std::fs::read(foreign.join("sealed.checked")).unwrap(), + b"foreign marker", + ); + } + + fn entity_identity(index: usize) -> GraphEntityId { + GraphEntityId::new(format!("symbol:{index:05}")).unwrap() + } + + /// A generation large enough that its copy spans several guard chunks + /// and pager pages, with a Bytes payload so it takes the production + /// (compact-eligible) row shape. + fn manifest(entities: usize, relations: usize) -> GraphGenerationManifest { + let projection = GraphProjectionIdentity::new( + GraphNamespace::new("sealed-build").unwrap(), + GraphProjectionId::new("code").unwrap(), + ); + let entity_rows = (0..entities) + .map(|index| { + GraphEntity::new( + entity_identity(index), + BTreeSet::from([GraphLabel::new("function").unwrap()]), + BTreeMap::from([ + ( + GraphPropertyName::new("name").unwrap(), + GraphProperty::String(format!("fn_{index:05}")), + ), + ( + GraphPropertyName::new("payload").unwrap(), + GraphProperty::Bytes(vec![(index % 251) as u8; 64]), + ), + ]), + ) + .unwrap() + }) + .collect(); + let entity_ref = + |index: usize| GraphEntityRef::new(projection.clone(), entity_identity(index)); + let relation_rows = (0..relations) + .map(|index| { + GraphGenerationRelation::new( + GraphRelationId::new(format!("call:{index:05}")).unwrap(), + entity_ref(index % entities), + entity_ref((index + 1) % entities), + GraphRelationKind::new("calls").unwrap(), + BTreeMap::new(), + ) + .unwrap() + }) .collect(); GraphGenerationManifest::new( projection, @@ -2809,6 +3619,211 @@ mod build_tests { .unwrap() } + /// Real layered generations share one physical base. The production + /// census must attribute those hard links to the live head, regardless + /// of how many older layered generations already reference the base. + #[cfg(unix)] + #[test] + fn sealed_census_counts_published_single_base_link_once() { + census_with_layered_publication(0); + } + + #[cfg(unix)] + #[test] + fn sealed_census_counts_published_multiple_base_links_once() { + census_with_layered_publication(1); + } + + #[cfg(unix)] + fn census_with_layered_publication(initial_layers: u64) { + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let database = open_source(&database_path); + let manifest = manifest(3, 2); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(&|| Ok(())).unwrap(); + let mut spill = database + .generation_row_spill(manifest.projection.clone()) + .unwrap(); + spill + .push_batch( + manifest.entities.clone(), + manifest.relations.clone(), + &|| Ok(()), + ) + .unwrap(); + std::fs::write(spill.attachment_path(), b"producer inputs").unwrap(); + let spilled = spill.finish(identity.clone(), &|| Ok(())).unwrap(); + let (cold, _) = build_or_open_sealed_store( + SealedRowSource::Spilled(&spilled), + &identity, + &expected, + &database_path, + &|| Ok(()), + ) + .unwrap(); + drop(spilled); + let base = cold.sealed_base(&|| Ok(())).unwrap().unwrap(); + let publish = |generation: &str| { + let mut identity = identity.clone(); + identity.generation = GraphGenerationId::new(generation).unwrap(); + let layered = database + .layered_row_spill(identity.projection.clone(), base.clone()) + .unwrap() + .finish(identity.clone(), &|| Ok(())) + .unwrap(); + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Layered(&layered), + &identity, + layered.expected_recovered_digest(), + &database_path, + &|| Ok(()), + ) + .unwrap(); + drop(layered); + store + }; + let mut stores = vec![cold]; + if initial_layers == 1 { + stores.push(publish("generation:child1")); + } + let original = stores[0].directory.join(SEALED_STORE_DATABASE_FILE); + let original_bytes = std::fs::read(&original).unwrap(); + assert_eq!( + std::fs::metadata(&original).unwrap().nlink(), + 1 + initial_layers + ); + let receipts: Vec<_> = stores + .iter() + .map(|store| { + let path = store.directory.join(SEALED_STORE_RECEIPT_FILE); + let bytes = std::fs::read(&path).unwrap(); + (path, bytes) + }) + .collect(); + stores.push(publish("generation:child2")); + assert_eq!( + std::fs::metadata(&original).unwrap().nlink(), + 2 + initial_layers + ); + assert_eq!(std::fs::read(&original).unwrap(), original_bytes); + for (path, bytes) in receipts { + assert_eq!(std::fs::read(path).unwrap(), bytes); + } + // Independent native identity census of the completed real artifacts. + // Compare each category as well as the unique physical total. + let mut physical = BTreeMap::<_, (u8, u64)>::new(); + for (index, store) in stores.iter().enumerate() { + let kind = if index + 1 == stores.len() { 0 } else { 1 }; + for entry in std::fs::read_dir(&store.directory).unwrap() { + let metadata = entry.unwrap().metadata().unwrap(); + assert!(metadata.is_file()); + physical + .entry((metadata.dev(), metadata.ino())) + .and_modify(|(owner, _)| *owner = (*owner).min(kind)) + .or_insert((kind, metadata.len())); + } + } + let census = census_sealed_store( + &database_path, + &BTreeSet::from(["generation:child2".to_owned()]), + ) + .unwrap(); + assert_eq!(census.head_count, 1); + assert_eq!(census.superseded_count, 1 + initial_layers); + assert_eq!(census.unrecognized_count, 0); + for kind in [0, 1] { + let bytes: u64 = physical + .values() + .filter(|(owner, _)| *owner == kind) + .map(|(_, bytes)| bytes) + .sum(); + let observed = if kind == 0 { + census.head_bytes + } else { + census.superseded_bytes + }; + assert_eq!( + observed, bytes, + "initial layers {initial_layers}, category {kind}" + ); + } + for store in stores { + store.close().unwrap(); + } + database.close().unwrap(); + } + + #[cfg(unix)] + #[test] + fn sealed_census_only_attributes_verified_abandoned_staging() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("source.grafeo"); + let root = sealed_store_root(&database); + let manifest = manifest(2, 1); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(&|| Ok(())).unwrap(); + let physical = identity.physical_namespace().unwrap(); + let name = physical.as_str().strip_prefix("generation:").unwrap(); + let staging = root.join(format!(".staging-{name}")); + std::fs::create_dir_all(&staging).unwrap(); + + let exited = SealedStagingOwnerV1 { + producer: (crate::generation::row_spill_owner() ^ 1).to_string(), + receipt: initial_sealed_staging_owner(&identity, &expected, physical.as_str()), + }; + let bytes = serde_json::to_vec(&exited).unwrap(); + let owner_path = staging.join(SEALED_STAGING_OWNER_FILE); + std::fs::write(&owner_path, &bytes).unwrap(); + std::fs::write(staging.join("payload"), vec![7; 4096]).unwrap(); + + let census = census_sealed_store(&database, &BTreeSet::new()).unwrap(); + assert_eq!(census.abandoned_staging_count, 1); + assert_eq!(census.abandoned_staging_bytes, bytes.len() as u64 + 4096); + assert_eq!(census.unrecognized_count, 0); + + // This same completed marker may belong to a seal currently running + // in this process. A remount must not report its bytes as garbage. + let live = SealedStagingOwnerV1 { + producer: crate::generation::row_spill_owner().to_string(), + receipt: initial_sealed_staging_owner(&identity, &expected, physical.as_str()), + }; + std::fs::write(&owner_path, serde_json::to_vec(&live).unwrap()).unwrap(); + let census = census_sealed_store(&database, &BTreeSet::new()).unwrap(); + assert_eq!(census.abandoned_staging_count, 0); + assert_eq!(census.abandoned_staging_bytes, 0); + assert_eq!(census.unrecognized_count, 1); + + // A copied authentic receipt in a different physical staging name + // does not authorize either reclamation or an unbounded size walk. + let foreign_name = if name == "f".repeat(64) { + "e".repeat(64) + } else { + "f".repeat(64) + }; + let foreign = root.join(format!(".staging-{foreign_name}")); + std::fs::create_dir(&foreign).unwrap(); + std::fs::write(foreign.join(SEALED_STAGING_OWNER_FILE), &bytes).unwrap(); + std::fs::File::create(foreign.join("sparse-foreign-payload")) + .unwrap() + .set_len(5 * 1024 * 1024 * 1024) + .unwrap(); + let census = census_sealed_store(&database, &BTreeSet::new()).unwrap(); + assert_eq!(census.abandoned_staging_count, 0); + assert_eq!(census.abandoned_staging_bytes, 0); + assert_eq!(census.unrecognized_count, 2); + assert!(foreign.join("sparse-foreign-payload").is_file()); + + // Even an owned directory whose marker exceeds the fixed bound must + // not cause its contents to be traversed or counted as abandoned. + let oversized = vec![b'x'; MAX_SEALED_RECEIPT_BYTES + 1]; + std::fs::write(&owner_path, &oversized).unwrap(); + let census = census_sealed_store(&database, &BTreeSet::new()).unwrap(); + assert_eq!(census.abandoned_staging_count, 0); + assert_eq!(census.unrecognized_count, 2); + assert_eq!(std::fs::read(&owner_path).unwrap(), oversized); + } + fn open_source(database_path: &std::path::Path) -> crate::GraphDbLeaseV1 { let owner = GraphDbOwner::open(GraphDbOpenOptions { location: GraphDbLocation::Persistent(database_path.to_path_buf()), @@ -2849,150 +3864,824 @@ mod build_tests { peak <= PEAK_BUDGET_BYTES, "the sealed build held {peak} bytes at peak, over its {PEAK_BUDGET_BYTES}-byte budget" ); - let left: Vec<_> = std::fs::read_dir(staging.path()) - .unwrap() - .map(|entry| entry.unwrap().file_name()) - .collect(); + let left: Vec<_> = std::fs::read_dir(staging.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!( + left, + vec![std::ffi::OsString::from(SEALED_STORE_DATABASE_FILE)] + ); + } + + /// A reopened sealed generation serves its rows in place: opening it + /// leaves a small fraction of the container resident, a point read + /// answers exactly, and a page that fails verification refuses the read + /// that touched it as corrupt instead of answering without the page. + /// Opening one used to decode every dictionary string and rebuild every + /// id map and property index, leaving more than the container resident. + #[test] + fn a_reopened_sealed_generation_serves_its_rows_in_place() { + let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); + let manifest = manifest(30_000, 45_000); + let identity = manifest.identity(); + let namespace = identity.physical_namespace().unwrap(); + let staging = tempfile::tempdir().unwrap(); + build_sealed_container( + SealedRowSource::Manifest(&manifest), + &identity, + staging.path(), + check, + ) + .unwrap(); + let path = staging.path().join(SEALED_STORE_DATABASE_FILE); + let container = std::fs::metadata(&path).unwrap().len(); + let open = || { + GraphDb::open_lazy_with_store_state( + sealed_artifact_database_options(path.clone()), + PersistentGraphStoreState::Existing, + ) + .unwrap() + }; + let name = GraphPropertyName::new("name").unwrap(); + + let database = open(); + let entity = database + .entity( + &namespace, + &entity_identity(12_345), + Arc::new(NeverCancelled), + ) + .unwrap() + .expect("the sealed entity reads back"); + assert_eq!( + entity.properties.get(&name), + Some(&GraphProperty::String("fn_12345".to_owned())) + ); + let resident = database.resident_engine_bytes().unwrap().unwrap(); + assert!( + resident * 4 < container, + "opening a {container}-byte sealed container left {resident} bytes resident" + ); + database.close().unwrap(); + drop(database); + + corrupt_stored_name(&path); + + let database = open(); + let refused = database.entity( + &namespace, + &entity_identity(12_345), + Arc::new(NeverCancelled), + ); + assert!( + matches!(&refused, Err(GraphDbError::Corrupt { message }) if message.contains("CRC mismatch")), + "a read over a corrupt sealed page must be refused as corrupt: {refused:?}" + ); + let later = database.entity(&namespace, &entity_identity(7), Arc::new(NeverCancelled)); + assert!( + matches!(later, Err(GraphDbError::Corrupt { .. })), + "once a page fails, the open serves no further reads: {later:?}" + ); + } + + fn corrupt_stored_name(path: &std::path::Path) { + let mut bytes = std::fs::read(path).unwrap(); + let at: Vec = bytes + .windows(8) + .enumerate() + .filter(|(_, window)| *window == b"fn_12345") + .map(|(at, _)| at) + .collect(); + assert_eq!(at.len(), 1, "the name is stored once, in its dictionary"); + bytes[at[0] + 3] ^= 0x01; + std::fs::write(path, &bytes).unwrap(); + } + + /// The read that first touches a corrupt sealed page is refused, not + /// answered without that page: a fresh open's target visit and its copy + /// proof each fail as corrupt on their first read. + #[test] + fn the_first_read_over_a_corrupt_sealed_page_is_refused() { + let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); + let manifest = manifest(30_000, 45_000); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(check).unwrap(); + let namespace = identity.physical_namespace().unwrap(); + let staging = tempfile::tempdir().unwrap(); + build_sealed_container( + SealedRowSource::Manifest(&manifest), + &identity, + staging.path(), + check, + ) + .unwrap(); + let path = staging.path().join(SEALED_STORE_DATABASE_FILE); + corrupt_stored_name(&path); + let open = || { + GraphDb::open_lazy_with_store_state( + sealed_artifact_database_options(path.clone()), + PersistentGraphStoreState::Existing, + ) + .unwrap() + }; + let calls = BTreeSet::from([GraphRelationKind::new("calls").unwrap()]); + + let mut targets = Vec::new(); + let visited = open().visit_outgoing_relation_targets( + &namespace, + &entity_identity(12_344), + &calls, + Arc::new(NeverCancelled), + &mut |target| targets.push(target), + ); + let proof = sealed_copy_proof(&open(), &identity, &expected, check); + assert!( + matches!(visited, Err(GraphDbError::Corrupt { .. })), + "a target visit over a corrupt sealed page must be refused: {visited:?} {targets:?} (copy proof: {proof:?})" + ); + assert!( + targets.is_empty(), + "a refused visit must not hand any target to the visitor: {targets:?}" + ); + assert!( + matches!(proof, Err(GraphDbError::Corrupt { .. })), + "a copy proof over a corrupt sealed page must be refused as corrupt: {proof:?}" + ); + } + + /// An unverified existing generation directory cannot be treated as + /// disposable scratch space. Rebuilding is allowed only after its owner + /// removes the unknown data. + #[test] + fn sealed_build_preserves_an_unverified_occupied_generation() { + let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(2, 1); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(check).unwrap(); + let root = sealed_store_root(&database_path); + let directory = sealed_generation_directory(&root, &identity.physical_namespace().unwrap()); + std::fs::create_dir_all(&directory).unwrap(); + let sentinel = directory.join("foreign-sentinel"); + std::fs::write(&sentinel, b"not owned").unwrap(); + + for foreign_receipt in [false, true] { + if foreign_receipt { + std::fs::write( + directory.join(SEALED_STORE_RECEIPT_FILE), + b"invalid receipt", + ) + .unwrap(); + } + let result = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ); + assert!( + result.is_err(), + "unreceipted or malformed foreign data must refuse rebuilding" + ); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"not owned"); + assert!( + !directory.join(SEALED_STORE_DATABASE_FILE).exists(), + "refused rebuilding must never write a container into foreign data" + ); + } + + std::fs::remove_dir_all(&directory).unwrap(); + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ) + .expect("clearing the unknown artifact allows a verified sealed build"); + assert_eq!(store.recovered_digest(), expected.as_str()); + assert!(directory.join(SEALED_STORE_DATABASE_FILE).is_file()); + } + + #[cfg(unix)] + #[test] + fn retirement_keeps_sealed_bytes_when_reader_close_is_uncertain() { + let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(2, 1); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(check).unwrap(); + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ) + .unwrap(); + let directory = store.directory.clone(); + let container = directory.join(SEALED_STORE_DATABASE_FILE); + let receipt = directory.join(SEALED_STORE_RECEIPT_FILE); + assert!(container.is_file() && receipt.is_file()); + + // Poison the real native reader's engine lock. Its physical close + // now returns DurabilityUncertain; the old discard ignored that error + // and removed the verified directory anyway on Unix. + let reader = Arc::clone(&store.database); + let poisoned = std::thread::spawn(move || { + let _guard = reader.inner.database.write().unwrap(); + panic!("exercise uncertain sealed reader close"); + }); + assert!(poisoned.join().is_err()); + let refused = store.discard(); + assert!( + matches!(refused, Err(GraphDbError::DurabilityUncertain { .. })), + "retirement must surface the failed native close: {refused:?}" + ); + assert!( + container.is_file(), + "uncertain close must preserve sealed bytes" + ); + assert!(receipt.is_file(), "retry must retain its ownership receipt"); + } + + #[test] + fn retirement_accepts_an_already_closed_derived_reader() { + let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(2, 1); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(check).unwrap(); + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ) + .unwrap(); + let directory = store.directory.clone(); + store.database.close().unwrap(); + store.discard().unwrap(); + assert!( + !directory.exists(), + "a cleanly closed reader is reclaimable" + ); + } + + #[test] + fn retirement_does_not_erase_a_prior_uncertain_close() { + let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(2, 1); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(check).unwrap(); + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ) + .unwrap(); + let directory = store.directory.clone(); + let container = directory.join(SEALED_STORE_DATABASE_FILE); + let receipt = directory.join(SEALED_STORE_RECEIPT_FILE); + + // Reproduce the post-failure state of native engine.close(): its + // handle is gone, Closed will be returned on retry, but durability + // remains uncertain. Retirement must never mistake that state for a + // previously successful close and destroy its sealed receipt. + store.database.inner.poisoned.store(true, Ordering::Release); + let first = store.database.close(); + assert!(matches!( + first, + Err(GraphDbError::DurabilityUncertain { .. }) + )); + let retry = store.discard(); + assert!( + matches!(retry, Err(GraphDbError::DurabilityUncertain { .. })), + "a prior uncertain close must not become an unlink: {retry:?}" + ); + assert!(container.is_file() && receipt.is_file()); + } + + #[test] + fn retirement_retries_must_keep_a_reader_with_uncertain_close_registered() { + let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(2, 1); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(check).unwrap(); + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ) + .unwrap(); + let locator = crate::lease::GenerationLocator::new( + identity.projection.clone(), + identity.generation.clone(), + ); + let parent = GraphDb::open(GraphDbOpenOptions { + location: GraphDbLocation::Persistent(database_path.clone()), + expected_format: GraphFormatVersion::current(), + durability: GraphDurability::WalSync, + cancellation: Arc::new(NeverCancelled), + }) + .unwrap(); + parent + .inner + .sealed_generations + .write() + .unwrap() + .insert(locator.clone(), Arc::clone(&store)); + + let container = store.directory.join(SEALED_STORE_DATABASE_FILE); + let receipt = store.directory.join(SEALED_STORE_RECEIPT_FILE); + let held_reader = Arc::clone(store.database()); + store.database.inner.poisoned.store(true, Ordering::Release); + for attempt in 1..=2 { + let retirement = parent.retire_sealed_generation_store(&locator); + assert!( + matches!(&retirement, Err(GraphDbError::DurabilityUncertain { .. })), + "retirement attempt {attempt} must refuse uncertain close: {retirement:?}" + ); + let registered = parent + .sealed_generation_reader(&locator) + .expect("failed close must preserve the registered reader"); + assert!(Arc::ptr_eq(®istered, &store)); + assert!(container.is_file() && receipt.is_file()); + } + assert!(Arc::ptr_eq(&held_reader, store.database())); + } + + #[test] + fn successful_retirement_uninstalls_the_reader_after_removing_artifact() { + let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(2, 1); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(check).unwrap(); + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ) + .unwrap(); + let locator = crate::lease::GenerationLocator::new( + identity.projection.clone(), + identity.generation.clone(), + ); + let parent = GraphDb::open(GraphDbOpenOptions { + location: GraphDbLocation::Persistent(database_path.clone()), + expected_format: GraphFormatVersion::current(), + durability: GraphDurability::WalSync, + cancellation: Arc::new(NeverCancelled), + }) + .unwrap(); + parent + .inner + .sealed_generations + .write() + .unwrap() + .insert(locator.clone(), Arc::clone(&store)); + + parent.retire_sealed_generation_store(&locator).unwrap(); + assert!(parent.sealed_generation_reader(&locator).is_none()); + assert!(!store.directory.exists()); + } + + /// A missing pathname is not evidence that an installed generation's + /// durable bytes were removed. It may have been relocated between + /// registration and the retirement attempt. + #[cfg(unix)] + #[test] + fn registered_retirement_refuses_disappeared_root_or_child_until_restored() { + let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); + for move_root in [true, false] { + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(2, 1); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(check).unwrap(); + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ) + .unwrap(); + let locator = crate::lease::GenerationLocator::new( + identity.projection.clone(), + identity.generation.clone(), + ); + let parent = GraphDb::open(GraphDbOpenOptions { + location: GraphDbLocation::Persistent(database_path.clone()), + expected_format: GraphFormatVersion::current(), + durability: GraphDurability::WalSync, + cancellation: Arc::new(NeverCancelled), + }) + .unwrap(); + parent + .inner + .sealed_generations + .write() + .unwrap() + .insert(locator.clone(), Arc::clone(&store)); + + let root = sealed_store_root(&database_path); + let (original, relocated) = if move_root { + (root, temp.path().join("relocated-sealed-root")) + } else { + (store.directory.clone(), root.join("relocated-sealed-child")) + }; + std::fs::rename(&original, &relocated).unwrap(); + let surviving_receipt = if move_root { + relocated + .join(store.directory.file_name().unwrap()) + .join(SEALED_STORE_RECEIPT_FILE) + } else { + relocated.join(SEALED_STORE_RECEIPT_FILE) + }; + let receipt = std::fs::read(&surviving_receipt).unwrap(); + + let refused = parent.retire_sealed_generation_store(&locator); + assert!( + matches!(&refused, Err(GraphDbError::Unavailable { .. })), + "missing owned artifact must remain pending (root={move_root}): {refused:?}" + ); + assert_eq!( + std::fs::read(&surviving_receipt).unwrap(), + receipt, + "retirement must preserve the relocated sealed receipt" + ); + let retained = parent + .sealed_generation_reader(&locator) + .expect("failed retirement must retain its registered reader"); + assert!(Arc::ptr_eq(&retained, &store)); + drop(retained); + + // Retry against the restored owner must really delete the bytes + // and remove the reader. A permanent refusal cannot pass. + std::fs::rename(&relocated, &original).unwrap(); + parent.retire_sealed_generation_store(&locator).unwrap(); + assert!(parent.sealed_generation_reader(&locator).is_none()); + assert!(!store.directory.exists()); + // Disk-only duplicate notifications remain idempotent. + retire_sealed_directory_checked(&store.directory, &locator, None).unwrap(); + } + } + + #[cfg(unix)] + #[test] + fn sealed_build_refuses_a_foreign_root_symlink() { + let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(2, 1); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(check).unwrap(); + let root = sealed_store_root(&database_path); + let foreign = temp.path().join("foreign"); + std::fs::create_dir(&foreign).unwrap(); + std::fs::write(foreign.join("sentinel"), b"not owned").unwrap(); + std::os::unix::fs::symlink(&foreign, &root).unwrap(); + + let result = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ); + assert!( + result.is_err(), + "a symlinked sealed root must refuse writes" + ); assert_eq!( - left, - vec![std::ffi::OsString::from(SEALED_STORE_DATABASE_FILE)] + std::fs::read(foreign.join("sentinel")).unwrap(), + b"not owned" + ); + assert_eq!( + std::fs::read_dir(&foreign).unwrap().count(), + 1, + "no staged container or receipt may be written into the foreign root" ); + + std::fs::remove_file(&root).unwrap(); + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ) + .expect("a genuine sealed root still supports building"); + assert_eq!(store.recovered_digest(), expected.as_str()); } - /// A reopened sealed generation serves its rows in place: opening it - /// leaves a small fraction of the container resident, a point read - /// answers exactly, and a page that fails verification refuses the read - /// that touched it as corrupt instead of answering without the page. - /// Opening one used to decode every dictionary string and rebuild every - /// id map and property index, leaving more than the container resident. + /// A pre-existing staging symlink must not become a writable artifact + /// root or be unlinked as disposable scratch space. + #[cfg(unix)] #[test] - fn a_reopened_sealed_generation_serves_its_rows_in_place() { + fn sealed_build_refuses_a_foreign_staging_symlink() { let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); - let manifest = manifest(30_000, 45_000); + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(2, 1); let identity = manifest.identity(); - let namespace = identity.physical_namespace().unwrap(); - let staging = tempfile::tempdir().unwrap(); - build_sealed_container( + let expected = manifest.expected_recovered_digest(check).unwrap(); + let root = sealed_store_root(&database_path); + let directory = sealed_generation_directory(&root, &identity.physical_namespace().unwrap()); + let staging = root.join(format!( + ".staging-{}", + directory.file_name().unwrap().to_str().unwrap() + )); + std::fs::create_dir_all(&root).unwrap(); + let foreign = temp.path().join("foreign"); + std::fs::create_dir(&foreign).unwrap(); + std::fs::write(foreign.join("sentinel"), b"unchanged").unwrap(); + std::os::unix::fs::symlink(&foreign, &staging).unwrap(); + + let result = build_or_open_sealed_store( SealedRowSource::Manifest(&manifest), &identity, - staging.path(), + &expected, + &database_path, check, - ) - .unwrap(); - let path = staging.path().join(SEALED_STORE_DATABASE_FILE); - let container = std::fs::metadata(&path).unwrap().len(); - let open = || { - GraphDb::open_lazy_with_store_state( - sealed_artifact_database_options(path.clone()), - PersistentGraphStoreState::Existing, - ) - .unwrap() - }; - let name = GraphPropertyName::new("name").unwrap(); - - let database = open(); - let entity = database - .entity( - &namespace, - &entity_identity(12_345), - Arc::new(NeverCancelled), - ) - .unwrap() - .expect("the sealed entity reads back"); + ); + assert!(result.is_err(), "a symlink must refuse the build"); + assert!( + std::fs::symlink_metadata(&staging) + .unwrap() + .file_type() + .is_symlink() + ); assert_eq!( - entity.properties.get(&name), - Some(&GraphProperty::String("fn_12345".to_owned())) + std::fs::read(foreign.join("sentinel")).unwrap(), + b"unchanged" ); - let resident = database.resident_engine_bytes().unwrap().unwrap(); assert!( - resident * 4 < container, - "opening a {container}-byte sealed container left {resident} bytes resident" + !foreign.join(SEALED_STORE_DATABASE_FILE).exists() + && !foreign.join("sealed.json").exists(), + "the foreign directory must not receive a container or receipt" ); - database.close().unwrap(); - drop(database); + assert!(!directory.exists(), "no generation was installed"); - corrupt_stored_name(&path); + // Removing the foreign link must restore a successful, verified seal. + // A fixture that always refuses to build would not prove the fix. + std::fs::remove_file(&staging).unwrap(); + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ) + .expect("a normal staging directory must still produce a sealed generation"); + assert_eq!(store.recovered_digest(), expected.as_str()); + assert!( + directory.join(SEALED_STORE_DATABASE_FILE).is_file(), + "valid staging must install a verified sealed container" + ); + } - let database = open(); - let refused = database.entity( - &namespace, - &entity_identity(12_345), - Arc::new(NeverCancelled), + /// A pre-existing real staging directory is not disposable merely + /// because its name matches the generation being built. + #[test] + fn sealed_build_preserves_a_foreign_real_staging_directory() { + let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(2, 1); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(check).unwrap(); + let root = sealed_store_root(&database_path); + let directory = sealed_generation_directory(&root, &identity.physical_namespace().unwrap()); + let staging = root.join(format!( + ".staging-{}", + directory.file_name().unwrap().to_str().unwrap() + )); + std::fs::create_dir_all(&staging).unwrap(); + let sentinel = staging.join("foreign-sentinel"); + std::fs::write(&sentinel, b"must survive").unwrap(); + + let result = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, ); assert!( - matches!(&refused, Err(GraphDbError::Corrupt { message }) if message.contains("CRC mismatch")), - "a read over a corrupt sealed page must be refused as corrupt: {refused:?}" + result.is_err(), + "an occupied staging entry must refuse sealing" ); - let later = database.entity(&namespace, &entity_identity(7), Arc::new(NeverCancelled)); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"must survive"); assert!( - matches!(later, Err(GraphDbError::Corrupt { .. })), - "once a page fails, the open serves no further reads: {later:?}" + !staging.join(SEALED_STORE_DATABASE_FILE).exists() + && !staging.join(SEALED_STORE_RECEIPT_FILE).exists(), + "refused sealing must not modify foreign staging data" ); + assert!(!directory.exists(), "no generation may be installed"); + + std::fs::remove_dir_all(&staging).unwrap(); + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ) + .expect("clearing the unknown stage allows a verified seal"); + assert_eq!(store.recovered_digest(), expected.as_str()); + assert!(directory.join(SEALED_STORE_DATABASE_FILE).is_file()); } - fn corrupt_stored_name(path: &std::path::Path) { - let mut bytes = std::fs::read(path).unwrap(); - let at: Vec = bytes - .windows(8) - .enumerate() - .filter(|(_, window)| *window == b"fn_12345") - .map(|(at, _)| at) - .collect(); - assert_eq!(at.len(), 1, "the name is stored once, in its dictionary"); - bytes[at[0] + 3] ^= 0x01; - std::fs::write(path, &bytes).unwrap(); + /// A store remount invokes the real startup sweep while a producer is + /// still streaming rows. The sweep must preserve its owner-bound staging + /// directory, or the builder can no longer install the verified artifact. + #[test] + fn remount_preserves_live_sealed_staging_and_seal_completes() { + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(48, 24); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(&|| Ok(())).unwrap(); + let root = sealed_store_root(&database_path); + let directory = sealed_generation_directory(&root, &identity.physical_namespace().unwrap()); + let staging = root.join(format!( + ".staging-{}", + directory.file_name().unwrap().to_str().unwrap() + )); + let remounted = std::cell::Cell::new(false); + let check = || { + if !remounted.replace(true) { + assert!(staging.join(SEALED_STAGING_OWNER_FILE).is_file()); + let remount = open_source(&database_path); + drop(remount); + assert!( + staging.join(SEALED_STAGING_OWNER_FILE).is_file(), + "remount must not reclaim a live producer's staged container" + ); + } + Ok(()) + }; + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + &check, + ) + .expect("live seal must finish after a store remount"); + assert!( + remounted.get(), + "remount must run inside the row-copy callback" + ); + assert!(directory.join(SEALED_STORE_DATABASE_FILE).is_file()); + assert_eq!(store.recovered_digest(), expected.as_str()); + assert!(!staging.exists()); } - /// The read that first touches a corrupt sealed page is refused, not - /// answered without that page: a fresh open's target visit and its copy - /// proof each fail as corrupt on their first read. + /// Kill the real builder during row preparation, before sealed.json is + /// published. Its early receipt must let a later store-open sweep reclaim + /// the crashed stage without touching foreign staging data. #[test] - fn the_first_read_over_a_corrupt_sealed_page_is_refused() { - let check: &dyn Fn() -> Result<(), GraphDbError> = &|| Ok(()); - let manifest = manifest(30_000, 45_000); + fn crashed_seal_before_final_receipt_is_reclaimed_and_can_retry() { + const CHILD_ROOT: &str = "TRACEDECAY_TEST_CRASHED_SEAL_ROOT"; + const CRASHED: i32 = 43; + if let Some(root) = std::env::var_os(CHILD_ROOT) { + let database_path = std::path::PathBuf::from(root).join("source.grafeo"); + let manifest = manifest(24, 12); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(&|| Ok(())).unwrap(); + let exit_during_copy = || -> Result<(), GraphDbError> { + std::process::exit(CRASHED); + }; + let _ = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + &exit_during_copy, + ); + panic!("the row stream did not invoke its cancellation callback"); + } + + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(24, 12); let identity = manifest.identity(); - let expected = manifest.expected_recovered_digest(check).unwrap(); - let namespace = identity.physical_namespace().unwrap(); - let staging = tempfile::tempdir().unwrap(); - build_sealed_container( + let expected = manifest.expected_recovered_digest(&|| Ok(())).unwrap(); + let root = sealed_store_root(&database_path); + let directory = sealed_generation_directory(&root, &identity.physical_namespace().unwrap()); + let staging = root.join(format!( + ".staging-{}", + directory.file_name().unwrap().to_str().unwrap() + )); + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg(std::thread::current().name().unwrap()) + .env(CHILD_ROOT, temp.path()) + .output() + .unwrap(); + assert_eq!(child.status.code(), Some(CRASHED), "{child:?}"); + assert!(staging.join(SEALED_STAGING_OWNER_FILE).is_file()); + assert!(!staging.join(SEALED_STORE_RECEIPT_FILE).exists()); + + let foreign = root.join(format!(".staging-{}", "0".repeat(64))); + std::fs::create_dir(&foreign).unwrap(); + std::fs::write(foreign.join("sentinel"), b"foreign").unwrap(); + sweep_abandoned_sealed_staging(&database_path); + assert!( + !staging.exists(), + "the crashed but proven stage must be reclaimed" + ); + assert_eq!(std::fs::read(foreign.join("sentinel")).unwrap(), b"foreign"); + + let (store, _) = build_or_open_sealed_store( SealedRowSource::Manifest(&manifest), &identity, - staging.path(), - check, + &expected, + &database_path, + &|| Ok(()), ) .unwrap(); - let path = staging.path().join(SEALED_STORE_DATABASE_FILE); - corrupt_stored_name(&path); - let open = || { - GraphDb::open_lazy_with_store_state( - sealed_artifact_database_options(path.clone()), - PersistentGraphStoreState::Existing, - ) - .unwrap() - }; - let calls = BTreeSet::from([GraphRelationKind::new("calls").unwrap()]); + assert_eq!(store.recovered_digest(), expected.as_str()); + assert!(directory.join(SEALED_STORE_DATABASE_FILE).is_file()); + } - let mut targets = Vec::new(); - let visited = open().visit_outgoing_relation_targets( - &namespace, - &entity_identity(12_344), - &calls, - Arc::new(NeverCancelled), - &mut |target| targets.push(target), - ); - let proof = sealed_copy_proof(&open(), &identity, &expected, check); - assert!( - matches!(visited, Err(GraphDbError::Corrupt { .. })), - "a target visit over a corrupt sealed page must be refused: {visited:?} {targets:?} (copy proof: {proof:?})" + /// The failed-builder cleanup must not delete a foreign replacement of a + /// staging pathname after the owner marker was written. + #[cfg(unix)] + #[test] + fn interrupted_seal_preserves_a_substituted_staging_directory() { + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("source.grafeo"); + let manifest = manifest(24, 12); + let identity = manifest.identity(); + let expected = manifest.expected_recovered_digest(&|| Ok(())).unwrap(); + let root = sealed_store_root(&database_path); + let directory = sealed_generation_directory(&root, &identity.physical_namespace().unwrap()); + let staging = root.join(format!( + ".staging-{}", + directory.file_name().unwrap().to_str().unwrap() + )); + let moved = root.join("original-stage"); + let swapped = std::cell::Cell::new(false); + let interrupt = || { + if !swapped.replace(true) { + std::fs::rename(&staging, &moved).unwrap(); + std::fs::create_dir(&staging).unwrap(); + std::fs::write(staging.join("sentinel"), b"foreign replacement").unwrap(); + } + Err(GraphDbError::Cancelled) + }; + let result = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + &interrupt, ); assert!( - targets.is_empty(), - "a refused visit must not hand any target to the visitor: {targets:?}" + matches!(&result, Err(GraphDbError::Cancelled)), + "{result:?}" ); - assert!( - matches!(proof, Err(GraphDbError::Corrupt { .. })), - "a copy proof over a corrupt sealed page must be refused as corrupt: {proof:?}" + assert_eq!( + std::fs::read(staging.join("sentinel")).unwrap(), + b"foreign replacement", + "cleanup must not erase the substituted directory" ); + assert!(moved.join(SEALED_STAGING_OWNER_FILE).is_file()); + assert!(!directory.exists()); + + std::fs::remove_dir_all(&staging).unwrap(); + std::fs::remove_dir_all(&moved).unwrap(); + let (store, _) = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + &|| Ok(()), + ) + .unwrap(); + assert_eq!(store.recovered_digest(), expected.as_str()); } /// The sealed container is written once, complete, after every row has @@ -3227,11 +4916,32 @@ mod build_tests { "an interrupted direct build must leave neither its staging directory nor an artifact" ); - // A crash image: the process died after creating the staging - // directory and part of a container. The next attempt owns that - // directory and rebuilds from the manifest. + // A crash before the receipt exists leaves no deletion authority. + // A retry must preserve the unverified staging bytes, not silently + // treat a name collision as proof that the directory belongs to us. std::fs::create_dir_all(&staging).unwrap(); - std::fs::write(staging.join(SEALED_STORE_DATABASE_FILE), b"torn container").unwrap(); + let torn_container = staging.join(SEALED_STORE_DATABASE_FILE); + std::fs::write(&torn_container, b"torn container").unwrap(); + let refused = build_or_open_sealed_store( + SealedRowSource::Manifest(&manifest), + &identity, + &expected, + &database_path, + check, + ); + assert!( + matches!(&refused, Err(GraphDbError::Unavailable { .. })), + "an unreceipted staging collision must fail closed: {refused:?}" + ); + assert_eq!(std::fs::read(&torn_container).unwrap(), b"torn container"); + assert!( + !directory.exists(), + "refused build must not publish a generation" + ); + + // The test fixture explicitly removes its unowned crash image. + // Only then may the same build succeed and prove its normal payload. + std::fs::remove_dir_all(&staging).unwrap(); let (direct, staging_proof) = build_or_open_sealed_store( SealedRowSource::Manifest(&manifest), &identity, @@ -3248,7 +4958,7 @@ mod build_tests { assert_eq!((direct.entity_count, direct.relation_count), (2_000, 3_000)); assert!( !staging.exists(), - "the crash image must be replaced, not kept" + "successful build must consume its staging directory" ); let _ = direct.database().close(); let direct_bytes = std::fs::read(&artifact).unwrap(); diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/runtime_contract.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/runtime_contract.rs index 123b4e9f3f7..a34e05465fd 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/runtime_contract.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/runtime_contract.rs @@ -5,6 +5,7 @@ use std::time::Duration; use grafeo_common::types::Value; use tempfile::TempDir; +use tracedecay_domain::canonical_text::sha256_hex; use tracedecay_graph_db::{ GraphBudgetKind, GraphCancellation, GraphDbError, GraphDbLeaseV1, GraphDbOwner, GraphEntity, GraphEntityId, GraphFormatVersion, GraphIdempotencyKey, GraphLabel, GraphMutation, @@ -1026,7 +1027,7 @@ fn wrong_tracedecay_format_requires_reset() { } #[test] -fn superseded_format_store_is_rebuilt_fresh_keeping_only_current_sealed_generations() { +fn superseded_format_store_rebuild_removes_proven_old_seals_but_keeps_unproven() { let temp = TempDir::new().unwrap(); let path = graph_path(temp.path()); // Format 4 kept a relation's payload properties on its locator as well as @@ -1063,17 +1064,52 @@ fn superseded_format_store_is_rebuilt_fresh_keeping_only_current_sealed_generati ) .unwrap(); raw.close().unwrap(); - let sealed_generation = temp.path().join("graph.sealed").join("0".repeat(64)); + // The on-disk name is the digest of the receipt's JSON identity tuple. + let sealed_generation_name = sha256_hex(br#"["project","code","g0"]"#); + let sealed_generation = temp + .path() + .join("graph.sealed") + .join(&sealed_generation_name); std::fs::create_dir_all(&sealed_generation).unwrap(); std::fs::write( sealed_generation.join("generation.grafeo"), b"format 4 bytes", ) .unwrap(); + // A prior-format sealed directory is deletable only when its receipt + // proves both the old format and the matching physical namespace. + std::fs::write( + sealed_generation.join("sealed.json"), + serde_json::json!({ + "version": 1, + "form": "compact", + "namespace": "project", + "projection": "code", + "generation": "g0", + "physical_namespace": format!("generation:{sealed_generation_name}"), + "recovered_digest": "sha256:old", + "entities": 1, + "relations": 0, + "graph_format": previous_format as u32, + }) + .to_string(), + ) + .unwrap(); + + // An unverified artifact may belong to a future build. A format reset + // cannot infer its age or ownership from a directory name alone. + let unproven_generation = temp.path().join("graph.sealed").join("2".repeat(64)); + std::fs::create_dir_all(&unproven_generation).unwrap(); + std::fs::write( + unproven_generation.join("generation.grafeo"), + b"unknown sealed bytes", + ) + .unwrap(); // A generation this build sealed before the staging container was // replaced, as a publication racing the rebuild leaves it. - let current_generation = temp.path().join("graph.sealed").join("1".repeat(64)); + let current_name = sha256_hex(br#"["project","code","g1"]"#); + let current_generation = temp.path().join("graph.sealed").join(¤t_name); std::fs::create_dir_all(¤t_generation).unwrap(); std::fs::write( current_generation.join("sealed.json"), @@ -1083,7 +1119,7 @@ fn superseded_format_store_is_rebuilt_fresh_keeping_only_current_sealed_generati "namespace": "project", "projection": "code", "generation": "g1", - "physical_namespace": format!("generation:{}", "1".repeat(64)), + "physical_namespace": format!("generation:{current_name}"), "recovered_digest": "sha256:1", "entities": 1, "relations": 0, @@ -1096,6 +1132,10 @@ fn superseded_format_store_is_rebuilt_fresh_keeping_only_current_sealed_generati let (_registered, db) = RegisteredGraph::open_lease(temp.path()).unwrap(); assert!(!sealed_generation.exists()); + assert!( + unproven_generation.join("generation.grafeo").exists(), + "an unverified generation must survive a format reset" + ); assert!(current_generation.join("sealed.json").exists()); assert_eq!( db.entity(&namespace(), &entity_id("stale"), live()) diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/sealed_store.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/sealed_store.rs index 3bb165ee0f6..ec402598a0f 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/sealed_store.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/sealed_store.rs @@ -1187,8 +1187,8 @@ fn restart_recovery_adopts_or_discards_the_on_disk_artifact() { assert!(registered.close().unwrap()); drop(registered); - // Tamper with the receipt: recovery must discard the artifact and serve - // from the staging database. + // Tamper with the receipt: recovery must refuse adoption and serve from + // staging, without deleting bytes that no longer prove our ownership. let root = sealed_store_root(temp.path()); let mut tampered = None; for entry in std::fs::read_dir(&root).unwrap().map(Result::unwrap) { @@ -1200,6 +1200,7 @@ fn restart_recovery_adopts_or_discards_the_on_disk_artifact() { } } let tampered = tampered.expect("the sealed artifact directory must exist"); + let tampered_receipt = std::fs::read(tampered.join("sealed.json")).unwrap(); let registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); let (control, probe) = control_and_probe(); let context = GraphPublicationOperationContextV1::new(&control, &probe).unwrap(); @@ -1217,18 +1218,22 @@ fn restart_recovery_adopts_or_discards_the_on_disk_artifact() { "a tampered artifact must not be adopted" ); assert!( - !tampered.exists(), - "a tampered artifact must be discarded from disk" + tampered.is_dir(), + "recovery must preserve an artifact with an unverified receipt" + ); + assert_eq!( + std::fs::read(tampered.join("sealed.json")).unwrap(), + tampered_receipt, + "recovery must not modify the unverified receipt" ); assert_snapshot_reads(&snapshot, &identity, "durable"); } -/// A seal that died between container write and rename leaves -/// `.staging-` under the sealed root. Nothing reads it, so the next -/// open of the store removes every such directory while leaving installed -/// artifacts untouched. +/// A copied installed receipt is not a staging-owner receipt. Recovery must +/// preserve that lookalike alongside unreceipted, mismatched, oversized and +/// linked entries, rather than deleting them based on their names. #[test] -fn reopening_the_store_sweeps_staging_left_by_an_interrupted_seal() { +fn reopening_the_store_reclaims_only_receipted_staging() { let temp = TempDir::new().unwrap(); let registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); let mut authority = RelationalAuthority::default(); @@ -1254,25 +1259,62 @@ fn reopening_the_store_sweeps_staging_left_by_an_interrupted_seal() { assert!(registered.close().unwrap()); drop(registered); - // Two interrupted seals: one of a generation that never installed and one - // whose digest matches the installed artifact but was started again. let root = sealed_store_root(temp.path()); let installed = std::fs::read_dir(&root) .unwrap() .map(Result::unwrap) .map(|entry| entry.path()) .find(|path| path.join("sealed.json").is_file()) - .expect("the sealed artifact directory must exist"); - let abandoned = [ - root.join(format!(".staging-{}", "c".repeat(64))), - root.join(format!( - ".staging-{}", - installed.file_name().unwrap().to_str().unwrap() - )), - ]; - for staging in &abandoned { - std::fs::create_dir_all(staging).unwrap(); - std::fs::write(staging.join("generation.grafeo"), b"partial container").unwrap(); + .expect("the installed sealed artifact directory must exist"); + let receipt = std::fs::read(installed.join("sealed.json")).unwrap(); + let installed_name = installed.file_name().unwrap().to_str().unwrap(); + let verified = root.join(format!(".staging-{installed_name}")); + std::fs::create_dir(&verified).unwrap(); + std::fs::copy( + installed.join("generation.grafeo"), + verified.join("generation.grafeo"), + ) + .unwrap(); + std::fs::write(verified.join("sealed.json"), &receipt).unwrap(); + + let unowned = root.join(format!(".staging-{}", "c".repeat(64))); + std::fs::create_dir(&unowned).unwrap(); + std::fs::write(unowned.join("generation.grafeo"), b"foreign bytes").unwrap(); + let mismatched = root.join(format!(".staging-{}", "d".repeat(64))); + std::fs::create_dir(&mismatched).unwrap(); + std::fs::write(mismatched.join("sealed.json"), &receipt).unwrap(); + std::fs::write(mismatched.join("sentinel"), b"wrong identity").unwrap(); + // A copied receipt whose physical namespace was rewritten to fit the + // staging filename still does not bind the original logical generation. + let forged_name = "a".repeat(64); + let forged = root.join(format!(".staging-{forged_name}")); + std::fs::create_dir(&forged).unwrap(); + let mut forged_receipt: serde_json::Value = serde_json::from_slice(&receipt).unwrap(); + forged_receipt["physical_namespace"] = + serde_json::Value::String(format!("generation:{forged_name}")); + let forged_bytes = serde_json::to_vec(&forged_receipt).unwrap(); + std::fs::write(forged.join("sealed.json"), &forged_bytes).unwrap(); + std::fs::write(forged.join("sentinel"), b"forged receipt").unwrap(); + let malformed = root.join(format!(".staging-{}", "b".repeat(64))); + std::fs::create_dir(&malformed).unwrap(); + std::fs::write(malformed.join("sealed.json"), b"not-json").unwrap(); + std::fs::write(malformed.join("sentinel"), b"malformed receipt").unwrap(); + let oversized = root.join(format!(".staging-{}", "e".repeat(64))); + std::fs::create_dir(&oversized).unwrap(); + std::fs::write(oversized.join("sealed.json"), vec![b'x'; 128 * 1024]).unwrap(); + std::fs::write(oversized.join("sentinel"), b"too large").unwrap(); + let unrelated = root.join(".staging-notes"); + std::fs::create_dir(&unrelated).unwrap(); + std::fs::write(unrelated.join("notes"), b"keep").unwrap(); + + #[cfg(unix)] + let outside = TempDir::new().unwrap(); + #[cfg(unix)] + let linked = root.join(format!(".staging-{}", "f".repeat(64))); + #[cfg(unix)] + { + std::fs::write(outside.path().join("sentinel"), b"outside").unwrap(); + std::os::unix::fs::symlink(outside.path(), &linked).unwrap(); } let registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); @@ -1287,27 +1329,76 @@ fn reopening_the_store_sweeps_staging_left_by_an_interrupted_seal() { &record.publication.key.projection, ) .unwrap(); - for staging in &abandoned { + + assert!( + verified.is_dir(), + "a copied final receipt must not authorize abandoned-stage cleanup" + ); + assert_eq!( + std::fs::read(verified.join("sealed.json")).unwrap(), + receipt, + "recovery must preserve the copied final receipt byte-for-byte" + ); + assert_eq!( + std::fs::read(unowned.join("generation.grafeo")).unwrap(), + b"foreign bytes", + "missing receipt is not deletion authority" + ); + assert_eq!( + std::fs::read(mismatched.join("sealed.json")).unwrap(), + receipt, + "a receipt for a different generation cannot authorize cleanup" + ); + assert_eq!( + std::fs::read(mismatched.join("sentinel")).unwrap(), + b"wrong identity" + ); + assert_eq!( + std::fs::read(forged.join("sealed.json")).unwrap(), + forged_bytes, + "a rewritten physical namespace is not generation ownership" + ); + assert_eq!( + std::fs::read(forged.join("sentinel")).unwrap(), + b"forged receipt" + ); + assert_eq!( + std::fs::read(malformed.join("sentinel")).unwrap(), + b"malformed receipt" + ); + assert_eq!( + std::fs::read(oversized.join("sentinel")).unwrap(), + b"too large", + "oversized receipts must be refused without recursive removal" + ); + assert_eq!(std::fs::read(unrelated.join("notes")).unwrap(), b"keep"); + #[cfg(unix)] + { assert!( - !staging.exists(), - "opening the store removes abandoned staging {}", - staging.display() + std::fs::symlink_metadata(&linked) + .unwrap() + .file_type() + .is_symlink(), + "a linked staging entry must not be followed or removed" + ); + assert_eq!( + std::fs::read(outside.path().join("sentinel")).unwrap(), + b"outside" ); } assert!( installed.join("sealed.json").is_file(), - "the installed sealed artifact is untouched by the sweep" + "the installed generation must remain untouched" ); assert!(snapshot.serves_from_sealed_store()); assert_snapshot_reads(&snapshot, &identity, "durable"); } -/// The sealed census splits the sealed root into what serves and what does -/// not: the head's artifact, a superseded generation's artifact, and staging -/// an interrupted seal left behind are each counted where they belong, and a -/// directory without a readable receipt is reported rather than classed. +/// The sealed census splits the sealed root into verified head and +/// superseded artifacts. Unreceipted staging and unrelated directories remain +/// unrecognized rather than falsely counted as reclaimable garbage. #[test] -fn sealed_census_separates_heads_from_superseded_and_abandoned_bytes() { +fn sealed_census_separates_heads_and_preserves_unproven_staging() { let temp = TempDir::new().unwrap(); let registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); let mut authority = RelationalAuthority::default(); @@ -1363,9 +1454,9 @@ fn sealed_census_separates_heads_from_superseded_and_abandoned_bytes() { census.superseded_count, 1, "g1 is sealed but no longer the head" ); - assert_eq!(census.abandoned_staging_count, 1); - assert_eq!(census.abandoned_staging_bytes, 4096); - assert_eq!(census.unrecognized_count, 1); + assert_eq!(census.abandoned_staging_count, 0); + assert_eq!(census.abandoned_staging_bytes, 0); + assert_eq!(census.unrecognized_count, 2); assert!(census.head_bytes > 0 && census.superseded_bytes > 0); // With no heads journaled every sealed artifact reads as superseded, and @@ -1517,6 +1608,105 @@ fn retirement_deletes_the_superseded_sealed_artifact() { ); } +/// A real sequence of five published sealed replay generations must retire +/// all four superseded artifacts independently, not merely the oldest one. +/// Each retirement goes through the same production registry API used by +/// code-generation retention, and the newest reader stays usable throughout. +#[test] +fn five_sealed_replay_generations_retire_to_the_live_head() { + let temp = TempDir::new().unwrap(); + let registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); + let mut authority = RelationalAuthority::default(); + let identity = projection("sealed-store:five-generation-retire", "code"); + let mut prior_head = None; + let mut superseded = Vec::new(); + let mut serving = None; + + for (ordinal, digit) in (1..=5).zip(['1', '2', '3', '4', '5']) { + let generation = format!("five-retire-g{ordinal}"); + let manifest = rich_manifest(identity.clone(), &generation, &format!("marker-{ordinal}")); + let record = stage_sealed_manifest( + &mut authority, + ®istered.binding, + &manifest, + &format!("publish:{generation}"), + prior_head.take(), + digit, + ); + let commit = publish_sealed(®istered, temp.path(), &mut authority, &record, &manifest); + prior_head = Some(commit.head.clone()); + assert!( + receipt_for_generation(temp.path(), &generation).is_some(), + "each published generation must have its own sealed artifact" + ); + if ordinal == 5 { + serving = Some(commit); + } else { + superseded.push(( + generation, + manifest.identity(), + CodeGenerationId::new(format!("code-generation.{}", manifest.generation.as_str())) + .unwrap(), + SealedGraphStateDigest::try_from(format!( + "sha256:{}", + digit.to_string().repeat(64) + )) + .unwrap(), + )); + drop(commit); + } + } + + assert_eq!(superseded.len(), 4); + assert_eq!( + sealed_receipts(temp.path()).len(), + 5, + "five distinct generations must exist before retirement" + ); + let serving = serving.unwrap(); + assert_snapshot_reads(&serving.snapshot, &identity, "marker-5"); + + let database = registered + .registry + .resolve(registration(registered.binding.clone(), temp.path())) + .unwrap(); + let (control, probe) = control_and_probe(); + let context = GraphPublicationOperationContextV1::new(&control, &probe).unwrap(); + for (generation, manifest_identity, code_generation, sealed_digest) in superseded { + assert!(matches!( + registered + .registry + .retire_one_code_generation_replay( + registration(registered.binding.clone(), temp.path()), + &mut authority, + &context, + &code_generation, + &sealed_digest, + ) + .unwrap(), + GraphReplayCollectionOutcome::Retired(_) + )); + assert!( + receipt_for_generation(temp.path(), &generation).is_none(), + "retired generation {generation} must be absent from disk" + ); + assert!( + !database + .is_generation_sealed_only(&manifest_identity) + .unwrap(), + "retired generation {generation} must leave sealed-only state" + ); + assert_snapshot_reads(&serving.snapshot, &identity, "marker-5"); + } + assert_eq!( + sealed_receipts(temp.path()).len(), + 1, + "only the live sealed generation should remain" + ); + assert!(receipt_for_generation(temp.path(), "five-retire-g5").is_some()); + assert_snapshot_reads(&serving.snapshot, &identity, "marker-5"); +} + /// A live direct-sealed reader (recovered through /// `recover_verified_sealed_snapshot`, which bypasses the staging database's /// verified-generation state) must gate retirement of its generation exactly @@ -2364,14 +2554,63 @@ fn spilled_rows_refuse_conflicting_repeats_and_dangling_endpoints() { /// own live publisher's and stays. #[test] fn store_open_sweeps_row_spills_abandoned_by_another_process() { + const CHILD_ROOT: &str = "TRACEDECAY_TEST_ABANDONED_GRAPH_SPILL"; + const SPILLED: i32 = 42; + let identity = projection("code", "store-open-spill"); + if let Some(root) = std::env::var_os(CHILD_ROOT) { + let root = PathBuf::from(root); + let registered = RegisteredGraph::new_mounted(&root).unwrap(); + let spill = registered + .registry + .generation_row_spill(registration(registered.binding.clone(), &root), identity) + .unwrap(); + std::fs::write(spill.attachment_path(), b"abandoned").unwrap(); + std::process::exit(SPILLED); + } + let temp = TempDir::new().unwrap(); + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg(std::thread::current().name().unwrap()) + .env(CHILD_ROOT, temp.path()) + .output() + .unwrap(); + assert_eq!(child.status.code(), Some(SPILLED), "{child:?}"); let root = sealed_store_root(temp.path()); - std::fs::create_dir_all(root.join(".rows-4194305-0")).unwrap(); - std::fs::write(root.join(".rows-4194305-0/entities-0.run"), b"abandoned").unwrap(); - let own = format!(".rows-{}-999999", std::process::id()); - std::fs::create_dir_all(root.join(&own)).unwrap(); + let abandoned = row_spills(temp.path()); + assert_eq!(abandoned.len(), 1); + let abandoned = root.join(&abandoned[0]); + let registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); + assert!(!abandoned.exists()); + let live_spill = registered + .registry + .generation_row_spill( + registration(registered.binding.clone(), temp.path()), + identity, + ) + .unwrap(); + let attachment = live_spill.attachment_path(); + std::fs::write(&attachment, b"live peer attachment").unwrap(); + drop(registered); + // A different producer's name is not authority to reclaim foreign bytes. + // Even a well-shaped abandoned-spill entry with a wrong receipt survives + // store opening, while the authentic abandoned spill above was reclaimed. + std::fs::create_dir(&abandoned).unwrap(); + std::fs::write(abandoned.join(".row-spill-owner-v1"), b"wrong owner").unwrap(); + std::fs::write(abandoned.join("foreign"), b"preserve").unwrap(); + let foreign = ".rows-notes"; + std::fs::create_dir(root.join(foreign)).unwrap(); + std::fs::write(root.join(foreign).join("notes"), b"keep").unwrap(); let _registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); - assert_eq!(row_spills(temp.path()), vec![own]); + assert_eq!( + std::fs::read(root.join(foreign).join("notes")).unwrap(), + b"keep" + ); + assert_eq!( + std::fs::read(abandoned.join("foreign")).unwrap(), + b"preserve" + ); + assert_eq!(std::fs::read(attachment).unwrap(), b"live peer attachment"); } diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs index 01c55d10f34..fa83a05c605 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs @@ -909,8 +909,10 @@ fn release_sweep_retries_idle_sealed_reader_hibernation() { let root = temp.path().to_path_buf(); let projection = replay.publication.key.projection.clone(); let (result_tx, result_rx) = mpsc::channel(); + let (started_tx, started_rx) = mpsc::channel(); let sweep = thread::spawn(move || { let (control, probe) = control_and_probe(); + started_tx.send(()).unwrap(); let result = registry.release_sealed_generation_staging_rows( registration(binding, &root), &mut authority, @@ -920,6 +922,10 @@ fn release_sweep_retries_idle_sealed_reader_hibernation() { result_tx.send(()).unwrap(); (authority, result) }); + // Start the bound only after the sweep thread has actually been + // scheduled; otherwise the test measures worker startup under CI load, + // not whether a live sealed reader blocks staging cleanup. + started_rx.recv().unwrap(); let busy_completion = result_rx.recv_timeout(Duration::from_millis(100)); let engines_during_read = database.sealed_generation_engine_census(); release.wait(); diff --git a/crates/tracedecay-private-fs/src/capability_dir.rs b/crates/tracedecay-private-fs/src/capability_dir.rs index c0c846fb8f4..734a209b8ff 100644 --- a/crates/tracedecay-private-fs/src/capability_dir.rs +++ b/crates/tracedecay-private-fs/src/capability_dir.rs @@ -5,13 +5,22 @@ //! Every mutation is relative to an open `Dir` handle so a parent path //! swapped for a symlink cannot redirect the operation. -use std::ffi::OsStr; -use std::io; +#[cfg(unix)] +use std::ffi::CString; +use std::ffi::{OsStr, OsString}; +use std::io::{self, Write}; +#[cfg(unix)] +use std::os::fd::{AsRawFd, FromRawFd}; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; -#[cfg(windows)] use cap_fs_ext::DirExt; #[cfg(not(windows))] use cap_fs_ext::OpenOptionsMaybeDirExt; +#[cfg(windows)] +use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt}; use cap_std::fs::{Dir, File, OpenOptions}; /// How many times a create-or-open that reports the entry missing is asked @@ -72,8 +81,6 @@ pub fn rename_noreplace( ) -> io::Result<()> { #[cfg(all(target_os = "linux", target_env = "gnu"))] { - use std::os::fd::AsRawFd; - crate::rename_noreplace::rename_noreplace_at( from_parent.as_raw_fd(), from, @@ -83,8 +90,6 @@ pub fn rename_noreplace( } #[cfg(target_os = "macos")] { - use std::os::fd::AsRawFd; - crate::rename_noreplace::rename_noreplace_at( from_parent.as_raw_fd(), from, @@ -212,6 +217,538 @@ pub fn sync_directory(directory: &Dir) -> io::Result<()> { } } +fn single_component(name: &OsStr) -> io::Result<()> { + let mut parts = Path::new(name).components(); + if !matches!(parts.next(), Some(std::path::Component::Normal(_))) || parts.next().is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "directory operation requires one entry name", + )); + } + Ok(()) +} + +/// Persist a new producer-owned staging receipt through a held directory. +/// +/// An exclusive marker creation is the only authority for removing an +/// incomplete marker. On write/sync failure, remove only that marker and +/// attempt a nonrecursive removal of the same held directory. A substituted +/// directory containing other bytes must survive. The caller must disable +/// pathname-based temporary-directory cleanup before invoking this operation. +pub fn write_exclusive_pending_receipt_nofollow( + directory_path: &Path, + filename: &OsStr, + contents: &[u8], +) -> io::Result<()> { + single_component(filename)?; + let directory = open_root_nofollow(directory_path)?; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + let mut marker = directory.open_with(filename, &options)?; + #[cfg(unix)] + let marker_identity = { + use cap_std::fs::MetadataExt; + marker + .metadata() + .ok() + .map(|metadata| (metadata.dev(), metadata.ino())) + }; + let written = marker.write_all(contents).and_then(|()| marker.sync_all()); + drop(marker); + if let Err(error) = written { + // Keep the original write failure. A different marker entry is not + // ours to delete, and a nonempty substituted directory is not ours + // to remove. Neither cleanup operation follows an ambient pathname. + #[cfg(unix)] + let same_marker = { + use cap_std::fs::MetadataExt; + marker_identity.is_some_and(|identity| { + directory + .symlink_metadata(filename) + .ok() + .is_some_and(|metadata| { + metadata.is_file() && (metadata.dev(), metadata.ino()) == identity + }) + }) + }; + #[cfg(not(unix))] + let same_marker = false; + if same_marker { + if let Err(cleanup) = directory.remove_file(filename) { + tracing::warn!(%cleanup, "pending owner marker could not be removed"); + } else if let Err(cleanup) = directory.remove_open_dir() { + tracing::warn!(%cleanup, "pending owner directory was not empty"); + } + } + return Err(error); + } + Ok(()) +} + +/// Publish a rebuildable marker through one held directory. Replacing the +/// pathname after acquisition cannot redirect its bytes into a foreign tree. +pub fn atomic_replace_child_file_nofollow( + directory_path: &Path, + filename: &OsStr, + contents: &[u8], +) -> io::Result<()> { + single_component(filename)?; + let directory = open_root_nofollow(directory_path)?; + atomic_replace_file_in_dir(&directory, filename, contents) +} + +fn atomic_replace_file_in_dir( + directory: &Dir, + filename: &OsStr, + contents: &[u8], +) -> io::Result<()> { + // An accelerator may replace a regular cache file, but cannot take + // ownership of an unrelated symlink, directory, FIFO or device entry. + // Check relative to the held parent, never through an ambient pathname. + fn regular_or_missing(directory: &Dir, filename: &OsStr) -> io::Result<()> { + match directory.symlink_metadata(filename) { + Ok(metadata) if metadata.file_type().is_file() => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "sealed proof destination is not a regular file", + )), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } + } + + regular_or_missing(directory, filename)?; + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + let (temporary, mut file) = (0..8) + .find_map(|_| { + let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let temporary = OsString::from(format!( + ".sealed-proof-{}-{:016x}-{sequence:016x}.tmp", + std::process::id(), + crate::process_instance_nonce(), + )); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + match directory.open_with(&temporary, &options) { + Ok(file) => Some(Ok((temporary, file))), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => None, + Err(error) => Some(Err(error)), + } + }) + .transpose()? + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::AlreadyExists, + "could not exclusively stage a sealed proof marker", + ) + })?; + let persisted = file.write_all(contents).and_then(|()| file.sync_all()); + drop(file); + let result = persisted + // Recheck before publishing in case the entry changed during staging. + .and_then(|()| regular_or_missing(directory, filename)) + .and_then(|()| replace_file(directory, &temporary, filename)) + .and_then(|()| match sync_directory(directory) { + Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()), + result => result, + }); + if result.is_err() { + let _ = directory.remove_file(&temporary); + } + result +} + +fn open_root_nofollow(root_path: &Path) -> io::Result { + let parent_path = root_path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "sealed root has no parent"))?; + let root_name = root_path + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "sealed root has no name"))?; + let parent = Dir::open_ambient_dir(parent_path, cap_std::ambient_authority())?; + parent.open_dir_nofollow(root_name) +} + +/// Install a staged generation without replacing even an empty destination +/// directory. The final component of the sealed root and the staging entry +/// must be real directories; a link or an occupied generation is not a +/// recoverable staging collision. +/// +/// The caller still owns the responsibility for the identity of the store +/// ancestors, which this pathname API cannot establish. +pub fn install_child_directory_noreplace_nofollow( + root_path: &Path, + staging_name: &OsStr, + generation_name: &OsStr, +) -> io::Result<()> { + single_component(staging_name)?; + single_component(generation_name)?; + let root = open_root_nofollow(root_path)?; + let staging = root.open_dir_nofollow(staging_name)?; + // Reject a linked staging entry, then close the extra directory handle + // before publication for platforms that disallow renaming open children. + drop(staging); + rename_noreplace(&root, staging_name, &root, generation_name) +} + +/// Bound deduplication metadata independently of the size of the store. +/// The Doctor caller reports partial coverage instead of trusting a census +/// whose inode inventory would otherwise grow without limit. +const MAX_SEALED_CENSUS_INODES: usize = 65_536; + +/// Census proof-bound children through held root/child capabilities, not +/// ambient paths. Unknown entries are counted but never traversed. On Unix, +/// every visited inode consumes bounded deduplication memory until the walk +/// ends: publication can add links even to a formerly single-linked file. +/// Exhausting that evidence budget returns `OutOfMemory`, never complete +/// totals. Receipts are bounded and read from the child being charged. +/// Category ordering is authority ordering: lower keys receive shared +/// physical bytes before higher keys, independent of directory iteration. +pub fn census_receipted_directory_bytes( + root_path: &Path, + unrecognized: K, + receipt_for: impl FnMut(&OsStr) -> (&'static str, usize), + classify: impl FnMut(&OsStr, Option<&[u8]>) -> K, +) -> io::Result> { + census_receipted_directory_bytes_bounded( + root_path, + unrecognized, + receipt_for, + classify, + MAX_SEALED_CENSUS_INODES, + ) +} + +fn census_receipted_directory_bytes_bounded( + root_path: &Path, + unrecognized: K, + receipt_for: impl FnMut(&OsStr) -> (&'static str, usize), + classify: impl FnMut(&OsStr, Option<&[u8]>) -> K, + max_inodes: usize, +) -> io::Result> { + let root = match open_root_nofollow(root_path) { + Ok(root) => root, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Default::default()), + Err(error) => return Err(error), + }; + census_receipted_children( + &root, + root.read_dir(".")? + .map(|entry| entry.map(|entry| entry.file_name())), + unrecognized, + receipt_for, + classify, + max_inodes, + ) +} + +fn census_receipted_children( + root: &Dir, + entries: impl Iterator>, + unrecognized: K, + mut receipt_for: impl FnMut(&OsStr) -> (&'static str, usize), + mut classify: impl FnMut(&OsStr, Option<&[u8]>) -> K, + max_inodes: usize, +) -> io::Result> { + let mut totals = std::collections::BTreeMap::::new(); + // Hard links belong to the highest-priority verified category, not the + // first entry returned by read_dir. A link count is not a closed group: + // concurrent publication can add an alias after its last observed link. + let mut counted = std::collections::HashMap::<(u64, u64), (K, u64)>::new(); + for name in entries { + let name = name?; + single_component(&name)?; + let child = if root.symlink_metadata(&name)?.is_dir() { + root.open_dir_nofollow(&name).ok() + } else { + None + }; + let (category, bytes) = if let Some(child) = child { + let (receipt_name, maximum) = receipt_for(&name); + let receipt = + read_bounded_child_receipt(&child, OsStr::new(receipt_name), maximum).ok(); + let category = classify(&name, receipt.as_deref()); + let bytes = if category == unrecognized { + 0 + } else { + directory_bytes_from_held_dir( + child, + category, + &mut totals, + &mut counted, + max_inodes, + )? + }; + (category, bytes) + } else { + (unrecognized, 0) + }; + let totals = totals.entry(category).or_default(); + totals.0 = totals.0.saturating_add(1); + totals.1 = totals.1.saturating_add(bytes); + } + Ok(totals) +} + +/// Walk no-follow entries relative to the same held child, even after its +/// old pathname has been renamed. The stack scales with directory depth. +fn directory_bytes_from_held_dir( + directory: Dir, + category: K, + totals: &mut std::collections::BTreeMap, + counted: &mut std::collections::HashMap<(u64, u64), (K, u64)>, + max_inodes: usize, +) -> io::Result { + let mut total = 0_u64; + // Hold only the current ancestry chain. Opening every sibling before + // visiting any of them exhausted the process descriptor limit on wide + // verified generations and made the whole sealed census fail. + let mut pending = vec![(directory.read_dir(".")?, directory)]; + while let Some((entries, parent)) = pending.last_mut() { + let Some(entry) = entries.next() else { + pending.pop(); + continue; + }; + let entry = entry?; + let name = entry.file_name(); + let metadata = parent.symlink_metadata(&name)?; + if metadata.is_dir() { + let child = parent.open_dir_nofollow(&name)?; + pending.push((child.read_dir(".")?, child)); + continue; + } + #[cfg(unix)] + { + use cap_std::fs::MetadataExt; + let key = (metadata.dev(), metadata.ino()); + if let Some((previous_kind, charged_bytes)) = counted.get_mut(&key) { + // A lower-ordered category has stronger ownership. A + // superseded generation encountered first cannot claim + // hard-linked physical bytes from a later live head. + if category < *previous_kind { + let previous = totals.get_mut(&*previous_kind).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "hard-linked sealed bytes lack their prior category", + ) + })?; + previous.1 = previous.1.checked_sub(*charged_bytes).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "hard-linked sealed bytes exceed their prior category", + ) + })?; + total = total.saturating_add(*charged_bytes); + *previous_kind = category; + } + continue; + } + if counted.len() >= max_inodes { + return Err(io::Error::new( + io::ErrorKind::OutOfMemory, + "sealed census inode budget exhausted; coverage is partial", + )); + } + counted.try_reserve(1).map_err(|_| { + io::Error::new( + io::ErrorKind::OutOfMemory, + "sealed census inode allocation failed; coverage is partial", + ) + })?; + counted.insert(key, (category, metadata.len())); + } + #[cfg(not(unix))] + let _ = (&counted, max_inodes); + total = total.saturating_add(metadata.len()); + } + Ok(total) +} + +/// Remove exactly one held child directory without following a substituted +/// sealed-root or child symlink. Missing entries are idempotent; other failures +/// remain errors so callers can retain retry ownership. +pub fn remove_child_directory_nofollow(root_path: &Path, name: &OsStr) -> io::Result { + single_component(name)?; + let root = match open_root_nofollow(root_path) { + Ok(root) => root, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + let child = match root.open_dir_nofollow(name) { + Ok(child) => child, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + remove_open_dir_all_nofollow(child)?; + Ok(true) +} + +/// Retire a named generation only when a bounded receipt read from the SAME +/// opened directory proves the requested identity. Missing is idempotent; +/// unknown receipts are refusals, never permission to discard durable bytes. +pub fn remove_receipted_child_directory_nofollow( + root_path: &Path, + child_name: &OsStr, + receipt_name: &OsStr, + maximum: usize, + authorized: impl FnOnce(&[u8]) -> bool, +) -> io::Result { + single_component(child_name)?; + single_component(receipt_name)?; + let root = match open_root_nofollow(root_path) { + Ok(root) => root, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + let child = match root.open_dir_nofollow(child_name) { + Ok(child) => child, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + let receipt = read_bounded_child_receipt(&child, receipt_name, maximum)?; + if !authorized(&receipt) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "sealed generation retirement receipt did not prove its ownership", + )); + } + remove_open_dir_all_nofollow(child)?; + Ok(true) +} + +/// Removes only children authorized by a bounded receipt read from the exact +/// directory handle that will be deleted. The sealed root and each child are +/// held capabilities: replacing either pathname after receipt validation +/// cannot redirect recursive removal to a different tree. +/// +/// The caller must own exclusive store maintenance. Unknown, invalid and +/// unreadable child receipts preserve their directory; an actual deletion +/// failure is returned so retirement can retry instead of claiming success. +pub fn remove_receipted_directories_nofollow( + root_path: &Path, + receipt_name: &OsStr, + maximum: usize, + authorized: impl FnMut(&OsStr, &[u8]) -> bool, +) -> io::Result { + remove_matching_receipted_directories_nofollow( + root_path, + receipt_name, + maximum, + |_| true, + authorized, + ) +} + +/// Scan and reclaim selected children beneath one held root. Filter names +/// before receipt I/O so unrelated installed generations are not treated as +/// abandoned staging or spills. Receipt verification and removal remain +/// anchored to the same opened child, even if the root pathname changes. +/// A failure on one entry is reported after its independent siblings are processed. +pub fn remove_matching_receipted_directories_nofollow( + root_path: &Path, + receipt_name: &OsStr, + maximum: usize, + mut candidate: impl FnMut(&OsStr) -> bool, + mut authorized: impl FnMut(&OsStr, &[u8]) -> bool, +) -> io::Result { + // Receipt names are always a single sibling: a slash-bearing name could + // otherwise traverse a different directory even with O_NOFOLLOW. + let mut receipt_components = Path::new(receipt_name).components(); + if !matches!( + receipt_components.next(), + Some(std::path::Component::Normal(_)) + ) || receipt_components.next().is_some() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "receipt must be a single filename", + )); + } + let root = open_root_nofollow(root_path)?; + let outcomes = root.read_dir(".")?.map(|entry| -> io::Result { + let entry = entry?; + if !entry.file_type()?.is_dir() { + return Ok(false); + } + let name = entry.file_name(); + if !candidate(&name) { + return Ok(false); + } + // Do not trust the DirEntry's former pathname kind after a rename. + let child = match root.open_dir_nofollow(&name) { + Ok(child) => child, + Err(error) => { + tracing::warn!(%error, entry = ?name, "sealed child could not be verified"); + return Ok(false); + } + }; + let receipt = match read_bounded_child_receipt(&child, receipt_name, maximum) { + Ok(receipt) => receipt, + Err(error) => { + tracing::warn!(%error, entry = ?name, "unverified sealed receipt preserved"); + return Ok(false); + } + }; + if !authorized(&name, &receipt) { + return Ok(false); + } + remove_open_dir_all_nofollow(child).inspect_err(|error| { + tracing::warn!(%error, entry = ?name, "verified child directory could not be removed"); + })?; + Ok(true) + }); + let mut removed = 0; + let mut first_error = None; + for outcome in outcomes { + match outcome { + Ok(true) => removed += 1, + Ok(false) => {} + Err(error) => { + first_error.get_or_insert(error); + } + } + } + first_error.map_or(Ok(removed), Err) +} + +#[cfg(unix)] +fn read_bounded_child_receipt( + directory: &Dir, + name: &OsStr, + maximum: usize, +) -> io::Result> { + let name = CString::new(name.as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "receipt name contains NUL"))?; + // SAFETY: the held directory fd is valid; name is a NUL-terminated + // sibling component. O_NOFOLLOW prevents link traversal and O_NONBLOCK + // prevents a substituted FIFO from blocking before the regular-file check. + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK, + ) + }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: openat returned a new owned fd and this File is its sole owner. + let file = unsafe { std::fs::File::from_raw_fd(descriptor) }; + crate::framed_log::read_bounded_open_file(file, maximum) +} + +#[cfg(windows)] +fn read_bounded_child_receipt( + directory: &Dir, + name: &OsStr, + maximum: usize, +) -> io::Result> { + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + let file = directory.open_with(name, &options)?.into_std(); + crate::framed_log::read_bounded_open_file(file, maximum) +} + /// Removes the held directory without following child symlinks. pub fn remove_open_dir_all_nofollow(directory: Dir) -> io::Result<()> { #[cfg(not(windows))] @@ -241,6 +778,90 @@ mod replacement_tests { use super::*; + #[cfg(unix)] + #[test] + fn marker_publication_does_not_follow_a_substituted_directory() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let owned = temp.path().join("owned"); + let moved = temp.path().join("moved"); + let foreign = temp.path().join("foreign"); + std::fs::create_dir(&owned).unwrap(); + std::fs::create_dir(&foreign).unwrap(); + std::fs::write(foreign.join("sealed.checked"), b"foreign bytes").unwrap(); + + let held = open_root_nofollow(&owned).unwrap(); + std::fs::rename(&owned, &moved).unwrap(); + symlink(&foreign, &owned).unwrap(); + + assert!( + atomic_replace_child_file_nofollow(&owned, OsStr::new("sealed.checked"), b"new proof",) + .is_err() + ); + atomic_replace_file_in_dir(&held, OsStr::new("sealed.checked"), b"owned proof").unwrap(); + assert_eq!( + std::fs::read(foreign.join("sealed.checked")).unwrap(), + b"foreign bytes", + ); + assert_eq!( + std::fs::read(moved.join("sealed.checked")).unwrap(), + b"owned proof", + ); + } + + #[cfg(unix)] + #[test] + fn proof_publication_preserves_nonregular_marker_entries() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let marker = temp.path().join("sealed.checked"); + let foreign = temp.path().join("foreign"); + std::fs::write(&foreign, b"foreign").unwrap(); + symlink(&foreign, &marker).unwrap(); + + assert_eq!( + atomic_replace_child_file_nofollow(temp.path(), OsStr::new("sealed.checked"), b"proof") + .unwrap_err() + .kind(), + io::ErrorKind::PermissionDenied + ); + assert!( + std::fs::symlink_metadata(&marker) + .unwrap() + .file_type() + .is_symlink(), + "the link itself, not merely its target, must survive" + ); + assert_eq!(std::fs::read(&foreign).unwrap(), b"foreign"); + + std::fs::remove_file(&marker).unwrap(); + std::fs::create_dir(&marker).unwrap(); + assert_eq!( + atomic_replace_child_file_nofollow(temp.path(), OsStr::new("sealed.checked"), b"proof") + .unwrap_err() + .kind(), + io::ErrorKind::PermissionDenied + ); + assert!(marker.is_dir()); + + std::fs::remove_dir(&marker).unwrap(); + atomic_replace_child_file_nofollow(temp.path(), OsStr::new("sealed.checked"), b"proof") + .unwrap(); + assert_eq!(std::fs::read(&marker).unwrap(), b"proof"); + for entry in std::fs::read_dir(temp.path()).unwrap() { + assert!( + !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".sealed-proof-"), + "rejected publications must not leave staging files" + ); + } + } + #[test] fn replacement_publishes_complete_bytes_and_refuses_parent_traversal() { let root = tempfile::tempdir().unwrap(); @@ -308,6 +929,9 @@ mod replacement_tests { any(target_os = "macos", all(target_os = "linux", target_env = "gnu")) ))] mod tests { + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + use cap_std::ambient_authority; use super::*; @@ -316,6 +940,359 @@ mod tests { Dir::open_ambient_dir(path, ambient_authority()).expect("open test directory") } + #[cfg(unix)] + #[test] + fn wide_verified_generation_census_stays_within_descriptor_budget() { + const CHILD: &str = "TRACEDECAY_WIDE_CENSUS_RLIMIT_CHILD"; + if std::env::var_os(CHILD).is_none() { + // Resource limits are process-wide; isolate the reduced budget + // from concurrently executing tests in the parent libtest runner. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "capability_dir::tests::wide_verified_generation_census_stays_within_descriptor_budget", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "census under reduced descriptor budget failed: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "the child must execute the actual behavioral regression: {}", + String::from_utf8_lossy(&output.stdout) + ); + return; + } + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sealed"); + let generation = root.join("generation"); + std::fs::create_dir_all(&generation).unwrap(); + std::fs::write(generation.join("sealed.json"), b"owned").unwrap(); + for index in 0..128 { + let column = generation.join(format!("column-{index}")); + std::fs::create_dir(&column).unwrap(); + std::fs::write(column.join("value"), b"x").unwrap(); + } + + // The old breadth-first handle stack needed >=128 live handles + // here; the corrected walk needs only the current three levels. + let mut limit: libc::rlimit = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) }, + 0 + ); + limit.rlim_cur = limit.rlim_cur.min(64); + assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &limit) }, 0); + + let totals = census_receipted_directory_bytes( + &root, + false, + |_| ("sealed.json", 64), + |_, receipt| receipt == Some(b"owned".as_slice()), + ) + .unwrap(); + assert_eq!(totals.get(&true), Some(&(1, 133))); + assert_eq!( + std::fs::read(generation.join("column-127/value")).unwrap(), + b"x" + ); + } + + /// Hard-linked base bytes count once and belong to the live head even + /// when a superseded generation was physically visited first. + #[cfg(unix)] + #[test] + fn shared_sealed_bytes_follow_head_priority_not_directory_visitation() { + #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] + enum Kind { + Head, + Superseded, + Unrecognized, + } + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sealed"); + let superseded = root.join("old"); + let head = root.join("head"); + std::fs::create_dir_all(&superseded).unwrap(); + std::fs::create_dir(&head).unwrap(); + std::fs::write(superseded.join("owner"), b"old").unwrap(); + std::fs::write(head.join("owner"), b"head").unwrap(); + std::fs::write(superseded.join("shared"), b"shared-bytes").unwrap(); + std::fs::hard_link(superseded.join("shared"), head.join("shared")).unwrap(); + + // Exercise the actual walker in the adverse order without relying on + // the operating system's unspecified read_dir ordering. + let mut counted = std::collections::HashMap::new(); + let mut charged = std::collections::BTreeMap::::new(); + for (kind, path) in [(Kind::Superseded, &superseded), (Kind::Head, &head)] { + let bytes = directory_bytes_from_held_dir( + open(path), + kind, + &mut charged, + &mut counted, + MAX_SEALED_CENSUS_INODES, + ) + .unwrap(); + let entry = charged.entry(kind).or_default(); + entry.0 += 1; + entry.1 += bytes; + } + assert_eq!(charged.get(&Kind::Superseded), Some(&(1, 3))); + assert_eq!(charged.get(&Kind::Head), Some(&(1, 16))); + + // The production census also classifies receipt-bound children and + // must report one physical copy without charging retired bytes twice. + let reported = census_receipted_directory_bytes( + &root, + Kind::Unrecognized, + |_| ("owner", 64), + |_, receipt| match receipt { + Some(bytes) if bytes == b"head" => Kind::Head, + Some(bytes) if bytes == b"old" => Kind::Superseded, + _ => Kind::Unrecognized, + }, + ) + .unwrap(); + assert_eq!(reported.get(&Kind::Head), Some(&(1, 16))); + assert_eq!(reported.get(&Kind::Superseded), Some(&(1, 3))); + assert_eq!(reported.get(&Kind::Unrecognized), None); + assert_eq!(std::fs::read(head.join("shared")).unwrap(), b"shared-bytes"); + } + + /// Disjoint pairs still require evidence for the full walk: a publisher + /// can add another alias after any pair completes. A small budget must + /// refuse; a sufficient budget must count every pair exactly once. + #[cfg(unix)] + #[test] + fn complete_link_pairs_require_retained_census_evidence() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sealed"); + let generation = root.join("generation"); + std::fs::create_dir_all(&generation).unwrap(); + std::fs::write(generation.join("owner"), b"owned").unwrap(); + for index in 0..4 { + let column = generation.join(format!("column-{index}")); + std::fs::create_dir(&column).unwrap(); + let original = column.join("original"); + std::fs::write(&original, b"rows").unwrap(); + std::fs::hard_link(&original, column.join("alias")).unwrap(); + } + + let refused = census_receipted_directory_bytes_bounded( + &root, + false, + |_| ("owner", 64), + |_, receipt| receipt == Some(b"owned".as_slice()), + 2, + ) + .unwrap_err(); + assert_eq!(refused.kind(), io::ErrorKind::OutOfMemory, "{refused}"); + let census = census_receipted_directory_bytes_bounded( + &root, + false, + |_| ("owner", 64), + |_, receipt| receipt == Some(b"owned".as_slice()), + 5, + ) + .unwrap(); + assert_eq!(census.get(&true), Some(&(1, 21))); + for index in 0..4 { + let column = generation.join(format!("column-{index}")); + assert_eq!(std::fs::read(column.join("original")).unwrap(), b"rows"); + assert_eq!(std::fs::read(column.join("alias")).unwrap(), b"rows"); + } + } + + /// An adversarial store with many distinct hard-linked inodes must + /// decline its complete census before unbounded heap growth. A caller + /// can then truthfully mark coverage partial rather than report zeros as + /// a complete inventory. The ordinary production entrypoint still counts + /// all the same real files when within its budget. + #[cfg(unix)] + #[test] + fn sealed_census_refuses_excess_shared_inodes_without_losing_files() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sealed"); + let generation = root.join("generation"); + std::fs::create_dir_all(&generation).unwrap(); + std::fs::write(generation.join("owner"), b"owned").unwrap(); + for index in 0..3 { + let source = generation.join(format!("payload-{index}")); + std::fs::write(&source, b"x").unwrap(); + std::fs::hard_link(&source, temp.path().join(format!("alias-{index}"))).unwrap(); + } + + let refused = census_receipted_directory_bytes_bounded( + &root, + false, + |_| ("owner", 64), + |_, receipt| receipt == Some(b"owned".as_slice()), + 3, + ) + .unwrap_err(); + assert_eq!(refused.kind(), io::ErrorKind::OutOfMemory, "{refused}"); + + let complete = census_receipted_directory_bytes( + &root, + false, + |_| ("owner", 64), + |_, receipt| receipt == Some(b"owned".as_slice()), + ) + .unwrap(); + assert_eq!(complete.get(&true), Some(&(1, 8))); + for index in 0..3 { + assert_eq!( + std::fs::read(generation.join(format!("payload-{index}"))).unwrap(), + b"x" + ); + assert_eq!( + std::fs::read(temp.path().join(format!("alias-{index}"))).unwrap(), + b"x" + ); + } + } + + /// A link group is not closed by its observed nlink count: a layered + /// publisher can add another alias after the census has visited every + /// old link. The head must inherit the one physical charge in both cases. + #[cfg(unix)] + #[test] + fn census_retains_inode_identity_across_later_link_publication() { + for initial_peer in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sealed"); + let old = root.join("old"); + std::fs::create_dir_all(&old).unwrap(); + std::fs::write(old.join("owner"), b"old").unwrap(); + let base = old.join("shared"); + std::fs::write(&base, b"rows").unwrap(); + let mut initial = vec![OsString::from("old")]; + if initial_peer { + let peer = root.join("old-peer"); + std::fs::create_dir(&peer).unwrap(); + std::fs::write(peer.join("owner"), b"old").unwrap(); + std::fs::hard_link(&base, peer.join("shared")).unwrap(); + initial.push(OsString::from("old-peer")); + } + assert_eq!( + std::fs::metadata(&base).unwrap().nlink(), + if initial_peer { 2 } else { 1 }, + ); + let root_dir = open_root_nofollow(&root).unwrap(); + let mut names = initial.into_iter(); + let mut published = false; + let entries = std::iter::from_fn(|| { + if let Some(name) = names.next() { + return Some(Ok(name)); + } + if published { + return None; + } + let head = root.join("head"); + std::fs::create_dir(&head).unwrap(); + std::fs::write(head.join("owner"), b"head").unwrap(); + std::fs::hard_link(&base, head.join("shared")).unwrap(); + published = true; + Some(Ok(OsString::from("head"))) + }); + let census = census_receipted_children( + &root_dir, + entries, + 2_u8, + |_| ("owner", 16), + |_, receipt| { + if receipt == Some(b"head".as_slice()) { + 0 + } else if receipt == Some(b"old".as_slice()) { + 1 + } else { + 2 + } + }, + MAX_SEALED_CENSUS_INODES, + ) + .unwrap(); + assert!(published, "the head must have been published mid-census"); + assert_eq!( + std::fs::metadata(&base).unwrap().nlink(), + if initial_peer { 3 } else { 2 }, + ); + assert_eq!(census.get(&0), Some(&(1, 8)), "live head owns shared bytes"); + assert_eq!( + census.get(&1), + Some(&(if initial_peer { 2 } else { 1 }, if initial_peer { 6 } else { 3 })), + "old generations retain only their own receipts" + ); + assert!(!census.contains_key(&2)); + assert_eq!(std::fs::read(root.join("head/shared")).unwrap(), b"rows"); + assert_eq!(std::fs::read(&base).unwrap(), b"rows"); + } + } + + #[test] + fn staged_generation_install_preserves_an_occupied_empty_directory() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sealed"); + std::fs::create_dir(&root).unwrap(); + let staged_name = OsStr::new(".staging-generation"); + let installed_name = OsStr::new("generation"); + let staged = root.join(staged_name); + let installed = root.join(installed_name); + std::fs::create_dir(&staged).unwrap(); + std::fs::write(staged.join("sealed.json"), b"built").unwrap(); + std::fs::create_dir(&installed).unwrap(); + + let result = install_child_directory_noreplace_nofollow(&root, staged_name, installed_name); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert!( + installed.is_dir(), + "an empty foreign target is not expendable" + ); + assert_eq!(std::fs::read(staged.join("sealed.json")).unwrap(), b"built"); + + std::fs::remove_dir(&installed).unwrap(); + install_child_directory_noreplace_nofollow(&root, staged_name, installed_name).unwrap(); + assert!(!staged.exists()); + assert_eq!( + std::fs::read(installed.join("sealed.json")).unwrap(), + b"built" + ); + } + + #[cfg(unix)] + #[test] + fn staged_generation_install_refuses_linked_root_and_staging_entries() { + let temp = tempfile::tempdir().unwrap(); + let foreign = temp.path().join("foreign"); + let real = temp.path().join("real"); + let link = temp.path().join("link"); + std::fs::create_dir(&foreign).unwrap(); + std::fs::create_dir(&real).unwrap(); + std::fs::write(foreign.join("sentinel"), b"preserve").unwrap(); + std::fs::create_dir(foreign.join("staged")).unwrap(); + std::os::unix::fs::symlink(&foreign, &link).unwrap(); + let stage = OsStr::new("staged"); + let published = OsStr::new("installed"); + assert!(install_child_directory_noreplace_nofollow(&link, stage, published).is_err()); + + std::os::unix::fs::symlink(&foreign, real.join("staged")).unwrap(); + assert!(install_child_directory_noreplace_nofollow(&real, stage, published).is_err()); + assert!(!foreign.join("installed").exists()); + assert!(!real.join("installed").exists()); + assert_eq!( + std::fs::read(foreign.join("sentinel")).unwrap(), + b"preserve" + ); + } + #[test] fn rename_noreplace_moves_between_parents_and_refuses_occupied_destinations() { let root = tempfile::tempdir().expect("create rename fixture"); @@ -373,6 +1350,289 @@ mod tests { ); } + /// An ambient-path census would charge the 100,041 foreign bytes after + /// a root swap. This census must charge only its held 41-byte payload. + #[cfg(unix)] + #[test] + fn receipted_census_keeps_opened_child_after_root_substitution() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("graph.sealed"); + let displaced = temp.path().join("displaced"); + let foreign = temp.path().join("foreign"); + std::fs::create_dir_all(root.join("generation")).unwrap(); + std::fs::create_dir_all(foreign.join("generation")).unwrap(); + std::fs::write(root.join("generation/owner"), b"owned").unwrap(); + std::fs::write(root.join("generation/payload"), vec![b'a'; 41]).unwrap(); + std::fs::write(foreign.join("generation/owner"), b"owned").unwrap(); + std::fs::write(foreign.join("generation/payload"), vec![b'f'; 100_041]).unwrap(); + #[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] + enum Entry { + Owned, + Unknown, + } + let totals = census_receipted_directory_bytes( + &root, + Entry::Unknown, + |_| ("owner", 16), + |name, receipt| { + assert_eq!(name, OsStr::new("generation")); + assert_eq!(receipt, Some(b"owned".as_slice())); + std::fs::rename(&root, &displaced).unwrap(); + std::os::unix::fs::symlink(&foreign, &root).unwrap(); + Entry::Owned + }, + ) + .unwrap(); + assert_eq!(totals.get(&Entry::Owned), Some(&(1, 46))); + assert_eq!( + std::fs::read(foreign.join("generation/payload")).unwrap(), + vec![b'f'; 100_041], + ); + assert_eq!( + std::fs::read(displaced.join("generation/payload")).unwrap(), + vec![b'a'; 41], + ); + } + + /// Candidate filtering must not reopen a potentially substituted root: + /// only the receipted owned child goes away; unrelated and foreign trees survive. + #[cfg(unix)] + #[test] + fn filtered_receipt_sweep_holds_root_through_path_substitution() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("graph.sealed"); + let displaced = temp.path().join("owned-displaced"); + let foreign = temp.path().join("foreign"); + std::fs::create_dir_all(root.join(".rows-old")).unwrap(); + std::fs::create_dir_all(root.join("generation")).unwrap(); + std::fs::create_dir_all(foreign.join(".rows-old")).unwrap(); + std::fs::write(root.join(".rows-old/.owner"), b"verified").unwrap(); + std::fs::write(root.join("generation/live"), b"live").unwrap(); + std::fs::write(foreign.join(".rows-old/.owner"), b"verified").unwrap(); + std::fs::write(foreign.join(".rows-old/sentinel"), b"foreign").unwrap(); + + let removed = remove_matching_receipted_directories_nofollow( + &root, + OsStr::new(".owner"), + 512, + |name| name == OsStr::new(".rows-old"), + |name, receipt| { + assert_eq!(name, OsStr::new(".rows-old")); + assert_eq!(receipt, b"verified"); + std::fs::rename(&root, &displaced).unwrap(); + std::os::unix::fs::symlink(&foreign, &root).unwrap(); + true + }, + ) + .unwrap(); + + assert_eq!(removed, 1); + assert!(!displaced.join(".rows-old").exists()); + assert_eq!( + std::fs::read(displaced.join("generation/live")).unwrap(), + b"live" + ); + assert_eq!( + std::fs::read(foreign.join(".rows-old/sentinel")).unwrap(), + b"foreign" + ); + } + + #[cfg(unix)] + #[test] + fn a_failed_verified_unlink_still_reclaims_independent_siblings() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("graph.sealed"); + for name in ["a", "b"] { + std::fs::create_dir_all(root.join(name)).unwrap(); + std::fs::write(root.join(name).join("owner"), name).unwrap(); + } + let mut verified = Vec::new(); + let error = remove_matching_receipted_directories_nofollow( + &root, + OsStr::new("owner"), + 64, + |_| true, + |name, receipt| { + assert_eq!(name.as_encoded_bytes(), receipt); + if verified.is_empty() { + // A concurrent cleanup wins after this child was verified. + // Its now-unlinked capability cannot be removed a second time. + std::fs::remove_dir_all(root.join(name)).unwrap(); + } + verified.push(name.to_owned()); + true + }, + ) + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "{error}"); + assert_eq!(verified.len(), 2); + assert!(!root.join(&verified[1]).exists()); + } + + /// A parent-path swap after receipt validation must not redirect cleanup: + /// the already-open child is removed, and matching foreign bytes survive. + #[cfg(unix)] + #[test] + fn verified_cleanup_remains_bound_when_sealed_root_is_substituted() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("graph.sealed"); + let displaced = temp.path().join("owned-displaced"); + let foreign = temp.path().join("foreign"); + std::fs::create_dir_all(root.join("a")).unwrap(); + std::fs::create_dir_all(foreign.join("a")).unwrap(); + std::fs::write(root.join("a/sealed.json"), b"owned receipt").unwrap(); + std::fs::write(root.join("a/owned"), b"owned").unwrap(); + std::fs::write(foreign.join("a/sentinel"), b"foreign").unwrap(); + + let removed = remove_receipted_directories_nofollow( + &root, + OsStr::new("sealed.json"), + 1024, + |name, bytes| { + assert_eq!(name, OsStr::new("a")); + assert_eq!(bytes, b"owned receipt"); + std::fs::rename(&root, &displaced).unwrap(); + std::os::unix::fs::symlink(&foreign, &root).unwrap(); + true + }, + ) + .unwrap(); + assert_eq!(removed, 1); + assert!(!displaced.join("a").exists()); + assert_eq!( + std::fs::read(foreign.join("a/sentinel")).unwrap(), + b"foreign" + ); + } + + /// The child also remains the exact opened directory when its original + /// entry name becomes occupied by an unrelated real directory. + #[cfg(unix)] + #[test] + fn verified_cleanup_does_not_remove_a_replacement_real_child() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("graph.sealed"); + std::fs::create_dir_all(root.join("a")).unwrap(); + std::fs::write(root.join("a/sealed.json"), b"owned receipt").unwrap(); + let moved = root.join("moved"); + let removed = remove_receipted_directories_nofollow( + &root, + OsStr::new("sealed.json"), + 1024, + |_, bytes| { + assert_eq!(bytes, b"owned receipt"); + std::fs::rename(root.join("a"), &moved).unwrap(); + std::fs::create_dir(root.join("a")).unwrap(); + std::fs::write(root.join("a/sentinel"), b"foreign").unwrap(); + true + }, + ) + .unwrap(); + assert_eq!(removed, 1); + assert!(!moved.exists()); + assert_eq!(std::fs::read(root.join("a/sentinel")).unwrap(), b"foreign"); + } + + #[test] + fn a_receipt_filename_cannot_escape_its_held_child() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sealed"); + std::fs::create_dir(&root).unwrap(); + for receipt in ["../outside", "a/b", "."] { + let error = + remove_receipted_directories_nofollow(&root, OsStr::new(receipt), 512, |_, _| true) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + } + } + + #[cfg(unix)] + #[test] + fn exact_receipted_retirement_preserves_unknown_and_swapped_real_children() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("graph.sealed"); + let child = root.join("a"); + let moved = root.join("moved"); + std::fs::create_dir_all(&child).unwrap(); + std::fs::write(child.join("sealed.json"), b"owned receipt").unwrap(); + std::fs::write(child.join("owned"), b"owned").unwrap(); + assert_eq!( + remove_receipted_child_directory_nofollow( + &root, + OsStr::new("a"), + OsStr::new("sealed.json"), + 1024, + |_| false, + ) + .unwrap_err() + .kind(), + io::ErrorKind::PermissionDenied, + ); + assert_eq!(std::fs::read(child.join("owned")).unwrap(), b"owned"); + let removed = remove_receipted_child_directory_nofollow( + &root, + OsStr::new("a"), + OsStr::new("sealed.json"), + 1024, + |receipt| { + assert_eq!(receipt, b"owned receipt"); + std::fs::rename(&child, &moved).unwrap(); + std::fs::create_dir(&child).unwrap(); + std::fs::write(child.join("foreign"), b"preserve").unwrap(); + true + }, + ) + .unwrap(); + assert!(removed); + assert!( + !moved.exists(), + "only the receipt-authorized directory is deleted" + ); + assert_eq!(std::fs::read(child.join("foreign")).unwrap(), b"preserve"); + } + + #[cfg(unix)] + #[test] + fn exact_cleanup_refuses_a_substituted_root_symlink() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("graph.sealed"); + let displaced = temp.path().join("original"); + let foreign = temp.path().join("foreign"); + std::fs::create_dir_all(root.join("a")).unwrap(); + std::fs::create_dir_all(foreign.join("a")).unwrap(); + std::fs::write(root.join("a/sealed.json"), b"owned").unwrap(); + std::fs::write(foreign.join("a/sealed.json"), b"foreign").unwrap(); + std::fs::write(foreign.join("a/sentinel"), b"preserve").unwrap(); + std::fs::rename(&root, &displaced).unwrap(); + std::os::unix::fs::symlink(&foreign, &root).unwrap(); + assert!(remove_child_directory_nofollow(&root, OsStr::new("a")).is_err()); + assert!( + remove_receipted_child_directory_nofollow( + &root, + OsStr::new("a"), + OsStr::new("sealed.json"), + 1024, + |_| true, + ) + .is_err() + ); + assert_eq!( + std::fs::read(foreign.join("a/sentinel")).unwrap(), + b"preserve" + ); + assert_eq!( + std::fs::read(displaced.join("a/sealed.json")).unwrap(), + b"owned" + ); + assert_eq!( + remove_child_directory_nofollow(&displaced, OsStr::new("../a")) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput, + ); + } + #[test] fn a_transient_missing_answer_is_looked_at_again() { let mut attempts = 0; diff --git a/crates/tracedecay-private-fs/src/coarse_time.rs b/crates/tracedecay-private-fs/src/coarse_time.rs index 8978f1b928a..8b8ccc1f0a3 100644 --- a/crates/tracedecay-private-fs/src/coarse_time.rs +++ b/crates/tracedecay-private-fs/src/coarse_time.rs @@ -6,13 +6,10 @@ //! timestamp quantum can share a stamp, so a change time is proof the bytes //! are stable only once the clock has left the quantum it was stamped in. -use std::collections::hash_map::RandomState; use std::fs::Metadata; -use std::hash::BuildHasher; #[cfg(unix)] use std::os::unix::fs::MetadataExt; use std::path::Path; -use std::sync::LazyLock; use std::sync::atomic::{AtomicU64, Ordering}; const NANOS_PER_SECOND: i128 = 1_000_000_000; @@ -120,10 +117,9 @@ impl ChangeStamp { fn unvouched() -> Self { // The random half keeps samples from different processes apart where // an identity is persisted as a digest. - static PROCESS: LazyLock = LazyLock::new(|| RandomState::new().hash_one(0_u8)); static SAMPLE: AtomicU64 = AtomicU64::new(0); let sample = SAMPLE.fetch_add(1, Ordering::Relaxed); - Self::Unvouched((u128::from(*PROCESS) << 64) | u128::from(sample)) + Self::Unvouched((u128::from(crate::process_instance_nonce()) << 64) | u128::from(sample)) } #[must_use] diff --git a/crates/tracedecay-private-fs/src/framed_log.rs b/crates/tracedecay-private-fs/src/framed_log.rs index 005e20779c2..408bc6780e9 100644 --- a/crates/tracedecay-private-fs/src/framed_log.rs +++ b/crates/tracedecay-private-fs/src/framed_log.rs @@ -278,6 +278,13 @@ pub fn read_bounded(path: &Path, maximum: usize) -> io::Result>> Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(error), }; + read_bounded_open_file(file, maximum).map(Some) +} + +/// Applies the same bounded regular-file proof to a file opened relative to a +/// held directory capability. The caller supplies a no-follow, nonblocking +/// handle; this function never resolves another pathname. +pub(crate) fn read_bounded_open_file(file: File, maximum: usize) -> io::Result> { let metadata = file.metadata()?; if !metadata.file_type().is_file() { return Err(io::Error::new( @@ -300,7 +307,7 @@ pub fn read_bounded(path: &Path, maximum: usize) -> io::Result>> "bounded read length mismatch", )); } - Ok(Some(bytes)) + Ok(bytes) } const TEMPORARY_SUFFIX: &str = ".tmp"; diff --git a/crates/tracedecay-private-fs/src/lib.rs b/crates/tracedecay-private-fs/src/lib.rs index 7e7298a08db..52c99ebe108 100644 --- a/crates/tracedecay-private-fs/src/lib.rs +++ b/crates/tracedecay-private-fs/src/lib.rs @@ -2,7 +2,10 @@ //! plus the Windows file-handle identity primitive that shares this crate's //! cold, dependency-light position. +use std::collections::hash_map::RandomState; +use std::hash::BuildHasher; use std::io; +use std::sync::LazyLock; pub mod capability_dir; mod coarse_time; @@ -15,6 +18,14 @@ mod rename_noreplace; pub use coarse_time::{ChangeClockReading, ChangeStamp, RewriteWitness, change_time_settled}; pub use inode_generation::inode_generation; +/// A process-local random witness for persisted identities that must distinguish +/// successive processes reusing a PID. Pair it with the PID for forked children. +#[must_use] +pub fn process_instance_nonce() -> u64 { + static NONCE: LazyLock = LazyLock::new(|| RandomState::new().hash_one(0_u8)); + *NONCE +} + pub use file_lease::FileLease; pub use lock_admission::{LockAdmissionError, lock_shared_until, lock_until}; #[cfg(windows)]