use std::{ fmt, os::fd::{AsRawFd, FromRawFd, OwnedFd}, }; use crate::temp_scan::valid_temp_name; use crate::{ ArtifactError, ArtifactStore, ReconciliationMutation, ReconciliationNamespace, ReconciliationScan, ReconciliationScanStop, recovery::{FINAL_NAMESPACE, namespace_name, namespace_number}, 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, }, }; mod stale_temp; pub use stale_temp::{StaleTemp, TempScan, TempScanCursor}; /// Opaque bounded-scan continuation, valid only for an unchanged namespace. /// Discard it after any put, quarantine, delete, or external mutation. pub struct ReconciliationCursor { root_dev: u64, root_ino: u64, namespace_start: u8, namespace: u8, namespace_end: u8, shard: u8, state: ReconciliationCursorState, } enum ReconciliationCursorState { OpenNamespace, ValidateNamespace(OwnedFd), OpenShard(OwnedFd), ValidateShard { namespace: OwnedFd, shard: OwnedFd, }, ReadShard { namespace: OwnedFd, shard: OwnedFd, shard_dev: u64, shard_ino: u64, buffer: Vec, offset: usize, filled: usize, cookie: i64, }, SeekShard { namespace: OwnedFd, shard: OwnedFd, shard_dev: u64, shard_ino: u64, buffer: Vec, cookie: i64, }, Transition, } struct ShardIdentity(u64, u64); impl fmt::Debug for ReconciliationCursor { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("ReconciliationCursor(..)") } } /// Opaque inode-bound evidence that exposes neither digest nor filesystem path. #[derive(Clone)] pub struct ReconciliationCandidate { pub(crate) root_dev: u64, pub(crate) root_ino: u64, pub(crate) namespace: ReconciliationNamespace, pub(crate) shard: String, pub(crate) shard_dev: u64, pub(crate) shard_ino: u64, pub(crate) name: String, pub(crate) dev: u64, pub(crate) ino: u64, pub(crate) modified_seconds: i64, pub(crate) modified_nanoseconds: i64, } impl ArtifactStore { pub(crate) fn scan_reconciliation_bounded( &self, start_namespace: ReconciliationNamespace, end_namespace: ReconciliationNamespace, continuation: Option, scan_budget: usize, result_limit: usize, ) -> Result<(ReconciliationScan, Vec), ArtifactError> { let root = self.root()?; let namespace = namespace_number(start_namespace); let namespace_end = namespace_number(end_namespace); let mut cursor = match continuation { Some(cursor) => { if cursor.root_dev != root.dev || cursor.root_ino != root.ino || cursor.namespace_start != namespace || cursor.namespace < namespace || cursor.namespace > cursor.namespace_end || cursor.namespace_end != namespace_end { return Err(ArtifactError::UnsafeRoot); } cursor } None => reconciliation_cursor(root.dev, root.ino, namespace, namespace_end), }; let report = ReconciliationScan::empty(None); let _lock = match RootLock::shared(root) { Ok(lock) => lock, Err(ArtifactError::Storage) => { return Ok(reconciliation_page( report, Vec::new(), cursor, ReconciliationScanStop::Retryable, )); } Err(error) => return Err(error), }; match ensure_root_unchanged(root) { Ok(()) => {} Err(ArtifactError::Storage) => { return Ok(reconciliation_page( report, Vec::new(), cursor, ReconciliationScanStop::Retryable, )); } Err(error) => return Err(error), } let mut report = report; if scan_budget == 0 { return Ok(reconciliation_page( report, Vec::new(), cursor, ReconciliationScanStop::ScanBudget, )); } if result_limit == 0 { return Ok(reconciliation_page( report, Vec::new(), cursor, ReconciliationScanStop::ResultLimit, )); } let mut entries = Vec::new(); while cursor.namespace <= cursor.namespace_end { if entries.len() == result_limit { return Ok(reconciliation_page( report, entries, cursor, ReconciliationScanStop::ResultLimit, )); } if report.traversal_syscalls == scan_budget { return Ok(reconciliation_page( report, entries, cursor, ReconciliationScanStop::ScanBudget, )); } let state = std::mem::replace(&mut cursor.state, ReconciliationCursorState::Transition); match reconciliation_step(root, &mut cursor, state, &mut report, &mut entries) { Ok(()) => {} Err((state, ArtifactError::Storage)) => { cursor.state = state; return Ok(reconciliation_page( report, entries, cursor, ReconciliationScanStop::Retryable, )); } Err((_, error)) => return Err(error), } } report.stop = ReconciliationScanStop::Complete; Ok((report, entries)) } /// Moves a revalidated final inode to quarantine without replacing an 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. 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), } } } fn reconciliation_cursor( root_dev: u64, root_ino: u64, namespace: u8, namespace_end: u8, ) -> ReconciliationCursor { ReconciliationCursor { root_dev, root_ino, namespace_start: namespace, namespace, namespace_end, shard: 0, state: ReconciliationCursorState::OpenNamespace, } } fn reconciliation_page( mut report: ReconciliationScan, entries: Vec, cursor: ReconciliationCursor, stop: ReconciliationScanStop, ) -> (ReconciliationScan, Vec) { report.stop = stop; report.continuation = Some(cursor); (report, entries) } fn reconciliation_step( root: &crate::store::Root, cursor: &mut ReconciliationCursor, state: ReconciliationCursorState, report: &mut ReconciliationScan, entries: &mut Vec, ) -> Result<(), (ReconciliationCursorState, ArtifactError)> { match state { ReconciliationCursorState::OpenNamespace => { count_traversal_syscall(report); match open_traversal_dir(root.fd.as_raw_fd(), namespace_name(cursor.namespace)) { Ok(namespace) => { cursor.state = ReconciliationCursorState::ValidateNamespace(namespace); } Err(ArtifactError::NotFound) => advance_namespace(cursor), Err(ArtifactError::UnsafeRoot) => { report.unsafe_entries += 1; advance_namespace(cursor); } Err(error) => { return Err((ReconciliationCursorState::OpenNamespace, error)); } } } ReconciliationCursorState::ValidateNamespace(namespace) => { count_traversal_syscall(report); match validate_traversal_dir(&namespace) { Ok(_) => { cursor.state = ReconciliationCursorState::OpenShard(namespace); } Err(ArtifactError::UnsafeRoot) => { report.unsafe_entries += 1; advance_namespace(cursor); } Err(error) => { return Err(( ReconciliationCursorState::ValidateNamespace(namespace), error, )); } } } ReconciliationCursorState::OpenShard(namespace) => { let shard_name = format!("{:02x}", cursor.shard); count_traversal_syscall(report); match open_traversal_dir(namespace.as_raw_fd(), shard_name.as_bytes()) { Ok(shard) => { cursor.state = ReconciliationCursorState::ValidateShard { namespace, shard }; } Err(ArtifactError::NotFound) => advance_reconciliation_shard(cursor, namespace), Err(ArtifactError::UnsafeRoot) => { report.unsafe_entries += 1; advance_reconciliation_shard(cursor, namespace); } Err(error) => { return Err((ReconciliationCursorState::OpenShard(namespace), error)); } } } ReconciliationCursorState::ValidateShard { namespace, shard } => { count_traversal_syscall(report); match validate_traversal_dir(&shard) { Ok(stat) => { cursor.state = read_shard_state( namespace, shard, ShardIdentity(stat.st_dev, stat.st_ino), vec![0; 8192], 0, 0, 0, ); } Err(ArtifactError::UnsafeRoot) => { report.unsafe_entries += 1; advance_reconciliation_shard(cursor, namespace); } Err(error) => { return Err(( ReconciliationCursorState::ValidateShard { namespace, shard }, error, )); } } } ReconciliationCursorState::ReadShard { namespace, shard, shard_dev, shard_ino, mut buffer, mut offset, mut filled, mut cookie, } => { if offset == filled { count_traversal_syscall(report); match checkpointed("reconciliation_getdents", || { getdents64(shard.as_raw_fd(), &mut buffer) }) { Ok(0) => advance_reconciliation_shard(cursor, namespace), Ok(count) => { offset = 0; filled = count; cursor.state = read_shard_state( namespace, shard, ShardIdentity(shard_dev, shard_ino), buffer, offset, filled, cookie, ); } Err(error) => { return Err(( ReconciliationCursorState::SeekShard { namespace, shard, shard_dev, shard_ino, buffer, cookie, }, error, )); } } return Ok(()); } let (name, after, after_cookie) = match parse_dirent(&buffer[..filled], offset) { Ok(entry) => entry, Err(error) => { return Err(( read_shard_state( namespace, shard, ShardIdentity(shard_dev, shard_ino), buffer, offset, filled, cookie, ), error, )); } }; if name == b"." || name == b".." { offset = after; cookie = after_cookie; cursor.state = read_shard_state( namespace, shard, ShardIdentity(shard_dev, shard_ino), buffer, offset, filled, cookie, ); return Ok(()); } let shard_name = format!("{:02x}", cursor.shard); let Ok(name) = String::from_utf8(name) else { report.scanned += 1; report.malformed += 1; offset = after; cookie = after_cookie; cursor.state = read_shard_state( namespace, shard, ShardIdentity(shard_dev, shard_ino), buffer, offset, filled, cookie, ); return Ok(()); }; if !valid_final_name(&name, &shard_name) { report.scanned += 1; if cursor.namespace != FINAL_NAMESPACE || !valid_temp_name(&name) { report.malformed += 1; } offset = after; cookie = after_cookie; cursor.state = read_shard_state( namespace, shard, ShardIdentity(shard_dev, shard_ino), buffer, offset, filled, cookie, ); return Ok(()); } #[cfg(debug_assertions)] let _ = crate::test_support::checkpoint("reconciliation_before_stat"); count_traversal_syscall(report); let stat = match checkpointed("reconciliation_stat", || { record_traversal_call(); nofollow_stat(shard.as_raw_fd(), &name) }) { Ok(stat) => Some(stat), Err(ArtifactError::NotFound) => None, Err(ArtifactError::UnsafeRoot) => { report.unsafe_entries += 1; None } Err(error) => { return Err(( read_shard_state( namespace, shard, ShardIdentity(shard_dev, shard_ino), buffer, offset, filled, cookie, ), error, )); } }; report.scanned += 1; offset = after; cookie = after_cookie; if let Some(stat) = stat { if check_file(&stat).is_err() { report.unsafe_entries += 1; } else { let candidate_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: candidate_namespace, shard: shard_name, shard_dev, shard_ino, name, dev: stat.st_dev, ino: stat.st_ino, modified_seconds: stat.st_mtime, modified_nanoseconds: stat.st_mtime_nsec, }); } } cursor.state = read_shard_state( namespace, shard, ShardIdentity(shard_dev, shard_ino), buffer, offset, filled, cookie, ); } ReconciliationCursorState::SeekShard { namespace, shard, shard_dev, shard_ino, buffer, cookie, } => { count_traversal_syscall(report); record_traversal_call(); if unsafe { libc::lseek(shard.as_raw_fd(), cookie, libc::SEEK_SET) } != cookie { return Err(( ReconciliationCursorState::SeekShard { namespace, shard, shard_dev, shard_ino, buffer, cookie, }, ArtifactError::Storage, )); } cursor.state = read_shard_state( namespace, shard, ShardIdentity(shard_dev, shard_ino), buffer, 0, 0, cookie, ); } ReconciliationCursorState::Transition => unreachable!("temporary scanner state"), } Ok(()) } fn count_traversal_syscall(report: &mut ReconciliationScan) { report.traversal_syscalls += 1; } fn read_shard_state( namespace: OwnedFd, shard: OwnedFd, shard_identity: ShardIdentity, buffer: Vec, offset: usize, filled: usize, cookie: i64, ) -> ReconciliationCursorState { let ShardIdentity(shard_dev, shard_ino) = shard_identity; ReconciliationCursorState::ReadShard { namespace, shard, shard_dev, shard_ino, buffer, offset, filled, cookie, } } fn open_traversal_dir(parent: i32, name: &[u8]) -> Result { let name = crate::store::c_name(name)?; record_traversal_call(); let raw = unsafe { libc::openat( parent, name.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, ) }; if raw < 0 { match unsafe { *libc::__errno_location() } { libc::ENOENT => Err(ArtifactError::NotFound), libc::EACCES | libc::ELOOP | libc::ENOTDIR => Err(ArtifactError::UnsafeRoot), _ => Err(ArtifactError::Storage), } } else { Ok(unsafe { OwnedFd::from_raw_fd(raw) }) } } fn validate_traversal_dir(directory: &OwnedFd) -> Result { record_traversal_call(); let stat = stat_fd(directory.as_raw_fd())?; if stat.st_uid != unsafe { libc::geteuid() } || (stat.st_mode & libc::S_IFMT) != libc::S_IFDIR || (stat.st_mode & 0o777) != 0o700 { Err(ArtifactError::UnsafeRoot) } else { Ok(stat) } } fn getdents64(directory: i32, buffer: &mut [u8]) -> Result { record_traversal_call(); let count = unsafe { libc::syscall( libc::SYS_getdents64, directory, buffer.as_mut_ptr(), buffer.len(), ) }; if count < 0 { Err(ArtifactError::Storage) } else { usize::try_from(count).map_err(|_| ArtifactError::Storage) } } fn parse_dirent(buffer: &[u8], offset: usize) -> Result<(Vec, usize, i64), ArtifactError> { const HEADER: usize = 19; if buffer.len().saturating_sub(offset) < HEADER { return Err(ArtifactError::Storage); } let record_length = usize::from(u16::from_ne_bytes([ buffer[offset + 16], buffer[offset + 17], ])); let after = offset .checked_add(record_length) .filter(|after| record_length >= HEADER && *after <= buffer.len()) .ok_or(ArtifactError::Storage)?; let raw_name = &buffer[offset + HEADER..after]; let end = raw_name .iter() .position(|byte| *byte == 0) .ok_or(ArtifactError::Storage)?; let cookie = i64::from_ne_bytes( buffer[offset + 8..offset + 16] .try_into() .map_err(|_| ArtifactError::Storage)?, ); Ok((raw_name[..end].to_vec(), after, cookie)) } fn advance_reconciliation_shard(cursor: &mut ReconciliationCursor, namespace: OwnedFd) { if cursor.shard == u8::MAX { advance_namespace(cursor); } else { cursor.shard += 1; cursor.state = ReconciliationCursorState::OpenShard(namespace); } } fn advance_namespace(cursor: &mut ReconciliationCursor) { cursor.namespace = if cursor.namespace >= cursor.namespace_end { cursor.namespace_end.saturating_add(1) } else { cursor.namespace + 1 }; cursor.shard = 0; cursor.state = ReconciliationCursorState::OpenNamespace; } pub(crate) 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(()) } pub(crate) 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) } /// After cross-directory rename, only canonical name and inode remain common; /// the quarantine shard cannot equal the capability's recorded final shard. 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), } } /// Attempts both directory fsyncs; either failure leaves a retryable ambiguity. 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) } 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()) } } fn record_traversal_call() { #[cfg(debug_assertions)] crate::test_support::record_traversal_call(); }