mod cross; /// Ephemeral (per-build) unshare contexts, including the process-global /// cleanup-hook registry drained by the SIGINT handler pub(crate) mod ephemeral; pub(crate) 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::{ AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier, MakeClassifier, MmdebstrapClassifier, QuiltClassifier, }; use crate::report::{BuildTarget, BuildView, TargetKind}; use std::error::Error; use std::path::{Path, PathBuf}; use std::sync::Arc; /// Build mode for the binary build #[derive(PartialEq)] pub enum BuildMode { /// Local build, directly on the context Local, } /// Phases of a binary build, announced to the [`BuildView`] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Phase { /// Downloading the chroot tarball (mmdebstrap) PreparingChroot, /// Extracting the chroot tarball ExtractingChroot, /// Device nodes, /proc bind mount, etc. FinalizingChroot, /// apt-get update UpdatingPackageLists, /// Installing build-essential & co InstallingEssentials, /// quilt push -a ApplyingPatches, /// --inject packages InjectingPackages, /// apt-get build-dep InstallingBuildDeps, /// debian/rules build Building, /// fakeroot debian/rules binary ProducingBinaries, /// Retrieving produced .deb files RetrievingArtifacts, } impl Phase { /// Human-readable label displayed in the status bar pub fn label(&self) -> &'static str { match self { Phase::PreparingChroot => "Preparing chroot", Phase::ExtractingChroot => "Extracting chroot", Phase::FinalizingChroot => "Finalizing chroot", Phase::UpdatingPackageLists => "Updating package lists", Phase::InstallingEssentials => "Installing essential packages", Phase::ApplyingPatches => "Applying patches", Phase::InjectingPackages => "Injecting packages", Phase::InstallingBuildDeps => "Installing build dependencies", Phase::Building => "Building package", Phase::ProducingBinaries => "Producing binary packages", Phase::RetrievingArtifacts => "Retrieving artifacts", } } } /// Default line classifier rewriting a phase's subprocess output fn default_classifier(phase: Phase) -> Box { match phase { Phase::PreparingChroot => Box::new(MmdebstrapClassifier::new()), Phase::ExtractingChroot | Phase::FinalizingChroot => Box::new(GenericClassifier::new()), Phase::UpdatingPackageLists => Box::new(AptUpdateClassifier::new()), Phase::InstallingEssentials => Box::new(AptInstallClassifier::new("Installing essentials")), Phase::ApplyingPatches => Box::new(QuiltClassifier::new(0)), Phase::InjectingPackages => Box::new(AptInstallClassifier::new("Injecting packages")), Phase::InstallingBuildDeps => { Box::new(AptInstallClassifier::new("Installing build dependencies")) } Phase::Building | Phase::ProducingBinaries => Box::new(MakeClassifier::new()), Phase::RetrievingArtifacts => Box::new(GenericClassifier::new()), } } /// Enter `phase` on the view with its default line classifier pub(crate) fn enter_phase(view: &dyn BuildView, phase: Phase) { view.phase(phase.label(), default_classifier(phase)); } /// Parameters of one [`build_binary_package`] call. pub struct DebBuildOptions<'a> { /// Target architecture; defaults to the host architecture. pub arch: Option, /// Target distribution series; defaults to the changelog series /// (UNRELEASED resolves to the vendor's development series). pub series: Option, /// Distribution pocket to resolve build-dependencies from. pub pocket: Option, /// Base URL the build environment resolves from (`pkh deb --mirror`): /// passed to mmdebstrap as the mirror positional, so the chroot /// tarball's apt sources carry it and every apt inside the build /// follows. A pinned runner (a caching proxy, a chosen mirror) uses /// it to resolve every package from one URL; the mirror must serve /// every suite and architecture the build resolves — the security /// pocket and, for a cross build, the target architecture's archive /// included. PPAs and other third-party sources are left alone. pub mirror: Option, /// Source tree to build; defaults to the process working directory. pub cwd: Option, /// Cross-compile for the target architecture instead of using /// qemu-binfmt. pub cross: bool, /// Build mode; defaults to [`BuildMode::Local`]. pub mode: Option, /// PPAs to add for build-dependencies (`user/ppa_name`). pub ppa: Vec, /// Packages to inject into the build environment before build-dep /// (.deb paths, archive names or PPA packages). pub inject: Vec, /// Parallel build jobs; defaults to the core count available in the /// build context. pub jobs: Option, /// Explicit build context; defaults to the current context. pub ctx: Option>, /// Incremental request (`pkh deb --incremental []`): which /// record to fork as the base of this build's staged tree, and /// whether this build's own staged tree is retained. Defaults to a /// plain run. pub incremental: Incremental, /// Reuse the shared ccache of this target (on by default; `pkh deb /// --no-ccache` disables it — an escape hatch for `debian/rules` /// whose own compiler handling conflicts with the shim). pub ccache: bool, /// Where build events (phases, progress, outcome) are reported. pub view: &'a dyn BuildView, } /// The `--incremental` request of a `pkh deb` build: which record the /// build forks its staged tree from, and whether its own staged tree is /// retained afterwards (the record's metadata and log are kept for /// every build either way). #[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum Incremental { /// A plain run (`pkh deb` without `--incremental`): build from /// scratch in a record whose staged tree is dropped at the end — /// metadata and log only remain. #[default] Off, /// `--incremental`: fork the auto-selected base — the newest /// successful record of this identity among the last five carrying /// a staged tree — and retain the new record's staged tree. With no /// candidate the build runs from scratch and its record becomes /// the identity's first base. Auto, /// `--incremental `: fork that exact record (unique id or /// prefix, restricted to the records of this host tree; a FAILED /// record is a valid base — the user pinned it deliberately) and /// retain the new record's staged tree. Id(String), } /// A view delegating every event to `inner` while teeing all captured /// subprocess lines into a build record's log through [`RecordLogSink`]: /// everything downstream (the chroot bootstrap, the build phases) asks /// the view for its sink, so one wrapper tees the whole run. struct RecordLogView<'a> { /// The caller's view. inner: &'a dyn BuildView, /// The record log sink, forwarding to the inner view's sink too. log: Arc, } impl BuildView for RecordLogView<'_> { fn target(&self, target: crate::report::BuildTarget) { self.inner.target(target); } fn phase(&self, name: &str, classifier: Box) { self.inner.phase(name, classifier); } fn message(&self, text: &str) { self.inner.message(text); } fn progress(&self, label: &str, pos: usize, total: usize) { self.inner.progress(label, pos, total); } fn sink(&self) -> Option> { Some(self.log.clone()) } fn finish_success(&self, artifacts: &[PathBuf]) { self.inner.finish_success(artifacts); } fn finish_failure(&self) { self.inner.finish_failure(); } fn suspend(&self) { self.inner.suspend(); } fn is_enabled(&self) -> bool { self.inner.is_enabled() } } impl Default for DebBuildOptions<'_> { fn default() -> Self { static QUIET: crate::report::Quiet = crate::report::Quiet; DebBuildOptions { arch: None, series: None, pocket: None, mirror: None, cwd: None, cross: false, mode: None, ppa: Vec::new(), inject: Vec::new(), jobs: None, ctx: None, incremental: Incremental::Off, ccache: true, view: &QUIET, } } } /// Build package in 'cwd' to a .deb /// /// Returns the list of produced artifacts (.deb files plus the upload /// metadata `.buildinfo`/`.changes`) retrieved locally, identified from /// `debian/files` and the native metadata generation rather than by /// globbing the build root (which would surface stale files). Subprocess /// output is captured through the view's sink (live view + tee log for the /// terminal adapter); on failure the view is cleared and prints a summary /// of captured errors. pub async fn build_binary_package( opts: DebBuildOptions<'_>, ) -> Result, Box> { let view = opts.view; let result = build_binary_package_impl(opts).await; if result.is_err() { view.finish_failure(); } result } /// Implementation of [`build_binary_package`], without failure handling async fn build_binary_package_impl( opts: DebBuildOptions<'_>, ) -> Result, Box> { let DebBuildOptions { ref arch, ref series, ref pocket, ref mirror, ref cwd, cross, ref mode, ref ppa, ref inject, ref jobs, ref ctx, ref incremental, ccache, view, } = opts; let cwd = cwd.as_deref().unwrap_or_else(|| Path::new(".")); // A mirror without a scheme cannot be an apt source URI: fail before // any chroot work, where the failure would surface as an opaque // apt-get update error. if let Some(mirror) = mirror && !mirror.contains("://") { return Err( format!("Invalid mirror '{mirror}': expected a base URL (scheme://host/path)").into(), ); } // Parse changelog to get package name, version and series let changelog_path = cwd.join("debian/changelog"); let (package, version, package_series) = crate::changelog::parse_changelog_header(&changelog_path)?; // UNRELEASED is not a real archive series: without an explicit --series, // build against the development series of the host vendor's distribution // instead. An explicit --series always wins. let mut series = if let Some(s) = series { s.to_string() } else if crate::distro_info::is_unreleased(&package_series) { let dist = crate::build::env::current_vendor(); let series = crate::distro_info::effective_series(&package_series, &dist).await?; log::info!( "Changelog is UNRELEASED, building against series {}", series ); series } else { package_series.clone() }; // A changelog (or an explicit --series) may carry a suite name instead of // the series codename: Debian conventionally writes 'unstable' where the // series data carries 'sid'. Resolve the alias so the codename identifies // the target everywhere downstream — the chroot bootstrap, the caches and // the distro-info lookups all key on codenames. if let Some((_, codename)) = crate::distro_info::resolve_suite_alias(&series) { log::info!("Suite '{}' resolved to series {}", series, codename); series = codename; } // A changelog may also target a pocket suite ('trixie-security') rather // than the bare series: split the pocket off so the build resolves // against the base series with that pocket enabled — the same target an // explicit '-s trixie -p security' names. An explicit --pocket wins. // The pocket must be one of the base series' distro-info pockets, which // also guards against series names merely containing a dash. let mut pocket = pocket.clone(); if pocket.is_none() && let Some((base, pocket_name)) = series .rsplit_once('-') .map(|(b, p)| (b.to_string(), p.to_string())) && let Ok(dist) = crate::distro_info::get_dist_from_series(&base).await && let Ok(pockets) = crate::distro_info::get_dist_pockets(&dist) && pockets.iter().any(|p| p.as_str() == pocket_name) { pocket = Some(pocket_name.clone()); series = base; log::info!( "Pocket suite resolved to series {} (pocket {})", series, pocket_name ); } let series = series.as_str(); let current_arch = crate::get_current_arch(); let arch = arch.as_deref().unwrap_or(¤t_arch); // Make sure we select a specific mode, either using user-requested // or by using default for user-supplied parameters let default_mode = BuildMode::Local; let mode = mode.as_ref().unwrap_or(&default_mode); // Create an ephemeral unshare context for all Local builds // Use qemu_binfmt when target architecture differs from host and cross is not requested let chroot_arch = if mode == &BuildMode::Local && arch != current_arch && !cross { Some(arch) } else { None }; // Use provided context or get current let base_ctx = ctx.clone().unwrap_or_else(context::current); // Identify the target in the live view once the changelog is parsed, so // even the chroot download output is attributed and tee'd view.target(BuildTarget { package: &package, version: &version, target: &format!("{series}/{arch}"), display: format!("Building {package} ({version}) for {series}/{arch}"), kind: TargetKind::Binary, tee_log: true, }); // 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. // // Build records are a local-only feature: with a local base context // every build runs in its own record under the sessions directory // (forked from a previous one with `--incremental`, thrown away // otherwise); 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 mut keep_upper = false; let session = if session_enabled { match resolve_record( incremental, &host_tree, &identity, &version, series, chroot_arch, mirror.as_deref(), ) { Ok((record, keep)) => { keep_upper = keep; Some(record) } Err(e) => { view.finish_failure(); return Err(e); } } } else { None }; // Tee every captured build line into the record's log: the wrapper // view hands the combined sink to everything downstream (the chroot // bootstrap, the build phases), so the log is a full tee whatever // renders the output. A view that consumes lines itself (the live // view on a terminal) is wrapped as-is; a view with NO sink (Quiet, // i.e. --verbose or a piped stdout) would have let the subprocess // inherit the terminal — the wrapper removes that fallback, so the // raw lines are written by a terminal sink instead, and --verbose // keeps showing raw output. A record-less build (non-local base // context) keeps the plain view. let record_log: Option, std::io::Error>> = session.as_ref().map(|s| { session::RecordLogSink::new( s, opts.view .sink() .or_else(|| Some(Arc::new(session::RawWriterSink::terminal()))), ) .map(Arc::new) }); let view: &dyn BuildView = match record_log { Some(Ok(log)) => &RecordLogView { inner: opts.view, log, }, Some(Err(e)) => { log::warn!("Cannot open the build record's log: {e}"); opts.view } None => opts.view, }; // The shared ccache of this target — skipped with --no-ccache. let ccache_dir = (session_enabled && ccache).then(|| session::ccache_dir(&identity)); let mirror_arg = mirror.as_deref(); let mut guard = if let Some(s) = session.clone() { Some( ephemeral::EphemeralContextGuard::new_for_session( s, series, chroot_arch, mirror_arg, base_ctx.clone(), view, keep_upper, ccache_dir.as_deref(), ) .await?, ) } else if *mode == BuildMode::Local { Some( ephemeral::EphemeralContextGuard::new_with_context( series, chroot_arch, mirror_arg, base_ctx.clone(), view, ) .await?, ) } else { None }; // Determine the build context explicitly: for Local builds it is the // ephemeral context the guard just created (taken from the guard itself, // never from the process-global, which concurrent builds may have // re-pointed at their own chroot); otherwise the base context is used // directly. let build_ctx = match guard.as_ref() { Some(g) => g.context(), None => base_ctx.clone(), }; // The measured durations of the build's work phases, recorded on the // record when the run ends — success or failure alike. let mut timings = session::BuildTimings::default(); let result = async { // 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. 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. If overlay staging is unavailable (no overlayfs, mount // failure, non-overlay driver), wipe the staged tree and // re-stage a fresh copy: copying over the previous attempt's // tree would merge stale build artifacts and resurrect // host-deleted files into the build. 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) => { log::info!( "Overlay staging unavailable ({}): the staged tree is \ re-staged from scratch, build artifacts are discarded", e ); s.wipe_staged_tree()?; let path = build_ctx.ensure_available(&parent_dir, &build_root)?; build_resume = false; crate::context::OverlayStaging { path, overlay: false, } } }; 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 // package/version/series above), so its staged copy wins; the // name-pattern search only runs as a fallback. let package_dir = resolve_package_directory( Path::new(&build_root), cwd, &package, &version, series, &build_ctx, )?; // Run the build using target build mode. It returns the exact set of // artifacts produced by this build (binary packages registered in // debian/files plus the generated .buildinfo/.changes), as paths // inside the build context. let remote_files: Vec = match mode { BuildMode::Local => { local::build( &package, &version, arch, series, pocket.as_deref(), mirror.as_deref(), &build_root, &package_dir, cross, ppa, inject, build_ctx.clone(), view, *jobs, session.as_ref(), build_resume, ccache, &mut timings, ) .await? } }; // Retrieve the produced artifacts (binary packages plus the upload // metadata) to the parent directory. enter_phase(view, Phase::RetrievingArtifacts); let total_debs = remote_files.len(); let mut artifacts = Vec::with_capacity(total_debs); for (idx, remote_file) in remote_files.iter().enumerate() { let file_name = remote_file.file_name().ok_or("Invalid remote filename")?; let local_dest = parent_dir.join(file_name); build_ctx.retrieve_path(remote_file, &local_dest)?; artifacts.push(local_dest); view.progress("Retrieving artifacts", idx + 1, total_debs); } view.finish_success(&artifacts); Ok(artifacts) } .await; // Clear the live view before returning: the ephemeral guard is dropped at // the end of this function and its cleanup commands (umount, rm -rf of // the chroot) inherit the terminal, so they must not fight the widget. view.suspend(); // Mark the build as successful for the guard's drop, which tears the // per-run chroot down and retains the record: metadata and log for // every build, the staged tree exactly for `--incremental` runs. if result.is_ok() && let Some(g) = &mut guard { g.mark_build_successful(); } // Every record's outcome is written — plain runs leave a metadata // record too. if result.is_err() && let Some(s) = &session { s.set_outcome(session::OUTCOME_FAILED); } // The measured durations land in the record whatever the outcome: a // failed build's partial timings are data too. Nothing measured (the // run ended before any timed command) records nothing. if let Some(s) = &session && !timings.is_empty() { s.set_timings(timings); } result } /// Resolve the record a build runs in, per the `--incremental` request, /// against the real sessions root and the chroot tarball the bootstrap /// would use today. See [`resolve_record_in`]. fn resolve_record( incremental: &Incremental, host_tree: &Path, identity: &session::SessionIdentity, version: &str, series: &str, chroot_arch: Option<&str>, mirror: Option<&str>, ) -> Result<(session::Session, bool), Box> { resolve_record_in( &session::sessions_root(), &session::cache_tarball_sha256(series, chroot_arch, mirror), incremental, host_tree, identity, version, ) } /// The testable core of [`resolve_record`] over an explicit sessions root /// and cached-tarball digest (the fork gate's reference). /// /// Every local build runs in its own record: [`Incremental::Off`] gets a /// brand-new one, [`Incremental::Auto`] and [`Incremental::Id`] fork the /// selected base. The returned flag says whether the record's staged /// tree is retained after the build — exactly the `--incremental` runs /// retain theirs. Metadata and the log are kept for every build either /// way. fn resolve_record_in( records_root: &Path, cache_tarball_sha256: &str, incremental: &Incremental, host_tree: &Path, identity: &session::SessionIdentity, version: &str, ) -> Result<(session::Session, bool), Box> { match incremental { Incremental::Off => Ok(( session::new_record_in(records_root, host_tree, identity, version)?, false, )), Incremental::Auto => { // A record locked by a live build cannot be a base: its // staged tree may be mid-mutation by the running build. let candidates: Vec = session::fork_candidates_in(records_root, identity) .into_iter() .filter(|s| !session::lock_is_held(&s.lock_path()).unwrap_or(true)) .take(session::KEEP_UPPERS) .collect(); for base in session::rank_base_candidates(candidates) { let why = if base.manifest().last_outcome == session::OUTCOME_SUCCESS { "the newest successful record of the target" } else { "no successful record among the last usable ones; the newest record" }; match session::fork_record_in( records_root, &base, host_tree, identity, version, cache_tarball_sha256, ) { Ok(record) => { log::info!( "Base of the incremental build: record {} ({why})", base.manifest().id ); return Ok((record, true)); } Err(session::SessionOpenError::Busy(busy)) => { log::info!("{busy}; trying the next candidate record"); } Err(e) => return Err(e.into()), } } log::info!( "No record of {identity} carrying a staged tree to fork; building \ from scratch — this run's record becomes the target's first base" ); Ok(( session::new_record_in(records_root, host_tree, identity, version)?, true, )) } Incremental::Id(id) => { let mut matches: Vec = session::by_id_prefix_in(records_root, id) .into_iter() .filter(|s| s.manifest().host_tree == host_tree.to_string_lossy()) .collect(); match matches.len() { 0 => Err(format!( "no build record matches id '{id}' for this tree \ (see `pkh deb list`)" ) .into()), 1 => { let base = matches.remove(0); log::info!( "Base of the incremental build: record {} (pinned by id)", base.manifest().id ); Ok(( session::fork_record_in( records_root, &base, host_tree, identity, version, cache_tarball_sha256, )?, true, )) } _ => { let found = matches .iter() .map(|s| s.manifest().id.clone()) .collect::>() .join(", "); Err(format!("ambiguous record id '{id}': matches {found}").into()) } } } } } /// Resolve the package directory for a build inside the staged build root. /// /// The tree the caller pointed at is authoritative: `cwd`'s changelog /// already defined the package, version and series for this build, so its /// staged copy is used outright when it carries a `debian/` tree. The /// name-pattern search ([`find_package_directory`], including the quirks /// overrides) only runs when that copy cannot be resolved — a default `.` /// cwd has no basename, and the pointed-at tree may live outside the staged /// parent. Embedded callers are the motivation: their working directory /// names (`tree`, `checkout`, ...) match none of the search patterns. pub(crate) fn resolve_package_directory( build_root: &Path, cwd: &Path, package: &str, version: &str, series: &str, ctx: &context::Context, ) -> Result> { if let Some(tree_name) = cwd.file_name() { let staged_tree = build_root.join(tree_name); if ctx.is_dir(&staged_tree)? && ctx.exists(&staged_tree.join("debian"))? { log::debug!( "Using the staged copy of {} at {}", cwd.display(), staged_tree.display() ); return Ok(staged_tree); } } find_package_directory(build_root, package, version, series, ctx) } /// Find the current package directory by trying both patterns: /// - package/package /// - package/package-origversion /// - custom directories from quirks configuration pub(crate) fn find_package_directory( parent_dir: &Path, package: &str, version: &str, series: &str, ctx: &context::Context, ) -> Result> { // Check quirks first for custom package directories let custom_dirs = crate::quirks::get_package_directories(package, series); for custom_dir in custom_dirs { let package_dir = parent_dir.join(&custom_dir); if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? { log::debug!( "Found package directory via quirks: {}", package_dir.display() ); return Ok(package_dir); } } // Try the current working directory name as a package directory // This is useful when the user is inside a non-standard package directory // e.g. ~/kernels/linux-riscv/linux-main -> tries "linux-main" if let Ok(cwd) = std::env::current_dir() && let Some(cwd_name) = cwd.file_name() { let cwd_name_str = cwd_name.to_string_lossy(); let package_dir = parent_dir.join(cwd_name_str.as_ref()); if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? { log::debug!( "Found package directory via CWD name: {}", package_dir.display() ); return Ok(package_dir); } } // Try package/package pattern first let package_dir = parent_dir.join(package).join(package); if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? { return Ok(package_dir); } // Compute origversion from version: remove everything after first '-', after stripping epoch let version_without_epoch = version.split_once(':').map(|(_, v)| v).unwrap_or(version); let origversion = version_without_epoch .split_once('-') .map(|(v, _)| v) .unwrap_or(version); // Try package/package-origversion pattern let package_dir = parent_dir .join(package) .join(format!("{}-{}", package, origversion)); if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? { return Ok(package_dir); } // Try 'package' only let package_dir = parent_dir.join(package); if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? { return Ok(package_dir); } // Try package-origversion only let package_dir = parent_dir.join(format!("{}-{}", package, origversion)); if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? { return Ok(package_dir); } // List all directories under 'package/' and log them let package_parent = parent_dir; if ctx.exists(package_parent)? { log::debug!( "Listing all directories under '{}':", package_parent.display() ); let entries = ctx.list_files(package_parent)?; let mut found_dirs = Vec::new(); for entry in entries { // list_files yields context-relative paths (e.g. rooted inside // the chroot for an unshare context): classify through the // context, a host-side stat would miss every entry. let is_dir = ctx.is_dir(&entry)?; log::debug!(" - {}", entry.display()); if is_dir && let Some(file_name) = entry.file_name() { found_dirs.push(file_name.to_string_lossy().into_owned()); } } // If we found directories but none matched our patterns, provide helpful error if !found_dirs.is_empty() { return Err(format!( "Could not find package directory for {} in {}. Found directories: {}", package, parent_dir.display(), found_dirs.join(", ") ) .into()); } } Err(format!( "Could not find package directory for {} in {}", package, parent_dir.display() ) .into()) } fn find_dsc_file( build_root: &str, package: &str, version: &str, ctx: &Arc, ) -> Result> { // Strip epoch from version (e.g., "1:2.3.4-5" -> "2.3.4-5") let version_without_epoch = version.split_once(':').map(|(_, v)| v).unwrap_or(version); let dsc_name = format!("{}_{}.dsc", package, version_without_epoch); let dsc_path = PathBuf::from(build_root).join(&dsc_name); // Check if the .dsc file exists in context if !ctx.exists(&dsc_path)? { return Err(format!("Could not find .dsc file at {}", dsc_path.display()).into()); } Ok(dsc_path) } /// Check whether an apt source URI points to a distribution archive /// (as opposed to a PPA or another third-party repository): a thin, /// dist-agnostic wrapper over [`crate::distro_info::is_official_source`], /// unioned across every known distro. Where the distribution is known, /// the dist-scoped check is preferred (it cannot misfire on another /// distro's mirror); this fallback stays for the sites that cannot know. pub(crate) fn is_archive_source(uri: &str) -> bool { crate::distro_info::supported_dists() .iter() .any(|dist| crate::distro_info::is_official_source(dist, uri)) } /// Whether `uri` counts as an archive source under a mirror pin: a /// distribution archive source, or already the pinned mirror URL itself /// — sources rewritten to it no longer carry an official host, but they /// are exactly the sources a previous pass or build pinned. pub(crate) fn is_archive_source_pinned(uri: &str, mirror: Option<&str>) -> bool { is_archive_source(uri) || mirror.is_some_and(|m| uri == m) } #[cfg(test)] mod tests { use super::*; use std::sync::Arc; /// The archive check is dist-agnostic (any distro's official mirror /// counts) and host-based: the country mirrors the old substring /// checks matched (`fr.archive.ubuntu.com`) still count, look-alike /// hosts and PPAs do not. #[test] fn archive_sources_are_official_mirrors_of_any_distro() { for uri in [ "https://archive.ubuntu.com/ubuntu", // Country mirrors front the Ubuntu archive. "http://fr.archive.ubuntu.com/ubuntu", "http://security.ubuntu.com/ubuntu", "http://ports.ubuntu.com/ubuntu-ports", "https://deb.debian.org/debian", ] { assert!(is_archive_source(uri), "{uri}"); } for uri in [ "https://ppa.launchpadcontent.net/user/ppa/ubuntu", "http://notarchive.ubuntu.com/ubuntu", "https://example.com/debian", ] { assert!(!is_archive_source(uri), "{uri}"); } } /// Under a mirror pin, the pinned URL itself counts as an archive /// source (a previous pass or build rewrote official sources to it); /// without a pin the check is the plain archive check. #[test] fn archive_sources_under_a_mirror_pin() { let mirror = "http://localhost:3142/ubuntu"; assert!(is_archive_source_pinned(mirror, Some(mirror))); assert!(is_archive_source_pinned( "http://archive.ubuntu.com/ubuntu", Some(mirror) )); assert!(!is_archive_source_pinned( "https://ppa.launchpadcontent.net/user/ppa/ubuntu", Some(mirror) )); assert!(!is_archive_source_pinned(mirror, None)); } /// An unshare context mapped over `chroot_root`, parented on a local /// context like the ephemeral build contexts are: exists/list_files/ /// is_dir answer through the path mapping, no namespace privileges /// needed. fn unshare_test_context(chroot_root: &Path) -> Context { let base = Context::new(crate::context::ContextConfig::Local).unwrap(); Context::with_parent( crate::context::ContextConfig::Unshare { path: chroot_root.to_string_lossy().to_string(), parent: None, }, Arc::new(base), ) } /// The staging-area listing must classify entries through the context: /// an unshare context returns build-root-relative paths that a host-side /// stat never sees (they live under the chroot root on the host), which /// used to silently empty the 'Found directories' list of the search /// failure message — and with it every hint about the actual layout. #[test] fn find_package_directory_lists_staged_directories_through_the_context() { let chroot = tempfile::tempdir().unwrap(); // Staged parent holding a single tree whose name matches none of // the search patterns (the embedded-caller layout: /tree) let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42"); std::fs::create_dir_all(staged_parent.join("tree/debian")).unwrap(); let ctx = unshare_test_context(chroot.path()); let err = find_package_directory( Path::new("/tmp/pkh-build-1/j-42"), "bc", "1.07.1-1ubuntu1", "questing", &ctx, ) .expect_err("no candidate matches a tree named 'tree'"); let message = err.to_string(); assert!( message.contains("Found directories: tree"), "error should list the staged directories through the context: {message}" ); } /// An explicit cwd must resolve to its staged copy even when its name /// matches none of the search patterns: the pointed-at tree is what the /// parsed changelog came from. #[test] fn resolve_package_directory_prefers_the_pointed_tree() { let chroot = tempfile::tempdir().unwrap(); let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42"); std::fs::create_dir_all(staged_parent.join("tree/debian/source")).unwrap(); let ctx = unshare_test_context(chroot.path()); let resolved = resolve_package_directory( Path::new("/tmp/pkh-build-1/j-42"), Path::new("/work/jobs/j-42/tree"), "bc", "1.07.1-1ubuntu1", "questing", &ctx, ) .expect("the staged copy of the pointed-at tree must resolve"); assert_eq!(resolved, PathBuf::from("/tmp/pkh-build-1/j-42/tree")); } /// When the pointed-at tree is not in the staging area under its own /// name, resolution falls back to the name-pattern search. #[test] fn resolve_package_directory_falls_back_to_the_name_search() { let chroot = tempfile::tempdir().unwrap(); let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42"); // Staged copy of a pulled tree: /- std::fs::create_dir_all(staged_parent.join("bc/bc-1.07.1/debian")).unwrap(); let ctx = unshare_test_context(chroot.path()); let resolved = resolve_package_directory( Path::new("/tmp/pkh-build-1/j-42"), // A tree never staged under that name Path::new("/work/other/checkout"), "bc", "1.07.1-1ubuntu1", "questing", &ctx, ) .expect("the pulled-tree layout must resolve via the name search"); assert_eq!( resolved, PathBuf::from("/tmp/pkh-build-1/j-42/bc/bc-1.07.1") ); } async fn test_build_end_to_end( package: &str, series: &str, dist: Option<&str>, arch: Option<&str>, cross: bool, ) { log::info!( "Starting end-to-end test for package: {} (series: {}, arch: {:?}, cross: {})", package, series, arch, cross ); let temp_dir = tempfile::tempdir().unwrap(); let cwd = temp_dir.path(); log::debug!("Created temporary directory: {}", cwd.display()); log::info!("Pulling package {} from {}...", package, series); let package_info = crate::package_info::lookup(package, None, Some(series), "", dist, None, None, None) .await .expect("Cannot lookup package information"); crate::pull::pull(&package_info, Some(cwd), None, true) .await .expect("Cannot pull package"); log::info!("Successfully pulled package {}", package); // Create a fresh local context for this test let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap()); // Change directory to the package directory let cwd = crate::deb::find_package_directory( cwd, package, &package_info.stanza.version, series, &ctx, ) .expect("Cannot find package directory"); log::debug!("Package directory: {}", cwd.display()); log::info!("Starting binary package build..."); crate::deb::build_binary_package(DebBuildOptions { arch: arch.map(str::to_string), series: Some(series.to_string()), cross, cwd: Some(cwd.to_path_buf()), ctx: Some(ctx), ..Default::default() }) .await .expect("Cannot build binary package (deb)"); log::info!("Successfully built binary package"); // Check that the .deb files are present let parent_dir = cwd.parent().expect("Cannot find parent directory"); let deb_files = std::fs::read_dir(parent_dir) .expect("Cannot read build directory") .filter_map(|entry| entry.ok()) .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "deb")) .collect::>(); log::info!("Found {} .deb files after build", deb_files.len()); for file in &deb_files { log::debug!(" - {}", file.path().display()); } assert!(!deb_files.is_empty(), "No .deb files found after build"); log::info!( "End-to-end test completed successfully for package: {}", package ); } // Tests no longer need to be 'serial' since each test uses its own // explicit context instead of shared global state. #[tokio::test] #[test_log::test] async fn test_deb_hello_ubuntu_end_to_end() { test_build_end_to_end("hello", "noble", None, None, false).await; } /// This ensures that we can cross-build packages #[tokio::test] #[test_log::test] #[cfg(target_arch = "x86_64")] async fn test_deb_hello_ubuntu_cross_end_to_end() { test_build_end_to_end("hello", "noble", None, Some("riscv64"), true).await; } /// This ensures that we can build packages from sid, even on older /// releases. It can sometimes be difficult with mmdebstrap issues /// for example. #[tokio::test] #[test_log::test] async fn test_deb_hello_debian_sid_end_to_end() { test_build_end_to_end("hello", "sid", None, None, false).await; } /// This is a specific test case for the linux-riscv package on Ubuntu /// It is important to ensure that pkh can cross-compile linux-riscv, as /// for risc-v hardware is still rare and cross-compilation is necessary /// to debug and test /// NOTE: Ideally, we want to run this in CI, but it takes more than 1h /// to fully build the linux-riscv package on an amd64 builder, which is too /// much time /// The series is the current LTS (26.04) rather than an interim one: /// interim series vanish from the mirrors a few months after their EOL /// (questing is already unreachable), an LTS stays pullable for years. #[ignore] #[tokio::test] #[test_log::test] #[cfg(target_arch = "x86_64")] async fn test_deb_linux_riscv_ubuntu_cross_end_to_end() { test_build_end_to_end("linux-riscv", "resolute", None, Some("riscv64"), true).await; } /// KNOWN-BROKEN cross build of the noble-era kernel, kept as an /// ignored fixture to work from. Noble controls declare their build /// tools unqualified (the `:native` idiom landed later), so exact dpkg /// semantics demand host-architecture instances of them /// (python3:riscv64, gcc-13:riscv64, clang-17:riscv64, ...) and the /// resulting two-architecture install set is unsolvable: t64 /// libraries (libclang1-17t64) conflict with their own foreign-arch /// variant, and the riscv64 toolchain instances drag depends chains /// (gcc:riscv64) that do not resolve from the chroot sources. A real /// run fails at the apt transaction with 'Unable to correct problems'. /// Resolute-era controls declare :native properly; see the test above. #[ignore] #[tokio::test] #[test_log::test] #[cfg(target_arch = "x86_64")] async fn test_deb_linux_riscv_noble_cross_end_to_end() { test_build_end_to_end("linux-riscv", "noble", None, Some("riscv64"), true).await; } /// This is a specific test case for the latest gcc package on Debian /// The GCC package is complex and hard to build, with specific stages /// and system-bound scripts. Building it requires specific things that /// we want to ensure are not broken. /// NOTE: Ideally, we want to run this in CI, but it takes more than 20h /// to fully build the gcc-15 package on an amd64 builder, which is too /// much time. #[ignore] #[cfg(target_arch = "x86_64")] #[tokio::test] #[test_log::test] async fn test_deb_gcc_debian_end_to_end() { test_build_end_to_end("gcc-15", "sid", None, None, false).await; } /// Create a synthetic source package that discriminates which architecture /// is used to resolve Build-Depends-Indep during cross builds: /// /// - 'libdb-dev' is an arch:any package that is not Multi-Arch: same, so an /// amd64 copy can only be installed by replacing the arm64 one /// - the arch-specific binary links against libdb for the host /// architecture, so the build only succeeds if the arm64 libdb-dev was /// left in place by the arch-independant build-dep pass fn create_indep_cross_test_source(parent: &Path) -> PathBuf { let pkg_dir = parent.join("pkh-crosstest"); std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap(); std::fs::write( pkg_dir.join("debian/changelog"), "pkh-crosstest (1.0) noble; urgency=medium\n\n \ * Synthetic package exercising Build-Depends-Indep in cross builds.\n\n \ -- pkh tests Tue, 15 Sep 2026 08:00:00 +0000\n", ) .unwrap(); std::fs::write( pkg_dir.join("debian/control"), "Source: pkh-crosstest\n\ Section: devel\n\ Priority: optional\n\ Maintainer: pkh tests \n\ Standards-Version: 4.7.4\n\ Build-Depends: debhelper-compat (= 13), libdb-dev\n\ Build-Depends-Indep: libdb-dev\n\ Architecture: any all\n\ \n\ Package: pkh-crosstest\n\ Architecture: any\n\ Depends: ${misc:Depends}, ${shlibs:Depends}\n\ Description: Cross-build regression package for build-dep resolution\n \ Builds a host-architecture binary against libdb to detect a cross\n \ build environment damaged by a wrongly-scoped build-dep pass.\n\ \n\ Package: pkh-crosstest-data\n\ Architecture: all\n\ Description: Cross-build regression package data (arch-indep)\n \ Arch-indep binary so the indep build path is exercised.\n", ) .unwrap(); std::fs::write( pkg_dir.join("debian/rules"), "#!/usr/bin/make -f\n\ %:\n\ \tdh $@\n\ \n\ override_dh_auto_build:\n\ \tprintf '#include \\nint main(void){DB *d; return db_create(&d, NULL, 0);}\\n' > main.c\n\ \t$(DEB_HOST_GNU_TYPE)-gcc main.c -ldb -o pkh-crosstest\n", ) .unwrap(); use std::os::unix::fs::PermissionsExt; let rules = pkg_dir.join("debian/rules"); let mut perms = std::fs::metadata(&rules).unwrap().permissions(); perms.set_mode(0o755); std::fs::set_permissions(&rules, perms).unwrap(); std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap(); pkg_dir } /// This ensures the arch-independant build-dep pass of a cross build /// resolves dependencies for the host architecture, like dpkg-checkbuilddeps /// does, instead of re-resolving the whole Build-Depends field for the /// native architecture, which swaps host-arch -dev packages for native ones /// and breaks the cross build environment. #[tokio::test] #[test_log::test] #[cfg(target_arch = "x86_64")] async fn test_deb_cross_indep_host_arch_end_to_end() { let temp_dir = tempfile::tempdir().unwrap(); let pkg_dir = create_indep_cross_test_source(temp_dir.path()); let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap()); crate::deb::build_binary_package(DebBuildOptions { arch: Some("arm64".to_string()), series: Some("noble".to_string()), cwd: Some(pkg_dir), cross: true, ctx: Some(ctx), ..Default::default() }) .await .expect("Cannot cross-build package declaring Build-Depends-Indep"); // Both binary packages must have been produced, including the // arch-independant one let deb_files: Vec = std::fs::read_dir(temp_dir.path()) .unwrap() .filter_map(|e| e.ok()) .map(|e| e.file_name().to_string_lossy().to_string()) .collect(); assert!( deb_files .iter() .any(|f| f.starts_with("pkh-crosstest_1.0_arm64.deb")), "arch-specific .deb not produced, got: {deb_files:?}" ); assert!( deb_files .iter() .any(|f| f.starts_with("pkh-crosstest-data_1.0_all.deb")), "arch-independant .deb not produced, got: {deb_files:?}" ); } /// A cross build whose build-dependencies cannot be satisfied under /// dpkg's cross semantics must abort at the install step, naming the /// unsatisfied dependency: the expected-failure counterpart of the /// cross builds above. #[tokio::test] #[test_log::test] #[cfg(target_arch = "x86_64")] async fn test_deb_cross_unsatisfiable_build_dep_fails_end_to_end() { let temp_dir = tempfile::tempdir().unwrap(); let pkg_dir = temp_dir.path().join("pkh-crosstest"); std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap(); std::fs::write( pkg_dir.join("debian/changelog"), "pkh-crosstest (1.0) noble; urgency=medium\n\n \ * Synthetic package declaring an unsatisfiable build-dependency.\n\n \ -- pkh tests Tue, 15 Sep 2026 08:00:00 +0000\n", ) .unwrap(); std::fs::write( pkg_dir.join("debian/control"), "Source: pkh-crosstest\n\ Section: devel\n\ Priority: optional\n\ Maintainer: pkh tests \n\ Standards-Version: 4.7.4\n\ Build-Depends: pkh-no-such-package-xyz\n\ Architecture: any\n\ \n\ Package: pkh-crosstest\n\ Architecture: any\n\ Depends: ${misc:Depends}, ${shlibs:Depends}\n\ Description: Cross-build negative regression package\n \ Declares a build-dependency absent from the archive.\n", ) .unwrap(); std::fs::write( pkg_dir.join("debian/rules"), "#!/usr/bin/make -f\n%:\n\tdh $@\n", ) .unwrap(); use std::os::unix::fs::PermissionsExt; let rules = pkg_dir.join("debian/rules"); let mut perms = std::fs::metadata(&rules).unwrap().permissions(); perms.set_mode(0o755); std::fs::set_permissions(&rules, perms).unwrap(); std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap(); let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap()); let err = crate::deb::build_binary_package(DebBuildOptions { arch: Some("arm64".to_string()), series: Some("noble".to_string()), cwd: Some(pkg_dir), cross: true, ctx: Some(ctx), ..Default::default() }) .await .expect_err("an unsatisfiable build-dependency must fail the cross build"); assert!( err.to_string().contains("pkh-no-such-package-xyz"), "error should name the unsatisfied dependency: {err}" ); } /// An embedded-caller layout — the tree checked out at /tree, a /// name matching none of the search patterns — must build: the staged /// copy of the tree the caller pointed at is resolved directly instead /// of being re-derived from package/version names (which used to fail /// with 'Could not find package directory'). #[tokio::test] #[test_log::test] async fn test_deb_builds_a_tree_named_directory_end_to_end() { let temp_dir = tempfile::tempdir().unwrap(); let pkg_dir = temp_dir.path().join("j-42/tree"); std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap(); std::fs::write( pkg_dir.join("debian/changelog"), "pkh-treetest (1.0) noble; urgency=medium\n\n \ * Synthetic package built from a directory named 'tree'.\n\n \ -- pkh tests Tue, 15 Sep 2026 08:00:00 +0000\n", ) .unwrap(); std::fs::write( pkg_dir.join("debian/control"), "Source: pkh-treetest\n\ Section: devel\n\ Priority: optional\n\ Maintainer: pkh tests \n\ Standards-Version: 4.7.4\n\ Build-Depends: debhelper-compat (= 13)\n\ Architecture: any\n\ \n\ Package: pkh-treetest\n\ Architecture: any\n\ Depends: ${misc:Depends}, ${shlibs:Depends}\n\ Description: Package-directory resolution regression package\n \ Its tree lives in a directory whose name matches none of the\n \ package-directory search patterns.\n", ) .unwrap(); std::fs::write( pkg_dir.join("debian/rules"), "#!/usr/bin/make -f\n%:\n\tdh $@\n", ) .unwrap(); use std::os::unix::fs::PermissionsExt; let rules = pkg_dir.join("debian/rules"); let mut perms = std::fs::metadata(&rules).unwrap().permissions(); perms.set_mode(0o755); std::fs::set_permissions(&rules, perms).unwrap(); std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap(); let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap()); crate::deb::build_binary_package(DebBuildOptions { series: Some("noble".to_string()), cwd: Some(pkg_dir), ctx: Some(ctx), ..Default::default() }) .await .expect("a tree named 'tree' must build"); let deb_files: Vec = std::fs::read_dir(temp_dir.path().join("j-42")) .unwrap() .filter_map(|e| e.ok()) .map(|e| e.file_name().to_string_lossy().to_string()) .collect(); assert!( deb_files .iter() .any(|f| f.starts_with("pkh-treetest_1.0_") && f.ends_with(".deb")), ".deb not produced for the 'tree'-named directory, got: {deb_files:?}" ); } // ------------------------------------------------------------------- // Record resolution (`--incremental` base selection) // ------------------------------------------------------------------- use crate::deb::session::record_fixtures::{ metadata_record, record_with_upper, sessions_fixture, test_identity, }; use crate::deb::session::{self, SessionIdentity}; /// The sha of the fixture's cached tarball (the fork gate's /// reference). fn cache_sha(tarball: &std::path::Path) -> String { session::sha256_file(tarball).unwrap() } /// A plain run resolves to a brand-new, lineage-free record whose /// staged tree will be dropped — including when no record exists at /// all yet. #[test] fn resolve_plain_run_builds_a_fresh_discardable_record() { let (root, tarball) = sessions_fixture(); let identity = test_identity(); let host_tree = root.path().join("tree"); let (record, keep_upper) = resolve_record_in( root.path(), &cache_sha(&tarball), &Incremental::Off, &host_tree, &identity, "1.0", ) .expect("a plain run always resolves to a fresh record"); assert!(!keep_upper, "a plain run drops its staged tree"); assert_eq!(record.manifest().base, None); assert!(!record.has_upper()); } /// The user scenario, end to end over the record layout: record A /// succeeds, a fork B of it fails — the default `--incremental` /// selection forks A again (the newest success), never the failed B. #[test] fn resolve_auto_forks_the_newest_success_after_a_failed_fork() { let (root, tarball) = sessions_fixture(); let identity = test_identity(); let host_tree = root.path().join("tree"); let a = record_with_upper( root.path(), &tarball, &host_tree, &identity, "1.0", Some(session::OUTCOME_SUCCESS), ); let a_id = a.manifest().id; let b = session::fork_record_in( root.path(), &a, &host_tree, &identity, "1.0", &cache_sha(&tarball), ) .unwrap(); let b_id = b.manifest().id; b.set_outcome(session::OUTCOME_FAILED); drop((a, b)); let (record, keep_upper) = resolve_record_in( root.path(), &cache_sha(&tarball), &Incremental::Auto, &host_tree, &identity, "1.0", ) .expect("the auto selection must fork a candidate record"); assert!(keep_upper); assert_eq!(record.manifest().base.as_deref(), Some(a_id.as_str())); assert_ne!(record.manifest().id, b_id); // The successful base's gate passes: its staged tree is carried. assert!(record.has_upper()); } /// A first `--incremental` run with no candidate builds completely /// fresh and retains its staged tree — it becomes the identity's /// first base. #[test] fn resolve_auto_without_candidates_builds_fresh_and_retains() { let (root, tarball) = sessions_fixture(); let identity = test_identity(); let host_tree = root.path().join("tree"); let (record, keep_upper) = resolve_record_in( root.path(), &cache_sha(&tarball), &Incremental::Auto, &host_tree, &identity, "1.0", ) .expect("no candidates must degrade to a fresh build"); assert!(keep_upper, "the first record becomes a base"); assert_eq!(record.manifest().base, None); } /// Records without a staged tree (plain runs, metadata-only ones) /// never make it into the selection: a newer successful /// metadata-only record loses to an older failed one carrying a /// staged tree. #[test] fn resolve_auto_skips_records_without_a_staged_tree() { let (root, tarball) = sessions_fixture(); let identity = test_identity(); let host_tree = root.path().join("tree"); let carrier = record_with_upper( root.path(), &tarball, &host_tree, &identity, "1.0", Some(session::OUTCOME_FAILED), ); let carrier_id = carrier.manifest().id; drop(carrier); // Newer and successful, but a plain run: no staged tree. drop(metadata_record( root.path(), &host_tree, &identity, "1.0", session::OUTCOME_SUCCESS, )); let (record, _) = resolve_record_in( root.path(), &cache_sha(&tarball), &Incremental::Auto, &host_tree, &identity, "1.0", ) .expect("the record carrying a staged tree is still the base"); assert_eq!(record.manifest().base.as_deref(), Some(carrier_id.as_str())); } /// `--incremental ` may pin a FAILED record — the user chose it /// deliberately and its staged tree may be perfectly fine. #[test] fn resolve_pinned_id_forks_a_failed_record() { let (root, tarball) = sessions_fixture(); let identity = test_identity(); let host_tree = root.path().join("tree"); let failed = record_with_upper( root.path(), &tarball, &host_tree, &identity, "1.0", Some(session::OUTCOME_FAILED), ); let failed_id = failed.manifest().id; drop(failed); let (record, keep_upper) = resolve_record_in( root.path(), &cache_sha(&tarball), &Incremental::Id(failed_id.clone()), &host_tree, &identity, "1.0", ) .expect("a pinned failed record is a valid base"); assert!(keep_upper); assert_eq!(record.manifest().base.as_deref(), Some(failed_id.as_str())); assert!(record.has_upper(), "the gate passes: sha and version match"); } /// A pinned id is restricted to the records of this host tree: an id /// recorded from another tree is refused. #[test] fn resolve_pinned_id_refuses_other_trees() { let (root, tarball) = sessions_fixture(); let identity = test_identity(); let host_tree = root.path().join("tree"); let other_tree = root.path().join("elsewhere"); let foreign = record_with_upper( root.path(), &tarball, &other_tree, &identity, "1.0", Some(session::OUTCOME_SUCCESS), ); let foreign_id = foreign.manifest().id; drop(foreign); let err = resolve_record_in( root.path(), &cache_sha(&tarball), &Incremental::Id(foreign_id), &host_tree, &identity, "1.0", ) .expect_err("records of other trees must not be forked by id"); assert!( err.to_string().contains("no build record matches id"), "wrong error: {err}" ); } /// An ambiguous id prefix is an error naming every match. #[test] fn resolve_pinned_id_refuses_ambiguous_prefixes() { let (root, tarball) = sessions_fixture(); let identity = test_identity(); let host_tree = root.path().join("tree"); drop(record_with_upper( root.path(), &tarball, &host_tree, &identity, "1.0", None, )); drop(record_with_upper( root.path(), &tarball, &host_tree, &identity, "1.0", None, )); // All record ids share the date-time prefix of their creation // second; anything shorter is ambiguous. let prefix = now_prefix(root.path(), &identity); let err = resolve_record_in( root.path(), &cache_sha(&tarball), &Incremental::Id(prefix), &host_tree, &identity, "1.0", ) .expect_err("an ambiguous prefix must be refused"); assert!( err.to_string().contains("ambiguous record id"), "wrong error: {err}" ); } /// The common date-time prefix of every record of the identity (the /// timestamp of the test-second they were all created in). fn now_prefix(root: &std::path::Path, identity: &SessionIdentity) -> String { session::identity_records_in(root, identity)[0] .manifest() .id .chars() .take(8) .collect() } /// A pinned record of another identity is refused: its staged tree /// was built for a different target. #[test] fn resolve_pinned_id_refuses_foreign_identities() { let (root, tarball) = sessions_fixture(); let identity = test_identity(); let other = SessionIdentity { arch: "riscv64".to_string(), ..identity.clone() }; let host_tree = root.path().join("tree"); let foreign = record_with_upper( root.path(), &tarball, &host_tree, &other, "1.0", Some(session::OUTCOME_SUCCESS), ); let foreign_id = foreign.manifest().id; drop(foreign); let err = resolve_record_in( root.path(), &cache_sha(&tarball), &Incremental::Id(foreign_id), &host_tree, &identity, "1.0", ) .expect_err("a foreign-identity record must not be forked"); assert!( err.to_string().contains("it was recorded for"), "wrong error: {err}" ); } /// The record-log view keeps the terminal alive for sink-less /// views: when the caller's view has no sink of its own (Quiet — /// `--verbose` or a piped stdout), the raw terminal sink takes its /// place, and every captured line lands BOTH in the record's /// build.log and on the writers (the terminal, in production). #[test] fn record_log_view_shows_raw_output_when_the_view_has_no_sink() { use crate::context::{LineSink, Stream}; use crate::deb::session::record_fixtures::SharedWriter; let (root, _tarball) = sessions_fixture(); let identity = test_identity(); let host_tree = root.path().join("tree"); let record = session::new_record_in(root.path(), &host_tree, &identity, "1.0").unwrap(); // The production wiring, with the terminal sink replaced by // injectable capture buffers: the Quiet view has no sink, so // the raw writer sink fills the fallback. let stdout = SharedWriter::new(); let stderr = SharedWriter::new(); let quiet = crate::report::Quiet; let fallback: Option> = quiet.sink().or_else(|| { Some(Arc::new(session::RawWriterSink::new( Box::new(stdout.clone()), Box::new(stderr.clone()), ))) }); let log = Arc::new(session::RecordLogSink::new(&record, fallback).unwrap()); let view = RecordLogView { inner: &quiet, log }; let sink = view .sink() .expect("the record-log view always provides one"); sink.line(Stream::Stdout, "raw stdout line"); sink.line(Stream::Stderr, "raw stderr line"); assert_eq!( stdout.contents(), "raw stdout line\n", "the fallback writer received the raw line" ); assert_eq!( stderr.contents(), "raw stderr line\n", "stderr lines reach the stderr writer" ); assert_eq!( std::fs::read_to_string(record.log_file()).unwrap(), "raw stdout line\nraw stderr line\n", "build.log keeps the full tee" ); } }