//! Cleanup of residual pkh build artifacts and caches. //! //! `pkh` leaves behind several kinds of artifacts while operating: //! //! - **Residual build/chroot directories** under the system temp directory, //! named `pkh-` (or `pkh--` on collision). //! They are created by the local and ephemeral build contexts and are //! *intentionally kept* when a build fails so they can be inspected. They //! may contain root-owned device nodes (created via `mknod`), a //! bind-mounted `/proc` and overlay filesystems, so they require careful //! unmounting before they can be removed. //! - **Build records** under `/var/tmp/pkh/sessions///` //! (see [`crate::deb::session`]): one record per build (metadata and //! log for every run, the staged tree of `--incremental` ones); the //! default policy removes the ones untouched for longer than the //! record retention window plus the corrupt leftovers, `--all` //! removes every record — recent builds and staged trees included, //! which is where the space is. A record a live build holds the lock //! of is never prunable, whatever the policy (at most five staged //! trees per target are kept by `pkh deb` itself). //! - **Shared apt download caches** under `/var/tmp/pkh/apt-cache/` //! (see [`crate::deb::ephemeral::apt_cache_dir`]): the default policy //! removes the ones untouched for longer than the retention window, //! `--all` removes them all; the `.debs` they hold are cheap to //! re-download. //! - **Cached chroot tarballs** (`~/.cache/pkh/*-buildd.tar.xz`) and their //! **stale download lockfiles** (`~/.cache/pkh/*.lock` untouched for longer //! than [`LOCK_STALE_AFTER`]; younger lockfiles may belong to a concurrent //! `pkh` run and are left alone). //! - **The apt keyring cache directories** (`pkh-keyrings` and the per-uid //! `pkh-keyrings-` under the system temp directory), used by //! mmdebstrap runs. //! - **Build logs** under `~/.cache/pkh/logs/` written by `pkh deb`: //! `deb---.log` (binary builds), //! `build---.log` (source builds) and the //! pre-identity placeholder `pkh-.log`. By default only logs //! beyond a small retention window (the newest [`KEEP_LOGS`] by embedded //! timestamp are kept) are removed; pass [`PruneOptions::all`] to remove //! them all. //! //! The [`prune()`] function discovers and removes all of the above. By //! default it removes everything that is cheap to regenerate (residual //! directories, keyrings, stale lockfiles, old build logs); pass //! [`PruneOptions::all`] to also discard the cached chroot tarballs, all //! build logs and the recent build records. [`PruneOptions::dry_run`] //! reports the same set grouped by kind with sizes, without removing //! anything. use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, SystemTime}; use chrono::NaiveDateTime; use directories::ProjectDirs; /// Options controlling the prune operation. #[derive(Clone, Copy, Default)] pub struct PruneOptions { /// When true, do not remove anything; only report what would be removed. pub dry_run: bool, /// When true, also remove cached chroot tarballs (which are expensive to /// re-download). Without this, tarballs are kept and only stale lockfiles /// are removed from the cache directory. pub all: bool, } /// Summary of a prune run. #[derive(Default)] pub struct PruneReport { /// Whether this was a dry run (nothing was actually removed). pub dry_run: bool, /// Paths that were removed, or that *would* be removed in dry-run mode. pub removed: Vec, /// The removed paths grouped by artifact kind, with sizes. Sizes are /// measured before removal, whatever the mode. pub groups: Vec, /// Total size across the groups. pub total_bytes: u64, /// Paths that could not be removed, with associated error messages. pub failed: Vec<(PathBuf, String)>, } impl PruneReport { /// Returns true if nothing was removed and nothing failed. pub fn is_empty(&self) -> bool { self.removed.is_empty() && self.failed.is_empty() } } /// One artifact category in a [`PruneReport`]: the artifact kind, how /// many entries of it were (or would be) removed, and their total size. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PruneGroup { /// The artifact kind (singular, as discovered). pub kind: &'static str, /// How many entries of this kind were removed. pub count: usize, /// Total size of the entries in bytes. pub bytes: u64, } impl PruneGroup { /// The category's display label, pluralized for the count /// ("1 stale lockfile", "2 stale lockfiles"). pub fn label(&self) -> String { if self.count == 1 { return self.kind.to_string(); } match self.kind.strip_suffix('y') { Some(stem) => format!("{stem}ies"), None => format!("{}s", self.kind), } } } /// Human-readable size for the prune report ("512 B", "3.5 MiB", /// "1.2 GiB", "1.3 TiB"). pub fn format_bytes(bytes: u64) -> String { const KIB: f64 = 1024.0; const MIB: f64 = 1024.0 * KIB; const GIB: f64 = 1024.0 * MIB; const TIB: f64 = 1024.0 * GIB; let size = bytes as f64; if size >= TIB { format!("{:.1} TiB", size / TIB) } else if size >= GIB { format!("{:.1} GiB", size / GIB) } else if size >= MIB { format!("{:.1} MiB", size / MIB) } else if size >= KIB { format!("{:.1} KiB", size / KIB) } else { format!("{bytes} B") } } /// Total size of every artifact in bytes, measured concurrently: the /// records hold hundreds of thousands of files (a single kernel build /// tree is ~100 GB of objects), and a serial walk costs tens of seconds. /// /// Directory walks never cross a mount point (`du -x`): residual chroots /// hold bind-mounted `/proc` and overlay filesystems whose virtual files /// (`/proc/kcore` alone reports 128 TiB) would poison the totals. /// Unreadable entries count as zero — prune removes them all the same. fn size_artifacts(artifacts: &[&Artifact]) -> Vec { use std::collections::VecDeque; use std::os::unix::fs::MetadataExt; use std::sync::Mutex; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; // The device each artifact lives on: the walk descends only into // directories on the same device. let root_devs: Vec = artifacts .iter() .map(|a| fs::symlink_metadata(a.path()).map_or(0, |meta| meta.dev())) .collect(); let totals: Vec = (0..artifacts.len()).map(|_| AtomicU64::new(0)).collect(); let queue: Mutex> = Mutex::new( artifacts .iter() .enumerate() .map(|(idx, artifact)| (idx, artifact.path().to_path_buf())) .collect(), ); // Every queued-but-unfinished item: the workers stop when the queue // is empty and nothing is in flight. let pending = AtomicUsize::new(artifacts.len()); let workers = std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(4) .min(16); std::thread::scope(|scope| { for _ in 0..workers { scope.spawn(|| { loop { let item = queue.lock().unwrap().pop_front(); let Some((idx, path)) = item else { if pending.load(Ordering::Acquire) == 0 { return; } std::thread::yield_now(); continue; }; let Ok(meta) = fs::symlink_metadata(&path) else { pending.fetch_sub(1, Ordering::AcqRel); continue; }; if !meta.is_dir() { totals[idx].fetch_add(meta.len(), Ordering::Relaxed); pending.fetch_sub(1, Ordering::AcqRel); continue; } let Ok(entries) = fs::read_dir(&path) else { pending.fetch_sub(1, Ordering::AcqRel); continue; }; for entry in entries.flatten() { let child = entry.path(); let Ok(meta) = fs::symlink_metadata(&child) else { continue; }; let same_device = meta.dev() == root_devs[idx]; if meta.is_dir() && same_device { pending.fetch_add(1, Ordering::AcqRel); queue.lock().unwrap().push_back((idx, child)); } else if !meta.is_dir() && same_device { totals[idx].fetch_add(meta.len(), Ordering::Relaxed); } } pending.fetch_sub(1, Ordering::AcqRel); } }); } }); totals.into_iter().map(|total| total.into_inner()).collect() } /// Number of newest build logs kept when `--all` is not passed. pub(crate) const KEEP_LOGS: usize = 10; /// Lockfiles younger than this are not pruned: a recently touched /// `.lock` may belong to a concurrent `pkh` run, whose chroot /// tarball download uses the lockfile's mere existence as a mutual-exclusion /// signal (see `deb::ephemeral`). Deleting such a lockfile mid-download would /// let two builds corrupt the shared tarball cache. Only lockfiles untouched /// for at least this long are considered stale. const LOCK_STALE_AFTER: Duration = Duration::from_secs(24 * 60 * 60); /// One discovered artifact that prune can act on. #[derive(Debug, Clone)] enum Artifact { /// A residual build/chroot directory under the system temp dir. TempDir(PathBuf), /// The apt keyring cache directory (`pkh-keyrings*` under the system /// temp dir). KeyringDir(PathBuf), /// A stale chroot tarball download lockfile. LockFile(PathBuf), /// A cached chroot tarball. Tarball(PathBuf), /// A build log file under `/logs`. LogFile(PathBuf), /// A build record root (manifest + log, and the staged tree of /// `--incremental` runs), one per build. Session(PathBuf), /// A `pkh test` run directory (report artifacts). TestRun(PathBuf), /// A shared apt download cache of one target (see /// [`crate::deb::ephemeral::apt_cache_dir`]). AptCache(PathBuf), /// An orphaned record lock file (`.lock` whose record /// root is gone). SessionLock(PathBuf), } impl Artifact { fn path(&self) -> &Path { match self { Artifact::TempDir(p) | Artifact::KeyringDir(p) | Artifact::LockFile(p) | Artifact::Tarball(p) | Artifact::LogFile(p) | Artifact::Session(p) | Artifact::TestRun(p) | Artifact::AptCache(p) | Artifact::SessionLock(p) => p, } } fn kind(&self) -> &'static str { match self { Artifact::TempDir(_) => "residual build directory", Artifact::KeyringDir(_) => "apt keyring cache", Artifact::LockFile(_) => "stale lockfile", Artifact::Tarball(_) => "cached chroot tarball", Artifact::LogFile(_) => "build log", Artifact::Session(_) => "build record", Artifact::TestRun(_) => "pkh test run", Artifact::AptCache(_) => "shared apt download cache", Artifact::SessionLock(_) => "stale record lockfile", } } } /// Discover the build records prune may remove under `sessions_dir` (the /// record layout of [`crate::deb::session`]: one `` directory /// — with its `.lock` beside it — per slug directory): with /// `--all`, every record (recent builds and staged trees included); /// otherwise only those untouched for longer than the retention window /// plus the corrupt leftovers. A record a live build holds the lock of /// is out of bounds either way. /// /// Orphaned lock files (`.lock` whose record root is gone — /// left behind by interrupted or discarded runs) are collected too, /// unless a live build holds them (probed with a non-blocking flock). /// The pre-record single-session layout kept its lock at the sessions /// root and its manifest directly inside the slug directory; both are /// collected as the leftovers they became. fn discover_sessions(sessions_dir: &Path, all: bool) -> Vec { let Ok(entries) = fs::read_dir(sessions_dir) else { return Vec::new(); }; let mut artifacts = Vec::new(); for entry in entries.flatten() { let path = entry.path(); let name = entry.file_name().to_string_lossy().into_owned(); // The old layout's `.lock` at the root: orphaned once no // slug directory of that name exists. if let Some(slug) = name.strip_suffix(".lock") { if path.is_file() && !sessions_dir.join(slug).is_dir() { collect_lock(&path, &mut artifacts); } continue; } let Some(slug_dir) = path.is_dir().then_some(path) else { continue; }; let Ok(records) = fs::read_dir(&slug_dir) else { continue; }; for record in records.flatten() { let path = record.path(); let name = record.file_name().to_string_lossy().into_owned(); if let Some(record_id) = name.strip_suffix(".lock") { if path.is_file() && !slug_dir.join(record_id).is_dir() { collect_lock(&path, &mut artifacts); } } else if path.is_dir() { if is_prunable_session(&path, all) { artifacts.push(Artifact::Session(path)); } } else if is_stale_path(&path, all) { // A record root only ever holds directories; a plain // file inside a slug directory is a leftover of the old // flat layout (its manifest / host-tree snapshot). artifacts.push(Artifact::Session(path)); } } } artifacts } /// Collect an orphaned lock file as prunable. A lock a live build holds /// is never collected, `--all` included: it is mutual exclusion for a /// concurrent run, not garbage. fn collect_lock(path: &Path, artifacts: &mut Vec) { if lock_held(path) { return; } artifacts.push(Artifact::SessionLock(path.to_path_buf())); } /// Whether a path is untouched for longer than the record retention /// window (mtime-based; the fallback for leftovers with no manifest). fn is_stale_path(path: &Path, all: bool) -> bool { all || fs::metadata(path) .and_then(|meta| meta.modified()) .is_ok_and(|mtime| { chrono::Utc::now() .signed_duration_since(chrono::DateTime::::from(mtime)) .to_std() .is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION) }) } /// Discover the `pkh test` run directories prune may remove under /// `tests_dir`: with `--all`, every run; otherwise only those untouched /// for longer than the sessions retention window. fn discover_test_runs(tests_dir: &Path, all: bool) -> Vec { let Ok(entries) = fs::read_dir(tests_dir) else { return Vec::new(); }; entries .flatten() .map(|entry| entry.path()) .filter(|path| path.is_dir()) .filter(|path| is_stale_path(path, all)) .map(Artifact::TestRun) .collect() } /// Discover the shared apt download caches prune may remove under /// `apt_cache_dir` (`/var/tmp/pkh/apt-cache`, one `-` /// subdirectory per target): with `--all`, every one; otherwise only /// those untouched for longer than the retention window. A cache in use /// by a live build is fresh by construction — its `.debs` are being /// read and written. fn discover_apt_cache(apt_cache_dir: &Path, all: bool) -> Vec { let Ok(entries) = fs::read_dir(apt_cache_dir) else { return Vec::new(); }; entries .flatten() .map(|entry| entry.path()) .filter(|path| path.is_dir()) .filter(|path| is_stale_path(path, all)) .map(Artifact::AptCache) .collect() } /// Whether a record root is prunable under the given policy: everything /// with `--all` (recent builds and staged trees included — that is where /// the space is), otherwise stale (untouched beyond the retention window) /// or corrupt (no readable manifest) records. A record a live build holds /// the lock of is never prunable, whatever the policy: pruning it would /// wreck the running build. Live records record a fresh `last_used` when /// they are created and are never stale; a record whose manifest is /// missing is mid-creation rather than corrupt while its lock is held, /// and is left alone. fn is_prunable_session(path: &Path, all: bool) -> bool { if lock_held(&record_lock_path(path)) { return false; } if all { return true; } let last_used = fs::read_to_string(path.join("manifest.json")) .ok() .and_then(|content| serde_json::from_str::(&content).ok()) .and_then(|value| { value .get("last_used") .and_then(|v| v.as_str()) .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) }); match last_used { Some(then) => chrono::Utc::now() .signed_duration_since(chrono::DateTime::::from(then)) .to_std() .is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION), // No readable manifest: a leftover (the lock is not held, per // the check above). None => true, } } /// The advisory lock file of the record root at `path` /// (`.lock` beside it). fn record_lock_path(path: &Path) -> PathBuf { path.with_file_name(format!( "{}.lock", path.file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default() )) } /// Whether the lock file at `path` is held by a live build; an unreadable /// probe errs on "held". fn lock_held(path: &Path) -> bool { crate::deb::session::lock_is_held(path).unwrap_or(true) } /// Determine the pkh cache directory (e.g. `~/.cache/pkh`), if project dirs /// can be resolved on this platform. pub fn cache_dir() -> Option { ProjectDirs::from("com", "pkh", "pkh").map(|d| d.cache_dir().to_path_buf()) } /// Check whether a directory entry name is the apt keyring cache directory, /// i.e. the legacy shared `pkh-keyrings` name or the per-uid /// `pkh-keyrings-` used by current pkh versions. fn is_keyring_dir_name(name: &str) -> bool { if name == "pkh-keyrings" { return true; } match name.strip_prefix("pkh-keyrings-") { Some(uid) => !uid.is_empty() && uid.bytes().all(|b| b.is_ascii_digit()), None => false, } } /// Check whether a directory entry name is a residual pkh build directory, /// i.e. it matches `pkh-` or `pkh--`. /// /// This deliberately rejects the `pkh-keyrings*` and `pkh-build-*` names so /// they are not mistaken for residual build chroots. fn is_residual_temp_dir(name: &str) -> bool { let Some(rest) = name.strip_prefix("pkh-") else { return false; }; let mut parts = rest.splitn(2, '-'); let first = parts.next().unwrap_or(""); if first.is_empty() || !first.bytes().all(|b| b.is_ascii_digit()) { return false; } match parts.next() { None => true, Some(second) => !second.is_empty() && second.bytes().all(|b| b.is_ascii_digit()), } } /// Check whether a file name is a pkh build log, i.e. it ends in `.log` and /// starts with one of the prefixes used by `pkh deb` logging: `deb-` (binary /// builds), `build-` (source builds) or `pkh-` (the pre-identity placeholder /// log that is renamed once the build identity is known). fn is_log_file_name(name: &str) -> bool { name.ends_with(".log") && (name.starts_with("deb-") || name.starts_with("build-") || name.starts_with("pkh-")) } /// Length of the `YYYYMMDDTHHMMSS` UTC timestamp embedded in log file names. const LOG_TIMESTAMP_LEN: usize = 15; /// Parse the UTC timestamp embedded in a build log file name. /// /// Log names embed the timestamp as their final dash-separated component /// (e.g. `deb-pkg-1.0-20260101T000000.log`, `build-src-1.0-20260101T000000.log`, /// `pkh-20260101T000000.log`). Returns `None` when the name carries no /// parseable timestamp. fn embedded_log_timestamp(path: &Path) -> Option { let name = path.file_name()?.to_str()?; let stem = name.strip_suffix(".log")?; let candidate = stem.rsplit_once('-')?.1; if candidate.len() != LOG_TIMESTAMP_LEN { return None; } let ts = NaiveDateTime::parse_from_str(candidate, "%Y%m%dT%H%M%S").ok()?; Some(ts.and_utc().into()) } /// Recency of a build log, used to order logs for retention: later values are /// more recent. /// /// The timestamp embedded in the file name is authoritative. When the name /// carries no parseable timestamp the file's modification time is used /// instead, and finally the Unix epoch, so that undateable logs sort as the /// oldest ones. fn log_recency(path: &Path) -> SystemTime { if let Some(ts) = embedded_log_timestamp(path) { return ts; } fs::metadata(path) .and_then(|m| m.modified()) .unwrap_or(SystemTime::UNIX_EPOCH) } /// Check whether a lockfile is safe to prune, i.e. it has not been modified /// for at least [`LOCK_STALE_AFTER`]. /// /// A freshly touched lockfile may belong to a live concurrent `pkh` run, so /// it must be left alone. If the modification time cannot be read the lock is /// treated as ancient and pruned (best-effort cleanup of an unreadable /// leftover); a modification time in the future (clock skew) counts as fresh. fn is_stale_lock(path: &Path) -> bool { let mtime = fs::metadata(path) .and_then(|m| m.modified()) .unwrap_or(SystemTime::UNIX_EPOCH); match mtime.elapsed() { Ok(age) => age >= LOCK_STALE_AFTER, Err(_) => false, } } /// Unescape the octal escape sequences used in `/proc/mounts` mount points /// (e.g. `\040` for space, `\011` for tab, `\012` for newline, `\134` for /// backslash). fn unescape_mountpath(s: &str) -> String { let bytes = s.as_bytes(); let mut out = String::with_capacity(s.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'\\' && i + 3 < bytes.len() { let o1 = (bytes[i + 1] as char).to_digit(8); let o2 = (bytes[i + 2] as char).to_digit(8); let o3 = (bytes[i + 3] as char).to_digit(8); if let (Some(a), Some(b), Some(c)) = (o1, o2, o3) { let val = (a << 6) | (b << 3) | c; out.push(val as u8 as char); i += 4; continue; } } out.push(bytes[i] as char); i += 1; } out } /// Parse `/proc/mounts`-style content and return mount points whose target is /// located strictly *under* `path` (i.e. nested inside it, not `path` itself). /// /// The list is returned ordered with the deepest paths first, so callers can /// unmount children before their parents. fn mounts_under_from_content(content: &str, path: &Path) -> Vec { let mut mounts: Vec = Vec::new(); for line in content.lines() { let mut fields = line.split_whitespace(); let _device = fields.next(); let Some(mountpoint) = fields.next() else { continue; }; let unescaped = unescape_mountpath(mountpoint); let mp = PathBuf::from(&unescaped); // starts_with is component-based, so /tmp/pkh-1234 is NOT considered // under /tmp/pkh-123. We additionally exclude the dir itself. if mp != path && mp.starts_with(path) { mounts.push(unescaped); } } // Deepest (longest) first so nested mounts are unmounted before parents. mounts.sort_by_key(|m| std::cmp::Reverse(m.len())); mounts } /// Discover all prune-able artifacts given the temp dir and cache dir roots. fn discover_artifacts(temp_dir: &Path, cache_dir: Option<&Path>) -> Vec { let mut artifacts = Vec::new(); if let Ok(entries) = fs::read_dir(temp_dir) { for entry in entries.flatten() { let path = entry.path(); let name = entry.file_name().to_string_lossy().into_owned(); if !path.is_dir() { continue; } if is_residual_temp_dir(&name) { artifacts.push(Artifact::TempDir(path)); } else if is_keyring_dir_name(&name) { artifacts.push(Artifact::KeyringDir(path)); } } } if let Some(cache) = cache_dir && let Ok(entries) = fs::read_dir(cache) { for entry in entries.flatten() { let path = entry.path(); let name = entry.file_name().to_string_lossy().into_owned(); if name.ends_with(".lock") { // Only stale lockfiles are prune-able; a fresh one may belong // to a live concurrent pkh run (see [`is_stale_lock`]). if is_stale_lock(&path) { artifacts.push(Artifact::LockFile(path)); } } else if name.ends_with("-buildd.tar.xz") { artifacts.push(Artifact::Tarball(path)); } } } // Build logs live in a dedicated subdirectory of the cache dir if let Some(cache) = cache_dir { let logs_dir = cache.join("logs"); if let Ok(entries) = fs::read_dir(&logs_dir) { for entry in entries.flatten() { let path = entry.path(); let name = entry.file_name().to_string_lossy().into_owned(); if path.is_file() && is_log_file_name(&name) { artifacts.push(Artifact::LogFile(path)); } } } } artifacts } /// Compute the set of build log paths that should be removed for the given /// options: all of them with `--all`, otherwise every log except the /// [`KEEP_LOGS`] newest ones. /// /// Recency is the timestamp embedded in each log's file name (falling back to /// the file modification time when the name cannot be parsed; see /// [`log_recency()`]). Log names sort by package and version first, so plain /// lexicographic order is *not* chronological and must not be used here. fn removable_logs(artifacts: &[Artifact], all: bool) -> std::collections::HashSet<&Path> { let mut logs: Vec<&Path> = artifacts .iter() .filter_map(|a| match a { Artifact::LogFile(p) => Some(p.as_path()), _ => None, }) .collect(); logs.sort_by_key(|p| log_recency(p)); if all { return logs.into_iter().collect(); } let keep = KEEP_LOGS.min(logs.len()); logs[..logs.len() - keep].iter().copied().collect() } /// Remove a path, first attempting a direct removal and only escalating to a /// privileged `rm -rf` when the direct attempt fails. /// /// User-writable artifacts (keyring dir, stale lockfiles) are removed directly /// without shelling out. Residual build directories may contain root-owned /// device nodes created via `mknod`, so the direct `remove_dir_all` will fail /// with `EACCES` and we transparently retry with `sudo rm -rf` (or `rm -rf` when /// already running as root). fn remove_path(path: &Path) -> std::io::Result<()> { let direct = if path.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }; if direct.is_ok() { return Ok(()); } remove_with_privilege(path) } /// Run an `rm -rf ` removal, transparently using `sudo` when the current /// process does not run as root. This is required because residual build /// directories may contain root-owned device nodes. fn remove_with_privilege(path: &Path) -> std::io::Result<()> { let is_root = crate::utils::root::is_root().unwrap_or(false); let mut cmd = if is_root { Command::new("rm") } else { let mut c = Command::new("sudo"); c.arg("rm"); c }; let status = cmd.arg("-rf").arg(path).status()?; if status.success() { Ok(()) } else { Err(std::io::Error::other(format!( "rm -rf {} failed with status {}", path.display(), status ))) } } /// Unmount a single mount point, transparently using `sudo` when not root. fn unmount(mountpoint: &str) -> std::io::Result<()> { let is_root = crate::utils::root::is_root().unwrap_or(false); let mut cmd = if is_root { Command::new("umount") } else { let mut c = Command::new("sudo"); c.arg("umount"); c }; let status = cmd.arg(mountpoint).status()?; if status.success() { Ok(()) } else { Err(std::io::Error::other(format!( "umount {} failed with status {}", mountpoint, status ))) } } /// Execute the prune operation against the given temp and cache roots, /// removing residual pkh artifacts. /// /// This is the testable core of [`prune()`]: callers pass in the temp /// directory and optional cache directory to scan. Build sessions are /// deliberately out of scope here — tests scan controlled roots, and a /// concurrent test suite must never touch the live sessions under the /// real sessions root. See the [module documentation](self) for what is /// removed. Returns a [`PruneReport`] describing what was removed (or, in /// dry-run mode, what would be removed). pub fn prune_in( temp_dir: &Path, cache_dir: Option<&Path>, options: PruneOptions, ) -> Result> { prune_in_roots(temp_dir, cache_dir, None, None, None, options) } /// Execute the prune operation against explicit roots. /// /// `sessions_dir` opts in to build-record pruning, `tests_dir` to /// test-run pruning and `apt_cache_dir` to shared apt-cache pruning (the /// production [`prune()`] passes the real roots; tests pass controlled /// directories or `None`). pub fn prune_in_roots( temp_dir: &Path, cache_dir: Option<&Path>, sessions_dir: Option<&Path>, tests_dir: Option<&Path>, apt_cache_dir: Option<&Path>, options: PruneOptions, ) -> Result> { let mut artifacts = discover_artifacts(temp_dir, cache_dir); if let Some(sessions_dir) = sessions_dir { artifacts.extend(discover_sessions(sessions_dir, options.all)); } if let Some(tests_dir) = tests_dir { artifacts.extend(discover_test_runs(tests_dir, options.all)); } if let Some(apt_cache_dir) = apt_cache_dir { artifacts.extend(discover_apt_cache(apt_cache_dir, options.all)); } let mut report = PruneReport { dry_run: options.dry_run, ..Default::default() }; let removable_logs = removable_logs(&artifacts, options.all); // The actionable subset with sizes measured BEFORE anything is // removed: a removed tree has no size left to report. let mut filtered: Vec<&Artifact> = Vec::new(); for artifact in &artifacts { // Cached tarballs are only removed when --all is requested: they are // expensive to re-download. if matches!(artifact, Artifact::Tarball(_)) && !options.all { continue; } // Build logs follow the retention policy computed above. if let Artifact::LogFile(p) = artifact && !removable_logs.contains(p.as_path()) { continue; } filtered.push(artifact); } let sizes = size_artifacts(&filtered); let actionable: Vec<(&Artifact, u64)> = filtered.iter().copied().zip(sizes).collect(); for (artifact, _) in &actionable { let path = artifact.path(); log::debug!("{}: {}", artifact.kind(), path.display()); if options.dry_run { report.removed.push(path.to_path_buf()); continue; } // Residual build directories may hold bind-mounted /proc and overlay // filesystems that must be unmounted before the directory can be // removed, otherwise `rm -rf` will fail or leave dangling mounts. // Session roots hold the same kind of mounts inside their chroot. if let Artifact::TempDir(_) | Artifact::Session(_) = artifact { let mounts_content = fs::read_to_string("/proc/mounts").unwrap_or_default(); for mp in mounts_under_from_content(&mounts_content, path) { log::debug!("Unmounting {} under {}", mp, path.display()); if let Err(e) = unmount(&mp) { log::warn!("Failed to unmount {}: {}", mp, e); } } } match remove_path(path) { Ok(()) => report.removed.push(path.to_path_buf()), Err(e) => report.failed.push((path.to_path_buf(), e.to_string())), } } // Group by kind in first-seen order. for (artifact, bytes) in &actionable { if let Some(group) = report .groups .iter_mut() .find(|group| group.kind == artifact.kind()) { group.count += 1; group.bytes += bytes; } else { report.groups.push(PruneGroup { kind: artifact.kind(), count: 1, bytes: *bytes, }); } } report.total_bytes = report.groups.iter().map(|group| group.bytes).sum(); Ok(report) } /// Execute the prune operation, removing residual pkh artifacts. /// /// Convenience wrapper around [`prune_in()`] that uses the system temp /// directory and the pkh cache directory. See the /// [module documentation](self) for what is removed. Returns a [`PruneReport`] /// describing what was removed (or, in dry-run mode, what would be removed). pub fn prune(options: PruneOptions) -> Result> { let temp_dir = std::env::temp_dir(); let cache = cache_dir(); prune_in_roots( &temp_dir, cache.as_deref(), Some(&crate::deb::session::sessions_root()), Some(&crate::test::runs_root()), Some(&PathBuf::from(crate::deb::ephemeral::APT_CACHE_ROOT)), options, ) } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::tempdir; /// Set a file's modification time (test helper). fn set_mtime(path: &Path, mtime: SystemTime) { let f = fs::OpenOptions::new().write(true).open(path).unwrap(); f.set_times(std::fs::FileTimes::new().set_modified(mtime)) .unwrap(); } /// A modification time safely older than [`LOCK_STALE_AFTER`]. fn old_mtime() -> SystemTime { SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(3600) } #[test] fn test_is_residual_temp_dir() { assert!(is_residual_temp_dir("pkh-1700000000")); assert!(is_residual_temp_dir("pkh-1700000000-1")); assert!(is_residual_temp_dir("pkh-1700000000-12")); // Keyrings and unshare work dirs must NOT match. assert!(!is_residual_temp_dir("pkh-keyrings")); assert!(!is_residual_temp_dir("pkh-keyrings-1000")); assert!(!is_residual_temp_dir("pkh-build-1700000000")); // Non-numeric / malformed names. assert!(!is_residual_temp_dir("pkh-")); assert!(!is_residual_temp_dir("pkh-abc")); assert!(!is_residual_temp_dir("pkh-123-abc")); assert!(!is_residual_temp_dir("pkh-123-")); assert!(!is_residual_temp_dir("pkh")); assert!(!is_residual_temp_dir("other-123")); } #[test] fn test_is_keyring_dir_name() { // Legacy shared name and current per-uid names are matched. assert!(is_keyring_dir_name("pkh-keyrings")); assert!(is_keyring_dir_name("pkh-keyrings-0")); assert!(is_keyring_dir_name("pkh-keyrings-1000")); // Everything else is not. assert!(!is_keyring_dir_name("pkh-keyrings-")); assert!(!is_keyring_dir_name("pkh-keyrings-abc")); assert!(!is_keyring_dir_name("pkh-keyrings-1000-2")); assert!(!is_keyring_dir_name("keyrings")); assert!(!is_keyring_dir_name("pkh-1000")); } #[test] fn test_is_log_file_name() { // The three naming shapes produced by ui/deb.rs. assert!(is_log_file_name("deb-hello-1.0-20260101T000000.log")); assert!(is_log_file_name("build-src-1.0-20260101T000000.log")); assert!(is_log_file_name("pkh-20260101T000000.log")); // Everything else is not. assert!(!is_log_file_name("deb-hello-1.0-20260101T000000.log.gz")); assert!(!is_log_file_name("not-a-build.txt")); assert!(!is_log_file_name("deb.txt")); assert!(!is_log_file_name("logs.txt")); assert!(!is_log_file_name("deb-")); assert!(!is_log_file_name("other-thing.log")); } #[test] fn test_log_recency_orders_by_embedded_timestamp() { // The embedded timestamp decides, even when it disagrees with // lexicographic order (zzz sorts after aaa). let old = Path::new("/logs/deb-zzz-1.0-20200101T000000.log"); let new = Path::new("/logs/deb-aaa-2.0-20260101T000000.log"); assert!(log_recency(old) < log_recency(new)); // All three prefixes participate in the same timeline. let build = Path::new("/logs/build-src-3.0-20230101T000000.log"); let placeholder = Path::new("/logs/pkh-20250101T000000.log"); assert!(log_recency(old) < log_recency(build)); assert!(log_recency(build) < log_recency(placeholder)); assert!(log_recency(placeholder) < log_recency(new)); } #[test] fn test_log_recency_falls_back_to_mtime() { let temp = tempdir().unwrap(); // No parseable timestamp in the name, so the mtime decides. let path = temp.path().join("deb-weird-name.log"); fs::write(&path, "log").unwrap(); let mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000); set_mtime(&path, mtime); assert_eq!(log_recency(&path), mtime); // A missing file with an unparseable name sorts as the oldest. assert_eq!( log_recency(&PathBuf::from("/logs/deb-nope-nope.log")), SystemTime::UNIX_EPOCH ); } #[test] fn test_is_stale_lock() { let temp = tempdir().unwrap(); let fresh = temp.path().join("fresh.tar.lock"); let stale = temp.path().join("stale.tar.lock"); fs::write(&fresh, "lock").unwrap(); fs::write(&stale, "lock").unwrap(); set_mtime(&stale, old_mtime()); assert!(!is_stale_lock(&fresh)); assert!(is_stale_lock(&stale)); } #[test] fn test_unescape_mountpath() { assert_eq!(unescape_mountpath("/tmp/simple"), "/tmp/simple"); assert_eq!(unescape_mountpath("/tmp/with\\040space"), "/tmp/with space"); assert_eq!(unescape_mountpath("/tmp/with\\011tab"), "/tmp/with\ttab"); assert_eq!( unescape_mountpath("/tmp/with\\134backslash"), "/tmp/with\\backslash" ); // Incomplete escape sequence is left untouched. assert_eq!(unescape_mountpath("/tmp/with\\04"), "/tmp/with\\04"); } #[test] fn test_mounts_under_from_content() { let path = PathBuf::from("/tmp/pkh-100"); let content = "\ proc /proc proc rw 0 0 proc /tmp/pkh-100/proc proc rw,bind 0 0 overlay /tmp/pkh-100/pkh-overlay/mnt overlay rw 0 0 overlay /tmp/pkh-1000/pkh-overlay/mnt overlay rw 0 0 none /tmp/other proc rw 0 0 "; let mounts = mounts_under_from_content(content, &path); // Only mounts strictly under /tmp/pkh-100 should match, deepest first. assert_eq!(mounts.len(), 2); assert_eq!(mounts[0], "/tmp/pkh-100/pkh-overlay/mnt"); assert_eq!(mounts[1], "/tmp/pkh-100/proc"); } #[test] fn test_discover_artifacts() { let temp = tempdir().unwrap(); let cache = tempdir().unwrap(); let temp_path = temp.path(); let cache_path = cache.path(); // Residual build dirs. fs::create_dir_all(temp_path.join("pkh-1700000000")).unwrap(); fs::create_dir_all(temp_path.join("pkh-1700000001-2")).unwrap(); // Keyring dirs (legacy shared name and per-uid name). fs::create_dir_all(temp_path.join("pkh-keyrings")).unwrap(); fs::create_dir_all(temp_path.join("pkh-keyrings-1000")).unwrap(); // Non-matching entries that should be ignored. fs::create_dir_all(temp_path.join("pkh-build-1700000000")).unwrap(); fs::create_dir_all(temp_path.join("other-dir")).unwrap(); fs::write(temp_path.join("pkh-123-file"), "not a dir").unwrap(); // Cache entries. fs::write(cache_path.join("noble-buildd.tar.xz"), "tarball").unwrap(); fs::write(cache_path.join("noble-amd64-buildd.tar.xz"), "tarball").unwrap(); // Only a stale lockfile is discovered; a fresh one could belong to a // live concurrent run and must be ignored. fs::write(cache_path.join("noble-buildd.tar.lock"), "lock").unwrap(); set_mtime(&cache_path.join("noble-buildd.tar.lock"), old_mtime()); fs::write(cache_path.join("noble-fresh.tar.lock"), "lock").unwrap(); fs::write(cache_path.join("stray.txt"), "ignore me").unwrap(); // Build logs: binary, source, and the pre-identity placeholder. let logs_dir = cache_path.join("logs"); fs::create_dir_all(&logs_dir).unwrap(); fs::write(logs_dir.join("deb-hello-1.0-20260101T000000.log"), "log").unwrap(); fs::write(logs_dir.join("build-src-1.0-20260101T000000.log"), "log").unwrap(); fs::write(logs_dir.join("pkh-20260101T000000.log"), "log").unwrap(); fs::write(logs_dir.join("not-a-build.txt"), "ignore me").unwrap(); let artifacts = discover_artifacts(temp_path, Some(cache_path)); let mut temp_dirs = 0; let mut keyring_dirs = 0; let mut locks = 0; let mut tarballs = 0; let mut logs = 0; for a in &artifacts { match a { Artifact::TempDir(_) => temp_dirs += 1, Artifact::KeyringDir(_) => keyring_dirs += 1, Artifact::LockFile(_) => locks += 1, Artifact::Tarball(_) => tarballs += 1, Artifact::LogFile(_) => logs += 1, Artifact::Session(_) => {} Artifact::TestRun(_) => {} Artifact::AptCache(_) => {} Artifact::SessionLock(_) => {} } } assert_eq!(temp_dirs, 2); assert_eq!(keyring_dirs, 2); assert_eq!(locks, 1); assert_eq!(tarballs, 2); assert_eq!(logs, 3); } #[test] fn test_prune_log_retention() { let temp = tempdir().unwrap(); let cache = tempdir().unwrap(); let cache_path = cache.path(); let logs_dir = cache_path.join("logs"); fs::create_dir_all(&logs_dir).unwrap(); // Create KEEP_LOGS + 3 logs across all naming shapes; the 3 oldest // (by embedded timestamp) should be pruned by default. let total = KEEP_LOGS + 3; let mut names = Vec::new(); for i in 0..total { let ts = format!("20260101T{:06}", i); let name = match i % 3 { 0 => format!("deb-pkg-1.0-{ts}.log"), 1 => format!("build-src-2.0-{ts}.log"), _ => format!("pkh-{ts}.log"), }; fs::write(logs_dir.join(&name), "log").unwrap(); names.push(name); } let report = prune_in( temp.path(), Some(cache_path), PruneOptions { dry_run: false, all: false, }, ) .unwrap(); let mut removed_logs: Vec = report .removed .iter() .filter(|p| p.starts_with(&logs_dir)) .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) .collect(); removed_logs.sort(); // Exactly the three oldest logs (i = 0, 1, 2) were removed. let mut expected: Vec = names[..3].to_vec(); expected.sort(); assert_eq!(removed_logs, expected); let remaining = fs::read_dir(&logs_dir).unwrap().count(); assert_eq!(remaining, KEEP_LOGS); // --all removes every remaining log let report = prune_in( temp.path(), Some(cache_path), PruneOptions { dry_run: false, all: true, }, ) .unwrap(); let removed_logs = report .removed .iter() .filter(|p| p.starts_with(&logs_dir)) .count(); assert_eq!(removed_logs, KEEP_LOGS); assert_eq!(fs::read_dir(&logs_dir).unwrap().count(), 0); } #[test] fn test_prune_log_retention_orders_by_timestamp_not_name() { let temp = tempdir().unwrap(); let cache = tempdir().unwrap(); let cache_path = cache.path(); let logs_dir = cache_path.join("logs"); fs::create_dir_all(&logs_dir).unwrap(); // KEEP_LOGS new logs whose names sort lexicographically FIRST // (package "aaa"), and older logs whose names sort LAST ("zzz" and a // pkh- placeholder). Retention must follow the embedded timestamps: // the aaa logs are kept and the lexicographically-later old logs are // pruned, the opposite of what name ordering would do. for i in 0..KEEP_LOGS { fs::write( logs_dir.join(format!("deb-aaa-1.0-20210101T{:06}.log", i)), "log", ) .unwrap(); } let old_names = [ "deb-zzz-2.0-20200101T000000.log".to_string(), "deb-zzz-2.0-20200101T000001.log".to_string(), "build-zzz-3.0-20200101T000000.log".to_string(), "pkh-20200101T000000.log".to_string(), ]; for name in &old_names { fs::write(logs_dir.join(name), "log").unwrap(); } let report = prune_in( temp.path(), Some(cache_path), PruneOptions { dry_run: false, all: false, }, ) .unwrap(); let removed_logs: Vec = report .removed .iter() .filter(|p| p.starts_with(&logs_dir)) .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) .collect(); let mut removed_sorted = removed_logs.clone(); removed_sorted.sort(); let mut expected = old_names.clone(); expected.sort(); assert_eq!(removed_sorted, expected); for i in 0..KEEP_LOGS { assert!( logs_dir .join(format!("deb-aaa-1.0-20210101T{:06}.log", i)) .exists() ); } assert_eq!(fs::read_dir(&logs_dir).unwrap().count(), KEEP_LOGS); } #[test] fn test_prune_dry_run_keeps_artifacts() { let temp = tempdir().unwrap(); let cache = tempdir().unwrap(); let temp_path = temp.path(); let cache_path = cache.path(); let dir = temp_path.join("pkh-1700000000"); fs::create_dir_all(&dir).unwrap(); fs::create_dir_all(temp_path.join("pkh-keyrings")).unwrap(); fs::create_dir_all(temp_path.join("pkh-keyrings-1000")).unwrap(); fs::write(cache_path.join("noble-buildd.tar.xz"), "tarball").unwrap(); // A stale lockfile is reported; a fresh one may belong to a live // concurrent run and is left out entirely. fs::write(cache_path.join("noble-buildd.tar.lock"), "lock").unwrap(); set_mtime(&cache_path.join("noble-buildd.tar.lock"), old_mtime()); fs::write(cache_path.join("noble-live.tar.lock"), "lock").unwrap(); let report = prune_in( temp_path, Some(cache_path), PruneOptions { dry_run: true, all: false, }, ) .unwrap(); // Dry run reports all discovered artifacts as "removed" without // touching the filesystem. assert!(report.dry_run); assert!(report.removed.iter().any(|p| p == &dir)); assert!( report .removed .iter() .any(|p| p == &temp_path.join("pkh-keyrings")) ); assert!( report .removed .iter() .any(|p| p == &temp_path.join("pkh-keyrings-1000")) ); assert!( report .removed .iter() .any(|p| p == &cache_path.join("noble-buildd.tar.lock")) ); // The fresh lockfile is not reported and survives the dry run. assert!( !report .removed .iter() .any(|p| p == &cache_path.join("noble-live.tar.lock")) ); // Tarballs require --all, so not reported here. assert!( !report .removed .iter() .any(|p| p == &cache_path.join("noble-buildd.tar.xz")) ); // Nothing was actually removed. assert!(dir.exists()); assert!(temp_path.join("pkh-keyrings").exists()); assert!(temp_path.join("pkh-keyrings-1000").exists()); assert!(cache_path.join("noble-buildd.tar.xz").exists()); assert!(cache_path.join("noble-buildd.tar.lock").exists()); assert!(cache_path.join("noble-live.tar.lock").exists()); } #[test] fn test_prune_removes_user_writable_cache() { // Removing the keyring dir and stale lockfiles does not require // privilege escalation since they are user-writable; exercise the real // prune_in() against controlled temp roots without needing sudo. let temp = tempdir().unwrap(); let cache = tempdir().unwrap(); let temp_path = temp.path(); let cache_path = cache.path(); fs::create_dir_all(temp_path.join("pkh-keyrings")).unwrap(); fs::create_dir_all(temp_path.join("pkh-keyrings-1000")).unwrap(); fs::write(cache_path.join("noble-buildd.tar.lock"), "lock").unwrap(); set_mtime(&cache_path.join("noble-buildd.tar.lock"), old_mtime()); fs::write(cache_path.join("noble-fresh.tar.lock"), "lock").unwrap(); fs::write(cache_path.join("noble-buildd.tar.xz"), "tarball").unwrap(); let report = prune_in( temp_path, Some(cache_path), PruneOptions { dry_run: false, all: false, }, ) .unwrap(); assert!(!report.dry_run); assert!( report .removed .iter() .any(|p| p == &temp_path.join("pkh-keyrings")) ); assert!( report .removed .iter() .any(|p| p == &temp_path.join("pkh-keyrings-1000")) ); assert!( report .removed .iter() .any(|p| p == &cache_path.join("noble-buildd.tar.lock")) ); assert!(!temp_path.join("pkh-keyrings").exists()); assert!(!temp_path.join("pkh-keyrings-1000").exists()); assert!(!cache_path.join("noble-buildd.tar.lock").exists()); // The fresh lockfile may belong to a live run and is preserved. assert!(cache_path.join("noble-fresh.tar.lock").exists()); // Tarball is preserved when --all is not set. assert!(cache_path.join("noble-buildd.tar.xz").exists()); } #[test] fn test_prune_all_removes_tarballs() { let temp = tempdir().unwrap(); let cache = tempdir().unwrap(); let cache_path = cache.path(); fs::write(cache_path.join("noble-buildd.tar.xz"), "tarball").unwrap(); fs::write(cache_path.join("noble-amd64-buildd.tar.xz"), "tarball").unwrap(); fs::write(cache_path.join("noble-buildd.tar.lock"), "lock").unwrap(); set_mtime(&cache_path.join("noble-buildd.tar.lock"), old_mtime()); let report = prune_in( temp.path(), Some(cache_path), PruneOptions { dry_run: false, all: true, }, ) .unwrap(); assert!( report .removed .iter() .any(|p| p == &cache_path.join("noble-buildd.tar.xz")) ); assert!( report .removed .iter() .any(|p| p == &cache_path.join("noble-amd64-buildd.tar.xz")) ); assert!( report .removed .iter() .any(|p| p == &cache_path.join("noble-buildd.tar.lock")) ); assert!(!cache_path.join("noble-buildd.tar.xz").exists()); assert!(!cache_path.join("noble-amd64-buildd.tar.xz").exists()); assert!(!cache_path.join("noble-buildd.tar.lock").exists()); } #[test] fn test_prune_keeps_fresh_lockfile_removes_stale_one() { // The chroot tarball download uses the lockfile's existence as a // mutual-exclusion signal, so a lockfile fresh enough to belong to a // live concurrent pkh run must survive pruning. let temp = tempdir().unwrap(); let cache = tempdir().unwrap(); let cache_path = cache.path(); let fresh = cache_path.join("noble-live.tar.lock"); let stale = cache_path.join("noble-abandoned.tar.lock"); fs::write(&fresh, "lock").unwrap(); fs::write(&stale, "lock").unwrap(); set_mtime(&stale, old_mtime()); let report = prune_in( temp.path(), Some(cache_path), PruneOptions { dry_run: false, all: false, }, ) .unwrap(); assert!(report.removed.iter().any(|p| p == &stale)); assert!(!report.removed.iter().any(|p| p == &fresh)); assert!(fresh.exists()); assert!(!stale.exists()); // Dry-run reports the same: only the stale lockfile. fs::write(&stale, "lock").unwrap(); set_mtime(&stale, old_mtime()); let report = prune_in( temp.path(), Some(cache_path), PruneOptions { dry_run: true, all: false, }, ) .unwrap(); assert!(report.removed.iter().any(|p| p == &stale)); assert!(!report.removed.iter().any(|p| p == &fresh)); assert!(fresh.exists()); assert!(stale.exists()); } #[test] fn test_prune_removes_user_writable_temp_dirs() { // Residual build dirs created by the tests are user-writable, so the // direct fs::remove_dir_all path should succeed without sudo. let temp = tempdir().unwrap(); let temp_path = temp.path(); fs::create_dir_all(temp_path.join("pkh-1700000000")).unwrap(); fs::create_dir_all(temp_path.join("pkh-1700000001-2")).unwrap(); let report = prune_in(temp_path, None, PruneOptions::default()).unwrap(); assert!( report .removed .iter() .any(|p| p == &temp_path.join("pkh-1700000000")) ); assert!( report .removed .iter() .any(|p| p == &temp_path.join("pkh-1700000001-2")) ); assert!(!temp_path.join("pkh-1700000000").exists()); assert!(!temp_path.join("pkh-1700000001-2").exists()); } #[test] fn test_prune_empty_report_when_nothing_found() { let temp = tempdir().unwrap(); let cache = tempdir().unwrap(); // No pkh artifacts at all. let report = prune_in(temp.path(), Some(cache.path()), PruneOptions::default()).unwrap(); assert!(report.is_empty()); } /// Write a minimal record manifest with the given `last_used` under /// `sessions///`. fn write_session_manifest(session_dir: &Path, last_used: &str) { fs::create_dir_all(session_dir).unwrap(); fs::write( session_dir.join("manifest.json"), format!(r#"{{ "last_used": "{last_used}" }}"#), ) .unwrap(); } /// Record pruning: stale and corrupt records go by default, live ones /// stay; `--all` takes everything. #[test] fn test_session_pruning_retention() { let temp = tempdir().unwrap(); let sessions = temp.path().join("sessions"); let slug = sessions.join("pkh-noble-amd64-1c0ffee0"); let stale = slug.join("20200101T000000"); let fresh = slug.join("20260929T000000"); let corrupt = slug.join("20260929T000001"); write_session_manifest(&stale, "2020-01-01T00:00:00+00:00"); write_session_manifest( &fresh, &(chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339(), ); fs::create_dir_all(&corrupt).unwrap(); fs::write(corrupt.join("manifest.json"), "not json").unwrap(); let report = prune_in_roots( temp.path(), None, Some(&sessions), None, None, PruneOptions::default(), ) .unwrap(); assert!(report.removed.contains(&stale)); assert!(report.removed.contains(&corrupt)); assert!(fresh.exists(), "a live record is not pruned by default"); assert!(!stale.exists()); assert!(!corrupt.exists()); // --all removes everything, including the live record. let report = prune_in_roots( temp.path(), None, Some(&sessions), None, None, PruneOptions { all: true, ..Default::default() }, ) .unwrap(); assert!(report.removed.contains(&fresh)); assert!(!fresh.exists()); } /// `--all` removes fresh records and staged trees (that is where the /// space is), but a record whose lock a live build holds is never /// pruned, and neither is its lock file. #[test] fn test_session_pruning_all_spares_records_a_live_build_holds() { use std::os::unix::io::AsRawFd; let temp = tempdir().unwrap(); let sessions = temp.path().join("sessions"); let slug = sessions.join("pkh-noble-amd64-1c0ffee0"); let free = slug.join("20260929T000000"); let busy = slug.join("20260929T000001"); write_session_manifest(&free, &chrono::Utc::now().to_rfc3339()); write_session_manifest(&busy, &chrono::Utc::now().to_rfc3339()); // Hold the busy record's lock for the whole test: a live build // owns it. let lock_path = slug.join("20260929T000001.lock"); let held = fs::OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&lock_path) .unwrap(); unsafe { libc::flock(held.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB); } let report = prune_in_roots( temp.path(), None, Some(&sessions), None, None, PruneOptions::default(), ) .unwrap(); assert!(report.removed.is_empty(), "fresh records stay by default"); // --all takes the unheld record and leaves the held one (and its // lock) alone. let report = prune_in_roots( temp.path(), None, Some(&sessions), None, None, PruneOptions { all: true, ..Default::default() }, ) .unwrap(); assert!(report.removed.contains(&free)); assert!(!free.exists()); assert!(busy.exists(), "a held record survives --all"); assert!(lock_path.exists(), "a held lock survives --all"); } /// The report groups the removed paths by kind with their sizes, so /// the output is a digest instead of a wall of paths. #[test] fn test_prune_report_groups_by_kind_with_sizes() { let temp = tempdir().unwrap(); let cache = tempdir().unwrap(); let dir = temp.path().join("pkh-1700000000"); fs::create_dir_all(&dir).unwrap(); fs::write(dir.join("obj.o"), vec![0u8; 3000]).unwrap(); let lockfile = cache.path().join("noble-buildd.tar.lock"); fs::write(&lockfile, "lock").unwrap(); set_mtime(&lockfile, old_mtime()); let report = prune_in( temp.path(), Some(cache.path()), PruneOptions { dry_run: true, ..Default::default() }, ) .unwrap(); let dirs = report .groups .iter() .find(|g| g.kind == "residual build directory") .expect("residual directories are grouped"); assert_eq!(dirs.count, 1); assert_eq!(dirs.bytes, 3000); // Count 1: the label stays singular; pluralization is covered by // the labels test below. assert_eq!(dirs.label(), "residual build directory"); let locks = report .groups .iter() .find(|g| g.kind == "stale lockfile") .expect("stale lockfiles are grouped"); assert_eq!(locks.count, 1); assert_eq!(locks.bytes, 4); assert_eq!(locks.label(), "stale lockfile"); assert_eq!(report.total_bytes, 3004); assert_eq!(format_bytes(3004), "2.9 KiB"); } #[test] fn test_format_bytes_and_group_labels() { assert_eq!(format_bytes(0), "0 B"); assert_eq!(format_bytes(512), "512 B"); assert_eq!(format_bytes(2048), "2.0 KiB"); assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MiB"); assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.0 GiB"); // /proc/kcore inside a bind-mounted chroot /proc reports exactly // this size; the mount-crossing walk keeps it out of the totals. assert_eq!(format_bytes(140_737_477_959_680), "128.0 TiB"); let group = PruneGroup { kind: "shared apt download cache", count: 3, bytes: 10, }; assert_eq!(group.label(), "shared apt download caches"); let group = PruneGroup { kind: "residual build directory", count: 18, bytes: 10, }; assert_eq!(group.label(), "residual build directories"); } /// `pkh test` run directories follow the retention window: stale /// runs go, fresh runs stay, `--all` takes everything. #[test] fn prunes_test_runs_by_retention() { let temp = tempdir().unwrap(); let tests = temp.path().join("tests"); let stale = tests.join("pkg-noble-amd64-20260101T000000"); let fresh = tests.join("pkg-noble-amd64-20991231T235959"); fs::create_dir_all(&stale).unwrap(); fs::create_dir_all(&fresh).unwrap(); // Directories open read-only (write-open is a file-only mode): // futimens still applies through the read handle. let dir = fs::File::open(&stale).unwrap(); dir.set_times( std::fs::FileTimes::new() .set_modified(SystemTime::now() - std::time::Duration::from_secs(8 * 24 * 60 * 60)), ) .unwrap(); let report = prune_in_roots( temp.path(), None, None, Some(&tests), None, PruneOptions::default(), ) .unwrap(); assert!(report.removed.contains(&stale)); assert!(!stale.exists()); assert!(fresh.exists(), "a fresh test run is not pruned"); // --all removes every run. let report = prune_in_roots( temp.path(), None, None, Some(&tests), None, PruneOptions { all: true, ..Default::default() }, ) .unwrap(); assert!(report.removed.contains(&fresh)); assert!(!fresh.exists()); } /// The shared apt download caches follow the retention window: a /// cache untouched for over a week goes, one in recent use stays, /// `--all` takes everything. #[test] fn prunes_stale_apt_caches_by_retention() { let temp = tempdir().unwrap(); let apt_cache = temp.path().join("apt-cache"); let stale = apt_cache.join("noble-amd64"); let fresh = apt_cache.join("sid-riscv64"); for dir in [&stale, &fresh] { fs::create_dir_all(dir.join("partial")).unwrap(); fs::write(dir.join("some-deb_1.0_amd64.deb"), "deb").unwrap(); } // Directories open read-only (write-open is a file-only mode): // futimens still applies through the read handle. let dir = fs::File::open(&stale).unwrap(); dir.set_times( std::fs::FileTimes::new() .set_modified(SystemTime::now() - std::time::Duration::from_secs(8 * 24 * 60 * 60)), ) .unwrap(); let report = prune_in_roots( temp.path(), None, None, None, Some(&apt_cache), PruneOptions::default(), ) .unwrap(); assert!(report.removed.contains(&stale)); assert!(!stale.exists()); assert!(fresh.exists(), "a recently used cache is not pruned"); // --all removes every cache. let report = prune_in_roots( temp.path(), None, None, None, Some(&apt_cache), PruneOptions { all: true, ..Default::default() }, ) .unwrap(); assert!(report.removed.contains(&fresh)); assert!(!fresh.exists()); } /// Orphaned record lock files are pruned (unless held by a live /// build); locks whose record root still exists are never touched. /// The pre-record layout's locks at the sessions root follow the same /// rules. #[test] fn test_session_lock_pruning() { use std::os::unix::io::AsRawFd; let temp = tempdir().unwrap(); let sessions = temp.path().join("sessions"); let slug = sessions.join("pkh-noble-amd64-1c0ffee0"); let orphan_free = slug.join("20260929T000000.lock"); let orphan_held = slug.join("20260929T000001.lock"); let rooted = slug.join("20260929T000002.lock"); let legacy = sessions.join("legacy-slug.lock"); fs::create_dir_all(&slug).unwrap(); fs::write(&orphan_free, b"").unwrap(); fs::write(&orphan_held, b"").unwrap(); fs::write(&rooted, b"").unwrap(); fs::create_dir_all(slug.join("20260929T000002")).unwrap(); fs::write(&legacy, b"").unwrap(); // Hold one orphaned lock, as a live build would. let holder = fs::OpenOptions::new() .read(true) .open(&orphan_held) .unwrap(); unsafe { libc::flock(holder.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB); } let report = prune_in_roots( temp.path(), None, Some(&sessions), None, None, PruneOptions::default(), ) .unwrap(); assert!(report.removed.contains(&orphan_free)); assert!(!orphan_free.exists()); assert!(report.removed.contains(&legacy)); assert!(!legacy.exists()); assert!(orphan_held.exists(), "a held lock belongs to a live build"); assert!(rooted.exists(), "a lock guarding an existing record stays"); // --all takes the held orphan too (the record it guarded is gone). drop(holder); let report = prune_in_roots( temp.path(), None, Some(&sessions), None, None, PruneOptions { all: true, ..Default::default() }, ) .unwrap(); assert!(report.removed.contains(&orphan_held)); assert!(!orphan_held.exists()); } }