Files
pkh/src/deb/ephemeral.rs
T
vhaudiquet 8ab69991ce
CI / build (push) Successful in 6m25s
CI / test (push) Skipped
CI / publish (push) Skipped
CI / snap (push) Successful in 12m9s
fmt: apply rustfmt
2026-10-01 16:41:28 +02:00

1610 lines
63 KiB
Rust

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<PathBuf, Box<dyn Error>> {
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 `<series>-<arch>` 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/<series>-<arch>/`, 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<context::Context>) {
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<PathBuf> {
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<Context>,
/// 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<Context>,
chroot_path: PathBuf,
build_succeeded: bool,
base_ctx: Arc<Context>,
/// 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<CleanupHookGuard>,
/// The build record this guard works in, when any.
session: Option<Session>,
/// 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<fs::File>,
}
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<Context>,
view: &dyn BuildView,
) -> Result<Self, Box<dyn Error>> {
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<Context>,
view: &dyn BuildView,
keep_upper: bool,
ccache_dir: Option<&Path>,
) -> Result<Self, Box<dyn Error>> {
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<Session>,
series: &str,
arch: Option<&str>,
mirror: Option<&str>,
base_ctx: Arc<Context>,
view: &dyn BuildView,
keep_upper: bool,
ccache_dir: Option<&Path>,
) -> Result<Self, Box<dyn Error>> {
// 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<dyn Fn() + Send> = 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<Context> {
Arc::clone(&self.ephemeral_ctx)
}
async fn download_and_extract_chroot(
series: &str,
arch: Option<&str>,
mirror: Option<&str>,
chroot_path: &PathBuf,
ctx: Arc<context::Context>,
view: &dyn BuildView,
ccache_dir: Option<&Path>,
) -> Result<Option<fs::File>, Box<dyn Error>> {
// 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<context::Context>,
view: &dyn BuildView,
ccache_dir: Option<&Path>,
) -> Result<Option<fs::File>, Box<dyn Error>> {
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<context::Context>,
) -> Option<fs::File> {
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<context::Context>,
view: &dyn BuildView,
) -> Result<(), Box<dyn Error>> {
// 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<dyn Error>> {
// 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<context::Context>,
) -> Result<(), Box<dyn Error>> {
// 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<context::Context>,
) -> Result<(), Box<dyn Error>> {
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<String, Box<dyn Error>> {
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());
}
}