diff --git a/src/apt/release.rs b/src/apt/release.rs index 817317c..a990abd 100644 --- a/src/apt/release.rs +++ b/src/apt/release.rs @@ -1037,7 +1037,7 @@ fn crc24(data: &[u8]) -> u32 { for _ in 0..8 { crc <<= 1; if crc & 0x100_0000 != 0 { - crc ^= 0x1864_CFB; + crc ^= 0x0186_4CFB; } } } diff --git a/src/deb/ephemeral.rs b/src/deb/ephemeral.rs index 15cf213..9a6ddf1 100644 --- a/src/deb/ephemeral.rs +++ b/src/deb/ephemeral.rs @@ -1,13 +1,285 @@ use crate::context::{self, Context, ContextConfig}; use crate::ui::deb::{DebUi, Phase}; use directories::ProjectDirs; +use std::any::Any; use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; use tar::Archive; 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; + +/// 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> = 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 { + 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) { + 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::() { + 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 { + 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. pub struct EphemeralContextGuard { @@ -15,6 +287,9 @@ pub struct EphemeralContextGuard { 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, } impl EphemeralContextGuard { @@ -44,9 +319,39 @@ impl EphemeralContextGuard { 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 - Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), &ui) - .await?; + if let Err(e) = + 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 context::manager().set_current_ephemeral(Context::new(ContextConfig::Unshare { @@ -59,6 +364,7 @@ impl EphemeralContextGuard { chroot_path, build_succeeded: false, base_ctx, + cleanup_hook, }) } @@ -370,6 +676,15 @@ impl EphemeralContextGuard { impl Drop for EphemeralContextGuard { 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); // 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 { + 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) { + 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()); + } +} diff --git a/src/deb/mod.rs b/src/deb/mod.rs index b1a9a77..38b0311 100644 --- a/src/deb/mod.rs +++ b/src/deb/mod.rs @@ -1,5 +1,7 @@ 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; use crate::context::{self, Context}; diff --git a/src/ui/deb.rs b/src/ui/deb.rs index b988569..5810c44 100644 --- a/src/ui/deb.rs +++ b/src/ui/deb.rs @@ -563,6 +563,22 @@ extern "C" fn on_sigint(_sig: libc::c_int) { { 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 unsafe { libc::_exit(130);