deb: unmount and remove the ephemeral chroot on Ctrl-C

The SIGINT handler libc::_exit(130)s, skipping EphemeralContextGuard's
drop and leaking the freshly bootstrapped chroot with its bind-mounted
/proc and overlay mounts. Resources now register a self-contained
cleanup hook in a process-global registry that the handler drains right
before exiting: the hook unmounts everything under the chroot path
(children first, lazy fallback) and removes the tree, using only stored
paths and direct umount/rm subprocesses so it cannot deadlock on a lock
the interrupted thread may hold; sudo -n keeps it from ever hanging on
a password prompt. Drop deregisters the hook first, so the normal
cleanup path is unchanged.

Also fixes the hex grouping of the CRC-24 polynomial in apt::release.
This commit is contained in:
2026-09-16 01:55:35 +02:00
parent 93176aa479
commit 512a1cb778
4 changed files with 513 additions and 4 deletions
+1 -1
View File
@@ -1037,7 +1037,7 @@ fn crc24(data: &[u8]) -> u32 {
for _ in 0..8 { for _ in 0..8 {
crc <<= 1; crc <<= 1;
if crc & 0x100_0000 != 0 { if crc & 0x100_0000 != 0 {
crc ^= 0x1864_CFB; crc ^= 0x0186_4CFB;
} }
} }
} }
+493 -2
View File
@@ -1,13 +1,285 @@
use crate::context::{self, Context, ContextConfig}; use crate::context::{self, Context, ContextConfig};
use crate::ui::deb::{DebUi, Phase}; use crate::ui::deb::{DebUi, Phase};
use directories::ProjectDirs; use directories::ProjectDirs;
use std::any::Any;
use std::error::Error; use std::error::Error;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tar::Archive; use tar::Archive;
use xz2::read::XzDecoder; use xz2::read::XzDecoder;
// ---------------------------------------------------------------------------
// Process-global cleanup hooks
//
// On Ctrl-C, the SIGINT handler in `ui::deb` restores the terminal and then
// `libc::_exit(130)`s, skipping all destructors — including
// [`EphemeralContextGuard::drop`] — which leaks the freshly bootstrapped
// chroot under /tmp together with its bind-mounted /proc and any overlayfs
// mounts. To make interrupt-time cleanup possible anyway, resources register
// a self-contained cleanup hook here; the SIGINT handler drains and runs the
// registry right before exiting.
// ---------------------------------------------------------------------------
/// A boxed, send-safe cleanup hook body
type CleanupFn = Box<dyn Fn() + Send>;
/// A pending cleanup hook together with its registry id
struct CleanupHook {
id: u64,
f: CleanupFn,
}
/// Registry of cleanup hooks waiting to run at interrupt time
static CLEANUP_HOOKS: Mutex<Vec<CleanupHook>> = Mutex::new(Vec::new());
/// Source of the registry ids used to deregister a specific hook
static NEXT_CLEANUP_HOOK_ID: AtomicU64 = AtomicU64::new(1);
/// Register a hook to be run by [`run_cleanup_hooks`] (i.e. when the process
/// is interrupted), returning a guard whose drop deregisters the hook again
fn register_cleanup_hook(f: CleanupFn) -> CleanupHookGuard {
let id = NEXT_CLEANUP_HOOK_ID.fetch_add(1, Ordering::Relaxed);
CLEANUP_HOOKS.lock().unwrap().push(CleanupHook { id, f });
CleanupHookGuard(id)
}
/// RAII handle to a registered cleanup hook: dropping it (or an explicit
/// [`CleanupHookGuard::deregister`]) removes the hook from the registry so
/// the interrupt path can no longer run it
struct CleanupHookGuard(u64);
impl CleanupHookGuard {
/// Registry id of the hook (used to filter the registry in tests)
#[cfg(test)]
fn id(&self) -> u64 {
self.0
}
/// Remove the hook from the registry; returns whether it was still pending
fn deregister(&mut self) -> bool {
deregister_cleanup_hook(self.0)
}
}
impl Drop for CleanupHookGuard {
fn drop(&mut self) {
deregister_cleanup_hook(self.0);
}
}
/// Remove a hook from the registry; returns whether it was still pending
fn deregister_cleanup_hook(id: u64) -> bool {
let mut hooks = CLEANUP_HOOKS.lock().unwrap();
let len_before = hooks.len();
hooks.retain(|hook| hook.id != id);
hooks.len() != len_before
}
/// Drain and run every registered cleanup hook exactly once
///
/// Called from the SIGINT handler right before the process exits. Draining
/// uses `try_lock` with a bounded retry instead of a blocking lock: if the
/// signal interrupted the main thread while it held [`CLEANUP_HOOKS`] (inside
/// register/deregister), blocking on the same non-recursive mutex from the
/// handler would deadlock the process. Timing out therefore skips cleanup
/// (leaking, as before this registry existed) rather than hanging.
pub(crate) fn run_cleanup_hooks() {
run_drained_hooks(drain_cleanup_hooks());
}
/// Take every pending hook out of the registry, waiting at most ~1s for the
/// registry lock (see [`run_cleanup_hooks`] for why this must not block forever)
fn drain_cleanup_hooks() -> Vec<CleanupHook> {
const RETRIES: usize = 200;
const RETRY_DELAY: Duration = Duration::from_millis(5);
for _ in 0..RETRIES {
if let Ok(mut hooks) = CLEANUP_HOOKS.try_lock() {
return std::mem::take(&mut *hooks);
}
std::thread::sleep(RETRY_DELAY);
}
log::error!("Timed out waiting for the cleanup hook registry; skipping interrupt cleanup");
Vec::new()
}
/// Run drained hooks one by one, isolating panics so that one failing hook
/// cannot skip the remaining ones
fn run_drained_hooks(hooks: Vec<CleanupHook>) {
for CleanupHook { id, f } in hooks {
// Hooks are arbitrary user code; assert unwind safety so they can be
// run inside a catching context
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
log::error!("Cleanup hook {id} panicked: {}", panic_message(&panic));
}
}
}
/// Best-effort message extraction from a panic payload
fn panic_message(panic: &(dyn Any + Send)) -> String {
if let Some(s) = panic.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = panic.downcast_ref::<String>() {
s.clone()
} else {
"non-string panic payload".to_string()
}
}
// ---------------------------------------------------------------------------
// Interrupt-time chroot cleanup
// ---------------------------------------------------------------------------
/// Interrupt-time cleanup of an ephemeral chroot: unmount every host-side
/// mount at or below `chroot_path` (the /proc bind mount, any overlay mounts)
/// and then remove the directory tree.
///
/// 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: the signal may arrive while the interrupted thread holds any of
/// those mutexes, and re-locking them from the signal handler would deadlock.
/// Instead it only reads /proc/mounts and spawns umount/rm directly.
///
/// It also differs from `drop` in that it removes the chroot regardless of
/// the build result: the build was aborted, and leaving a still-mounted
/// chroot behind is exactly the leak this hook exists to prevent.
///
/// 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_chroot(chroot_path: &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
let mounts = host_mounts_under(chroot_path);
for mount_point in mounts.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()
);
}
}
// Remove the chroot tree itself (tolerates a missing directory)
let status = privileged_command("rm", is_root)
.arg("-rf")
.arg(chroot_path)
.status();
match status {
Ok(status) if status.success() => {
log::debug!(
"Removed chroot {} during interrupt cleanup",
chroot_path.display()
);
}
Ok(status) => {
log::error!(
"Failed to remove chroot {} during interrupt cleanup \
(rm exited with {status}); run `pkh prune`",
chroot_path.display()
);
}
Err(e) => {
log::error!(
"Failed to run rm for chroot {} during interrupt cleanup: {e}; run `pkh prune`",
chroot_path.display()
);
}
}
}
/// 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
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
}
}
/// 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
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)
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 /// An ephemeral unshare context guard that creates and manages a temporary chroot environment
/// for building packages with unshare permissions. /// for building packages with unshare permissions.
pub struct EphemeralContextGuard { pub struct EphemeralContextGuard {
@@ -15,6 +287,9 @@ pub struct EphemeralContextGuard {
chroot_path: PathBuf, chroot_path: PathBuf,
build_succeeded: bool, build_succeeded: bool,
base_ctx: Arc<Context>, 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>,
} }
impl EphemeralContextGuard { impl EphemeralContextGuard {
@@ -44,9 +319,39 @@ impl EphemeralContextGuard {
chroot_path.display() 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
// SIGINT handler unmounts and removes the chroot 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`, whose
// driver mutex may be held by the interrupted thread. For remote or
// nested bases the chroot lives elsewhere, and leftovers stay
// handled by `pkh prune` as before.
let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) {
Some(register_cleanup_hook(Box::new({
let chroot_path = chroot_path.clone();
move || sigint_cleanup_chroot(&chroot_path)
})))
} else {
log::debug!(
"Base context is not local; skipping interrupt-time cleanup registration for {}",
chroot_path.display()
);
None
};
// Download and extract the chroot tarball // Download and extract the chroot tarball
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), &ui) if let Err(e) =
.await?; Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), &ui)
.await
{
// The guard (and its Drop) never materializes on this path, so
// stop tracking the chroot for interrupt cleanup; as before, a
// failed bootstrap leaves its partial directory in place.
drop(cleanup_hook);
return Err(e);
}
// Switch to an ephemeral context to build the package in the chroot // Switch to an ephemeral context to build the package in the chroot
context::manager().set_current_ephemeral(Context::new(ContextConfig::Unshare { context::manager().set_current_ephemeral(Context::new(ContextConfig::Unshare {
@@ -59,6 +364,7 @@ impl EphemeralContextGuard {
chroot_path, chroot_path,
build_succeeded: false, build_succeeded: false,
base_ctx, base_ctx,
cleanup_hook,
}) })
} }
@@ -370,6 +676,15 @@ impl EphemeralContextGuard {
impl Drop for EphemeralContextGuard { impl Drop for EphemeralContextGuard {
fn drop(&mut self) { fn drop(&mut self) {
// 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); log::debug!("Cleaning up ephemeral context ({:?})...", self.chroot_path);
// Clean up any overlay mounts before resetting the context. // Clean up any overlay mounts before resetting the context.
@@ -453,3 +768,179 @@ impl Drop for EphemeralContextGuard {
} }
} }
} }
#[cfg(test)]
mod cleanup_registry_tests {
use super::*;
use std::sync::atomic::AtomicUsize;
/// Serializes these tests: they drain the process-global registry, and
/// unrelated tests (e.g. live end-to-end builds) may hold registrations
/// concurrently that must be neither run nor lost. Poison-proof: a test
/// failing while holding the lock must not cascade into the others.
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn test_lock() -> std::sync::MutexGuard<'static, ()> {
TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Drain the registry and take out only the hooks with the given ids,
/// putting everything else back so unrelated registrations (e.g. hooks of
/// live end-to-end builds running concurrently) stay pending
fn take_hooks(ids: &[u64]) -> Vec<CleanupHook> {
let drained = drain_cleanup_hooks();
let mut mine = Vec::new();
let mut others = Vec::new();
for hook in drained {
if ids.contains(&hook.id) {
mine.push(hook);
} else {
others.push(hook);
}
}
CLEANUP_HOOKS.lock().unwrap().extend(others);
mine
}
/// Register a hook that counts its invocations
fn counting_hook() -> (CleanupHookGuard, Arc<AtomicUsize>) {
let counter = Arc::new(AtomicUsize::new(0));
let seen = counter.clone();
let guard = register_cleanup_hook(Box::new(move || {
seen.fetch_add(1, Ordering::SeqCst);
}));
(guard, counter)
}
/// Hooks run in registration order, and draining means each hook runs
/// exactly once even across repeated cleanup passes.
#[test]
fn hooks_run_once_in_registration_order() {
let _serial = test_lock();
let log = Arc::new(Mutex::new(Vec::new()));
let mut guards = Vec::new();
let mut ids = Vec::new();
for name in ["hook-a", "hook-b", "hook-c"] {
let log = log.clone();
// The returned guard must stay alive: dropping it deregisters
let guard = register_cleanup_hook(Box::new(move || log.lock().unwrap().push(name)));
ids.push(guard.id());
guards.push(guard);
}
// Only our own hooks are extracted; they run in registration order
let mine = take_hooks(&ids);
assert_eq!(mine.len(), ids.len());
run_drained_hooks(mine);
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
// Draining removed them: a second pass runs nothing again
assert!(take_hooks(&ids).is_empty());
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
drop(guards);
}
/// A panicking hook is contained by the runner: it neither aborts the
/// process nor skips the hooks registered around it.
#[test]
fn panicking_hook_does_not_skip_the_others() {
let _serial = test_lock();
let (before, ran_before) = counting_hook();
let boom = register_cleanup_hook(Box::new(|| panic!("cleanup exploded")));
let (after, ran_after) = counting_hook();
let ids = [before.id(), boom.id(), after.id()];
run_drained_hooks(take_hooks(&ids));
assert_eq!(ran_before.load(Ordering::SeqCst), 1);
assert_eq!(ran_after.load(Ordering::SeqCst), 1);
}
/// Explicit deregistration removes the hook: it is no longer drained and
/// never runs; a second deregistration reports it as already gone.
#[test]
fn deregistered_hook_never_runs() {
let _serial = test_lock();
let (mut guard, ran) = counting_hook();
assert!(guard.deregister());
assert!(!guard.deregister());
assert!(take_hooks(&[guard.id()]).is_empty());
assert_eq!(ran.load(Ordering::SeqCst), 0);
}
/// Dropping the registration guard deregisters the hook implicitly.
#[test]
fn dropping_the_guard_deregisters_the_hook() {
let _serial = test_lock();
let id;
let ran;
{
let (guard, counter) = counting_hook();
id = guard.id();
ran = counter;
drop(guard);
}
assert!(take_hooks(&[id]).is_empty());
assert_eq!(ran.load(Ordering::SeqCst), 0);
}
/// /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());
}
/// 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());
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
mod cross; mod cross;
mod ephemeral; /// Ephemeral (per-build) unshare contexts, including the process-global
/// cleanup-hook registry drained by the SIGINT handler
pub(crate) mod ephemeral;
mod local; mod local;
use crate::context::{self, Context}; use crate::context::{self, Context};
+16
View File
@@ -563,6 +563,22 @@ extern "C" fn on_sigint(_sig: libc::c_int) {
{ {
eprintln!("\nInterrupted — full log: {}", path.display()); eprintln!("\nInterrupted — full log: {}", path.display());
} }
// Run the registered cleanup hooks (currently: unmount and remove the
// ephemeral build chroot, see `deb::ephemeral::sigint_cleanup_chroot`),
// then exit with the conventional 130 status.
//
// Like the terminal restoration above, this is NOT strictly
// async-signal-safe: it locks a mutex, spawns subprocesses and does I/O.
// That is a deliberate tradeoff, no worse than the rest of this handler:
// exiting immediately would skip all destructors and leak the chroot
// together with its bind-mounted /proc and overlay mounts. The hooks are
// self-contained (they only touch stored paths and spawn umount/rm
// directly), so they cannot deadlock on a lock the interrupted thread
// might have held; the hook registry itself is only ever taken with
// try_lock plus a bounded retry for the same reason. Note that SIGINT
// stays blocked for the duration of the handler, so a second Ctrl-C will
// not interrupt a slow cleanup — send SIGTERM/SIGKILL if it ever hangs.
crate::deb::ephemeral::run_cleanup_hooks();
// SAFETY: raw exit bypassing destructors, intended in a signal handler // SAFETY: raw exit bypassing destructors, intended in a signal handler
unsafe { unsafe {
libc::_exit(130); libc::_exit(130);