Files
pkh/src/deb/mod.rs
T
vhaudiquet bb90842ac9 deb: resolve pocket suites and serve Debian security updates
A changelog may 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. The pocket must be one of
the base series' distro-info pockets, which also guards against
series names merely containing a dash; an explicit --pocket wins.

Debian serves security updates from a dedicated archive: appending
the security suite to the main mirror would 404 on 'apt-get update'
(ubuntu serves every pocket from the same archive, where appending
works) - clone the pinned source against deb.debian.org/debian-security
instead, as mmdebstrap does.
2026-10-02 01:19:24 +02:00

1894 lines
71 KiB
Rust

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<dyn Classifier> {
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<String>,
/// Target distribution series; defaults to the changelog series
/// (UNRELEASED resolves to the vendor's development series).
pub series: Option<String>,
/// Distribution pocket to resolve build-dependencies from.
pub pocket: Option<String>,
/// 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<String>,
/// Source tree to build; defaults to the process working directory.
pub cwd: Option<PathBuf>,
/// Cross-compile for the target architecture instead of using
/// qemu-binfmt.
pub cross: bool,
/// Build mode; defaults to [`BuildMode::Local`].
pub mode: Option<BuildMode>,
/// PPAs to add for build-dependencies (`user/ppa_name`).
pub ppa: Vec<String>,
/// Packages to inject into the build environment before build-dep
/// (.deb paths, archive names or PPA packages).
pub inject: Vec<String>,
/// Parallel build jobs; defaults to the core count available in the
/// build context.
pub jobs: Option<usize>,
/// Explicit build context; defaults to the current context.
pub ctx: Option<Arc<Context>>,
/// Incremental request (`pkh deb --incremental [<id>]`): 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 <id>`: 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<session::RecordLogSink>,
}
impl BuildView for RecordLogView<'_> {
fn target(&self, target: crate::report::BuildTarget) {
self.inner.target(target);
}
fn phase(&self, name: &str, classifier: Box<dyn Classifier>) {
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<Arc<dyn crate::context::LineSink>> {
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<Vec<PathBuf>, Box<dyn Error>> {
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<Vec<PathBuf>, Box<dyn Error>> {
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(&current_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<Result<Arc<session::RecordLogSink>, 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<PathBuf> = 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<dyn Error>> {
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<dyn Error>> {
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::Session> =
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::Session> = 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::<Vec<_>>()
.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<PathBuf, Box<dyn Error>> {
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<PathBuf, Box<dyn Error>> {
// 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<Context>,
) -> Result<PathBuf, Box<dyn Error>> {
// 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: <job>/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: <pkg>/<pkg>-<origversion>
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::<Vec<_>>();
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 <pkh@example.com> 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 <pkh@example.com>\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 <db.h>\\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<String> = 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 <pkh@example.com> 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 <pkh@example.com>\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 <job>/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 <pkh@example.com> 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 <pkh@example.com>\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<String> = 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 <id>` 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<Arc<dyn LineSink>> = 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"
);
}
}