use crate::context::{self, Context, ContextConfig}; use crate::deb::session::{self, Session}; use crate::deb::{Phase, enter_phase}; use crate::interrupt::CleanupHookGuard; use crate::report::BuildView; use directories::ProjectDirs; use sha2::{Digest, Sha256}; use std::error::Error; use std::fs; use std::os::unix::io::AsRawFd; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; use tar::Archive; use xz2::read::XzDecoder; // --------------------------------------------------------------------------- // Interrupt-time cleanup // // On Ctrl-C, the watchdog in `crate::interrupt` runs the hook registered in // [`EphemeralContextGuard::new_with_context`] right before exiting — the // interrupt sequence skips all destructors, which would otherwise leak the // freshly bootstrapped chroot under /tmp together with its bind-mounted // /proc and any overlayfs mounts. // --------------------------------------------------------------------------- /// Interrupt-time teardown of an anonymous temp chroot (no build record): /// unmount everything under it, then remove it — a plain `rm -rf` with a /// retry loop, best-effort. See [`sigint_cleanup_record`] for records. fn sigint_cleanup_chroot(chroot_path: &Path) { let is_root = unsafe { libc::geteuid() } == 0; unmount_under_logged(chroot_path); remove_with_retries(chroot_path, is_root); } /// Interrupt-time teardown of a build record: unmount everything under /// the record root, remove the per-run state (the chroot and the overlay /// scratch workdir) and, for a plain run, the staged tree and its /// host-tree snapshot. An `--incremental` run keeps whatever staging /// created — the keep only claims a staged tree when one exists on /// disk; a run interrupted before staging keeps none. The manifest and /// the build log always stay: every build leaves a metadata record, the /// interrupt included. /// /// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go /// through the context manager, the ephemeral context's driver (whose /// `cleanup()` unmounts the tracked overlays) or the base context's /// command builder: interrupt-time hooks must be self-contained, and /// those machineries may be mid-mutation on the interrupted thread. /// Instead it only reads /proc/mounts and spawns umount/rm directly. /// /// Best-effort by design: if a child process still holds a mount busy or /// privilege escalation is unavailable, individual steps fail; failures /// are logged (pointing at `pkh prune` for the leftovers) and never /// panic. fn sigint_cleanup_record(record_root: &Path, keep_upper: bool) { let is_root = unsafe { libc::geteuid() } == 0; unmount_under_logged(record_root); // Per-run state first: the chroot holds the /proc bind mount and the // device nodes, the workdir holds root-owned overlay scratch. for dir in ["chroot", "tree-upper-work"] { remove_with_retries(&record_root.join(dir), is_root); } if keep_upper && record_root.join("tree-upper").is_dir() { log::debug!( "Interrupted build record kept its staged tree at {} (fork it \ with `pkh deb --incremental`)", record_root.display() ); } else if keep_upper { // The run ended before staging created one: keep the message // honest — there is no staged tree to fork. log::debug!( "Interrupted build record kept at {} (no staged tree was \ created — the run ended before staging)", record_root.display() ); } else { for path in ["tree-upper", "host-files.list"] { remove_with_retries(&record_root.join(path), is_root); } } } /// Unmount every mount at or under `base`, deepest first, logging the /// failures (interrupt-time: leftover mounts are `pkh prune`'s to /// collect). fn unmount_under_logged(base: &Path) { let is_root = unsafe { libc::geteuid() } == 0; // Unmount children before parents: /proc/mounts lists mounts roughly // in creation order, so walk it in reverse. for mount_point in host_mounts_under(base).into_iter().rev() { if unmount_path(&mount_point, is_root) { log::debug!( "Unmounted {} during interrupt cleanup", mount_point.display() ); } else { log::error!( "Failed to unmount {} during interrupt cleanup; \ run `pkh prune` once the mount is free", mount_point.display() ); } } } /// Privileged `rm -rf` with a retry loop: a child the Ctrl+C interrupted /// may still be finishing its writeout — dpkg defers SIGINT until it /// reaches a safe state — so retry while rm reports the tree non-empty /// instead of leaving it half-removed. A missing path succeeds at once. fn remove_with_retries(path: &Path, is_root: bool) { const RETRIES: usize = 10; const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300); let mut last = None; for attempt in 0..=RETRIES { if attempt > 0 { std::thread::sleep(RETRY_DELAY); } last = Some( privileged_command("rm", is_root) .arg("-rf") .arg(path) .status(), ); if matches!(&last, Some(Ok(status)) if status.success()) { break; } } match last { Some(Ok(status)) if status.success() => { log::debug!("Removed {} during interrupt cleanup", path.display()); } Some(Ok(status)) => { log::error!( "Failed to remove {} during interrupt cleanup \ (rm exited with {status}); run `pkh prune`", path.display() ); } Some(Err(e)) => { log::error!( "Failed to run rm for {} during interrupt cleanup: {e}; run `pkh prune`", path.display() ); } None => unreachable!("at least one rm attempt ran"), } } /// Build a `Command` for `program`, wrapped in non-interactive sudo when not /// running as root: interrupt cleanup must never block on a password prompt, /// so without cached credentials the command fails fast and is logged instead pub(crate) fn privileged_command(program: &str, is_root: bool) -> Command { if is_root { Command::new(program) } else { let mut cmd = Command::new("sudo"); cmd.arg("-n").arg(program); cmd } } /// Privileged `rm -rf` of a tree (residual chroots hold root-owned device /// nodes); shared with the record removal in [`crate::deb::session`]. /// /// A tree the current user can fully remove needs no escalation, so the /// plain removal runs first (record forks and discarded records carry /// user-owned state); escalation only happens when it fails. pub(crate) fn privileged_remove(path: &Path) -> std::io::Result<()> { match fs::remove_dir_all(path) { Ok(()) => return Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(_) => {} } let is_root = crate::utils::root::is_root().unwrap_or(false); let status = privileged_command("rm", is_root) .arg("-rf") .arg(path) .status() .map_err(|e| std::io::Error::other(format!("failed to run rm: {e}")))?; if status.success() { Ok(()) } else { Err(std::io::Error::other(format!( "rm -rf {} exited with {status}", path.display() ))) } } /// Clear a directory's contents, keeping the directory itself. /// /// Built for re-cleaning a crashed build's leftovers: a SIGKILLed build /// (OOM, power loss) leaves its overlay mounts and /proc bind mount behind, /// and the kernel creates root-owned `work` state inside an overlay /// workdir that a plain `rm -rf` cannot remove. So every child is first /// unmounted at depth (re-reading /proc/mounts until nothing is left under /// it), then removed with a privileged `rm -rf` fallback. /// /// Returns an error when some content could not be removed: callers should /// refuse to layer a fresh build over a half-cleared tree. pub(crate) fn clear_dir(dir: &Path) -> std::io::Result<()> { fs::create_dir_all(dir)?; const MAX_UNMOUNT_PASSES: usize = 10; for entry in fs::read_dir(dir)?.flatten() { let path = entry.path(); // Unmount everything under the child, deepest first; the loop // handles stacks (a re-mount over an earlier, never-unmounted one — // exactly what consecutive crashed builds leave behind). for _ in 0..MAX_UNMOUNT_PASSES { let mounts = host_mounts_under(&path); if mounts.is_empty() { break; } let is_root = crate::utils::root::is_root().unwrap_or(false); for mount in mounts.into_iter().rev() { if !unmount_path(&mount, is_root) { log::warn!("Failed to unmount {} (retrying)", mount.display()); } } } let result = if path.is_dir() { fs::remove_dir_all(&path) } else { fs::remove_file(&path) } .or_else(|_| privileged_remove(&path)); if let Err(e) = result { return Err(std::io::Error::other(format!( "cannot remove {}: {e}", path.display() ))); } } Ok(()) } /// Whether `path` is currently a mount point (according to /proc/mounts). fn is_mountpoint(path: &Path) -> bool { let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let Ok(mounts_text) = fs::read_to_string("/proc/mounts") else { return false; }; mounts_text.lines().any(|line| { line.split_whitespace() .nth(1) .map(|mp| unescape_mount_field(mp) == path.to_string_lossy()) .unwrap_or(false) }) } /// The cached chroot tarball path for a series/arch, as used by the /// bootstrap: the record's fork gate compares the tarball's digest /// against the sha recorded in a base's manifest. A mirror pin is hashed /// into the name (before the `-buildd.tar.xz` suffix prune matches), so /// a pinned tarball never collides with the distro-default one — a /// `--mirror` run over a stale distro tarball would silently resolve /// from the distro, exactly the lie the pin must not tell. pub(crate) fn chroot_tarball_path( series: &str, arch: Option<&str>, mirror: Option<&str>, ) -> Result> { let proj_dirs = ProjectDirs::from("com", "pkh", "pkh").ok_or("Could not determine project directories")?; Ok(proj_dirs .cache_dir() .join(chroot_tarball_filename(series, arch, mirror))) } /// The cached chroot tarball's file name for a series/arch/mirror pub(crate) fn chroot_tarball_filename( series: &str, arch: Option<&str>, mirror: Option<&str>, ) -> String { let target = match arch { Some(a) => format!("{series}-{a}"), None => series.to_string(), }; match mirror { None => format!("{target}-buildd.tar.xz"), Some(m) => { let mut hasher = Sha256::new(); hasher.update(m.as_bytes()); let hash = format!("{:x}", hasher.finalize()); format!("{target}-{}-buildd.tar.xz", &hash[..12]) } } } /// Root of the shared apt download caches (`/var/tmp/pkh/apt-cache`), /// one `-` subdirectory per target. pub(crate) const APT_CACHE_ROOT: &str = "/var/tmp/pkh/apt-cache"; /// The shared apt download cache of a `series`/`arch` target: /// `/var/tmp/pkh/apt-cache/-/`, bind-mounted over each /// chroot's `/var/cache/apt/archives` so dependency installs reuse the /// `.debs` previous runs downloaded instead of fetching them again. /// `arch` is the chroot's architecture (the foreign one for qemu-binfmt /// builds, the host one otherwise), keyed like the chroot tarball cache. pub(crate) fn apt_cache_dir(series: &str, arch: Option<&str>) -> PathBuf { // The host architecture when the chroot is not a foreign one. let arch = arch .map(str::to_string) .unwrap_or_else(crate::get_current_arch); PathBuf::from(APT_CACHE_ROOT).join(format!("{series}-{arch}")) } /// Name of the apt configuration file that keeps downloaded packages /// (without it apt deletes the `.debs` it installs, and the shared /// cache would never fill). pub(crate) const APT_KEEP_CONF: &str = "99pkh-shared-download-cache"; /// Write the apt configuration keeping downloaded packages into the /// chroot, so installs populate the (shared) download cache. pub(crate) fn write_apt_keep_conf(chroot_path: &Path) -> std::io::Result<()> { let conf_dir = chroot_path.join("etc/apt/apt.conf.d"); fs::create_dir_all(&conf_dir)?; fs::write( conf_dir.join(APT_KEEP_CONF), "APT::Keep-Downloaded-Packages \"true\";\n", ) } /// The in-chroot mount point of the shared ccache directory. pub(crate) const CCACHE_MOUNT: &str = "/var/cache/pkh-ccache"; /// The in-chroot directory holding the ccache compiler shims /// (gcc/g++/cc/c++ → `/usr/bin/ccache`), prepended to the build's PATH /// by [`crate::deb::local`]. pub(crate) const CCACHE_SHIM_DIR: &str = "/usr/lib/pkh-ccache-shim"; /// Bind-mount the shared ccache directory of a build identity over the /// chroot's [`CCACHE_MOUNT`] and install the compiler shims, so the /// builds of that target reuse each other's compiled objects (content /// keyed, see the ccache env in [`crate::deb::local`]). ccache is /// designed for concurrent access to one cache — no cross-run lock is /// needed. /// /// Failures are warnings, never errors: an unshared cache dir makes /// ccache per-run (still correct, just slower), and a missing shim /// leaves the build on the real compilers. fn share_ccache(chroot_path: &Path, cache: &Path, ctx: Arc) { if let Err(e) = fs::create_dir_all(cache).and_then(|()| install_ccache_shim(chroot_path)) { log::warn!("Cannot share the ccache {}: {e}", cache.display()); return; } let mount_point = chroot_path.join(CCACHE_MOUNT.trim_start_matches('/')); if let Err(e) = fs::create_dir_all(&mount_point) { log::warn!( "Cannot create the ccache mount point {}: {e}", mount_point.display() ); return; } // Check if we're running as root to avoid unnecessary sudo let is_root = crate::utils::root::is_root().unwrap_or(false); let mut cmd = ctx.command(if is_root { "mount" } else { "sudo" }); if !is_root { cmd.arg("mount"); } match cmd.arg("--bind").arg(cache).arg(&mount_point).status() { Ok(status) if status.success() => { log::debug!( "Bind-mounted the shared ccache {} over {}", cache.display(), mount_point.display() ); } outcome => { log::warn!( "Cannot bind-mount the shared ccache {} over {}: {:?}; \ this run's cache is not shared", cache.display(), mount_point.display(), outcome ); } } } /// Install the ccache PATH shim inside the chroot: `gcc`, `g++`, `cc` /// and `c++` symlinking to the ccache binary the essentials install, in /// a directory nothing else populates. The absolute target resolves /// inside the chroot, where the shim dir is prepended to PATH. pub(crate) fn install_ccache_shim(chroot_path: &Path) -> std::io::Result<()> { let shim_dir = chroot_path.join(CCACHE_SHIM_DIR.trim_start_matches('/')); fs::create_dir_all(&shim_dir)?; for compiler in ["gcc", "g++", "cc", "c++"] { let shim = shim_dir.join(compiler); match fs::symlink_metadata(&shim) { Ok(_) => fs::remove_file(&shim)?, // idempotent re-install Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e), } std::os::unix::fs::symlink("/usr/bin/ccache", &shim)?; } Ok(()) } /// Unmount `path`, falling back to a lazy unmount if the first attempt fails /// because something still holds the mount busy (e.g. an interrupted child /// that has not exited yet); returns whether the mount is gone pub(crate) fn unmount_path(path: &Path, is_root: bool) -> bool { if privileged_command("umount", is_root) .arg(path) .status() .is_ok_and(|s| s.success()) { return true; } privileged_command("umount", is_root) .arg("-l") .arg(path) .status() .is_ok_and(|s| s.success()) } /// Collect the host-side mount points at or below `base`, in /proc/mounts /// order (empty if /proc/mounts cannot be read) pub(crate) fn host_mounts_under(base: &Path) -> Vec { let mut mounts = Vec::new(); let Ok(mounts_text) = fs::read_to_string("/proc/mounts") else { return mounts; }; // Compare against the canonical path: /proc/mounts shows resolved paths, // while the chroot path may go through a symlinked TMPDIR let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf()); for line in mounts_text.lines() { let mut fields = line.split_whitespace(); let (Some(_device), Some(mount_point)) = (fields.next(), fields.next()) else { continue; }; let path = PathBuf::from(unescape_mount_field(mount_point)); if path.starts_with(&base) && !mounts.contains(&path) { mounts.push(path); } } mounts } /// Decode the octal escapes /proc/mounts uses in its path fields /// (`\040` for space, `\011` for tab, `\012` for newline, `\134` for backslash) fn unescape_mount_field(field: &str) -> String { let bytes = field.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'\\' && i + 4 <= bytes.len() && bytes[i + 1..i + 4] .iter() .all(|b| (b'0'..=b'7').contains(b)) && let Ok(value) = u8::from_str_radix(&field[i + 1..i + 4], 8) { out.push(value); i += 4; } else { out.push(bytes[i]); i += 1; } } String::from_utf8_lossy(&out).into_owned() } /// An ephemeral unshare context guard that creates and manages a temporary chroot environment /// for building packages with unshare permissions. /// /// In session mode ([`EphemeralContextGuard::new_for_session`]) the chroot /// lives inside a [`Session`] record's `chroot/` directory — extracted /// fresh from the tarball cache on every run (nothing chroot-shaped is /// persisted) and removed at the end of the run. The record itself is /// always kept as metadata and log; its staged tree is retained exactly /// for `--incremental` runs (`keep_upper`). pub struct EphemeralContextGuard { /// The ephemeral build context this guard created (an unshare context /// bound to the chroot, parented on the base context). Held explicitly so /// cleanup and the build itself never depend on the process-global /// "current" context, which concurrent builds swap for their own. ephemeral_ctx: Arc, /// The context that was current (globally) when this guard was created, /// restored on drop. Saving the handle instead of a config name is what /// keeps concurrent builds from restoring over each other. previous_context: Arc, chroot_path: PathBuf, build_succeeded: bool, base_ctx: Arc, /// Registration of the interrupt-time cleanup hook; deregistered when /// this guard drops, so the hook can never fire after the normal cleanup cleanup_hook: Option, /// The build record this guard works in, when any. session: Option, /// Whether the record's staged tree is retained after the build /// (`pkh deb --incremental`). keep_upper: bool, /// The host-side lock of the shared apt download cache, held while /// its bind mount is up (see /// [`EphemeralContextGuard::share_apt_cache`]); released when the /// guard drops, together with the mount. Held for its drop side /// effect alone, hence the underscore: it is never read. _apt_cache_lock: Option, } impl EphemeralContextGuard { /// Create a new ephemeral unshare context with an explicit base context /// /// # Arguments /// * `series` - The distribution series (e.g., "noble", "sid") /// * `arch` - Optional target architecture. If provided and different from host, /// downloads a chroot for that architecture (uses qemu_binfmt transparently) /// * `base_ctx` - The base context to use for creating the chroot pub async fn new_with_context( series: &str, arch: Option<&str>, mirror: Option<&str>, base_ctx: Arc, view: &dyn BuildView, ) -> Result> { Self::new_internal(None, series, arch, mirror, base_ctx, view, false, None).await } /// Create the ephemeral build context inside `session`'s chroot. /// /// # Arguments /// * `session` - The build record owning the chroot (locked by the /// caller) /// * `series` - The distribution series (e.g., "noble", "sid") /// * `arch` - Optional target architecture, as for /// [`EphemeralContextGuard::new_with_context`] /// * `base_ctx` - The base context to use for chroot work /// * `keep_upper` - Retain the record's staged tree after the build /// (`pkh deb --incremental`); a plain run's record keeps metadata /// and log only /// * `ccache_dir` - The shared ccache directory to mount at the /// chroot's `/var/cache/pkh-ccache` (`pkh deb` computes it from the /// build identity; `None` leaves the chroot unshared) #[allow(clippy::too_many_arguments)] pub async fn new_for_session( session: Session, series: &str, arch: Option<&str>, mirror: Option<&str>, base_ctx: Arc, view: &dyn BuildView, keep_upper: bool, ccache_dir: Option<&Path>, ) -> Result> { Self::new_internal( Some(session), series, arch, mirror, base_ctx, view, keep_upper, ccache_dir, ) .await } #[allow(clippy::too_many_arguments)] async fn new_internal( session: Option, series: &str, arch: Option<&str>, mirror: Option<&str>, base_ctx: Arc, view: &dyn BuildView, keep_upper: bool, ccache_dir: Option<&Path>, ) -> Result> { // Save the globally-installed context so Drop can restore exactly // this handle: concurrent builds install their own ephemeral // overrides, so the only safe restoration value is the one observed // before this guard swapped anything in. let previous_context = context::current(); // In session mode the chroot lives inside the record root, as // per-run scratch; otherwise it is a fresh temp dir. let chroot_path = match &session { Some(s) => { let path = s.chroot_path(); fs::create_dir_all(&path)?; path } None => PathBuf::from(base_ctx.create_temp_dir()?), }; log::debug!( "Creating new chroot for {} (arch: {:?}) at {}...", series, arch, chroot_path.display() ); // Register the interrupt-time cleanup hook before any heavy work: if // the user hits Ctrl-C during bootstrap or the build itself, the // interrupt watchdog unmounts the chroot's mounts through this hook // (see `sigint_cleanup_chroot`). This only works for a local // base context: the hook must be self-contained (stored path + // direct umount/rm subprocesses) and cannot go through `base_ctx`. // For remote or nested bases the chroot lives elsewhere, and // leftovers stay handled by `pkh prune` as before. // // Session mode tears the record's per-run state down (chroot and // overlay workdir) and keeps its staged tree exactly for // `--incremental`; a plain temp chroot is removed entirely. let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) { let hook: Box = match session.as_ref().map(|s| s.root().to_path_buf()) { Some(record_root) => { Box::new(move || sigint_cleanup_record(&record_root, keep_upper)) } None => { let chroot_path = chroot_path.clone(); Box::new(move || sigint_cleanup_chroot(&chroot_path)) } }; Some(crate::interrupt::register_cleanup_hook(hook)) } else { log::debug!( "Base context is not local; skipping interrupt-time cleanup registration for {}", chroot_path.display() ); None }; // Download and extract the chroot tarball — always fresh; the // record's chroot directory is per-run scratch. The shared apt // cache's lock (when the cache is shared) is held from here on. let apt_cache_lock = match Self::download_and_extract_chroot( series, arch, mirror, &chroot_path, base_ctx.clone(), view, ccache_dir, ) .await { Ok(lock) => lock, Err(e) => { // On a Ctrl+C the interrupt watchdog owns the tree: keep the // hook registered (forgetting the guard) so it removes the // partial directory, instead of the historical behavior of // leaving it in place. Without an interrupt this is a plain // bootstrap failure and the partial directory stays, as before. if crate::interrupt::interrupted() && let Some(hook) = cleanup_hook { std::mem::forget(hook); } else { drop(cleanup_hook); } return Err(e); } }; // Record the tarball digest this environment came from: the fork // gate of a later `--incremental` run compares it against the // cache to decide whether the record's staged tree is still // reusable. if let Some(s) = &session && let Ok(tarball) = chroot_tarball_path(series, arch, mirror) && let Ok(sha) = session::sha256_file(&tarball) { s.set_tarball_sha256(&sha); } // Switch to an ephemeral context to build the package in the chroot. // The parent is the base context itself (the one that bootstrapped // the chroot), wired through `with_parent` instead of a config-name // lookup, so an explicit non-current base (e.g. ssh) is used for // everything that runs inside the chroot. The Arc stays in the // guard: the build and the cleanup use it directly. let ephemeral_ctx = Arc::new(Context::with_parent( ContextConfig::Unshare { path: chroot_path.to_string_lossy().to_string(), // The real parent is bound below via `with_parent`; the // config field is only used for contexts read from the // persisted configuration. parent: None, }, base_ctx.clone(), )); context::manager().set_current_ephemeral(ephemeral_ctx.clone()); Ok(Self { previous_context, ephemeral_ctx, chroot_path, build_succeeded: false, base_ctx, cleanup_hook, session, keep_upper, _apt_cache_lock: apt_cache_lock, }) } /// The ephemeral build context created by this guard /// /// Callers must take the context from here rather than from /// [`crate::context::current()`]: the process-global is a shared swap /// slot that another concurrent build may have re-pointed at its own /// chroot, while this handle is guaranteed to be this guard's context. pub fn context(&self) -> Arc { Arc::clone(&self.ephemeral_ctx) } async fn download_and_extract_chroot( series: &str, arch: Option<&str>, mirror: Option<&str>, chroot_path: &PathBuf, ctx: Arc, view: &dyn BuildView, ccache_dir: Option<&Path>, ) -> Result, Box> { // The chroot is per-run scratch: nothing is reused, so never // extract over stale content. clear_dir(chroot_path)?; // Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it let ctx_for_devices = ctx.clone(); // Get project directories for caching let proj_dirs = ProjectDirs::from("com", "pkh", "pkh") .ok_or("Could not determine project directories")?; let cache_dir = proj_dirs.cache_dir(); fs::create_dir_all(cache_dir)?; let tarball_filename = chroot_tarball_filename(series, arch, mirror); let tarball_path = cache_dir.join(&tarball_filename); // Check for existing lockfile, and wait for a timeout if it exists // After timeout, warn the user let lockfile_path = tarball_path.with_extension("lock"); // Check if lockfile exists and wait for it to be removed let mut wait_time = 0; let timeout = 300; // 5 minutes timeout let poll_interval = 5; // Check every 5 seconds while ctx.exists(&lockfile_path)? { // Stop waiting on a Ctrl+C: the interrupt watchdog removes the // (yet empty) chroot and exits without waiting for the poll if crate::interrupt::interrupted() { return Err("Interrupted while waiting for the chroot tarball".into()); } if wait_time >= timeout { log::warn!( "Lockfile {} exists and has been present for more than {} seconds. \ Another process may be downloading the chroot tarball. Continuing anyway...", lockfile_path.display(), timeout ); break; } log::info!( "Lockfile {} exists, waiting for download to complete... ({}s/{})", lockfile_path.display(), wait_time, timeout ); std::thread::sleep(std::time::Duration::from_secs(poll_interval)); wait_time += poll_interval; } // Download tarball if it doesn't exist if !tarball_path.exists() { log::debug!( "Downloading chroot tarball for {} (arch: {:?})...", series, arch ); enter_phase(view, Phase::PreparingChroot); Self::download_chroot_tarball(series, arch, mirror, &tarball_path, ctx, view).await?; } else { log::debug!( "Using cached chroot tarball for {} (arch: {:?})", series, arch ); } // Extract tarball to chroot directory log::debug!("Extracting chroot tarball to {}...", chroot_path.display()); enter_phase(view, Phase::ExtractingChroot); Self::extract_tarball(&tarball_path, chroot_path, view)?; // Create device nodes, bind-mount /proc and share the apt cache let apt_cache_lock = Self::finalize_chroot(series, arch, chroot_path, ctx_for_devices, view, ccache_dir)?; // The bootstrap's tarball digest is recorded by the caller (the // record's fork gate compares it against the cache later). Ok(apt_cache_lock) } /// Device nodes, the /proc bind mount, the shared apt download cache /// and the shared ccache for a fresh chroot: idempotent /// finalization, safe to run on every run. Returns the shared apt /// cache's lock, held while its mount is up (see /// [`EphemeralContextGuard::share_apt_cache`]). fn finalize_chroot( series: &str, arch: Option<&str>, chroot_path: &Path, ctx: Arc, view: &dyn BuildView, ccache_dir: Option<&Path>, ) -> Result, Box> { if !Self::device_nodes_ready(chroot_path) { // The tar crate materializes the tarball's device entries as // empty regular files (see [Self::DEVICE_NODES]), so readiness // is keyed on the nodes being char devices, not on presence. log::debug!("Creating device nodes in chroot..."); enter_phase(view, Phase::FinalizingChroot); Self::create_device_nodes(chroot_path, ctx.clone())?; } // Bind mount /proc from host into chroot (before entering unshare namespace) // This allows /proc to work in containers where mounting inside unshare fails. // A chroot reused within the same run may still carry the mount: // only mount when it is not already there. if is_mountpoint(&chroot_path.join("proc")) { log::debug!("/proc is already bind-mounted in the chroot"); } else { log::debug!("Bind-mounting /proc into chroot..."); Self::bind_mount_proc(chroot_path, ctx.clone())?; } if let Some(dir) = ccache_dir { share_ccache(chroot_path, dir, ctx.clone()); } Ok(Self::share_apt_cache(series, arch, chroot_path, ctx)) } /// Bind-mount the shared apt download cache of `series`/`arch` over /// the chroot's `/var/cache/apt/archives`, so dependency installs /// reuse the `.debs` previous runs downloaded instead of fetching /// them again. The apt conf keeping downloaded packages /// ([`write_apt_keep_conf`]) makes the installs populate it. /// /// While the mount is up, the run holds a host-side flock on the /// cache's lock file (returned to the caller, released when the /// guard drops): apt's own `archives/lock` is acquired inside the /// chroot's pid namespace, where a concurrent build's apt appears /// as "held by process 0" — an invisible process it refuses to wait /// for — so pkh serializes the apt traffic of builds sharing one /// cache itself. Concurrent builds of the same target wait for each /// other instead of racing apt. /// /// Any failure is a warning, never an error: the chroot keeps its /// own cache dir and everything stays correct, just slower. fn share_apt_cache( series: &str, arch: Option<&str>, chroot_path: &Path, ctx: Arc, ) -> Option { let cache = apt_cache_dir(series, arch); if let Err(e) = fs::create_dir_all(cache.join("partial")) { log::warn!( "Cannot create the shared apt cache {}: {e}; using the \ chroot's own cache", cache.display() ); return None; } let archives = chroot_path.join("var/cache/apt/archives"); if let Err(e) = fs::create_dir_all(&archives).and_then(|()| write_apt_keep_conf(chroot_path)) { log::warn!( "Cannot prepare the chroot's apt cache for sharing: {e}; \ using the chroot's own cache" ); return None; } // Serialize with the other runs sharing this cache BEFORE apt // can run in the chroot. A held lock is a live run: wait for it // (a crashed pkh releases its flock with its process). let lock_path = cache.with_extension("lock"); let lock = match fs::OpenOptions::new() .create(true) .append(true) .open(&lock_path) { Ok(file) => file, Err(e) => { log::warn!( "Cannot open the shared apt cache lock {}: {e}; using \ the chroot's own cache", lock_path.display() ); return None; } }; let taken = unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) }; if taken != 0 { log::warn!( "Cannot lock the shared apt cache {}: {}; using the \ chroot's own cache", lock_path.display(), std::io::Error::last_os_error() ); return None; } // Check if we're running as root to avoid unnecessary sudo let is_root = crate::utils::root::is_root().unwrap_or(false); let mut cmd = ctx.command(if is_root { "mount" } else { "sudo" }); if !is_root { cmd.arg("mount"); } let status = cmd.arg("--bind").arg(&cache).arg(&archives).status(); match status { Ok(status) if status.success() => { log::debug!( "Bind-mounted the shared apt cache {} over {}", cache.display(), archives.display() ); Some(lock) } outcome => { log::warn!( "Cannot bind-mount the shared apt cache {} over {}: \ {:?}; using the chroot's own cache", cache.display(), archives.display(), outcome ); None // dropping the lock: nothing is shared } } } async fn download_chroot_tarball( series: &str, arch: Option<&str>, mirror: Option<&str>, tarball_path: &Path, ctx: Arc, view: &dyn BuildView, ) -> Result<(), Box> { // Create a lock file to make sure that noone tries to use the file while it's not fully downloaded let lockfile_path = tarball_path.with_extension("lock"); ctx.command("touch") .arg(lockfile_path.to_string_lossy().to_string()) .status()?; // Download the keyring(s) let keyring_dir = crate::apt::keyring::download_cache_keyrings(Some(ctx.clone()), series).await?; // Use mmdebstrap to download the tarball to the cache directory let mut cmd = ctx.command("mmdebstrap"); cmd.arg("--variant=buildd") .arg("--mode=unshare") .arg("--include=mount,curl,ca-certificates") .arg("--format=tar") .arg(format!("--keyring={}", keyring_dir.display())) // Setup hook to copy keyrings into the chroot so apt inside can use them .arg("--setup-hook=mkdir -p \"$1/etc/apt/trusted.gpg.d\"") .arg(format!( "--setup-hook=cp {}/*.gpg \"$1/etc/apt/trusted.gpg.d/\"", keyring_dir.display() )); // Add architecture if specified if let Some(a) = arch { cmd.arg(format!("--arch={}", a)); } cmd.arg(series) .arg(tarball_path.to_string_lossy().to_string()); // The mirror positional: without it mmdebstrap writes the distro // defaults into the tarball's apt sources verbatim; with it, the // tarball's sources.list carries the mirror, so every apt inside // the build follows. if let Some(m) = mirror { cmd.arg(m); } if let Some(s) = view.sink() { cmd.capture(s); } let status = cmd.status()?; if !status.success() { // Remove file on error let _ = ctx .command("rm") .arg("-f") .arg(tarball_path.to_string_lossy().to_string()) .status(); let _ = ctx .command("rm") .arg("-f") .arg(lockfile_path.to_string_lossy().to_string()) .status(); return Err(format!( "Failed to download chroot tarball for series {} (arch: {:?})", series, arch ) .into()); } // Remove lockfile: tarball is fully downloaded let _ = ctx .command("rm") .arg("-f") .arg(lockfile_path.to_string_lossy().to_string()) .status(); Ok(()) } fn extract_tarball( tarball_path: &PathBuf, chroot_path: &PathBuf, view: &dyn BuildView, ) -> Result<(), Box> { // Create the chroot directory fs::create_dir_all(chroot_path)?; // Open the tarball file let tarball_file = std::fs::File::open(tarball_path)?; let xz_decoder = XzDecoder::new(tarball_file); let mut archive = Archive::new(xz_decoder); // Extract entries one by one so progress can be reported (a full // second decompression pass just to count entries upfront would be // too expensive for multi-hundred-MB chroot tarballs) let mut count = 0usize; for entry in archive.entries()? { // Bail on a Ctrl+C before the interrupt watchdog's rm -rf races // this loop writing entries into the tree being removed if crate::interrupt::interrupted() { return Err("Interrupted while extracting the chroot".into()); } let mut entry = entry?; entry.unpack_in(chroot_path)?; count += 1; if count.is_multiple_of(100) { view.message(&format!("Extracting chroot… ({count} files)")); } } view.message(&format!("Extracting chroot… ({count} files)")); Ok(()) } /// The device nodes a buildd chroot needs, as (name, major, minor). /// /// The chroot tarballs carry these as char-device entries, but the /// tar crate cannot create special files: it treats entry types it /// does not recognize as regular ones, so extraction leaves *empty /// regular files* at these paths. Anything redirecting into them /// then writes real data, and a reader gets garbage (a `/dev/null` /// passed to `dpkg-gencontrol -T` aborts the build with "bad line /// in substvars file"). Hence the nodes are (re)created with mknod, /// gated on being char devices rather than merely existing. const DEVICE_NODES: &[(&str, u32, u32)] = &[ ("console", 5, 1), ("full", 1, 7), ("null", 1, 3), ("ptmx", 5, 2), ("random", 1, 8), ("tty", 5, 0), ("urandom", 1, 9), ("zero", 1, 5), ]; /// Whether every node in [`Self::DEVICE_NODES`] exists as a char /// device in the chroot. fn device_nodes_ready(chroot_path: &Path) -> bool { use std::os::unix::fs::FileTypeExt; Self::DEVICE_NODES.iter().all(|(name, _, _)| { chroot_path .join("dev") .join(name) .metadata() .is_ok_and(|meta| meta.file_type().is_char_device()) }) } fn create_device_nodes( chroot_path: &Path, ctx: Arc, ) -> Result<(), Box> { // Ensure /dev directory exists fs::create_dir_all(chroot_path.join("dev"))?; // Check if we're running as root let is_root = crate::utils::root::is_root()?; for (name, major, minor) in Self::DEVICE_NODES { let path = chroot_path.join("dev").join(name); // Clear whatever is there: the empty regular file the // extraction leaves behind, or a node from an earlier run. let _ = ctx .command("rm") .arg("-f") .arg(path.to_string_lossy().to_string()) .status(); // Create the node using mknod (with sudo if not root) let mut cmd = ctx.command(if is_root { "mknod" } else { "sudo" }); if !is_root { cmd.arg("mknod"); } let status = cmd .arg("-m") .arg("666") .arg(path.to_string_lossy().to_string()) .arg("c") .arg(major.to_string()) .arg(minor.to_string()) .status()?; if !status.success() { return Err(format!("Failed to create device node /dev/{name}").into()); } } Ok(()) } /// Bind mount /proc from host into the chroot /// This is done before entering the unshare namespace, so it works in containers fn bind_mount_proc( chroot_path: &Path, ctx: Arc, ) -> Result<(), Box> { let proc_path = chroot_path.join("proc"); // Ensure /proc directory exists in chroot fs::create_dir_all(&proc_path)?; // Check if we're running as root let is_root = crate::utils::root::is_root()?; // Bind mount host's /proc into chroot (with sudo if not root) let mut cmd = ctx.command(if is_root { "mount" } else { "sudo" }); if !is_root { cmd.arg("mount"); } let status = cmd .arg("--bind") .arg("/proc") .arg(proc_path.to_string_lossy().to_string()) .status()?; if !status.success() { log::warn!( "Could not bind-mount /proc into chroot at {}. Some packages may not install correctly.", proc_path.display() ); } else { log::debug!("Bind-mounted /proc into chroot at {}", proc_path.display()); } Ok(()) } /// Mark the build as successful, which will trigger chroot cleanup on drop pub fn mark_build_successful(&mut self) { self.build_succeeded = true; } /// The staged build root inside the chroot: the stable /// `/tmp/pkh-build` for record builds (the overlay upperdir mirrors /// paths relative to the merge point, so the staged tree must live at /// the same in-chroot path on every attempt for the build artifacts to /// be reusable), a fresh timestamped temp dir otherwise. pub fn build_root(&self) -> Result> { match &self.session { Some(s) => { std::fs::create_dir_all(s.chroot_path().join("tmp/pkh-build"))?; Ok(s.build_root()) } None => Ok(self.ephemeral_ctx.create_temp_dir()?), } } } impl Drop for EphemeralContextGuard { fn drop(&mut self) { // On Ctrl+C the interrupt watchdog owns the chroot teardown through // the registered hook: duplicating it here would race the hook's // umount/rm (mounts vanish under each other). Dropping this guard // would normally deregister the hook, so while the watchdog runs it // must be leaked instead to keep it registered (if it was already // drained, forgetting is a harmless no-op). if crate::interrupt::interrupted() { context::manager().set_current_ephemeral(self.previous_context.clone()); // The record keeps its metadata: record the aborted build // honestly before the hook tears the per-run state down — // failed, and kept_upper only if a staged tree exists. if let Some(session) = &self.session { session.record_interrupted(self.keep_upper); } if let Some(hook) = self.cleanup_hook.take() { std::mem::forget(hook); } return; } // Deregister the interrupt-time cleanup hook first: the normal // cleanup below takes care of the chroot, so the hook must not fire // afterwards. (If a SIGINT arrived mid-drop and the hook is already // running concurrently, deregistration simply does not find it — // both paths are individually idempotent and failure-tolerant.) if let Some(mut cleanup_hook) = self.cleanup_hook.take() { cleanup_hook.deregister(); } log::debug!("Cleaning up ephemeral context ({:?})...", self.chroot_path); // Clean up any overlay mounts before resetting the context. This // explicitly targets the context this guard created — never // `context::current()`, which a concurrent build may have re-pointed // at its own chroot. The actual unmount commands run via the parent // (base) context, so they work regardless. if let Err(e) = self.ephemeral_ctx.cleanup() { log::warn!("Failed to clean up overlay mounts: {}", e); } // Restore the context that was current when this guard was created, // not whatever is globally current at drop time (another concurrent // build's override may be installed there). This only swaps the // in-memory handle: the persisted configuration still names the // context selected by the user, as `set_current_ephemeral` never // touches it. context::manager().set_current_ephemeral(self.previous_context.clone()); // Session mode: the record's per-run chroot comes down in every // case (nothing chroot-shaped is persisted — success, failure and // interrupt all end the run), then the record is retained: // metadata and log always, the staged tree exactly for // `--incremental` runs. if let Some(session) = &self.session { let proc_path = self.chroot_path.join("proc"); let is_root = crate::utils::root::is_root().unwrap_or(false); let _ = if is_root { self.base_ctx.command("umount").arg(&proc_path).status() } else { self.base_ctx .command("sudo") .arg("umount") .arg(&proc_path) .status() }; session.remove_run_state(); session.retain_upper(self.keep_upper, self.build_succeeded); return; } // Remove chroot directory only if build succeeded if self.build_succeeded { log::debug!( "Build succeeded, removing chroot directory: {}", self.chroot_path.display() ); // Check if we're running as root to avoid unnecessary sudo let is_root = crate::utils::root::is_root().unwrap_or(false); // Unmount everything under the chroot before removing it: // /proc, and the shared apt download cache bind-mounted over // /var/cache/apt/archives — an rm -rf through that mount // would delete the shared cache itself. for mount in host_mounts_under(&self.chroot_path).into_iter().rev() { if !unmount_path(&mount, is_root) { log::warn!( "Failed to unmount {} under {}", mount.display(), self.chroot_path.display() ); } } let result = if is_root { self.base_ctx .command("rm") .arg("-rf") .arg(&self.chroot_path) .status() } else { self.base_ctx .command("sudo") .arg("rm") .arg("-rf") .arg(&self.chroot_path) .status() }; match result { Ok(status) => { if !status.success() { log::error!( "Failed to remove chroot directory {}", self.chroot_path.display() ); } else { log::debug!( "Successfully removed chroot directory: {}", self.chroot_path.display() ); } } Err(e) => { log::error!( "Failed to execute cleanup command for {}: {}", self.chroot_path.display(), e ); } } } else { log::debug!( "Build did not succeed or was not marked as successful, keeping chroot directory: {}", self.chroot_path.display() ); } } } #[cfg(test)] mod chroot_cleanup_tests { use super::*; /// The tarball cache name: per series/arch as before, and a distinct /// deterministic name per pinned mirror — hashed BEFORE the /// `-buildd.tar.xz` suffix, which is the pattern prune matches (a /// pinned tarball pruning like any other). #[test] fn tarball_filename_keys_the_mirror_in() { assert_eq!( chroot_tarball_filename("noble", None, None), "noble-buildd.tar.xz" ); assert_eq!( chroot_tarball_filename("noble", Some("riscv64"), None), "noble-riscv64-buildd.tar.xz" ); let pinned = chroot_tarball_filename("noble", Some("amd64"), Some("http://localhost:3142/ubuntu")); assert!(pinned.starts_with("noble-amd64-")); assert!(pinned.ends_with("-buildd.tar.xz")); // Deterministic, and distinct per mirror (and from the unpinned). assert_eq!( pinned, chroot_tarball_filename("noble", Some("amd64"), Some("http://localhost:3142/ubuntu")) ); assert_ne!( pinned, chroot_tarball_filename("noble", Some("amd64"), Some("http://localhost:3143/ubuntu")) ); assert_ne!( pinned, chroot_tarball_filename("noble", Some("amd64"), None) ); } /// /proc/mounts path fields use octal escapes for whitespace and /// backslashes; anything else must be kept verbatim. #[test] fn mount_field_unescaping_decodes_octal_escapes() { assert_eq!(unescape_mount_field("/mnt/plain"), "/mnt/plain"); assert_eq!( unescape_mount_field("/mnt/with\\040space"), "/mnt/with space" ); assert_eq!(unescape_mount_field("/mnt/with\\011tab"), "/mnt/with\ttab"); assert_eq!(unescape_mount_field("back\\134slash"), "back\\slash"); // Not an escape sequence: kept verbatim assert_eq!(unescape_mount_field("back\\9slash"), "back\\9slash"); assert_eq!(unescape_mount_field("trailing\\"), "trailing\\"); } /// Interrupt cleanup of a path that has no mounts and does not exist must /// be a harmless no-op (no panic, nothing left behind). #[test] fn sigint_cleanup_of_missing_chroot_is_a_noop() { let dir = tempfile::tempdir().unwrap(); let missing = dir.path().join("no-such-chroot"); sigint_cleanup_chroot(&missing); assert!(!missing.exists()); } /// What the tar crate leaves after extracting the chroot tarball: /// every device entry materializes as an empty *regular* file. The /// readiness guard must reject that shape, or `/dev/null` stays a /// regular file and a reader of a redirected write sees garbage (the /// `dpkg-gencontrol -T/dev/null` substvars abort). #[test] fn device_nodes_reject_regular_files_left_by_extraction() { let dir = tempfile::tempdir().unwrap(); let chroot = dir.path().join("chroot"); std::fs::create_dir_all(chroot.join("dev")).unwrap(); for (name, _, _) in EphemeralContextGuard::DEVICE_NODES { std::fs::write(chroot.join("dev").join(name), b"").unwrap(); } assert!( !EphemeralContextGuard::device_nodes_ready(&chroot), "regular files must not count as ready device nodes" ); } /// The ready case without root: a symlink to the host's real node, /// whose metadata resolves to the char device. #[test] fn device_nodes_accept_real_char_devices() { let dir = tempfile::tempdir().unwrap(); let chroot = dir.path().join("chroot"); std::fs::create_dir_all(chroot.join("dev")).unwrap(); for (name, _, _) in EphemeralContextGuard::DEVICE_NODES { std::os::unix::fs::symlink(format!("/dev/{name}"), chroot.join("dev").join(name)) .unwrap(); } assert!(EphemeralContextGuard::device_nodes_ready(&chroot)); } /// A real directory with no mounts under it is simply removed. Skipped /// when non-root without working non-interactive sudo, since removal then /// legitimately fails (and is only logged). #[test] fn sigint_cleanup_removes_an_unmounted_directory() { let is_root = unsafe { libc::geteuid() } == 0; if !is_root && !privileged_command("true", false) .status() .is_ok_and(|s| s.success()) { return; } let dir = tempfile::tempdir().unwrap(); let chroot = dir.path().join("chroot"); std::fs::create_dir_all(chroot.join("rootfs")).unwrap(); std::fs::write(chroot.join("rootfs").join("file.txt"), "data").unwrap(); sigint_cleanup_chroot(&chroot); assert!(!chroot.exists()); } /// The record interrupt teardown removes the per-run state and keeps /// the manifest and log; an `--incremental` record keeps its staged /// tree, a plain one loses it. #[test] fn sigint_record_teardown_keeps_metadata_and_drops_the_chroot() { let is_root = unsafe { libc::geteuid() } == 0; if !is_root && !privileged_command("true", false) .status() .is_ok_and(|s| s.success()) { return; } let dir = tempfile::tempdir().unwrap(); let incremental = dir.path().join("incremental"); let plain = dir.path().join("plain"); for root in [&incremental, &plain] { std::fs::create_dir_all(root.join("chroot/rootfs")).unwrap(); std::fs::create_dir_all(root.join("tree-upper/src")).unwrap(); std::fs::create_dir_all(root.join("log")).unwrap(); std::fs::write(root.join("manifest.json"), "{}").unwrap(); std::fs::write(root.join("log/build.log"), "output\n").unwrap(); std::fs::write(root.join("host-files.list"), "f\t1\t1\ta\n").unwrap(); } sigint_cleanup_record(&incremental, true); sigint_cleanup_record(&plain, false); for root in [&incremental, &plain] { assert!(!root.join("chroot").exists(), "the chroot is per-run"); assert!(root.join("manifest.json").is_file(), "metadata stays"); assert!(root.join("log/build.log").is_file(), "the log stays"); } assert!( incremental.join("tree-upper/src").exists(), "an interrupted incremental run keeps its staged tree" ); assert!( incremental.join("host-files.list").is_file(), "the snapshot stays with the staged tree" ); assert!( !plain.join("tree-upper").exists(), "an interrupted plain run drops its staged tree" ); assert!( !plain.join("host-files.list").exists(), "the snapshot goes with the staged tree" ); } /// clear_dir removes content a plain `rm -rf` cannot: a root-owned /// 0700 directory (the shape of an overlay workdir left by a crashed /// build). Skipped when neither root nor working non-interactive sudo /// is available, since escalation is then legitimately impossible. #[test] fn clear_dir_removes_root_owned_subdirectories() { let is_root = unsafe { libc::geteuid() } == 0; if !is_root && !privileged_command("true", false) .status() .is_ok_and(|s| s.success()) { return; } let dir = tempfile::tempdir().unwrap(); let cleared = dir.path().join("chroot"); std::fs::create_dir_all(cleared.join("stale")).unwrap(); std::fs::write(cleared.join("stale").join("f"), "x").unwrap(); // Root-owned, non-traversable: the crashed-workdir shape. let _ = privileged_command("chmod", is_root) .arg("-R") .arg("0700") .arg(cleared.join("stale")) .status(); let _ = privileged_command("chown", is_root) .arg("-R") .arg("root:root") .arg(cleared.join("stale")) .status(); clear_dir(&cleared).expect("clear_dir removes root-owned leftovers"); let read = std::fs::read_dir(&cleared).unwrap(); assert_eq!(read.count(), 0, "the directory must be empty"); } /// The shared apt cache is keyed per series and chroot architecture /// (the foreign arch for qemu-binfmt builds, the host one /// otherwise), like the chroot tarball cache. #[test] fn apt_cache_dirs_are_keyed_per_series_and_arch() { assert_eq!( apt_cache_dir("noble", Some("riscv64")), PathBuf::from("/var/tmp/pkh/apt-cache/noble-riscv64") ); let native = apt_cache_dir("noble", None); assert_eq!( native, PathBuf::from(format!( "/var/tmp/pkh/apt-cache/noble-{}", crate::get_current_arch() )) ); assert_ne!(native, apt_cache_dir("sid", None), "per series"); } /// The apt configuration keeping downloaded packages lands in the /// chroot's apt.conf.d, so installs populate the shared cache. #[test] fn apt_keep_conf_lands_in_the_chroot() { let dir = tempfile::tempdir().unwrap(); let chroot = dir.path().join("chroot"); write_apt_keep_conf(&chroot).unwrap(); let conf = chroot.join("etc/apt/apt.conf.d").join(APT_KEEP_CONF); let content = fs::read_to_string(&conf).unwrap(); assert_eq!(content, "APT::Keep-Downloaded-Packages \"true\";\n"); // Idempotent: a second write does not fail or duplicate. write_apt_keep_conf(&chroot).unwrap(); assert_eq!(fs::read_to_string(&conf).unwrap(), content); } /// The ccache shim installs the four compiler symlinks pointing at /// the ccache binary, and reinstalls cleanly over an existing one. #[test] fn ccache_shim_installs_the_four_compiler_links() { let dir = tempfile::tempdir().unwrap(); let chroot = dir.path().join("chroot"); install_ccache_shim(&chroot).unwrap(); let shim_dir = chroot.join(CCACHE_SHIM_DIR.trim_start_matches('/')); for compiler in ["gcc", "g++", "cc", "c++"] { let target = fs::read_link(shim_dir.join(compiler)) .unwrap_or_else(|_| panic!("the {compiler} shim must be a symlink")); assert_eq!(target, Path::new("/usr/bin/ccache")); } // A second install refreshes the links instead of failing. install_ccache_shim(&chroot).unwrap(); assert!(fs::symlink_metadata(shim_dir.join("gcc")).is_ok()); } }