use std::{ os::fd::AsRawFd, time::{Duration, SystemTime, UNIX_EPOCH}, }; use super::{is_lower_hex, nofollow_stat}; use crate::{ ArtifactError, ArtifactStore, store::{RootLock, ensure_root_unchanged, fsync_fd, open_existing_dir, stat_fd, unlinkat}, temp_scan::{list_names_after, valid_temp_name}, }; /// Opaque single-use evidence for a stale temporary inode under the pinned root. #[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, } /// Opaque, inode-bound progress marker for the bounded stale-temp sweeper. /// /// It deliberately advances by shard instead of retaining a directory offset: /// directory offsets are invalidated by a concurrent writer, while round-robin /// shard progress prevents a busy low-numbered shard from starving all others. #[derive(Clone, Debug)] pub struct TempScanCursor { root_dev: u64, root_ino: u64, next_shard: u8, shard_continuation: Option, } #[derive(Clone, Debug)] struct TempShardContinuation { shard: u8, dev: u64, ino: u64, cookie: i64, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct TempScan { pub scanned: usize, pub omitted: usize, /// True when this page completed a full round-robin traversal. pub complete: bool, } impl ArtifactStore { /// Scans at most `scan_budget` entries and returns `result_limit` stale capabilities. pub fn scan_stale_temps( &self, grace: Duration, scan_budget: usize, result_limit: usize, ) -> Result<(TempScan, Vec), ArtifactError> { self.scan_stale_temps_after(grace, None, scan_budget, result_limit) .map(|(report, candidates, _)| (report, candidates)) } /// Resumes stale-temp cleanup from an opaque round-robin marker. A /// continuation is valid only for this exact pinned root. pub fn scan_stale_temps_after( &self, grace: Duration, continuation: Option, scan_budget: usize, result_limit: usize, ) -> Result<(TempScan, Vec, TempScanCursor), 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 { complete: true, ..TempScan::default() }, Vec::new(), TempScanCursor { root_dev: root.dev, root_ino: root.ino, next_shard: 0, shard_continuation: None, }, )); } Err(error) => return Err(error), }; let (start_shard, mut shard_continuation) = match continuation { Some(cursor) if cursor.root_dev == root.dev && cursor.root_ino == root.ino => { (cursor.next_shard, cursor.shard_continuation) } Some(_) => return Err(ArtifactError::UnsafeRoot), None => (0, None), }; let mut report = TempScan::default(); let mut remaining = scan_budget; let mut result = Vec::new(); if remaining == 0 { return Ok(( report, result, TempScanCursor { root_dev: root.dev, root_ino: root.ino, next_shard: start_shard, shard_continuation, }, )); } let mut next_shard = start_shard; for offset in 0_u16..=255 { if remaining == 0 { break; } let shard_number = start_shard.wrapping_add(offset as u8); next_shard = shard_number.wrapping_add(1); let shard = format!("{shard_number:02x}"); let shard_fd = match open_existing_dir(sha.as_raw_fd(), shard.as_bytes()) { Ok(fd) => fd, Err(ArtifactError::NotFound) => { shard_continuation = None; continue; } Err(error) => return Err(error), }; let stat = stat_fd(shard_fd.as_raw_fd())?; let resume = shard_continuation.take().and_then(|continuation| { if continuation.shard == shard_number && continuation.dev == stat.st_dev && continuation.ino == stat.st_ino { Some(continuation.cookie) } else { None } }); let page = list_names_after(shard_fd.as_raw_fd(), &mut remaining, &mut report, resume)?; for entry in page.names { let name = entry.name; 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; if result_limit > 0 { return Ok(( report, result, TempScanCursor { root_dev: root.dev, root_ino: root.ino, next_shard: shard_number, shard_continuation: Some(TempShardContinuation { shard: shard_number, dev: stat.st_dev, ino: stat.st_ino, cookie: entry.cookie_before, }), }, )); } 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, }); } } if let Some(cookie) = page.continuation { return Ok(( report, result, TempScanCursor { root_dev: root.dev, root_ino: root.ino, next_shard: shard_number, shard_continuation: Some(TempShardContinuation { shard: shard_number, dev: stat.st_dev, ino: stat.st_ino, cookie, }), }, )); } } report.complete = true; Ok(( report, result, TempScanCursor { root_dev: root.dev, root_ino: root.ino, next_shard, shard_continuation: None, }, )) } /// Deletes a revalidated stale inode under an exclusive lock, then fsyncs. 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()) } }