CheckOpts had no build-arch concept: the build-side facts and :native qualifiers resolved against the host arch, so in a cross build (-a armhf on amd64) 'Build-Depends: foo:native' looked for an armhf package where dpkg-checkbuilddeps looks for an amd64 one. CheckOpts gains build_arch (DEB_BUILD_ARCH), used for :native and the dpkg status attribution; bracketed arch restrictions keep evaluating against the host arch.
2107 lines
80 KiB
Rust
2107 lines
80 KiB
Rust
//! Native Debian source-package build pipeline.
|
|
//!
|
|
//! Re-implements the orchestration performed by `dpkg-buildpackage -S`
|
|
//! (environment setup, `dpkg-source` lifecycle, `.buildinfo` / `.changes`
|
|
//! generation and OpenPGP signing) natively in Rust, while delegating the
|
|
//! source-tree work (tarballs, diffs, patches) to `dpkg-source` as a
|
|
//! subprocess.
|
|
|
|
pub mod binary;
|
|
pub mod buildinfo;
|
|
pub mod buildtype;
|
|
pub mod changes;
|
|
pub mod env;
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::error::Error;
|
|
use std::io::IsTerminal;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
use std::sync::Arc;
|
|
|
|
use crate::context::capture::pump;
|
|
use crate::context::{LineSink, Stream};
|
|
use crate::debian::{
|
|
ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList, parse_paragraphs,
|
|
};
|
|
use crate::ui::deb::DebUi;
|
|
use crate::ui::logfmt::{DpkgSourceClassifier, GenericClassifier};
|
|
|
|
/// Options for a native source-package build.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct SourceBuildOptions {
|
|
/// Explicit signing key id / fingerprint (`-k`). When unset, a secret
|
|
/// key matching the changelog maintainer email is searched.
|
|
pub sign_keyid: Option<String>,
|
|
/// Sign even for an UNRELEASED changelog (`--force-sign`).
|
|
pub force_sign: bool,
|
|
/// Force build-dependency checking even though this is a source-only
|
|
/// build (`-D`). `dpkg-buildpackage` skips `dpkg-checkbuilddeps`
|
|
/// entirely for source-only builds unless forced.
|
|
pub force_dep_check: bool,
|
|
}
|
|
|
|
/// Artifacts produced by a successful source build.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SourceBuildOutput {
|
|
/// The generated `.dsc`.
|
|
pub dsc: PathBuf,
|
|
/// The generated `.buildinfo`.
|
|
pub buildinfo: PathBuf,
|
|
/// The generated `.changes`.
|
|
pub changes: PathBuf,
|
|
/// Source tarballs referenced by the `.dsc` (orig, debian tar...).
|
|
pub tarballs: Vec<PathBuf>,
|
|
/// Whether all artifacts were signed.
|
|
pub signed: bool,
|
|
}
|
|
|
|
/// Build a Debian source package (to a .dsc) using the native pipeline.
|
|
///
|
|
/// When `ui` is set, subprocess output is captured into a live view (status
|
|
/// bar + rolling pane) and tee'd to a log file; on failure the view prints a
|
|
/// summary of the last captured errors. Without a UI, commands inherit the
|
|
/// terminal as before.
|
|
///
|
|
/// A `dpkg-source -b` failure is classified (see
|
|
/// [`classify_dpkg_source_failure`]); when the vendored rust dependencies
|
|
/// diverged from the orig-vendor component and a terminal is attached, the
|
|
/// flow offers to re-vendor, recreate the component and retry the build
|
|
/// exactly once.
|
|
pub fn build_source_package(
|
|
cwd: Option<&Path>,
|
|
ui: Option<Arc<DebUi>>,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
let cwd = cwd.unwrap_or_else(|| Path::new("."));
|
|
let output = match run_source_build(cwd, &SourceBuildOptions::default(), ui.clone()) {
|
|
Ok(output) => output,
|
|
Err(e) if e.downcast_ref::<VendorDriftError>().is_some() => {
|
|
return retry_after_revendor(cwd, ui, e);
|
|
}
|
|
Err(e) => {
|
|
if let Some(u) = &ui {
|
|
u.finish_failure();
|
|
}
|
|
return Err(e);
|
|
}
|
|
};
|
|
|
|
// Artifact listing in dpkg order: dsc → tarballs → buildinfo → changes.
|
|
let mut artifacts = Vec::with_capacity(3 + output.tarballs.len());
|
|
artifacts.push(output.dsc.clone());
|
|
artifacts.extend(output.tarballs.iter().cloned());
|
|
artifacts.push(output.buildinfo.clone());
|
|
artifacts.push(output.changes.clone());
|
|
|
|
// The live view lists the artifacts itself when it renders; otherwise
|
|
// (verbose mode or non-TTY stdout) print them as plain lines.
|
|
let listed = match &ui {
|
|
Some(u) if u.is_enabled() => {
|
|
u.finish_success(&artifacts, u.elapsed());
|
|
true
|
|
}
|
|
_ => false,
|
|
};
|
|
if !listed {
|
|
for artifact in &artifacts {
|
|
println!(" {}", crate::ui::display_path(artifact));
|
|
}
|
|
}
|
|
|
|
if output.signed {
|
|
println!("Package built and signed successfully!");
|
|
} else {
|
|
println!("Package built successfully (unsigned).");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The re-vendor retry hook for a [`VendorDriftError`]: on an interactive
|
|
/// terminal, offer to re-run the vendoring step (the same helper the rust
|
|
/// template uses at scaffold time), recreate the `orig-vendor` component
|
|
/// from the fresh `vendor/` tree and retry the source build exactly once.
|
|
/// Without a terminal (or on a declined offer) the original error is
|
|
/// returned untouched.
|
|
fn retry_after_revendor(
|
|
cwd: &Path,
|
|
ui: Option<Arc<DebUi>>,
|
|
original: Box<dyn Error>,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
|
|
if !interactive {
|
|
if let Some(u) = &ui {
|
|
u.finish_failure();
|
|
}
|
|
return Err(original);
|
|
}
|
|
|
|
log::error!("{original}");
|
|
let retry = crate::ui::prompt::confirm(
|
|
"Re-vendor the Cargo dependencies and retry the build?",
|
|
false,
|
|
)
|
|
.unwrap_or(false);
|
|
if !retry {
|
|
if let Some(u) = &ui {
|
|
u.finish_failure();
|
|
}
|
|
return Err(original);
|
|
}
|
|
|
|
// 1. Re-vendor into the tree. The vendoring helper never overwrites an
|
|
// existing `.cargo/config.toml` (scaffold-time safety); in a package
|
|
// we vendored before, that config is ours — remove it so it is
|
|
// refreshed. A foreign config (no vendored-source marker) is left
|
|
// alone and makes the vendoring report incomplete below.
|
|
let config = cwd.join(".cargo/config.toml");
|
|
if config.exists()
|
|
&& std::fs::read_to_string(&config)
|
|
.is_ok_and(|content| content.contains("[source.crates-io]"))
|
|
{
|
|
std::fs::remove_file(&config)?;
|
|
}
|
|
if !crate::new::templates::rust::vendor_dependencies(cwd)? {
|
|
if let Some(u) = &ui {
|
|
u.finish_failure();
|
|
}
|
|
return Err(
|
|
"Re-vendoring did not complete: the tree is unchanged, fix the \
|
|
vendoring by hand and build again."
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
// 2. Recreate the orig-vendor component from the fresh vendor/ tree,
|
|
// under the name dpkg-source globs for: the changelog version's
|
|
// UPSTREAM part (`0.14.0`, not the full `0.14.0-1` — the stale
|
|
// component would survive and the next build would fail again).
|
|
let entry = crate::debian::parse_changelog_entry(&cwd.join("debian/changelog"))?;
|
|
let uversion = crate::new::orig::component_upstream_version(&entry.version);
|
|
let component = crate::new::orig::vendor_component_path(cwd, &entry.source, uversion)
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"cannot determine the output directory of '{}' ",
|
|
cwd.display()
|
|
)
|
|
})?;
|
|
if component.exists() {
|
|
std::fs::remove_file(&component)?;
|
|
}
|
|
crate::new::orig::create_vendor_component(cwd, &entry.source, uversion)?;
|
|
|
|
// 3. One retry.
|
|
run_source_build(cwd, &SourceBuildOptions::default(), ui).map(|_| ())
|
|
}
|
|
|
|
/// Run the full native source-build pipeline in `cwd`.
|
|
///
|
|
/// Steps (mirroring `dpkg-buildpackage -S -I -i -nc -d`):
|
|
/// 1. sanity checks and metadata resolution (changelog, control),
|
|
/// 2. environment setup (`SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`, arch vars),
|
|
/// 3. signing decision (key discovery, UNRELEASED handling),
|
|
/// 4. `dpkg-source --before-build` then `dpkg-source -b`,
|
|
/// 5. native `.buildinfo` generation (+ registration in `debian/files`),
|
|
/// 6. native `.changes` generation,
|
|
/// 7. `dpkg-source --after-build`,
|
|
/// 8. signing cascade: dsc → buildinfo → changes, recomputing checksums of
|
|
/// already-generated files at each step.
|
|
pub fn run_source_build(
|
|
cwd: &Path,
|
|
opts: &SourceBuildOptions,
|
|
ui: Option<Arc<DebUi>>,
|
|
) -> Result<SourceBuildOutput, Box<dyn Error>> {
|
|
// Without a live UI, test runs still capture command output into the
|
|
// per-test log file instead of letting it inherit the terminal
|
|
let sink: Option<Arc<dyn LineSink>> = ui
|
|
.as_ref()
|
|
.map(|u| u.sink())
|
|
.or_else(crate::test_support::subprocess_sink);
|
|
// ------------------------------------------------------------------
|
|
// 1. Sanity checks
|
|
// ------------------------------------------------------------------
|
|
let parent = cwd
|
|
.parent()
|
|
.filter(|p| !p.as_os_str().is_empty())
|
|
.ok_or_else(|| format!("cannot determine output directory from '{}'", cwd.display()))?
|
|
.to_path_buf();
|
|
|
|
let rules_path = cwd.join("debian/rules");
|
|
if !rules_path.exists() {
|
|
return Err(format!(
|
|
"'{}' not found: '{}' does not look like a Debian source tree",
|
|
rules_path.display(),
|
|
cwd.display()
|
|
)
|
|
.into());
|
|
}
|
|
|
|
let changelog_path = cwd.join("debian/changelog");
|
|
let control_path = cwd.join("debian/control");
|
|
if !changelog_path.exists() {
|
|
return Err(format!("'{}' not found", changelog_path.display()).into());
|
|
}
|
|
if !control_path.exists() {
|
|
return Err(format!("'{}' not found", control_path.display()).into());
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 2. Metadata resolution
|
|
// ------------------------------------------------------------------
|
|
let entry = crate::debian::parse_changelog_entry(&changelog_path)?;
|
|
let ctrl = ControlInfo::parse(&control_path)?;
|
|
|
|
if let Some(u) = &ui {
|
|
u.set_build_target(&entry.source, &entry.version.full(), &entry.distribution);
|
|
}
|
|
|
|
// binNMU builds reference the *previous* (source) version in their
|
|
// artifact metadata, like dpkg-genchanges/genbuildinfo do.
|
|
let previous_version = if entry.binary_only {
|
|
crate::debian::changelog::parse_previous_version(&changelog_path)?
|
|
} else {
|
|
None
|
|
};
|
|
let source_display = if entry.binary_only {
|
|
match previous_version.as_deref() {
|
|
Some(prev) => format!("{} ({})", entry.source, prev),
|
|
None => entry.source.clone(),
|
|
}
|
|
} else {
|
|
entry.source.clone()
|
|
};
|
|
let binary_only_changes = entry.binary_only.then(|| {
|
|
format!(
|
|
"{}\n\n -- {} <{}> {}",
|
|
entry.changes_field, entry.maintainer_name, entry.maintainer_email, entry.date_raw
|
|
)
|
|
});
|
|
|
|
let sversion = entry.version.no_epoch();
|
|
let dsc_name = format!("{}_{}.dsc", entry.source, sversion);
|
|
let dsc_path = parent.join(&dsc_name);
|
|
let buildinfo_name = format!("{}_{}_source.buildinfo", entry.source, sversion);
|
|
let buildinfo_path = parent.join(&buildinfo_name);
|
|
let changes_name = format!("{}_{}_source.changes", entry.source, sversion);
|
|
let changes_path = parent.join(&changes_name);
|
|
|
|
// ------------------------------------------------------------------
|
|
// 3. Environment setup
|
|
// ------------------------------------------------------------------
|
|
let vendor = env::current_vendor();
|
|
let profiles = env::resolve_build_profiles(&[], &vendor);
|
|
let mut pipeline_env = env::build_env(entry.timestamp, env::num_parallel(), &profiles);
|
|
|
|
let arch_vars = env::arch_env(None)?;
|
|
pipeline_env.extend(arch_vars.clone());
|
|
|
|
// ------------------------------------------------------------------
|
|
// 4. Signing decision
|
|
// ------------------------------------------------------------------
|
|
let mut signing_key = opts.sign_keyid.clone();
|
|
if signing_key.is_none() {
|
|
match crate::utils::gpg::find_signing_key_for_email(&entry.maintainer_email) {
|
|
Ok(Some(key)) => {
|
|
log::info!("Using GPG key {} for signing", key);
|
|
signing_key = Some(key);
|
|
}
|
|
Ok(None) => {
|
|
log::warn!(
|
|
"No GPG secret key found for {} <{}>, building without signing",
|
|
entry.maintainer_name,
|
|
entry.maintainer_email
|
|
);
|
|
}
|
|
Err(e) => {
|
|
log::warn!(
|
|
"Failed to check for GPG key: {}, building without signing",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
let do_sign = match &signing_key {
|
|
None => false,
|
|
Some(_) if entry.distribution == "UNRELEASED" && !opts.force_sign => {
|
|
log::warn!("Not signing UNRELEASED build; use force_sign to override");
|
|
false
|
|
}
|
|
Some(_) => true,
|
|
};
|
|
|
|
// ------------------------------------------------------------------
|
|
// 5. dpkg-source lifecycle: before-build + source build
|
|
// ------------------------------------------------------------------
|
|
if let Some(u) = &ui {
|
|
u.phase_custom("Applying patches", Box::new(DpkgSourceClassifier::new()));
|
|
}
|
|
run_command(
|
|
cwd,
|
|
"dpkg-source",
|
|
&["-I", "-i", "--before-build", "."],
|
|
&pipeline_env,
|
|
sink.as_ref(),
|
|
)?;
|
|
|
|
// Build-dependency check (native dpkg-checkbuilddeps equivalent).
|
|
// dpkg-buildpackage skips it entirely for source-only builds unless
|
|
// forced with -D; unsatisfied dependencies abort with exit status 3.
|
|
if opts.force_dep_check {
|
|
if let Some(u) = &ui {
|
|
u.progress_message("Checking build dependencies");
|
|
}
|
|
let check_opts = crate::debian::deps::CheckOpts {
|
|
host_arch: arch_vars
|
|
.get("DEB_HOST_ARCH")
|
|
.cloned()
|
|
.unwrap_or_else(|| crate::debian::arch::native().unwrap_or_default()),
|
|
build_arch: arch_vars
|
|
.get("DEB_BUILD_ARCH")
|
|
.cloned()
|
|
.unwrap_or_else(|| crate::debian::arch::native().unwrap_or_default()),
|
|
build_profiles: profiles.clone(),
|
|
..Default::default()
|
|
};
|
|
let report = crate::debian::deps::check_build_depends(&ctrl, &check_opts)?;
|
|
if !report.is_ok() {
|
|
eprintln!("{}", report.message());
|
|
return Err(Box::new(crate::debian::deps::UnmetBuildDependencies(
|
|
report,
|
|
)));
|
|
}
|
|
}
|
|
|
|
if let Some(u) = &ui {
|
|
u.phase_custom(
|
|
"Building source package",
|
|
Box::new(DpkgSourceClassifier::new()),
|
|
);
|
|
}
|
|
if let Err(failure) = run_command_capturing(
|
|
cwd,
|
|
"dpkg-source",
|
|
&["-I", "-i", "-b", "."],
|
|
&pipeline_env,
|
|
sink.as_ref(),
|
|
) {
|
|
return Err(dpkg_source_failure_error(
|
|
classify_dpkg_source_failure(&failure.stderr),
|
|
&failure.stderr,
|
|
failure.error,
|
|
));
|
|
}
|
|
|
|
if !dsc_path.exists() {
|
|
return Err(format!(
|
|
"dpkg-source did not produce the expected '{}'",
|
|
dsc_path.display()
|
|
)
|
|
.into());
|
|
}
|
|
|
|
// Binary-only uploads redistribute the *previous* source: metadata
|
|
// references the previous version's .dsc (which must already exist in
|
|
// the output directory), exactly like dpkg-genchanges/genbuildinfo.
|
|
let ref_dsc_name = match previous_version.as_deref().filter(|_| entry.binary_only) {
|
|
Some(prev) => {
|
|
let prev_version = crate::debian::DebianVersion::parse(prev)?;
|
|
let name = format!("{}_{}.dsc", entry.source, prev_version.no_epoch());
|
|
if !parent.join(&name).exists() {
|
|
return Err(format!(
|
|
"binary-only build requires the previous source '{} \
|
|
{}' to exist next to the package",
|
|
entry.source, prev
|
|
)
|
|
.into());
|
|
}
|
|
name
|
|
}
|
|
None => dsc_name.clone(),
|
|
};
|
|
let ref_dsc_path = parent.join(&ref_dsc_name);
|
|
|
|
// ------------------------------------------------------------------
|
|
// 6. .buildinfo generation (native dpkg-genbuildinfo equivalent)
|
|
// ------------------------------------------------------------------
|
|
if let Some(u) = &ui {
|
|
u.progress_message("Generating .buildinfo");
|
|
}
|
|
let mut checksums = FileChecksums::new();
|
|
checksums.add_file_as(&ref_dsc_path, &ref_dsc_name)?;
|
|
|
|
let status_path = PathBuf::from("/var/lib/dpkg/status");
|
|
let bd_fields = [ctrl.source.get("Build-Depends").unwrap_or("")];
|
|
let installed_bd = buildinfo::installed_build_depends(&status_path, &bd_fields)?;
|
|
let environment = env::buildinfo_environment(&pipeline_env);
|
|
|
|
let render_buildinfo_doc = |checksums: &FileChecksums| {
|
|
buildinfo::render_buildinfo(&buildinfo::BuildInfoInput {
|
|
source: source_display.clone(),
|
|
binaries: Vec::new(), // source-only build
|
|
architecture: "source".to_string(),
|
|
version: entry.version.full(),
|
|
binary_only_changes: binary_only_changes.clone(),
|
|
build_origin: vendor.clone(),
|
|
build_architecture: arch_vars
|
|
.get("DEB_BUILD_ARCH")
|
|
.cloned()
|
|
.unwrap_or_else(crate::get_current_arch),
|
|
build_date: chrono::Local::now().to_rfc2822(),
|
|
checksums: checksums.clone(),
|
|
installed_build_depends: installed_bd.clone(),
|
|
environment: environment.clone(),
|
|
})
|
|
};
|
|
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
|
|
|
|
// Register the .buildinfo in debian/files (as dpkg-genbuildinfo does).
|
|
let files_path = cwd.join("debian/files");
|
|
let mut files_list = FilesList::load(&files_path)?;
|
|
files_list.retain(|e| {
|
|
!(e.package.as_deref() == Some(entry.source.as_str())
|
|
&& e.package_type.as_deref() == Some("buildinfo"))
|
|
});
|
|
files_list.add(FilesEntry::new(
|
|
&buildinfo_name,
|
|
ctrl.section(),
|
|
ctrl.priority(),
|
|
));
|
|
files_list.save_atomic(&files_path)?;
|
|
|
|
// ------------------------------------------------------------------
|
|
// 7. .changes generation (native dpkg-genchanges equivalent)
|
|
// ------------------------------------------------------------------
|
|
if let Some(u) = &ui {
|
|
u.progress_message("Generating .changes");
|
|
}
|
|
// Pull the tarball checksums out of the referenced .dsc so they are
|
|
// distributed through the .changes like dpkg-genchanges does, in the
|
|
// order the .dsc itself lists them.
|
|
let dsc_content = std::fs::read_to_string(&ref_dsc_path)
|
|
.map_err(|e| format!("cannot read '{}': {}", ref_dsc_path.display(), e))?;
|
|
let dsc_para = parse_paragraphs(crate::debian::control::strip_clearsigned_armour(
|
|
&dsc_content,
|
|
))
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| format!("'{}' is empty", ref_dsc_path.display()))?;
|
|
|
|
let mut tarball_paths = Vec::new();
|
|
let mut dsc_file_names: Vec<String> = Vec::new();
|
|
let mut dsc_files: HashMap<String, PartialChecksum> = HashMap::new();
|
|
// Distribution order follows the Checksums fields (Checksums-Sha1 then
|
|
// Checksums-Sha256), like dpkg-genchanges; the `Files` field only
|
|
// supplements the md5 digests.
|
|
for field in ["Checksums-Sha1", "Checksums-Sha256", "Files"] {
|
|
let Some(value) = dsc_para.get(field) else {
|
|
continue;
|
|
};
|
|
for cl in parse_checksum_field(field, value)
|
|
.map_err(|e| format!("cannot parse '{}': {e}", ref_dsc_path.display()))?
|
|
{
|
|
if !dsc_files.contains_key(&cl.name) {
|
|
dsc_file_names.push(cl.name.clone());
|
|
}
|
|
let slot = dsc_files.entry(cl.name.clone()).or_default();
|
|
match field {
|
|
"Checksums-Sha1" => slot.sha1 = Some(cl.digest),
|
|
"Checksums-Sha256" => slot.sha256 = Some(cl.digest),
|
|
_ => slot.md5 = Some(cl.digest),
|
|
}
|
|
slot.size = Some(cl.size);
|
|
}
|
|
}
|
|
for name in &dsc_file_names {
|
|
if name == &ref_dsc_name {
|
|
continue; // already computed directly above
|
|
}
|
|
let path = parent.join(name);
|
|
if !path.exists() {
|
|
return Err(format!(
|
|
"file '{}' referenced by '{}' is missing",
|
|
name,
|
|
dsc_path.display()
|
|
)
|
|
.into());
|
|
}
|
|
let partial = dsc_files.get(name).ok_or_else(|| {
|
|
format!(
|
|
"file '{name}' listed in '{}' has no checksum entry",
|
|
ref_dsc_path.display()
|
|
)
|
|
})?;
|
|
checksums.insert_entry(
|
|
name,
|
|
ChecksumEntry {
|
|
size: partial.size.unwrap_or(0),
|
|
md5: partial.md5.clone().unwrap_or_default(),
|
|
sha1: partial.sha1.clone().unwrap_or_default(),
|
|
sha256: partial.sha256.clone().unwrap_or_default(),
|
|
},
|
|
);
|
|
tarball_paths.push(path);
|
|
}
|
|
|
|
// The .buildinfo itself is distributed through the .changes, last (as
|
|
// dpkg-genchanges does when it consumes debian/files).
|
|
checksums.add_file_as(&buildinfo_path, &buildinfo_name)?;
|
|
|
|
// The .changes lists section/priority for every distributed file; the
|
|
// dsc and tarballs use the source stanza defaults (not persisted into
|
|
// debian/files, matching dpkg).
|
|
let mut changes_files = files_list.clone();
|
|
changes_files.add(FilesEntry::new(
|
|
&ref_dsc_name,
|
|
ctrl.section(),
|
|
ctrl.priority(),
|
|
));
|
|
for name in &dsc_file_names {
|
|
if name != &ref_dsc_name {
|
|
changes_files.add(FilesEntry::new(name, ctrl.section(), ctrl.priority()));
|
|
}
|
|
}
|
|
|
|
let changed_by = format!("{} <{}>", entry.maintainer_name, entry.maintainer_email);
|
|
let render_changes_doc = |checksums: &FileChecksums| {
|
|
changes::render_changes(&changes::ChangesInput {
|
|
date: entry.date_raw.clone(),
|
|
source: source_display.clone(),
|
|
binaries: Vec::new(), // source-only upload
|
|
built_for_profiles: profiles.clone(),
|
|
architecture: "source".to_string(),
|
|
version: entry.version.full(),
|
|
distribution: entry.distribution.clone(),
|
|
urgency: entry.urgency.clone(),
|
|
maintainer: ctrl.source.get("Maintainer").map(str::to_string),
|
|
changed_by: Some(changed_by.clone()),
|
|
descriptions: Vec::new(),
|
|
closes: entry.closes.clone(),
|
|
changes_field: entry.changes_field.clone(),
|
|
checksums: checksums.clone(),
|
|
files_list: changes_files.clone(),
|
|
})
|
|
};
|
|
changes::save_changes(&changes_path, &render_changes_doc(&checksums))?;
|
|
|
|
// ------------------------------------------------------------------
|
|
// 8. dpkg-source after-build (unapplies quilt patches it applied)
|
|
// ------------------------------------------------------------------
|
|
if let Some(u) = &ui {
|
|
u.phase_custom("Restoring patches", Box::new(DpkgSourceClassifier::new()));
|
|
}
|
|
run_command(
|
|
cwd,
|
|
"dpkg-source",
|
|
&["-I", "-i", "--after-build", "."],
|
|
&pipeline_env,
|
|
sink.as_ref(),
|
|
)?;
|
|
|
|
// ------------------------------------------------------------------
|
|
// 9. Signing cascade: dsc -> buildinfo -> changes
|
|
// ------------------------------------------------------------------
|
|
let mut signed = false;
|
|
if let Some(keyid) = signing_key.filter(|_| do_sign) {
|
|
crate::utils::gpg::validate_key_id(&keyid)?;
|
|
|
|
if let Some(u) = &ui {
|
|
u.phase_custom("Signing artifacts", Box::new(GenericClassifier::new()));
|
|
}
|
|
|
|
log::info!("Signing {}", dsc_name);
|
|
crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?;
|
|
// The freshly built .dsc changed: refresh its checksums inside the
|
|
// .buildinfo. For binary-only builds the metadata references the
|
|
// *previous* .dsc (untouched by this build), so there is nothing to
|
|
// refresh.
|
|
if !entry.binary_only {
|
|
checksums.add_file_as(&dsc_path, &dsc_name)?;
|
|
}
|
|
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
|
|
|
|
log::info!("Signing {}", buildinfo_name);
|
|
crate::utils::gpg::clearsign_file(&buildinfo_path, &keyid)?;
|
|
// Both .dsc and .buildinfo changed: refresh the .changes.
|
|
checksums.add_file_as(&buildinfo_path, &buildinfo_name)?;
|
|
changes::save_changes(&changes_path, &render_changes_doc(&checksums))?;
|
|
|
|
log::info!("Signing {}", changes_name);
|
|
crate::utils::gpg::clearsign_file(&changes_path, &keyid)?;
|
|
|
|
signed = true;
|
|
}
|
|
|
|
Ok(SourceBuildOutput {
|
|
dsc: dsc_path,
|
|
buildinfo: buildinfo_path,
|
|
changes: changes_path,
|
|
tarballs: tarball_paths,
|
|
signed,
|
|
})
|
|
}
|
|
|
|
/// A partially-known checksum entry taken from a `.dsc` checksum field.
|
|
#[derive(Debug, Default)]
|
|
struct PartialChecksum {
|
|
size: Option<u64>,
|
|
md5: Option<String>,
|
|
sha1: Option<String>,
|
|
sha256: Option<String>,
|
|
}
|
|
|
|
/// One validated line of a `Checksums-Sha1` / `Checksums-Sha256` / `Files`
|
|
/// field body.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ChecksumLine {
|
|
/// Digest as written in the first column.
|
|
digest: String,
|
|
/// File size in bytes.
|
|
size: u64,
|
|
/// File name (last column of the line).
|
|
name: String,
|
|
}
|
|
|
|
/// Parse the body of a `Checksums-Sha1` / `Checksums-Sha256` / `Files` field
|
|
/// (one file per line) into validated entries. Shared by the source-build
|
|
/// pipeline and the binary-only metadata generation so both accept exactly
|
|
/// the same lines.
|
|
///
|
|
/// Both layouts are accepted, detected by column count:
|
|
/// - 3 columns: `<digest> <size> <name>` (modern `Checksums-*` fields and
|
|
/// the `Files` field of freshly built `.dsc`/`.changes`),
|
|
/// - 5 columns: `<digest> <size> <section> <priority> <name>` (legacy
|
|
/// `Files` fields, where the name is the last token).
|
|
///
|
|
/// Any other line (notably 4 columns) or a non-numeric size is a malformed
|
|
/// line and yields an error naming `field` and the offending line.
|
|
fn parse_checksum_field(field: &str, value: &str) -> Result<Vec<ChecksumLine>, String> {
|
|
let mut entries = Vec::new();
|
|
for line in value.lines() {
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let tokens: Vec<&str> = line.split_whitespace().collect();
|
|
let (digest, size, name) = match tokens.as_slice() {
|
|
[digest, size, name] => (*digest, *size, *name),
|
|
// Legacy 5-column `Files` layout: digest size section priority name.
|
|
[digest, size, _section, _priority, name] => (*digest, *size, *name),
|
|
_ => {
|
|
return Err(format!(
|
|
"malformed '{field}' line (expected 'checksum size name' \
|
|
or legacy 'checksum size section priority name', got {} \
|
|
columns): '{line}'",
|
|
tokens.len()
|
|
));
|
|
}
|
|
};
|
|
let size: u64 = size.parse().map_err(|_| {
|
|
format!("malformed '{field}' line (size '{size}' is not a number): '{line}'")
|
|
})?;
|
|
entries.push(ChecksumLine {
|
|
digest: digest.to_string(),
|
|
size,
|
|
name: name.to_string(),
|
|
});
|
|
}
|
|
Ok(entries)
|
|
}
|
|
|
|
/// Why a `dpkg-source -b` run failed, classified from its captured stderr.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum DpkgSourceFailure {
|
|
/// A path under `vendor/` (or the `orig-vendor` component itself) is
|
|
/// named in the error: the vendored dependencies changed since the
|
|
/// orig-vendor component was created.
|
|
VendorDrift,
|
|
/// Unrepresentable changes to source outside `vendor/`: the upstream
|
|
/// tree drifted beyond the orig snapshot.
|
|
UpstreamDrift,
|
|
/// Anything else (changelog errors, missing files, …).
|
|
Other,
|
|
}
|
|
|
|
/// Classify a captured `dpkg-source -b` stderr. Any error line naming a
|
|
/// `vendor/` path or the `orig-vendor` component wins (the vendored tree is
|
|
/// what the build choked on); the bare "unrepresentable changes to source"
|
|
/// summary without a vendor mention points at general upstream drift.
|
|
pub(crate) fn classify_dpkg_source_failure(stderr: &str) -> DpkgSourceFailure {
|
|
if stderr
|
|
.lines()
|
|
.any(|line| line.contains("vendor/") || line.contains("orig-vendor"))
|
|
{
|
|
return DpkgSourceFailure::VendorDrift;
|
|
}
|
|
if stderr.contains("unrepresentable changes to source") {
|
|
return DpkgSourceFailure::UpstreamDrift;
|
|
}
|
|
DpkgSourceFailure::Other
|
|
}
|
|
|
|
/// The typed error of the [`DpkgSourceFailure::VendorDrift`] case, letting
|
|
/// the build wrapper offer the re-vendor retry. The type must survive all
|
|
/// the way to [`build_source_package`], so the offending dpkg-source line
|
|
/// travels inside the error instead of being string-wrapped around it.
|
|
#[derive(Debug)]
|
|
pub(crate) struct VendorDriftError {
|
|
/// The offending dpkg-source stderr line, when one was captured.
|
|
detail: Option<String>,
|
|
}
|
|
|
|
impl std::fmt::Display for VendorDriftError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(
|
|
f,
|
|
"dpkg-source failed: the vendored dependencies changed since \
|
|
the orig-vendor component was created. Re-vendor the tree \
|
|
(`cargo vendor`) and recreate the <name>_<version>.\
|
|
orig-vendor.tar.xz component, then build again."
|
|
)?;
|
|
if let Some(detail) = &self.detail {
|
|
write!(f, " (dpkg-source said: {detail})")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Error for VendorDriftError {}
|
|
|
|
/// The user-facing error for a classified `dpkg-source -b` failure: a
|
|
/// pointed explanation for the two known drift cases, the raw command
|
|
/// failure otherwise.
|
|
fn dpkg_source_failure_error(
|
|
failure: DpkgSourceFailure,
|
|
stderr: &str,
|
|
command_error: Box<dyn Error>,
|
|
) -> Box<dyn Error> {
|
|
match failure {
|
|
DpkgSourceFailure::VendorDrift => {
|
|
let detail = stderr
|
|
.lines()
|
|
.find(|line| line.contains("vendor"))
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty());
|
|
Box::new(VendorDriftError {
|
|
detail: detail.map(str::to_string),
|
|
})
|
|
}
|
|
DpkgSourceFailure::UpstreamDrift => format!(
|
|
"dpkg-source failed: the upstream tree drifted beyond the orig \
|
|
snapshot — bump the version (`pkh chlog`) or re-scaffold. \
|
|
(raw error: {command_error})"
|
|
)
|
|
.into(),
|
|
DpkgSourceFailure::Other => command_error,
|
|
}
|
|
}
|
|
|
|
/// A command failure together with the stderr captured while it ran (empty
|
|
/// when the streams were inherited, e.g. verbose mode).
|
|
struct CommandFailure {
|
|
error: Box<dyn Error>,
|
|
stderr: String,
|
|
}
|
|
|
|
/// Run a build command in `cwd` with extra environment variables layered on
|
|
/// top of the inherited environment.
|
|
///
|
|
/// When `sink` is set, stdout/stderr are piped and every line is forwarded to
|
|
/// it (live view + tee log) while the stderr is additionally captured for
|
|
/// the failure classification; otherwise stdio is inherited from the
|
|
/// terminal. Returns an error (with the captured stderr) on non-zero exit
|
|
/// status.
|
|
fn run_command_capturing(
|
|
cwd: &Path,
|
|
program: &str,
|
|
args: &[&str],
|
|
env: &BTreeMap<String, String>,
|
|
sink: Option<&Arc<dyn LineSink>>,
|
|
) -> Result<(), CommandFailure> {
|
|
log::debug!(
|
|
"running: {} {} (in {})",
|
|
program,
|
|
args.join(" "),
|
|
cwd.display()
|
|
);
|
|
|
|
let mut cmd = Command::new(program);
|
|
cmd.current_dir(cwd).envs(env).args(args);
|
|
|
|
// The last 64 KiB of stderr, kept for the dpkg-source failure
|
|
// classification.
|
|
let stderr_capture = Arc::new(std::sync::Mutex::new(String::new()));
|
|
|
|
let status = match sink {
|
|
None => cmd.status().map_err(|e| CommandFailure {
|
|
error: format!("failed to run '{}': {}", program, e).into(),
|
|
stderr: String::new(),
|
|
})?,
|
|
Some(sink) => {
|
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
|
let mut child = cmd.spawn().map_err(|e| CommandFailure {
|
|
error: format!("failed to run '{}': {}", program, e).into(),
|
|
stderr: String::new(),
|
|
})?;
|
|
let stdout = child.stdout.take();
|
|
let stderr = child.stderr.take();
|
|
|
|
// One reader thread per stream; interleaving across streams is
|
|
// approximate (channel arrival order), acceptable for display.
|
|
let out_sink = sink.clone();
|
|
let err_sink = sink.clone();
|
|
let err_capture = Arc::clone(&stderr_capture);
|
|
let out_thread = std::thread::spawn(move || {
|
|
if let Some(out) = stdout {
|
|
pump(out, Stream::Stdout, &*out_sink);
|
|
}
|
|
});
|
|
let err_thread = std::thread::spawn(move || {
|
|
if let Some(stderr) = stderr {
|
|
pump(
|
|
stderr,
|
|
Stream::Stderr,
|
|
&CapturingSink {
|
|
inner: err_sink,
|
|
capture: err_capture,
|
|
},
|
|
);
|
|
}
|
|
});
|
|
let _ = out_thread.join();
|
|
let _ = err_thread.join();
|
|
|
|
child.wait().map_err(|e| CommandFailure {
|
|
error: format!("failed to wait for '{}': {}", program, e).into(),
|
|
stderr: String::new(),
|
|
})?
|
|
}
|
|
};
|
|
|
|
if !status.success() {
|
|
return Err(CommandFailure {
|
|
error: format!(
|
|
"'{} {}' failed with status: {}",
|
|
program,
|
|
args.join(" "),
|
|
status
|
|
)
|
|
.into(),
|
|
stderr: std::mem::take(&mut stderr_capture.lock().unwrap_or_else(|e| e.into_inner())),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Run a build command, discarding the captured stderr.
|
|
fn run_command(
|
|
cwd: &Path,
|
|
program: &str,
|
|
args: &[&str],
|
|
env: &BTreeMap<String, String>,
|
|
sink: Option<&Arc<dyn LineSink>>,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
run_command_capturing(cwd, program, args, env, sink).map_err(|failure| failure.error)
|
|
}
|
|
|
|
/// A [`LineSink`] forwarding every line to the live view while appending
|
|
/// stderr lines to the classification buffer.
|
|
struct CapturingSink {
|
|
inner: Arc<dyn LineSink>,
|
|
capture: Arc<std::sync::Mutex<String>>,
|
|
}
|
|
|
|
const STDERR_CAPTURE_LIMIT: usize = 64 * 1024;
|
|
|
|
impl LineSink for CapturingSink {
|
|
fn line(&self, stream: Stream, line: &str) {
|
|
if stream == Stream::Stderr {
|
|
let mut buffer = self.capture.lock().unwrap_or_else(|e| e.into_inner());
|
|
buffer.push_str(line);
|
|
buffer.push('\n');
|
|
// Keep the tail: dpkg-source reports the offending path last.
|
|
if buffer.len() > STDERR_CAPTURE_LIMIT {
|
|
let cut = buffer.len() - STDERR_CAPTURE_LIMIT;
|
|
let start = buffer[cut..]
|
|
.find('\n')
|
|
.map(|index| cut + index + 1)
|
|
.unwrap_or(buffer.len());
|
|
buffer.drain(..start);
|
|
}
|
|
}
|
|
self.inner.line(stream, line);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::debian::DebianVersion;
|
|
|
|
#[test]
|
|
fn partial_checksum_defaults() {
|
|
let p = PartialChecksum::default();
|
|
assert!(p.size.is_none());
|
|
assert!(p.md5.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn debian_version_from_debian_module_usable() {
|
|
let v = DebianVersion::parse("1.0-2").unwrap();
|
|
assert_eq!(v.no_epoch(), "1.0-2");
|
|
}
|
|
|
|
#[test]
|
|
fn checksum_field_parses_three_column_lines() {
|
|
let entries = parse_checksum_field(
|
|
"Checksums-Sha256",
|
|
" aaa111 12 hello_1.0.orig.tar.xz\n bbb222 3 hello_1.0-1.debian.tar.xz",
|
|
)
|
|
.expect("valid 3-column field");
|
|
assert_eq!(
|
|
entries,
|
|
vec![
|
|
ChecksumLine {
|
|
digest: "aaa111".into(),
|
|
size: 12,
|
|
name: "hello_1.0.orig.tar.xz".into(),
|
|
},
|
|
ChecksumLine {
|
|
digest: "bbb222".into(),
|
|
size: 3,
|
|
name: "hello_1.0-1.debian.tar.xz".into(),
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn checksum_field_parses_legacy_five_column_files() {
|
|
// Old archive .dsc/.changes carry `Files` as
|
|
// md5 size section priority name.
|
|
let entries = parse_checksum_field(
|
|
"Files",
|
|
" d111 100 editors optional hello_1.0.orig.tar.gz\n \
|
|
d222 55 web optional hello_1.0-1.diff.gz",
|
|
)
|
|
.expect("valid legacy 5-column field");
|
|
assert_eq!(entries.len(), 2);
|
|
assert_eq!(entries[0].digest, "d111");
|
|
assert_eq!(entries[0].size, 100);
|
|
assert_eq!(entries[0].name, "hello_1.0.orig.tar.gz");
|
|
assert_eq!(entries[1].name, "hello_1.0-1.diff.gz");
|
|
}
|
|
|
|
#[test]
|
|
fn checksum_field_rejects_four_column_line() {
|
|
let err = parse_checksum_field(
|
|
"Checksums-Sha256",
|
|
" aaa111 12 hello_1.0.orig.tar.xz\n ccc333 12 bogus hello_1.0-1.debian.tar.xz",
|
|
)
|
|
.expect_err("4-column line must be rejected");
|
|
assert!(err.contains("Checksums-Sha256"), "{err}");
|
|
assert!(err.contains("hello_1.0-1.debian.tar.xz"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn checksum_field_rejects_non_numeric_size() {
|
|
let err = parse_checksum_field("Files", " d111 twelve hello.tar.xz")
|
|
.expect_err("non-numeric size must be rejected");
|
|
assert!(err.contains("twelve"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn checksum_field_skips_blank_lines() {
|
|
let entries = parse_checksum_field("Files", "\n d111 100 hello.tar.xz\n\n")
|
|
.expect("blank lines are ignored");
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].name, "hello.tar.xz");
|
|
}
|
|
|
|
/// Real-shape `dpkg-source -b` stderr excerpts and their class.
|
|
#[test]
|
|
fn dpkg_source_failure_classification() {
|
|
// Vendored dependencies diverged from the orig-vendor component
|
|
// (dpkg names the offending paths before the summary line).
|
|
let vendor = concat!(
|
|
"dpkg-source: info: building mytool using existing \
|
|
./mytool_0.1.0.orig.tar.xz\n",
|
|
"dpkg-source: info: using source format version 3.0 (quilt)\n",
|
|
"dpkg-source: error: cannot represent change to \
|
|
vendor/libc/src/unix/linux_like/mod.rs: binary file contents \
|
|
changed\n",
|
|
"dpkg-source: error: unrepresentable changes to source\n",
|
|
);
|
|
assert_eq!(
|
|
classify_dpkg_source_failure(vendor),
|
|
DpkgSourceFailure::VendorDrift
|
|
);
|
|
|
|
// A missing orig-vendor component makes the vendored files look
|
|
// "new"; still vendor drift.
|
|
let missing_component = concat!(
|
|
"dpkg-source: error: cannot represent change to \
|
|
vendor/libc/Cargo.toml: new file is binary\n",
|
|
"dpkg-source: error: unrepresentable changes to source\n",
|
|
);
|
|
assert_eq!(
|
|
classify_dpkg_source_failure(missing_component),
|
|
DpkgSourceFailure::VendorDrift
|
|
);
|
|
|
|
// The component named in the error is vendor drift too.
|
|
assert_eq!(
|
|
classify_dpkg_source_failure(
|
|
"dpkg-source: error: orig-vendor component tarball \
|
|
checksum mismatch\n",
|
|
),
|
|
DpkgSourceFailure::VendorDrift
|
|
);
|
|
|
|
// Upstream tree drift outside vendor/.
|
|
let upstream = concat!(
|
|
"dpkg-source: error: cannot represent change to \
|
|
assets/logo.png: binary file contents changed\n",
|
|
"dpkg-source: error: unrepresentable changes to source\n",
|
|
);
|
|
assert_eq!(
|
|
classify_dpkg_source_failure(upstream),
|
|
DpkgSourceFailure::UpstreamDrift
|
|
);
|
|
assert_eq!(
|
|
classify_dpkg_source_failure("dpkg-source: error: unrepresentable changes to source\n"),
|
|
DpkgSourceFailure::UpstreamDrift
|
|
);
|
|
|
|
// Anything else.
|
|
assert_eq!(
|
|
classify_dpkg_source_failure(
|
|
"dpkg-source: error: syntax error in debian/control at line 3\n"
|
|
),
|
|
DpkgSourceFailure::Other
|
|
);
|
|
assert_eq!(classify_dpkg_source_failure(""), DpkgSourceFailure::Other);
|
|
}
|
|
|
|
/// The user-facing wording of the classified failures.
|
|
#[test]
|
|
fn dpkg_source_failure_messages() {
|
|
let vendor = "dpkg-source: error: cannot represent change to \
|
|
vendor/serde/src/de/mod.rs: binary file contents changed\n\
|
|
dpkg-source: error: unrepresentable changes to source\n";
|
|
let error = dpkg_source_failure_error(
|
|
DpkgSourceFailure::VendorDrift,
|
|
vendor,
|
|
"'dpkg-source -I -i -b .' failed with status: exit status: 2".into(),
|
|
);
|
|
let message = error.to_string();
|
|
assert!(
|
|
message.contains("vendored dependencies changed"),
|
|
"{message}"
|
|
);
|
|
assert!(message.contains("orig-vendor"), "{message}");
|
|
// The offending dpkg line travels along as the detail.
|
|
assert!(message.contains("vendor/serde/src/de/mod.rs"), "{message}");
|
|
// Regression: the typed error must SURVIVE the message building, or
|
|
// the re-vendor retry hook never fires.
|
|
assert!(error.downcast_ref::<VendorDriftError>().is_some());
|
|
|
|
let upstream_error = dpkg_source_failure_error(
|
|
DpkgSourceFailure::UpstreamDrift,
|
|
"dpkg-source: error: unrepresentable changes to source\n",
|
|
"'dpkg-source -I -i -b .' failed with status: exit status: 2".into(),
|
|
);
|
|
let message = upstream_error.to_string();
|
|
assert!(
|
|
message.contains("drifted beyond the orig snapshot"),
|
|
"{message}"
|
|
);
|
|
assert!(message.contains("pkh chlog"), "{message}");
|
|
|
|
// Other: the raw command error passes through untouched.
|
|
let other = dpkg_source_failure_error(DpkgSourceFailure::Other, "", "boom".into());
|
|
assert_eq!(other.to_string(), "boom");
|
|
|
|
// A vendor-drift error (with or without detail) is recognized by
|
|
// the retry hook.
|
|
for detail in [None, Some("dpkg-source: error: nope".to_string())] {
|
|
let boxed: Box<dyn Error> = Box::new(VendorDriftError { detail });
|
|
assert!(boxed.downcast_ref::<VendorDriftError>().is_some());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Differential tests: build synthetic (or real archive) source packages
|
|
/// with both real `dpkg-buildpackage` and the native pipeline, then compare
|
|
/// the produced `.dsc` / `.changes` / `.buildinfo`.
|
|
///
|
|
/// Fields that legitimately depend on machine state (`Installed-Build-Depends`,
|
|
/// `Environment`, `Build-Date`, `Build-Tainted-By`) and checksum lines of the
|
|
/// `.buildinfo` itself are excluded from the comparison; everything else must
|
|
/// match byte-for-byte.
|
|
#[cfg(test)]
|
|
mod differential_tests {
|
|
use super::*;
|
|
use crate::debian::control::{Paragraph, parse_paragraphs};
|
|
use std::fs;
|
|
|
|
const MAINTAINER: &str = "Pkh Diff <pkh-diff@example.invalid>";
|
|
const DATE: &str = "Thu, 01 Jan 2026 00:00:00 +0000";
|
|
|
|
/// Specification of a synthetic source package.
|
|
struct FixtureSpec {
|
|
name: &'static str,
|
|
version: &'static str,
|
|
distribution: &'static str,
|
|
urgency: &'static str,
|
|
/// `debian/source/format` content ("3.0 (native)", "3.0 (quilt)", "1.0").
|
|
format: &'static str,
|
|
binaries: &'static [(&'static str, &'static str)],
|
|
section: &'static str,
|
|
priority: &'static str,
|
|
body: &'static [&'static str],
|
|
patches: &'static [&'static str],
|
|
extra_source_fields: &'static [(&'static str, &'static str)],
|
|
/// Two-entry changelog (previous entry) for binNMU cases.
|
|
with_previous_entry: bool,
|
|
}
|
|
|
|
impl FixtureSpec {
|
|
fn new(name: &'static str, version: &'static str, distribution: &'static str) -> Self {
|
|
FixtureSpec {
|
|
name,
|
|
version,
|
|
distribution,
|
|
urgency: "medium",
|
|
format: "3.0 (native)",
|
|
binaries: &[],
|
|
section: "utils",
|
|
priority: "optional",
|
|
body: &["* Something changed."],
|
|
patches: &[],
|
|
extra_source_fields: &[],
|
|
with_previous_entry: false,
|
|
}
|
|
}
|
|
|
|
fn sversion(&self) -> String {
|
|
// Version without epoch, as used in artifact file names.
|
|
self.version
|
|
.split_once(':')
|
|
.map(|(_, rest)| rest.to_string())
|
|
.unwrap_or_else(|| self.version.to_string())
|
|
}
|
|
|
|
fn changelog(&self) -> String {
|
|
let mut out = format!(
|
|
"{} ({}) {}; urgency={}\n\n",
|
|
self.name, self.version, self.distribution, self.urgency
|
|
);
|
|
for line in self.body {
|
|
out.push_str(" * ");
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
out.push_str(&format!("\n -- {MAINTAINER} {DATE}\n"));
|
|
if self.with_previous_entry {
|
|
let prev = match self.version.split_once(':') {
|
|
Some((_, r)) => r.to_string(),
|
|
None => self.version.to_string(),
|
|
};
|
|
// Turn "1.0-1+b1" into "1.0-1" for the previous entry.
|
|
let prev = prev.rsplit_once('+').map(|(p, _)| p).unwrap_or(&prev);
|
|
out.push_str(&format!(
|
|
"\n{p} ({pv}) {d}; urgency={u}\n\n * Initial release.\n\n -- {MAINTAINER} {DATE}\n",
|
|
p = self.name,
|
|
pv = prev,
|
|
d = self.distribution,
|
|
u = self.urgency
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn control(&self) -> String {
|
|
let mut out = format!(
|
|
"Source: {}\nSection: {}\nPriority: {}\nMaintainer: {MAINTAINER}\nBuild-Depends: build-essential\n",
|
|
self.name, self.section, self.priority
|
|
);
|
|
for (k, v) in self.extra_source_fields {
|
|
out.push_str(k);
|
|
out.push_str(": ");
|
|
out.push_str(v);
|
|
out.push('\n');
|
|
}
|
|
// dpkg-source refuses trees without any binary stanza.
|
|
let binaries: &[(&str, &str)] = if self.binaries.is_empty() {
|
|
&[(self.name, "all")]
|
|
} else {
|
|
self.binaries
|
|
};
|
|
for (bname, barch) in binaries {
|
|
out.push_str(&format!(
|
|
"\nPackage: {bname}\nArchitecture: {barch}\nDescription: {bname} component\n A component of {}.\n",
|
|
self.name
|
|
));
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// Materialize a fixture tree under `base`, along with an orig tarball
|
|
/// when the source format requires one. Returns the tree path.
|
|
fn write_fixture(base: &Path, spec: &FixtureSpec) -> PathBuf {
|
|
let sversion = spec.sversion();
|
|
let dir_name = format!("{}-{}", spec.name, sversion);
|
|
let dir = base.join(&dir_name);
|
|
fs::create_dir_all(dir.join("debian/source")).expect("create debian/source");
|
|
if !spec.patches.is_empty() {
|
|
fs::create_dir_all(dir.join("debian/patches")).expect("create debian/patches");
|
|
}
|
|
|
|
fs::write(dir.join("hello.txt"), "upstream content v1\n").expect("write upstream file");
|
|
fs::write(dir.join("debian/changelog"), spec.changelog()).expect("write changelog");
|
|
fs::write(dir.join("debian/control"), spec.control()).expect("write control");
|
|
fs::write(
|
|
dir.join("debian/source/format"),
|
|
format!("{}\n", spec.format),
|
|
)
|
|
.expect("write format");
|
|
|
|
let rules = dir.join("debian/rules");
|
|
fs::write(&rules, "#!/usr/bin/make -f\n%:\n\tdh $@\n").expect("write rules");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(&rules, fs::Permissions::from_mode(0o755)).expect("chmod rules");
|
|
}
|
|
|
|
for (i, patch) in spec.patches.iter().enumerate() {
|
|
let name = format!("{:02}-fix.patch", i + 1);
|
|
fs::write(dir.join("debian/patches").join(&name), format!("{patch}\n"))
|
|
.expect("write patch");
|
|
}
|
|
if !spec.patches.is_empty() {
|
|
let series = (1..=spec.patches.len())
|
|
.map(|i| format!("{:02}-fix.patch", i))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
fs::write(dir.join("debian/patches/series"), series + "\n").expect("write series");
|
|
}
|
|
|
|
// quilt and 1.0 formats need a pristine orig tarball next to the tree,
|
|
// named after the *upstream* version.
|
|
if spec.format != "3.0 (native)" {
|
|
let uversion = crate::debian::DebianVersion::parse(spec.version)
|
|
.expect("valid fixture version")
|
|
.upstream;
|
|
let upstream = base.join(".upstream");
|
|
fs::create_dir_all(upstream.join(&dir_name)).expect("create upstream dir");
|
|
fs::write(
|
|
upstream.join(&dir_name).join("hello.txt"),
|
|
"upstream content v1\n",
|
|
)
|
|
.expect("write pristine upstream file");
|
|
|
|
let ext = if spec.format == "1.0" { "gz" } else { "xz" };
|
|
let tarball = base.join(format!("{}_{}.orig.tar.{}", spec.name, uversion, ext));
|
|
let mut cmd = Command::new("tar");
|
|
cmd.current_dir(&upstream);
|
|
match ext {
|
|
"gz" => {
|
|
cmd.arg("-cz");
|
|
}
|
|
_ => {
|
|
cmd.arg("-cJ");
|
|
}
|
|
}
|
|
cmd.arg("-f").arg(&tarball).arg(&dir_name);
|
|
let status = crate::test_support::run_logged(&mut cmd).expect("run tar");
|
|
assert!(status.success(), "tar failed for {}", tarball.display());
|
|
}
|
|
|
|
dir
|
|
}
|
|
|
|
fn copy_path(src: &Path, dst_root: &Path) {
|
|
let status =
|
|
crate::test_support::run_logged(Command::new("cp").arg("-a").arg(src).arg(dst_root))
|
|
.expect("run cp -a");
|
|
assert!(
|
|
status.success(),
|
|
"cp -a {} {} failed",
|
|
src.display(),
|
|
dst_root.display()
|
|
);
|
|
}
|
|
|
|
fn run_dpkg(tree: &Path) {
|
|
let status = crate::test_support::run_logged(
|
|
Command::new("dpkg-buildpackage").current_dir(tree).args([
|
|
"-S",
|
|
"-I",
|
|
"-i",
|
|
"-nc",
|
|
"-d",
|
|
"--no-sign",
|
|
]),
|
|
)
|
|
.expect("failed to run dpkg-buildpackage (is dpkg-dev installed?)");
|
|
assert!(status.success(), "dpkg-buildpackage failed");
|
|
}
|
|
|
|
fn strip_signature(text: &str) -> &str {
|
|
match text.find("-----BEGIN PGP SIGNATURE-----") {
|
|
Some(i) => &text[..i],
|
|
None => text,
|
|
}
|
|
}
|
|
|
|
fn first_paragraph(text: &str) -> Paragraph {
|
|
parse_paragraphs(text)
|
|
.into_iter()
|
|
.next()
|
|
.expect("document has a paragraph")
|
|
}
|
|
|
|
/// Compare a `.changes`: every field must be identical, except checksum
|
|
/// lines referring to the `.buildinfo` itself (whose content legitimately
|
|
/// differs on machine-dependent fields).
|
|
fn assert_changes_equivalent(golden: &Path, ours: &Path) {
|
|
let g = first_paragraph(strip_signature(
|
|
&fs::read_to_string(golden).expect("read golden changes"),
|
|
));
|
|
let o = first_paragraph(strip_signature(
|
|
&fs::read_to_string(ours).expect("read our changes"),
|
|
));
|
|
|
|
let gkeys: std::collections::BTreeSet<&str> = g.iter().map(|(k, _)| k).collect();
|
|
let okeys: std::collections::BTreeSet<&str> = o.iter().map(|(k, _)| k).collect();
|
|
assert_eq!(gkeys, okeys, ".changes field sets differ");
|
|
|
|
for (key, gvalue) in g.iter() {
|
|
let ovalue = o.get(key).unwrap();
|
|
if matches!(key, "Checksums-Sha1" | "Checksums-Sha256" | "Files") {
|
|
let without_buildinfo = |v: &str| -> String {
|
|
v.lines()
|
|
.filter(|l| !l.trim_end().ends_with(".buildinfo"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
};
|
|
assert_eq!(
|
|
without_buildinfo(gvalue),
|
|
without_buildinfo(ovalue),
|
|
".changes field {} differs",
|
|
key
|
|
);
|
|
} else {
|
|
assert_eq!(gvalue, ovalue, ".changes field {} differs", key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compare a `.buildinfo` structure, skipping machine-dependent fields.
|
|
fn assert_buildinfo_equivalent(golden: &Path, ours: &Path) {
|
|
const SKIP: &[&str] = &[
|
|
"Installed-Build-Depends",
|
|
"Environment",
|
|
"Build-Date",
|
|
"Build-Tainted-By",
|
|
];
|
|
let g = first_paragraph(strip_signature(
|
|
&fs::read_to_string(golden).expect("read golden buildinfo"),
|
|
));
|
|
let o = first_paragraph(strip_signature(
|
|
&fs::read_to_string(ours).expect("read our buildinfo"),
|
|
));
|
|
|
|
for (key, gvalue) in g.iter() {
|
|
if SKIP.contains(&key) {
|
|
continue;
|
|
}
|
|
let ovalue = o
|
|
.get(key)
|
|
.unwrap_or_else(|| panic!(".buildinfo missing field {}", key));
|
|
assert_eq!(gvalue, ovalue, ".buildinfo field {} differs", key);
|
|
}
|
|
for (key, _) in o.iter() {
|
|
assert!(
|
|
SKIP.contains(&key) || g.get(key).is_some(),
|
|
".buildinfo has unexpected extra field {}",
|
|
key
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Build `src_tree` with both implementations and compare all artifacts.
|
|
fn differential_on_tree(src_tree: &Path) {
|
|
let src_parent = src_tree.parent().expect("tree has a parent directory");
|
|
let tree_name = src_tree.file_name().expect("tree has a name").to_owned();
|
|
|
|
let base = tempfile::tempdir().expect("tempdir");
|
|
let golden_root = base.path().join("golden");
|
|
let ours_root = base.path().join("ours");
|
|
fs::create_dir_all(&golden_root).expect("mkdir golden");
|
|
fs::create_dir_all(&ours_root).expect("mkdir ours");
|
|
|
|
copy_path(src_tree, &golden_root);
|
|
copy_path(src_tree, &ours_root);
|
|
|
|
// Sibling orig tarballs are required by quilt/1.0 formats; sibling
|
|
// .dsc files are required by binary-only (binNMU) builds.
|
|
for entry in fs::read_dir(src_parent).expect("list source parent") {
|
|
let entry = entry.expect("dir entry");
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
if entry.path().is_file() && (name.contains(".orig.tar.") || name.ends_with(".dsc")) {
|
|
copy_path(&entry.path(), &golden_root);
|
|
copy_path(&entry.path(), &ours_root);
|
|
}
|
|
}
|
|
|
|
let golden_tree = golden_root.join(&tree_name);
|
|
let ours_tree = ours_root.join(&tree_name);
|
|
|
|
run_dpkg(&golden_tree);
|
|
run_source_build(&ours_tree, &SourceBuildOptions::default(), None)
|
|
.expect("native source pipeline failed");
|
|
|
|
let entry =
|
|
crate::debian::parse_changelog_entry(&ours_tree.join("debian/changelog")).unwrap();
|
|
let sversion = entry.version.no_epoch();
|
|
let dsc = golden_root.join(format!("{}_{}.dsc", entry.source, sversion));
|
|
let changes = golden_root.join(format!("{}_{}_source.changes", entry.source, sversion));
|
|
let buildinfo = golden_root.join(format!("{}_{}_source.buildinfo", entry.source, sversion));
|
|
|
|
assert!(
|
|
dsc.exists() && changes.exists() && buildinfo.exists(),
|
|
"native pipeline did not produce all artifacts"
|
|
);
|
|
|
|
// The .dsc payload must be byte-identical (both unsigned here).
|
|
let g_dsc_text = fs::read_to_string(&dsc).expect("read golden dsc");
|
|
let o_dsc_text = fs::read_to_string(ours_root.join(&dsc)).expect("read our dsc");
|
|
assert_eq!(
|
|
strip_signature(&g_dsc_text),
|
|
strip_signature(&o_dsc_text),
|
|
".dsc payload differs"
|
|
);
|
|
|
|
assert_changes_equivalent(&changes, &ours_root.join(&changes));
|
|
assert_buildinfo_equivalent(&buildinfo, &ours_root.join(&buildinfo));
|
|
}
|
|
|
|
fn differential_case(spec: &FixtureSpec) {
|
|
let base = tempfile::tempdir().expect("tempdir");
|
|
let tree = write_fixture(base.path(), spec);
|
|
differential_on_tree(&tree);
|
|
}
|
|
|
|
/// Differential check of [`crate::debian::arch::arch_env`] against real
|
|
/// `dpkg-architecture -f -a <arch>` for one architecture.
|
|
fn diff_arch_env_one(arch: Option<&str>) {
|
|
let mut cmd = Command::new("dpkg-architecture");
|
|
cmd.arg("-f");
|
|
if let Some(a) = arch {
|
|
cmd.args(["-a", a]);
|
|
}
|
|
let output = cmd
|
|
.output()
|
|
.expect("run dpkg-architecture (is dpkg-dev installed?)");
|
|
assert!(
|
|
output.status.success(),
|
|
"dpkg-architecture -f {arch:?} failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let mut expected = BTreeMap::new();
|
|
for line in String::from_utf8_lossy(&output.stdout).lines() {
|
|
if let Some((key, value)) = line.split_once('=') {
|
|
expected.insert(key.to_string(), value.to_string());
|
|
}
|
|
}
|
|
|
|
let ours = crate::debian::arch::arch_env(arch)
|
|
.unwrap_or_else(|e| panic!("native arch_env({arch:?}) failed: {e}"));
|
|
assert_eq!(
|
|
ours, expected,
|
|
"arch_env({arch:?}) differs from dpkg-architecture"
|
|
);
|
|
}
|
|
|
|
/// Every architecture known to the local dpkg must produce an identical
|
|
/// environment dump (`dpkg-architecture -L`).
|
|
#[test]
|
|
fn diff_arch_env_all_known_arches() {
|
|
let output = Command::new("dpkg-architecture")
|
|
.arg("-L")
|
|
.output()
|
|
.expect("run dpkg-architecture -L (is dpkg-dev installed?)");
|
|
assert!(output.status.success());
|
|
for arch in String::from_utf8_lossy(&output.stdout).lines() {
|
|
let arch = arch.trim();
|
|
if arch.is_empty() {
|
|
continue;
|
|
}
|
|
diff_arch_env_one(Some(arch));
|
|
}
|
|
}
|
|
|
|
/// Native (no explicit host architecture) must match too.
|
|
#[test]
|
|
fn diff_arch_env_native() {
|
|
diff_arch_env_one(None);
|
|
}
|
|
|
|
/// Differential check of [`crate::debian::deps::check_build_depends`]
|
|
/// against real `dpkg-checkbuilddeps` on one fixture: exit status and
|
|
/// reported unmet/conflict lists must match.
|
|
fn diff_checkbuilddeps_case(control: &str, status: &str, args: &[&str]) {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("control"), control).expect("write control");
|
|
let admindir = dir.path().join("admin");
|
|
fs::create_dir_all(&admindir).expect("mkdir admindir");
|
|
fs::write(admindir.join("status"), status).expect("write status");
|
|
|
|
// Real tool. Profiles are always pinned via -P so the comparison is
|
|
// independent of the local vendor defaults; -I skips the vendor
|
|
// builtin dependencies (build-essential:native), matching the
|
|
// native checker which knows no builtins. All options must precede
|
|
// the control-file operand (POSIX-style option parsing).
|
|
// The diagnostics are compared against the native checker's English
|
|
// messages, so the tool must run under the C locale regardless of
|
|
// the host configuration.
|
|
let output = Command::new("dpkg-checkbuilddeps")
|
|
.current_dir(dir.path())
|
|
.env("LC_ALL", "C")
|
|
.arg("--admindir")
|
|
.arg(&admindir)
|
|
.args(args)
|
|
.arg("-I")
|
|
.arg("control")
|
|
.output()
|
|
.expect("run dpkg-checkbuilddeps (is dpkg-dev installed?)");
|
|
let real_exit = output.status.code().unwrap_or(-1);
|
|
let real_msg = String::from_utf8_lossy(&output.stderr)
|
|
.lines()
|
|
.filter_map(|l| l.split_once("error: ").map(|(_, m)| m.trim()))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
// Native checker with equivalent options.
|
|
let mut profiles: Vec<String> = Vec::new();
|
|
let mut ignore_arch = false;
|
|
let mut ignore_indep = false;
|
|
let mut i = 0;
|
|
while i < args.len() {
|
|
match args[i] {
|
|
"-A" => ignore_arch = true,
|
|
"-B" => ignore_indep = true,
|
|
"-P" => {
|
|
i += 1;
|
|
profiles = args
|
|
.get(i)
|
|
.map(|p| p.split(',').map(str::to_string).collect())
|
|
.unwrap_or_default();
|
|
}
|
|
_ => {}
|
|
}
|
|
i += 1;
|
|
}
|
|
let host_arch = crate::debian::arch::native().unwrap_or_else(|_| "amd64".into());
|
|
let opts = crate::debian::deps::CheckOpts {
|
|
host_arch: host_arch.clone(),
|
|
build_arch: host_arch,
|
|
build_profiles: profiles,
|
|
ignore_arch,
|
|
ignore_indep,
|
|
ignore_builtin: true,
|
|
admindir: admindir.clone(),
|
|
};
|
|
let control_info =
|
|
crate::debian::ControlInfo::parse_content(control).expect("parse control");
|
|
let report = crate::debian::deps::check_build_depends(&control_info, &opts)
|
|
.expect("native parse failure");
|
|
|
|
let ours_exit = if report.is_ok() { 0 } else { 1 };
|
|
assert_eq!(
|
|
ours_exit, real_exit,
|
|
"exit status mismatch for {control:?} {args:?}"
|
|
);
|
|
assert_eq!(
|
|
report.message(),
|
|
real_msg,
|
|
"diagnostics mismatch for {control:?} {args:?}"
|
|
);
|
|
}
|
|
|
|
/// Matrix of dependency-checking scenarios validated against the real
|
|
/// tool: alternatives, version relations, arch/profile restrictions,
|
|
/// conflicts and `-A`/`-B`/`-P` flag handling.
|
|
#[test]
|
|
fn diff_checkbuilddeps_matrix() {
|
|
const STATUS: &str = "\
|
|
Package: libc6
|
|
Status: install ok installed
|
|
Version: 2.39-0ubuntu8
|
|
Architecture: amd64
|
|
|
|
Package: libfoo-dev
|
|
Status: install ok installed
|
|
Version: 1.2-3
|
|
Architecture: amd64
|
|
|
|
Package: ma-foreign-pkg
|
|
Status: install ok installed
|
|
Version: 1.0
|
|
Architecture: i386
|
|
Multi-Arch: foreign
|
|
|
|
Package: provider
|
|
Status: install ok installed
|
|
Version: 5.0
|
|
Architecture: amd64
|
|
Provides: virtual-thing (= 2.0), plain-virtual
|
|
";
|
|
const HEAD: &str = "Source: t\nMaintainer: a <a@b.c>\n";
|
|
const TAIL: &str = "\nPackage: t\nArchitecture: any\nDescription: x\n y\n";
|
|
|
|
let case = |bd: &str, bc: &str, args: &[&str]| {
|
|
let mut control = String::from(HEAD);
|
|
if !bd.is_empty() {
|
|
control.push_str(&format!("Build-Depends: {bd}\n"));
|
|
}
|
|
if !bc.is_empty() {
|
|
control.push_str(&format!("Build-Conflicts: {bc}\n"));
|
|
}
|
|
control.push_str(TAIL);
|
|
diff_checkbuilddeps_case(&control, STATUS, args);
|
|
};
|
|
|
|
// Satisfied / unsatisfied basics.
|
|
case("libc6 (>= 1)", "", &["-P", "cross"]);
|
|
case("missing-abc", "", &["-P", "cross"]);
|
|
case("libc6 (>> 999)", "", &["-P", "cross"]);
|
|
// Alternatives.
|
|
case("missing-a | libc6", "", &["-P", "cross"]);
|
|
case("missing-a | missing-b", "", &["-P", "cross"]);
|
|
// Architecture restrictions (host is the native arch).
|
|
case("missing-abc [!amd64]", "", &["-P", "cross"]);
|
|
case("missing-abc [amd64]", "", &["-P", "cross"]);
|
|
// Profile restrictions.
|
|
case("missing-abc <stage1>", "", &["-P", "stage1"]);
|
|
case("missing-abc <stage1>", "", &["-P", "cross"]);
|
|
case("missing-abc <!stage1>", "", &["-P", "stage1"]);
|
|
// Multi-Arch foreign satisfies unqualified deps.
|
|
case("ma-foreign-pkg", "", &["-P", "cross"]);
|
|
// Provides: versioned provide satisfying / not satisfying.
|
|
case("virtual-thing (>= 1.0)", "", &["-P", "cross"]);
|
|
case("virtual-thing (>= 3.0)", "", &["-P", "cross"]);
|
|
case("plain-virtual", "", &["-P", "cross"]);
|
|
case("plain-virtual (>= 1.0)", "", &["-P", "cross"]);
|
|
// Conflicts.
|
|
case("", "libc6 (<< 1)", &["-P", "cross"]);
|
|
case("", "libc6", &["-P", "cross"]);
|
|
case("", "missing-abc", &["-P", "cross"]);
|
|
// -A/-B field handling.
|
|
let control_ab = format!(
|
|
"{HEAD}Build-Depends: libc6\nBuild-Depends-Arch: missing-arch-dep\nBuild-Depends-Indep: missing-indep-dep\n{TAIL}"
|
|
);
|
|
diff_checkbuilddeps_case(&control_ab, STATUS, &["-P", "cross"]);
|
|
diff_checkbuilddeps_case(&control_ab, STATUS, &["-A", "-P", "cross"]);
|
|
diff_checkbuilddeps_case(&control_ab, STATUS, &["-B", "-P", "cross"]);
|
|
|
|
// Combined unmet + conflict reporting in one run.
|
|
case(
|
|
"missing-one, libc6 (>> 999)",
|
|
"libfoo-dev",
|
|
&["-P", "cross"],
|
|
);
|
|
}
|
|
|
|
/// Differential check of [`crate::debian::version`] against real
|
|
/// `dpkg --compare-versions` over every ported dpkg test vector and
|
|
/// every relation operator.
|
|
#[test]
|
|
fn diff_version_compare_against_dpkg() {
|
|
let vectors = crate::debian::version::test_vectors::COMPARE;
|
|
assert!(!vectors.is_empty());
|
|
for (a, b, expected) in vectors {
|
|
let va =
|
|
crate::debian::DebianVersion::parse(a).unwrap_or_else(|e| panic!("parse {a}: {e}"));
|
|
let vb =
|
|
crate::debian::DebianVersion::parse(b).unwrap_or_else(|e| panic!("parse {b}: {e}"));
|
|
let ours = match va.cmp(&vb) {
|
|
std::cmp::Ordering::Less => -1,
|
|
std::cmp::Ordering::Equal => 0,
|
|
std::cmp::Ordering::Greater => 1,
|
|
};
|
|
assert_eq!(ours, *expected, "native compare: {a} cmp {b}");
|
|
|
|
// Cross-check the relation operators against the real tool.
|
|
for (op, holds) in [
|
|
("<<", *expected < 0),
|
|
("<=", *expected <= 0),
|
|
("=", *expected == 0),
|
|
(">=", *expected >= 0),
|
|
(">>", *expected > 0),
|
|
] {
|
|
let output = Command::new("dpkg")
|
|
.args(["--compare-versions", "--", a, op, b])
|
|
.status()
|
|
.expect("run dpkg --compare-versions");
|
|
assert_eq!(
|
|
output.success(),
|
|
holds,
|
|
"dpkg --compare-versions -- {a} {op} {b}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Differential check of the binary-build metadata generation against
|
|
/// real `dpkg-buildpackage -b`: both sides build the same tree (rules
|
|
/// driving dpkg-gencontrol/dpkg-deb directly, no debhelper needed),
|
|
/// then the produced `.changes`/`.buildinfo` are compared field by
|
|
/// field modulo machine-dependent values.
|
|
#[test]
|
|
fn diff_binary_build_metadata() {
|
|
const NAME: &str = "pkh-diff-m";
|
|
let control = format!(
|
|
"Source: {NAME}\nSection: utils\nPriority: optional\nMaintainer: {MAINTAINER}\nBuild-Depends: libc6\n\n\
|
|
Package: {NAME}\nArchitecture: any\nDescription: test package main\n long description\n\n\
|
|
Package: {NAME}-u\nPackage-Type: udeb\nArchitecture: all\nDescription: test udeb\n short\n"
|
|
);
|
|
let changelog = format!(
|
|
"{NAME} (1.0-1) unstable; urgency=medium\n\n * Binary build test.\n\n -- {MAINTAINER} {DATE}\n"
|
|
);
|
|
let rules = format!(
|
|
"#!/usr/bin/make -f\nV = $(shell dpkg-parsechangelog -S Version)\nA = $(shell dpkg-architecture -qDEB_HOST_ARCH)\n\nbuild:\n\tmkdir -p debian/tmp/usr/bin\n\tprintf '#!/bin/sh\\necho hi\\n' > debian/tmp/usr/bin/hello\n\tchmod 755 debian/tmp/usr/bin/hello\n\ttouch $@\n\nbinary: build\n\trm -rf debian/{NAME} debian/{NAME}-u\n\tmkdir -p debian/{NAME}/usr/bin debian/{NAME}/DEBIAN\n\tcp -r debian/tmp/. debian/{NAME}/\n\tdpkg-gencontrol -p{NAME} -Pdebian/{NAME}\n\tdpkg-deb --build debian/{NAME} ..\n\tmkdir -p debian/{NAME}-u/usr/share debian/{NAME}-u/DEBIAN\n\techo data > debian/{NAME}-u/usr/share/data.txt\n\tdpkg-gencontrol -p{NAME}-u -Pdebian/{NAME}-u\n\tdpkg-deb --build debian/{NAME}-u ..\n\tmv ../{NAME}-u_$(V)_all.deb ../{NAME}-u_$(V)_all.udeb\n\nclean:\n\trm -rf debian/tmp debian/{NAME} debian/{NAME}-u build-stamp debian/files debian/*.substvars\n\n.PHONY: build binary clean\n"
|
|
);
|
|
|
|
let write_tree = |root: &Path| {
|
|
fs::create_dir_all(root.join(format!("{NAME}/debian/source"))).expect("mkdir tree");
|
|
let tree = root.join(NAME);
|
|
fs::write(tree.join("debian/control"), &control).expect("write control");
|
|
fs::write(tree.join("debian/changelog"), &changelog).expect("write changelog");
|
|
fs::write(tree.join("debian/source/format"), "3.0 (native)\n").expect("write format");
|
|
fs::write(tree.join("debian/rules"), &rules).expect("write rules");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(tree.join("debian/rules"), fs::Permissions::from_mode(0o755))
|
|
.expect("chmod rules");
|
|
}
|
|
tree
|
|
};
|
|
|
|
let base = tempfile::tempdir().expect("tempdir");
|
|
let golden_root = base.path().join("golden");
|
|
let ours_root = base.path().join("ours");
|
|
fs::create_dir_all(&golden_root).expect("mkdir golden");
|
|
fs::create_dir_all(&ours_root).expect("mkdir ours");
|
|
|
|
let golden_tree = write_tree(&golden_root);
|
|
let ours_tree = write_tree(&ours_root);
|
|
|
|
// Golden side: real dpkg-buildpackage binary build.
|
|
let status = crate::test_support::run_logged(
|
|
Command::new("dpkg-buildpackage")
|
|
.current_dir(&golden_tree)
|
|
.args(["-b", "-d", "--no-sign"]),
|
|
)
|
|
.expect("run dpkg-buildpackage (is dpkg-dev installed?)");
|
|
assert!(status.success(), "golden dpkg-buildpackage -b failed");
|
|
|
|
// Ours: emulate the pkh deb flow (rules build + rules binary with a
|
|
// dpkg-buildpackage-like environment), then run the native metadata
|
|
// generation through a local context. dpkg-buildpackage runs the
|
|
// rules targets directly by default (missing Rules-Requires-Root is
|
|
// treated as 'no'), so no fakeroot wrapper here either.
|
|
let entry =
|
|
crate::debian::parse_changelog_entry_from_str(&changelog).expect("parse changelog");
|
|
let vendor = env::current_vendor();
|
|
let profiles = env::resolve_build_profiles(&[], &vendor);
|
|
let parallel = env::num_parallel();
|
|
let build_env_vars: BTreeMap<String, String> = [
|
|
("LANG".to_string(), "C".to_string()),
|
|
(
|
|
"DEB_BUILD_OPTIONS".to_string(),
|
|
format!("parallel={parallel}"),
|
|
),
|
|
("SOURCE_DATE_EPOCH".to_string(), entry.timestamp.to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect();
|
|
|
|
for target in ["build", "binary"] {
|
|
let status = crate::test_support::run_logged(
|
|
Command::new("debian/rules")
|
|
.current_dir(&ours_tree)
|
|
.envs(build_env_vars.clone())
|
|
.arg(target),
|
|
)
|
|
.expect("run rules target");
|
|
assert!(status.success(), "debian/rules {target} failed");
|
|
}
|
|
|
|
let ctx = std::sync::Arc::new(
|
|
crate::context::Context::new(crate::context::ContextConfig::Local).unwrap(),
|
|
);
|
|
let native_arch = crate::debian::arch::native().unwrap_or_else(|_| "amd64".into());
|
|
let opts = crate::build::binary::BinaryMetadataOptions {
|
|
profiles,
|
|
vendor,
|
|
// The metadata records exactly the environment exported to the
|
|
// build steps above.
|
|
exported_env: build_env_vars,
|
|
build_arch: native_arch.clone(),
|
|
host_arch: native_arch,
|
|
};
|
|
crate::build::binary::generate_binary_metadata(&ctx, &ours_tree, &ours_root, &opts)
|
|
.expect("native binary metadata generation failed");
|
|
|
|
// Compare artifacts.
|
|
assert_changes_equivalent(
|
|
&golden_root.join(format!("{NAME}_1.0-1_amd64.changes")),
|
|
&ours_root.join(format!("{NAME}_1.0-1_amd64.changes")),
|
|
);
|
|
assert_buildinfo_equivalent(
|
|
&golden_root.join(format!("{NAME}_1.0-1_amd64.buildinfo")),
|
|
&ours_root.join(format!("{NAME}_1.0-1_amd64.buildinfo")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_native_minimal() {
|
|
differential_case(&FixtureSpec::new("pkh-diff-a", "1.0-1", "unstable"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_native_epoch() {
|
|
differential_case(&FixtureSpec::new("pkh-diff-b", "3:2.4.1-1", "unstable"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_native_tilde() {
|
|
differential_case(&FixtureSpec::new("pkh-diff-c", "1.0~rc2-1", "unstable"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_quilt_ubuntu_focal_high_urgency() {
|
|
let mut spec = FixtureSpec::new("pkh-diff-d", "1.2-1", "focal");
|
|
spec.format = "3.0 (quilt)";
|
|
spec.patches = &[
|
|
"--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n",
|
|
];
|
|
spec.urgency = "high";
|
|
differential_case(&spec);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_quilt_noble_multiple_binaries() {
|
|
let mut spec = FixtureSpec::new("pkh-diff-e", "0.9-2", "noble");
|
|
spec.format = "3.0 (quilt)";
|
|
spec.patches = &[
|
|
"--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n",
|
|
];
|
|
spec.binaries = &[("pkh-diff-e-bin", "any"), ("pkh-diff-e-common", "all")];
|
|
differential_case(&spec);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_quilt_single_patch() {
|
|
let mut spec = FixtureSpec::new("pkh-diff-f", "2.10-3", "unstable");
|
|
spec.format = "3.0 (quilt)";
|
|
spec.patches = &[
|
|
"--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n",
|
|
];
|
|
differential_case(&spec);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_quilt_epoch_two_patches_jammy() {
|
|
let mut spec = FixtureSpec::new("pkh-diff-g", "9:1.5-2", "jammy");
|
|
spec.format = "3.0 (quilt)";
|
|
spec.patches = &[
|
|
"--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched once\n",
|
|
"--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-patched once\n+patched twice\n",
|
|
];
|
|
differential_case(&spec);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_format_1_0() {
|
|
let mut spec = FixtureSpec::new("pkh-diff-h", "0.1-1", "unstable");
|
|
spec.format = "1.0";
|
|
differential_case(&spec);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_binmu_binary_only() {
|
|
let mut spec = FixtureSpec::new("pkh-diff-i", "1.0-1+b1", "unstable");
|
|
spec.body = &["* Binary-only rebuild."];
|
|
spec.with_previous_entry = true;
|
|
|
|
// Binary-only metadata references the previous version's .dsc, which
|
|
// must already exist next to the package tree.
|
|
let base = tempfile::tempdir().expect("tempdir");
|
|
let tree = write_fixture(base.path(), &spec);
|
|
let prev_dsc = format!(
|
|
"Format: 3.0 (native)\nSource: {}\nBinary: {}\nArchitecture: all\nVersion: 1.0-1\nMaintainer: {MAINTAINER}\n",
|
|
spec.name, spec.name
|
|
);
|
|
fs::write(
|
|
base.path().join(format!("{}_1.0-1.dsc", spec.name)),
|
|
prev_dsc,
|
|
)
|
|
.expect("write previous dsc");
|
|
differential_on_tree(&tree);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_closes_bugs() {
|
|
let mut spec = FixtureSpec::new("pkh-diff-j", "2.0-1", "unstable");
|
|
spec.body = &[
|
|
"* Fix crash (Closes: #123456)",
|
|
"* Another fix (Closes: #42)",
|
|
];
|
|
differential_case(&spec);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_unreleased_no_sign() {
|
|
differential_case(&FixtureSpec::new("pkh-diff-k", "1.1-1", "UNRELEASED"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_extra_control_fields() {
|
|
let mut spec = FixtureSpec::new("pkh-diff-l", "5.3-1", "trixie");
|
|
spec.extra_source_fields = &[
|
|
("Homepage", "https://example.com/pkh-diff"),
|
|
("Vcs-Git", "https://example.com/git/pkh-diff.git"),
|
|
];
|
|
differential_case(&spec);
|
|
}
|
|
|
|
/// Differential check of a single real archive package: pull it with
|
|
/// pkh's own [`crate::pull`] (archive download mode) from `dist`
|
|
/// (optionally `series`), then compare artifacts produced by real
|
|
/// `dpkg-buildpackage` against the native pipeline. Requires network
|
|
/// access.
|
|
fn differential_real_archive_package(package: &str, dist: &str, series: Option<&str>) {
|
|
let fetch_dir = tempfile::tempdir().expect("tempdir");
|
|
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
|
|
|
let package_info = rt
|
|
.block_on(crate::package_info::lookup(
|
|
package,
|
|
None,
|
|
series,
|
|
"",
|
|
Some(dist),
|
|
None,
|
|
None,
|
|
None,
|
|
))
|
|
.unwrap_or_else(|e| panic!("package lookup failed for {}: {}", package, e));
|
|
rt.block_on(crate::pull::pull(
|
|
&package_info,
|
|
Some(fetch_dir.path()),
|
|
None,
|
|
true,
|
|
))
|
|
.unwrap_or_else(|e| panic!("pull failed for {}: {}", package, e));
|
|
|
|
// pull extracts the source tree under '<fetch_dir>/<package>/<package>',
|
|
// with the orig tarball and .dsc alongside it.
|
|
let tree = fetch_dir.path().join(package).join(package);
|
|
assert!(
|
|
tree.join("debian/changelog").exists(),
|
|
"pulled tree for {} has no debian/changelog",
|
|
package
|
|
);
|
|
log::info!(
|
|
"differential test against real package: {} ({}/{})",
|
|
package,
|
|
dist,
|
|
series.unwrap_or("latest")
|
|
);
|
|
differential_on_tree(&tree);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_real_hello_ubuntu_noble() {
|
|
differential_real_archive_package("hello", "ubuntu", Some("noble"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_real_dosfstools_debian_trixie() {
|
|
differential_real_archive_package("dosfstools", "debian", Some("trixie"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_real_sl_ubuntu_resolute() {
|
|
differential_real_archive_package("sl", "ubuntu", Some("resolute"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_real_linux_ubuntu_resolute() {
|
|
differential_real_archive_package("linux", "ubuntu", Some("resolute"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_real_linux_riscv_ubuntu_resolute() {
|
|
differential_real_archive_package("linux-riscv", "ubuntu", Some("resolute"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_real_2048_universe_ubuntu_end_to_end() {
|
|
differential_real_archive_package("2048", "ubuntu", Some("noble"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_real_1oom_contrib_debian_end_to_end() {
|
|
differential_real_archive_package("1oom", "debian", Some("trixie"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_real_agg_svn_fallback_ok() {
|
|
differential_real_archive_package("agg", "debian", Some("trixie"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_real_hello_debian_latest_end_to_end() {
|
|
differential_real_archive_package("hello", "debian", None);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_real_hello_ubuntu_latest_end_to_end() {
|
|
differential_real_archive_package("hello", "ubuntu", None);
|
|
}
|
|
|
|
/// Arbitrary corpus via environment variables, for ad-hoc broad runs:
|
|
/// ```text
|
|
/// PKH_DIFF_PACKAGES="bash coreutils curl" \
|
|
/// PKH_DIFF_DIST=ubuntu \
|
|
/// PKH_DIFF_SERIES=noble \
|
|
/// cargo test --lib differential_real_archive_packages -- --ignored
|
|
/// ```
|
|
#[test]
|
|
#[ignore = "requires network access; intended for ad-hoc broad runs"]
|
|
fn differential_real_archive_packages() {
|
|
let packages = std::env::var("PKH_DIFF_PACKAGES")
|
|
.unwrap_or_else(|_| "hello".to_string())
|
|
.split_whitespace()
|
|
.map(str::to_string)
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(
|
|
!packages.is_empty(),
|
|
"PKH_DIFF_PACKAGES contained no package names"
|
|
);
|
|
|
|
let dist = std::env::var("PKH_DIFF_DIST").unwrap_or_else(|_| "ubuntu".to_string());
|
|
let series = std::env::var("PKH_DIFF_SERIES").ok();
|
|
|
|
for name in &packages {
|
|
differential_real_archive_package(name, &dist, series.as_deref());
|
|
}
|
|
}
|
|
}
|