//! 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. //! - **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 and all //! build logs, which are expensive to re-create. 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, /// 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() } } /// 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), } impl Artifact { fn path(&self) -> &Path { match self { Artifact::TempDir(p) | Artifact::KeyringDir(p) | Artifact::LockFile(p) | Artifact::Tarball(p) | Artifact::LogFile(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", } } } /// 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 roots, removing residual pkh /// artifacts. /// /// This is the testable core of [`prune()`]: callers pass in the temp /// directory and optional cache directory to scan. 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> { let artifacts = discover_artifacts(temp_dir, cache_dir); let mut report = PruneReport { dry_run: options.dry_run, ..Default::default() }; let removable_logs = removable_logs(&artifacts, options.all); 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; } let path = artifact.path(); log::info!("{}: {}", 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. if let Artifact::TempDir(_) = 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())), } } 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(&temp_dir, cache.as_deref(), 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, } } 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()); } }