diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations.rs b/crates/tracedecay-code-index-retention/src/code_index_generations.rs index 1f9f8ab24b1..6639b00da23 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations.rs @@ -10,7 +10,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs::File; -use std::io::{BufReader, Read}; +use std::io::{BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -55,6 +55,8 @@ pub use graph_replay_release::{ CodeGenerationGraphReplayReleasePageV1, CodeGenerationGraphReplayReleaseV1, code_generation_graph_replay_release_page, complete_code_generation_graph_replay_release, }; +#[cfg(unix)] +pub use locking::CachedCodeGenerationStoreReadLockV1; use locking::acquire_generation_segments_lock_checked; pub use locking::{ CodeGenerationStoreLockV1, acquire_code_generation_store_lock, @@ -112,7 +114,7 @@ use text_artifacts::{ plan_collectable_text_artifacts_cancellable, stage_collectable_text_artifacts, }; -use generation_scan::{read_generation_format_revision, read_generation_metadata}; +use generation_scan::{read_generation_format_revision_from_file, read_generation_metadata}; #[cfg(test)] use scope_quarantine::ScopeQuarantineAuthority; @@ -435,6 +437,13 @@ pub enum CodeGenerationRetentionErrorV1 { TargetHeld(String), } +/// A daemon-owned generation reader releases its open segment descriptors +/// before the retention executor unlinks any corresponding store entries. +/// The callback runs under the exclusive generation and shared-segment locks; +/// a refusal preserves the existing files and their durable receipts. +pub type RetainedSegmentHandleRetirementV1 = + Arc Result<(), CodeGenerationRetentionErrorV1> + Send + Sync>; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CodeGenerationRetentionModeV1 { DryRun, @@ -1414,13 +1423,30 @@ fn sweep_unreferenced_generation_segments( })? .to_owned() }; - if read_generation_format_revision(&path, is_cancelled)? + // The revision probe and full descriptor mark must observe the + // same opened inode. Reopening here paid a second endpoint-security + // authorization for every current-revision manifest and could read + // different bytes if the pathname changed between the two opens. + // Cancellation must win immediately before endpoint-authorized + // open(), including when a listed generation disappeared meanwhile. + if is_cancelled() { + return Err(CodeGenerationRetentionErrorV1::Cancelled); + } + let mut file = File::open(&path).map_err(|error| { + if is_cancelled() { + CodeGenerationRetentionErrorV1::Cancelled + } else { + deferred_if_absent(error) + } + })?; + if read_generation_format_revision_from_file(&mut file, &path, is_cancelled)? != SEALED_GENERATION_FORMAT_REVISION_V1 { continue; } + file.seek(SeekFrom::Start(0)).map_err(storage)?; let mut reader = CancellableGenerationManifestReaderV1 { - file: File::open(&path).map_err(deferred_if_absent)?, + file, hasher: Sha256::new(), is_cancelled, cancelled: false, @@ -1757,6 +1783,7 @@ pub fn execute_code_generation_retention( completed_at, graph_replay_pool_root, &|| false, + None, ) } @@ -1776,6 +1803,7 @@ pub fn execute_code_generation_retention_cancellable( completed_at: UtcMicros, graph_replay_pool_root: Option<&Path>, is_cancelled: &dyn Fn() -> bool, + retire_cached_segments: Option<&RetainedSegmentHandleRetirementV1>, ) -> Result { if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); @@ -1819,6 +1847,16 @@ pub fn execute_code_generation_retention_cancellable( "active generation changed after the retention mark phase".to_owned(), )); } + // Readers take the shared store lock before consulting their descriptor + // cache. Once this exclusive lock is held there can be no in-flight + // retained read; empty the daemon's cache before unlinking any segment. + // Otherwise POSIX keeps deleted segment bytes allocated indefinitely. + if (!plan.collectable_generations.is_empty() + || plan.collectable_generation_segments == GenerationSegmentCensusV1::Present) + && let Some(retire) = retire_cached_segments + { + retire()?; + } let mut generation_segment_batch_exhausted = if plan.collectable_generations.is_empty() && plan.collectable_generation_segments == GenerationSegmentCensusV1::Present { @@ -2095,6 +2133,7 @@ fn run_code_generation_retention_cancellable( completed_at, graph_replay_pool_root, is_cancelled, + None, ) } diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs index 94e144df538..e90ac413252 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs @@ -1,5 +1,7 @@ use std::fs::File; use std::io::Read; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; use std::path::Path; use sha2::{Digest, Sha256}; @@ -14,14 +16,14 @@ use super::{ const MAX_FORMAT_REVISION_PREFIX_BYTES: usize = 4 * 1024; const GENERATION_METADATA_READ_CHUNK_BYTES: usize = 4 * 1024; -pub(super) fn read_generation_format_revision( +pub(super) fn read_generation_format_revision_from_file( + file: &mut File, path: &Path, is_cancelled: &dyn Fn() -> bool, ) -> Result { if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); } - let file = File::open(path).map_err(deferred_if_absent)?; let mut prefix = Vec::with_capacity(MAX_FORMAT_REVISION_PREFIX_BYTES); file.take(MAX_FORMAT_REVISION_PREFIX_BYTES as u64) .read_to_end(&mut prefix) @@ -47,8 +49,25 @@ pub(super) fn read_generation_metadata( if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); } + // Enumerating a content-addressed name does not authorize following an + // alias to another generation. This metadata witness adds no pathname open. + let named_before = path.symlink_metadata().map_err(deferred_if_absent)?; + if !named_before.file_type().is_file() { + return Err(CodeGenerationRetentionErrorV1::UnsafeState(format!( + "generation file '{}' is not a regular file", + path.display() + ))); + } let mut file = File::open(path).map_err(deferred_if_absent)?; - let size_bytes = file.metadata().map_err(storage)?.len(); + let opened_before = file.metadata().map_err(storage)?; + #[cfg(unix)] + if !same_generation_file_identity(&named_before, &opened_before) { + return Err(CodeGenerationRetentionErrorV1::UnsafeState(format!( + "generation file '{}' changed while opening", + path.display() + ))); + } + let size_bytes = opened_before.len(); let mut hasher = Sha256::new(); let (format_revision, manifest) = read_generation_metadata_prefix(&mut file, path, verification, &mut hasher, is_cancelled)?; @@ -75,9 +94,44 @@ pub(super) fn read_generation_metadata( } GenerationDigestVerificationV1::MetadataOnly => named_state_digest(path)?, }; + // The manifest, digest and reported size must belong to the pathname + // enumerated by retention, not a replaced inode or modified old handle. + // Metadata-only scans cannot fall back to trusting a substituted name. + let named_after = path.symlink_metadata().map_err(deferred_if_absent)?; + let opened_after = file.metadata().map_err(storage)?; + #[cfg(unix)] + let unchanged = same_generation_file_identity(&named_before, &named_after) + && same_generation_file_identity(&named_before, &opened_after); + #[cfg(not(unix))] + let unchanged = named_after.file_type().is_file() + && opened_after.is_file() + && named_after.len() == size_bytes + && opened_after.len() == size_bytes; + if !unchanged { + return Err(CodeGenerationRetentionErrorV1::UnsafeState(format!( + "generation file '{}' changed during metadata read", + path.display() + ))); + } Ok((format_revision, manifest, state_digest, size_bytes)) } +#[cfg(unix)] +fn same_generation_file_identity( + original: &std::fs::Metadata, + current: &std::fs::Metadata, +) -> bool { + original.file_type().is_file() + && current.file_type().is_file() + && original.dev() == current.dev() + && original.ino() == current.ino() + && original.len() == current.len() + && original.mtime() == current.mtime() + && original.mtime_nsec() == current.mtime_nsec() + && original.ctime() == current.ctime() + && original.ctime_nsec() == current.ctime_nsec() +} + /// Read only as far as complete metadata requires, up to the existing bound. /// Geometrically spaced probes keep repeated prefix parsing linear in bytes /// even when a manifest is large. Reads stay small and cancellable between @@ -233,6 +287,8 @@ mod tests { use std::cell::Cell; use std::fs::{self, File, OpenOptions}; use std::io::{Seek, SeekFrom, Write}; + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; @@ -242,7 +298,8 @@ mod tests { use super::{ CodeGenerationRetentionErrorV1, GENERATION_METADATA_READ_CHUNK_BYTES, GenerationDigestVerificationV1, MAX_GENERATION_METADATA_PREFIX_BYTES, parse_json_u32_field, - read_generation_format_revision, read_generation_metadata, read_generation_metadata_prefix, + read_generation_format_revision_from_file, read_generation_metadata, + read_generation_metadata_prefix, }; fn generation_bytes(manifest_padding: usize, payload_bytes: usize) -> Vec { @@ -270,10 +327,13 @@ mod tests { let root = tempfile::tempdir().unwrap(); let bytes = generation_bytes(0, 256 * 1024); let (path, expected_digest) = write_generation(root.path(), &bytes); + let mut revision_file = File::open(&path).unwrap(); assert_eq!( - read_generation_format_revision(&path, &|| false).unwrap(), + read_generation_format_revision_from_file(&mut revision_file, &path, &|| false) + .unwrap(), SEALED_GENERATION_FORMAT_REVISION_V1 ); + drop(revision_file); let mut file = File::open(&path).unwrap(); let (revision, manifest) = read_generation_metadata_prefix( &mut file, @@ -428,6 +488,71 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn generation_metadata_refuses_symlink_aliases_in_both_modes() { + let root = tempfile::tempdir().unwrap(); + let bytes = generation_bytes(0, 32 * 1024); + let (target, _) = write_generation(root.path(), &bytes); + let alias = root.path().join(format!("generation-{}.json", "a".repeat(64))); + std::os::unix::fs::symlink(&target, &alias).unwrap(); + assert_eq!(fs::read(&alias).unwrap(), bytes, "alias resolves to valid bytes"); + + for mode in [ + GenerationDigestVerificationV1::MetadataOnly, + GenerationDigestVerificationV1::Full, + ] { + assert!(matches!( + read_generation_metadata(&alias, mode, &|| false), + Err(CodeGenerationRetentionErrorV1::UnsafeState(_)) + )); + } + let (_, _, digest, size) = + read_generation_metadata(&target, GenerationDigestVerificationV1::Full, &|| false) + .unwrap(); + assert_eq!(digest, encode_tagged_lowercase_hex("sha256:", &Sha256::digest(&bytes))); + assert_eq!(size, bytes.len() as u64); + } + + #[cfg(unix)] + #[test] + fn generation_metadata_refuses_same_byte_inode_rotation_during_read() { + for mode in [ + GenerationDigestVerificationV1::MetadataOnly, + GenerationDigestVerificationV1::Full, + ] { + let root = tempfile::tempdir().unwrap(); + let bytes = generation_bytes(0, 64 * 1024); + let (path, _) = write_generation(root.path(), &bytes); + let substitute = root.path().join("substitute.tmp"); + fs::write(&substitute, &bytes).unwrap(); + let original = fs::metadata(&path).unwrap(); + let foreign = fs::metadata(&substitute).unwrap(); + assert_ne!( + (original.dev(), original.ino()), + (foreign.dev(), foreign.ino()), + "the replacement must use a different real inode" + ); + let probes = Cell::new(0_usize); + let result = read_generation_metadata(&path, mode, &|| { + let previous = probes.get(); + probes.set(previous + 1); + // The third cancellation checkpoint is after reading the + // first nonempty prefix from the already-open descriptor. + if previous == 2 { + fs::rename(&substitute, &path).unwrap(); + } + false + }); + assert!(probes.get() >= 3, "the real reader must cross the swap"); + assert!(matches!( + result, + Err(CodeGenerationRetentionErrorV1::UnsafeState(_)) + )); + assert_eq!(fs::read(&path).unwrap(), bytes); + } + } + #[test] fn format_revision_requires_a_complete_unsigned_json_integer() { for input in [ diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs index e5cf9aec730..683fd75a962 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs @@ -1,7 +1,11 @@ #[cfg(test)] use std::cell::Cell; use std::fs::{File, OpenOptions}; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::sync::{Mutex, MutexGuard, TryLockError}; use std::time::Instant; use tracedecay_private_fs::FileLease; @@ -47,6 +51,99 @@ impl CodeGenerationStoreLockV1 { } } +/// Reuses the same lock-file descriptor for successive bounded historical reads. +/// +/// Every read still obtains a fresh kernel shared lock. The mutex is acquired +/// without blocking so concurrent callers and cancelled requests can retry. +/// The pathname and opened inode are authenticated before and after locking; +/// a replaced lock authority must never silently authorize an old inode. +#[cfg(unix)] +pub struct CachedCodeGenerationStoreReadLockV1 { + store_root: PathBuf, + file: Mutex>, +} + +#[cfg(unix)] +pub struct CachedCodeGenerationStoreReadGuardV1<'a> { + file: MutexGuard<'a, Option>, +} + +#[cfg(unix)] +impl Drop for CachedCodeGenerationStoreReadGuardV1<'_> { + fn drop(&mut self) { + if let Some(file) = self.file.as_ref() { + if let Err(error) = file.unlock() { + tracing::warn!(lease = CODE_GENERATION_LEASE_LABEL, %error, "cached read lock release failed"); + } + } + } +} + +#[cfg(unix)] +impl CachedCodeGenerationStoreReadLockV1 { + pub fn new(store_root: &Path) -> Self { + Self { + store_root: store_root.to_path_buf(), + file: Mutex::new(None), + } + } + + fn verify_named_inode(file: &File, path: &Path) -> Result<(), CodeGenerationRetentionErrorV1> { + let opened = file.metadata().map_err(storage)?; + // A symlink to the retired inode is not the original lock authority. + // lstat authenticates the directory entry without another open(). + let named = std::fs::symlink_metadata(path).map_err(super::deferred_if_absent)?; + if !named.is_file() || (opened.dev(), opened.ino()) != (named.dev(), named.ino()) { + return Err(CodeGenerationRetentionErrorV1::UnsafeState( + "generation-store lock pathname no longer names the opened inode".to_owned(), + )); + } + Ok(()) + } + + pub fn try_acquire( + &self, + ) -> Result>, CodeGenerationRetentionErrorV1> + { + let mut cached = match self.file.try_lock() { + Ok(guard) => guard, + Err(TryLockError::WouldBlock) => return Ok(None), + Err(TryLockError::Poisoned(_)) => { + return Err(CodeGenerationRetentionErrorV1::UnsafeState( + "cached generation-store read lock is poisoned".to_owned(), + )); + } + }; + let root = canonical_store_root(&self.store_root)?; + let path = root.join(STORE_LOCK_FILE); + if cached.is_none() { + *cached = Some(open_lock_file(&path)?); + } + let Some(file) = cached.as_ref() else { + return Err(CodeGenerationRetentionErrorV1::UnsafeState( + "cached generation-store lock was not initialized".to_owned(), + )); + }; + Self::verify_named_inode(file, &path)?; + match file.try_lock_shared().map_err(std::io::Error::from) { + Ok(()) => { + let lease = CachedCodeGenerationStoreReadGuardV1 { file: cached }; + // Recheck after kernel admission, before any pointer/segment read. + // On an error the guard's Drop explicitly unlocks the descriptor. + let Some(file) = lease.file.as_ref() else { + return Err(CodeGenerationRetentionErrorV1::UnsafeState( + "cached generation-store lock disappeared".to_owned(), + )); + }; + Self::verify_named_inode(file, &path)?; + Ok(Some(lease)) + } + Err(error) if tracedecay_private_fs::is_lock_contended(&error) => Ok(None), + Err(error) => Err(storage(error)), + } + } +} + pub fn acquire_code_generation_store_lock( store_root: &Path, ) -> Result { @@ -403,3 +500,114 @@ fn open_lock_file(path: &Path) -> Result { .open(path) .map_err(super::deferred_if_absent) } + +#[cfg(all(test, unix))] +mod cached_read_lock_tests { + use super::*; + + #[test] + fn cached_reader_keeps_exclusive_fencing_and_refuses_replaced_lock_file() { + let store = tempfile::tempdir().expect("isolated generation-store root"); + let cached = CachedCodeGenerationStoreReadLockV1::new(store.path()); + + let read = cached + .try_acquire() + .expect("first shared kernel lock") + .expect("uncontended shared lease"); + assert!( + try_acquire_code_generation_store_lock(store.path()) + .expect("probe a real exclusive kernel lock") + .is_none(), + "a writer must not enter a protected historical page read" + ); + assert!( + cached + .try_acquire() + .expect("second in-process probe") + .is_none(), + "the single descriptor must not be concurrently unlocked by another reader" + ); + drop(read); + + let writer = try_acquire_code_generation_store_lock(store.path()) + .expect("exclusive acquisition after reader release") + .expect("read guard must explicitly unlock"); + assert!( + cached + .try_acquire() + .expect("shared probe under writer") + .is_none(), + "a real exclusive lock must prevent a cached shared lease" + ); + drop(writer); + drop( + cached + .try_acquire() + .expect("reader recovers from write lock") + .expect("shared lease after exclusive release"), + ); + + let path = store.path().join(STORE_LOCK_FILE); + let original = std::fs::metadata(&path).expect("cached lock inode"); + let retired = store.path().join("retired-lock-inode"); + std::fs::rename(&path, &retired) + .expect("replace the lock pathname without closing the original inode"); + std::fs::File::create(&path).expect("different lock file at the same pathname"); + let replacement = std::fs::metadata(&path).expect("new lock inode"); + assert_ne!( + (original.dev(), original.ino()), + (replacement.dev(), replacement.ino()), + "replacement must be a different real inode" + ); + assert!( + matches!( + cached.try_acquire(), + Err(CodeGenerationRetentionErrorV1::UnsafeState(_)) + ), + "a cached descriptor must not lock a retired inode to authorize a new scope" + ); + + // A symlink back to the retired inode has the same dev/ino when + // followed. The cached reader must still refuse this replacement: + // verifying metadata(path) would wrongly accept it. + std::fs::remove_file(&path).expect("remove replacement lock inode"); + std::os::unix::fs::symlink(&retired, &path) + .expect("replace the lock with an alias of the retired inode"); + let followed = std::fs::metadata(&path).expect("follow symlink as positive control"); + assert_eq!( + (followed.dev(), followed.ino()), + (original.dev(), original.ino()), + "the symlink must resolve to exactly the still-open cached inode" + ); + assert!( + std::fs::symlink_metadata(&path) + .expect("inspect replacement type") + .file_type() + .is_symlink(), + "the replacement must be a real symlink" + ); + assert!( + matches!( + cached.try_acquire(), + Err(CodeGenerationRetentionErrorV1::UnsafeState(_)) + ), + "a lock-file symlink must not authorize a retired cached inode" + ); + + // Recovery through the original pathname must not require another + // cache entry or skip the real shared kernel-lock acquisition. + std::fs::remove_file(&path).expect("remove lock symlink"); + std::fs::rename(&retired, &path).expect("restore exact original lock inode"); + let read = cached + .try_acquire() + .expect("recheck restored original lock") + .expect("reacquire real shared kernel lock"); + assert!( + try_acquire_code_generation_store_lock(store.path()) + .expect("probe exclusive lock after recovery") + .is_none(), + "restored cached read must still fence exclusive writers" + ); + drop(read); + } +} diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index 7fc9f340678..37eebb99415 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -2,6 +2,12 @@ use super::*; use super::{journal, receipt_store}; +#[cfg(target_os = "linux")] +use std::ffi::CString; +#[cfg(target_os = "linux")] +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +#[cfg(target_os = "linux")] +use std::os::unix::ffi::OsStrExt; use tracedecay_domain::sha256_hex_suffix; mod graph_replay_pool_lock_tests; @@ -997,6 +1003,7 @@ fn cancellable_artifact_apply_stops_rehash_before_quarantine_and_retries() { UtcMicros(15), None, &|| checks.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 6, + None, ) .expect_err("cancellation must interrupt the under-lock artifact rehash"); @@ -1939,6 +1946,245 @@ fn metadata_only_segment_census_distinguishes_empty_cancelled_and_exhausted() { assert_eq!(remaining_entries, 0); } +#[cfg(target_os = "linux")] +fn native_manifest_open_events(observer: &OwnedFd) -> usize { + let mut opens = 0; + let mut buffer = [0u8; 4096]; + loop { + // The watch includes close events, preventing inotify from coalescing + // successive opens of the same inode into one unread notification. + let read = unsafe { + libc::read( + observer.as_raw_fd(), + buffer.as_mut_ptr().cast(), + buffer.len(), + ) + }; + if read < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + break; + } + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + panic!("cannot read native manifest-open events: {error}"); + } + if read == 0 { + break; + } + let bytes = usize::try_from(read).expect("nonnegative event byte count"); + let header_size = std::mem::size_of::(); + let mut offset = 0; + while offset < bytes { + assert!( + offset + header_size <= bytes, + "complete native event header" + ); + let event = unsafe { + std::ptr::read_unaligned(buffer[offset..].as_ptr().cast::()) + }; + let end = offset + header_size + usize::try_from(event.len).unwrap(); + assert!(end <= bytes, "complete native event payload"); + if event.mask & libc::IN_OPEN != 0 { + opens += 1; + } + offset = end; + } + } + opens +} + +/// A full mark pass must reuse the revision probe's actual descriptor, not +/// reauthorize a pathname for the same manifest. The retained evidence is +/// still marked live, while unrelated content is eligible for collection. +#[cfg(target_os = "linux")] +#[test] +fn segment_mark_reads_a_generation_manifest_with_one_kernel_open() { + let store = tempfile::TempDir::new().expect("retention store"); + let generation_root = store.path().join(GENERATIONS_DIRECTORY); + let segments_root = store.path().join(GENERATION_SEGMENTS_DIRECTORY); + std::fs::create_dir_all(&generation_root).expect("generation directory"); + std::fs::create_dir_all(&segments_root).expect("segment directory"); + + let evidence_bytes = b"nonempty retained segment evidence"; + let evidence_digest = encode_tagged_lowercase_hex("sha256:", &Sha256::digest(evidence_bytes)); + let segment_descriptor = serde_json::json!({ + "segment_digest": evidence_digest, + "segment_size_bytes": evidence_bytes.len(), + "decoded_size_bytes": evidence_bytes.len(), + }); + let generation = serde_json::json!({ + "format_revision": SEALED_GENERATION_FORMAT_REVISION_V1, + "manifest": { + "generation_id": "generation.v1.manifest-open", + "seal": { "sealed_at": 7 } + }, + "snapshot": { "files": [] }, + "file_segments": [], + "file_evidence": [], + "generation_evidence": { + "segment_digest": evidence_digest, + "segment_size_bytes": evidence_bytes.len(), + "pages": [{ + "page_ordinal": 0, + "page_digest": evidence_digest, + "page_size_bytes": evidence_bytes.len() + }] + }, + "code_graph_pages": [], + "resolution_index": { + "definitions": [segment_descriptor.clone()], + "references": [segment_descriptor.clone()], + "import_aliases": segment_descriptor + } + }); + let generation_bytes = serde_json::to_vec(&generation).expect("serialize generation"); + let manifest_bytes = serde_json::to_vec(&serde_json::json!({ + "state_digest": encode_tagged_lowercase_hex("sha256:", &Sha256::digest(&generation_bytes)), + "generation": generation, + })) + .expect("serialize sealed manifest"); + let manifest_digest = encode_lowercase_hex(&Sha256::digest(&manifest_bytes)); + let manifest_path = generation_root.join(format!("generation-{manifest_digest}.json")); + std::fs::write(&manifest_path, &manifest_bytes).expect("write sealed manifest"); + + let referenced_path = segments_root.join(format!( + "segment-{}.json", + sha256_hex_suffix(&evidence_digest).expect("evidence digest") + )); + std::fs::write(&referenced_path, evidence_bytes).expect("write retained evidence"); + let orphan_bytes = b"unreferenced segment"; + let orphan_digest = encode_lowercase_hex(&Sha256::digest(orphan_bytes)); + let orphan_path = segments_root.join(format!("segment-{orphan_digest}.json")); + std::fs::write(&orphan_path, orphan_bytes).expect("write unreferenced segment"); + + let raw_fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) }; + assert!( + raw_fd >= 0, + "native inotify: {}", + std::io::Error::last_os_error() + ); + let observer = unsafe { OwnedFd::from_raw_fd(raw_fd) }; + let native_path = + CString::new(manifest_path.as_os_str().as_bytes()).expect("manifest path has no NUL"); + let watch = unsafe { + libc::inotify_add_watch( + observer.as_raw_fd(), + native_path.as_ptr(), + libc::IN_OPEN | libc::IN_CLOSE_NOWRITE, + ) + }; + assert!( + watch >= 0, + "native manifest watch: {}", + std::io::Error::last_os_error() + ); + + drop(File::open(&manifest_path).expect("positive control pathname open")); + assert_eq!( + native_manifest_open_events(&observer), + 1, + "the observer must detect an actual kernel open" + ); + let (found, exhausted) = + sweep_unreferenced_generation_segments(store.path(), None, false, &|| false, None) + .expect("authenticated mark and conservative sweep"); + assert!(found, "the unrelated nonempty segment is collectable"); + assert!(!exhausted); + assert_eq!( + native_manifest_open_events(&observer), + 1, + "revision probe and full segment mark must reuse one opened inode" + ); + + let (found, exhausted) = + sweep_unreferenced_generation_segments(store.path(), None, true, &|| false, None) + .expect("collect only the unreferenced segment"); + assert!(found); + assert!(!exhausted); + assert_eq!( + native_manifest_open_events(&observer), + 1, + "applying the mark must also avoid a second manifest open" + ); + assert_eq!( + std::fs::read(&referenced_path).expect("retained evidence survives"), + evidence_bytes + ); + assert!(!orphan_path.exists(), "unreferenced segment was removed"); + + // Identical byte extent but a different content-addressed file digest is + // never accepted as live evidence on the next mark. + let mut altered = manifest_bytes; + altered.push(b' '); + std::fs::write(&manifest_path, altered).expect("mutate disposable manifest"); + assert!(matches!( + sweep_unreferenced_generation_segments(store.path(), None, false, &|| false, None), + Err(CodeGenerationRetentionErrorV1::UnsafeState(_)) + )); + assert_eq!( + std::fs::read(&referenced_path).expect("corruption cannot erase live evidence"), + evidence_bytes + ); +} + +/// The marked file may be removed after the directory entry was returned. +/// A cancellation arriving at that exact boundary must not be rewritten as +/// an unrelated busy-store error or trigger any segment collection. +#[test] +fn segment_mark_prefers_cancellation_before_disappeared_manifest_open() { + let store = tempfile::TempDir::new().expect("isolated retention store"); + let generation_root = store.path().join(GENERATIONS_DIRECTORY); + let segments_root = store.path().join(GENERATION_SEGMENTS_DIRECTORY); + std::fs::create_dir_all(&generation_root).expect("generation directory"); + std::fs::create_dir_all(&segments_root).expect("segment directory"); + + let manifest_bytes = b"nonempty manifest that is withdrawn after enumeration"; + let digest = encode_lowercase_hex(&Sha256::digest(manifest_bytes)); + let manifest_path = generation_root.join(format!("generation-{digest}.json")); + std::fs::write(&manifest_path, manifest_bytes).expect("create a real manifest"); + + let segment_bytes = b"nonempty unreferenced segment remains untouched on cancellation"; + let segment_digest = encode_lowercase_hex(&Sha256::digest(segment_bytes)); + let segment_path = segments_root.join(format!("segment-{segment_digest}.json")); + std::fs::write(&segment_path, segment_bytes).expect("create segment to protect"); + + // With this single scope, the first cancellation probe runs when the + // actual manifest directory entry is visited. Simulate the listed file + // disappearing and cancellation becoming active before open(). The next + // probe must be the pre-open cancellation boundary. + let calls = std::cell::Cell::new(0usize); + let cancelled = || { + let next = calls.get() + 1; + calls.set(next); + if next == 1 { + std::fs::remove_file(&manifest_path) + .expect("simulate concurrent withdrawal of enumerated manifest"); + false + } else { + true + } + }; + let error = sweep_unreferenced_generation_segments(store.path(), None, true, &cancelled, None) + .expect_err("cancellation after enumeration must prevent segment deletion"); + assert!(matches!(error, CodeGenerationRetentionErrorV1::Cancelled)); + assert_eq!( + calls.get(), + 2, + "the pre-open cancellation check must run after enumerating the file" + ); + assert!( + !manifest_path.exists(), + "the withdrawal must actually occur" + ); + assert_eq!( + std::fs::read(&segment_path).expect("protected segment bytes"), + segment_bytes, + "cancellation cannot collect an unmarked segment" + ); +} + #[test] fn maintenance_preparation_wakes_for_an_unreferenced_final_segment() { let store = tempfile::TempDir::new().expect("create unpublished store"); @@ -3697,8 +3943,13 @@ fn recovery_completes_a_committed_rewrite_that_never_reached_the_pointer() { fn vanished_listed_generation_open_defers_instead_of_storage_loss() { let root = tempfile::tempdir().expect("census root"); let missing = root.path().join(format!("generation-{:064x}.json", 1)); - let error = super::generation_scan::read_generation_format_revision(&missing, &|| false) - .expect_err("a vanished listed generation defers the census"); + let error = super::generation_scan::read_generation_metadata( + &missing, + GenerationDigestVerificationV1::MetadataOnly, + &|| false, + ) + .err() + .expect("a vanished listed generation defers the census"); assert!( matches!(error, CodeGenerationRetentionErrorV1::GenerationStoreBusy), "a missing listed generation is a publisher race, not a storage failure: {error:?}" @@ -3706,9 +3957,13 @@ fn vanished_listed_generation_open_defers_instead_of_storage_loss() { let directory = root.path().join("not-a-generation-file"); std::fs::create_dir(&directory).expect("directory where a file was listed"); - let storage_error = - super::generation_scan::read_generation_format_revision(&directory, &|| false) - .expect_err("a directory is not a vanished file"); + let storage_error = super::generation_scan::read_generation_metadata( + &directory, + GenerationDigestVerificationV1::MetadataOnly, + &|| false, + ) + .err() + .expect("a directory is not a vanished file"); assert!( matches!(storage_error, CodeGenerationRetentionErrorV1::Storage(_)), "non-NotFound census I/O stays a storage failure: {storage_error:?}" @@ -3810,6 +4065,7 @@ fn abandoned_preparations_are_collected_only_after_shared_project_publication_le UtcMicros(1), None, &|| false, + None, ); assert!(matches!( result, @@ -3827,6 +4083,7 @@ fn abandoned_preparations_are_collected_only_after_shared_project_publication_le UtcMicros(1), None, &|| locking::store_lock_park_count() > prior_parks, + None, ); assert!(matches!( result, diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_pool_lock_tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_pool_lock_tests.rs index 2b2e5785fcd..29df960d515 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_pool_lock_tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_pool_lock_tests.rs @@ -276,6 +276,7 @@ fn execute_cancels_held_pool_acquire_before_any_exposure() { UtcMicros(121), Some(&pool_root), &|| graph_replay_pool_acquire_observation().0 >= 1, + None, ) .expect_err("cancellation during pool wait must abort execute"); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 1fd7b36eec5..e348f5e1280 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -13,21 +13,32 @@ use std::{ time::{Duration, SystemTime}, }; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; #[cfg(test)] use std::sync::atomic::AtomicUsize; +#[cfg(all(test, unix))] +thread_local! { + static PREPARED_SEGMENT_DIGEST_OPENS: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} + +#[cfg(not(unix))] use same_file::Handle; use sha2::{Digest, Sha256}; use tracedecay_application::code_index::DaemonCodeIndexControlV1; +#[cfg(unix)] +use tracedecay_code_index_retention::code_index_generations::CachedCodeGenerationStoreReadLockV1; use tracedecay_code_index_retention::code_index_generations::{ CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1, CodeGenerationRetentionErrorV1, CodeGenerationStoreLockV1, CodeIndexScopeStoreResetV1, DurableGenerationCardinalityV1, DurableGenerationIndexEntryV1, DurablePublicationPointerV1, DurableSealedCodeGenerationIdentityV1, MAX_DURABLE_GENERATION_INDEX_BYTES_V1, - MAX_DURABLE_GENERATION_INDEX_ENTRIES_V1, acquire_generation_segments_publication_lock, - code_generation_segments_root, durable_generation_index_digest, reset_code_index_scope_store, - retain_bounded_generation_index, try_acquire_code_generation_store_lock, - try_acquire_code_generation_store_read_lock, + MAX_DURABLE_GENERATION_INDEX_ENTRIES_V1, RetainedSegmentHandleRetirementV1, + acquire_generation_segments_publication_lock, code_generation_segments_root, + durable_generation_index_digest, reset_code_index_scope_store, retain_bounded_generation_index, + try_acquire_code_generation_store_lock, try_acquire_code_generation_store_read_lock, }; use tracedecay_domain::{ CodeGenerationId, ContentDigest, ManifestDigest, ProjectId, SanitizerRevision, WorktreeId, @@ -57,6 +68,26 @@ use tracedecay_code_index::{ use super::{CodeIndexSchedulerErrorV1, PendingHintsV1}; use crate::code_graph_seat::CodeGraphBuildAdmissionV1; +#[cfg(all(test, unix))] +#[path = "tests/staged_segment_reads.rs"] +mod staged_segment_reads; + +#[cfg(all(test, unix))] +#[path = "tests/immutable_segment_equality.rs"] +mod immutable_segment_equality; + +#[cfg(all(test, unix))] +#[path = "tests/retained_segment_admission.rs"] +mod retained_segment_admission; + +#[cfg(all(test, unix))] +#[path = "tests/publication_reset.rs"] +mod publication_reset; + +#[cfg(all(test, unix))] +#[path = "tests/publication_pointer_validation.rs"] +mod publication_pointer_validation; + const MAX_DURABLE_PUBLICATION_POINTER_BYTES: u64 = 512 * 1024; const DURABLE_GENERATION_IO_CHUNK_BYTES_V1: usize = 64 * 1024; @@ -145,7 +176,7 @@ impl SharedCodeIndexBytePoolV1 { pub(super) fn release_dead_entries(&self) { self.bytes .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + .unwrap_or_else(PoisonError::into_inner) .retain(|_, entry| entry.strong_count() > 0); self.physical_artifacts.release_dead_entries(); } @@ -153,10 +184,7 @@ impl SharedCodeIndexBytePoolV1 { #[cfg(test)] pub(super) fn stats(&self) -> CodeIndexBytePoolStatsV1 { let physical_artifacts = self.physical_artifacts.stats(); - let bytes = self - .bytes - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + let bytes = self.bytes.lock().unwrap_or_else(PoisonError::into_inner); CodeIndexBytePoolStatsV1 { parse_chunk_inserted: physical_artifacts.inserted, parse_chunk_reused: physical_artifacts.reused, @@ -214,7 +242,7 @@ struct DecodedGenerationStateV1 { /// The pinned active generation. /// /// Held outside the LRU deque: every unpinned query serves from it, so LRU - /// pressure from pinned or cursor-paged reads of older generations must + /// pressure from pinned or cursor-paged readers of older generations must /// never be able to drop it and force a re-decode on the request path. active: Option>, /// Bumped by every successful publication. A decode that started before a @@ -382,7 +410,7 @@ impl Drop for HeldActiveDecodeV1 { .unwrap_or_else(PoisonError::into_inner); state .in_flight - .retain(|pending| *pending != DecodeSubjectV1::Active); + .retain(|subject| *subject != DecodeSubjectV1::Active); if state.active.is_none() { state.active = self.restore.take(); } @@ -404,6 +432,10 @@ struct PublicationPointerMemoV1 { size: u64, digest: String, pointer: DurablePublicationPointerV1, + // Retain the authenticated inode, not just its digest. An ordinary + // historical range read must not pay another endpoint-authorized open. + #[cfg(unix)] + file: Option, } #[derive(Clone)] @@ -494,6 +526,13 @@ pub struct DaemonCodeIndexPublicationStoreV1 { expected_sanitizer_revision: SanitizerRevision, disposition: CodeIndexPublicationDispositionV1, pointer_memo: Arc>>, + /// A fresh shared kernel lock per historical read, without a new open(2). + #[cfg(unix)] + retained_generation_lock: Arc, + /// One bounded descriptor cache for retained page reads across requests. + /// Cloned stores share its identity checks; retirement discards it. + #[cfg(unix)] + pub(super) retained_segment_reads: Arc>>, undecoded_active_expectation: Option, /// The canonical source-hint authority plus the exact pre-capture epoch /// used by a retained rebuild. Ordinary publication leaves this absent. @@ -538,6 +577,33 @@ pub(super) struct UnpublishedCodeIndexPublicationV1 { pub(super) publication: CodeIndexSealedPublicationV1, } +// On Unix, directory dev/inode is the same identity used by the handle +// library, without an endpoint-authorized open for each prepare/commit check. +#[cfg(unix)] +#[derive(Clone, Copy, PartialEq, Eq)] +struct UnixDirectoryIdentityV1 { + device: u64, + inode: u64, +} + +#[cfg(unix)] +impl UnixDirectoryIdentityV1 { + fn at(path: &Path) -> Result { + let metadata = path + .metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + if !metadata.is_dir() { + return Err(DaemonCodeIndexPublicationStoreV1::unavailable( + "code-generation identity path is not a directory", + )); + } + Ok(Self { + device: metadata.dev(), + inode: metadata.ino(), + }) + } +} + /// An unpublished store transaction. No full store handle is retained here: /// the current handle supplies the source fence when commit is retried. pub struct DaemonCodeIndexPreparationV1 { @@ -545,8 +611,17 @@ pub struct DaemonCodeIndexPreparationV1 { expected_active_generation: Option, undecoded_expectation: Option, incumbent_bytes: Option>, + #[cfg(unix)] + store_identity: UnixDirectoryIdentityV1, + #[cfg(not(unix))] store_identity: Handle, + #[cfg(unix)] + segments_identity: UnixDirectoryIdentityV1, + #[cfg(not(unix))] segments_identity: Handle, + #[cfg(unix)] + generations_identity: UnixDirectoryIdentityV1, + #[cfg(not(unix))] generations_identity: Handle, cache: Arc, active_path: PathBuf, @@ -574,6 +649,20 @@ struct DaemonCodeIndexPreparationFilesV1 { // prohibit removing an open file. Retention outlives all staging cleanup. segments: StagedGenerationSegmentsV1, evidence: TemporaryEvidencePackV1, + // Only the open preparation owns these handles. Its retention lease + // prevents an authorized sweep from unlinking their sealed paths. + #[cfg(unix)] + pinned_segment_reads: VecDeque, + // Eviction limits descriptors but must not discard the identity recorded + // on the first open of any sealed pathname in this preparation. + #[cfg(unix)] + observed_segment_identities: BTreeMap, + #[cfg(unix)] + segment_read_open_attempts: u64, + // One changed sealed pathname invalidates the entire transaction. Retrying + // must not silently accept substituted bytes under the same digest name. + #[cfg(unix)] + segment_reads_poisoned: bool, directory: tempfile::TempDir, _retention: CodeGenerationStoreLockV1, referenced_segment_bytes: u64, @@ -602,6 +691,15 @@ impl DaemonCodeIndexPreparationV1 { }) } + #[cfg(all(test, unix))] + pub(super) fn staged_segment_read_open_attempts_for_test(&self) -> u64 { + let state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + match &*state { + DaemonCodeIndexPreparationStateV1::Open(files) => files.segment_read_open_attempts, + _ => 0, + } + } + fn invalidate_uncommitted(&self) { let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); if matches!(&*state, DaemonCodeIndexPreparationStateV1::Open(_)) { @@ -627,10 +725,16 @@ impl DaemonCodeIndexPreparationV1 { segments, mut evidence, _retention, + #[cfg(unix)] + pinned_segment_reads, .. } = *files; evidence.rollback_unattached(directory.path())?; drop(evidence); + // Release pinned descriptors before staged cleanup so a failed + // preparation cannot retain unlinked sealed content. + #[cfg(unix)] + drop(pinned_segment_reads); drop(segments); let result = directory .close() @@ -664,6 +768,12 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { let DaemonCodeIndexPreparationStateV1::Open(files) = &mut *state else { return Err(Self::closed()); }; + #[cfg(unix)] + if files.segment_reads_poisoned { + return Err(DaemonCodeIndexPublicationStoreV1::unavailable( + "staged sealed segment identity was invalidated", + )); + } if files.manifest_identity.is_some() { return Err(DaemonCodeIndexPublicationStoreV1::unavailable( "code-index preparation already entered commit", @@ -738,6 +848,10 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { .insert(self.segments_root.join(name), path); files.evidence.attach_to_manifest(); } + #[cfg(unix)] + if let Some((path, identity)) = files.evidence.authenticated.take() { + files.segments.authenticated_segments.insert(path, identity); + } files.referenced_segment_bytes = files .referenced_segment_bytes .saturating_add(segment_size_bytes); @@ -774,10 +888,12 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { let state = Arc::clone(&self.state); let segments_root = self.segments_root.clone(); Arc::new(move |request, buffer| { - let state = state + // Refusals before a handle is acquired must not expose a prior read. + buffer.clear(); + let mut state = state .lock() .map_err(|_| CodeIndexProductionErrorV1::Publication(Self::closed()))?; - match &*state { + match &mut *state { DaemonCodeIndexPreparationStateV1::Aborted => { Err(CodeIndexProductionErrorV1::Publication(Self::closed())) } @@ -789,18 +905,191 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { ) } DaemonCodeIndexPreparationStateV1::Open(files) => { - let digest = match request { - SealedGenerationSegmentReadV1::Whole { digest, .. } - | SealedGenerationSegmentReadV1::Range { digest, .. } => digest, + #[cfg(unix)] + if files.segment_reads_poisoned { + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity was invalidated".to_owned(), + )); + } + let (digest, size_bytes, offset, length) = match request { + SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { + (digest, size_bytes, 0, size_bytes) + } + SealedGenerationSegmentReadV1::Range { + digest, + size_bytes, + offset, + length, + } => (digest, size_bytes, offset, length), }; + // Rejected requests are not cache accesses: they must not + // evict a good handle or change the next eviction victim. + if offset + .checked_add(length) + .is_none_or(|end| end > size_bytes) + { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment range exceeds its manifest identity" + .to_owned(), + )); + } + usize::try_from(length).map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "sealed generation segment range exceeds addressable memory".to_owned(), + ) + })?; let suffix = sha256_hex_suffix(digest.as_str()).ok_or_else(|| { CodeIndexProductionErrorV1::Contract( "sealed segment digest is not sha256".to_owned(), ) })?; let canonical = segments_root.join(format!("segment-{suffix}.json")); - let path = files.segments.pending.get(&canonical).unwrap_or(&canonical); - DaemonCodeIndexPublicationStoreV1::read_segment_at(path, request, buffer) + let path = files + .segments + .pending + .get(&canonical) + .cloned() + .unwrap_or(canonical); + #[cfg(not(unix))] + { + DaemonCodeIndexPublicationStoreV1::read_segment_at(&path, request, buffer) + } + #[cfg(unix)] + { + // Range reads from the same staged segment can be + // interleaved by the sparse successor. Keep at most + // eight handles, in least-recently-used order. + if let Some(position) = files + .pinned_segment_reads + .iter() + .position(|entry| entry.path == path) + { + let cached = &files.pinned_segment_reads[position]; + if cached.segment.size_bytes != size_bytes { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment identity does not match its manifest" + .to_owned(), + )); + } + let unchanged = cached.unchanged(); + let entry = + files.pinned_segment_reads.remove(position).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "staged segment read cache lost a handle".to_owned(), + ) + })?; + if unchanged { + files.pinned_segment_reads.push_back(entry); + } else { + // A sealed pathname and its inode must still + // match the snapshot captured at the first open. + files.segment_reads_poisoned = true; + files.pinned_segment_reads.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity changed while pinned" + .to_owned(), + )); + } + } + if files + .pinned_segment_reads + .back() + .is_none_or(|entry| entry.path != path) + { + // An evicted pathname cannot acquire a new + // identity merely because its handle was closed. + let observed = files.observed_segment_identities.get(&path).cloned(); + let named = path.symlink_metadata(); + if let Some(original) = observed.as_ref() + && !named.as_ref().is_ok_and(|current| { + DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + original, current, + ) + }) + { + files.segment_reads_poisoned = true; + files.pinned_segment_reads.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity changed after eviction" + .to_owned(), + )); + } + let named = named.map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "staged sealed segment is unavailable: {error}" + )) + })?; + if !named.file_type().is_file() || named.len() != size_bytes { + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity does not match its manifest" + .to_owned(), + )); + } + // Authenticate the candidate before displacing a + // healthy cached handle. A failed open must not + // cause another endpoint-authorized open later. + // Admission holds at most one transient descriptor + // beyond the eight-entry retained cache. + let pinned = + DaemonCodeIndexPublicationStoreV1::open_pinned_staged_segment( + &path, + request, + &named, + &mut files.segment_read_open_attempts, + )?; + if let Some(original) = observed.as_ref() { + if !DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + original, + &pinned.identity, + ) { + files.segment_reads_poisoned = true; + files.pinned_segment_reads.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity changed while reopening" + .to_owned(), + )); + } + } else { + files + .observed_segment_identities + .insert(path.clone(), pinned.identity.clone()); + } + if files.pinned_segment_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { + files.pinned_segment_reads.pop_front(); + } + files.pinned_segment_reads.push_back(pinned); + } + let pinned = files.pinned_segment_reads.back_mut().ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "staged segment read cache has no handle".to_owned(), + ) + })?; + let result = + DaemonCodeIndexPublicationStoreV1::read_pinned_partitioned_segment( + &mut pinned.segment, + request, + buffer, + ); + // The read itself may race an in-place write or path + // replacement. Never expose bytes after that change. + if !pinned.unchanged() { + buffer.clear(); + files.segment_reads_poisoned = true; + files.pinned_segment_reads.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity changed during read".to_owned(), + )); + } + // A failed authenticated read (including a digest mismatch + // without a metadata change) invalidates the preparation. + // A retry must not reauthorize these staged bytes. + if result.is_err() { + buffer.clear(); + files.segment_reads_poisoned = true; + files.pinned_segment_reads.clear(); + } + result + } } } }) @@ -819,6 +1108,8 @@ pub(super) struct TemporaryEvidencePackV1 { page_count: u32, committed: bool, published_path: Option, + #[cfg(unix)] + authenticated: Option<(PathBuf, std::fs::Metadata)>, } /// One seal's new file segments. Each keeps a temporary name until the whole @@ -828,6 +1119,11 @@ struct StagedGenerationSegmentsV1 { durable: DurableFileBatch, /// Temporary path of every written segment, keyed by its final name. pending: BTreeMap, + // Canonical files and newly written temporaries are authenticated by + // their original opened handles. Commit rechecks inode identity rather + // than reopening and hashing already authenticated segment pathnames. + #[cfg(unix)] + authenticated_segments: BTreeMap, flushed: bool, } @@ -836,6 +1132,8 @@ impl StagedGenerationSegmentsV1 { Self { durable: DurableFileBatch::new(), pending: BTreeMap::new(), + #[cfg(unix)] + authenticated_segments: BTreeMap::new(), flushed: false, } } @@ -844,6 +1142,57 @@ impl StagedGenerationSegmentsV1 { self.pending.contains_key(final_path) } + // A staged inode was authenticated before the collision read. The + // comparator proves two CURRENT pathnames match; it cannot prove that a + // staged pathname still names the original authenticated bytes. + fn validate_authenticated_temporary( + temporary: &Path, + authenticated: Option<&std::fs::Metadata>, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + #[cfg(unix)] + if let Some(original) = authenticated { + let current = temporary + .symlink_metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + if !DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity(original, ¤t) { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "staged sealed segment changed during canonical deduplication", + )); + } + } + #[cfg(not(unix))] + let _ = (temporary, authenticated); + Ok(()) + } + + // A concurrent publisher can create the final name after our missing-path + // probe. Never replace an immutable content-addressed file in that gap. + fn publish_missing_immutable_file( + temporary: &Path, + final_path: &Path, + authenticated_temporary: Option<&std::fs::Metadata>, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + match tracedecay_private_fs::framed_log::rename_noreplace(temporary, final_path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let metadata = final_path + .symlink_metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + if !metadata.file_type().is_file() + || !DaemonCodeIndexPublicationStoreV1::files_equal(final_path, temporary)? + { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "concurrently published immutable code-index file does not match its content address", + )); + } + Self::validate_authenticated_temporary(temporary, authenticated_temporary)?; + std::fs::remove_file(temporary) + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable) + } + Err(error) => Err(DaemonCodeIndexPublicationStoreV1::unavailable(error)), + } + } + /// Flush every staged segment once, then name each. A retry resumes the /// remaining names without trying to sync paths already renamed. fn publish(&mut self) -> Result<(), CodeIndexPublicationStoreErrorV1> { @@ -858,6 +1207,20 @@ impl StagedGenerationSegmentsV1 { .first_key_value() .map(|(final_path, temporary)| (final_path.clone(), temporary.clone())) { + // Evidence packs are already fsynced before they enter pending, + // so the ordinary writer batch cannot detect a later inode swap. + // Bind every pending pathname to the bytes authenticated during + // staging before any immutable canonical name is published. + #[cfg(unix)] + let authenticated = + Some(self.authenticated_segments.get(&temporary).ok_or_else(|| { + DaemonCodeIndexPublicationStoreV1::corruption( + "staged sealed segment lacks its authenticated inode", + ) + })?); + #[cfg(not(unix))] + let authenticated = None; + Self::validate_authenticated_temporary(&temporary, authenticated)?; match final_path.symlink_metadata() { Ok(metadata) => { if !metadata.file_type().is_file() @@ -867,12 +1230,15 @@ impl StagedGenerationSegmentsV1 { "existing sealed segment does not match its content address", )); } + // The equality read can observe a new, byte-identical + // temporary inode after our pre-comparison witness check. + // Never discard the authenticated staged copy on that basis. + Self::validate_authenticated_temporary(&temporary, authenticated)?; std::fs::remove_file(&temporary) .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::rename(&temporary, &final_path) - .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + Self::publish_missing_immutable_file(&temporary, &final_path, authenticated)?; } Err(error) => return Err(DaemonCodeIndexPublicationStoreV1::unavailable(error)), } @@ -896,11 +1262,45 @@ struct PinnedGenerationSegmentV1 { file: File, } +// The file count is bounded independently of the number of staged segments. +#[cfg(unix)] +const MAX_STAGED_SEGMENT_READ_HANDLES: usize = 8; + +#[cfg(unix)] +pub(super) struct PinnedPreparationSegmentV1 { + path: PathBuf, + segment: PinnedGenerationSegmentV1, + // Both stat and fstat reflect later writes. Keep the original metadata, + // otherwise comparing them to each other accepts same-inode mutations. + identity: std::fs::Metadata, +} + +#[cfg(unix)] +impl PinnedPreparationSegmentV1 { + fn unchanged(&self) -> bool { + self.path + .symlink_metadata() + .ok() + .zip(self.segment.file.metadata().ok()) + .is_some_and(|(named, pinned)| { + DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + &self.identity, + &named, + ) && DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + &self.identity, + &pinned, + ) + }) + } +} + impl TemporaryEvidencePackV1 { pub(super) fn create(path: PathBuf) -> Result { - let file = std::fs::OpenOptions::new() - .create_new(true) - .write(true) + let mut options = std::fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + options.read(true); + let file = options .open(&path) .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; Ok(Self { @@ -911,6 +1311,8 @@ impl TemporaryEvidencePackV1 { page_count: 0, committed: false, published_path: None, + #[cfg(unix)] + authenticated: None, }) } @@ -987,15 +1389,31 @@ impl TemporaryEvidencePackV1 { let final_path = root.join(format!("segment-{digest_hex}.json")); match final_path.symlink_metadata() { Ok(metadata) => { - if !metadata.file_type().is_file() - || metadata.len() != segment_size_bytes - || DaemonCodeIndexPublicationStoreV1::state_digest_file(&final_path)? - != segment_digest.as_str() - { + // Reject non-files and mismatched extents before opening the + // canonical name: a FIFO must never block digest verification. + if !metadata.file_type().is_file() || metadata.len() != segment_size_bytes { return Err(DaemonCodeIndexPublicationStoreV1::corruption( "existing sealed evidence pack does not match its content address", )); } + #[cfg(unix)] + let actual = DaemonCodeIndexPublicationStoreV1::state_digest_existing_segment( + &final_path, + &metadata, + )?; + #[cfg(not(unix))] + let actual = DaemonCodeIndexPublicationStoreV1::state_digest_file(&final_path)?; + if actual != segment_digest.as_str() { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "existing sealed evidence pack does not match its content address", + )); + } + // The same opened inode verified by SHA-256 is authoritative + // for this preparation's subsequent manifest verification. + #[cfg(unix)] + { + self.authenticated = Some((final_path, metadata)); + } drop(self.file.take()); std::fs::remove_file(&self.path) .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; @@ -1014,11 +1432,73 @@ impl TemporaryEvidencePackV1 { })? .sync_all() .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + // On Unix, keep the original writer descriptor through the rename + // and hash the actual published bytes. Opening the pack by pathname + // again would trigger endpoint authorization for no additional proof. + #[cfg(unix)] + let before_rename = self + .file + .as_ref() + .ok_or_else(|| { + DaemonCodeIndexPublicationStoreV1::unavailable( + "sealed evidence pack writer is already closed", + ) + })? + .metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + #[cfg(not(unix))] drop(self.file.take()); let staged_path = staging_root.join(format!("segment-{digest_hex}.json")); std::fs::rename(&self.path, &staged_path) .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; - self.published_path = Some(staged_path); + self.published_path = Some(staged_path.clone()); + #[cfg(unix)] + { + let file = self.file.as_mut().ok_or_else(|| { + DaemonCodeIndexPublicationStoreV1::unavailable( + "sealed evidence pack writer is already closed", + ) + })?; + let opened = file + .metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + // Rename may change ctime, but must not change the writer inode. + if !opened.is_file() + || opened.dev() != before_rename.dev() + || opened.ino() != before_rename.ino() + || opened.len() != before_rename.len() + || opened.mtime() != before_rename.mtime() + || opened.mtime_nsec() != before_rename.mtime_nsec() + { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "sealed evidence pack writer changed during staging", + )); + } + file.seek(SeekFrom::Start(0)) + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + if DaemonCodeIndexPublicationStoreV1::state_digest_reader(file)? + != segment_digest.as_str() + { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "staged evidence pack bytes differ from the authenticated content address", + )); + } + let after = file + .metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + let named = staged_path + .symlink_metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + if !DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity(&opened, &after) + || !DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity(&after, &named) + { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "sealed evidence pack changed during writer authentication", + )); + } + self.authenticated = Some((staged_path, after)); + } + drop(self.file.take()); DaemonCodeIndexPublicationStoreV1::sync_directory(staging_root)?; Ok(true) } @@ -1111,6 +1591,12 @@ impl DaemonCodeIndexPublicationStoreV1 { expected_sanitizer_revision, disposition: CodeIndexPublicationDispositionV1::Active, pointer_memo: Arc::new(Mutex::new(None)), + #[cfg(unix)] + retained_generation_lock: Arc::new(CachedCodeGenerationStoreReadLockV1::new( + store_root, + )), + #[cfg(unix)] + retained_segment_reads: Arc::new(Mutex::new(VecDeque::new())), undecoded_active_expectation: None, reconcile_publication_fence: None, shutdown_signal: None, @@ -1249,7 +1735,9 @@ impl DaemonCodeIndexPublicationStoreV1 { } fn require_regular_pointer_slot(&self) -> Result<(), CodeIndexPublicationStoreErrorV1> { - match std::fs::metadata(&self.active_path) { + // A dangling or same-inode symlink is still not a pointer slot. + // Inspect the directory entry without another endpoint-authorized open. + match std::fs::symlink_metadata(&self.active_path) { Ok(metadata) if metadata.file_type().is_file() => Ok(()), Ok(_) => Err(Self::corrupt_non_file_pointer()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -1345,45 +1833,184 @@ impl DaemonCodeIndexPublicationStoreV1 { )); } let final_path = segments_root.join(format!("segment-{digest_hex}.json")); + // A pending file remains this preparation's verification authority even + // when another publisher installs its canonical name in the meantime. + // Verify its bytes at commit; publish() authenticates the competing + // canonical bytes before deduplicating without replacing them. + if staged.contains(&final_path) { + return Ok(()); + } + // A repeated write of an already authenticated canonical segment + // should not pay another endpoint-scanned open and SHA-256 read. + // The original opened-file witness is authoritative only while its + // exact pathname inode and metadata remain unchanged. + #[cfg(unix)] + if let Some(original) = staged.authenticated_segments.get(&final_path) { + let current = final_path.symlink_metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(original, ¤t) { + return Err(Self::corruption( + "previously authenticated sealed segment changed before repeated write", + )); + } + return Ok(()); + } match final_path.symlink_metadata() { Ok(metadata) => { existing_segment_bytes_read.fetch_add(metadata.len(), Ordering::Relaxed); if !metadata.file_type().is_file() || metadata.len() != u64::try_from(bytes.len()).map_err(Self::unavailable)? - || Self::state_digest_file(&final_path)? != expected_digest { return Err(Self::corruption( "existing sealed segment does not match its content address", )); } + #[cfg(unix)] + { + if Self::state_digest_existing_segment(&final_path, &metadata)? + != expected_digest + { + return Err(Self::corruption( + "existing sealed segment does not match its content address", + )); + } + staged.authenticated_segments.insert(final_path, metadata); + } + #[cfg(not(unix))] + if Self::state_digest_file(&final_path)? != expected_digest { + return Err(Self::corruption( + "existing sealed segment does not match its content address", + )); + } return Ok(()); } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(Self::unavailable(error)), } - if staged.contains(&final_path) { - return Ok(()); - } let temporary_path = staging_root.join(format!("segment-{digest_hex}.json")); - let mut file = std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&temporary_path) - .map_err(Self::unavailable)?; - staged.pending.insert(final_path, temporary_path.clone()); - file.write_all(bytes).map_err(Self::unavailable)?; - staged - .durable - .written(&temporary_path, &file) - .map_err(Self::unavailable) + let mut options = std::fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + options.read(true); + let mut file = options.open(&temporary_path).map_err(Self::unavailable)?; + // Read the actual staged bytes through their writer descriptor. + // A read and an inode/metadata witness prove the bytes at write time; + // no second endpoint-authorized pathname open is required at commit. + // Non-Unix durability and verification remain unchanged. + let admitted = + (|| -> Result, CodeIndexPublicationStoreErrorV1> { + file.write_all(bytes).map_err(Self::unavailable)?; + #[cfg(unix)] + let original = { + let before = file.metadata().map_err(Self::unavailable)?; + file.seek(SeekFrom::Start(0)).map_err(Self::unavailable)?; + if Self::state_digest_reader(&mut file)? != expected_digest { + return Err(Self::corruption( + "newly staged segment differs from its content address", + )); + } + let after = file.metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(&before, &after) { + return Err(Self::corruption( + "newly staged segment changed during authentication", + )); + } + after + }; + #[cfg(not(unix))] + let original = file.metadata().map_err(Self::unavailable)?; + staged + .durable + .written_with_identity(&temporary_path, &file, &original) + .map_err(Self::unavailable)?; + // On Linux the batch performed its final identity checks + // before enrolling the member. No fallible post-admission + // check may remove its pathname while retaining its handle. + #[cfg(target_os = "linux")] + { + Ok(Some(original)) + } + #[cfg(all(unix, not(target_os = "linux")))] + { + let after = file.metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(&original, &after) { + return Err(Self::corruption( + "newly staged segment changed during durability admission", + )); + } + Ok(Some(after)) + } + #[cfg(not(unix))] + { + Ok(None) + } + })(); + drop(file); + // The pending index is publication authority. Never enroll a file + // whose write, content authentication or durability admission failed. + let witness = match admitted { + Ok(witness) => witness, + Err(error) => { + std::fs::remove_file(&temporary_path).map_err(|cleanup| { + Self::unavailable(format!( + "staged segment admission failed: {error}; temporary cleanup failed: {cleanup}" + )) + })?; + return Err(error); + } + }; + if let Some(witness) = witness { + #[cfg(unix)] + staged + .authenticated_segments + .insert(temporary_path.clone(), witness); + #[cfg(not(unix))] + let _ = witness; + } + staged.pending.insert(final_path, temporary_path); + Ok(()) + } + + #[cfg(unix)] + fn state_digest_existing_segment( + path: &Path, + original: &std::fs::Metadata, + ) -> Result { + #[cfg(test)] + PREPARED_SEGMENT_DIGEST_OPENS.with(|count| count.set(count.get().saturating_add(1))); + let mut file = File::open(path).map_err(Self::unavailable)?; + let opened = file.metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(original, &opened) { + return Err(Self::corruption( + "existing sealed segment changed before authentication", + )); + } + let digest = Self::state_digest_reader(&mut file)?; + let after = file.metadata().map_err(Self::unavailable)?; + let named = path.symlink_metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(original, &after) + || !Self::same_unix_segment_identity(original, &named) + { + return Err(Self::corruption( + "existing sealed segment changed during authentication", + )); + } + Ok(digest) } fn state_digest_file(path: &Path) -> Result { + #[cfg(all(test, unix))] + PREPARED_SEGMENT_DIGEST_OPENS.with(|count| count.set(count.get().saturating_add(1))); let mut file = File::open(path).map_err(Self::unavailable)?; + Self::state_digest_reader(&mut file) + } + + fn state_digest_reader( + reader: &mut impl Read, + ) -> Result { let mut hasher = Sha256::new(); let mut buffer = vec![0_u8; DURABLE_GENERATION_IO_CHUNK_BYTES_V1]; loop { - let read = file.read(&mut buffer).map_err(Self::unavailable)?; + let read = reader.read(&mut buffer).map_err(Self::unavailable)?; if read == 0 { break; } @@ -1392,7 +2019,26 @@ impl DaemonCodeIndexPublicationStoreV1 { Ok(encode_tagged_lowercase_hex("sha256:", &hasher.finalize())) } + // A canonical generation is immutable. Close the staging descriptor and + // use the same atomic no-replace authority as sealed segments: tempfile's + // no-clobber persist may leave two hard links after an interrupted publish. + fn publish_missing_generation_manifest( + temporary: tempfile::NamedTempFile, + final_path: &Path, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + let temporary = temporary.into_temp_path(); + StagedGenerationSegmentsV1::publish_missing_immutable_file( + temporary.as_ref(), + final_path, + None, + ) + } + fn files_equal(left: &Path, right: &Path) -> Result { + // Measure the endpoint-authorized pathname opens as well as the byte + // loop, which has its own narrower span below. + let _pathname_span = + tracing::trace_span!("code_index.generation.compare_immutable_pathnames").entered(); let left_metadata = left.symlink_metadata().map_err(Self::unavailable)?; let right_metadata = right.symlink_metadata().map_err(Self::unavailable)?; if !left_metadata.file_type().is_file() || !right_metadata.file_type().is_file() { @@ -1403,25 +2049,62 @@ impl DaemonCodeIndexPublicationStoreV1 { if left_metadata.len() != right_metadata.len() { return Ok(false); } - let mut left = File::open(left).map_err(Self::unavailable)?; - let mut right = File::open(right).map_err(Self::unavailable)?; + let mut left_file = File::open(left).map_err(Self::unavailable)?; + let mut right_file = File::open(right).map_err(Self::unavailable)?; + #[cfg(unix)] + { + let left_opened = left_file.metadata().map_err(Self::unavailable)?; + let right_opened = right_file.metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(&left_metadata, &left_opened) + || !Self::same_unix_segment_identity(&right_metadata, &right_opened) + { + return Err(Self::corruption( + "immutable code-index file changed while opening for comparison", + )); + } + } let mut left_buffer = vec![0_u8; DURABLE_GENERATION_IO_CHUNK_BYTES_V1]; let mut right_buffer = vec![0_u8; DURABLE_GENERATION_IO_CHUNK_BYTES_V1]; - loop { - let left_read = left.read(&mut left_buffer).map_err(Self::unavailable)?; - if left_read == 0 { - return Ok(right - .read(&mut right_buffer[..1]) - .map_err(Self::unavailable)? - == 0); - } - right - .read_exact(&mut right_buffer[..left_read]) - .map_err(Self::unavailable)?; - if left_buffer[..left_read] != right_buffer[..left_read] { - return Ok(false); + // Content equality authorizes discarding the pending canonical copy. + // A slow endpoint-scanned read must not authorize a swapped pathname. + let equal = { + let _span = + tracing::trace_span!("code_index.generation.compare_immutable_bytes").entered(); + loop { + let left_read = left_file + .read(&mut left_buffer) + .map_err(Self::unavailable)?; + if left_read == 0 { + break right_file + .read(&mut right_buffer[..1]) + .map_err(Self::unavailable)? + == 0; + } + right_file + .read_exact(&mut right_buffer[..left_read]) + .map_err(Self::unavailable)?; + if left_buffer[..left_read] != right_buffer[..left_read] { + break false; + } + } + }; + #[cfg(unix)] + { + let left_named = left.symlink_metadata().map_err(Self::unavailable)?; + let right_named = right.symlink_metadata().map_err(Self::unavailable)?; + let left_opened = left_file.metadata().map_err(Self::unavailable)?; + let right_opened = right_file.metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(&left_metadata, &left_named) + || !Self::same_unix_segment_identity(&left_metadata, &left_opened) + || !Self::same_unix_segment_identity(&right_metadata, &right_named) + || !Self::same_unix_segment_identity(&right_metadata, &right_opened) + { + return Err(Self::corruption( + "immutable code-index file changed during comparison", + )); } } + Ok(equal) } pub(super) fn state_digest(bytes: &[u8]) -> String { @@ -1478,7 +2161,24 @@ impl DaemonCodeIndexPublicationStoreV1 { pub(super) fn read_publication_pointer( &self, ) -> Result, CodeIndexPublicationStoreErrorV1> { - let metadata = match std::fs::metadata(&self.active_path) { + self.read_publication_pointer_with_bytes() + .map(|result| result.map(|(pointer, _)| pointer)) + } + + // The CAS callers need the *exact serialized bytes* of the pointer they + // just authenticated. Return those bytes from the original file open + // rather than making an independent endpoint-scanned pathname open. + fn read_publication_pointer_with_bytes( + &self, + ) -> Result)>, CodeIndexPublicationStoreErrorV1> + { + // The named pointer must itself be a regular file. Following a + // symlink would let an equal-byte replacement inherit the memo. + #[cfg(unix)] + let named = std::fs::symlink_metadata(&self.active_path); + #[cfg(not(unix))] + let named = std::fs::metadata(&self.active_path); + let metadata = match named { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { *self @@ -1499,10 +2199,77 @@ impl DaemonCodeIndexPublicationStoreV1 { } let mtime = metadata.modified().ok(); let size = metadata.len(); + // Verify a cached pointer through its existing descriptor. Metadata + // witnesses both sides of the read, including same-byte inode swaps, + // while hashing the current bytes detects in-place changes even on + // filesystems with coarse timestamps. An atomic replacement misses + // this inode and falls through to the ordinary cold validation. + #[cfg(unix)] + { + let mut cached = self + .pointer_memo + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(entry) = cached.as_mut() + && let Some(file) = entry.file.as_mut() + { + let before = file.metadata().map_err(Self::map_pointer_io)?; + if Self::same_unix_segment_identity(&metadata, &before) { + file.seek(SeekFrom::Start(0)) + .map_err(Self::map_pointer_io)?; + let mut current = Vec::new(); + (&mut *file) + .take(MAX_DURABLE_PUBLICATION_POINTER_BYTES + 1) + .read_to_end(&mut current) + .map_err(Self::map_pointer_io)?; + let after = file.metadata().map_err(Self::map_pointer_io)?; + let named_after = self + .active_path + .symlink_metadata() + .map_err(Self::map_pointer_io)?; + if !Self::same_unix_segment_identity(&before, &after) + || !Self::same_unix_segment_identity(&before, &named_after) + { + return Err(Self::corruption( + "active code-generation pointer changed during pinned validation", + )); + } + if Self::state_digest(¤t) != entry.digest { + return Err(Self::corruption( + "active code-generation pointer bytes changed while pinned", + )); + } + entry.mtime = mtime; + entry.size = size; + return Ok(Some((entry.pointer.clone(), current))); + } + } + } // Size and mtime are hints, not identity. Text-artifact attach writes // a fixed-width pointer through another path, and a 1-second mtime // filesystem can leave both unchanged while the bytes move. The memo // is reused only when the file digest matches. + // A cold pointer validation must not authorize a second pathname + // open merely to populate the memo. Keep the original descriptor + // through parsing and authenticate its bytes again before memoizing. + #[cfg(unix)] + let (bytes, mut opened_pointer) = { + let mut file = File::open(&self.active_path).map_err(Self::map_pointer_io)?; + let mut bytes = Vec::new(); + (&mut file) + .take(MAX_DURABLE_PUBLICATION_POINTER_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(Self::map_pointer_io)?; + if u64::try_from(bytes.len()).map_err(Self::unavailable)? + > MAX_DURABLE_PUBLICATION_POINTER_BYTES + { + return Err(Self::corruption( + "durable code-generation index exceeds its byte bound", + )); + } + (bytes, file) + }; + #[cfg(not(unix))] let bytes = std::fs::read(&self.active_path).map_err(Self::map_pointer_io)?; let digest = Self::state_digest(&bytes); { @@ -1513,9 +2280,31 @@ impl DaemonCodeIndexPublicationStoreV1 { if let Some(memo) = memo.as_mut() && memo.digest == digest { + // A matching digest does not authenticate a substituted + // pathname. Preserve the cold path's inode checks without + // another endpoint-scanned open. + #[cfg(unix)] + { + let opened = opened_pointer.metadata().map_err(Self::map_pointer_io)?; + let named = self + .active_path + .symlink_metadata() + .map_err(Self::map_pointer_io)?; + if !Self::same_unix_segment_identity(&metadata, &opened) + || !Self::same_unix_segment_identity(&opened, &named) + { + return Err(Self::corruption( + "active code-generation pointer changed during cached validation", + )); + } + } memo.mtime = mtime; memo.size = size; - return Ok(Some(memo.pointer.clone())); + #[cfg(unix)] + { + memo.file = Some(opened_pointer); + } + return Ok(Some((memo.pointer.clone(), bytes))); } } let pointer: DurablePublicationPointerV1 = @@ -1633,38 +2422,139 @@ impl DaemonCodeIndexPublicationStoreV1 { .unwrap_or_else(PoisonError::into_inner); // Install only when the file is still the bytes just parsed. A rename // that landed during validation owns the memo. - if std::fs::read(&self.active_path).ok().as_deref() == Some(bytes.as_slice()) { + // On Unix, re-read through the authenticated descriptor rather than + // opening the same endpoint-scanned pathname again. The two inode + // witnesses reject a concurrent rename; the second byte comparison + // rejects an in-place change during parsing, even on coarse clocks. + #[cfg(unix)] + let unchanged = { + Self::require_cold_pointer_witness( + &self.active_path, + &mut opened_pointer, + &metadata, + &bytes, + )?; + true + }; + #[cfg(not(unix))] + let unchanged = std::fs::read(&self.active_path).ok().as_deref() == Some(bytes.as_slice()); + if unchanged { *memo = Some(PublicationPointerMemoV1 { mtime, size, digest, pointer: pointer.clone(), + #[cfg(unix)] + file: Some(opened_pointer), }); } - Ok(Some(pointer)) + Ok(Some((pointer, bytes))) + } + + /// Fail closed if a cold pointer's pathname or bytes changed while its + /// original descriptor was parsed. A failed witness is not a valid + /// publication snapshot and must never be returned to a caller. + #[cfg(unix)] + fn require_cold_pointer_witness( + path: &Path, + file: &mut File, + named_before: &std::fs::Metadata, + bytes: &[u8], + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + let opened_before = file.metadata().map_err(Self::map_pointer_io)?; + file.seek(SeekFrom::Start(0)) + .map_err(Self::map_pointer_io)?; + let mut current = Vec::new(); + (&mut *file) + .take(MAX_DURABLE_PUBLICATION_POINTER_BYTES + 1) + .read_to_end(&mut current) + .map_err(Self::map_pointer_io)?; + let opened_after = file.metadata().map_err(Self::map_pointer_io)?; + let named_after = match path.symlink_metadata() { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + Err(error) => return Err(Self::map_pointer_io(error)), + }; + if !named_after.file_type().is_file() { + return Err(Self::corrupt_non_file_pointer()); + } + // An ordinary concurrent publication replaces the pointer inode. + // Refuse that stale observation without incorrectly resetting a + // healthy store; the caller can retry on the new publication. + if (named_before.dev(), named_before.ino()) != (opened_before.dev(), opened_before.ino()) + || (named_after.dev(), named_after.ino()) != (opened_before.dev(), opened_before.ino()) + { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + // Mutation of the same inode or its bytes violates immutable pointer + // identity and requires the existing typed corruption reset. + if current != bytes + || !Self::same_unix_segment_identity(named_before, &opened_before) + || !Self::same_unix_segment_identity(&opened_before, &opened_after) + || !Self::same_unix_segment_identity(&opened_before, &named_after) + { + return Err(Self::corruption( + "active code-generation pointer changed during cold validation", + )); + } + Ok(()) } - fn remember_publication_pointer(&self, pointer: &DurablePublicationPointerV1, bytes: &[u8]) { + fn remember_publication_pointer( + &self, + pointer: &DurablePublicationPointerV1, + bytes: &[u8], + persisted: &mut File, + ) { let mut memo = self .pointer_memo .lock() .unwrap_or_else(PoisonError::into_inner); - // The memo and the file it names are one critical section. A publisher - // that observed older bytes must not install them over a newer file. - match std::fs::read(&self.active_path) { - Ok(current) if current == bytes => { - let metadata = std::fs::metadata(&self.active_path).ok(); - *memo = Some(PublicationPointerMemoV1 { - mtime: metadata - .as_ref() - .and_then(|metadata| metadata.modified().ok()), - size: metadata.map_or(0, |metadata| metadata.len()), - digest: Self::state_digest(bytes), - pointer: pointer.clone(), - }); + // The tempfile descriptor survives its atomic rename. Reopen-free + // readback authenticates that exact inode, while pathname metadata + // prevents memoizing a pointer replaced by another writer. + #[cfg(unix)] + let metadata = (|| -> std::io::Result> { + let opened = persisted.metadata()?; + let named_before = self.active_path.symlink_metadata()?; + if !Self::same_unix_segment_identity(&opened, &named_before) { + return Ok(None); } - Ok(_) => {} - Err(_) => *memo = None, + persisted.seek(SeekFrom::Start(0))?; + let mut current = Vec::new(); + (&mut *persisted) + .take(MAX_DURABLE_PUBLICATION_POINTER_BYTES + 1) + .read_to_end(&mut current)?; + let after = persisted.metadata()?; + let named_after = self.active_path.symlink_metadata()?; + Ok((current == bytes + && Self::same_unix_segment_identity(&opened, &after) + && Self::same_unix_segment_identity(&opened, &named_after)) + .then_some(named_after)) + })() + .ok() + .flatten(); + #[cfg(not(unix))] + let metadata = match std::fs::read(&self.active_path) { + Ok(current) if current == bytes => std::fs::metadata(&self.active_path).ok(), + _ => None, + }; + #[cfg(not(unix))] + let _ = persisted; + if let Some(metadata) = metadata { + *memo = Some(PublicationPointerMemoV1 { + mtime: metadata.modified().ok(), + size: metadata.len(), + digest: Self::state_digest(bytes), + pointer: pointer.clone(), + #[cfg(unix)] + file: persisted.try_clone().ok(), + }); + } else { + // Never preserve an older memo after an unverified replacement. + *memo = None; } } @@ -1711,7 +2601,7 @@ impl DaemonCodeIndexPublicationStoreV1 { .map_err(Self::unavailable)?; temporary.write_all(bytes).map_err(Self::unavailable)?; temporary.as_file().sync_all().map_err(Self::unavailable)?; - temporary + let mut persisted = temporary .persist(&self.active_path) .map_err(|error| Self::map_pointer_io(error.error))?; Self::sync_directory( @@ -1719,7 +2609,7 @@ impl DaemonCodeIndexPublicationStoreV1 { .parent() .ok_or_else(|| Self::unavailable("active pointer has no parent directory"))?, )?; - self.remember_publication_pointer(pointer, bytes); + self.remember_publication_pointer(pointer, bytes, &mut persisted); Ok(()) } @@ -1730,6 +2620,10 @@ impl DaemonCodeIndexPublicationStoreV1 { buffer: &mut Vec, control: &dyn CodeIndexExecutionControlV1, ) -> Result<(), CodeIndexProductionErrorV1> { + // A rejected or interrupted request must not return bytes left by + // an earlier successful read, even when it never reaches the file. + buffer.clear(); + #[cfg(not(unix))] let root = self .active_path .parent() @@ -1745,9 +2639,15 @@ impl DaemonCodeIndexPublicationStoreV1 { CodeIndexInterruptionV1::DeadlineExceeded, )); } - if let Some(lock) = - try_acquire_code_generation_store_read_lock(root).map_err(Self::unavailable)? - { + #[cfg(unix)] + let candidate = self + .retained_generation_lock + .try_acquire() + .map_err(Self::unavailable)?; + #[cfg(not(unix))] + let candidate = + try_acquire_code_generation_store_read_lock(root).map_err(Self::unavailable)?; + if let Some(lock) = candidate { break lock; } std::thread::park_timeout(Duration::from_millis(1)); @@ -1764,7 +2664,40 @@ impl DaemonCodeIndexPublicationStoreV1 { CodeIndexInterruptionV1::Cancelled, )); } - self.read_partitioned_segment(request, buffer) + #[cfg(unix)] + let result = self + .retained_segment_reads + .lock() + .map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "retained generation segment reader cache was poisoned".to_owned(), + ) + }) + .and_then(|mut pinned| { + Self::read_cached_retained_segment( + &self.segments_root, + request, + buffer, + &mut pinned, + ) + }); + #[cfg(not(unix))] + let result = self.read_partitioned_segment(request, buffer); + // Endpoint authorization may block inside open(). A request that was + // live before I/O must not return data after its deadline or cancel. + if control.is_cancelled() { + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Interrupted( + CodeIndexInterruptionV1::Cancelled, + )); + } + if control.is_deadline_exceeded() { + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Interrupted( + CodeIndexInterruptionV1::DeadlineExceeded, + )); + } + result } fn read_partitioned_segment( @@ -1779,8 +2712,26 @@ impl DaemonCodeIndexPublicationStoreV1 { /// the store, for a build over the sealed active generation. pub(super) fn segment_reader(&self) -> Arc { let segments_root = self.segments_root.clone(); + // A retained generation can request many pages from the same sealed + // segment. Keep descriptors inside the reader lifetime, not in a + // process-global cache that could survive generation retirement. + #[cfg(unix)] + let pinned_reads = Mutex::new(VecDeque::::new()); Arc::new(move |request, buffer: &mut Vec| { - Self::read_segment_under(&segments_root, request, buffer) + buffer.clear(); + #[cfg(unix)] + { + let mut pinned = pinned_reads.lock().map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "sealed generation reader cache was poisoned".to_owned(), + ) + })?; + Self::read_cached_retained_segment(&segments_root, request, buffer, &mut pinned) + } + #[cfg(not(unix))] + { + Self::read_segment_under(&segments_root, request, buffer) + } }) } @@ -1794,6 +2745,7 @@ impl DaemonCodeIndexPublicationStoreV1 { request: SealedGenerationSegmentReadV1<'_>, buffer: &mut Vec, ) -> Result<(), CodeIndexProductionErrorV1> { + buffer.clear(); let digest = match request { SealedGenerationSegmentReadV1::Whole { digest, .. } | SealedGenerationSegmentReadV1::Range { digest, .. } => digest, @@ -1805,12 +2757,23 @@ impl DaemonCodeIndexPublicationStoreV1 { Self::read_segment_at(&path, request, buffer) } - fn read_segment_at( - path: &Path, + /// Reuse endpoint-authorized opens for repeated pages of a retained + /// generation. Every hit still checks the named and opened inode: a pinned + /// descriptor cannot authorize reads through a replaced pathname. + #[cfg(unix)] + #[tracing::instrument( + name = "code_index.generation.decode.segment", + level = "trace", + skip_all + )] + fn read_cached_retained_segment( + segments_root: &Path, request: SealedGenerationSegmentReadV1<'_>, buffer: &mut Vec, + pinned_reads: &mut VecDeque, ) -> Result<(), CodeIndexProductionErrorV1> { - let (_digest, expected_size, offset, length) = match request { + buffer.clear(); + let (digest, size_bytes, offset, length) = match request { SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { (digest, size_bytes, 0, size_bytes) } @@ -1821,6 +2784,141 @@ impl DaemonCodeIndexPublicationStoreV1 { length, } => (digest, size_bytes, offset, length), }; + if offset + .checked_add(length) + .is_none_or(|end| end > size_bytes) + { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment range exceeds its manifest identity".to_owned(), + )); + } + usize::try_from(length).map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "sealed generation segment range exceeds addressable memory".to_owned(), + ) + })?; + let suffix = sha256_hex_suffix(digest.as_str()).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("sealed segment digest is not sha256".to_owned()) + })?; + let path = segments_root.join(format!("segment-{suffix}.json")); + if let Some(index) = pinned_reads.iter().position(|entry| entry.path == path) { + if pinned_reads[index].segment.size_bytes != size_bytes { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment identity does not match its manifest".to_owned(), + )); + } + if !pinned_reads[index].unchanged() { + pinned_reads.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "retained sealed segment changed while pinned".to_owned(), + )); + } + if let Err(error) = + Self::read_checked_retained_segment(&mut pinned_reads[index], request, buffer) + { + pinned_reads.clear(); + return Err(error); + } + // Only a successful read changes recency. A failed cache hit + // retains the existing fail-closed invalidation behavior. + let entry = pinned_reads.remove(index).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed generation read cache lost a handle".to_owned(), + ) + })?; + pinned_reads.push_back(entry); + } else { + let named = path.symlink_metadata().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment is unavailable: {error}" + )) + })?; + if !named.file_type().is_file() || named.len() != size_bytes { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment identity does not match its manifest".to_owned(), + )); + } + // Opening is not admission: the first authenticated read must + // also succeed before a healthy cached handle can be evicted. + // Handle::from_path would trigger an additional authorized open. + let file = File::open(&path).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment cannot be opened: {error}" + )) + })?; + let opened = file.metadata().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment metadata cannot be read: {error}" + )) + })?; + let named_after = path.symlink_metadata().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment pathname cannot be rechecked: {error}" + )) + })?; + if !Self::same_unix_segment_identity(&named, &opened) + || !Self::same_unix_segment_identity(&named_after, &opened) + { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment identity changed while opening".to_owned(), + )); + } + let mut candidate = PinnedPreparationSegmentV1 { + path, + identity: opened, + segment: PinnedGenerationSegmentV1 { + digest: digest.as_str().to_owned(), + size_bytes, + file, + }, + }; + // A failed cold candidate owns no cache authority. Drop only + // that candidate; existing handles still validate on every use. + Self::read_checked_retained_segment(&mut candidate, request, buffer)?; + if pinned_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { + pinned_reads.pop_front(); + } + pinned_reads.push_back(candidate); + } + Ok(()) + } + + #[cfg(unix)] + fn read_checked_retained_segment( + pinned: &mut PinnedPreparationSegmentV1, + request: SealedGenerationSegmentReadV1<'_>, + buffer: &mut Vec, + ) -> Result<(), CodeIndexProductionErrorV1> { + let result = Self::read_pinned_partitioned_segment(&mut pinned.segment, request, buffer); + if !pinned.unchanged() { + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "retained sealed segment changed during read".to_owned(), + )); + } + if result.is_err() { + buffer.clear(); + } + result + } + + fn read_segment_at( + path: &Path, + request: SealedGenerationSegmentReadV1<'_>, + buffer: &mut Vec, + ) -> Result<(), CodeIndexProductionErrorV1> { + buffer.clear(); + let (digest, expected_size, offset, length, whole) = match request { + SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { + (digest, size_bytes, 0, size_bytes, true) + } + SealedGenerationSegmentReadV1::Range { + digest, + size_bytes, + offset, + length, + } => (digest, size_bytes, offset, length, false), + }; if offset .checked_add(length) .is_none_or(|end| end > expected_size) @@ -1839,23 +2937,55 @@ impl DaemonCodeIndexPublicationStoreV1 { "sealed generation segment identity does not match its manifest".to_owned(), )); } - buffer.clear(); let length = usize::try_from(length).map_err(|_| { CodeIndexProductionErrorV1::Contract( "sealed generation segment range exceeds addressable memory".to_owned(), ) })?; buffer.resize(length, 0); - File::open(path) - .and_then(|mut file| { - file.seek(SeekFrom::Start(offset))?; - file.read_exact(buffer) - }) - .map_err(|error| { - CodeIndexProductionErrorV1::Contract(format!( - "sealed generation segment read failed: {error}" - )) - }) + let read = (|| -> std::io::Result<()> { + let mut file = File::open(path)?; + #[cfg(unix)] + { + let opened = file.metadata()?; + if !Self::same_unix_segment_identity(&metadata, &opened) { + return Err(std::io::Error::other( + "sealed segment pathname changed while opening", + )); + } + } + file.seek(SeekFrom::Start(offset))?; + file.read_exact(buffer)?; + #[cfg(unix)] + { + let opened = file.metadata()?; + let named = path.symlink_metadata()?; + if !Self::same_unix_segment_identity(&metadata, &opened) + || !Self::same_unix_segment_identity(&metadata, &named) + { + return Err(std::io::Error::other( + "sealed segment pathname or bytes changed during read", + )); + } + } + Ok(()) + })(); + if let Err(error) = read { + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment read failed: {error}" + ))); + } + // A full segment has its content address available without another + // pathname open. Never return same-length corrupt bytes to a caller. + // Range callers retain their existing per-page digest authority. + if whole && Self::state_digest(buffer) != digest.as_str() { + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment does not match its content address".to_owned(), + )); + } + Ok(()) } fn open_pinned_partitioned_segment( @@ -1907,33 +3037,117 @@ impl DaemonCodeIndexPublicationStoreV1 { "sealed generation segment metadata cannot be read: {error}" )) })?; - let file_identity = Handle::from_file(file.try_clone().map_err(|error| { + // On Unix the pathname/opened inode and change-time identity can be + // compared without Handle::from_path (another endpoint-scanned open). + #[cfg(unix)] + let same_identity = { + // Also re-stat the pathname after opening. Comparing just the + // pre-open path and fd would miss a replacement after open, while + // Handle::from_path would require another endpoint-scanned open. + let path_after_open = path.symlink_metadata().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment pathname cannot be rechecked: {error}" + )) + })?; + Self::same_unix_segment_identity(&path_metadata, &file_metadata) + && Self::same_unix_segment_identity(&path_after_open, &file_metadata) + }; + #[cfg(not(unix))] + let same_identity = { + let file_identity = Handle::from_file(file.try_clone().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment handle cannot be cloned: {error}" + )) + })?) + .map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment handle identity cannot be read: {error}" + )) + })?; + let path_identity = Handle::from_path(&path).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment path identity cannot be read: {error}" + )) + })?; + file_identity == path_identity + }; + if !file_metadata.is_file() || file_metadata.len() != expected_size || !same_identity { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment identity changed while it was opened".to_owned(), + )); + } + Ok(PinnedGenerationSegmentV1 { + digest: digest.to_owned(), + size_bytes: expected_size, + file, + }) + } + + #[cfg(unix)] + fn same_unix_segment_identity(named: &std::fs::Metadata, pinned: &std::fs::Metadata) -> bool { + named.file_type().is_file() + && pinned.is_file() + && named.dev() == pinned.dev() + && named.ino() == pinned.ino() + && named.len() == pinned.len() + && named.mtime() == pinned.mtime() + && named.mtime_nsec() == pinned.mtime_nsec() + && named.ctime() == pinned.ctime() + && named.ctime_nsec() == pinned.ctime_nsec() + } + + /// Open an admitted staged pathname without another pre-open stat or + /// `Handle::from_path` open. The caller validates the request and metadata + /// before evicting a cached handle; this checks identity across the open. + #[cfg(unix)] + fn open_pinned_staged_segment( + path: &Path, + request: SealedGenerationSegmentReadV1<'_>, + named: &std::fs::Metadata, + open_attempts: &mut u64, + ) -> Result { + let (digest, size_bytes) = match request { + SealedGenerationSegmentReadV1::Whole { digest, size_bytes } + | SealedGenerationSegmentReadV1::Range { + digest, size_bytes, .. + } => (digest.as_str(), size_bytes), + }; + *open_attempts = (*open_attempts).saturating_add(1); + tracing::trace!( + target: "tracedecay::code_index", + staged_segment_file_open_count = *open_attempts, + "attempted staged segment pathname open after an LRU miss" + ); + let file = File::open(path).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( - "sealed generation segment handle cannot be cloned: {error}" + "staged sealed segment cannot be opened: {error}" )) - })?) - .map_err(|error| { + })?; + let opened = file.metadata().map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( - "sealed generation segment handle identity cannot be read: {error}" + "staged sealed segment metadata cannot be read: {error}" )) })?; - let path_identity = Handle::from_path(&path).map_err(|error| { + let named_after_open = path.symlink_metadata().map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( - "sealed generation segment path identity cannot be read: {error}" + "staged sealed segment pathname cannot be rechecked: {error}" )) })?; - if !file_metadata.is_file() - || file_metadata.len() != expected_size - || file_identity != path_identity + if !Self::same_unix_segment_identity(named, &opened) + || !Self::same_unix_segment_identity(&named_after_open, &opened) { return Err(CodeIndexProductionErrorV1::Contract( - "sealed generation segment identity changed while it was opened".to_owned(), + "staged sealed segment was replaced while opening".to_owned(), )); } - Ok(PinnedGenerationSegmentV1 { - digest: digest.to_owned(), - size_bytes: expected_size, - file, + Ok(PinnedPreparationSegmentV1 { + path: path.to_path_buf(), + identity: opened, + segment: PinnedGenerationSegmentV1 { + digest: digest.to_owned(), + size_bytes, + file, + }, }) } @@ -1942,16 +3156,18 @@ impl DaemonCodeIndexPublicationStoreV1 { request: SealedGenerationSegmentReadV1<'_>, buffer: &mut Vec, ) -> Result<(), CodeIndexProductionErrorV1> { - let (digest, expected_size, offset, length) = match request { + // Errors must not leave bytes from an earlier successful range visible. + buffer.clear(); + let (digest, expected_size, offset, length, whole) = match request { SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { - (digest.as_str(), size_bytes, 0, size_bytes) + (digest.as_str(), size_bytes, 0, size_bytes, true) } SealedGenerationSegmentReadV1::Range { digest, size_bytes, offset, length, - } => (digest.as_str(), size_bytes, offset, length), + } => (digest.as_str(), size_bytes, offset, length, false), }; if offset .checked_add(length) @@ -1968,17 +3184,26 @@ impl DaemonCodeIndexPublicationStoreV1 { "sealed generation segment range exceeds addressable memory".to_owned(), ) })?; - buffer.clear(); buffer.resize(length, 0); pinned .file .seek(SeekFrom::Start(offset)) .and_then(|_| pinned.file.read_exact(buffer)) .map_err(|error| { + buffer.clear(); CodeIndexProductionErrorV1::Contract(format!( "sealed generation segment read failed: {error}" )) - }) + })?; + // A cold pinned read must enforce the same whole-segment digest + // contract as the uncached reader, not just its pathname and extent. + if whole && Self::state_digest(buffer) != digest { + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment does not match its content address".to_owned(), + )); + } + Ok(()) } #[tracing::instrument( @@ -2880,6 +4105,28 @@ impl DaemonCodeIndexPublicationStoreV1 { /// entry shape digests differently, is deleted outright: no copy is kept /// and nothing is migrated. A store another owner holds is a typed busy /// refusal the caller retries; every other failure is reported as is. + pub(super) fn retained_segment_reclaimer(&self) -> RetainedSegmentHandleRetirementV1 { + #[cfg(unix)] + { + let cached = Arc::clone(&self.retained_segment_reads); + Arc::new(move || { + cached + .lock() + .map_err(|_| { + CodeGenerationRetentionErrorV1::UnsafeState( + "retained segment cache is poisoned".to_owned(), + ) + })? + .clear(); + Ok(()) + }) + } + #[cfg(not(unix))] + { + Arc::new(|| Ok(())) + } + } + pub(super) fn reset_corrupt_store( &self, ) -> Result { @@ -2890,7 +4137,25 @@ impl DaemonCodeIndexPublicationStoreV1 { let store_lock = try_acquire_code_generation_store_lock(store_root) .map_err(Self::unavailable)? .ok_or(CodeIndexPublicationStoreErrorV1::StoreLockContended)?; - let receipt = reset_code_index_scope_store(&store_lock).map_err(Self::unavailable)?; + // Do not let an old retained descriptor keep deleted segment bytes + // resident after this scope's reset, even when another store clone + // served the last page. + #[cfg(unix)] + self.retained_segment_reads + .lock() + .map_err(|_| Self::unavailable("retained segment read cache was poisoned"))? + .clear(); + let receipt = { + // Release the shared pointer descriptor before any unlink, even + // when later cleanup or reconstruction fails. Keep the memo locked + // through deletion so a reader cannot repopulate it during reset. + let mut memo = self + .pointer_memo + .lock() + .unwrap_or_else(PoisonError::into_inner); + *memo = None; + reset_code_index_scope_store(&store_lock).map_err(Self::unavailable)? + }; std::fs::create_dir_all(&self.generations_root).map_err(Self::unavailable)?; tracedecay_code_index_retention::code_index_generations::record_scope_root( store_root, @@ -2908,10 +4173,6 @@ impl DaemonCodeIndexPublicationStoreV1 { .lock() .unwrap_or_else(PoisonError::into_inner) = None; self.cache.ready.notify_all(); - *self - .pointer_memo - .lock() - .unwrap_or_else(PoisonError::into_inner) = None; *self .active_manifest_memo .lock() @@ -2993,13 +4254,10 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { }; let (incumbent_bytes, pointer) = { let _read = self.acquire_generation_read_lock()?; - let pointer = self.read_publication_pointer()?; - let bytes = if pointer.is_some() { - Some(std::fs::read(&self.active_path).map_err(Self::unavailable)?) - } else { - None - }; - (bytes, pointer) + match self.read_publication_pointer_with_bytes()? { + Some((pointer, bytes)) => (Some(bytes), Some(pointer)), + None => (None, None), + } }; match undecoded_expectation.as_ref() { Some(expected) @@ -3018,9 +4276,18 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { { return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); } + #[cfg(unix)] + let store_identity = UnixDirectoryIdentityV1::at(store_root)?; + #[cfg(not(unix))] let store_identity = Handle::from_path(store_root).map_err(Self::unavailable)?; + #[cfg(unix)] + let segments_identity = UnixDirectoryIdentityV1::at(&self.segments_root)?; + #[cfg(not(unix))] let segments_identity = Handle::from_path(&self.segments_root).map_err(Self::unavailable)?; + #[cfg(unix)] + let generations_identity = UnixDirectoryIdentityV1::at(&self.generations_root)?; + #[cfg(not(unix))] let generations_identity = Handle::from_path(&self.generations_root).map_err(Self::unavailable)?; self.seal_checkpoint()?; @@ -3053,6 +4320,14 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { directory, segments: StagedGenerationSegmentsV1::new(), evidence, + #[cfg(unix)] + pinned_segment_reads: VecDeque::new(), + #[cfg(unix)] + observed_segment_identities: BTreeMap::new(), + #[cfg(unix)] + segment_read_open_attempts: 0, + #[cfg(unix)] + segment_reads_poisoned: false, _retention: retention, referenced_segment_bytes: 0, manifest_identity: None, @@ -3112,6 +4387,12 @@ impl DaemonCodeIndexPublicationStoreV1 { let DaemonCodeIndexPreparationStateV1::Open(files) = &mut *session else { return Err(DaemonCodeIndexPreparationV1::closed()); }; + #[cfg(unix)] + if files.segment_reads_poisoned { + return Err(Self::unavailable( + "staged sealed segment identity was invalidated", + )); + } let manifest_bytes = generation.manifest_bytes(); let generation_size = u64::try_from(manifest_bytes.len()).map_err(Self::unavailable)?; if generation_size > MAX_DURABLE_GENERATION_INDEX_BYTES_V1 { @@ -3138,11 +4419,9 @@ impl DaemonCodeIndexPublicationStoreV1 { .map_err(Self::unavailable)? .ok_or(CodeIndexPublicationStoreErrorV1::StoreLockContended)?; self.validate_preparation_store(preparation, generation)?; - let prior_pointer = self.read_publication_pointer()?; - let prior_bytes = if prior_pointer.is_some() { - Some(std::fs::read(&self.active_path).map_err(Self::unavailable)?) - } else { - None + let (prior_pointer, prior_bytes) = match self.read_publication_pointer_with_bytes()? { + Some((pointer, bytes)) => (Some(pointer), Some(bytes)), + None => (None, None), }; let pointer_already_installed = files.pending_pointer.as_deref().is_some() && files.pending_pointer.as_deref() == prior_bytes.as_deref(); @@ -3263,7 +4542,19 @@ impl DaemonCodeIndexPublicationStoreV1 { let state_digest = { let _span = tracing::trace_span!("code_index.generation.publish.state_digest").entered(); - Self::state_digest_file(temporary.path())? + // Read back the fsynced bytes through the writer's existing handle. + // A second pathname open is unnecessary and expensive under EDR. + temporary + .as_file_mut() + .seek(SeekFrom::Start(0)) + .map_err(Self::unavailable)?; + let digest = Self::state_digest_reader(temporary.as_file_mut())?; + if digest != Self::state_digest(manifest_bytes) { + return Err(Self::corruption( + "staged generation bytes changed after serialization", + )); + } + digest }; let suffix = sha256_hex_suffix(&state_digest) .ok_or_else(|| Self::unavailable("sealed generation digest is not sha256"))?; @@ -3283,11 +4574,9 @@ impl DaemonCodeIndexPublicationStoreV1 { temporary.close().map_err(Self::unavailable)?; } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - // The manifest is immutable once named. Failure cleanup never - // deletes a canonical name another scope may have attached. - temporary - .persist(&generation_path) - .map_err(|error| Self::unavailable(error.error))?; + // A concurrent publisher can win after the missing-path probe. + // Never replace its immutable generation, even transiently. + Self::publish_missing_generation_manifest(temporary, &generation_path)?; } Err(error) => return Err(Self::unavailable(error)), } @@ -3406,17 +4695,27 @@ impl DaemonCodeIndexPublicationStoreV1 { .ok_or_else(|| Self::unavailable("active code-generation pointer has no store root"))?; if self.active_path != preparation.active_path || !Arc::ptr_eq(&self.cache, &preparation.cache) - || Handle::from_path(store_root).map_err(Self::unavailable)? - != preparation.store_identity - || Handle::from_path(&self.segments_root).map_err(Self::unavailable)? - != preparation.segments_identity - || Handle::from_path(&self.generations_root).map_err(Self::unavailable)? - != preparation.generations_identity || self.disposition != preparation.disposition || CodeIndexGenerationScopeV1::for_snapshot(generation.snapshot()) != preparation.scope { return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); } + #[cfg(unix)] + let same_directories = UnixDirectoryIdentityV1::at(store_root)? + == preparation.store_identity + && UnixDirectoryIdentityV1::at(&self.segments_root)? == preparation.segments_identity + && UnixDirectoryIdentityV1::at(&self.generations_root)? + == preparation.generations_identity; + #[cfg(not(unix))] + let same_directories = Handle::from_path(store_root).map_err(Self::unavailable)? + == preparation.store_identity + && Handle::from_path(&self.segments_root).map_err(Self::unavailable)? + == preparation.segments_identity + && Handle::from_path(&self.generations_root).map_err(Self::unavailable)? + == preparation.generations_identity; + if !same_directories { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } Ok(()) } @@ -3453,10 +4752,22 @@ impl DaemonCodeIndexPublicationStoreV1 { Self::unavailable(error) } })?; - if !metadata.file_type().is_file() - || metadata.len() != identity.size_bytes - || Self::state_digest_file(path)? != identity.digest.as_str() - { + if !metadata.file_type().is_file() || metadata.len() != identity.size_bytes { + return Err(Self::corruption( + "prepared generation segment does not match its manifest identity", + )); + } + #[cfg(unix)] + if let Some(original) = files.segments.authenticated_segments.get(path) { + if !Self::same_unix_segment_identity(original, &metadata) { + return Err(Self::corruption( + "previously verified sealed segment changed before commit", + )); + } + verified.insert(identity.digest, identity.size_bytes); + continue; + } + if Self::state_digest_file(path)? != identity.digest.as_str() { return Err(Self::corruption( "prepared generation segment does not match its manifest identity", )); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs index 8e4b0a26fdb..c25e041ed50 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs @@ -26,6 +26,7 @@ use tracedecay_code_index::production::{ CodeIndexExecutionControlV1, CodeIndexPublishedGenerationV1, VerifiedSealedTextGenerationMetadataV1, }; +use tracedecay_code_index_retention::code_index_generations::RetainedSegmentHandleRetirementV1; use tracedecay_contracts::code_index_freshness::{ CodeGraphServingReadinessV1, CodeIndexBuildBlockedReasonV1, CodeIndexBuildPhaseV1, CodeIndexBuildProgressV1, CodeIndexConvergenceParkedV1, @@ -2386,6 +2387,34 @@ type AttributionSeatsV1 = BTreeMap)>; impl CodeIndexSchedulerRegistryV1 { + /// Coordinate ordinary daemon retention with retained-history readers. + /// The executor invokes the returned owner only while it holds the + /// exclusive store lock; no scheduler mutex or I/O is held here. + pub async fn retained_segment_reclaimer( + &self, + project_root: &Path, + ) -> Option { + let project_root = canonical_existing_identity(project_root).ok()?; + if let Some(retire) = self.mounted.lock().await.get(&project_root).map(|mounted| { + mounted + .historical_generation_owner + .publication + .retained_segment_reclaimer() + }) { + return Some(retire); + } + self.retiring + .lock() + .await + .get(&project_root) + .map(|retiring| { + retiring + .historical_generation_owner + .publication + .retained_segment_reclaimer() + }) + } + fn incomplete_text_slice_may_continue(pending_wake: &PendingWakeV1) -> bool { !pending_wake.has_pending_arrival() } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs new file mode 100644 index 00000000000..960310540b3 --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs @@ -0,0 +1,247 @@ +//! Immutable deduplication must never accept bytes from a displaced pathname. +use std::fs; +use std::os::unix::fs::MetadataExt; +use std::path::PathBuf; +use std::sync::{ + Arc, + atomic::{AtomicU64, AtomicUsize, Ordering}, +}; + +use sha2::{Digest, Sha256}; +use tempfile::TempDir; +use tracedecay_domain::{ManifestDigest, sha256_hex_suffix}; +use tracedecay_runtime_core::logging::set_tracing_capture; +use tracing::level_filters::LevelFilter; +use tracing::span::{Attributes, Id, Record}; +use tracing::{Event, Metadata, Subscriber}; + +use super::{DaemonCodeIndexPublicationStoreV1, StagedGenerationSegmentsV1}; + +struct ReplaceCanonicalDuringComparison { + canonical: PathBuf, + displaced: PathBuf, + replacement: PathBuf, + observed: Arc, +} + +impl Subscriber for ReplaceCanonicalDuringComparison { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.is_span() && metadata.name() == "code_index.generation.compare_immutable_bytes" + } + + fn max_level_hint(&self) -> Option { + Some(LevelFilter::TRACE) + } + + fn new_span(&self, _attributes: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, _event: &Event<'_>) {} + + fn enter(&self, _span: &Id) { + assert_eq!( + self.observed.fetch_add(1, Ordering::SeqCst), + 0, + "the byte comparison must enter exactly once" + ); + // Both real files were already opened; substitute an equal-byte new + // inode so comparing descriptor bytes alone incorrectly returns true. + fs::rename(&self.canonical, &self.displaced).expect("displace original inode"); + fs::rename(&self.replacement, &self.canonical).expect("install different inode"); + } + + fn exit(&self, _span: &Id) {} +} + +#[test] +fn canonical_deduplication_refuses_same_byte_path_replacement_during_read() { + let root = TempDir::new().expect("private immutable comparison fixture"); + let canonical = root.path().join("segment-canonical.json"); + let pending = root.path().join("segment-pending.json"); + let displaced = root.path().join("segment-displaced.json"); + let replacement = root.path().join("segment-replacement.json"); + let bytes = b"nonempty deterministic immutable segment bytes"; + fs::write(&canonical, bytes).expect("canonical content"); + fs::write(&pending, bytes).expect("pending content"); + fs::write(&replacement, bytes).expect("replacement content"); + + let canonical_identity = fs::symlink_metadata(&canonical).expect("canonical identity"); + let replacement_identity = fs::symlink_metadata(&replacement).expect("replacement identity"); + assert_ne!( + (canonical_identity.dev(), canonical_identity.ino()), + (replacement_identity.dev(), replacement_identity.ino()), + "the replacement must be a real, different inode" + ); + assert!( + DaemonCodeIndexPublicationStoreV1::files_equal(&canonical, &pending) + .expect("intact files compare equal"), + "positive control reads two nonempty actual files" + ); + + let observed = Arc::new(AtomicUsize::new(0)); + let swap = ReplaceCanonicalDuringComparison { + canonical: canonical.clone(), + displaced: displaced.clone(), + replacement, + observed: Arc::clone(&observed), + }; + let result = { + let _capture = set_tracing_capture(swap); + DaemonCodeIndexPublicationStoreV1::files_equal(&canonical, &pending) + }; + assert_eq!(observed.load(Ordering::SeqCst), 1); + let error = result.expect_err("a changed canonical name cannot authorize deduplication"); + assert!( + error.to_string().contains("changed during comparison"), + "the exact inode change must be the refusal: {error}" + ); + assert_eq!(fs::read(&canonical).expect("replacement bytes"), bytes); + assert_eq!(fs::read(&pending).expect("pending bytes"), bytes); + let installed = fs::symlink_metadata(&canonical).expect("installed identity"); + assert_eq!( + (installed.dev(), installed.ino()), + (replacement_identity.dev(), replacement_identity.ino()) + ); + + fs::remove_file(&canonical).expect("discard substituted fixture"); + fs::rename(&displaced, &canonical).expect("restore original fixture inode"); + assert!( + DaemonCodeIndexPublicationStoreV1::files_equal(&canonical, &pending) + .expect("recovered exact original comparison"), + "the original bytes still deduplicate after recovery" + ); +} + +struct ReplacePendingBeforeComparison { + pending: PathBuf, + displaced: PathBuf, + replacement: PathBuf, + observed: Arc, +} + +impl Subscriber for ReplacePendingBeforeComparison { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.is_span() && metadata.name() == "code_index.generation.compare_immutable_pathnames" + } + + fn max_level_hint(&self) -> Option { + Some(LevelFilter::TRACE) + } + + fn new_span(&self, _attributes: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, _event: &Event<'_>) {} + + fn enter(&self, _span: &Id) { + assert_eq!( + self.observed.fetch_add(1, Ordering::SeqCst), + 0, + "a single canonical comparison must run" + ); + // The publisher already checked the original staged inode. Substitute + // a second same-byte inode BEFORE the comparator samples metadata. + fs::rename(&self.pending, &self.displaced).expect("retain original staged inode"); + fs::rename(&self.replacement, &self.pending).expect("replace staged pathname"); + } + + fn exit(&self, _span: &Id) {} +} + +#[test] +fn staged_deduplication_refuses_a_swapped_temporary_before_comparison() { + let root = TempDir::new().expect("isolated immutable publication fixture"); + let segments = root.path().join("segments"); + let temporary_root = root.path().join("staging"); + fs::create_dir(&segments).expect("canonical segment directory"); + fs::create_dir(&temporary_root).expect("staging directory"); + let bytes = b"authenticated nonempty segment for canonical deduplication"; + let digest = + ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)).expect("content address"); + let suffix = sha256_hex_suffix(digest.as_str()).expect("canonical hex suffix"); + let canonical = segments.join(format!("segment-{suffix}.json")); + let mut staged = StagedGenerationSegmentsV1::new(); + let already_read = AtomicU64::new(0); + DaemonCodeIndexPublicationStoreV1::stage_segment( + &segments, + &temporary_root, + &already_read, + &digest, + bytes, + &mut staged, + ) + .expect("real stage with authenticated writer and durability admission"); + let temporary = staged + .pending + .get(&canonical) + .expect("pending content-addressed segment") + .clone(); + let original = fs::symlink_metadata(&temporary).expect("authenticated inode"); + fs::write(&canonical, bytes).expect("concurrently named canonical bytes"); + let replacement = temporary_root.join("replacement"); + let displaced = temporary_root.join("original"); + fs::write(&replacement, bytes).expect("same bytes in a second inode"); + let changed = fs::symlink_metadata(&replacement).expect("replacement inode"); + assert_ne!( + (original.dev(), original.ino()), + (changed.dev(), changed.ino()) + ); + assert_eq!(already_read.load(Ordering::Relaxed), 0); + + let observed = Arc::new(AtomicUsize::new(0)); + let swap = ReplacePendingBeforeComparison { + pending: temporary.clone(), + displaced: displaced.clone(), + replacement, + observed: Arc::clone(&observed), + }; + let result = { + let _capture = set_tracing_capture(swap); + staged.publish() + }; + assert_eq!(observed.load(Ordering::SeqCst), 1); + let error = result.expect_err("a new staged inode cannot authorize deduplication"); + assert!( + error + .to_string() + .contains("changed during canonical deduplication"), + "refuse the stale staging witness: {error}" + ); + assert_eq!( + fs::read(&canonical).expect("original canonical bytes"), + bytes + ); + assert_eq!(fs::read(&temporary).expect("replacement bytes"), bytes); + assert_eq!(fs::read(&displaced).expect("original staged bytes"), bytes); + assert!(staged.pending.contains_key(&canonical)); + + fs::remove_file(&temporary).expect("remove substituted fixture"); + fs::rename(&displaced, &temporary).expect("restore the authenticated inode"); + // Rename changes ctime even when the original inode is restored. That + // original witness must remain invalid; only a fresh preparation may + // authenticate a new epoch of staged bytes. + let retry = staged + .publish() + .expect_err("a changed witness cannot be reused"); + assert!( + retry + .to_string() + .contains("changed during canonical deduplication"), + "the previous authentication must not become reusable: {retry}" + ); + assert!(staged.pending.contains_key(&canonical)); + assert_eq!( + fs::read(&canonical).expect("canonical remains intact"), + bytes + ); +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs new file mode 100644 index 00000000000..f618e467c8d --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs @@ -0,0 +1,109 @@ +//! Cold publication authentication must not return a pointer from a retired inode. +use std::fs::{self, File}; +use std::io::Read; +use std::os::unix::fs::{MetadataExt, symlink}; + +use tempfile::TempDir; +use tracedecay_code_index::production::CodeIndexPublicationStoreErrorV1; + +use super::DaemonCodeIndexPublicationStoreV1; + +#[test] +fn cold_pointer_witness_refuses_same_byte_inode_swap_and_symlink_alias() { + let root = TempDir::new().expect("isolated pointer directory"); + let pointer = root.path().join("active-code-generation-v1.json"); + let retired = root.path().join("retired-pointer"); + let replacement = root.path().join("replacement-pointer"); + let bytes = br#"{"published":"real-cold-pointer-witness"}"#; + fs::write(&pointer, bytes).expect("write nonempty original pointer"); + let named_before = pointer.symlink_metadata().expect("original pointer inode"); + let mut opened = File::open(&pointer).expect("open original pointer once"); + let mut readback = Vec::new(); + opened + .read_to_end(&mut readback) + .expect("read the exact original pointer bytes"); + assert_eq!(readback.as_slice(), bytes); + assert!( + DaemonCodeIndexPublicationStoreV1::require_cold_pointer_witness( + &pointer, + &mut opened, + &named_before, + &readback, + ) + .is_ok(), + "unchanged real pointer must authenticate" + ); + + fs::write(&replacement, bytes).expect("different inode with identical bytes"); + fs::rename(&pointer, &retired).expect("retire original pathname"); + assert!(matches!( + DaemonCodeIndexPublicationStoreV1::require_cold_pointer_witness( + &pointer, + &mut opened, + &named_before, + &readback, + ), + Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap) + )); + fs::rename(&replacement, &pointer).expect("replace pathname with same bytes"); + let replacement_identity = pointer.symlink_metadata().expect("replacement inode"); + assert_ne!( + (named_before.dev(), named_before.ino()), + (replacement_identity.dev(), replacement_identity.ino()), + "a real different inode must be used as the negative control" + ); + assert_eq!(fs::read(&pointer).expect("replacement bytes"), readback); + assert!(matches!( + DaemonCodeIndexPublicationStoreV1::require_cold_pointer_witness( + &pointer, + &mut opened, + &named_before, + &readback, + ), + Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap) + )); + + fs::remove_file(&pointer).expect("remove replacement"); + symlink(&retired, &pointer).expect("symlink to the exact opened inode"); + let followed = fs::metadata(&pointer).expect("positive symlink target control"); + assert_eq!( + (named_before.dev(), named_before.ino()), + (followed.dev(), followed.ino()) + ); + assert!( + pointer + .symlink_metadata() + .expect("symlink entry") + .file_type() + .is_symlink() + ); + assert!(matches!( + DaemonCodeIndexPublicationStoreV1::require_cold_pointer_witness( + &pointer, + &mut opened, + &named_before, + &readback, + ), + Err(CodeIndexPublicationStoreErrorV1::CorruptionResetRequired(_)) + )); + + fs::remove_file(&pointer).expect("remove symlink"); + fs::rename(&retired, &pointer).expect("restore original inode"); + // Rename updates ctime on Unix; authenticate the restored pathname's + // current metadata while retaining the original open descriptor. + let restored_identity = pointer.symlink_metadata().expect("restored inode"); + assert_eq!( + (named_before.dev(), named_before.ino()), + (restored_identity.dev(), restored_identity.ino()) + ); + assert!( + DaemonCodeIndexPublicationStoreV1::require_cold_pointer_witness( + &pointer, + &mut opened, + &restored_identity, + &readback, + ) + .is_ok(), + "the original inode and exact bytes must recover without another open" + ); +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_reset.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_reset.rs new file mode 100644 index 00000000000..b31a6563fc6 --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_reset.rs @@ -0,0 +1,192 @@ +//! Failed scope resets must release cached descriptors before unlinking files. +use std::fs; +use std::os::unix::fs::MetadataExt; +use std::sync::Arc; + +use tempfile::TempDir; +use tracedecay_code_index::production::{ + CodeIndexAtomicPublicationPort, CodeIndexPublicationStoreErrorV1, +}; +use tracedecay_code_index_retention::code_index_generations::try_acquire_code_generation_store_read_lock; +use tracedecay_domain::{ProjectId, SanitizerRevision}; + +use super::{DaemonCodeIndexPublicationStoreV1, SharedCodeIndexBytePoolV1}; +use crate::code_index_scheduler::tests::GitFixture; +use crate::code_index_scheduler::{CodeIndexReconcileOutcomeV1, CodeIndexWorktreeSchedulerV1}; + +#[cfg(target_os = "linux")] +fn live_inode_handles(identity: &fs::Metadata) -> usize { + let mut count = 0; + for entry in fs::read_dir("/proc/self/fd").expect("observe process descriptors") { + let path = entry.expect("descriptor directory entry").path(); + match fs::metadata(&path) { + Ok(current) if (current.dev(), current.ino()) == (identity.dev(), identity.ino()) => { + count += 1; + } + Ok(_) => {} + // Unrelated test threads may close a descriptor after enumeration. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("cannot inspect {}: {error}", path.display()), + } + } + count +} + +#[test] +fn failed_reset_releases_pointer_handles_across_store_clones() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn reset_fixture() -> u32 { 17 }\n")]); + let source_root = TempDir::new().expect("isolated source store"); + let mut source = CodeIndexWorktreeSchedulerV1::open( + ProjectId::new("project.code-index-tests").expect("project identity"), + fixture.path(), + source_root.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ) + .expect("source scheduler"); + assert!(matches!( + source.reconcile_now().expect("publish source generation"), + CodeIndexReconcileOutcomeV1::Published(_) + )); + let generation = Arc::clone(&source.latest_complete().expect("decode source").generation); + drop(source); + + let target_root = TempDir::new().expect("isolated target store"); + let open_target = || { + DaemonCodeIndexPublicationStoreV1::new( + target_root.path(), + fixture.path(), + SanitizerRevision::new(tracedecay_privacy::CODE_SOURCE_SANITIZER_VERSION_V1) + .expect("sanitizer revision"), + ) + .expect("target publication store") + }; + let mut target = open_target(); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare actual generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage actual generation"); + target + .commit(&mut preparation, &publication) + .expect("publish actual generation"); + drop(preparation); + let clone = target.clone(); + assert_eq!( + clone + .read_publication_pointer() + .expect("authenticate cached pointer") + .expect("published pointer") + .generation_id, + generation.manifest().generation_id.as_str() + ); + let pointer_bytes = fs::read(&target.active_path).expect("exact pointer bytes"); + assert_ne!(pointer_bytes.as_slice(), b""); + let identity = target + .active_path + .symlink_metadata() + .expect("pointer inode"); + { + let memo = target.pointer_memo.lock().expect("shared pointer memo"); + let file = memo + .as_ref() + .expect("memoized publication") + .file + .as_ref() + .expect("memo owns an actual descriptor"); + let opened = file.metadata().expect("cached descriptor metadata"); + assert_eq!( + (opened.dev(), opened.ino()), + (identity.dev(), identity.ino()) + ); + } + #[cfg(target_os = "linux")] + assert_ne!( + live_inode_handles(&identity), + 0, + "positive live-handle control" + ); + + // A rejected reset owns no deletion authority and must preserve its memo. + let reader = try_acquire_code_generation_store_read_lock(target_root.path()) + .expect("acquire production read lock") + .expect("uncontended read lock"); + assert!(matches!( + target.reset_corrupt_store(), + Err(CodeIndexPublicationStoreErrorV1::StoreLockContended) + )); + assert_eq!( + fs::read(&target.active_path).expect("preserved pointer"), + pointer_bytes + ); + assert!(clone.pointer_memo.lock().expect("preserved memo").is_some()); + drop(reader); + + // The existing fault fixture makes cache reset fail after real on-disk + // deletion and directory reconstruction. No filesystem operation is mocked. + target.poison_decoded_cache_for_test(); + let error = target + .reset_corrupt_store() + .expect_err("decoded-cache refusal must propagate after deletion"); + assert!( + matches!( + &error, + CodeIndexPublicationStoreErrorV1::Unavailable(detail) + if detail == "daemon decoded-generation lock is poisoned" + ), + "unexpected reset error: {error}" + ); + assert!( + !target + .active_path + .try_exists() + .expect("inspect reset pointer"), + "the injected failure must occur after the actual pointer unlink" + ); + // Observe before any pointer read: a read of the missing name could itself + // clear the old memo and conceal the reset's descriptor leak. + #[cfg(target_os = "linux")] + assert_eq!( + live_inode_handles(&identity), + 0, + "no deleted pointer inode remains open" + ); + assert!(target.pointer_memo.lock().expect("reset memo").is_none()); + assert!( + clone + .pointer_memo + .lock() + .expect("cloned reset memo") + .is_none() + ); + + // A fresh store must be able to rebuild the reset scope and recover the + // same snapshot, independently of the deliberately poisoned old cache. + let mut recovered = open_target(); + assert!( + recovered + .load_active_shared() + .expect("empty reset store") + .is_none() + ); + let mut preparation = recovered + .prepare(&generation.sealed_scope(), None) + .expect("prepare after failed reset"); + let publication = generation + .stage_publication(&mut preparation) + .expect("restage exact source snapshot"); + recovered + .commit(&mut preparation, &publication) + .expect("publish after failed reset"); + drop(preparation); + drop(recovered); + let cold = open_target(); + let loaded = cold + .load_active_shared() + .expect("cold reload after rebuilding") + .expect("republished generation"); + assert_eq!( + loaded.snapshot().content_identity, + generation.snapshot().content_identity + ); +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index cd6290fbe80..22a2bf401ac 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -1,3 +1,6 @@ +#[cfg(target_os = "linux")] +use std::os::unix::fs::MetadataExt; + use std::{ collections::{BTreeMap, BTreeSet}, fmt::Write as _, @@ -16,7 +19,8 @@ use tracedecay_code_index_retention::code_index_generations::{ CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1, CodeGenerationRetentionErrorV1, CodeGenerationRetentionModeV1, DEFAULT_STRANDED_SCOPE_MINIMUM_AGE_SECS, DurableGenerationCardinalityV1, DurableGenerationIndexEntryV1, DurablePublicationPointerV1, - MAX_CODE_GENERATION_RETENTION_BATCH_V1, acquire_code_generation_store_lock, + DurableSealedCodeGenerationIdentityV1, MAX_CODE_GENERATION_RETENTION_BATCH_V1, + RetainedSegmentHandleRetirementV1, acquire_code_generation_store_lock, code_generation_segments_root, code_text_artifact_staging_root, code_text_artifacts_root, durable_generation_index_digest, execute_code_generation_retention_cancellable, execute_scope_root_retention, plan_scope_root_retention_with_liveness_proof, @@ -640,6 +644,7 @@ fn partitioned_reclamation_is_bounded_and_preserves_retained_segments() { UtcMicros(8_000_000), None, &|| orphan_paths.iter().any(|path| !path.exists()), + None, ) .expect_err("interrupt segment sweep after its first unlink"); assert!( @@ -1641,6 +1646,188 @@ fn evidence_pack_failure_after_pages_never_publishes_manifest_or_pointer() { ); } +#[cfg(target_os = "linux")] +#[test] +fn ordinary_retention_closes_cached_historical_segment_before_unlink() { + let fixture = GitFixture::new(&[( + "src/lib.rs", + "pub fn retained_before_collection() -> usize { 1 }\n", + )]); + let store = TempDir::new().expect("isolated generation store"); + let generation_ids = retention_generations(&fixture, store.path(), 2); + assert_eq!(generation_ids.len(), 2); + + let pointer: DurablePublicationPointerV1 = serde_json::from_slice( + &std::fs::read(store.path().join("active-code-generation-v1.json")) + .expect("read the published generation index"), + ) + .expect("decode the published generation index"); + let old = pointer + .generation_index + .iter() + .find(|entry| entry.generation_id == generation_ids[0].as_str()) + .expect("old generation remains indexed"); + let active = pointer + .generation_index + .iter() + .find(|entry| entry.generation_id == generation_ids[1].as_str()) + .expect("successor generation remains indexed"); + let segments_of = |generation_file: &str| { + let manifest = std::fs::read( + store + .path() + .join("code-generations-v1") + .join(generation_file), + ) + .expect("read exact generation manifest"); + CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + manifest.as_slice(), + ) + .expect("parse the current sealed format") + .expect("a published generation has segment identities") + }; + let old_segments = segments_of(&old.generation_file); + let active_segments = segments_of(&active.generation_file); + let active_digests = active_segments + .iter() + .map(|identity| identity.digest.as_str()) + .collect::>(); + let retired_segment = old_segments + .iter() + .find(|identity| !active_digests.contains(identity.digest.as_str())) + .expect("the source edit must replace at least one nonempty segment"); + let suffix = + sha256_hex_suffix(retired_segment.digest.as_str()).expect("segment content-address suffix"); + let path = code_generation_segments_root(store.path()).join(format!("segment-{suffix}.json")); + let expected = std::fs::read(&path).expect("read live old segment"); + assert!( + !expected.is_empty(), + "the retired segment contains actual bytes" + ); + let inode = std::fs::metadata(&path).expect("original segment inode"); + + let scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + let publication = scheduler.publication.clone(); + let identity = DurableSealedCodeGenerationIdentityV1 { + locator: old.generation_file.clone(), + digest: ManifestDigest::new(old.state_digest.clone()).expect("generation digest"), + size_bytes: old.size_bytes, + }; + let mut received = vec![0x55, 0xaa]; + publication + .read_retained_partitioned_segment( + &identity, + SealedGenerationSegmentReadV1::Whole { + digest: &retired_segment.digest, + size_bytes: retired_segment.size_bytes, + }, + &mut received, + &UninterruptibleCodeIndexControlV1, + ) + .expect("serve an actual historical segment through the cached reader"); + assert_eq!(received, expected); + assert_eq!( + publication + .retained_segment_reads + .lock() + .expect("retained cache") + .len(), + 1, + "one actual historical segment file is held open" + ); + let holders = || { + std::fs::read_dir("/proc/self/fd") + .expect("inspect actual process descriptor table") + .filter_map(Result::ok) + .filter_map(|entry| std::fs::metadata(entry.path()).ok()) + .filter(|metadata| metadata.dev() == inode.dev() && metadata.ino() == inode.ino()) + .count() + }; + assert!( + holders() > 0, + "a live descriptor must pin the original inode" + ); + + let plan = + tracedecay_code_index_retention::code_index_generations::plan_code_generation_retention( + store.path(), + &BTreeSet::new(), + ) + .expect("independently verify retention liveness and digests"); + assert!( + plan.collectable_generations + .iter() + .any(|item| item.generation_id == generation_ids[0]), + "the old generation must be collectable" + ); + let release = publication.retained_segment_reclaimer(); + let releases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed_releases = Arc::clone(&releases); + let guarded: RetainedSegmentHandleRetirementV1 = Arc::new(move || { + observed_releases.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + release() + }); + let report = execute_code_generation_retention_cancellable( + store.path(), + plan, + CodeGenerationRetentionModeV1::Apply, + UtcMicros(100_000_000), + None, + &|| false, + Some(&guarded), + ) + .expect("apply the real normal retention transaction"); + assert!( + report + .deleted_generations + .iter() + .any(|item| item.generation_id == generation_ids[0]), + "retention must remove the actual old generation" + ); + assert_eq!(releases.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!( + publication + .retained_segment_reads + .lock() + .expect("retained cache") + .is_empty(), + "retention must clear the shared descriptor cache before unlinking" + ); + assert!(!path.exists(), "the old-only segment pathname is reclaimed"); + assert_eq!( + holders(), + 0, + "normal retention must not leave the deleted inode allocated on an idle daemon" + ); + assert!( + publication + .load_generation(&generation_ids[1]) + .expect("read live generation") + .is_some(), + "the active snapshot must remain readable" + ); + received = vec![0x55, 0xaa]; + assert!( + publication + .read_retained_partitioned_segment( + &identity, + SealedGenerationSegmentReadV1::Whole { + digest: &retired_segment.digest, + size_bytes: retired_segment.size_bytes, + }, + &mut received, + &UninterruptibleCodeIndexControlV1, + ) + .is_err(), + "a collected generation must never be served from a stale cached descriptor" + ); + assert_eq!(received, Vec::::new()); +} + /// Membership of the durable `generation_index` is a referential-integrity /// check, not a liveness mark: a published store keeps the active generation /// and the vector-readable sources, and everything older is collectable while @@ -3044,6 +3231,86 @@ fn publication_pointer_memo_follows_bytes_when_size_and_mtime_stay_put() { ); } +#[cfg(unix)] +#[test] +fn pointer_commit_refuses_existing_and_dangling_symlink_slots() { + use std::os::unix::fs::symlink; + + for target_exists in [true, false] { + let store = TempDir::new().expect("isolated publication store"); + let project = TempDir::new().expect("isolated project"); + let publication = super::super::DaemonCodeIndexPublicationStoreV1::new( + store.path(), + project.path(), + SanitizerRevision::new(tracedecay_privacy::CODE_SOURCE_SANITIZER_VERSION_V1) + .expect("sanitizer revision"), + ) + .expect("open publication store"); + let pointer_path = store.path().join("active-code-generation-v1.json"); + let foreign_path = store.path().join("foreign-pointer.json"); + let observed = same_length_publication_pointer("generation.symlink-observed", 0x35); + let replacement = same_length_publication_pointer("generation.symlink-replacement", 0x36); + let observed_bytes = serde_json::to_vec(&observed).expect("encode observed pointer"); + let replacement_bytes = + serde_json::to_vec(&replacement).expect("encode replacement pointer"); + if target_exists { + std::fs::write(&foreign_path, &observed_bytes).expect("write foreign pointer"); + } + symlink(&foreign_path, &pointer_path).expect("install symlinked pointer slot"); + let lock = acquire_code_generation_store_lock(store.path()).expect("exclusive store lock"); + let error = publication + .commit_observed_pointer( + &lock, + target_exists.then_some(observed_bytes.as_slice()), + &replacement, + &replacement_bytes, + ) + .expect_err("a symlink must never acquire pointer publication authority"); + assert!( + matches!( + error, + CodeIndexPublicationStoreErrorV1::CorruptionResetRequired(_) + ), + "a symlinked pointer slot must be reset-required corruption: {error}" + ); + assert_eq!( + std::fs::read_link(&pointer_path).expect("link remains in place"), + foreign_path, + "refusal must not replace the symlink" + ); + if target_exists { + assert_eq!( + std::fs::read(&foreign_path).expect("foreign pointer still exists"), + observed_bytes, + "refusal must not change the foreign pointer" + ); + } else { + assert!( + !foreign_path.exists(), + "refusal must not create the dangling symlink target" + ); + } + + // The same exact-byte CAS must still work after the invalid entry + // is replaced by an ordinary file under the existing store lock. + std::fs::remove_file(&pointer_path).expect("remove symlink"); + std::fs::write(&pointer_path, &observed_bytes).expect("restore regular pointer"); + publication + .commit_observed_pointer( + &lock, + Some(observed_bytes.as_slice()), + &replacement, + &replacement_bytes, + ) + .expect("ordinary pointer slot preserves CAS publication"); + assert_eq!( + std::fs::read(&pointer_path).expect("read published pointer"), + replacement_bytes, + "the successful publication must persist exact bytes" + ); + } +} + #[test] fn stale_pointer_commit_does_not_replace_a_changed_active_pointer() { let store = TempDir::new().expect("store root"); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/retained_segment_admission.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/retained_segment_admission.rs new file mode 100644 index 00000000000..1a9a2c02f2e --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/retained_segment_admission.rs @@ -0,0 +1,261 @@ +//! Failed first reads must not discard unrelated authenticated segment handles. +use std::collections::VecDeque; +use std::fs; +#[cfg(target_os = "linux")] +use std::fs::File; +use std::os::fd::{AsRawFd, RawFd}; +#[cfg(target_os = "linux")] +use std::path::Path; +use std::path::PathBuf; +use std::sync::atomic::AtomicU64; +#[cfg(target_os = "linux")] +use std::sync::mpsc::{self, Receiver}; +#[cfg(target_os = "linux")] +use std::time::Duration; + +#[cfg(target_os = "linux")] +use notify::{RecommendedWatcher, Watcher}; +use sha2::{Digest, Sha256}; +use tracedecay_code_index::production::{ + CodeIndexProductionErrorV1, SealedGenerationSegmentReadV1, +}; +use tracedecay_domain::{ManifestDigest, sha256_hex_suffix}; + +use super::{ + DaemonCodeIndexPublicationStoreV1, MAX_STAGED_SEGMENT_READ_HANDLES, PinnedPreparationSegmentV1, + StagedGenerationSegmentsV1, +}; + +fn cache_handles(cache: &VecDeque) -> Vec<(PathBuf, RawFd)> { + cache + .iter() + .map(|entry| (entry.path.clone(), entry.segment.file.as_raw_fd())) + .collect() +} + +#[cfg(target_os = "linux")] +struct OpenObserver { + _watcher: RecommendedWatcher, + events: Receiver>, +} + +#[cfg(target_os = "linux")] +impl OpenObserver { + fn new(root: &Path) -> Self { + let (sender, events) = mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event| { + let _ = sender.send(event); + }) + .expect("native open observer"); + watcher + .watch(root, notify::RecursiveMode::NonRecursive) + .expect("watch the actual segment directory"); + Self { + _watcher: watcher, + events, + } + } + + fn opens(&self) -> usize { + let mut count = 0; + while let Ok(event) = self.events.recv_timeout(Duration::from_millis(250)) { + if matches!( + event.expect("kernel event").kind, + notify::EventKind::Access(notify::event::AccessKind::Open(_)) + ) { + count += 1; + } + } + count + } +} + +#[test] +fn retained_failed_first_read_preserves_healthy_handles_and_recovers() { + let root = tempfile::tempdir().expect("isolated segment store"); + let segments = root.path().join("segments"); + fs::create_dir(&segments).expect("canonical segment directory"); + let staging = tempfile::tempdir_in(root.path()).expect("private staging directory"); + let mut staged = StagedGenerationSegmentsV1::new(); + let bytes: Vec> = (0..=MAX_STAGED_SEGMENT_READ_HANDLES) + .map(|index| format!("nonempty retained segment {index:04}: exact bytes").into_bytes()) + .collect(); + let digests: Vec = bytes + .iter() + .map(|bytes| { + ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)).expect("segment digest") + }) + .collect(); + let paths: Vec = digests + .iter() + .map(|digest| { + segments.join(format!( + "segment-{}.json", + sha256_hex_suffix(digest.as_str()).expect("canonical digest suffix") + )) + }) + .collect(); + let existing_bytes = AtomicU64::new(0); + for (digest, bytes) in digests.iter().zip(&bytes) { + DaemonCodeIndexPublicationStoreV1::stage_segment( + &segments, + staging.path(), + &existing_bytes, + digest, + bytes, + &mut staged, + ) + .expect("stage through the production writer"); + } + staged + .publish() + .expect("durably publish actual segment files"); + drop(staged); + + let candidate = MAX_STAGED_SEGMENT_READ_HANDLES; + let mut damaged = bytes[candidate].clone(); + damaged[0] ^= 1; + fs::write(&paths[candidate], &damaged).expect("corrupt the disposable cold candidate"); + assert_eq!(damaged.len(), bytes[candidate].len()); + assert_ne!( + DaemonCodeIndexPublicationStoreV1::state_digest(&damaged), + digests[candidate].as_str() + ); + + #[cfg(target_os = "linux")] + let observer = OpenObserver::new(&segments); + #[cfg(target_os = "linux")] + { + let _ = observer.opens(); + drop(File::open(&paths[0]).expect("positive observer control")); + assert_eq!(observer.opens(), 1, "the observer must count a real open"); + } + + let request = |index: usize| SealedGenerationSegmentReadV1::Whole { + digest: &digests[index], + size_bytes: bytes[index].len() as u64, + }; + let mut cache = VecDeque::new(); + let mut output = Vec::new(); + for (index, expected) in bytes.iter().take(candidate).enumerate() { + DaemonCodeIndexPublicationStoreV1::read_cached_retained_segment( + &segments, + request(index), + &mut output, + &mut cache, + ) + .expect("authenticate every healthy segment"); + assert_eq!(&output, expected); + } + assert_eq!(cache.len(), MAX_STAGED_SEGMENT_READ_HANDLES); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), MAX_STAGED_SEGMENT_READ_HANDLES); + let healthy = cache_handles(&cache); + + for _ in 0..2 { + output = vec![0x55, 0xaa]; + let error = DaemonCodeIndexPublicationStoreV1::read_cached_retained_segment( + &segments, + request(candidate), + &mut output, + &mut cache, + ) + .expect_err("a corrupt candidate must fail its first authenticated read"); + let CodeIndexProductionErrorV1::Contract(detail) = error else { + panic!("expected an authenticated read refusal, got {error}"); + }; + assert_eq!( + detail, + "sealed generation segment does not match its content address" + ); + assert_eq!( + output, + Vec::::new(), + "refusal clears stale and corrupt bytes" + ); + assert_eq!( + cache_handles(&cache), + healthy, + "failed admission preserves healthy descriptors and their LRU positions" + ); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), 1, "the refused read must open its file"); + } + + for (index, expected) in bytes.iter().take(candidate).enumerate() { + DaemonCodeIndexPublicationStoreV1::read_cached_retained_segment( + &segments, + request(index), + &mut output, + &mut cache, + ) + .expect("healthy data remains readable after candidate refusal"); + assert_eq!(&output, expected); + } + assert_eq!(cache_handles(&cache), healthy); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), 0, "healthy handles must not be reopened"); + + fs::write(&paths[candidate], &bytes[candidate]).expect("repair disposable candidate"); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), 1, "observe the fixture write separately"); + DaemonCodeIndexPublicationStoreV1::read_cached_retained_segment( + &segments, + request(candidate), + &mut output, + &mut cache, + ) + .expect("the repaired cold candidate can pass authentication and enter the cache"); + assert_eq!(output, bytes[candidate]); + assert_eq!(cache.len(), MAX_STAGED_SEGMENT_READ_HANDLES); + assert_eq!( + cache.front().expect("remaining healthy handle").path, + paths[1] + ); + assert_eq!( + cache.back().expect("admitted candidate").path, + paths[candidate] + ); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), 1); + + DaemonCodeIndexPublicationStoreV1::read_cached_retained_segment( + &segments, + SealedGenerationSegmentReadV1::Range { + digest: &digests[candidate], + size_bytes: bytes[candidate].len() as u64, + offset: 1, + length: 3, + }, + &mut output, + &mut cache, + ) + .expect("admitted candidate serves an exact range without reopening"); + assert_eq!(output, bytes[candidate][1..4]); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), 0); + + // Failed admission is cache-neutral, not permission to trust changed + // cached data. A later mutation must retain the fail-closed hit behavior. + fs::write(&paths[candidate], &damaged).expect("mutate the now-cached disposable file"); + #[cfg(target_os = "linux")] + let _ = observer.opens(); + assert!( + DaemonCodeIndexPublicationStoreV1::read_cached_retained_segment( + &segments, + request(candidate), + &mut output, + &mut cache, + ) + .is_err(), + "an admitted handle still refuses a changed inode or digest" + ); + assert_eq!(output, Vec::::new()); + assert_eq!( + cache.len(), + 0, + "changed cached authority still invalidates handles" + ); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), 0); +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs new file mode 100644 index 00000000000..ec427cbaf10 --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -0,0 +1,2625 @@ +//! Exercise the production preparation, filesystem writer, and reader together. +use std::{ + fs::File, + io::Write, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +#[cfg(target_os = "linux")] +use notify::Watcher; +use sha2::{Digest, Sha256}; +use tempfile::TempDir; +use tracedecay_code_index::production::{ + CodeIndexAtomicPublicationPort, CodeIndexExecutionControlV1, CodeIndexInterruptionV1, + CodeIndexPreparationPort, CodeIndexProductionErrorV1, CodeIndexPublicationStoreErrorV1, + CodeIndexPublishedGenerationV1, SealedGenerationSegmentPublicationV1, + SealedGenerationSegmentReadV1, +}; +use tracedecay_code_index_retention::code_index_generations::DurableSealedCodeGenerationIdentityV1; +use tracedecay_domain::{ManifestDigest, ProjectId, SanitizerRevision, sha256_hex_suffix}; +use tracedecay_runtime_core::logging::set_tracing_capture; + +use super::{ + DaemonCodeIndexPreparationStateV1, DaemonCodeIndexPreparationV1, + DaemonCodeIndexPublicationStoreV1, MAX_STAGED_SEGMENT_READ_HANDLES, + PREPARED_SEGMENT_DIGEST_OPENS, SharedCodeIndexBytePoolV1, StagedGenerationSegmentsV1, +}; +use crate::code_index_scheduler::tests::GitFixture; +use crate::code_index_scheduler::{CodeIndexReconcileOutcomeV1, CodeIndexWorktreeSchedulerV1}; + +fn source_generation( + fixture: &GitFixture, + store_root: &Path, +) -> Arc { + let mut source = CodeIndexWorktreeSchedulerV1::open( + ProjectId::new("project.code-index-tests").expect("project identity"), + fixture.path(), + store_root.to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ) + .expect("source scheduler"); + assert!(matches!( + source.reconcile_now().expect("source publication"), + CodeIndexReconcileOutcomeV1::Published(_) + )); + Arc::clone(&source.latest_complete().expect("decode source").generation) +} + +fn target_store(root: &Path, fixture: &GitFixture) -> DaemonCodeIndexPublicationStoreV1 { + DaemonCodeIndexPublicationStoreV1::new( + root, + fixture.path(), + SanitizerRevision::new(tracedecay_privacy::CODE_SOURCE_SANITIZER_VERSION_V1) + .expect("sanitizer revision"), + ) + .expect("target publication store") +} + +fn segment_digest(bytes: &[u8]) -> ManifestDigest { + ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)).expect("canonical digest") +} + +fn staged_path(preparation: &DaemonCodeIndexPreparationV1, digest: &ManifestDigest) -> PathBuf { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must still be open"); + }; + let suffix = sha256_hex_suffix(digest.as_str()).expect("digest suffix"); + let canonical = preparation + .segments_root + .join(format!("segment-{suffix}.json")); + files + .segments + .pending + .get(&canonical) + .expect("segment was staged, not published") + .clone() +} + +fn pinned_handle_count(preparation: &DaemonCodeIndexPreparationV1) -> usize { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must still be open"); + }; + files.pinned_segment_reads.len() +} + +#[test] +fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replacements() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn fixture() -> u32 { 7 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let contents = (0..9) + .map(|index| format!("sealed-staged-segment-{index}-bytes").into_bytes()) + .collect::>(); + let digests = contents + .iter() + .map(|bytes| segment_digest(bytes)) + .collect::>(); + for (digest, bytes) in digests.iter().zip(&contents) { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("write exact staged segment"); + } + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + for offset in 0..16 { + reader( + SealedGenerationSegmentReadV1::Range { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + offset, + length: 4, + }, + &mut read, + ) + .expect("read staged segment range"); + assert_eq!(&read, &contents[0][offset as usize..offset as usize + 4]); + } + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 1, + "16 overlapping production reads must open the first file once", + ); + let invalid_range = reader( + SealedGenerationSegmentReadV1::Range { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + offset: contents[0].len() as u64 - 1, + length: 2, + }, + &mut read, + ); + assert!(matches!( + invalid_range, + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!(read.is_empty(), "a bad range must not expose stale bytes"); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 1); + + // Alternating ranges exercise LRU promotion, not just consecutive reads. + for offset in 0..16 { + for index in [0, 1] { + reader( + SealedGenerationSegmentReadV1::Range { + digest: &digests[index], + size_bytes: contents[index].len() as u64, + offset, + length: 4, + }, + &mut read, + ) + .expect("read interleaved staged ranges"); + assert_eq!( + &read, + &contents[index][offset as usize..offset as usize + 4] + ); + } + } + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 2); + for (digest, bytes) in digests.iter().zip(&contents).skip(1) { + reader( + SealedGenerationSegmentReadV1::Whole { + digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ) + .expect("read distinct staged segment"); + assert_eq!(&read, bytes); + } + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 9, + "nine distinct segments must each need a real file open", + ); + assert_eq!( + pinned_handle_count(&preparation), + MAX_STAGED_SEGMENT_READ_HANDLES + ); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ) + .expect("reopen the evicted first segment"); + assert_eq!(&read, &contents[0]); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 10, + "an eight-handle bound must evict and reopen the ninth distinct file", + ); + + let path = staged_path(&preparation, &digests[0]); + let replacement_path = path.with_extension("replacement.tmp"); + std::fs::write(&replacement_path, vec![b'Z'; contents[0].len()]) + .expect("write replacement bytes"); + std::fs::rename(&replacement_path, &path).expect("replace the pinned path"); + for _ in 0..2 { + read.extend_from_slice(b"stale buffer sentinel"); + let result = reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ); + assert!(matches!( + result, + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!(read.is_empty(), "replacement and retry must clear output"); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 10); + } + assert_eq!(pinned_handle_count(&preparation), 0); + assert!(matches!( + preparation.write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digests[1], + bytes: &contents[1], + }), + Err(CodeIndexPublicationStoreErrorV1::Unavailable(_)) + )); + preparation.abort().expect("abort and clean staged files"); + assert!(!path.exists(), "abort removes replaced staged files"); + // An escaped reader must not keep the preparation or its staged bytes live. + read.extend_from_slice(b"stale after abort"); + assert!(matches!( + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ), + Err(CodeIndexProductionErrorV1::Publication(_)) + )); + assert_eq!(read, [] as [u8; 0]); +} + +#[test] +fn staged_segment_cold_digest_mismatch_poison_survives_restoration() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + + // An auxiliary segment outside the manifest can still invalidate the + // transaction. Corrupt it *before* the first pinned read, so all inode + // metadata stays unchanged during that read and only SHA-256 catches it. + let original = b"auxiliary sealed segment"; + let digest = segment_digest(original); + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digest, + bytes: original, + }) + .expect("stage auxiliary segment"); + let path = staged_path(&preparation, &digest); + let corrupted = vec![b'X'; original.len()]; + std::fs::write(&path, &corrupted).expect("corrupt the same staged inode"); + assert_eq!(std::fs::read(&path).expect("corrupt bytes"), corrupted); + + let reader = preparation.segment_reader(); + let opens_before = preparation.staged_segment_read_open_attempts_for_test(); + let request = SealedGenerationSegmentReadV1::Whole { + digest: &digest, + size_bytes: original.len() as u64, + }; + let mut read = b"prior success sentinel".to_vec(); + assert!(matches!( + reader(request, &mut read), + Err(CodeIndexProductionErrorV1::Contract(detail)) + if detail.contains("content address") + )); + assert!(read.is_empty(), "corrupt bytes must not escape"); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + opens_before + 1, + "the checksum refusal must follow a real pathname open" + ); + assert_eq!( + pinned_handle_count(&preparation), + 0, + "a failed digest must invalidate the pinned cache even when metadata is stable" + ); + + // Neither retries nor restoration can make a poisoned transaction valid. + read.extend_from_slice(b"stale retry"); + assert!(matches!( + reader(request, &mut read), + Err(CodeIndexProductionErrorV1::Contract(detail)) + if detail.contains("identity was invalidated") + )); + assert_eq!(read, Vec::::new()); + std::fs::write(&path, original).expect("restore exact original bytes"); + assert!(matches!( + reader(request, &mut read), + Err(CodeIndexProductionErrorV1::Contract(detail)) + if detail.contains("identity was invalidated") + )); + assert_eq!(read, Vec::::new()); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + opens_before + 1, + "poisoned reads must not cause another endpoint-scanned open" + ); + assert!(matches!( + preparation.write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digest, + bytes: original, + }), + Err(CodeIndexPublicationStoreErrorV1::Unavailable(detail)) + if detail.contains("identity was invalidated") + )); + assert!(matches!( + target.commit(&mut preparation, &publication), + Err(CodeIndexPublicationStoreErrorV1::Unavailable(detail)) + if detail.contains("identity was invalidated") + )); + assert!( + target + .load_active_manifest() + .expect("active manifest probe") + .is_none(), + "a poisoned preparation must not publish its valid-looking manifest" + ); + preparation.abort().expect("discard poisoned preparation"); + assert!(!path.exists()); +} + +#[test] +fn staged_segment_in_place_mutation_poison_survives_restoration_and_refuses_commit() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage a real valid publication"); + + // This auxiliary segment is deliberately outside the valid manifest. + // Commit must refuse the poisoned transaction, not just a digest mismatch + // discovered while verifying a segment the manifest happens to reference. + let bytes = b"original-staged-segment"; + let digest = segment_digest(bytes); + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digest, + bytes, + }) + .expect("write auxiliary staged segment"); + let opens_before = preparation.staged_segment_read_open_attempts_for_test(); + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ) + .expect("pin original staged bytes"); + assert_eq!(read.as_slice(), bytes); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + opens_before + 1 + ); + let path = staged_path(&preparation, &digest); + let before = path.symlink_metadata().expect("original metadata"); + let mut writer = File::options() + .write(true) + .open(&path) + .expect("same inode writer"); + writer + .write_all(&vec![b'X'; bytes.len()]) + .expect("same-length overwrite"); + // Force a visible timestamp change even on coarse-resolution filesystems. + writer + .set_modified(before.modified().expect("mtime") + Duration::from_secs(2)) + .expect("change mtime"); + drop(writer); + let after = path.symlink_metadata().expect("mutated metadata"); + assert_eq!( + (before.dev(), before.ino(), before.len()), + (after.dev(), after.ino(), after.len()) + ); + assert_ne!( + before.modified().expect("old mtime"), + after.modified().expect("new mtime") + ); + let result = reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ); + assert!(matches!( + result, + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!(read.is_empty(), "mutated bytes must never escape"); + assert_eq!(pinned_handle_count(&preparation), 0); + + // Restoration cannot unpoison an already-invalidated transaction. + std::fs::write(&path, bytes).expect("restore original bytes"); + read.extend_from_slice(b"stale after mutation"); + assert!(matches!( + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert_eq!(read, [] as [u8; 0]); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + opens_before + 1 + ); + assert!(matches!( + preparation.write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digest, + bytes, + }), + Err(CodeIndexPublicationStoreErrorV1::Unavailable(detail)) + if detail.contains("identity was invalidated") + )); + assert!(matches!( + target.commit(&mut preparation, &publication), + Err(CodeIndexPublicationStoreErrorV1::Unavailable(detail)) + if detail.contains("identity was invalidated") + )); + assert!( + target + .load_active_manifest() + .expect("active manifest probe") + .is_none() + ); + preparation.abort().expect("abort poisoned preparation"); + assert!(!path.exists()); +} + +#[test] +fn staged_segment_cold_refusals_clear_output_without_poisoning_valid_reads() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let first = b"first staged bytes"; + let cold = b"cold staged bytes"; + let first_digest = segment_digest(first); + let cold_digest = segment_digest(cold); + for (digest, bytes) in [ + (&first_digest, first.as_slice()), + (&cold_digest, cold.as_slice()), + ] { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("stage segment"); + } + let missing_digest = segment_digest(b"never staged"); + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &first_digest, + size_bytes: first.len() as u64, + }, + &mut read, + ) + .expect("prime buffer and first handle"); + assert_eq!(read.as_slice(), first); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 1); + for request in [ + SealedGenerationSegmentReadV1::Range { + digest: &cold_digest, + size_bytes: cold.len() as u64, + offset: cold.len() as u64 - 1, + length: 2, + }, + SealedGenerationSegmentReadV1::Whole { + digest: &cold_digest, + size_bytes: cold.len() as u64 + 1, + }, + SealedGenerationSegmentReadV1::Whole { + digest: &missing_digest, + size_bytes: 12, + }, + ] { + read.extend_from_slice(b"stale cold-refusal sentinel"); + assert!(matches!( + reader(request, &mut read), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!( + read.is_empty(), + "even a refusal before File::open must clear output" + ); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 1); + } + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &first_digest, + size_bytes: first.len() as u64, + }, + &mut read, + ) + .expect("invalid requests must not poison an unchanged preparation"); + assert_eq!(read.as_slice(), first); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 1); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &cold_digest, + size_bytes: cold.len() as u64, + }, + &mut read, + ) + .expect("valid cold request still opens its file"); + assert_eq!(read.as_slice(), cold); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 2); + preparation.abort().expect("clean up preparation"); +} + +#[test] +fn staged_segment_evicted_path_replacement_poison_without_reopen() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let contents = (0..9) + .map(|index| format!("eviction-identity-segment-{index}").into_bytes()) + .collect::>(); + let digests = contents + .iter() + .map(|bytes| segment_digest(bytes)) + .collect::>(); + for (digest, bytes) in digests.iter().zip(&contents) { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("stage sealed segment"); + } + let first_path = staged_path(&preparation, &digests[0]); + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + for (digest, bytes) in digests.iter().zip(&contents) { + reader( + SealedGenerationSegmentReadV1::Whole { + digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ) + .expect("pin distinct segment"); + assert_eq!(&read, bytes); + } + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 9); + assert_eq!( + pinned_handle_count(&preparation), + MAX_STAGED_SEGMENT_READ_HANDLES + ); + + let replacement_path = first_path.with_extension("eviction-replacement.tmp"); + std::fs::write(&replacement_path, vec![b'Z'; contents[0].len()]) + .expect("same-length replacement"); + std::fs::rename(&replacement_path, &first_path).expect("replace evicted segment pathname"); + read.extend_from_slice(b"old data"); + assert!(matches!( + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!( + read.is_empty(), + "replacement cannot expose stale or new bytes" + ); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 9, + "replaced evicted path must be rejected before another File::open" + ); + assert_eq!(pinned_handle_count(&preparation), 0); + assert!(matches!( + preparation.write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digests[1], + bytes: &contents[1], + }), + Err(CodeIndexPublicationStoreErrorV1::Unavailable(_)) + )); + preparation.abort().expect("remove poisoned staging"); + assert!(!first_path.exists()); +} + +#[test] +fn staged_preparation_exact_segment_bytes_commit_and_reload() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + let identities = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("parse staged segment identities") + .expect("current partitioned generation revision"); + let identity = identities + .into_iter() + .find(|identity| identity.size_bytes > 4) + .expect("real generation must have a nonempty segment"); + + let reader = preparation.segment_reader(); + let initial_opens = preparation.staged_segment_read_open_attempts_for_test(); + let mut exact = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut exact, + ) + .expect("read staged sealed whole segment"); + assert_eq!( + segment_digest(&exact), + identity.digest, + "production reader must return the exact addressed segment" + ); + let mut range = Vec::new(); + for _ in 0..16 { + reader( + SealedGenerationSegmentReadV1::Range { + digest: &identity.digest, + size_bytes: identity.size_bytes, + offset: 1, + length: 3, + }, + &mut range, + ) + .expect("read repeated byte-exact range"); + assert_eq!(range.as_slice(), &exact[1..4]); + } + assert!( + preparation.staged_segment_read_open_attempts_for_test() - initial_opens <= 1, + "whole and repeated range reads may open the segment only once" + ); + let committed = target + .commit(&mut preparation, &publication) + .expect("publish the real staged generation"); + assert_eq!( + committed.as_ref(), + publication.manifest_bytes().as_ref(), + "commit must preserve the staged manifest bytes" + ); + drop(reader); + drop(preparation); + // A new store has no decoded-generation or active-manifest memo from the + // publisher; this must really reconstruct the snapshot from durable bytes. + drop(target); + let cold_store = target_store(target_root.path(), &fixture); + let reloaded = cold_store + .load_active_shared() + .expect("decode committed generation") + .expect("active durable generation"); + assert_eq!( + reloaded.manifest().generation_id, + generation.manifest().generation_id + ); + assert_eq!( + reloaded.snapshot().content_identity, + generation.snapshot().content_identity, + "a cold durable read must recover the exact published snapshot identity" + ); +} + +#[test] +fn staged_writer_authentication_avoids_commit_reopens_and_rejects_replaced_inode() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + let identities = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("parse staged segment identities") + .expect("partitioned generation"); + let identity = identities + .into_iter() + .find(|identity| identity.size_bytes > 0) + .expect("nonempty generation segment"); + let path = staged_path(&preparation, &identity.digest); + let bytes = std::fs::read(&path).expect("real staged segment"); + assert!( + !bytes.is_empty(), + "the test must authenticate real segment bytes" + ); + assert_eq!(segment_digest(&bytes), identity.digest); + let before = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must remain open"); + }; + assert!( + !files.segments.pending.is_empty(), + "real files must be staged" + ); + assert!( + files.segments.authenticated_segments.contains_key(&path), + "new staged bytes must be verified through their original writer" + ); + target + .verify_prepared_segments(files, publication.manifest_bytes().as_ref()) + .expect("authenticate exact pending generation without reopening"); + } + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), + before, + "commit verification must not reopen authenticated staged segments" + ); + + // Digest equality alone cannot authorize a replacement of the original + // writer's inode, even when the replacement has byte-identical content. + let replacement = path.with_extension("replacement.tmp"); + std::fs::write(&replacement, &bytes).expect("make equal-byte replacement"); + std::fs::rename(&replacement, &path).expect("replace staged inode"); + let before_refusal = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must remain open"); + }; + assert!( + target + .verify_prepared_segments(files, publication.manifest_bytes().as_ref()) + .is_err(), + "replacing a staged inode must fail even if its bytes are identical" + ); + } + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), + before_refusal, + "identity rejection must not reopen a substituted staged inode" + ); + preparation + .abort() + .expect("abort replaced staged generation"); +} + +#[test] +fn cold_staged_whole_segment_read_rejects_same_inode_content_corruption() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + let identity = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("parse staged segment identities") + .expect("partitioned generation") + .into_iter() + .find(|segment| segment.size_bytes > 4) + .expect("nonempty staged segment"); + let path = staged_path(&preparation, &identity.digest); + let original = std::fs::read(&path).expect("staged segment bytes"); + assert_eq!(segment_digest(&original), identity.digest); + let before = path.symlink_metadata().expect("staged inode"); + + // Mutate before the first reader open: metadata checks within the pinned + // reader cannot discover a change to an inode they have never observed. + let mut corrupted = original.clone(); + corrupted[0] ^= 1; + std::fs::write(&path, &corrupted).expect("corrupt staged inode in place"); + assert_ne!(segment_digest(&corrupted), identity.digest); + let after = path.symlink_metadata().expect("changed inode"); + assert_eq!( + (before.dev(), before.ino(), before.len()), + (after.dev(), after.ino(), after.len()), + "the regression must preserve the pathname, inode and extent" + ); + + let reader = preparation.segment_reader(); + let open_count = preparation.staged_segment_read_open_attempts_for_test(); + assert_eq!(open_count, 0, "exercise an actual cold pinned read"); + let mut output = b"stale response".to_vec(); + assert!(matches!( + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut output, + ), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + open_count + 1, + "the digest refusal must follow one real pinned-file open" + ); + assert!(output.is_empty(), "corrupt bytes must not escape"); + preparation + .abort() + .expect("abort corrupted staged generation"); +} + +#[test] +fn committed_whole_segment_read_refuses_same_length_corruption() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + target + .commit(&mut preparation, &publication) + .expect("commit real generation"); + drop(preparation); + drop(target); + + let cold = target_store(target_root.path(), &fixture); + let identity = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("parse real manifest") + .expect("partitioned generation") + .into_iter() + .find(|segment| segment.size_bytes > 4) + .expect("nonempty addressed segment"); + let request = SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }; + let mut bytes = Vec::new(); + cold.read_partitioned_segment(request, &mut bytes) + .expect("cold valid whole-segment read"); + assert_eq!(segment_digest(&bytes), identity.digest); + let mut range = Vec::new(); + cold.read_partitioned_segment( + SealedGenerationSegmentReadV1::Range { + digest: &identity.digest, + size_bytes: identity.size_bytes, + offset: 1, + length: 3, + }, + &mut range, + ) + .expect("valid range still returns exact bytes"); + assert_eq!(range.as_slice(), &bytes[1..4]); + + // Keep the inode and extent: size/path checks alone accept this corruption. + let segment_path = cold.segments_root.join(format!( + "segment-{}.json", + sha256_hex_suffix(identity.digest.as_str()).expect("digest suffix") + )); + let original = segment_path.symlink_metadata().expect("original segment"); + let mut corrupt = vec![b'X'; bytes.len()]; + if corrupt == bytes { + corrupt[0] ^= 1; + } + std::fs::write(&segment_path, &corrupt).expect("mutate same inode"); + let changed = segment_path.symlink_metadata().expect("mutated segment"); + assert_eq!( + (original.dev(), original.ino(), original.len()), + (changed.dev(), changed.ino(), changed.len()) + ); + let mut reused = b"stale output".to_vec(); + assert!(matches!( + cold.read_partitioned_segment(request, &mut reused), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert_eq!(reused, [] as [u8; 0], "corrupt bytes must not escape"); +} + +#[test] +fn staged_segment_rejected_reads_preserve_full_cache_and_recency() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let contents = (0..9) + .map(|index| format!("admission-staged-segment-{index}-bytes").into_bytes()) + .collect::>(); + let digests = contents + .iter() + .map(|bytes| segment_digest(bytes)) + .collect::>(); + for (digest, bytes) in digests.iter().zip(&contents) { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("stage real segment"); + } + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + for (digest, bytes) in digests.iter().zip(&contents).take(8) { + reader( + SealedGenerationSegmentReadV1::Whole { + digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ) + .expect("fill the handle cache"); + assert_eq!(&read, bytes); + } + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 8); + assert_eq!(pinned_handle_count(&preparation), 8); + let missing_digest = segment_digest(b"never staged admission"); + for request in [ + SealedGenerationSegmentReadV1::Range { + digest: &digests[8], + size_bytes: contents[8].len() as u64, + offset: contents[8].len() as u64 - 1, + length: 2, + }, + SealedGenerationSegmentReadV1::Range { + digest: &digests[8], + size_bytes: contents[8].len() as u64, + offset: u64::MAX, + length: 2, + }, + SealedGenerationSegmentReadV1::Whole { + digest: &digests[8], + size_bytes: contents[8].len() as u64 + 1, + }, + SealedGenerationSegmentReadV1::Whole { + digest: &missing_digest, + size_bytes: b"never staged admission".len() as u64, + }, + SealedGenerationSegmentReadV1::Range { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + offset: contents[0].len() as u64 - 1, + length: 2, + }, + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64 + 1, + }, + ] { + read.extend_from_slice(b"stale rejected-read sentinel"); + assert!(matches!( + reader(request, &mut read), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert_eq!(read, [] as [u8; 0]); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 8); + assert_eq!( + pinned_handle_count(&preparation), + 8, + "a rejected request must not evict a valid handle" + ); + } + + // The next valid cold read must evict zero, not one: invalid accesses + // to zero above must not make it more recent than the other seven files. + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[8], + size_bytes: contents[8].len() as u64, + }, + &mut read, + ) + .expect("admit valid cold read after refusals"); + assert_eq!(read, contents[8]); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 9); + assert_eq!(pinned_handle_count(&preparation), 8); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[1], + size_bytes: contents[1].len() as u64, + }, + &mut read, + ) + .expect("second-oldest handle must still be cached"); + assert_eq!(read, contents[1]); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 9, + "invalid cache hits must not change the eviction victim" + ); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ) + .expect("reopen the actual least-recently-used segment"); + assert_eq!(read, contents[0]); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 10); + preparation.abort().expect("clean up staged cache fixture"); +} + +// A pathname may disappear after its metadata was validated but before the +// first open. Exercise that real syscall boundary without depending on races +// or the runner's permissions. +#[cfg(target_os = "linux")] +struct MoveStagedCandidateBeforeOpen { + source: PathBuf, + destination: PathBuf, + observed: Arc, +} + +#[cfg(target_os = "linux")] +impl tracing::Subscriber for MoveStagedCandidateBeforeOpen { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + metadata.is_event() + && metadata.target() == "tracedecay::code_index" + && metadata + .fields() + .field("staged_segment_file_open_count") + .is_some() + } + + fn max_level_hint(&self) -> Option { + Some(tracing::level_filters::LevelFilter::TRACE) + } + + fn new_span(&self, _attributes: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + + fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {} + + fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {} + + fn event(&self, _event: &tracing::Event<'_>) { + assert_eq!( + self.observed.fetch_add(1, Ordering::SeqCst), + 0, + "the candidate must attempt one real pathname open" + ); + std::fs::rename(&self.source, &self.destination) + .expect("move the candidate between metadata validation and File::open"); + } + + fn enter(&self, _span: &tracing::span::Id) {} + + fn exit(&self, _span: &tracing::span::Id) {} +} + +#[cfg(target_os = "linux")] +#[test] +fn staged_failed_cold_open_preserves_eight_healthy_handles() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let contents = (0..9) + .map(|index| format!("staged-open-race-{index}-bytes").into_bytes()) + .collect::>(); + let digests = contents + .iter() + .map(|bytes| segment_digest(bytes)) + .collect::>(); + for (digest, bytes) in digests.iter().zip(&contents) { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("stage real nonempty segment"); + } + + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + for index in 0..8 { + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[index], + size_bytes: contents[index].len() as u64, + }, + &mut read, + ) + .expect("admit real healthy descriptor"); + assert_eq!(read, contents[index]); + } + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 8); + assert_eq!(pinned_handle_count(&preparation), 8); + + let candidate = staged_path(&preparation, &digests[8]); + let moved = candidate.with_extension("moved-before-open"); + let observed = Arc::new(AtomicUsize::new(0)); + let injector = MoveStagedCandidateBeforeOpen { + source: candidate.clone(), + destination: moved.clone(), + observed: Arc::clone(&observed), + }; + read.extend_from_slice(b"stale before the failed open"); + let refused = { + let _capture = set_tracing_capture(injector); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[8], + size_bytes: contents[8].len() as u64, + }, + &mut read, + ) + }; + assert!(matches!( + refused, + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert_eq!(observed.load(Ordering::SeqCst), 1); + assert!( + read.is_empty(), + "a failed first open cannot return stale bytes" + ); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 9); + assert_eq!( + pinned_handle_count(&preparation), + 8, + "the failed open must not evict the oldest valid descriptor" + ); + + std::fs::rename(&moved, &candidate).expect("restore the same original inode"); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ) + .expect("previous oldest handle must remain cached"); + assert_eq!(read, contents[0]); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 9, + "a failed admission must not cause a second endpoint-scanned open" + ); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[8], + size_bytes: contents[8].len() as u64, + }, + &mut read, + ) + .expect("restored candidate must be readable"); + assert_eq!(read, contents[8]); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 10); + assert_eq!(pinned_handle_count(&preparation), 8); + preparation.abort().expect("remove staged files"); +} + +#[test] +fn staged_segment_publish_never_overwrites_a_concurrent_canonical_name() { + let root = TempDir::new().expect("sealed segment race fixture"); + let canonical = root.path().join("segment-content-address.json"); + let temporary = root.path().join("staged-segment.json"); + let original = b"sealed-original"; + let changed = b"sealed-modified"; + assert_eq!(original.len(), changed.len()); + + // Exercise the actual production helper after the missing-path probe, + // with a conflicting canonical name created in the race window. + std::fs::write(&canonical, original).expect("competing canonical bytes"); + std::fs::write(&temporary, changed).expect("staged bytes"); + assert!( + StagedGenerationSegmentsV1::publish_missing_immutable_file(&temporary, &canonical, None) + .is_err(), + "a race must refuse, not replace a different immutable segment" + ); + assert_eq!( + std::fs::read(&canonical).expect("preserved competing segment"), + original + ); + assert_eq!( + std::fs::read(&temporary).expect("staged bytes retained on refusal"), + changed + ); + + // When the competing publisher installed identical bytes, deduplication + // may finish without another publication or destroying canonical bytes. + std::fs::write(&temporary, original).expect("identical staged bytes"); + StagedGenerationSegmentsV1::publish_missing_immutable_file(&temporary, &canonical, None) + .expect("identical concurrent publication is safe to deduplicate"); + assert!(!temporary.exists()); + assert_eq!( + std::fs::read(&canonical).expect("canonical name retained"), + original + ); + + std::fs::remove_file(&canonical).expect("vacate canonical name"); + std::fs::write(&temporary, changed).expect("new staged segment"); + StagedGenerationSegmentsV1::publish_missing_immutable_file(&temporary, &canonical, None) + .expect("missing canonical name accepts one atomic publication"); + assert!(!temporary.exists()); + assert_eq!( + std::fs::read(&canonical).expect("new published bytes"), + changed + ); +} + +#[test] +fn repeated_pending_segment_accepts_identical_concurrent_canonical_publication() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn fixture() -> u32 { 7 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + + let (canonical, temporary) = { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must remain open"); + }; + files + .segments + .pending + .iter() + .next() + .map(|(name, temporary)| (name.clone(), temporary.clone())) + .expect("a real generation must stage at least one segment") + }; + let bytes = std::fs::read(&temporary).expect("staged segment bytes"); + let digest = segment_digest(&bytes); + assert!( + !canonical.exists(), + "the first stage has not named its segment" + ); + + // A separate publisher wins the canonical name after this preparation + // writes its own temporary file. It has the same authenticated bytes but + // a different inode. Repeating the write must not confuse their identities. + let mut foreign = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&canonical) + .expect("concurrent canonical publication"); + foreign.write_all(&bytes).expect("canonical bytes"); + foreign.sync_all().expect("durable competing segment"); + let foreign_identity = foreign.metadata().expect("canonical inode"); + drop(foreign); + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digest, + bytes: &bytes, + }) + .expect("repeat an already-pending segment after concurrent naming"); + { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must remain open"); + }; + assert_eq!( + files.segments.pending.get(&canonical), + Some(&temporary), + "this preparation still owns its original temporary bytes" + ); + assert!( + !files + .segments + .authenticated_segments + .contains_key(&canonical), + "the concurrently named inode must not masquerade as our pending inode" + ); + target + .verify_prepared_segments(files, publication.manifest_bytes().as_ref()) + .expect("verification authenticates pending bytes instead of another inode"); + } + + target + .commit(&mut preparation, &publication) + .expect("identical canonical bytes deduplicate at commit"); + assert!(!temporary.exists(), "deduplicated temporary is discharged"); + assert_eq!( + std::fs::read(&canonical).expect("canonical bytes after commit"), + bytes + ); + let after = canonical.metadata().expect("canonical inode after commit"); + assert_eq!(after.dev(), foreign_identity.dev()); + assert_eq!(after.ino(), foreign_identity.ino()); +} + +#[cfg(unix)] +#[test] +fn prepared_generation_rechecks_directory_identity_without_reopening_paths() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage generation"); + + target + .validate_preparation_store(&preparation, &publication) + .expect("unchanged directories must retain preparation identity"); + let canonical = target.segments_root.clone(); + let moved = target_root.path().join("detached-segments"); + std::fs::rename(&canonical, &moved).expect("move original directory inode"); + std::fs::create_dir(&canonical).expect("substitute another directory"); + assert!(matches!( + target.validate_preparation_store(&preparation, &publication), + Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap) + )); + std::fs::remove_dir(&canonical).expect("remove empty substituted directory"); + std::fs::rename(&moved, &canonical).expect("restore owned original inode"); + target + .validate_preparation_store(&preparation, &publication) + .expect("restoring original inode restores the exact identity"); + preparation.abort().expect("release owned preparation"); +} + +#[test] +fn generation_manifest_publication_preserves_concurrent_canonical_bytes() { + let root = TempDir::new().expect("generation publication fixture"); + let good = b"generation-one"; + let other = b"generation-two"; + assert_eq!(good.len(), other.len()); + let digest = segment_digest(good); + let suffix = sha256_hex_suffix(digest.as_str()).expect("generation suffix"); + let canonical = root.path().join(format!("generation-{suffix}.json")); + let stage = |bytes: &[u8]| { + let mut temporary = tempfile::Builder::new() + .prefix(".generation-publication-") + .tempfile_in(root.path()) + .expect("stage generation"); + temporary.write_all(bytes).expect("write staged manifest"); + temporary + .as_file() + .sync_all() + .expect("durable staged manifest"); + temporary + }; + + // The competing pathname appears after the publisher's missing-path + // observation. A same-length conflict must not replace it. + std::fs::write(&canonical, good).expect("competing generation"); + assert!( + DaemonCodeIndexPublicationStoreV1::publish_missing_generation_manifest( + stage(other), + &canonical, + ) + .is_err(), + "conflicting concurrent generation must refuse publication" + ); + assert_eq!( + std::fs::read(&canonical).expect("competing generation retained"), + good + ); + assert_eq!( + std::fs::read_dir(root.path()) + .expect("generation directory") + .count(), + 1, + "refused publication must clean its staging file" + ); + + // A second publisher with identical content may safely deduplicate. + DaemonCodeIndexPublicationStoreV1::publish_missing_generation_manifest(stage(good), &canonical) + .expect("identical immutable generation deduplicates"); + assert_eq!(std::fs::read(&canonical).expect("original content"), good); + assert_eq!( + std::fs::read_dir(root.path()) + .expect("generation directory") + .count(), + 1, + "deduplicated publication must not retain staging" + ); + + std::fs::remove_file(&canonical).expect("vacate immutable name"); + DaemonCodeIndexPublicationStoreV1::publish_missing_generation_manifest(stage(good), &canonical) + .expect("publish new immutable name"); + assert_eq!( + std::fs::read(&canonical).expect("new generation bytes"), + good + ); + assert_eq!( + std::fs::read_dir(root.path()) + .expect("generation directory") + .count(), + 1, + "successful publication must consume staging" + ); +} + +struct InterruptOnCheckpoint { + interrupt_at: usize, + deadline: bool, + checks: AtomicUsize, +} + +impl InterruptOnCheckpoint { + fn new(interrupt_at: usize, deadline: bool) -> Self { + Self { + interrupt_at, + deadline, + checks: AtomicUsize::new(0), + } + } +} + +impl CodeIndexExecutionControlV1 for InterruptOnCheckpoint { + fn is_cancelled(&self) -> bool { + let check = self.checks.fetch_add(1, Ordering::SeqCst) + 1; + !self.deadline && check >= self.interrupt_at + } + + fn is_deadline_exceeded(&self) -> bool { + let check = self.checks.fetch_add(1, Ordering::SeqCst) + 1; + self.deadline && check >= self.interrupt_at + } +} + +#[test] +fn retained_generation_read_refuses_pre_and_post_io_interruptions_without_leaking_bytes() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + target + .commit(&mut preparation, &publication) + .expect("publish real generation"); + drop(preparation); + + let pointer = target + .read_publication_pointer() + .expect("read durable pointer") + .expect("active generation pointer"); + let active = pointer + .generation_index + .iter() + .find(|entry| entry.generation_id == pointer.generation_id) + .expect("active generation entry"); + let sealed = DurableSealedCodeGenerationIdentityV1 { + locator: active.generation_file.clone(), + digest: ManifestDigest::new(active.state_digest.clone()).expect("canonical digest"), + size_bytes: active.size_bytes, + }; + let segment = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("parse real manifest") + .expect("current generation revision") + .into_iter() + .find(|segment| segment.size_bytes > 0) + .expect("nonempty retained segment"); + let request = SealedGenerationSegmentReadV1::Whole { + digest: &segment.digest, + size_bytes: segment.size_bytes, + }; + let mut expected = Vec::new(); + target + .read_partitioned_segment(request, &mut expected) + .expect("read exact committed segment"); + assert_eq!(segment_digest(&expected), segment.digest); + + // A control refuses after the initial two checks, i.e. after the real + // pointer and segment reads. A stale successful result is not acceptable. + for (checkpoint, deadline) in [(1, false), (2, true), (3, false), (4, true)] { + let control = InterruptOnCheckpoint::new(checkpoint, deadline); + let mut bytes = b"previous successful read must not survive".to_vec(); + let error = target + .read_retained_partitioned_segment(&sealed, request, &mut bytes, &control) + .expect_err("interrupted retained read must refuse"); + if deadline { + assert!(matches!( + error, + CodeIndexProductionErrorV1::Interrupted(CodeIndexInterruptionV1::DeadlineExceeded) + )); + } else { + assert!(matches!( + error, + CodeIndexProductionErrorV1::Interrupted(CodeIndexInterruptionV1::Cancelled) + )); + } + assert_eq!(bytes, [] as [u8; 0], "no earlier or late bytes may escape"); + assert_eq!( + control.checks.load(Ordering::SeqCst), + checkpoint, + "interrupt must occur at the intended pre/post-I/O checkpoint" + ); + } + + let active = InterruptOnCheckpoint::new(usize::MAX, false); + let mut bytes = b"stale".to_vec(); + target + .read_retained_partitioned_segment(&sealed, request, &mut bytes, &active) + .expect("unchanged generation remains readable"); + assert_eq!(bytes, expected); +} + +#[test] +fn staged_existing_segments_reuse_authenticated_inodes_without_reopening() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut first = target + .prepare(&generation.sealed_scope(), None) + .expect("first preparation"); + let publication = generation + .stage_publication(&mut first) + .expect("stage real generation"); + target + .commit(&mut first, &publication) + .expect("publish canonical sealed segments"); + drop(first); + + // A manifest supplied without stage ownership must authenticate every + // canonical file, not trust its digest-shaped filename. + let unproven = target + .prepare( + &generation.sealed_scope(), + Some(&generation.manifest().generation_id), + ) + .expect("unproven preparation"); + let full_before = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + { + let state = unproven.state.lock().expect("unproven state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("unproven preparation must remain open"); + }; + target + .verify_prepared_segments(files, publication.manifest_bytes().as_ref()) + .expect("authenticate independently supplied manifest"); + } + let full_opens = PREPARED_SEGMENT_DIGEST_OPENS + .with(std::cell::Cell::get) + .saturating_sub(full_before); + assert!( + full_opens > 0, + "the counter must observe canonical file opens" + ); + unproven.abort().expect("abort unproven preparation"); + + let mut staged = target + .prepare( + &generation.sealed_scope(), + Some(&generation.manifest().generation_id), + ) + .expect("new preparation"); + let staged_publication = generation + .stage_publication(&mut staged) + .expect("restage existing canonical bytes"); + let before = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + let existing_path = { + let state = staged.state.lock().expect("staged state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("staged preparation must remain open"); + }; + let path = files + .segments + .authenticated_segments + .keys() + .find(|path| { + !files + .segments + .pending + .values() + .any(|pending| pending == *path) + }) + .expect("previously verified canonical segment, not an uncommitted temporary") + .clone(); + target + .verify_prepared_segments(files, staged_publication.manifest_bytes().as_ref()) + .expect("reuse sealed inode authentication"); + path + }; + let warm_opens = PREPARED_SEGMENT_DIGEST_OPENS + .with(std::cell::Cell::get) + .saturating_sub(before); + assert_eq!( + warm_opens, 0, + "the pack and all other authenticated segments must stay closed during verification" + ); + + // Equal bytes do not authorize silently replacing an already witnessed + // immutable inode between stage and commit. + let replacement = existing_path.with_extension("witness-replacement.tmp"); + std::fs::write( + &replacement, + std::fs::read(&existing_path).expect("canonical bytes"), + ) + .expect("same-content replacement"); + std::fs::rename(&replacement, &existing_path).expect("replace canonical inode"); + let opens_before_refusal = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + { + let state = staged.state.lock().expect("staged state after replacement"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("staged preparation must remain open"); + }; + assert!( + target + .verify_prepared_segments(files, staged_publication.manifest_bytes().as_ref()) + .is_err(), + "a substituted inode must be refused even with identical bytes" + ); + } + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), + opens_before_refusal, + "identity refusal must not reopen the replaced pathname" + ); + staged.abort().expect("abort changed preparation"); +} + +#[test] +fn repeated_canonical_segment_writes_reuse_witness_and_refuse_replacement() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut first = target + .prepare(&generation.sealed_scope(), None) + .expect("first preparation"); + let publication = generation + .stage_publication(&mut first) + .expect("stage canonical generation"); + target + .commit(&mut first, &publication) + .expect("publish canonical segments"); + drop(first); + + let identity = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("read real segment identities") + .expect("current partitioned generation") + .into_iter() + .find(|identity| identity.size_bytes > 0) + .expect("nonempty canonical segment"); + let suffix = sha256_hex_suffix(identity.digest.as_str()).expect("canonical digest suffix"); + let canonical = target.segments_root.join(format!("segment-{suffix}.json")); + let bytes = std::fs::read(&canonical).expect("canonical segment"); + assert_eq!(segment_digest(&bytes), identity.digest); + + let mut preparation = target + .prepare( + &generation.sealed_scope(), + Some(&generation.manifest().generation_id), + ) + .expect("repeated-write preparation"); + let before = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + for _ in 0..16 { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &identity.digest, + bytes: &bytes, + }) + .expect("authenticate or reuse exact existing segment"); + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get) - before, + 1, + "sixteen repeated writes must open and hash the canonical file only once" + ); + } + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut read, + ) + .expect("read the exact staged canonical segment"); + assert_eq!(read, bytes); + + // A byte-identical inode replacement is not the file that was hashed. + // A subsequent write must reject it without another File::open. + let replacement = canonical.with_extension("repeat-witness.tmp"); + std::fs::write(&replacement, &bytes).expect("same-byte replacement"); + std::fs::rename(&replacement, &canonical).expect("replace canonical inode"); + assert!( + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &identity.digest, + bytes: &bytes, + }) + .is_err(), + "a replaced immutable inode must not inherit the prior witness" + ); + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get) - before, + 1, + "identity refusal should not reopen a substituted inode" + ); + preparation + .abort() + .expect("release repeated-write preparation"); +} + +#[cfg(target_os = "linux")] +#[test] +fn failed_durability_admission_cannot_leave_a_publishable_segment() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn fixture() -> u32 { 7 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + + // The 33rd admission flushes the first 32 real writer handles on Linux. + // Mutating a member makes that barrier fail after the 33rd temporary was + // created, exposing the old bug that nevertheless enrolled the temporary. + let contents = (0..33) + .map(|index| format!("durability-admission-{index}-bytes").into_bytes()) + .collect::>(); + let digests = contents + .iter() + .map(|bytes| segment_digest(bytes)) + .collect::>(); + for (digest, bytes) in digests.iter().zip(&contents).take(32) { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("stage first durability window"); + } + + let first_path = staged_path(&preparation, &digests[0]); + let prior = first_path + .symlink_metadata() + .expect("first member metadata"); + let mut writer = File::options() + .write(true) + .open(&first_path) + .expect("mutate same inode"); + writer + .write_all(&vec![b'X'; contents[0].len()]) + .expect("same-length mutation"); + writer + .set_modified(prior.modified().expect("mtime") + Duration::from_secs(2)) + .expect("force distinct mtime"); + drop(writer); + + let pending_path = { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must be open"); + }; + let suffix = sha256_hex_suffix(digests[32].as_str()).expect("digest suffix"); + files + .directory + .path() + .join(format!("segment-{suffix}.json")) + }; + for _ in 0..2 { + let result = preparation.write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digests[32], + bytes: &contents[32], + }); + assert!(matches!( + result, + Err(CodeIndexPublicationStoreErrorV1::Unavailable(_)) + )); + assert!( + !pending_path.exists(), + "a failed writer must clean its temporary, including on retry" + ); + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must remain abortable"); + }; + assert!( + !files + .segments + .pending + .values() + .any(|path| path == &pending_path), + "failed durable admission must not be treated as staged" + ); + assert_eq!( + files.segments.pending.len(), + 32, + "only the initially admitted members remain owned" + ); + } + preparation.abort().expect("abort failed durability window"); + assert!(!pending_path.exists()); +} + +#[cfg(target_os = "linux")] +fn observed_pointer_opens( + events: &std::sync::mpsc::Receiver>, +) -> usize { + let mut opens = 0; + // Inotify delivers access events asynchronously through notify. Drain to + // quiescence rather than observing an arbitrary instant after the call. + while let Ok(event) = events.recv_timeout(Duration::from_millis(250)) { + let event = event.expect("kernel file watch event"); + if matches!( + event.kind, + notify::EventKind::Access(notify::event::AccessKind::Open(_)) + ) { + opens += 1; + } + } + opens +} + +#[cfg(target_os = "linux")] +#[test] +fn publication_pointer_cas_reads_exact_bytes_with_one_kernel_open() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 3 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut publisher = target_store(target_root.path(), &fixture); + let mut preparation = publisher + .prepare(&generation.sealed_scope(), None) + .expect("prepare real generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real snapshot"); + publisher + .commit(&mut preparation, &publication) + .expect("persist actual generation"); + drop(preparation); + drop(publisher); + + let cold = target_store(target_root.path(), &fixture); + let expected = std::fs::read(&cold.active_path).expect("persisted exact pointer bytes"); + assert!(!expected.is_empty(), "CAS must compare an actual pointer"); + + let (sender, receiver) = std::sync::mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let _ = sender.send(event); + }) + .expect("native file observer"); + watcher + .watch(&cold.active_path, notify::RecursiveMode::NonRecursive) + .expect("watch exact pointer inode"); + drop(File::open(&cold.active_path).expect("positive pathname-open control")); + assert_eq!( + observed_pointer_opens(&receiver), + 1, + "observer must count a real kernel open" + ); + + let (pointer, bytes) = cold + .read_publication_pointer_with_bytes() + .expect("read one authenticated pointer and its CAS bytes") + .expect("published pointer"); + assert_eq!( + observed_pointer_opens(&receiver), + 1, + "reading the exact CAS bytes must not reopen the pointer" + ); + assert_eq!(bytes, expected, "CAS must retain the exact persisted bytes"); + assert_eq!( + pointer.generation_id, + generation.manifest().generation_id.as_str() + ); + + let reloaded = cold + .load_active_shared() + .expect("cold read published generation") + .expect("published snapshot"); + assert_eq!( + reloaded.snapshot().content_identity, + generation.snapshot().content_identity, + "one-open pointer CAS read must preserve cold snapshot identity" + ); +} + +#[test] +fn cached_publication_pointer_refuses_equal_byte_symlink_and_recovers_snapshot() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut publisher = target_store(target_root.path(), &fixture); + let mut preparation = publisher + .prepare(&generation.sealed_scope(), None) + .expect("prepare real generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage generation"); + publisher + .commit(&mut preparation, &publication) + .expect("publish generation"); + drop(preparation); + + let pointer_path = publisher.active_path.clone(); + let exact_bytes = std::fs::read(&pointer_path).expect("committed pointer bytes"); + assert_ne!(exact_bytes.as_slice(), b""); + assert_eq!( + publisher + .read_publication_pointer() + .expect("legitimate cached pointer") + .expect("active generation") + .generation_id, + generation.manifest().generation_id.as_str() + ); + + let original = target_root.path().join("original-active-pointer"); + std::fs::rename(&pointer_path, &original).expect("move disposable pointer"); + std::os::unix::fs::symlink(&original, &pointer_path) + .expect("substitute symlink to the exact same bytes"); + assert_eq!( + std::fs::read(&pointer_path).expect("linked pointer data"), + exact_bytes, + "symlink replacement must preserve the same bytes and digest" + ); + assert!( + publisher.read_publication_pointer().is_err(), + "the warm memo must refuse an equal-byte symlink replacement" + ); + let cold = target_store(target_root.path(), &fixture); + assert!( + cold.read_publication_pointer().is_err(), + "cold pointer validation must also refuse a symlink" + ); + + std::fs::remove_file(&pointer_path).expect("remove test symlink"); + std::fs::rename(&original, &pointer_path).expect("restore original pointer"); + let loaded = cold + .load_active_shared() + .expect("load restored generation") + .expect("published snapshot"); + assert_eq!( + loaded.snapshot().content_identity, + generation.snapshot().content_identity, + "restored exact inode must recover the original published snapshot" + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_publication_pointer_validation_uses_one_kernel_open_and_recovers_snapshot() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut publisher = target_store(target_root.path(), &fixture); + let mut preparation = publisher + .prepare(&generation.sealed_scope(), None) + .expect("prepare published generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage source snapshot"); + publisher + .commit(&mut preparation, &publication) + .expect("commit real generation"); + drop(preparation); + drop(publisher); + + let cold = target_store(target_root.path(), &fixture); + let (sender, receiver) = std::sync::mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let _ = sender.send(event); + }) + .expect("native file observer"); + watcher + .watch(&cold.active_path, notify::RecursiveMode::NonRecursive) + .expect("watch the exact publication pointer"); + drop(File::open(&cold.active_path).expect("nonzero control open")); + assert_eq!( + observed_pointer_opens(&receiver), + 1, + "the observer must report a real kernel pathname open" + ); + + let pointer = cold + .read_publication_pointer() + .expect("cold pointer validation") + .expect("published pointer"); + assert_eq!( + pointer.generation_id, + generation.manifest().generation_id.as_str() + ); + assert_eq!( + observed_pointer_opens(&receiver), + 1, + "parse and post-parse byte revalidation must share one file open" + ); + + let reloaded = cold + .load_active_shared() + .expect("read committed snapshot") + .expect("published generation"); + assert_eq!( + reloaded.snapshot().content_identity, + generation.snapshot().content_identity, + "one-open validation must not alter the durable snapshot" + ); +} + +#[cfg(target_os = "linux")] +fn observed_named_opens( + events: &std::sync::mpsc::Receiver>, + path: &Path, +) -> usize { + let mut opens = 0; + while let Ok(event) = events.recv_timeout(Duration::from_millis(250)) { + let event = event.expect("kernel file watch event"); + if event.paths.iter().any(|candidate| candidate == path) + && matches!( + event.kind, + notify::EventKind::Access(notify::event::AccessKind::Open(_)) + ) + { + opens += 1; + } + } + opens +} + +#[cfg(target_os = "linux")] +#[test] +fn retained_generation_ranges_reuse_one_open_and_refuse_a_replaced_inode() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn retained() -> u32 { 7 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut publisher = target_store(target_root.path(), &fixture); + let mut preparation = publisher + .prepare(&generation.sealed_scope(), None) + .expect("prepare real generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage source snapshot"); + let identities = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("parse sealed segment identities") + .expect("partitioned generation"); + let identity = identities + .into_iter() + .find(|identity| identity.size_bytes > 4) + .expect("real generation has a nonempty segment"); + publisher + .commit(&mut preparation, &publication) + .expect("publish real generation"); + drop(preparation); + drop(publisher); + + let cold = target_store(target_root.path(), &fixture); + let reloaded = cold + .load_active_shared() + .expect("decode retained generation") + .expect("committed generation"); + assert_eq!( + reloaded.snapshot().content_identity, + generation.snapshot().content_identity, + "retained segment caching must preserve the exact sealed snapshot" + ); + let suffix = sha256_hex_suffix(identity.digest.as_str()).expect("digest suffix"); + let path = cold.segments_root.join(format!("segment-{suffix}.json")); + let (sender, receiver) = std::sync::mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let _ = sender.send(event); + }) + .expect("native file-open observer"); + watcher + .watch(&cold.segments_root, notify::RecursiveMode::NonRecursive) + .expect("watch sealed segment directory"); + + let reader = cold.segment_reader(); + let mut whole = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut whole, + ) + .expect("read committed content-addressed segment"); + assert_eq!(segment_digest(&whole), identity.digest); + assert!(whole.len() > 4); + let mut range = Vec::new(); + for _ in 0..16 { + reader( + SealedGenerationSegmentReadV1::Range { + digest: &identity.digest, + size_bytes: identity.size_bytes, + offset: 1, + length: 3, + }, + &mut range, + ) + .expect("read retained page without pathname reopen"); + assert_eq!(range, whole[1..4]); + } + assert_eq!( + observed_named_opens(&receiver, &path), + 1, + "one whole and sixteen range reads must cause exactly one kernel open" + ); + // Prove the observer itself records a real open. + drop(File::open(&path).expect("control pathname open")); + assert_eq!(observed_named_opens(&receiver, &path), 1); + + let previous = target_root.path().join("retained-original-inode"); + std::fs::rename(&path, &previous).expect("move original inode"); + std::fs::write(&path, &whole).expect("replace with identical bytes"); + assert_ne!( + std::fs::metadata(&path) + .expect("replacement metadata") + .ino(), + std::fs::metadata(&previous) + .expect("original metadata") + .ino(), + "replacement must use a distinct inode" + ); + // Drain opens caused by writing the replacement, not by the reader. + let _ = observed_named_opens(&receiver, &path); + let mut refused = vec![0x55, 0xaa]; + assert!(matches!( + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut refused, + ), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!( + refused.is_empty(), + "refused read must expose no stale bytes" + ); + assert_eq!( + observed_named_opens(&receiver, &path), + 0, + "an invalidated pinned inode must be refused without a pathname open" + ); + std::fs::remove_file(&path).expect("remove substituted name"); + std::fs::rename(&previous, &path).expect("restore original inode"); +} + +#[cfg(target_os = "linux")] +#[test] +fn initial_pointer_publication_reuses_persisted_descriptor_without_pathname_open() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 2 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut publisher = target_store(target_root.path(), &fixture); + let active_path = publisher.active_path.clone(); + + // Watch the parent: the final pathname does not exist until the first + // publication rename, so it cannot be watched directly beforehand. + let (sender, receiver) = std::sync::mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let _ = sender.send(event); + }) + .expect("native file observer"); + watcher + .watch( + active_path.parent().expect("pointer parent"), + notify::RecursiveMode::NonRecursive, + ) + .expect("watch publication directory"); + + let mut preparation = publisher + .prepare(&generation.sealed_scope(), None) + .expect("prepare first generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage source snapshot"); + publisher + .commit(&mut preparation, &publication) + .expect("publish first generation"); + + assert!(active_path.is_file(), "the committed pointer must exist"); + assert_eq!( + observed_named_opens(&receiver, &active_path), + 0, + "memo authentication must reuse the persisted writer, not reopen the pointer" + ); + drop(preparation); + drop(publisher); + + // Non-vacuous kernel-observer control: the same directory watch must + // record a real pathname open after the publication. + drop(File::open(&active_path).expect("control open of persisted pointer")); + assert_eq!( + observed_named_opens(&receiver, &active_path), + 1, + "watcher must observe a real open of the committed pointer" + ); + + let cold = target_store(target_root.path(), &fixture); + let reloaded = cold + .load_active_shared() + .expect("load persisted generation") + .expect("published generation"); + assert_eq!( + reloaded.snapshot().content_identity, + generation.snapshot().content_identity, + "open-free memo population must preserve cold snapshot identity" + ); +} + +#[test] +fn staged_evidence_pack_writer_authentication_avoids_extra_opens_and_refuses_replacement() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn evidence() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let bytes = b"nonempty evidence pack data, authenticated after the staging rename"; + let digest = segment_digest(bytes); + preparation + .write_segment( + SealedGenerationSegmentPublicationV1::GenerationEvidencePage { + page_ordinal: 0, + page_digest: &digest, + bytes, + }, + ) + .expect("append a real evidence pack page"); + let before = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + preparation + .write_segment( + SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { + segment_digest: &digest, + segment_size_bytes: bytes.len() as u64, + page_count: 1, + }, + ) + .expect("flush and stage evidence pack"); + let (path, witness) = { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("evidence preparation must remain open"); + }; + let path = files + .evidence + .published_path + .as_ref() + .expect("nonempty evidence pack was staged") + .clone(); + let witness = files + .segments + .authenticated_segments + .get(&path) + .expect("the evidence pack must retain its writer's authenticated inode") + .clone(); + assert!( + files + .segments + .pending + .values() + .any(|pending| pending == &path), + "the authenticated file must still be pending publication" + ); + (path, witness) + }; + assert_eq!( + std::fs::read(&path) + .expect("staged evidence bytes") + .as_slice(), + &bytes[..] + ); + assert_eq!(segment_digest(bytes), digest); + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), + before, + "staging must authenticate through the writer, not reopen the evidence pack" + ); + + // Prove the instrumented counter would observe a real digest pathname open. + assert_eq!( + DaemonCodeIndexPublicationStoreV1::state_digest_existing_segment( + &path, + &path.symlink_metadata().expect("original staged inode"), + ) + .expect("positive digest-open control"), + digest.as_str() + ); + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), + before + 1, + "positive control must observe one actual pathname open" + ); + let replacement = path.with_extension("evidence-replacement.tmp"); + std::fs::write(&replacement, bytes).expect("write equal-byte different inode"); + std::fs::rename(&replacement, &path).expect("replace staged inode"); + assert!( + !DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + &witness, + &path.symlink_metadata().expect("replacement inode"), + ), + "equal bytes cannot reuse an authenticated inode witness" + ); + // The real publication path must reject the substituted temporary before + // it gains a durable canonical name. The evidence writer is already synced + // and does not enter the ordinary DurableFileBatch, so its batch sync alone + // cannot supply this check. + let canonical = target.segments_root.join(format!( + "segment-{}.json", + sha256_hex_suffix(digest.as_str()).expect("digest suffix") + )); + assert!(!canonical.exists(), "evidence pack has not been published"); + let opens_before_refusal = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + { + let mut state = preparation.state.lock().expect("changed evidence state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &mut *state else { + panic!("changed evidence preparation must remain open"); + }; + assert!( + files.segments.publish().is_err(), + "a same-byte, different-inode evidence pack must not be published" + ); + assert!( + files + .segments + .pending + .values() + .any(|pending| pending == &path), + "rejected pack remains under private temporary ownership" + ); + } + assert!( + !canonical.exists(), + "refused pack must not gain a canonical name" + ); + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), + opens_before_refusal, + "inode refusal must not introduce an extra pathname digest open" + ); + preparation.abort().expect("release failed preparation"); +} + +#[cfg(target_os = "linux")] +#[test] +fn retained_history_reads_across_requests_share_one_segment_open_and_refuse_replacement() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn retained() -> u32 { 9 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut publisher = target_store(target_root.path(), &fixture); + let mut preparation = publisher + .prepare(&generation.sealed_scope(), None) + .expect("prepare generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage production evidence"); + let segment = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("sealed segment identities") + .expect("partitioned generation") + .into_iter() + .find(|identity| identity.size_bytes > 4) + .expect("nonempty real segment"); + publisher + .commit(&mut preparation, &publication) + .expect("publish complete generation"); + drop(preparation); + drop(publisher); + + let cold = target_store(target_root.path(), &fixture); + let (pointer_sender, pointer_receiver) = std::sync::mpsc::channel(); + let mut pointer_watcher = + notify::recommended_watcher(move |event: notify::Result| { + let _ = pointer_sender.send(event); + }) + .expect("native pointer open observer"); + pointer_watcher + .watch(&cold.active_path, notify::RecursiveMode::NonRecursive) + .expect("watch the authenticated active pointer"); + let pointer = cold + .read_publication_pointer() + .expect("read installed pointer") + .expect("active generation"); + assert_eq!( + observed_pointer_opens(&pointer_receiver), + 1, + "cold publication pointer authentication requires one kernel open" + ); + let entry = pointer + .generation_index + .iter() + .find(|entry| entry.generation_id == pointer.generation_id) + .expect("indexed active generation"); + let identity = DurableSealedCodeGenerationIdentityV1 { + locator: pointer.generation_file.clone(), + digest: ManifestDigest::new(pointer.state_digest.clone()).expect("generation digest"), + size_bytes: entry.size_bytes, + }; + let suffix = sha256_hex_suffix(segment.digest.as_str()).expect("segment digest"); + let path = cold.segments_root.join(format!("segment-{suffix}.json")); + let (sender, receiver) = std::sync::mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let _ = sender.send(event); + }) + .expect("native observer"); + watcher + .watch(&cold.segments_root, notify::RecursiveMode::NonRecursive) + .expect("watch real sealed segment directory"); + + let lock_path = target_root.path().join(".code-generation-retention.lock"); + let (lock_sender, lock_receiver) = std::sync::mpsc::channel(); + let mut lock_watcher = + notify::recommended_watcher(move |event: notify::Result| { + let _ = lock_sender.send(event); + }) + .expect("native generation-store lock open observer"); + lock_watcher + .watch(target_root.path(), notify::RecursiveMode::NonRecursive) + .expect("watch the real generation-store lock directory"); + let control = tracedecay_code_index::production::UninterruptibleCodeIndexControlV1; + let mut whole = Vec::new(); + cold.read_retained_partitioned_segment( + &identity, + SealedGenerationSegmentReadV1::Whole { + digest: &segment.digest, + size_bytes: segment.size_bytes, + }, + &mut whole, + &control, + ) + .expect("first authenticated historical segment read"); + assert_eq!(whole.len() as u64, segment.size_bytes); + assert_eq!(segment_digest(&whole), segment.digest); + assert!(whole.len() > 4); + let mut range = Vec::new(); + for _ in 0..16 { + cold.read_retained_partitioned_segment( + &identity, + SealedGenerationSegmentReadV1::Range { + digest: &segment.digest, + size_bytes: segment.size_bytes, + offset: 1, + length: 3, + }, + &mut range, + &control, + ) + .expect("read historical page without reopening segment"); + assert_eq!(range, whole[1..4]); + } + assert_eq!( + observed_named_opens(&receiver, &path), + 1, + "one whole and sixteen historical reads must require one segment open" + ); + assert_eq!( + observed_named_opens(&lock_receiver, &lock_path), + 1, + "17 real historical reads must perform one cold lock-file open, not 17" + ); + drop(File::open(&lock_path).expect("positive lock pathname-open control")); + assert_eq!( + observed_named_opens(&lock_receiver, &lock_path), + 1, + "kernel observer must count an independent lock-file open" + ); + assert_eq!( + observed_pointer_opens(&pointer_receiver), + 0, + "17 historical reads must reuse the pointer's authenticated descriptor" + ); + drop(File::open(&cold.active_path).expect("positive pointer pathname-open control")); + assert_eq!( + observed_pointer_opens(&pointer_receiver), + 1, + "kernel observer must detect an independently opened pointer" + ); + drop(File::open(&path).expect("control pathname open")); + assert_eq!( + observed_named_opens(&receiver, &path), + 1, + "kernel observer must count a separate real pathname open" + ); + + let peer = cold.clone(); + peer.read_retained_partitioned_segment( + &identity, + SealedGenerationSegmentReadV1::Range { + digest: &segment.digest, + size_bytes: segment.size_bytes, + offset: 2, + length: 2, + }, + &mut range, + &control, + ) + .expect("a second store handle shares the bounded retained cache"); + assert_eq!(range, whole[2..4]); + assert_eq!( + observed_named_opens(&receiver, &path), + 0, + "a cloned publication store must not repeat the endpoint open" + ); + assert_eq!( + observed_pointer_opens(&pointer_receiver), + 0, + "a clone must share the pinned publication pointer without another open" + ); + assert_eq!( + observed_named_opens(&lock_receiver, &lock_path), + 0, + "a cloned store must reuse the same lock descriptor while reacquiring the kernel lease" + ); + + let previous = target_root.path().join("retained-history-original-inode"); + std::fs::rename(&path, &previous).expect("move disposable canonical inode"); + std::fs::write(&path, &whole).expect("replace with identical bytes and another inode"); + assert_ne!( + std::fs::metadata(&previous) + .expect("original metadata") + .ino(), + std::fs::metadata(&path) + .expect("replacement metadata") + .ino(), + "the test must substitute a different inode" + ); + // Fixture replacement opens are not reader opens. + let _ = observed_named_opens(&receiver, &path); + range = vec![0x11, 0x22]; + assert!( + peer.read_retained_partitioned_segment( + &identity, + SealedGenerationSegmentReadV1::Range { + digest: &segment.digest, + size_bytes: segment.size_bytes, + offset: 1, + length: 3, + }, + &mut range, + &control, + ) + .is_err(), + "a replaced pathname must not inherit a cached segment's identity" + ); + assert_eq!( + range, + Vec::::new(), + "failure reveals no previously read bytes" + ); + assert_eq!( + observed_named_opens(&receiver, &path), + 0, + "inode rejection does not need another endpoint-authorized open" + ); + std::fs::remove_file(&path).expect("remove disposable replacement"); + std::fs::rename(&previous, &path).expect("restore original inode"); +} diff --git a/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/reconcile.rs b/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/reconcile.rs index 35ca13c8c1d..494ab27a578 100644 --- a/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/reconcile.rs +++ b/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/reconcile.rs @@ -15,7 +15,9 @@ use std::{ use tempfile::TempDir; use tracedecay_application::diagnostics_publication::CodeIndexPublicationIdentityPortV1; use tracedecay_application::lsp_runtime::LspCodeIndexProjectionIdentityPort; -use tracedecay_code_index_retention::code_index_generations::acquire_code_generation_store_lock; +use tracedecay_code_index_retention::code_index_generations::{ + acquire_code_generation_store_lock, try_acquire_code_generation_store_read_lock, +}; use tracedecay_contracts::code_index_freshness::{ CodeGraphServingReadinessV1, CodeIndexBuildBlockedReasonV1, CodeIndexReadinessTargetV1, CodeIndexReadinessV1, CodeIndexReadinessWaitReadV1, CodeIndexStalenessStateV1, @@ -11836,7 +11838,7 @@ async fn graph_off_changed_source_advances_text_authority_without_full_decode() let (scope, privacy_domain) = { let mut scheduler = scheduler( &fixture, - scoped_store, + scoped_store.clone(), Arc::new(SharedCodeIndexBytePoolV1::default()), ); published(scheduler.reconcile_now().expect("seed retained generation")); @@ -11932,16 +11934,11 @@ async fn graph_off_changed_source_advances_text_authority_without_full_decode() progress.owner_epoch }; - let durable_generations_root = { - let mut scheduler = scheduler - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let durable = scheduler.publication.generations_root.clone(); - let blocker = store.path().join("publication-root-blocker"); - std::fs::write(&blocker, b"not a directory").expect("write publication blocker"); - scheduler.publication.generations_root = blocker; - durable - }; + // A reader permits preparation against the real store and holds off + // only the exclusive pointer commit, leaving a valid candidate to retry. + let publication_reader = try_acquire_code_generation_store_read_lock(&scoped_store) + .expect("open store reader") + .expect("generation A has settled"); fixture.edit( "src/file_0000.rs", "pub fn beta_0000() -> usize { 10_000 }\n", @@ -12032,7 +12029,7 @@ async fn graph_off_changed_source_advances_text_authority_without_full_decode() fixture.path(), &["commit", "-qam", "advance to generation C"], ); - scheduler.publication.generations_root = durable_generations_root; + drop(publication_reader); scheduler.request_background_reconcile_for_observed_change(); assert!( scheduler diff --git a/crates/tracedecay-maintenance/src/store_maintenance/mod.rs b/crates/tracedecay-maintenance/src/store_maintenance/mod.rs index 8475df33b86..9c4340341e0 100644 --- a/crates/tracedecay-maintenance/src/store_maintenance/mod.rs +++ b/crates/tracedecay-maintenance/src/store_maintenance/mod.rs @@ -14,8 +14,8 @@ use crate::lease::ProjectStoreMaintenanceLeaseV1; use crate::telemetry::StoreTelemetrySamplingRegistry; use tracedecay_code_index_retention::code_index_generations::{ CodeGenerationRetentionErrorV1, CodeGenerationRetentionModeV1, CodeGenerationRetentionPlanV1, - CodeGenerationRetentionReportV1, CodeIndexScopeV1, code_index_scopes, code_index_store_root, - execute_code_generation_retention_cancellable, + CodeGenerationRetentionReportV1, CodeIndexScopeV1, RetainedSegmentHandleRetirementV1, + code_index_scopes, code_index_store_root, execute_code_generation_retention_cancellable, }; use tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1; use tracedecay_domain::CodeGenerationId; @@ -220,6 +220,7 @@ async fn execute_collection( scan_owner_root: &Path, observations: &StoreTelemetrySamplingRegistry, cancellation: &CancellationToken, + retire_cached_segments: Option, ) -> Result { // `current_timestamp()` counts seconds; wrapping it in `UtcMicros` stamped // every deletion receipt with a seconds value in a micros-typed field @@ -238,6 +239,7 @@ async fn execute_collection( completed_at, Some(&execution_pool_root), &|| execution_cancellation.is_cancelled(), + retire_cached_segments.as_ref(), )?; scan.lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -467,6 +469,9 @@ pub async fn run_code_generation_retention( log_code_generation_retention_degraded(observations, "retention_cancelled"); return CodeGenerationRetentionOutcomeV1::Failed; } + let retire_cached_segments = schedulers + .retained_segment_reclaimer(lease.project_root()) + .await; let report = match execute_collection( &store_root, plan, @@ -475,6 +480,7 @@ pub async fn run_code_generation_retention( &layout.data_root, observations, cancellation, + retire_cached_segments, ) .await { @@ -645,6 +651,7 @@ pub async fn run_registered_code_generation_retention( } continue; } + let retire_cached_segments = schedulers.retained_segment_reclaimer(&root).await; match execute_collection( &scope.store_root, plan, @@ -653,6 +660,7 @@ pub async fn run_registered_code_generation_retention( &store.data_root, observations, cancellation, + retire_cached_segments, ) .await { diff --git a/crates/tracedecay-maintenance/src/store_maintenance/text_artifact_scan_tests.rs b/crates/tracedecay-maintenance/src/store_maintenance/text_artifact_scan_tests.rs index 67c476d421c..8401b0b3f33 100644 --- a/crates/tracedecay-maintenance/src/store_maintenance/text_artifact_scan_tests.rs +++ b/crates/tracedecay-maintenance/src/store_maintenance/text_artifact_scan_tests.rs @@ -58,6 +58,7 @@ async fn maintenance_owner_advances_noop_pages_and_applies_a_later_orphan() { store.path(), &observations, &cancellation, + None, ) .await .unwrap(); diff --git a/crates/tracedecay-private-fs/src/framed_log.rs b/crates/tracedecay-private-fs/src/framed_log.rs index 005e20779c2..f6eaea55ca5 100644 --- a/crates/tracedecay-private-fs/src/framed_log.rs +++ b/crates/tracedecay-private-fs/src/framed_log.rs @@ -4,6 +4,8 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Seek, SeekFrom, Write}; #[cfg(target_os = "linux")] use std::os::fd::AsRawFd; +#[cfg(target_os = "linux")] +use std::os::unix::fs::MetadataExt; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; #[cfg(windows)] @@ -16,6 +18,14 @@ use windows_sys::Win32::Storage::FileSystem::{ FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, }; +#[cfg(all(test, target_os = "linux"))] +#[path = "framed_log_batch_tests.rs"] +mod batch_tests; + +#[cfg(all(test, target_os = "linux", feature = "test-helpers"))] +#[path = "framed_log_window_tests.rs"] +mod window_tests; + #[cfg(test)] thread_local! { static DIRECTORY_SYNC_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; @@ -67,23 +77,74 @@ pub fn sync_parent_directory(path: &Path, policy: DirectorySyncPolicy) -> io::Re } } -/// New files whose contents become durable together, before any is renamed -/// into place. +/// New, fully written files whose contents become durable before any is renamed. +/// Members must remain immutable until publication. /// -/// On Linux each member's writeback starts as soon as it is written, and -/// [`Self::sync`] then fsyncs the members concurrently. Concurrent fsyncs -/// share a commit on a journaling filesystem and on ZFS's intent log alike, -/// but on ZFS one fsync after another pays one intent-log commit each, so a -/// seal of a hundred segments waited out a hundred commits. The cost stays -/// proportional to the batch's own bytes. `syncfs(2)` also paid one -/// commit, but waited for every dirty inode on the filesystem, so a one-file -/// seal stalled for seconds behind unrelated writers. Other platforms fsync -/// each file as it is written. +/// Linux retains at most one concurrency window of duplicated writer handles. +/// When that window fills, its members are fsynced concurrently before their +/// handles are released. This preserves grouped log commits without reopening +/// every pathname through endpoint security or holding a descriptor per segment. +/// [`Self::sync`] flushes the final window and checks that earlier members still +/// name the same immutable files. It never flushes unrelated filesystem data. +/// Other platforms fsync each file as it is written. #[derive(Default)] pub struct DurableFileBatch { - /// Paths rather than open handles, so a large seal holds no descriptors. #[cfg(target_os = "linux")] - members: Vec, + pending: Vec, + #[cfg(target_os = "linux")] + synced: Vec, +} + +#[cfg(target_os = "linux")] +struct PendingDurableFile { + identity: DurableFileIdentity, + file: File, +} + +#[cfg(target_os = "linux")] +struct DurableFileIdentity { + path: PathBuf, + metadata: fs::Metadata, +} + +#[cfg(target_os = "linux")] +impl DurableFileIdentity { + fn validate_metadata(&self, current: &fs::Metadata) -> io::Result<()> { + let original = &self.metadata; + if !original.file_type().is_file() + || !current.file_type().is_file() + || original.dev() != current.dev() + || original.ino() != current.ino() + || original.len() != current.len() + || original.mtime() != current.mtime() + || original.mtime_nsec() != current.mtime_nsec() + || original.ctime() != current.ctime() + || original.ctime_nsec() != current.ctime_nsec() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "durable batch member changed after it was written", + )); + } + Ok(()) + } + + fn validate_path(&self) -> io::Result<()> { + self.validate_metadata(&fs::symlink_metadata(&self.path)?) + } +} + +#[cfg(target_os = "linux")] +impl PendingDurableFile { + fn sync(&self) -> io::Result<()> { + self.identity.validate_path()?; + self.identity.validate_metadata(&self.file.metadata()?)?; + sync_owned_file(&self.identity.path, &self.file)?; + // A replacement or in-place write during the barrier must not be + // reported as a successful flush of the originally admitted bytes. + self.identity.validate_metadata(&self.file.metadata()?)?; + self.identity.validate_path() + } } impl DurableFileBatch { @@ -93,25 +154,68 @@ impl DurableFileBatch { /// Record one fully written member file, created at `path`. pub fn written(&mut self, path: &Path, file: &File) -> io::Result<()> { + let observed = file.metadata()?; + self.written_with_identity(path, file, &observed) + } + + /// Record the exact inode and metadata that the caller already authenticated + /// through its writer. A changed member must fail before admission, not + /// leave a stale handle in a later durability window. + pub fn written_with_identity( + &mut self, + path: &Path, + file: &File, + authenticated: &fs::Metadata, + ) -> io::Result<()> { #[cfg(target_os = "linux")] { - // SAFETY: `file` is an open descriptor for the whole call. + let identity = DurableFileIdentity { + path: path.to_path_buf(), + metadata: authenticated.clone(), + }; + identity.validate_metadata(&file.metadata()?)?; + // An invalid incoming member must not force a full, otherwise + // healthy window to fsync before its admission is rejected. + identity.validate_path()?; + if self.pending.len() == DURABLE_BATCH_CONCURRENT_SYNCS { + // Keep ownership on failure: no unproven member may move to + // the synced set, and no additional descriptor is admitted. + self.sync_pending()?; + self.synced + .extend(self.pending.drain(..).map(|member| member.identity)); + // A slow filesystem barrier leaves time for the incoming + // path or descriptor to change. Do not admit that change. + identity.validate_metadata(&file.metadata()?)?; + identity.validate_path()?; + } + // Duplication retains the writer's inode without a pathname open. + let retained = file.try_clone()?; + // SAFETY: `retained` is an open descriptor for the whole call. let started = unsafe { - libc::sync_file_range(file.as_raw_fd(), 0, 0, libc::SYNC_FILE_RANGE_WRITE) + libc::sync_file_range(retained.as_raw_fd(), 0, 0, libc::SYNC_FILE_RANGE_WRITE) }; if started != 0 { return Err(io::Error::last_os_error()); } - self.members.push(path.to_path_buf()); + // This is the last fallible check before admission. There must + // be no caller-side validation that could delete a registered + // member after a successful return. + identity.validate_metadata(&retained.metadata()?)?; + identity.validate_path()?; + self.pending.push(PendingDurableFile { + identity, + file: retained, + }); Ok(()) } #[cfg(not(target_os = "linux"))] { + let _ = authenticated; sync_owned_file(path, file) } } - /// Make every member file's contents durable. + /// Make every member file's contents durable, refusing changed members. #[tracing::instrument( name = "private_fs.framed_log.sync_file_batch", level = "trace", @@ -120,33 +224,62 @@ impl DurableFileBatch { pub fn sync(&self) -> io::Result<()> { #[cfg(target_os = "linux")] { - let next = std::sync::atomic::AtomicUsize::new(0); - let syncer = || -> io::Result<()> { - while let Some(member) = self.members.get(next.fetch_add(1, Ordering::Relaxed)) { - sync_file_at(member)?; - } - Ok(()) - }; - let syncers = self.members.len().min(DURABLE_BATCH_CONCURRENT_SYNCS); - std::thread::scope(|scope| { - let helpers = (1..syncers) - .map(|_| scope.spawn(syncer)) - .collect::>(); - let mut outcome = syncer(); - for helper in helpers { - let helped = helper - .join() - .unwrap_or_else(|panic| std::panic::resume_unwind(panic)); - outcome = outcome.and(helped); - } - outcome - }) + // Already-flushed windows need no further opens or barriers, but + // missing or substituted names must still refuse publication. + for identity in &self.synced { + identity.validate_path()?; + } + self.sync_pending()?; + // The final barriers may block long enough for an earlier window + // to change. Its pre-barrier observation cannot authorize the + // whole batch after those barriers return. Recheck without opens. + for identity in &self.synced { + identity.validate_path()?; + } + Ok(()) } #[cfg(not(target_os = "linux"))] { Ok(()) } } + + #[cfg(target_os = "linux")] + fn sync_pending(&self) -> io::Result<()> { + let next = std::sync::atomic::AtomicUsize::new(0); + let syncer = || -> io::Result<()> { + while let Some(member) = self.pending.get(next.fetch_add(1, Ordering::Relaxed)) { + member.sync()?; + #[cfg(all(test, feature = "test-helpers"))] + sync_latency::after_batch_member_sync(&member.identity.path); + } + Ok(()) + }; + let syncers = self.pending.len().min(DURABLE_BATCH_CONCURRENT_SYNCS); + std::thread::scope(|scope| { + let helpers = (1..syncers) + .map(|_| scope.spawn(syncer)) + .collect::>(); + let mut outcome = syncer(); + for helper in helpers { + let helped = match helper.join() { + Ok(helped) => helped, + Err(panic) => std::panic::resume_unwind(panic), + }; + outcome = outcome.and(helped); + } + outcome + })?; + // An early member may change after its own barrier and validation + // while another worker is still syncing. Authenticate the whole + // window after every worker finishes, before success or handle release. + for member in &self.pending { + let identity = &member.identity; + identity.validate_metadata(&member.file.metadata()?)?; + identity.validate_path()?; + } + Ok(()) + } } /// Barriers one batch keeps in flight together: enough for the filesystem to @@ -373,6 +506,8 @@ fn remove_owned_temp(path: &Path) { pub mod sync_latency { use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; + #[cfg(test)] + use std::sync::mpsc; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; @@ -381,6 +516,46 @@ pub mod sync_latency { root: PathBuf, delay: Duration, syncs: Arc, + #[cfg(test)] + pause: Option>, + } + + // A fixture can synchronize exactly at a real fsync, without wall-time + // races, mock barriers, or changes to production file I/O. + #[cfg(test)] + struct SyncPauseSignal { + entered: mpsc::Sender<()>, + resume: Mutex>, + after_member: bool, + } + + #[cfg(test)] + impl SyncPauseSignal { + fn wait(&self) { + let _ = self.entered.send(()); + let _ = self + .resume + .lock() + .unwrap_or_else(PoisonError::into_inner) + .recv(); + } + } + + #[cfg(test)] + pub(super) struct SyncPause { + entered: mpsc::Receiver<()>, + resume: mpsc::Sender<()>, + } + + #[cfg(test)] + impl SyncPause { + pub(super) fn wait_for_sync(&self, timeout: Duration) -> bool { + self.entered.recv_timeout(timeout).is_ok() + } + + pub(super) fn resume(self) { + let _ = self.resume.send(()); + } } static INJECTIONS: Mutex> = Mutex::new(Vec::new()); @@ -409,9 +584,11 @@ pub mod sync_latency { } } - /// Delay every fsync of `root` or a path below it by `delay`. - #[must_use] - pub fn inject(root: &Path, delay: Duration) -> SyncLatencyGuard { + fn register( + root: &Path, + delay: Duration, + #[cfg(test)] pause: Option>, + ) -> SyncLatencyGuard { let id = NEXT_ID.fetch_add(1, Ordering::SeqCst); let syncs = Arc::new(AtomicU64::new(0)); INJECTIONS @@ -422,20 +599,98 @@ pub mod sync_latency { root: root.to_path_buf(), delay, syncs: Arc::clone(&syncs), + #[cfg(test)] + pause, }); SyncLatencyGuard { id, syncs } } + /// Delay every fsync of root or a path below it by delay. + #[must_use] + pub fn inject(root: &Path, delay: Duration) -> SyncLatencyGuard { + #[cfg(test)] + { + register(root, delay, None) + } + #[cfg(not(test))] + { + register(root, delay) + } + } + + /// Pause one actual barrier under a private fixture root until released. + #[cfg(test)] + #[must_use] + pub(super) fn pause(root: &Path) -> (SyncLatencyGuard, SyncPause) { + pause_at(root, false) + } + + /// Observe a completed member before the concurrent window joins. + #[cfg(all(test, target_os = "linux"))] + #[must_use] + pub(super) fn pause_after_batch_member(root: &Path) -> (SyncLatencyGuard, SyncPause) { + pause_at(root, true) + } + + #[cfg(test)] + fn pause_at(root: &Path, after_member: bool) -> (SyncLatencyGuard, SyncPause) { + let (entered_sender, entered_receiver) = mpsc::channel(); + let (resume_sender, resume_receiver) = mpsc::channel(); + let signal = SyncPauseSignal { + entered: entered_sender, + resume: Mutex::new(resume_receiver), + after_member, + }; + let guard = register(root, Duration::ZERO, Some(Arc::new(signal))); + ( + guard, + SyncPause { + entered: entered_receiver, + resume: resume_sender, + }, + ) + } + + #[cfg(all(test, target_os = "linux"))] + pub(super) fn after_batch_member_sync(path: &Path) { + let pause = INJECTIONS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .iter() + .find(|injection| path.starts_with(&injection.root)) + .and_then(|injection| injection.pause.clone()) + .filter(|pause| pause.after_member); + if let Some(pause) = pause { + pause.wait(); + } + } + + struct MatchedInjection { + delay: Duration, + syncs: Arc, + #[cfg(test)] + pause: Option>, + } + pub(super) fn before_sync(path: &Path) { let matched = INJECTIONS .lock() .unwrap_or_else(PoisonError::into_inner) .iter() .find(|injection| path.starts_with(&injection.root)) - .map(|injection| (injection.delay, Arc::clone(&injection.syncs))); - if let Some((delay, syncs)) = matched { - syncs.fetch_add(1, Ordering::SeqCst); - std::thread::sleep(delay); + .map(|injection| MatchedInjection { + delay: injection.delay, + syncs: Arc::clone(&injection.syncs), + #[cfg(test)] + pause: injection.pause.clone(), + }); + if let Some(matched) = matched { + matched.syncs.fetch_add(1, Ordering::SeqCst); + #[cfg(test)] + if let Some(pause) = matched.pause.filter(|pause| !pause.after_member) { + pause.wait(); + } + std::thread::sleep(matched.delay); } } } diff --git a/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs new file mode 100644 index 00000000000..49397878e4c --- /dev/null +++ b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs @@ -0,0 +1,449 @@ +//! Real filesystem evidence for bounded, descriptor-owned durability batches. +use std::ffi::CString; +use std::fs::{self, File}; +use std::io::{ErrorKind, Read, Write}; +use std::mem::size_of; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +#[cfg(feature = "test-helpers")] +use super::sync_latency; +use super::{DURABLE_BATCH_CONCURRENT_SYNCS, DurableFileBatch}; + +/// Count kernel open events, not calls to an observer unrelated to the opener. +struct OpenEvents(File); + +impl OpenEvents { + fn watch(root: &Path) -> Self { + // SAFETY: inotify_init1 has no pointer arguments. + let descriptor = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) }; + assert!( + descriptor >= 0, + "inotify: {}", + std::io::Error::last_os_error() + ); + // SAFETY: the successful descriptor is owned exactly once by this File. + let file = unsafe { File::from_raw_fd(descriptor) }; + let path = CString::new(root.as_os_str().as_bytes()).expect("fixture path"); + // SAFETY: file is live and path is NUL-terminated for the whole call. + let watch = + unsafe { libc::inotify_add_watch(file.as_raw_fd(), path.as_ptr(), libc::IN_OPEN) }; + assert!(watch >= 0, "watch: {}", std::io::Error::last_os_error()); + Self(file) + } + + fn drain(&mut self) -> usize { + let mut opens = 0; + let mut buffer = [0_u8; 8192]; + loop { + let length = match self.0.read(&mut buffer) { + Ok(length) => length, + Err(error) if error.kind() == ErrorKind::WouldBlock => return opens, + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(error) => panic!("read inotify events: {error}"), + }; + assert!(length > 0, "inotify unexpectedly reached EOF"); + let mut offset = 0; + while offset < length { + assert!(offset + size_of::() <= length); + // SAFETY: the complete header is in bounds; read_unaligned + // handles the byte buffer's alignment. No borrowed name escapes. + let event = unsafe { + buffer + .as_ptr() + .add(offset) + .cast::() + .read_unaligned() + }; + assert_eq!(event.mask & libc::IN_Q_OVERFLOW, 0, "lost open events"); + if event.mask & libc::IN_OPEN != 0 { + opens += 1; + } + offset += size_of::() + + usize::try_from(event.len).expect("event length"); + assert!(offset <= length, "truncated inotify name"); + } + } + } +} + +fn write_member(batch: &mut DurableFileBatch, root: &Path, index: usize) -> PathBuf { + let path = root.join(format!("segment-{index}.tmp")); + let mut file = File::create(&path).expect("member writer"); + file.write_all(format!("sealed segment {index}").as_bytes()) + .expect("member bytes"); + batch.written(&path, &file).expect("record member"); + path +} + +#[test] +fn durable_batch_flushes_multiple_windows_without_pathname_reopens() { + let root = tempfile::tempdir().expect("batch root"); + let mut events = OpenEvents::watch(root.path()); + let probe = root.path().join("open-probe"); + drop(File::create(&probe).expect("real probe open")); + assert_eq!( + events.drain(), + 1, + "the kernel observer must actually see opens" + ); + fs::remove_file(&probe).expect("remove probe"); + + let mut batch = DurableFileBatch::new(); + let members = DURABLE_BATCH_CONCURRENT_SYNCS * 2 + 3; + for index in 0..members { + write_member(&mut batch, root.path(), index); + assert!(batch.pending.len() <= DURABLE_BATCH_CONCURRENT_SYNCS); + } + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS * 2); + assert_eq!(batch.pending.len(), 3); + batch.sync().expect("flush final window"); + assert_eq!( + events.drain(), + members, + "only the original writer opens are allowed; flushing must not reopen members" + ); + batch.sync().expect("repeat final barrier"); + assert_eq!(events.drain(), 0, "a repeated sync must not reopen paths"); + drop(batch); + for index in 0..members { + assert_eq!( + fs::read(root.path().join(format!("segment-{index}.tmp"))) + .expect("exact durable readback"), + format!("sealed segment {index}").as_bytes() + ); + } +} + +#[cfg(feature = "test-helpers")] +#[test] +fn durable_batch_flushes_each_window_before_releasing_its_handles() { + let root = tempfile::tempdir().expect("batch root"); + let barriers = sync_latency::inject(root.path(), Duration::ZERO); + let mut batch = DurableFileBatch::new(); + let members = DURABLE_BATCH_CONCURRENT_SYNCS * 2 + 3; + for index in 0..members { + write_member(&mut batch, root.path(), index); + } + assert_eq!( + barriers.syncs(), + u64::try_from(DURABLE_BATCH_CONCURRENT_SYNCS * 2).expect("flushed count"), + "both completed windows must have issued real file barriers" + ); + batch.sync().expect("flush remaining members"); + assert_eq!( + barriers.syncs(), + u64::try_from(members).expect("member count"), + "no member may be omitted or flushed as unrelated filesystem data" + ); +} + +#[test] +fn durable_batch_rejects_a_path_not_owned_by_the_writer() { + let root = tempfile::tempdir().expect("batch root"); + let path = root.path().join("member.tmp"); + let other = root.path().join("other.tmp"); + let mut writer = File::create(&path).expect("writer"); + writer.write_all(b"original").expect("member bytes"); + fs::write(&other, b"original").expect("same bytes in another inode"); + let mut batch = DurableFileBatch::new(); + assert_eq!( + batch + .written(&other, &writer) + .expect_err("wrong inode") + .kind(), + ErrorKind::InvalidData + ); + assert_eq!(batch.pending.len(), 0); + batch.written(&path, &writer).expect("correct writer path"); + drop(writer); + batch.sync().expect("flush caller-closed writer"); + assert_eq!(fs::read(path).expect("read member"), b"original"); +} + +#[test] +fn durable_batch_refuses_pending_and_already_flushed_replacements() { + for members in [1, DURABLE_BATCH_CONCURRENT_SYNCS + 1] { + let root = tempfile::tempdir().expect("batch root"); + let mut batch = DurableFileBatch::new(); + for index in 0..members { + write_member(&mut batch, root.path(), index); + } + let first = root.path().join("segment-0.tmp"); + let replacement = root.path().join("replacement.tmp"); + // Same bytes rule out content mismatch as the reason for refusing. + fs::write(&replacement, b"sealed segment 0").expect("replacement"); + fs::rename(&replacement, &first).expect("replace recorded inode"); + for _ in 0..2 { + assert_eq!( + batch.sync().expect_err("replaced member").kind(), + ErrorKind::InvalidData + ); + } + assert_eq!( + fs::read(first).expect("replacement preserved"), + b"sealed segment 0" + ); + } +} + +#[test] +fn durable_batch_refuses_a_missing_member_from_an_earlier_window() { + let root = tempfile::tempdir().expect("batch root"); + let mut batch = DurableFileBatch::new(); + for index in 0..=DURABLE_BATCH_CONCURRENT_SYNCS { + write_member(&mut batch, root.path(), index); + } + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + fs::remove_file(root.path().join("segment-0.tmp")).expect("remove flushed member"); + assert_eq!( + batch.sync().expect_err("missing flushed member").kind(), + ErrorKind::NotFound + ); +} + +#[test] +fn durable_batch_refuses_same_inode_mutation_after_early_flush() { + let root = tempfile::tempdir().expect("batch root"); + let mut batch = DurableFileBatch::new(); + for index in 0..=DURABLE_BATCH_CONCURRENT_SYNCS { + write_member(&mut batch, root.path(), index); + } + let path = root.path().join("segment-0.tmp"); + let before = fs::metadata(&path).expect("original metadata"); + let mut writer = File::options().write(true).open(&path).expect("same inode"); + writer + .write_all(b"changed segment!") + .expect("same-length mutation"); + writer + .set_modified(before.modified().expect("mtime") + Duration::from_secs(2)) + .expect("observable mutation on coarse timestamp filesystems"); + drop(writer); + let after = fs::metadata(&path).expect("mutated metadata"); + assert_eq!( + (before.dev(), before.ino(), before.len()), + (after.dev(), after.ino(), after.len()) + ); + assert_ne!( + before.modified().expect("original mtime"), + after.modified().expect("changed mtime") + ); + assert_eq!( + batch.sync().expect_err("mutated early member").kind(), + ErrorKind::InvalidData + ); +} + +#[test] +fn durable_batch_refuses_a_changed_authenticated_writer_before_admission_and_recovers() { + let root = tempfile::tempdir().expect("batch root"); + let mut batch = DurableFileBatch::new(); + for index in 0..DURABLE_BATCH_CONCURRENT_SYNCS { + write_member(&mut batch, root.path(), index); + } + + let next = root.path().join("next.tmp"); + let mut writer = File::create(&next).expect("candidate writer"); + writer.write_all(b"next segment").expect("candidate bytes"); + let authenticated = writer.metadata().expect("authenticated inode"); + writer + .set_modified(authenticated.modified().expect("mtime") + Duration::from_secs(2)) + .expect("change metadata after authentication"); + #[cfg(feature = "test-helpers")] + let barriers = sync_latency::inject(root.path(), Duration::ZERO); + + assert_eq!( + batch + .written_with_identity(&next, &writer, &authenticated) + .expect_err("stale authentication must refuse admission") + .kind(), + ErrorKind::InvalidData + ); + #[cfg(feature = "test-helpers")] + assert_eq!( + barriers.syncs(), + 0, + "invalid next member must not flush the healthy 32-member window" + ); + assert_eq!(batch.pending.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert_eq!(batch.synced.len(), 0); + drop(writer); + fs::remove_file(&next).expect("discard rejected temporary"); + + // A new writer for the same name can be admitted because the rejected + // inode was never registered in the batch. + let mut retry = File::create(&next).expect("retry writer"); + retry.write_all(b"next segment").expect("retry bytes"); + let retry_identity = retry.metadata().expect("retry identity"); + batch + .written_with_identity(&next, &retry, &retry_identity) + .expect("admit the replacement writer"); + #[cfg(feature = "test-helpers")] + assert_eq!( + barriers.syncs(), + u64::try_from(DURABLE_BATCH_CONCURRENT_SYNCS).expect("window size"), + "the 32 accepted members need real barriers before their handles release" + ); + assert_eq!(batch.pending.len(), 1); + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + drop(retry); + batch.sync().expect("flush the replacement member"); + #[cfg(feature = "test-helpers")] + assert_eq!( + barriers.syncs(), + u64::try_from(DURABLE_BATCH_CONCURRENT_SYNCS + 1).expect("total count"), + "retry must not skip or duplicate durability barriers" + ); + assert_eq!(fs::read(&next).expect("read back retry"), b"next segment"); +} + +#[cfg(feature = "test-helpers")] +#[test] +fn durable_batch_invalid_next_member_does_not_flush_a_full_window() { + let root = tempfile::tempdir().expect("batch root"); + let mut batch = DurableFileBatch::new(); + for index in 0..DURABLE_BATCH_CONCURRENT_SYNCS { + write_member(&mut batch, root.path(), index); + } + + let next = root.path().join("next.tmp"); + let other = root.path().join("unrelated.tmp"); + let mut writer = File::create(&next).expect("next writer"); + writer.write_all(b"next segment").expect("next bytes"); + fs::write(&other, b"next segment").expect("same bytes, different inode"); + let barriers = sync_latency::inject(root.path(), Duration::ZERO); + + assert_eq!( + batch + .written(&other, &writer) + .expect_err("a foreign pathname must be rejected before flushing") + .kind(), + ErrorKind::InvalidData + ); + assert_eq!( + barriers.syncs(), + 0, + "an invalid new member must not trigger durability work on a full valid window" + ); + assert_eq!(batch.pending.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert_eq!(batch.synced.len(), 0); + + batch.written(&next, &writer).expect("admit correct member"); + assert_eq!( + barriers.syncs(), + u64::try_from(DURABLE_BATCH_CONCURRENT_SYNCS).expect("window size"), + "admitting the real next member must flush the previous window" + ); + assert_eq!(batch.pending.len(), 1); + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + drop(writer); + batch.sync().expect("flush admitted member"); + assert_eq!( + barriers.syncs(), + u64::try_from(DURABLE_BATCH_CONCURRENT_SYNCS + 1).expect("member count"), + "the optimization must not skip a durability barrier" + ); + assert_eq!(fs::read(&next).expect("durable member"), b"next segment"); +} + +#[test] +fn durable_batch_failed_window_keeps_handles_and_does_not_admit_another_member() { + let root = tempfile::tempdir().expect("batch root"); + let mut batch = DurableFileBatch::new(); + for index in 0..DURABLE_BATCH_CONCURRENT_SYNCS { + write_member(&mut batch, root.path(), index); + } + fs::remove_file(root.path().join("segment-0.tmp")).expect("remove pending member"); + let next = root.path().join("next.tmp"); + let mut writer = File::create(&next).expect("next writer"); + writer.write_all(b"next segment").expect("next bytes"); + assert_eq!( + batch + .written(&next, &writer) + .expect_err("failed window") + .kind(), + ErrorKind::NotFound + ); + assert_eq!(batch.pending.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert_eq!( + batch.synced.len(), + 0, + "no partial window may claim durability" + ); + assert_eq!( + batch.sync().expect_err("retry remains refused").kind(), + ErrorKind::NotFound + ); +} + +#[cfg(feature = "test-helpers")] +#[test] +fn durable_batch_checks_earlier_windows_after_the_last_barrier() { + let root = tempfile::tempdir().expect("isolated durability root"); + let mut batch = DurableFileBatch::new(); + // 33 real writers: the first window has 32 completed fsyncs and the + // final window still owns its one descriptor. + for index in 0..=DURABLE_BATCH_CONCURRENT_SYNCS { + write_member(&mut batch, root.path(), index); + } + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert_eq!(batch.pending.len(), 1); + batch.sync().expect("original batch is valid"); + + let first = root.path().join("segment-0.tmp"); + let original = fs::symlink_metadata(&first).expect("original inode"); + let replacement = root.path().join("replacement.tmp"); + fs::write(&replacement, b"sealed segment 0").expect("same-byte replacement"); + let other = fs::symlink_metadata(&replacement).expect("replacement inode"); + assert_ne!((original.dev(), original.ino()), (other.dev(), other.ino())); + + // The existing fsync test fixture pauses at the actual barrier, so + // replacement happens after preflight but before fsync returns. + let (barriers, pause) = sync_latency::pause(root.path()); + let result = std::thread::scope(|scope| { + let worker = scope.spawn(|| batch.sync()); + let reached = pause.wait_for_sync(Duration::from_secs(10)); + let replaced = if reached { + fs::rename(&replacement, &first) + } else { + Ok(()) + }; + pause.resume(); + assert!(reached, "one real final-window barrier must be observed"); + replaced.expect("replace earlier inode while final barrier is paused"); + worker.join().expect("fsync worker") + }); + assert_eq!(barriers.syncs(), 1, "one real fsync must have executed"); + assert_eq!( + result + .expect_err("the changed earlier inode must never publish") + .kind(), + ErrorKind::InvalidData + ); + assert_eq!( + fs::read(&first).expect("replacement remains intact"), + b"sealed segment 0" + ); + let installed = fs::symlink_metadata(&first).expect("installed inode"); + assert_eq!( + (installed.dev(), installed.ino()), + (other.dev(), other.ino()) + ); + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert_eq!(batch.pending.len(), 1); + assert_eq!( + batch + .sync() + .expect_err("invalidated batch must keep refusing") + .kind(), + ErrorKind::InvalidData + ); + assert_eq!( + barriers.syncs(), + 1, + "failed retry must not issue another fsync" + ); +} diff --git a/crates/tracedecay-private-fs/src/framed_log_window_tests.rs b/crates/tracedecay-private-fs/src/framed_log_window_tests.rs new file mode 100644 index 00000000000..5f706ed487f --- /dev/null +++ b/crates/tracedecay-private-fs/src/framed_log_window_tests.rs @@ -0,0 +1,181 @@ +//! Changes after one member's completed barrier must invalidate its whole window. +use std::fs::{self, File}; +use std::io::{ErrorKind, Write}; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use super::{DURABLE_BATCH_CONCURRENT_SYNCS, DurableFileBatch, sync_latency}; + +fn write_members(batch: &mut DurableFileBatch, root: &Path, count: usize) -> Vec { + (0..count) + .map(|index| { + let path = root.join(format!("segment-{index}.tmp")); + let mut writer = File::create(&path).expect("member writer"); + writer + .write_all(format!("sealed segment {index}").as_bytes()) + .expect("nonempty member bytes"); + batch + .written(&path, &writer) + .expect("admit original member"); + path + }) + .collect() +} + +#[test] +fn final_sync_refuses_replacement_after_a_pending_member_completed() { + let root = tempfile::tempdir().expect("isolated batch root"); + let mut batch = DurableFileBatch::new(); + let paths = write_members(&mut batch, root.path(), 2); + let first = &paths[0]; + let original = fs::metadata(first).expect("original member identity"); + + let (barriers, result) = thread::scope(|scope| { + // This handshake happens after the real fsync and both per-member + // identity checks. No sleep or scheduler ordering establishes the race. + // Keep the pause local so any assertion failure disconnects the worker + // before thread::scope joins it. + let (barriers, pause) = sync_latency::pause_after_batch_member(first); + let worker = scope.spawn(|| batch.sync()); + assert!(pause.wait_for_sync(Duration::from_secs(10))); + assert_eq!( + barriers.syncs(), + 1, + "a real member fsync must have completed" + ); + + let replacement = root.path().join("replacement.tmp"); + fs::write(&replacement, b"sealed segment 0").expect("identical replacement bytes"); + fs::rename(&replacement, first).expect("replace the already-checked inode"); + let current = fs::metadata(first).expect("replacement identity"); + assert_ne!( + (original.dev(), original.ino()), + (current.dev(), current.ino()), + "same-byte replacement must actually change the inode" + ); + pause.resume(); + (barriers, worker.join().expect("batch worker")) + }); + + let error = result.expect_err("changed pending window"); + assert_eq!(error.kind(), ErrorKind::InvalidData); + assert_eq!(batch.pending.len(), 2, "failed sync retains both handles"); + assert!(batch.synced.is_empty()); + assert_eq!( + fs::read(first).expect("replacement survives"), + b"sealed segment 0" + ); + let error = batch + .sync() + .expect_err("retry must still refuse the old identity"); + assert_eq!(error.kind(), ErrorKind::InvalidData); + assert_eq!( + barriers.syncs(), + 1, + "retry must not flush the substituted member" + ); + drop(barriers); + drop(batch); + + // A fresh, explicitly authenticated batch can publish the surviving bytes. + let mut recovered = DurableFileBatch::new(); + for path in &paths { + let writer = File::options() + .read(true) + .write(true) + .open(path) + .expect("fresh writer"); + recovered + .written(path, &writer) + .expect("authenticate current inode"); + } + let barriers = sync_latency::inject(root.path(), Duration::ZERO); + recovered.sync().expect("flush freshly authenticated batch"); + assert_eq!(barriers.syncs(), 2); + for (index, path) in paths.iter().enumerate() { + assert_eq!( + fs::read(path).expect("exact durable recovery bytes"), + format!("sealed segment {index}").as_bytes() + ); + } +} + +#[test] +fn window_rollover_refuses_mutation_after_a_pending_member_completed() { + let root = tempfile::tempdir().expect("isolated full-window root"); + let mut batch = DurableFileBatch::new(); + let paths = write_members(&mut batch, root.path(), DURABLE_BATCH_CONCURRENT_SYNCS); + let first = &paths[0]; + let original = fs::metadata(first).expect("original member identity"); + let incoming = root.path().join("incoming.tmp"); + let mut writer = File::create(&incoming).expect("incoming writer"); + writer + .write_all(b"incoming segment") + .expect("incoming bytes"); + + let (barriers, result) = thread::scope(|scope| { + let (barriers, pause) = sync_latency::pause_after_batch_member(first); + let worker = scope.spawn(|| batch.written(&incoming, &writer)); + assert!(pause.wait_for_sync(Duration::from_secs(10))); + assert_eq!( + barriers.syncs(), + 1, + "a real member fsync must have completed" + ); + + let mut changed = File::options() + .write(true) + .open(first) + .expect("same inode writer"); + changed + .write_all(b"changed segment!") + .expect("same-length mutation"); + changed + .set_modified(original.modified().expect("original mtime") + Duration::from_secs(2)) + .expect("observable mutation even on coarse timestamp filesystems"); + drop(changed); + let current = fs::metadata(first).expect("mutated member identity"); + assert_eq!( + (original.dev(), original.ino(), original.len()), + (current.dev(), current.ino(), current.len()), + "the mutation must preserve both inode and byte count" + ); + pause.resume(); + (barriers, worker.join().expect("admission worker")) + }); + + let error = result.expect_err("changed full window"); + assert_eq!(error.kind(), ErrorKind::InvalidData); + assert_eq!(batch.pending.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert!( + batch.synced.is_empty(), + "no unproven member may be promoted" + ); + assert!( + batch + .pending + .iter() + .all(|member| member.identity.path != incoming) + ); + assert_eq!( + fs::read(first).expect("mutated bytes survive"), + b"changed segment!" + ); + assert_eq!( + fs::read(&incoming).expect("incoming bytes survive"), + b"incoming segment" + ); + let error = batch + .written(&incoming, &writer) + .expect_err("retry preserves failed admission"); + assert_eq!(error.kind(), ErrorKind::InvalidData); + assert_eq!( + barriers.syncs(), + 1, + "changed identity must fail before another fsync" + ); + assert_eq!(batch.pending.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert!(batch.synced.is_empty()); +}