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:
+1
-1
@@ -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
@@ -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
@@ -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};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user