use std::{ fmt, os::fd::AsRawFd, time::{Duration, SystemTime, UNIX_EPOCH}, }; use crate::{ ArtifactError, ArtifactStore, ReconciliationMutation, ReconciliationNamespace, ReconciliationScan, store::{ QUARANTINE_DIR, RootLock, check_file, checkpointed, ensure_root_unchanged, fsync_fd, open_existing_dir, open_or_create_dir, rename_no_replace_at, stat_fd, unlinkat, }, }; const FINAL_NAMESPACE: u8 = 0; const QUARANTINE_NAMESPACE: u8 = 1; /// Opaque continuation for a bounded reconciliation traversal. It is valid /// only for the unchanged pinned root from which it was returned. pub struct ReconciliationCursor { root_dev: u64, root_ino: u64, namespace: u8, shard: u8, position: i64, } impl fmt::Debug for ReconciliationCursor { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("ReconciliationCursor(..)") } } /// Opaque, inode-bound evidence for one valid final or quarantined artifact. /// It intentionally exposes neither its digest nor filesystem path. #[derive(Clone)] pub struct ReconciliationCandidate { root_dev: u64, root_ino: u64, namespace: ReconciliationNamespace, shard: String, shard_dev: u64, shard_ino: u64, name: String, dev: u64, ino: u64, modified_seconds: i64, modified_nanoseconds: i64, } impl fmt::Debug for ReconciliationCandidate { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("ReconciliationCandidate(..)") } } impl ReconciliationCandidate { pub fn namespace(&self) -> ReconciliationNamespace { self.namespace } } /// Opaque, single-use evidence that a particular stale temporary inode was /// observed under this store's pinned root. It is not a blob reference. #[derive(Debug)] pub struct StaleTemp { name: String, shard: String, root_dev: u64, root_ino: u64, dev: u64, ino: u64, modified_seconds: i64, modified_nanoseconds: i64, grace: Duration, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct TempScan { pub scanned: usize, pub omitted: usize, } impl ArtifactStore { /// Streams valid final and quarantine entries with an opaque continuation. /// Neither the report nor the returned capabilities disclose paths/digests. pub fn scan_reconciliation( &self, continuation: Option, scan_budget: usize, result_limit: usize, ) -> Result<(ReconciliationScan, Vec), ArtifactError> { let root = self.root()?; let _lock = RootLock::shared(root)?; ensure_root_unchanged(root)?; let mut cursor = match continuation { Some(cursor) => { if cursor.root_dev != root.dev || cursor.root_ino != root.ino || cursor.namespace > QUARANTINE_NAMESPACE { return Err(ArtifactError::UnsafeRoot); } cursor } None => reconciliation_cursor(root.dev, root.ino, FINAL_NAMESPACE, 0, 0), }; let mut report = ReconciliationScan::empty(None); if scan_budget == 0 || result_limit == 0 { report.continuation = Some(cursor); return Ok((report, Vec::new())); } let mut remaining = scan_budget; let mut entries = Vec::new(); while cursor.namespace <= QUARANTINE_NAMESPACE { if remaining == 0 || entries.len() == result_limit { report.continuation = Some(cursor); return Ok((report, entries)); } let namespace_name = namespace_name(cursor.namespace); let namespace = match open_existing_dir(root.fd.as_raw_fd(), namespace_name) { Ok(fd) => fd, Err(ArtifactError::NotFound) => { advance_namespace(&mut cursor); continue; } Err(error) => return Err(error), }; loop { if remaining == 0 { report.continuation = Some(cursor); return Ok((report, entries)); } let shard_name = format!("{:02x}", cursor.shard); let shard = match open_existing_dir(namespace.as_raw_fd(), shard_name.as_bytes()) { Ok(fd) => fd, Err(ArtifactError::NotFound) => { consume_scan_work(&mut report, &mut remaining); if cursor.shard == u8::MAX { break; } advance_shard(&mut cursor); continue; } Err(error) => return Err(error), }; let shard_stat = stat_fd(shard.as_raw_fd())?; let mut directory = DirectoryStream::open(shard.as_raw_fd(), cursor.position)?; loop { if remaining == 0 || entries.len() == result_limit { report.continuation = Some(cursor); return Ok((report, entries)); } let Some((name, after)) = directory.next()? else { // An empty directory still consumes one bounded // traversal unit, so a sparse namespace cannot make // us probe all 256 shards in one page. consume_scan_work(&mut report, &mut remaining); break; }; cursor.position = after; consume_scan_work(&mut report, &mut remaining); let Ok(name) = String::from_utf8(name) else { report.malformed += 1; continue; }; if !valid_final_name(&name, &shard_name) { report.malformed += 1; continue; } #[cfg(debug_assertions)] let _ = crate::test_support::checkpoint("reconciliation_before_stat"); let stat = match checkpointed("reconciliation_stat", || { nofollow_stat(shard.as_raw_fd(), &name) }) { Ok(stat) => stat, Err(ArtifactError::NotFound) => { // The entry was concurrently removed. It is no // longer retryable, so the cursor may advance. continue; } Err(ArtifactError::UnsafeRoot) => { report.unsafe_entries += 1; continue; } Err(error) => return Err(error), }; if check_file(&stat).is_err() { report.unsafe_entries += 1; continue; } let namespace = if cursor.namespace == FINAL_NAMESPACE { report.final_entries += 1; ReconciliationNamespace::Final } else { report.quarantined_entries += 1; ReconciliationNamespace::Quarantine }; entries.push(ReconciliationCandidate { root_dev: root.dev, root_ino: root.ino, namespace, shard: shard_name.clone(), shard_dev: shard_stat.st_dev, shard_ino: shard_stat.st_ino, name, dev: stat.st_dev, ino: stat.st_ino, modified_seconds: stat.st_mtime, modified_nanoseconds: stat.st_mtime_nsec, }); // The continuation is deliberately kept at `after`, // immediately before the next unissued item. } if cursor.shard == u8::MAX { break; } advance_shard(&mut cursor); } advance_namespace(&mut cursor); } Ok((report, entries)) } /// Moves a revalidated final inode into the private quarantine namespace /// without replacing any existing quarantine inode. pub fn quarantine_reconciliation( &self, candidate: ReconciliationCandidate, ) -> Result { if candidate.namespace != ReconciliationNamespace::Final { return Err(ArtifactError::UnsafeRoot); } let root = self.root()?; let _lock = RootLock::exclusive(root)?; ensure_reconciliation_root(root, &candidate)?; let final_root = open_existing_dir(root.fd.as_raw_fd(), namespace_name(FINAL_NAMESPACE))?; let final_shard = open_existing_dir(final_root.as_raw_fd(), candidate.shard.as_bytes())?; match revalidate_candidate(&candidate, final_shard.as_raw_fd()) { Ok(()) => {} Err(ArtifactError::NotFound) => { return self.recover_quarantine(root, &candidate); } Err(ArtifactError::UnsafeRoot) => { return match self.recover_quarantine(root, &candidate) { Ok(outcome) => Ok(outcome), Err(ArtifactError::NotFound) => Err(ArtifactError::UnsafeRoot), Err(error) => Err(error), }; } Err(error) => return Err(error), } let quarantine_root = open_or_create_dir(root.fd.as_raw_fd(), QUARANTINE_DIR)?; let quarantine_shard = open_or_create_dir(quarantine_root.as_raw_fd(), candidate.shard.as_bytes())?; match checkpointed("reconciliation_quarantine_rename", || { rename_no_replace_at( final_shard.as_raw_fd(), candidate.name.as_bytes(), quarantine_shard.as_raw_fd(), candidate.name.as_bytes(), ) })? { true => match fsync_quarantine_dirs(&final_shard, &quarantine_shard) { Ok(()) => Ok(ReconciliationMutation::Quarantined), Err(_) => Ok(ReconciliationMutation::Retryable), }, false => self.recover_quarantine(root, &candidate), } } /// Idempotently removes a revalidated quarantined inode and fsyncs its /// directory. An fsync ambiguity is explicitly retryable. pub fn delete_quarantined_reconciliation( &self, candidate: ReconciliationCandidate, ) -> Result { if candidate.namespace != ReconciliationNamespace::Quarantine { return Err(ArtifactError::UnsafeRoot); } let root = self.root()?; let _lock = RootLock::exclusive(root)?; ensure_reconciliation_root(root, &candidate)?; let quarantine_root = match open_existing_dir(root.fd.as_raw_fd(), QUARANTINE_DIR) { Ok(fd) => fd, Err(ArtifactError::NotFound) => return Ok(ReconciliationMutation::AlreadyAbsent), Err(error) => return Err(error), }; let shard = match open_existing_dir(quarantine_root.as_raw_fd(), candidate.shard.as_bytes()) { Ok(fd) => fd, Err(ArtifactError::NotFound) => return Ok(ReconciliationMutation::AlreadyAbsent), Err(error) => return Err(error), }; match revalidate_candidate(&candidate, shard.as_raw_fd()) { Ok(()) => {} Err(ArtifactError::NotFound) => { return fsync_mutation(&shard, ReconciliationMutation::AlreadyAbsent); } Err(error) => return Err(error), } match checkpointed("reconciliation_delete_unlink", || { unlinkat(shard.as_raw_fd(), candidate.name.as_bytes()) }) { Ok(()) => fsync_mutation(&shard, ReconciliationMutation::Deleted), Err(ArtifactError::NotFound) => { fsync_mutation(&shard, ReconciliationMutation::AlreadyAbsent) } Err(error) => Err(error), } } fn recover_quarantine( &self, root: &crate::store::Root, candidate: &ReconciliationCandidate, ) -> Result { let final_root = open_existing_dir(root.fd.as_raw_fd(), namespace_name(FINAL_NAMESPACE))?; let final_shard = open_existing_dir(final_root.as_raw_fd(), candidate.shard.as_bytes())?; let quarantine_root = match open_existing_dir(root.fd.as_raw_fd(), QUARANTINE_DIR) { Ok(fd) => fd, Err(ArtifactError::NotFound) => return Err(ArtifactError::NotFound), Err(error) => return Err(error), }; let shard = match open_existing_dir(quarantine_root.as_raw_fd(), candidate.shard.as_bytes()) { Ok(fd) => fd, Err(ArtifactError::NotFound) => return Err(ArtifactError::NotFound), Err(error) => return Err(error), }; revalidate_moved_candidate(candidate, shard.as_raw_fd())?; match fsync_quarantine_dirs(&final_shard, &shard) { Ok(()) => Ok(ReconciliationMutation::AlreadyQuarantined), Err(_) => Ok(ReconciliationMutation::Retryable), } } /// Streams temporary entries, reading at most `scan_budget` directory /// entries and returning at most `result_limit` stale capabilities. pub fn scan_stale_temps( &self, grace: Duration, scan_budget: usize, result_limit: usize, ) -> Result<(TempScan, Vec), ArtifactError> { let root = self.root()?; let _lock = RootLock::shared(root)?; ensure_root_unchanged(root)?; let sha = match open_existing_dir(root.fd.as_raw_fd(), b"sha256") { Ok(fd) => fd, Err(ArtifactError::NotFound) => return Ok((TempScan::default(), Vec::new())), Err(error) => return Err(error), }; let mut report = TempScan::default(); let mut remaining = scan_budget; let mut result = Vec::new(); for shard in (0_u8..=255).map(|value| format!("{value:02x}")) { if remaining == 0 { break; } let shard_fd = match open_existing_dir(sha.as_raw_fd(), shard.as_bytes()) { Ok(fd) => fd, Err(ArtifactError::NotFound) => continue, Err(error) => return Err(error), }; for name in list_names(shard_fd.as_raw_fd(), &mut remaining, &mut report)? { if !valid_temp_name(&name) { continue; } #[cfg(debug_assertions)] let _ = crate::test_support::checkpoint("housekeeping_before_stat"); let stat = match nofollow_stat(shard_fd.as_raw_fd(), &name) { Ok(stat) => stat, Err(ArtifactError::NotFound) => continue, Err(error) => return Err(error), }; if (stat.st_mode & libc::S_IFMT) != libc::S_IFREG || stat.st_nlink != 1 { continue; } let modified = UNIX_EPOCH .checked_add(Duration::new( stat.st_mtime.max(0) as u64, stat.st_mtime_nsec.max(0) as u32, )) .unwrap_or(SystemTime::UNIX_EPOCH); if SystemTime::now() .duration_since(modified) .is_ok_and(|age| age >= grace) { if result.len() == result_limit { report.omitted += 1; continue; } result.push(StaleTemp { name, shard: shard.clone(), root_dev: root.dev, root_ino: root.ino, dev: stat.st_dev, ino: stat.st_ino, modified_seconds: stat.st_mtime, modified_nanoseconds: stat.st_mtime_nsec, grace, }); } } } Ok((report, result)) } /// Deletes only a previously discovered temporary inode after an exclusive /// cooperative root lock and inode revalidation, then fsyncs its shard. pub fn delete_stale_temp(&self, candidate: StaleTemp) -> Result<(), ArtifactError> { let root = self.root()?; let _lock = RootLock::exclusive(root)?; ensure_root_unchanged(root)?; if candidate.root_dev != root.dev || candidate.root_ino != root.ino { return Err(ArtifactError::UnsafeRoot); } if !valid_temp_name(&candidate.name) || candidate.shard.len() != 2 || !candidate.shard.bytes().all(is_lower_hex) { return Err(ArtifactError::UnsafeRoot); } let sha = open_existing_dir(root.fd.as_raw_fd(), b"sha256")?; let shard = open_existing_dir(sha.as_raw_fd(), candidate.shard.as_bytes())?; let stat = nofollow_stat(shard.as_raw_fd(), &candidate.name)?; if stat.st_dev != candidate.dev || stat.st_ino != candidate.ino || stat.st_mtime != candidate.modified_seconds || stat.st_mtime_nsec != candidate.modified_nanoseconds || (stat.st_mode & libc::S_IFMT) != libc::S_IFREG || stat.st_nlink != 1 { return Err(ArtifactError::UnsafeRoot); } let modified = UNIX_EPOCH .checked_add(Duration::new( stat.st_mtime.max(0) as u64, stat.st_mtime_nsec.max(0) as u32, )) .unwrap_or(SystemTime::UNIX_EPOCH); if !SystemTime::now() .duration_since(modified) .is_ok_and(|age| age >= candidate.grace) { return Err(ArtifactError::UnsafeRoot); } unlinkat(shard.as_raw_fd(), candidate.name.as_bytes())?; fsync_fd(shard.as_raw_fd()) } } fn reconciliation_cursor( root_dev: u64, root_ino: u64, namespace: u8, shard: u8, position: i64, ) -> ReconciliationCursor { ReconciliationCursor { root_dev, root_ino, namespace, shard, position, } } fn namespace_name(namespace: u8) -> &'static [u8] { match namespace { FINAL_NAMESPACE => b"sha256", QUARANTINE_NAMESPACE => QUARANTINE_DIR, _ => unreachable!("validated reconciliation namespace"), } } fn advance_shard(cursor: &mut ReconciliationCursor) { debug_assert_ne!(cursor.shard, u8::MAX); cursor.shard += 1; cursor.position = 0; } fn advance_namespace(cursor: &mut ReconciliationCursor) { cursor.namespace = cursor.namespace.saturating_add(1); cursor.shard = 0; cursor.position = 0; } fn consume_scan_work(report: &mut ReconciliationScan, remaining: &mut usize) { debug_assert!(*remaining > 0); *remaining -= 1; report.scanned += 1; } fn ensure_reconciliation_root( root: &crate::store::Root, candidate: &ReconciliationCandidate, ) -> Result<(), ArtifactError> { ensure_root_unchanged(root)?; if candidate.root_dev != root.dev || candidate.root_ino != root.ino || candidate.shard.len() != 2 || !candidate.shard.bytes().all(is_lower_hex) || !valid_final_name(&candidate.name, &candidate.shard) { return Err(ArtifactError::UnsafeRoot); } Ok(()) } fn revalidate_candidate( candidate: &ReconciliationCandidate, shard: i32, ) -> Result<(), ArtifactError> { let shard_stat = stat_fd(shard)?; if shard_stat.st_dev != candidate.shard_dev || shard_stat.st_ino != candidate.shard_ino { return Err(ArtifactError::UnsafeRoot); } let stat = nofollow_stat(shard, &candidate.name)?; if stat.st_dev != candidate.dev || stat.st_ino != candidate.ino || stat.st_mtime != candidate.modified_seconds || stat.st_mtime_nsec != candidate.modified_nanoseconds { return Err(ArtifactError::UnsafeRoot); } check_file(&stat).map_err(|_| ArtifactError::UnsafeRoot) } /// Recovery follows a successful cross-directory rename. The candidate remains /// bound to the original final shard, so the quarantine shard identity cannot /// equal its recorded shard identity; only the canonical name and inode are /// revalidated here. fn revalidate_moved_candidate( candidate: &ReconciliationCandidate, shard: i32, ) -> Result<(), ArtifactError> { let stat = nofollow_stat(shard, &candidate.name)?; if stat.st_dev != candidate.dev || stat.st_ino != candidate.ino || stat.st_mtime != candidate.modified_seconds || stat.st_mtime_nsec != candidate.modified_nanoseconds { return Err(ArtifactError::UnsafeRoot); } check_file(&stat).map_err(|_| ArtifactError::UnsafeRoot) } fn fsync_mutation( shard: &std::os::fd::OwnedFd, success: ReconciliationMutation, ) -> Result { match checkpointed("reconciliation_delete_fsync", || { fsync_fd(shard.as_raw_fd()) }) { Ok(()) => Ok(success), Err(_) => Ok(ReconciliationMutation::Retryable), } } /// Persist both directory entry changes even if the first fsync reports an /// error. Either failure remains an ambiguity, but skipping the second fsync /// would needlessly enlarge the crash window. fn fsync_quarantine_dirs( final_shard: &std::os::fd::OwnedFd, quarantine_shard: &std::os::fd::OwnedFd, ) -> Result<(), ArtifactError> { let source = checkpointed("reconciliation_quarantine_source_fsync", || { fsync_fd(final_shard.as_raw_fd()) }); let destination = checkpointed("reconciliation_quarantine_destination_fsync", || { fsync_fd(quarantine_shard.as_raw_fd()) }); source.and(destination) } fn valid_final_name(name: &str, shard: &str) -> bool { name.len() == 64 && name.starts_with(shard) && name.bytes().all(is_lower_hex) } struct DirectoryStream { directory: *mut libc::DIR, } impl DirectoryStream { fn open(parent: i32, position: i64) -> Result { let duplicate = crate::store::duplicate_fd(parent)?; let raw = std::os::fd::IntoRawFd::into_raw_fd(duplicate); let directory = unsafe { libc::fdopendir(raw) }; if directory.is_null() { unsafe { libc::close(raw); } return Err(ArtifactError::Storage); } if position != 0 { unsafe { libc::seekdir(directory, position as libc::c_long); } } Ok(Self { directory }) } fn next(&mut self) -> Result, i64)>, ArtifactError> { loop { unsafe { *libc::__errno_location() = 0; } let entry = unsafe { libc::readdir(self.directory) }; if entry.is_null() { if unsafe { *libc::__errno_location() } != 0 { return Err(ArtifactError::Storage); } return Ok(None); } let after = unsafe { libc::telldir(self.directory) } as i64; let name = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); if name == b"." || name == b".." { continue; } return Ok(Some((name.to_vec(), after))); } } } impl Drop for DirectoryStream { fn drop(&mut self) { unsafe { libc::closedir(self.directory); } } } fn list_names( parent: i32, remaining: &mut usize, report: &mut TempScan, ) -> Result, ArtifactError> { let dot = std::ffi::CString::new(".").map_err(|_| ArtifactError::Storage)?; let raw = unsafe { libc::openat( parent, dot.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC, ) }; if raw < 0 { return Err(ArtifactError::Storage); } let directory = unsafe { libc::fdopendir(raw) }; if directory.is_null() { unsafe { libc::close(raw); } return Err(ArtifactError::Storage); } let mut names = Vec::new(); loop { if *remaining == 0 { break; } unsafe { *libc::__errno_location() = 0; } let entry = unsafe { libc::readdir(directory) }; if entry.is_null() { if unsafe { *libc::__errno_location() } != 0 { unsafe { libc::closedir(directory); } return Err(ArtifactError::Storage); } break; } let name = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); if name == b"." || name == b".." { continue; } report.scanned += 1; *remaining -= 1; if let Ok(name) = std::str::from_utf8(name) { names.push(name.to_owned()); } } unsafe { libc::closedir(directory); } Ok(names) } fn valid_temp_name(name: &str) -> bool { let Some(rest) = name.strip_prefix(ArtifactStore::temp_prefix()) else { return false; }; let mut fields = rest.split('-'); let (Some(nonce), Some(pid), Some(sequence), None) = (fields.next(), fields.next(), fields.next(), fields.next()) else { return false; }; nonce.len() == 32 && nonce.bytes().all(is_lower_hex) && !pid.is_empty() && pid.bytes().all(|byte| byte.is_ascii_digit()) && !sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit()) } fn is_lower_hex(byte: u8) -> bool { byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte) } fn nofollow_stat(parent: i32, name: &str) -> Result { let name = crate::store::c_name(name.as_bytes())?; let mut stat = unsafe { std::mem::zeroed() }; if unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) } == 0 { Ok(stat) } else { Err(crate::store::classify_errno()) } }