diff --git a/src/deb/ephemeral.rs b/src/deb/ephemeral.rs index 687b092..7556b7d 100644 --- a/src/deb/ephemeral.rs +++ b/src/deb/ephemeral.rs @@ -1,4 +1,5 @@ use crate::context::{self, Context, ContextConfig}; +use crate::deb::session::{self, Session}; use crate::deb::{Phase, enter_phase}; use crate::interrupt::CleanupHookGuard; use crate::report::BuildView; @@ -21,9 +22,10 @@ use xz2::read::XzDecoder; // /proc and any overlayfs mounts. // --------------------------------------------------------------------------- -/// 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. +/// Interrupt-time teardown of a chroot tree. When `keep_tree` is set the +/// tree itself is left in place after unmounting (a resumable build +/// session: the interrupt is a pause, not a discard); otherwise this is +/// the historical full cleanup. /// /// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go /// through the context manager, the ephemeral context's driver (whose @@ -32,14 +34,14 @@ use xz2::read::XzDecoder; /// machineries may be mid-mutation on the interrupted thread. 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. +/// On a full cleanup it removes the tree 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) { +fn sigint_cleanup_chroot(chroot_path: &Path, keep_tree: bool) { let is_root = unsafe { libc::geteuid() } == 0; // Unmount children before parents: /proc/mounts lists mounts roughly in @@ -60,6 +62,14 @@ fn sigint_cleanup_chroot(chroot_path: &Path) { } } + if keep_tree { + log::debug!( + "Interrupted build session kept at {} (resume with `pkh deb --resume`)", + chroot_path.display() + ); + return; + } + // Remove the chroot tree itself (tolerates a missing directory). A // child the Ctrl+C interrupted may still be finishing its writeout — // dpkg defers SIGINT until it reaches a safe state — so retry while rm @@ -108,7 +118,7 @@ fn sigint_cleanup_chroot(chroot_path: &Path) { /// 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 { +pub(crate) fn privileged_command(program: &str, is_root: bool) -> Command { if is_root { Command::new(program) } else { @@ -118,10 +128,74 @@ fn privileged_command(program: &str, is_root: bool) -> Command { } } +/// Privileged `rm -rf` of a tree (residual chroots hold root-owned device +/// nodes); shared with the session removal in [`crate::deb::session`]. +pub(crate) fn privileged_remove(path: &Path) -> std::io::Result<()> { + let is_root = crate::utils::root::is_root().unwrap_or(false); + let status = privileged_command("rm", is_root) + .arg("-rf") + .arg(path) + .status() + .map_err(|e| std::io::Error::other(format!("failed to run rm: {e}")))?; + if status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!( + "rm -rf {} exited with {status}", + path.display() + ))) + } +} + +/// Remove the contents of `dir` but keep the directory itself. +fn wipe_dir_contents(dir: &Path) -> std::io::Result<()> { + fs::create_dir_all(dir)?; + for entry in fs::read_dir(dir)?.flatten() { + let path = entry.path(); + if path.is_dir() { + fs::remove_dir_all(&path)?; + } else { + fs::remove_file(&path)?; + } + } + Ok(()) +} + +/// Whether `path` is currently a mount point (according to /proc/mounts). +fn is_mountpoint(path: &Path) -> bool { + let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let Ok(mounts_text) = fs::read_to_string("/proc/mounts") else { + return false; + }; + mounts_text.lines().any(|line| { + line.split_whitespace() + .nth(1) + .map(|mp| unescape_mount_field(mp) == path.to_string_lossy()) + .unwrap_or(false) + }) +} + +/// The cached chroot tarball path for a series/arch, as used by the +/// bootstrap: the resume path compares it against the tarball recorded in +/// the session manifest. +pub(crate) fn chroot_tarball_path( + series: &str, + arch: Option<&str>, +) -> Result> { + let proj_dirs = + ProjectDirs::from("com", "pkh", "pkh").ok_or("Could not determine project directories")?; + let tarball_filename = if let Some(a) = arch { + format!("{}-{}-buildd.tar.xz", series, a) + } else { + format!("{}-buildd.tar.xz", series) + }; + Ok(proj_dirs.cache_dir().join(tarball_filename)) +} + /// 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 { +pub(crate) fn unmount_path(path: &Path, is_root: bool) -> bool { if privileged_command("umount", is_root) .arg(path) .status() @@ -138,7 +212,7 @@ fn unmount_path(path: &Path, is_root: bool) -> bool { /// 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 { +pub(crate) 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; @@ -185,6 +259,12 @@ fn unescape_mount_field(field: &str) -> String { /// An ephemeral unshare context guard that creates and manages a temporary chroot environment /// for building packages with unshare permissions. +/// +/// In session mode ([`EphemeralContextGuard::new_for_session`]) the chroot +/// lives inside a [`Session`]: a previous attempt's chroot is reused when +/// the caller verified it, teardown keeps the tree for a later `--resume`, +/// and a successful build removes the session unless `keep_on_success` is +/// set. pub struct EphemeralContextGuard { /// The ephemeral build context this guard created (an unshare context /// bound to the chroot, parented on the base context). Held explicitly so @@ -201,6 +281,10 @@ pub struct EphemeralContextGuard { /// 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, + /// The resumable build session this guard works in, when any. + session: Option, + /// Whether a successful build keeps the session (`pkh deb --keep`). + keep_on_success: bool, } impl EphemeralContextGuard { @@ -216,6 +300,53 @@ impl EphemeralContextGuard { arch: Option<&str>, base_ctx: Arc, view: &dyn BuildView, + ) -> Result> { + Self::new_internal(None, series, arch, base_ctx, view, false, false).await + } + + /// Create the ephemeral build context inside `session`'s chroot. + /// + /// # Arguments + /// * `session` - The resumable session owning the chroot (locked by the + /// caller) + /// * `series` - The distribution series (e.g., "noble", "sid") + /// * `arch` - Optional target architecture, as for + /// [`EphemeralContextGuard::new_with_context`] + /// * `base_ctx` - The base context to use for chroot work + /// * `reuse_chroot` - Adopt the previous attempt's bootstrapped chroot + /// instead of re-extracting the tarball; the caller must only set + /// this after the session's chroot passed the integrity probe + /// * `keep_on_success` - Keep the session after a successful build + /// (`pkh deb --keep`) + pub async fn new_for_session( + session: Session, + series: &str, + arch: Option<&str>, + base_ctx: Arc, + view: &dyn BuildView, + reuse_chroot: bool, + keep_on_success: bool, + ) -> Result> { + Self::new_internal( + Some(session), + series, + arch, + base_ctx, + view, + reuse_chroot, + keep_on_success, + ) + .await + } + + async fn new_internal( + session: Option, + series: &str, + arch: Option<&str>, + base_ctx: Arc, + view: &dyn BuildView, + reuse_chroot: bool, + keep_on_success: bool, ) -> Result> { // Save the globally-installed context so Drop can restore exactly // this handle: concurrent builds install their own ephemeral @@ -223,9 +354,16 @@ impl EphemeralContextGuard { // before this guard swapped anything in. let previous_context = context::current(); - // Create a temporary directory for the chroot - let chroot_path_str = base_ctx.create_temp_dir()?; - let chroot_path = PathBuf::from(chroot_path_str); + // In session mode the chroot lives inside the session root (and is + // reused when the caller says so); otherwise it is a fresh temp dir. + let chroot_path = match &session { + Some(s) => { + let path = s.chroot_path(); + fs::create_dir_all(&path)?; + path + } + None => PathBuf::from(base_ctx.create_temp_dir()?), + }; log::debug!( "Creating new chroot for {} (arch: {:?}) at {}...", @@ -236,16 +374,19 @@ impl EphemeralContextGuard { // Register the interrupt-time cleanup hook before any heavy work: if // the user hits Ctrl-C during bootstrap or the build itself, the - // interrupt watchdog unmounts and removes the chroot through this - // hook (see `sigint_cleanup_chroot`). This only works for a local + // interrupt watchdog unmounts the chroot's mounts through this hook + // (see `sigint_cleanup_chroot`). This only works for a local // base context: the hook must be self-contained (stored path + // direct umount/rm subprocesses) and cannot go through `base_ctx`. // For remote or nested bases the chroot lives elsewhere, and - // leftovers stay handled by `pkh prune` as before. + // leftovers stay handled by `pkh prune` as before. A session tree is + // kept on interrupt (a pause, not a discard); a plain temp chroot is + // still removed. let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) { Some(crate::interrupt::register_cleanup_hook(Box::new({ let chroot_path = chroot_path.clone(); - move || sigint_cleanup_chroot(&chroot_path) + let keep_tree = session.is_some(); + move || sigint_cleanup_chroot(&chroot_path, keep_tree) }))) } else { log::debug!( @@ -255,10 +396,18 @@ impl EphemeralContextGuard { None }; - // Download and extract the chroot tarball - if let Err(e) = - Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), view) - .await + // Download and extract the chroot tarball (or reuse the session's + // prepared chroot) + if let Err(e) = Self::download_and_extract_chroot( + series, + arch, + &chroot_path, + base_ctx.clone(), + view, + session.as_ref(), + reuse_chroot, + ) + .await { // On a Ctrl+C the interrupt watchdog owns the tree: keep the // hook registered (forgetting the guard) so it removes the @@ -300,6 +449,8 @@ impl EphemeralContextGuard { build_succeeded: false, base_ctx, cleanup_hook, + session, + keep_on_success, }) } @@ -319,7 +470,26 @@ impl EphemeralContextGuard { chroot_path: &PathBuf, ctx: Arc, view: &dyn BuildView, + session: Option<&Session>, + reuse_chroot: bool, ) -> Result<(), Box> { + // Session resume: the previous attempt's chroot passed the caller's + // checks — skip the tarball extraction entirely, the environment + // (apt lists, installed build-deps) is still in there. + if let Some(s) = session { + if reuse_chroot && s.chroot_is_usable() { + log::info!( + "Reusing the prepared chroot of session {} (bootstrap skipped)", + s.manifest().id + ); + Self::finalize_chroot(chroot_path, ctx, view)?; + return Ok(()); + } + // Not reusing (fresh session or failed probe): never extract + // over stale content. + wipe_dir_contents(chroot_path)?; + } + // Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it let ctx_for_devices = ctx.clone(); // Get project directories for caching @@ -394,16 +564,58 @@ impl EphemeralContextGuard { enter_phase(view, Phase::ExtractingChroot); Self::extract_tarball(&tarball_path, chroot_path, view)?; - // Create device nodes in the chroot - log::debug!("Creating device nodes in chroot..."); - enter_phase(view, Phase::FinalizingChroot); - Self::create_device_nodes(chroot_path, ctx_for_devices.clone())?; + // Create device nodes in the chroot and bind-mount /proc + Self::finalize_chroot(chroot_path, ctx_for_devices, view)?; + + // Record the bootstrap in the session: marker file (the resume + // integrity probe) and the tarball the chroot came from. + if let Some(s) = session { + std::fs::write(chroot_path.join(".pkh-ready"), b"")?; + let metadata = fs::metadata(&tarball_path)?; + let tarball_sha256 = session::sha256_file(&tarball_path) + .map_err(|e| format!("cannot hash the chroot tarball: {e}"))?; + s.set_chroot(session::ChrootInfo { + tarball: tarball_filename, + tarball_size: metadata.len(), + tarball_mtime_secs: metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0), + tarball_sha256, + ready: true, + }); + log::debug!("Session {} chroot is ready", s.manifest().id); + } + + Ok(()) + } + + /// Device nodes and the /proc bind mount for a (possibly reused) + /// chroot: idempotent finalization, safe to run on every attempt. + fn finalize_chroot( + chroot_path: &Path, + ctx: Arc, + view: &dyn BuildView, + ) -> Result<(), Box> { + if !chroot_path.join("dev/null").exists() { + // Create device nodes in the chroot + log::debug!("Creating device nodes in chroot..."); + enter_phase(view, Phase::FinalizingChroot); + Self::create_device_nodes(chroot_path, ctx.clone())?; + } // Bind mount /proc from host into chroot (before entering unshare namespace) - // This allows /proc to work in containers where mounting inside unshare fails - log::debug!("Bind-mounting /proc into chroot..."); - Self::bind_mount_proc(chroot_path, ctx_for_devices)?; - + // This allows /proc to work in containers where mounting inside unshare fails. + // A reused chroot may still carry the mount from the previous + // attempt: only mount when it is not already there. + if is_mountpoint(&chroot_path.join("proc")) { + log::debug!("/proc is already bind-mounted in the chroot"); + } else { + log::debug!("Bind-mounting /proc into chroot..."); + Self::bind_mount_proc(chroot_path, ctx)?; + } Ok(()) } @@ -617,6 +829,21 @@ impl EphemeralContextGuard { pub fn mark_build_successful(&mut self) { self.build_succeeded = true; } + + /// The staged build root inside the chroot: the stable + /// `/tmp/pkh-build` for session builds (the overlay upperdir mirrors + /// paths relative to the merge point, so the staged tree must live at + /// the same in-chroot path on every attempt for the build artifacts to + /// be reusable), a fresh timestamped temp dir otherwise. + pub fn build_root(&self) -> Result> { + match &self.session { + Some(s) => { + std::fs::create_dir_all(s.chroot_path().join("tmp/pkh-build"))?; + Ok(s.build_root()) + } + None => Ok(self.ephemeral_ctx.create_temp_dir()?), + } + } } impl Drop for EphemeralContextGuard { @@ -663,6 +890,46 @@ impl Drop for EphemeralContextGuard { // touches it. context::manager().set_current_ephemeral(self.previous_context.clone()); + // Session mode: the tree is a resumable build session. The /proc + // bind mount must come down in every case (a kept session has to be + // unmount-clean for the next resume); the tree itself stays unless + // a successful build without `--keep` consumes it. + if let Some(session) = &self.session { + let proc_path = self.chroot_path.join("proc"); + let is_root = crate::utils::root::is_root().unwrap_or(false); + let _ = if is_root { + self.base_ctx.command("umount").arg(&proc_path).status() + } else { + self.base_ctx + .command("sudo") + .arg("umount") + .arg(&proc_path) + .status() + }; + + if self.build_succeeded { + if self.keep_on_success { + session.set_outcome(session::OUTCOME_SUCCESS); + log::info!( + "Build session {} kept (resume with `pkh deb --resume`)", + session.manifest().id + ); + } else { + log::debug!( + "Build succeeded, removing build session {}", + session.root().display() + ); + session.remove(); + } + } else { + log::debug!( + "Build did not succeed: session {} kept (resume with `pkh deb --resume`)", + session.manifest().id + ); + } + return; + } + // Remove chroot directory only if build succeeded if self.build_succeeded { log::debug!( @@ -757,7 +1024,7 @@ mod chroot_cleanup_tests { 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); + sigint_cleanup_chroot(&missing, false); assert!(!missing.exists()); } @@ -780,7 +1047,7 @@ mod chroot_cleanup_tests { std::fs::create_dir_all(chroot.join("rootfs")).unwrap(); std::fs::write(chroot.join("rootfs").join("file.txt"), "data").unwrap(); - sigint_cleanup_chroot(&chroot); + sigint_cleanup_chroot(&chroot, false); assert!(!chroot.exists()); } diff --git a/src/deb/local.rs b/src/deb/local.rs index 2c436ae..3579672 100644 --- a/src/deb/local.rs +++ b/src/deb/local.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use crate::apt; use crate::deb::cross; +use crate::deb::session; use crate::debian::control::ControlInfo; use crate::debian::deps::{Deps, Facts, ParseOpts, PkgRelation}; @@ -26,6 +27,37 @@ fn cap<'a>( cmd } +/// The session journal stamps this build works with: the input stamps of +/// the patch and build-dependency phases, computed from the host tree and +/// the resolved selectors. +struct JournalStamps { + patches: Option, + control: Option, +} + +/// Compute the journal stamps for a session build; `None` without one. +fn journal_stamps( + session: &session::Session, + arch: &str, + series: &str, + pocket: Option<&str>, + cross: bool, + ppa: &[String], + inject_packages: &[String], +) -> JournalStamps { + let host_tree = PathBuf::from(session.manifest().host_tree); + let selectors = format!( + "{arch}\n{cross}\n{series}\n{}\n{}\n{}", + pocket.unwrap_or(""), + ppa.join(","), + inject_packages.join(",") + ); + JournalStamps { + patches: session::patches_stamp(&host_tree), + control: session::control_stamp(&host_tree, &selectors), + } +} + #[allow(clippy::too_many_arguments)] pub async fn build( package: &str, @@ -41,9 +73,15 @@ pub async fn build( ctx: Arc, view: &dyn BuildView, jobs: Option, + session: Option<&session::Session>, + resume: bool, ) -> Result, Box> { let sink: Option> = view.sink(); + // Journal stamps for a session build; recording is a no-op without one. + let stamps = + session.map(|s| journal_stamps(s, arch, series, pocket, cross, ppa, inject_packages)); + // Environment let mut env = HashMap::::new(); env.insert("LANG".to_string(), "C".to_string()); @@ -207,6 +245,9 @@ pub async fn build( or re-run with RUST_LOG=debug for more details." .into()); } + if let Some(s) = session { + s.record_phase("apt_update", None); + } // Install essential packages log::debug!("Installing essential packages for local build..."); @@ -231,6 +272,9 @@ pub async fn build( if !status.success() { return Err("Could not install essential packages for the build".into()); } + if let Some(s) = session { + s.record_phase("essentials", None); + } // The package directory was resolved by the caller (the staged copy of // the tree the user pointed at, or the name-pattern search fallback) @@ -249,8 +293,23 @@ pub async fn build( Err(e) => log::debug!("cannot read changelog for SOURCE_DATE_EPOCH: {}", e), } - // Apply quilt patches if the package provides a patch series - apply_quilt_patches(package_dir_str, &env, ctx.clone(), view, &sink)?; + // Apply quilt patches if the package provides a patch series. On a + // resumed session the previous attempt's patches may still be applied: + // pop them first when the patch tree changed since. + let patch_stamp = stamps.as_ref().and_then(|s| s.patches.clone()); + let pop_patches = resume && { + let previous = session + .and_then(|s| s.manifest().phases.get("patches").map(|p| p.stamp.clone())) + .unwrap_or(None); + let applied = ctx + .exists(&Path::new(package_dir_str).join(".pc/applied-patches")) + .unwrap_or(false); + applied && previous != patch_stamp + }; + apply_quilt_patches(package_dir_str, &env, ctx.clone(), view, &sink, pop_patches)?; + if let Some(s) = session { + s.record_phase("patches", patch_stamp); + } // Install injected packages if specified if !inject_packages.is_empty() { @@ -274,6 +333,12 @@ pub async fn build( view, &sink, )?; + if let Some(s) = session { + s.record_phase( + "build_deps", + stamps.as_ref().and_then(|st| st.control.clone()), + ); + } // Run the build step log::debug!("Building (debian/rules build) package..."); @@ -289,6 +354,20 @@ pub async fn build( if !status.success() { return Err("Error while building the package".into()); } + if let Some(s) = session { + s.record_phase("build", None); + } + + // On a resumed session the previous attempt's debian/files would make + // the artifact collection below surface its outputs: only the new + // attempt's registration may remain. + if resume { + let _ = ctx + .command("rm") + .arg("-f") + .arg(Path::new(package_dir_str).join("debian/files")) + .status(); + } // Run the 'binary' step to produce deb enter_phase(view, Phase::ProducingBinaries); @@ -334,6 +413,10 @@ pub async fn build( } } + if let Some(s) = session { + s.record_phase("binary", None); + } + Ok(artifacts) } @@ -843,13 +926,15 @@ fn generate_upload_metadata( } /// Apply quilt patches before building, if the package provides a -/// 'debian/patches/series' file +/// 'debian/patches/series' file. `pop_first` un-applies the previous +/// attempt's patches first (resumed session whose patch tree changed). fn apply_quilt_patches( package_dir: &str, env: &HashMap, ctx: Arc, view: &dyn BuildView, sink: &Option>, + pop_first: bool, ) -> Result<(), Box> { let series_path = Path::new(package_dir).join("debian/patches/series"); if !ctx.exists(&series_path)? { @@ -907,13 +992,33 @@ fn apply_quilt_patches( return Err("Could not install 'quilt', required to apply patches".into()); } + let mut patch_env = env.clone(); + patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string()); + + // Un-apply the previous attempt's patches when the patch tree changed: + // best-effort (a failing pop leaves the state quilt reports; the fresh + // `push -a` below then fails loudly on the actual problem). + if pop_first { + log::info!("Patch series changed since the previous attempt, un-applying patches"); + let status = cap( + ctx.command("quilt") + .current_dir(package_dir) + .envs(patch_env.clone()) + .arg("pop") + .arg("-a"), + sink, + ) + .status()?; + if !status.success() { + warn!("'quilt pop -a' failed; continuing with 'quilt push -a'"); + } + } + // Apply all patches listed in the series view.phase( Phase::ApplyingPatches.label(), Box::new(QuiltClassifier::new(total_patches)), ); - let mut patch_env = env.clone(); - patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string()); let status = cap( ctx.command("quilt") .current_dir(package_dir) @@ -1395,6 +1500,7 @@ mod tests { ctx, &crate::report::Quiet, &None, + false, ) .unwrap(); } diff --git a/src/deb/mod.rs b/src/deb/mod.rs index d4e8915..512bf94 100644 --- a/src/deb/mod.rs +++ b/src/deb/mod.rs @@ -3,6 +3,9 @@ mod cross; /// cleanup-hook registry drained by the SIGINT handler pub(crate) mod ephemeral; mod local; +/// Resumable build sessions: manifest, discovery, `pkh deb list` and the +/// host-tree snapshot/sync that keeps build artifacts alive across attempts +pub mod session; use crate::context::{self, Context}; use crate::logfmt::{ @@ -115,10 +118,33 @@ pub struct DebBuildOptions<'a> { pub jobs: Option, /// Explicit build context; defaults to the current context. pub ctx: Option>, + /// Resumable-session request: adopt a previous attempt's session + /// (`pkh deb --resume []`). Defaults to a fresh build, which + /// replaces the session of this identity without reading it. + pub resume: SessionResume, + /// Keep the session after a successful build (`pkh deb --keep`). + /// Without it a success consumes the session, as today's successful + /// build removes its chroot. + pub keep_session: bool, /// Where build events (phases, progress, outcome) are reported. pub view: &'a dyn BuildView, } +/// Which session a `pkh deb` build should adopt, if any. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum SessionResume { + /// No adoption: build from scratch, replacing the session of this + /// identity (recording stays on). + #[default] + Fresh, + /// Adopt the newest usable session recorded from this tree + /// (`pkh deb --resume`). + Latest, + /// Adopt the session with this id or unique id prefix + /// (`pkh deb --resume `). + Id(String), +} + impl Default for DebBuildOptions<'_> { fn default() -> Self { static QUIET: crate::report::Quiet = crate::report::Quiet; @@ -133,6 +159,8 @@ impl Default for DebBuildOptions<'_> { inject: Vec::new(), jobs: None, ctx: None, + resume: SessionResume::Fresh, + keep_session: false, view: &QUIET, } } @@ -175,6 +203,8 @@ async fn build_binary_package_impl( ref inject, ref jobs, ref ctx, + ref resume, + keep_session, view, } = opts; let cwd = cwd.as_deref().unwrap_or_else(|| Path::new(".")); @@ -233,7 +263,50 @@ async fn build_binary_package_impl( // Create an ephemeral unshare context for all Local builds. It is kept in // this scope so it outlives the guarded section below and is only dropped // once the live view has been cleared. - let mut guard = if *mode == BuildMode::Local { + // + // Resumable build sessions are a local-only feature: with a local base + // context the chroot lives in a session root (recording always on, + // adoption only when requested); every other base context keeps the + // historical anonymous temp chroot. + let session_enabled = *mode == BuildMode::Local + && matches!(base_ctx.config, crate::context::ContextConfig::Local); + let host_tree = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); + let identity = session::SessionIdentity { + package: package.clone(), + series: series.to_string(), + arch: arch.to_string(), + cross, + }; + + let session = if session_enabled { + match resolve_session(resume, &host_tree, &identity, &version, series, chroot_arch) { + Ok(session) => session, + Err(e) => { + view.finish_failure(); + return Err(e); + } + } + } else { + None + }; + let resume_chroot = session + .as_ref() + .is_some_and(|s| s.chroot_is_usable() && resume != &SessionResume::Fresh); + + let mut guard = if let Some(s) = session.clone() { + Some( + ephemeral::EphemeralContextGuard::new_for_session( + s, + series, + chroot_arch, + base_ctx.clone(), + view, + resume_chroot, + keep_session, + ) + .await?, + ) + } else if *mode == BuildMode::Local { Some( ephemeral::EphemeralContextGuard::new_with_context( series, @@ -258,16 +331,71 @@ async fn build_binary_package_impl( }; let result = async { - // Prepare build directory - let build_root = build_ctx.create_temp_dir()?; + // Prepare build directory: the stable /tmp/pkh-build for session + // builds (see EphemeralContextGuard::build_root — the overlay + // upperdir mirrors paths relative to the merge point), a fresh + // timestamped temp dir otherwise. + let build_root = match guard.as_ref() { + Some(g) => g.build_root()?, + None => build_ctx.create_temp_dir()?, + }; - // Ensure availability of all needed files for the build - let parent_dir = cwd.parent().ok_or("Cannot find parent directory")?; - build_ctx.ensure_available(parent_dir, &build_root)?; - let parent_dir_name = parent_dir - .file_name() - .ok_or("Cannot find parent directory name")?; - let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap()); + // Ensure availability of all needed files for the build. Session + // builds sync the persistent overlay upperdir with the host tree + // first (propagating host-side deletions and modifications into + // the upperdir) and re-mount it, so the previous attempt's build + // artifacts (object files) are visible to make; a cold staging + // (no snapshot, or no overlay support) stages a fresh copy. + let parent_dir = host_tree + .parent() + .ok_or("Cannot find parent directory")? + .to_path_buf(); + let mut build_resume = false; + if let Some(s) = &session { + build_resume = session::sync_upper_for_resume(s, &parent_dir)?; + if build_resume { + log::info!( + "Reusing the build artifacts of the previous attempt (incremental build)" + ); + } + } + let staged_path = if let Some(s) = &session { + // Re-mount the session's persistent upperdir over the host + // tree; a driver without overlay support (or a failed mount) + // falls back to a fresh copy — still environment-resumable, + // just not artifact-resumable. + let staging = match build_ctx.ensure_available_with_overlay( + &parent_dir, + &build_root, + &s.upper_dir(), + &s.work_dir(), + ) { + Ok(staging) => staging, + Err(e) if e.kind() == std::io::ErrorKind::Unsupported => { + let path = build_ctx.ensure_available(&parent_dir, &build_root)?; + crate::context::OverlayStaging { + path, + overlay: false, + } + } + Err(e) => return Err(e.into()), + }; + if !staging.overlay { + // Copy fallback: the staged tree is fresh, there is nothing + // incremental to build on. + build_resume = false; + } + staging.path + } else { + build_ctx.ensure_available(&parent_dir, &build_root)? + }; + // The staged parent (build root + workspace name) is where the + // build steps and the artifact collection work. + let build_root = staged_path.to_string_lossy().into_owned(); + + if let Some(s) = &session { + s.set_build_resume(build_resume); + } // Resolve the package directory inside the staging area. The tree // the caller pointed at is authoritative (its changelog defined the @@ -302,6 +430,8 @@ async fn build_binary_package_impl( build_ctx.clone(), view, *jobs, + session.as_ref(), + resume_chroot, ) .await? } @@ -333,16 +463,147 @@ async fn build_binary_package_impl( // the chroot) inherit the terminal, so they must not fight the widget. view.suspend(); - // Mark build as successful to trigger chroot cleanup + // Mark build as successful to trigger chroot cleanup. The guard's drop + // disposes of the session: removed on a plain success, kept with + // `--keep` (outcome recorded there). if result.is_ok() && let Some(ref mut g) = guard { g.mark_build_successful(); } + // Failures keep the session for a later `--resume`: record the outcome. + if result.is_err() + && let Some(s) = &session + { + s.set_outcome(session::OUTCOME_FAILED); + } result } +/// Resolve the session a build runs in. +/// +/// - [`SessionResume::Fresh`]: open (or create) the session of this +/// identity and reset it for a fresh attempt — the previous attempt's +/// content is replaced without being read. When a concurrent build holds +/// the session, build without one (the historical anonymous temp chroot). +/// - [`SessionResume::Latest`]: adopt the newest usable session recorded +/// from this tree whose identity matches this build; sessions for other +/// identities are a conflict (refused, never silently switched). +/// - [`SessionResume::Id`]: adopt the session matching the id (or unique +/// prefix) recorded from this tree. +/// +/// Returns the session handle, if any; `Ok(None, ..)` means "build without +/// a session". The bool says whether the previous attempt's chroot may be +/// reused (it passed the integrity probe and the bootstrap tarball still +/// matches). +fn resolve_session( + resume: &SessionResume, + host_tree: &Path, + identity: &session::SessionIdentity, + version: &str, + series: &str, + chroot_arch: Option<&str>, +) -> Result, Box> { + let usable = |s: &session::Session| { + s.chroot_is_usable() && session::tarball_matches(s, series, chroot_arch) + }; + match resume { + SessionResume::Fresh => match session::fresh_session(host_tree, identity, version) { + Ok(s) => Ok(Some(s)), + Err(session::SessionOpenError::Busy(busy)) => { + log::info!("{busy}; building without a session"); + Ok(None) + } + Err(session::SessionOpenError::Other(e)) => Err(e.into()), + }, + SessionResume::Latest => { + let candidates: Vec = session::for_tree(host_tree) + .into_iter() + .filter(|s| usable(s)) + .collect(); + match candidates + .iter() + .find(|s| s.manifest().identity == *identity) + { + Some(s) => Ok(Some(adopt_session(s, identity, version)?)), + None if candidates.is_empty() => { + log::info!( + "No resumable build session found for this tree; building from scratch" + ); + Ok(None) + } + None => { + let found = candidates + .iter() + .map(|s| s.manifest().identity.to_string()) + .collect::>() + .join(", "); + Err(format!( + "cannot resume: the sessions of this tree were built for {found}, \ + but this build targets {identity}. Use `pkh deb list` and \ + `pkh deb --resume `, or adjust the selectors" + ) + .into()) + } + } + } + SessionResume::Id(id) => { + let mut matches: Vec = session::by_id_prefix(id) + .into_iter() + .filter(|s| s.manifest().host_tree == host_tree.to_string_lossy()) + .filter(|s| usable(s)) + .collect(); + match matches.len() { + 0 => Err(format!( + "no usable build session matches id '{id}' for this tree \ + (see `pkh deb list`)" + ) + .into()), + 1 => Ok(Some(adopt_session(&matches.remove(0), identity, version)?)), + _ => { + let found = matches + .iter() + .map(|s| s.manifest().id.clone()) + .collect::>() + .join(", "); + Err(format!("ambiguous session id '{id}': matches {found}").into()) + } + } + } + } +} + +/// Lock a session for adoption and update its journal: outcome resets to +/// running, a changed changelog version drops the build artifacts (the +/// environment is kept), and the reuse decision for the chroot is made. +fn adopt_session( + session: &session::Session, + identity: &session::SessionIdentity, + version: &str, +) -> Result> { + session + .acquire() + .map_err(|busy| -> Box { format!("cannot resume: {busy}").into() })?; + let manifest = session.manifest(); + if manifest.tree_version != version { + log::info!( + "Version changed since the previous attempt ({}): build artifacts \ + are discarded, the environment is reused", + manifest.tree_version + ); + session.wipe_build_artifacts(); + session.set_tree_version(version); + } + session.set_outcome(session::OUTCOME_RUNNING); + log::info!( + "Resuming session {} for {identity}: the prepared chroot and \ + installed build dependencies are reused", + manifest.id + ); + Ok(session.clone()) +} + /// Resolve the package directory for a build inside the staged build root. /// /// The tree the caller pointed at is authoritative: `cwd`'s changelog diff --git a/src/deb/session.rs b/src/deb/session.rs new file mode 100644 index 0000000..a75902a --- /dev/null +++ b/src/deb/session.rs @@ -0,0 +1,1455 @@ +//! Resumable build sessions for `pkh deb`. +//! +//! A session is one (package, series, arch, cross) build environment plus +//! its phase journal, persisted under the sessions directory +//! (`/var/tmp/pkh/sessions//` by default) so a later +//! `pkh deb --resume` can adopt it: the bootstrapped chroot, the installed +//! build-dependencies and the build artifacts produced inside the staged +//! tree (object files, ...) survive between attempts. +//! +//! The layout of one session root: +//! +//! ```text +//! /var/tmp/pkh/sessions// +//! session.json # the manifest + phase journal +//! .lock # advisory lock (flock) marking a live build +//! host-files.list # snapshot of the host tree at staging time +//! chroot/ # the chroot tree (marker: .pkh-ready) +//! ``` +//! +//! Recording is always on for local builds (a plain `pkh deb` replaces the +//! session of its identity); reusing one is opt-in (`--resume`). This +//! module holds the manifest model, discovery, locking, the host-tree +//! snapshot used to propagate host-side deletions into the reused overlay +//! upperdir, and the `pkh deb list` rendering. The adoption policy lives in +//! [`crate::deb`]. + +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::os::unix::io::AsRawFd; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Manifest schema version: a session recorded by another schema is +/// ignored (and replaced by the next build) rather than adopted. +pub(crate) const SCHEMA_VERSION: u32 = 1; + +/// How long a session must be untouched before the default `pkh prune` +/// removes it. +pub const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +/// The build is running (`last_outcome`). +pub const OUTCOME_RUNNING: &str = "running"; +/// The last build adopted from this session succeeded (`last_outcome`). +pub const OUTCOME_SUCCESS: &str = "success"; +/// The last build adopted from this session failed (`last_outcome`). +pub const OUTCOME_FAILED: &str = "failed"; + +/// Marker file written inside the chroot once the bootstrap (extraction, +/// device nodes) fully succeeded: the integrity probe of a resume. +const CHROOT_READY_MARKER: &str = ".pkh-ready"; +/// Name of the manifest file inside a session root. +const MANIFEST_NAME: &str = "session.json"; +/// Name of the host-tree snapshot inside a session root. +const SNAPSHOT_NAME: &str = "host-files.list"; +/// Advisory lock file suffix next to a session root (`.lock`). +const LOCK_NAME: &str = "lock"; +/// Stable build root inside the chroot for session builds: the overlay +/// upperdir mirrors paths relative to the merge point, so the staged tree +/// must live at the same in-chroot path on every attempt for the object +/// files to be reusable. +pub(crate) const BUILD_ROOT: &str = "/tmp/pkh-build"; + +/// What a session was built for: the identity keys the environment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionIdentity { + /// Source package name. + pub package: String, + /// Distribution series the chroot was bootstrapped for. + pub series: String, + /// Target architecture. + pub arch: String, + /// Whether the build cross-compiles. + pub cross: bool, +} + +impl std::fmt::Display for SessionIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}/{} {}{}", + self.package, + self.series, + self.arch, + if self.cross { " (cross)" } else { "" } + ) + } +} + +/// Recorded state of the chroot tarball the session was bootstrapped from. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChrootInfo { + /// Tarball file name (e.g. `stonking-arm64-buildd.tar.xz`). + pub tarball: String, + /// Tarball size in bytes at bootstrap time. + pub tarball_size: u64, + /// Tarball modification time (Unix seconds) at bootstrap time. + pub tarball_mtime_secs: u64, + /// Tarball SHA-256 at bootstrap time. + pub tarball_sha256: String, + /// Whether the bootstrap (extract + device nodes) fully completed. + pub ready: bool, +} + +/// One journal entry: when a phase last completed, and its input stamp. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PhaseStamp { + /// RFC 3339 completion timestamp. + pub at: String, + /// Hash of the phase inputs (control content, patch tree, ...), when + /// the phase has one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stamp: Option, +} + +/// The session manifest: identity, journal and tree state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionManifest { + /// Manifest schema version ([`SCHEMA_VERSION`]). + pub schema: u32, + /// pkh version that last wrote the manifest. + pub pkh: String, + /// Session id: the build-start timestamp of the latest attempt + /// (`YYYYMMDDTHHMMSS`, UTC). + pub id: String, + /// Canonical host tree the session was recorded from; `pkh deb list` + /// and `--resume` scope sessions to this. + pub host_tree: String, + /// What the environment was built for. + pub identity: SessionIdentity, + /// RFC 3339 creation timestamp. + pub created: String, + /// RFC 3339 timestamp of the latest attempt. + pub last_used: String, + /// Outcome of the latest attempt ([`OUTCOME_RUNNING`]/[`OUTCOME_SUCCESS`]/[`OUTCOME_FAILED`]). + pub last_outcome: String, + /// Chroot bootstrap state. + pub chroot: ChrootInfo, + /// Phase journal: completed phases with their input stamps. + pub phases: BTreeMap, + /// Changelog version of the latest attempt: a version change between + /// attempts discards the build artifacts (upperdir), not the + /// environment. + pub tree_version: String, + /// Whether the latest attempt staged the tree over the reused overlay + /// upperdir (build-level resume was active). + pub build_resume: bool, +} + +/// State shared through the [`Session`] handle: the manifest plus the +/// advisory lock file held open for the lifetime of the handle. +struct SessionState { + manifest: SessionManifest, + lock: Option, +} + +/// A handle to one session root: manifest access, journal recording and +/// the paths of everything the build flow touches. Clones share the state. +#[derive(Clone)] +pub struct Session { + root: PathBuf, + state: Arc>, +} + +impl Session { + /// The session root directory. + pub fn root(&self) -> &Path { + &self.root + } + + /// A snapshot of the manifest. + pub fn manifest(&self) -> SessionManifest { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .manifest + .clone() + } + + /// The chroot directory inside the session root. + pub fn chroot_path(&self) -> PathBuf { + self.root.join("chroot") + } + + /// The persistent overlay upperdir (host-side path): build writes land + /// here and survive between attempts. + pub fn upper_dir(&self) -> PathBuf { + self.chroot_path().join("pkh-overlay/upper-build") + } + + /// The overlay workdir (host-side path). + pub fn work_dir(&self) -> PathBuf { + self.chroot_path().join("pkh-overlay/work-build") + } + + /// The host-tree snapshot file inside the session root. + pub(crate) fn snapshot_file(&self) -> PathBuf { + self.root.join(SNAPSHOT_NAME) + } + + /// The chroot-side path of the staged build root (stable across + /// attempts, see [`BUILD_ROOT`]). + pub fn build_root(&self) -> String { + BUILD_ROOT.to_string() + } + + /// Persist the manifest atomically (write to a temp file, rename). + pub fn save(&self) -> io::Result<()> { + let manifest = self.manifest(); + save_manifest(&self.root, &manifest) + } + + /// Record a phase completion in the journal and persist it. + pub fn record_phase(&self, phase: &str, stamp: Option) { + let entry = PhaseStamp { + at: now_rfc3339(), + stamp, + }; + self.with_manifest(|manifest| { + manifest.phases.insert(phase.to_string(), entry); + manifest.last_used = now_rfc3339(); + }); + } + + /// Record the outcome of the latest attempt and persist it. + pub fn set_outcome(&self, outcome: &str) { + self.with_manifest(|manifest| { + manifest.last_outcome = outcome.to_string(); + manifest.last_used = now_rfc3339(); + }); + } + + /// Record the chroot bootstrap state and persist it. + pub fn set_chroot(&self, info: ChrootInfo) { + self.with_manifest(|manifest| { + manifest.chroot = info; + }); + } + + /// Record the changelog version of this attempt. + pub fn set_tree_version(&self, version: &str) { + self.with_manifest(|manifest| { + manifest.tree_version = version.to_string(); + }); + } + + /// Record whether this attempt reuses the overlay upperdir. + pub fn set_build_resume(&self, value: bool) { + self.with_manifest(|manifest| { + manifest.build_resume = value; + }); + } + + /// Run `f` over the manifest and persist the result; failures are + /// logged and never abort the build (the journal is a cache). + fn with_manifest(&self, f: impl FnOnce(&mut SessionManifest)) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(&mut state.manifest); + if let Err(e) = save_manifest(&self.root, &state.manifest) { + log::warn!( + "Failed to save the build session manifest '{}': {}", + self.root.join(MANIFEST_NAME).display(), + e + ); + } + } + + /// Take the advisory lock marking a live build on this session. + /// + /// Fails with [`SessionBusy`] when another build holds it. The lock is + /// released when the handle (and every clone) drops. + /// + /// The lock file lives NEXT TO the session root (not inside it): a + /// teardown removes the root while still holding the lock, and a lock + /// file inside it would be deleted under the holder — a concurrent + /// opener would then create a fresh inode and lock nothing. + pub fn acquire(&self) -> Result<(), SessionBusy> { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.lock.is_some() { + return Ok(()); + } + let file = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(self.lock_path()) + .map_err(|e| SessionBusy { + root: self.root.clone(), + source: e.to_string(), + })?; + let taken = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if taken != 0 { + return Err(SessionBusy { + root: self.root.clone(), + source: io::Error::last_os_error().to_string(), + }); + } + state.lock = Some(file); + Ok(()) + } + + /// The advisory lock file path: `/.lock`, outside + /// the session root it guards (see [`Session::acquire`]). + fn lock_path(&self) -> PathBuf { + let name = self + .root + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + self.root.with_file_name(format!("{name}.{LOCK_NAME}")) + } + + /// Whether the chroot of this session passes the integrity probe + /// (bootstrap completed marker + a runnable `/bin/sh`). + pub fn chroot_is_usable(&self) -> bool { + let manifest = self.manifest(); + manifest.chroot.ready + && self.chroot_path().join(CHROOT_READY_MARKER).exists() + && self.chroot_path().join("bin/sh").exists() + } + + /// Wipe the persistent overlay upperdir and workdir (used when the + /// changelog version changed: object files of the old version must not + /// leak into the new build). + pub fn wipe_build_artifacts(&self) { + for dir in [self.upper_dir(), self.work_dir()] { + if let Err(e) = fs::remove_dir_all(&dir) + && e.kind() != io::ErrorKind::NotFound + { + log::warn!("Failed to remove '{}': {}", dir.display(), e); + } + } + } + + /// Remove the whole session tree (unmounting anything mounted under + /// it first). The lock file is deleted along with the root; the flock + /// stays held by this handle until it drops. + pub fn remove(&self) { + if let Err(e) = remove_session_dir(&self.root) { + log::warn!( + "Failed to remove build session {}: {}", + self.root.display(), + e + ); + } + } +} + +/// Error returned when a session is locked by another live build. +#[derive(Debug)] +pub struct SessionBusy { + /// The locked session root. + pub root: PathBuf, + /// Underlying error detail. + pub source: String, +} + +impl std::fmt::Display for SessionBusy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "build session {} is locked by another build", + self.root.display() + ) + } +} + +impl std::error::Error for SessionBusy {} + +/// The sessions directory: `$PKH_SESSIONS_DIR`, defaulting to +/// `/var/tmp/pkh/sessions`. +pub fn sessions_root() -> PathBuf { + if let Ok(dir) = std::env::var("PKH_SESSIONS_DIR") + && !dir.is_empty() + { + return PathBuf::from(dir); + } + PathBuf::from("/var/tmp/pkh/sessions") +} + +/// RFC 3339 UTC timestamp for the manifest fields. +fn now_rfc3339() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +/// Build-start timestamp used as session id (`YYYYMMDDTHHMMSS`, UTC). +fn now_session_id() -> String { + chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string() +} + +/// Sanitized, hash-suffixed directory name for an identity: human-readable +/// and unique without trusting timestamps. +fn slug_for(identity: &SessionIdentity) -> String { + let raw = format!( + "{}_{}_{}_{}", + identity.package, identity.series, identity.arch, identity.cross + ); + let sanitized: String = raw + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') { + c + } else { + '_' + } + }) + .collect(); + let mut hasher = Sha256::new(); + hasher.update(raw.as_bytes()); + let hash = hex(&hasher.finalize())[..8].to_string(); + format!("{sanitized}-{hash}") +} + +/// Lowercase hex encoding of a digest. +fn hex(digest: &[u8]) -> String { + digest.iter().map(|b| format!("{b:02x}")).collect() +} + +/// SHA-256 of a file, streamed. +pub(crate) fn sha256_file(path: &Path) -> io::Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + io::copy(&mut file, &mut hasher)?; + Ok(hex(&hasher.finalize())) +} + +/// SHA-256 stamp of a directory's content: one digest over every file's +/// relative path and bytes, walked in sorted order. `None` when the +/// directory does not exist. +pub(crate) fn dir_content_stamp(dir: &Path) -> Option { + let mut hasher = Sha256::new(); + let mut stack = vec![dir.to_path_buf()]; + let mut paths: Vec = Vec::new(); + while let Some(current) = stack.pop() { + let Ok(entries) = fs::read_dir(¤t) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + paths.push(path); + } + } + } + if paths.is_empty() { + return None; + } + paths.sort(); + for path in paths { + let rel = path.strip_prefix(dir).ok()?; + hasher.update(rel.to_string_lossy().as_bytes()); + hasher.update([0]); + if let Ok(content) = fs::read(&path) { + hasher.update(&content); + } + hasher.update([0]); + } + Some(hex(&hasher.finalize())) +} + +/// Input stamp of the quilt patch phase: a content digest of the host +/// tree's `debian/patches` directory. `None` when the tree carries none. +pub fn patches_stamp(host_tree: &Path) -> Option { + dir_content_stamp(&host_tree.join("debian/patches")) +} + +/// Input stamp of the build-dependency phase: the `debian/control` content +/// plus every selector that changes dependency resolution (arch, cross, +/// PPA list, injected packages, series, pocket). +pub fn control_stamp(host_tree: &Path, selectors: &str) -> Option { + let control = fs::read(host_tree.join("debian/control")).ok()?; + let mut hasher = Sha256::new(); + hasher.update(&control); + hasher.update([0]); + hasher.update(selectors.as_bytes()); + Some(hex(&hasher.finalize())) +} + +/// Persist a manifest atomically. +/// +/// A handle may outlive its session (the build removed it on success, or a +/// later attempt replaced it); such a save must not resurrect the +/// directory, so a vanished root is skipped. +fn save_manifest(root: &Path, manifest: &SessionManifest) -> io::Result<()> { + if !root.try_exists()? { + log::debug!( + "Session root '{}' is gone, skipping the manifest save", + root.display() + ); + return Ok(()); + } + let tmp = root.join(format!("{MANIFEST_NAME}.tmp")); + let content = serde_json::to_string_pretty(manifest) + .map_err(|e| io::Error::other(format!("cannot serialize the session manifest: {e}")))?; + fs::write(&tmp, content)?; + fs::rename(&tmp, root.join(MANIFEST_NAME)) +} + +/// Load one session from its root; `None` when there is no readable +/// current-schema manifest (the directory is then inert for discovery and +/// gets replaced by the next build). +fn open_from_dir(root: PathBuf) -> Option { + let content = fs::read_to_string(root.join(MANIFEST_NAME)).ok()?; + let manifest: SessionManifest = serde_json::from_str(&content).ok()?; + if manifest.schema != SCHEMA_VERSION { + return None; + } + Some(Session { + root, + state: Arc::new(Mutex::new(SessionState { + manifest, + lock: None, + })), + }) +} + +/// Error of the session setup paths. +#[derive(Debug)] +pub enum SessionOpenError { + /// The session is locked by a concurrent build. + Busy(SessionBusy), + /// Anything else (I/O, manifest errors). + Other(String), +} + +impl std::fmt::Display for SessionOpenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SessionOpenError::Busy(busy) => write!(f, "{busy}"), + SessionOpenError::Other(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for SessionOpenError {} + +/// Open the session directory of `identity` (whether or not a previous +/// attempt created it) and reset it for a fresh build: the previous +/// attempt's content is wiped (except the lock file) and a new manifest is +/// written. Fails with [`SessionOpenError::Busy`] when a concurrent build +/// holds the session. +pub fn fresh_session( + host_tree: &Path, + identity: &SessionIdentity, + tree_version: &str, +) -> Result { + let root = sessions_root().join(slug_for(identity)); + fs::create_dir_all(&root) + .map_err(|e| SessionOpenError::Other(format!("cannot create {}: {e}", root.display())))?; + + let session = match open_from_dir(root.clone()) { + Some(session) => session, + None => Session { + root: root.clone(), + state: Arc::new(Mutex::new(SessionState { + manifest: SessionManifest { + schema: SCHEMA_VERSION, + pkh: env!("CARGO_PKG_VERSION").to_string(), + id: now_session_id(), + host_tree: host_tree.to_string_lossy().into_owned(), + identity: identity.clone(), + created: now_rfc3339(), + last_used: now_rfc3339(), + last_outcome: OUTCOME_RUNNING.to_string(), + chroot: ChrootInfo { + tarball: String::new(), + tarball_size: 0, + tarball_mtime_secs: 0, + tarball_sha256: String::new(), + ready: false, + }, + phases: BTreeMap::new(), + tree_version: tree_version.to_string(), + build_resume: false, + }, + lock: None, + })), + }, + }; + + session.acquire().map_err(SessionOpenError::Busy)?; + + // Wipe the previous attempt (the lock file lives outside the root): a + // previous chroot holds root-owned device nodes, so escalate on + // failure, like the prune removal does. + for entry in fs::read_dir(&root) + .map_err(|e| SessionOpenError::Other(format!("cannot clear {}: {e}", root.display())))? + .flatten() + { + let path = entry.path(); + let result = if path.is_dir() { + fs::remove_dir_all(&path) + } else { + fs::remove_file(&path) + } + .or_else(|_| crate::deb::ephemeral::privileged_remove(&path)); + if let Err(e) = result { + log::warn!( + "Failed to clear the previous session file '{}': {}", + path.display(), + e + ); + } + } + + session.reset_fresh(host_tree, identity, tree_version); + Ok(session) +} + +impl Session { + /// Replace the manifest with a fresh one for a new attempt (the + /// session lock must be held; used by [`fresh_session`]). + fn reset_fresh(&self, host_tree: &Path, identity: &SessionIdentity, tree_version: &str) { + self.with_manifest(|manifest| { + *manifest = SessionManifest { + schema: SCHEMA_VERSION, + pkh: env!("CARGO_PKG_VERSION").to_string(), + id: now_session_id(), + host_tree: host_tree.to_string_lossy().into_owned(), + identity: identity.clone(), + created: manifest.created.clone(), + last_used: now_rfc3339(), + last_outcome: OUTCOME_RUNNING.to_string(), + chroot: ChrootInfo { + tarball: String::new(), + tarball_size: 0, + tarball_mtime_secs: 0, + tarball_sha256: String::new(), + ready: false, + }, + phases: BTreeMap::new(), + tree_version: tree_version.to_string(), + build_resume: false, + }; + }); + } +} + +/// Whether the cached chroot tarball the session was bootstrapped from is +/// still the one the current bootstrap would use (name, then size/mtime, +/// falling back to the recorded SHA-256 when the cheap check drifts). +pub(crate) fn tarball_matches(session: &Session, series: &str, arch: Option<&str>) -> bool { + let Ok(expected) = crate::deb::ephemeral::chroot_tarball_path(series, arch) else { + return false; + }; + let Ok(metadata) = fs::metadata(&expected) else { + return false; + }; + let info = &session.manifest().chroot; + if info.tarball + != expected + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() + { + return false; + } + let mtime_secs = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + if info.tarball_size == metadata.len() && info.tarball_mtime_secs == mtime_secs { + return true; + } + sha256_file(&expected).is_ok_and(|hash| hash == info.tarball_sha256) +} + +/// Load every discoverable session, newest first. +pub fn load_all() -> Vec { + let Ok(entries) = fs::read_dir(sessions_root()) else { + return Vec::new(); + }; + let mut sessions: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .filter_map(open_from_dir) + .collect(); + sessions.sort_by(|a, b| { + let a = a.manifest(); + let b = b.manifest(); + b.last_used.cmp(&a.last_used) + }); + sessions +} + +/// Sessions recorded from `tree` (any identity), newest first. +pub fn for_tree(tree: &Path) -> Vec { + let tree = tree.to_string_lossy().into_owned(); + load_all() + .into_iter() + .filter(|s| s.manifest().host_tree == tree) + .collect() +} + +/// Sessions whose id starts with `prefix` (the `pkh deb --resume ` +/// lookup). Ambiguous prefixes yield several results; the caller rejects +/// them. +pub fn by_id_prefix(prefix: &str) -> Vec { + load_all() + .into_iter() + .filter(|s| s.manifest().id.starts_with(prefix)) + .collect() +} + +/// One row of the `pkh deb list` table. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionListRow { + /// Session id (build-start timestamp of the latest attempt). + pub id: String, + /// Source package name. + pub package: String, + /// Changelog version of the latest attempt. + pub version: String, + /// `series/arch` target, with the cross marker folded into the arch. + pub target: String, + /// Outcome of the latest attempt. + pub outcome: String, + /// Human-readable age of the latest attempt. + pub age: String, +} + +/// The rows `pkh deb list` shows for a tree, newest first. +pub fn list_for_tree(tree: &Path) -> Vec { + for_tree(tree) + .into_iter() + .map(|session| { + let manifest = session.manifest(); + let cross = if manifest.identity.cross { "*" } else { "" }; + SessionListRow { + id: manifest.id, + package: manifest.identity.package, + version: manifest.tree_version, + target: format!( + "{}/{}{}", + manifest.identity.series, manifest.identity.arch, cross + ), + outcome: manifest.last_outcome, + age: age_of(&manifest.last_used), + } + }) + .collect() +} + +/// Human-readable age of an RFC 3339 timestamp ("2h", "3d"); "unknown" +/// when it cannot be parsed. +fn age_of(rfc3339: &str) -> String { + let Ok(then) = + chrono::DateTime::parse_from_rfc3339(rfc3339).map(|then| then.with_timezone(&chrono::Utc)) + else { + return "unknown".to_string(); + }; + let Ok(elapsed) = (chrono::Utc::now() - then).to_std() else { + return "now".to_string(); + }; + let secs = elapsed.as_secs(); + match secs { + 0 => "now".to_string(), + 1..=59 => format!("{secs}s"), + 60..=3599 => format!("{}m", secs / 60), + 3600..=86399 => format!("{}h", secs / 3600), + _ => format!("{}d", secs / 86400), + } +} + +/// Render the `pkh deb list` table. +pub fn render_session_list(tree: &Path, rows: &[SessionListRow]) -> String { + let mut out = String::new(); + if rows.is_empty() { + out.push_str(&format!( + "No build sessions recorded from {}.\n", + tree.display() + )); + return out; + } + out.push_str(&format!("Sessions for {}:\n", tree.display())); + let headers = ["ID", "PACKAGE", "VERSION", "TARGET", "LAST RUN", "AGE"]; + let table: Vec> = rows + .iter() + .map(|row| { + vec![ + row.id.clone(), + row.package.clone(), + row.version.clone(), + row.target.clone(), + row.outcome.clone(), + row.age.clone(), + ] + }) + .collect(); + let widths: Vec = headers + .iter() + .enumerate() + .map(|(i, header)| { + table + .iter() + .map(|row| row[i].chars().count()) + .max() + .unwrap_or(0) + .max(header.len()) + }) + .collect(); + let line = |cells: &[String]| { + cells + .iter() + .enumerate() + .map(|(i, cell)| format!("{cell:>() + .join(" ") + .trim_end() + .to_string() + }; + out.push_str(&line( + &headers.iter().map(|h| h.to_string()).collect::>(), + )); + out.push('\n'); + for row in &table { + out.push_str(&line(row)); + out.push('\n'); + } + out.push_str("\nResume with: pkh deb --resume [] (* = cross build)\n"); + out +} + +// --------------------------------------------------------------------------- +// Host-tree snapshot and overlay upperdir sync +// +// On resume the staged tree is re-mounted as an overlay over the host tree +// with the SAME upperdir as the previous attempt, so build artifacts written +// inside the chroot (object files) survive while host-side edits show +// through the live lowerdir. Two things do not propagate through a plain +// re-mount, and the snapshot fixes both: +// +// - host-side deletions: a file the user deleted on the host still appears +// in the merged view as long as the upperdir holds a copy of it (e.g. +// written by quilt); +// - host-side modifications of files the upperdir shadows: the stale upper +// copy would keep winning over the edited lower one. +// +// The snapshot records the host tree (metadata only) at staging time; the +// sync below drops upper entries for paths the host has since deleted or +// modified, keeps everything else (the build artifacts), and refreshes the +// snapshot for the next attempt. +// --------------------------------------------------------------------------- + +/// Metadata of one host-tree entry, for change detection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct EntryMeta { + /// Modification time in nanoseconds since the Unix epoch. + pub mtime_ns: i128, + /// Size in bytes (symlinks: target length). + pub size: u64, +} + +impl EntryMeta { + fn from_metadata(metadata: &fs::Metadata) -> Self { + let mtime_ns = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_nanos() as i128) + .unwrap_or(0); + EntryMeta { + mtime_ns, + size: metadata.len(), + } + } +} + +/// Walk a host tree, mapping slash-separated relative paths to metadata. +/// Symlinks are recorded as themselves (not followed). +pub(crate) fn walk_tree_meta(root: &Path) -> io::Result> { + let mut map = BTreeMap::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir)?.flatten() { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + if metadata.is_dir() { + stack.push(path); + } else { + let rel = path + .strip_prefix(root) + .map_err(|e| io::Error::other(format!("cannot relativize '{path:?}': {e}")))? + .to_string_lossy() + .into_owned(); + map.insert(rel, EntryMeta::from_metadata(&metadata)); + } + } + } + Ok(map) +} + +/// Write the snapshot file for `meta`. +pub(crate) fn write_snapshot(meta: &BTreeMap, path: &Path) -> io::Result<()> { + let mut content = String::new(); + for (rel, entry) in meta { + content.push_str(&format!("f\t{}\t{}\t{rel}\n", entry.mtime_ns, entry.size)); + } + fs::write(path, content) +} + +/// Load the snapshot file; `None` when it is missing or unreadable. +pub(crate) fn load_snapshot(path: &Path) -> Option> { + let content = fs::read_to_string(path).ok()?; + let mut map = BTreeMap::new(); + for line in content.lines() { + let mut fields = line.split('\t'); + let _kind = fields.next()?; + let mtime_ns = fields.next()?.parse::().ok()?; + let size = fields.next()?.parse::().ok()?; + let rel = fields.next()?.to_string(); + map.insert(rel, EntryMeta { mtime_ns, size }); + } + Some(map) +} + +/// Record the host tree snapshot for a fresh staging. +pub(crate) fn take_snapshot(host_parent: &Path, session: &Session) -> io::Result<()> { + let meta = walk_tree_meta(host_parent)?; + write_snapshot(&meta, &session.snapshot_file()) +} + +/// One kind of entry found in a persisted overlay upperdir. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpperKind { + /// Overlayfs whiteout (char device 0:0) hiding a lower entry. + Whiteout, + /// Directory. + Dir, + /// Regular file, symlink or any other non-directory entry. + File, +} + +/// One entry of the upperdir walk, relative to the merge root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct UpperEntry { + /// Slash-separated path relative to the merge root. + pub rel: String, + /// Entry kind. + pub kind: UpperKind, +} + +/// One change to apply to the upperdir before re-mounting it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum UpperOp { + /// Remove `rel` from the upperdir; `dir` removes the subtree. + Remove { rel: String, dir: bool }, +} + +/// Walk the upperdir (pre-order: parents before children), classifying +/// whiteouts. +pub(crate) fn walk_upper(upper_root: &Path) -> io::Result> { + let mut entries = Vec::new(); + let mut stack = vec![upper_root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir)?.flatten() { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + let rel = path + .strip_prefix(upper_root) + .map_err(|e| io::Error::other(format!("cannot relativize '{path:?}': {e}")))? + .to_string_lossy() + .into_owned(); + if metadata.file_type().is_char_device() && metadata.rdev() == 0 { + entries.push(UpperEntry { + rel, + kind: UpperKind::Whiteout, + }); + } else if metadata.is_dir() { + entries.push(UpperEntry { + rel: rel.clone(), + kind: UpperKind::Dir, + }); + stack.push(path); + } else { + entries.push(UpperEntry { + rel, + kind: UpperKind::File, + }); + } + } + } + Ok(entries) +} + +/// Pure core of the resume sync: given the host tree at staging time +/// (`snapshot`), the host tree now (`current`) and the upperdir contents, +/// decide which upper entries must go. The rules: +/// +/// - a whiteout is kept only when it still hides the file it hid at +/// staging time (present and unchanged on the host); a host modification +/// or re-creation must win; +/// - a directory the host deleted takes its whole upper subtree with it; +/// - an upper file whose host original was deleted or modified is dropped +/// (the build artifact cases — no host original — are kept). +pub(crate) fn plan_upper_sync( + snapshot: &BTreeMap, + current: &BTreeMap, + upper: &[UpperEntry], +) -> Vec { + let mut ops = Vec::new(); + for entry in upper { + match entry.kind { + UpperKind::Whiteout => { + // Keep only the still-accurate hiding of an unchanged host + // file; anything else (host edited, re-created or removed + // the path) makes the whiteout stale or wrong. + let still_accurate = snapshot + .get(&entry.rel) + .is_some_and(|staged| current.get(&entry.rel) == Some(staged)); + if !still_accurate { + ops.push(UpperOp::Remove { + rel: entry.rel.clone(), + dir: false, + }); + } + } + UpperKind::Dir => { + // A host-deleted directory removes its whole upper subtree + // (including build artifacts inside it). + if snapshot.contains_key(&entry.rel) && !current.contains_key(&entry.rel) { + ops.push(UpperOp::Remove { + rel: entry.rel.clone(), + dir: true, + }); + } + } + UpperKind::File => { + let Some(staged) = snapshot.get(&entry.rel) else { + continue; // build artifact: keep + }; + match current.get(&entry.rel) { + // Host deleted the file: the upper copy must not keep + // appearing in the merge. + None => ops.push(UpperOp::Remove { + rel: entry.rel.clone(), + dir: false, + }), + // Host modified the file: the upper copy would shadow + // the edit. + Some(now) if now != staged => ops.push(UpperOp::Remove { + rel: entry.rel.clone(), + dir: false, + }), + // Host unchanged: the upper copy carries the build's + // writes (e.g. quilt patches); keep it. + Some(_) => {} + } + } + } + } + ops +} + +/// Apply sync ops to the upperdir, skipping paths already covered by a +/// removed ancestor. Returns how many entries were actually removed. +pub(crate) fn apply_upper_sync(upper_root: &Path, ops: &[UpperOp]) -> io::Result { + let mut removed_prefixes: Vec = Vec::new(); + let mut removed = 0; + for op in ops { + let UpperOp::Remove { rel, dir } = op; + if removed_prefixes + .iter() + .any(|prefix| rel.starts_with(prefix.as_str())) + { + continue; + } + let path = upper_root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)); + let result = if *dir { + fs::remove_dir_all(&path) + } else { + fs::remove_file(&path) + }; + match result { + Ok(()) => { + removed += 1; + if *dir { + let mut prefix = rel.clone(); + if !prefix.ends_with('/') { + prefix.push('/'); + } + removed_prefixes.push(prefix); + } + } + Err(e) if e.kind() != io::ErrorKind::NotFound => { + return Err(e); + } + Err(_) => {} + } + } + Ok(removed) +} + +/// Sync the persistent upperdir with the current host tree before a resume +/// re-mount. Returns whether build-level resume is possible (a usable +/// snapshot existed and the upper was synced): without a snapshot the +/// upper is wiped and the next build stages cold, but safely. +pub(crate) fn sync_upper_for_resume(session: &Session, host_parent: &Path) -> io::Result { + let Some(snapshot) = load_snapshot(&session.snapshot_file()) else { + log::info!( + "No host-tree snapshot in the session: build artifacts are \ + discarded, the environment is still reused" + ); + session.wipe_build_artifacts(); + take_snapshot(host_parent, session)?; + return Ok(false); + }; + let current = walk_tree_meta(host_parent)?; + let upper_entries = walk_upper(&session.upper_dir()).unwrap_or_default(); + let ops = plan_upper_sync(&snapshot, ¤t, &upper_entries); + let removed = apply_upper_sync(&session.upper_dir(), &ops)?; + if removed > 0 { + log::debug!("Synced the session tree: {removed} stale upper entries dropped"); + } + write_snapshot(¤t, &session.snapshot_file())?; + Ok(true) +} + +// --------------------------------------------------------------------------- +// Removal (shared by `pkh deb` teardown and `pkh prune`) +// --------------------------------------------------------------------------- + +/// Remove a session root: unmount every mount under it (the /proc bind +/// mount, overlay filesystems), then `rm -rf` it (privileged when needed: +/// the chroot holds root-owned device nodes). +pub(crate) fn remove_session_dir(root: &Path) -> io::Result<()> { + let is_root = crate::utils::root::is_root().unwrap_or(false); + let mounts = crate::deb::ephemeral::host_mounts_under(root); + for mount in mounts.into_iter().rev() { + if !crate::deb::ephemeral::unmount_path(&mount, is_root) { + log::warn!( + "Failed to unmount {} under {}", + mount.display(), + root.display() + ); + } + } + crate::deb::ephemeral::privileged_remove(root) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta(mtime_ns: i128, size: u64) -> EntryMeta { + EntryMeta { mtime_ns, size } + } + + fn rel_map(entries: &[(&str, EntryMeta)]) -> BTreeMap { + entries + .iter() + .map(|(rel, m)| (rel.to_string(), *m)) + .collect() + } + + /// A build artifact (no host original in the snapshot) survives the + /// sync; a host-deleted or host-modified source file does not. + #[test] + fn plan_keeps_artifacts_and_drops_stale_upper_files() { + let staged = meta(1000, 10); + let snapshot = rel_map(&[("src/a.c", staged)]); + let current = rel_map(&[("src/b.c", staged)]); // a.c deleted, b.c added + let upper = vec![ + UpperEntry { + rel: "src/a.c".into(), + kind: UpperKind::File, + }, + UpperEntry { + rel: "src/b.o".into(), + kind: UpperKind::File, + }, + ]; + assert_eq!( + plan_upper_sync(&snapshot, ¤t, &upper), + vec![UpperOp::Remove { + rel: "src/a.c".into(), + dir: false, + }] + ); + } + + /// A host-modified file must not stay shadowed by its upper copy. + #[test] + fn plan_drops_upper_copy_of_modified_host_file() { + let snapshot = rel_map(&[("src/a.c", meta(1000, 10))]); + let current = rel_map(&[("src/a.c", meta(2000, 12))]); + let upper = vec![UpperEntry { + rel: "src/a.c".into(), + kind: UpperKind::File, + }]; + assert_eq!( + plan_upper_sync(&snapshot, ¤t, &upper), + vec![UpperOp::Remove { + rel: "src/a.c".into(), + dir: false, + }] + ); + } + + /// An unchanged host file keeps its upper copy (the build's writes, + /// e.g. quilt patches, stay applied across the resume). + #[test] + fn plan_keeps_upper_copy_of_unchanged_host_file() { + let staged = meta(1000, 10); + let snapshot = rel_map(&[("src/a.c", staged)]); + let current = rel_map(&[("src/a.c", staged)]); + let upper = vec![UpperEntry { + rel: "src/a.c".into(), + kind: UpperKind::File, + }]; + assert!(plan_upper_sync(&snapshot, ¤t, &upper).is_empty()); + } + + /// Whiteouts survive only when they still hide an unchanged host file; + /// a host re-creation (new metadata) drops them so the file shows. + #[test] + fn plan_keeps_accurate_whiteouts_only() { + let staged = meta(1000, 10); + let snapshot = rel_map(&[("src/a.c", staged)]); + let current = rel_map(&[("src/a.c", staged)]); + let upper = vec![UpperEntry { + rel: "src/a.c".into(), + kind: UpperKind::Whiteout, + }]; + assert!(plan_upper_sync(&snapshot, ¤t, &upper).is_empty()); + + let recreated = rel_map(&[("src/a.c", meta(5000, 99))]); + assert_eq!( + plan_upper_sync(&snapshot, &recreated, &upper), + vec![UpperOp::Remove { + rel: "src/a.c".into(), + dir: false, + }] + ); + + // A whiteout for a path that never existed at staging time (e.g. + // the VCS-dir pruning) is not in the snapshot: it must be kept + // only while the host still has the path — the .git case, where + // current has no entry because the walk skips nothing but the + // whiteout hides it; here the path is gone from the host, so the + // whiteout is stale. + let upper_git = vec![UpperEntry { + rel: ".git/config".into(), + kind: UpperKind::Whiteout, + }]; + assert_eq!( + plan_upper_sync(&snapshot, ¤t, &upper_git), + vec![UpperOp::Remove { + rel: ".git/config".into(), + dir: false, + }] + ); + } + + /// A host-deleted directory removes its whole upper subtree in one op. + #[test] + fn plan_removes_subtree_of_deleted_host_directory() { + let staged = meta(1000, 10); + let snapshot = rel_map(&[("src/old", staged)]); + let current = BTreeMap::new(); + let upper = vec![ + UpperEntry { + rel: "src/old".into(), + kind: UpperKind::Dir, + }, + UpperEntry { + rel: "src/old/x.o".into(), + kind: UpperKind::File, + }, + ]; + assert_eq!( + plan_upper_sync(&snapshot, ¤t, &upper), + vec![UpperOp::Remove { + rel: "src/old".into(), + dir: true, + }] + ); + } + + /// Applying ops removes the right entries; removals under an already + /// removed ancestor are skipped, and missing paths are tolerated. + #[test] + fn apply_removes_entries_and_dedupes_ancestors() { + let base = tempfile::tempdir().unwrap(); + let upper = base.path().join("upper"); + fs::create_dir_all(upper.join("src/old")).unwrap(); + fs::write(upper.join("src/a.c"), "x").unwrap(); + fs::write(upper.join("src/old/x.o"), "x").unwrap(); + fs::write(upper.join("gone.c"), "x").unwrap(); + + let ops = vec![ + UpperOp::Remove { + rel: "src/old".into(), + dir: true, + }, + UpperOp::Remove { + rel: "src/old/x.o".into(), + dir: false, + }, + UpperOp::Remove { + rel: "src/a.c".into(), + dir: false, + }, + UpperOp::Remove { + rel: "gone.c".into(), + dir: false, + }, + ]; + let removed = apply_upper_sync(&upper, &ops).unwrap(); + // src/old/x.o is covered by its removed ancestor; all three others + // (src/old, src/a.c, gone.c) are removed. + assert_eq!(removed, 3); + assert!(!upper.join("src/old").exists()); + assert!(!upper.join("src/a.c").exists()); + assert!(!upper.join("gone.c").exists()); + } + + /// The snapshot round-trips through its file format. + #[test] + fn snapshot_round_trips() { + let base = tempfile::tempdir().unwrap(); + let path = base.path().join("host-files.list"); + let meta_map = rel_map(&[ + ("tree/src/a.c", meta(12345, 7)), + ("orig.tar.xz", meta(1, 2)), + ]); + write_snapshot(&meta_map, &path).unwrap(); + assert_eq!(load_snapshot(&path), Some(meta_map)); + } + + /// Identity slugs are sanitized and collision-free, and the manifest + /// round-trips through JSON. + #[test] + fn slug_and_manifest_round_trip() { + let identity = SessionIdentity { + package: "linux/hacked".to_string(), + series: "stonking".to_string(), + arch: "arm64".to_string(), + cross: true, + }; + let slug = slug_for(&identity); + assert!( + slug.chars() + .all(|c| c.is_ascii_alphanumeric() || "_-.".contains(c)) + ); + assert_eq!(slug_for(&identity), slug); // stable + + let manifest = SessionManifest { + schema: SCHEMA_VERSION, + pkh: "0.1.0".to_string(), + id: "20260926T143505".to_string(), + host_tree: "/home/me/linux".to_string(), + identity: identity.clone(), + created: now_rfc3339(), + last_used: now_rfc3339(), + last_outcome: OUTCOME_FAILED.to_string(), + chroot: ChrootInfo { + tarball: "stonking-arm64-buildd.tar.xz".to_string(), + tarball_size: 1, + tarball_mtime_secs: 2, + tarball_sha256: "ab".to_string(), + ready: true, + }, + phases: BTreeMap::from([( + "apt_update".to_string(), + PhaseStamp { + at: now_rfc3339(), + stamp: None, + }, + )]), + tree_version: "7.3.0-5.6~local2".to_string(), + build_resume: true, + }; + let base = tempfile::tempdir().unwrap(); + save_manifest(base.path(), &manifest).unwrap(); + let loaded = open_from_dir(base.path().to_path_buf()).unwrap(); + assert_eq!(loaded.manifest(), manifest); + + // A foreign schema is not discoverable. + let mut foreign = manifest.clone(); + foreign.schema = SCHEMA_VERSION + 1; + save_manifest(base.path(), &foreign).unwrap(); + assert!(open_from_dir(base.path().to_path_buf()).is_none()); + } + + /// `pkh deb list` renders a table; the empty case explains itself. + #[test] + fn session_list_rendering() { + let rows = vec![ + SessionListRow { + id: "20260926T143505".to_string(), + package: "linux".to_string(), + version: "7.3.0-5.6~local2".to_string(), + target: "stonking/arm64*".to_string(), + outcome: OUTCOME_FAILED.to_string(), + age: "2h".to_string(), + }, + SessionListRow { + id: "20260925T090012".to_string(), + package: "linux".to_string(), + version: "7.3.0-5.5~local1".to_string(), + target: "stonking/riscv64*".to_string(), + outcome: OUTCOME_SUCCESS.to_string(), + age: "1d".to_string(), + }, + ]; + let rendered = render_session_list(Path::new("/home/me/linux"), &rows); + assert!( + rendered.contains("Sessions for /home/me/linux:"), + "{rendered}" + ); + assert!(rendered.contains("20260926T143505"), "{rendered}"); + assert!(rendered.contains("stonking/arm64*"), "{rendered}"); + assert!( + rendered.contains("Resume with: pkh deb --resume []"), + "{rendered}" + ); + + let empty = render_session_list(Path::new("/home/me/linux"), &[]); + assert!(empty.contains("No build sessions recorded"), "{empty}"); + } + + /// Ages render in the coarse units the list promises. + #[test] + fn age_rendering() { + let now = chrono::Utc::now(); + assert_eq!( + age_of(&now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)), + "now" + ); + let two_hours = + (now - chrono::Duration::hours(2)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + assert_eq!(age_of(&two_hours), "2h"); + let three_days = + (now - chrono::Duration::days(3)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + assert_eq!(age_of(&three_days), "3d"); + assert_eq!(age_of("not a date"), "unknown"); + } +}