Files
pkh/src/prune.rs
T
vhaudiquet 50ae12cafe prune: match real log names, order retention by time, spare fresh locks
Log retention only matched 'deb-*' logs, so source-build ('build-*')
and placeholder ('pkh-*') logs accumulated forever, and the 'keep the
newest' sort was lexicographic on names that sort by package/version
first, so arbitrary logs were kept. All three log shapes are matched
now and retention orders by the timestamp embedded in the name (mtime
fallback). Stale-lockfile pruning no longer deletes lockfiles younger
than 24h: a fresh <tarball>.lock is the mutual-exclusion signal of a
concurrent download and deleting it could corrupt the shared tarball
cache.
2026-09-16 04:04:50 +02:00

1088 lines
40 KiB
Rust

//! 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-<timestamp>` (or `pkh-<timestamp>-<attempt>` 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-<uid>` under the system temp directory), used by
//! mmdebstrap runs.
//! - **Build logs** under `~/.cache/pkh/logs/` written by `pkh deb`:
//! `deb-<package>-<version>-<timestamp>.log` (binary builds),
//! `build-<package>-<version>-<timestamp>.log` (source builds) and the
//! pre-identity placeholder `pkh-<timestamp>.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<PathBuf>,
/// 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
/// `<tarball>.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 `<cache>/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<PathBuf> {
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-<uid>` 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-<digits>` or `pkh-<digits>-<digits>`.
///
/// 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<SystemTime> {
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<String> {
let mut mounts: Vec<String> = 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<Artifact> {
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 <path>` 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<PruneReport, Box<dyn std::error::Error>> {
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<PruneReport, Box<dyn std::error::Error>> {
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<String> = 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<String> = 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<String> = 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());
}
}