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