Compare commits
7
Commits
53078fbca1
...
9f47e7dae8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f47e7dae8
|
||
|
|
c9b48d4573 | ||
|
|
429429e414 | ||
|
|
42fcfc2dfa | ||
|
|
dfaab0606a | ||
|
|
489b2aa29b | ||
|
|
4c52336000 |
@@ -0,0 +1,475 @@
|
|||||||
|
//! Binary-build metadata generation: native `.buildinfo` / `.changes`
|
||||||
|
//! production for binary-only builds (`pkh deb`), the equivalent of
|
||||||
|
//! `dpkg-genbuildinfo -b` + `dpkg-genchanges -b`.
|
||||||
|
//!
|
||||||
|
//! All tree/database access goes through a [`Context`] so the generation can
|
||||||
|
//! run against a build tree living in a local directory, an ephemeral
|
||||||
|
//! chroot or a remote host. Artifact digests are computed inside the context
|
||||||
|
//! with coreutils (`md5sum`, `sha1sum`, `sha256sum`, `stat`), keeping the
|
||||||
|
//! flow binary-safe regardless of the transport.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::context::Context;
|
||||||
|
use crate::debian::{
|
||||||
|
ChecksumEntry, ControlInfo, FileChecksums, FilesList, parse_changelog_entry_from_str,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Digests of one artifact.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
struct ArtifactHashes {
|
||||||
|
size: u64,
|
||||||
|
md5: String,
|
||||||
|
sha1: String,
|
||||||
|
sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options driving binary metadata generation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BinaryMetadataOptions {
|
||||||
|
/// Active build profiles (`Built-For-Profiles`).
|
||||||
|
pub profiles: Vec<String>,
|
||||||
|
/// Vendor name (`Build-Origin`).
|
||||||
|
pub vendor: String,
|
||||||
|
/// Parallel job count advertised in `DEB_BUILD_OPTIONS`.
|
||||||
|
pub parallel: usize,
|
||||||
|
/// Reproducible-builds epoch exported to the build.
|
||||||
|
pub source_date_epoch: i64,
|
||||||
|
/// Build architecture (the machine inside the build context).
|
||||||
|
pub build_arch: String,
|
||||||
|
/// Host architecture (the packages' target); equals the build
|
||||||
|
/// architecture except for cross builds.
|
||||||
|
pub host_arch: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate `<pkg>_<ver>_<arch>.buildinfo` and `.changes` for a finished
|
||||||
|
/// binary build, consuming `debian/files` from `package_dir` and the
|
||||||
|
/// artifacts sitting in `upload_dir`. Returns both paths (inside the
|
||||||
|
/// context).
|
||||||
|
///
|
||||||
|
/// Mirrors the observable behavior of `dpkg-genbuildinfo -b` and
|
||||||
|
/// `dpkg-genchanges -b`: sorted `Binary` list, encounter-order `Architecture`
|
||||||
|
/// accumulation, sorted `Description` lines formatted like dpkg, `.buildinfo`
|
||||||
|
/// registration in `debian/files`, and binary-NMU handling (`Source:
|
||||||
|
/// pkg (prev)` + previous `.dsc` redistribution when present).
|
||||||
|
pub fn generate_binary_metadata(
|
||||||
|
ctx: &Arc<Context>,
|
||||||
|
package_dir: &Path,
|
||||||
|
upload_dir: &Path,
|
||||||
|
opts: &BinaryMetadataOptions,
|
||||||
|
) -> Result<(PathBuf, PathBuf), Box<dyn Error>> {
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Metadata sources inside the context
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
let changelog_content = ctx.read_file(&package_dir.join("debian/changelog"))?;
|
||||||
|
let entry = parse_changelog_entry_from_str(&changelog_content)?;
|
||||||
|
|
||||||
|
let control_content = ctx.read_file(&package_dir.join("debian/control"))?;
|
||||||
|
let control = ControlInfo::parse_content(&control_content)?;
|
||||||
|
|
||||||
|
let files_content = ctx
|
||||||
|
.read_file(&package_dir.join("debian/files"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut files_list = FilesList::parse(&files_content)?;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Collect binary artifacts registered in debian/files
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
let artifact_names: Vec<String> = files_list
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e.package_type.as_deref(), Some("deb") | Some("udeb")))
|
||||||
|
.map(|e| e.filename.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if artifact_names.is_empty() {
|
||||||
|
return Err("binary build with no binary artifacts found; cannot distribute".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hashes = hashes_in_context(ctx, upload_dir, &artifact_names)?;
|
||||||
|
|
||||||
|
let mut checksums = FileChecksums::new();
|
||||||
|
let mut arch_values: Vec<String> = Vec::new();
|
||||||
|
let mut arch_seen = std::collections::HashSet::new();
|
||||||
|
for name in &artifact_names {
|
||||||
|
let entry_hashes = hashes
|
||||||
|
.remove(name)
|
||||||
|
.ok_or_else(|| format!("artifact '{name}' listed in debian/files but not found"))?;
|
||||||
|
checksums.insert_entry(
|
||||||
|
name,
|
||||||
|
ChecksumEntry {
|
||||||
|
size: entry_hashes.size,
|
||||||
|
md5: entry_hashes.md5,
|
||||||
|
sha1: entry_hashes.sha1,
|
||||||
|
sha256: entry_hashes.sha256,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Architecture accumulation in encounter order (dpkg-genchanges).
|
||||||
|
if let Some(file_entry) = files_list.get(name)
|
||||||
|
&& let Some(arch) = file_entry
|
||||||
|
.arch
|
||||||
|
.as_ref()
|
||||||
|
.or_else(|| file_entry.attrs.get("architecture"))
|
||||||
|
&& arch_seen.insert(arch.clone())
|
||||||
|
{
|
||||||
|
arch_values.push(arch.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Binary-NMU: redistribute the previous source when present
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
let sversion = entry.version.no_epoch();
|
||||||
|
let mut source_display = entry.source.clone();
|
||||||
|
let mut binary_only_changes = None;
|
||||||
|
|
||||||
|
if entry.binary_only
|
||||||
|
&& let Ok(prev_entry) = crate::debian::changelog::parse_previous_version_from_str(
|
||||||
|
&ctx.read_file(&package_dir.join("debian/changelog"))?,
|
||||||
|
)
|
||||||
|
&& let Some(prev) = prev_entry
|
||||||
|
{
|
||||||
|
source_display = format!("{} ({})", entry.source, prev);
|
||||||
|
binary_only_changes = Some(format!(
|
||||||
|
"{}\n\n -- {} <{}> {}",
|
||||||
|
entry.changes_field, entry.maintainer_name, entry.maintainer_email, entry.date_raw
|
||||||
|
));
|
||||||
|
let prev_version = crate::debian::DebianVersion::parse(&prev)?;
|
||||||
|
let dsc_name = format!("{}_{}.dsc", entry.source, prev_version.no_epoch());
|
||||||
|
let dsc_path = upload_dir.join(&dsc_name);
|
||||||
|
if ctx.exists(&dsc_path)? {
|
||||||
|
include_dsc_artifacts(ctx, upload_dir, &dsc_name, &mut checksums)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Binary package names and descriptions
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
let mut binaries: Vec<String> = Vec::new();
|
||||||
|
for name in &artifact_names {
|
||||||
|
if let Some(e) = files_list.get(name)
|
||||||
|
&& let Some(pkg) = &e.package
|
||||||
|
&& !binaries.contains(pkg)
|
||||||
|
{
|
||||||
|
binaries.push(pkg.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binaries.sort();
|
||||||
|
|
||||||
|
// Description lines: first line of each binary stanza's Description,
|
||||||
|
// formatted exactly like dpkg-genchanges, sorted.
|
||||||
|
let mut descriptions = Vec::new();
|
||||||
|
for stanza in &control.binaries {
|
||||||
|
let Some(pkg) = stanza.get("Package") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !binaries.contains(&pkg.to_string()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let summary = stanza
|
||||||
|
.get("Description")
|
||||||
|
.unwrap_or("no description available")
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap_or("no description available");
|
||||||
|
// Package-Type overrides the artifact-derived type (deb default).
|
||||||
|
let pkg_type = stanza
|
||||||
|
.get("Package-Type")
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
files_list
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.package.as_deref() == Some(pkg))
|
||||||
|
.and_then(|f| f.package_type.clone())
|
||||||
|
.unwrap_or_else(|| "deb".to_string())
|
||||||
|
});
|
||||||
|
descriptions.push(crate::build::changes::format_description(
|
||||||
|
pkg, &pkg_type, summary,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
descriptions.sort();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Installed-Build-Depends closure over the context status database
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
let status_content = ctx
|
||||||
|
.read_file(Path::new("/var/lib/dpkg/status"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let bd_fields = [
|
||||||
|
control.source.get("Build-Depends").unwrap_or(""),
|
||||||
|
control.source.get("Build-Depends-Arch").unwrap_or(""),
|
||||||
|
control.source.get("Build-Depends-Indep").unwrap_or(""),
|
||||||
|
];
|
||||||
|
let installed_build_depends =
|
||||||
|
crate::build::buildinfo::installed_build_depends_from_content(&status_content, &bd_fields)?;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// .buildinfo generation, then registration in debian/files
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
let pipeline_env = pipeline_environment(opts);
|
||||||
|
let environment = crate::build::env::buildinfo_environment(&pipeline_env);
|
||||||
|
|
||||||
|
// dpkg-genbuildinfo sorts the accumulated architecture values, while
|
||||||
|
// dpkg-genchanges keeps encounter order.
|
||||||
|
let mut buildinfo_arch_values = arch_values.clone();
|
||||||
|
buildinfo_arch_values.sort();
|
||||||
|
|
||||||
|
let buildinfo_name = format!("{}_{}_{}.buildinfo", entry.source, sversion, opts.host_arch);
|
||||||
|
let buildinfo_doc =
|
||||||
|
crate::build::buildinfo::render_buildinfo(&crate::build::buildinfo::BuildInfoInput {
|
||||||
|
source: source_display.clone(),
|
||||||
|
binaries: binaries.clone(),
|
||||||
|
architecture: buildinfo_arch_values.join(" "),
|
||||||
|
version: entry.version.full(),
|
||||||
|
binary_only_changes: binary_only_changes.clone(),
|
||||||
|
build_origin: opts.vendor.clone(),
|
||||||
|
build_architecture: opts.build_arch.clone(),
|
||||||
|
build_date: chrono::Local::now().to_rfc2822(),
|
||||||
|
checksums: checksums.clone(),
|
||||||
|
installed_build_depends,
|
||||||
|
environment,
|
||||||
|
});
|
||||||
|
let buildinfo_path = upload_dir.join(&buildinfo_name);
|
||||||
|
ctx.write_file(
|
||||||
|
&buildinfo_path,
|
||||||
|
&crate::debian::control::write_paragraph(&buildinfo_doc),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Register the .buildinfo in debian/files, like dpkg-genbuildinfo does,
|
||||||
|
// so the .changes distributes it.
|
||||||
|
files_list.add(crate::debian::FilesEntry::new(
|
||||||
|
&buildinfo_name,
|
||||||
|
control.section(),
|
||||||
|
control.priority(),
|
||||||
|
));
|
||||||
|
ctx.write_file(&package_dir.join("debian/files"), &files_list.render())?;
|
||||||
|
|
||||||
|
// Hash the freshly written .buildinfo inside the context.
|
||||||
|
let buildinfo_hashes =
|
||||||
|
hashes_in_context(ctx, upload_dir, std::slice::from_ref(&buildinfo_name))?;
|
||||||
|
if let Some(h) = buildinfo_hashes.get(&buildinfo_name) {
|
||||||
|
checksums.insert_entry(
|
||||||
|
&buildinfo_name,
|
||||||
|
ChecksumEntry {
|
||||||
|
size: h.size,
|
||||||
|
md5: h.md5.clone(),
|
||||||
|
sha1: h.sha1.clone(),
|
||||||
|
sha256: h.sha256.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// .changes generation
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
let changes_name = format!("{}_{}_{}.changes", entry.source, sversion, opts.host_arch);
|
||||||
|
let changed_by = format!("{} <{}>", entry.maintainer_name, entry.maintainer_email);
|
||||||
|
let changes_doc = crate::build::changes::render_changes(&crate::build::changes::ChangesInput {
|
||||||
|
date: entry.date_raw.clone(),
|
||||||
|
source: source_display,
|
||||||
|
binaries,
|
||||||
|
built_for_profiles: opts.profiles.clone(),
|
||||||
|
architecture: arch_values.join(" "),
|
||||||
|
version: entry.version.full(),
|
||||||
|
distribution: entry.distribution.clone(),
|
||||||
|
urgency: entry.urgency.clone(),
|
||||||
|
maintainer: control.source.get("Maintainer").map(str::to_string),
|
||||||
|
changed_by: Some(changed_by),
|
||||||
|
descriptions,
|
||||||
|
closes: entry.closes.clone(),
|
||||||
|
changes_field: entry.changes_field.clone(),
|
||||||
|
checksums,
|
||||||
|
files_list,
|
||||||
|
});
|
||||||
|
let changes_path = upload_dir.join(&changes_name);
|
||||||
|
ctx.write_file(
|
||||||
|
&changes_path,
|
||||||
|
&crate::debian::control::write_paragraph(&changes_doc),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok((buildinfo_path, changes_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Environment exported to the build steps; recorded (filtered) in the
|
||||||
|
/// `.buildinfo` `Environment` field.
|
||||||
|
fn pipeline_environment(opts: &BinaryMetadataOptions) -> BTreeMap<String, String> {
|
||||||
|
let mut env = BTreeMap::new();
|
||||||
|
env.insert(
|
||||||
|
"SOURCE_DATE_EPOCH".to_string(),
|
||||||
|
opts.source_date_epoch.to_string(),
|
||||||
|
);
|
||||||
|
env.insert(
|
||||||
|
"DEB_BUILD_OPTIONS".to_string(),
|
||||||
|
format!("parallel={}", opts.parallel),
|
||||||
|
);
|
||||||
|
if !opts.profiles.is_empty() {
|
||||||
|
env.insert("DEB_BUILD_PROFILES".to_string(), opts.profiles.join(","));
|
||||||
|
}
|
||||||
|
env
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute md5/sha1/sha256 digests and sizes for the named files inside the
|
||||||
|
/// context directory `dir`, using coreutils.
|
||||||
|
fn hashes_in_context(
|
||||||
|
ctx: &Arc<Context>,
|
||||||
|
dir: &Path,
|
||||||
|
names: &[String],
|
||||||
|
) -> Result<BTreeMap<String, ArtifactHashes>, Box<dyn Error>> {
|
||||||
|
let mut out: BTreeMap<String, ArtifactHashes> = names
|
||||||
|
.iter()
|
||||||
|
.map(|n| (n.clone(), ArtifactHashes::default()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sizes.
|
||||||
|
let output = ctx
|
||||||
|
.command("stat")
|
||||||
|
.current_dir(dir)
|
||||||
|
.arg("-c")
|
||||||
|
.arg("%s %n")
|
||||||
|
.args(names)
|
||||||
|
.output()?;
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
for line in stdout.lines() {
|
||||||
|
let Some((size, name)) = line.trim().split_once(' ') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(slot) = out.get_mut(name) {
|
||||||
|
slot.size = size.parse().unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digests.
|
||||||
|
for (tool, field) in [
|
||||||
|
("md5sum", 0usize),
|
||||||
|
("sha1sum", 1usize),
|
||||||
|
("sha256sum", 2usize),
|
||||||
|
] {
|
||||||
|
let output = ctx
|
||||||
|
.command(tool)
|
||||||
|
.current_dir(dir)
|
||||||
|
.args(names)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("failed to run '{tool}' inside the build context: {e}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"'{tool}' failed inside the build context: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
for line in stdout.lines() {
|
||||||
|
let Some((digest, name)) = line.trim().split_once(" ") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = name.trim_start_matches('*');
|
||||||
|
if let Some(slot) = out.get_mut(name) {
|
||||||
|
match field {
|
||||||
|
0 => slot.md5 = digest.to_string(),
|
||||||
|
1 => slot.sha1 = digest.to_string(),
|
||||||
|
_ => slot.sha256 = digest.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the `.dsc` checksums (and its referenced tarballs) into the
|
||||||
|
/// checksum registry, mirroring how binary-NMU uploads redistribute the
|
||||||
|
/// previous source.
|
||||||
|
fn include_dsc_artifacts(
|
||||||
|
ctx: &Arc<Context>,
|
||||||
|
upload_dir: &Path,
|
||||||
|
dsc_name: &str,
|
||||||
|
checksums: &mut FileChecksums,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let dsc_content = ctx.read_file(&upload_dir.join(dsc_name))?;
|
||||||
|
let para = crate::debian::control::parse_paragraphs(&dsc_content)
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| format!("'{dsc_name}' is empty"))?;
|
||||||
|
|
||||||
|
let mut names: Vec<String> = Vec::new();
|
||||||
|
let mut partials: BTreeMap<String, PartialDscChecksums> = BTreeMap::new();
|
||||||
|
for field in ["Checksums-Sha1", "Checksums-Sha256"] {
|
||||||
|
if let Some(value) = para.get(field) {
|
||||||
|
for line in value.lines() {
|
||||||
|
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if tokens.len() != 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let slot = partials.entry(tokens[2].to_string()).or_default();
|
||||||
|
if field == "Checksums-Sha1" {
|
||||||
|
slot.sha1 = Some(tokens[0].to_string());
|
||||||
|
} else {
|
||||||
|
slot.sha256 = Some(tokens[0].to_string());
|
||||||
|
}
|
||||||
|
slot.size = tokens[1].parse().ok().or(slot.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(files_value) = para.get("Files") {
|
||||||
|
for line in files_value.lines() {
|
||||||
|
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if tokens.len() >= 3 {
|
||||||
|
let slot = partials.entry(tokens[2].to_string()).or_default();
|
||||||
|
slot.md5 = Some(tokens[0].to_string());
|
||||||
|
slot.size = tokens[1].parse().ok().or(slot.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for field in ["Checksums-Sha1", "Checksums-Sha256"] {
|
||||||
|
if let Some(value) = para.get(field) {
|
||||||
|
for line in value.lines() {
|
||||||
|
if let Some(name) = line.split_whitespace().nth(2) {
|
||||||
|
names.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The .dsc itself is hashed fresh (it may be signed/rewritten); the
|
||||||
|
// tarballs reuse the .dsc-recorded digests, like dpkg-genchanges does.
|
||||||
|
let dsc_hashes =
|
||||||
|
hashes_in_context(ctx, upload_dir, std::slice::from_ref(&dsc_name.to_string()))?;
|
||||||
|
if let Some(h) = dsc_hashes.get(dsc_name) {
|
||||||
|
checksums.insert_entry(
|
||||||
|
dsc_name,
|
||||||
|
ChecksumEntry {
|
||||||
|
size: h.size,
|
||||||
|
md5: h.md5.clone(),
|
||||||
|
sha1: h.sha1.clone(),
|
||||||
|
sha256: h.sha256.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for name in &names {
|
||||||
|
if name == dsc_name {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let p = &partials[name];
|
||||||
|
checksums.insert_entry(
|
||||||
|
name,
|
||||||
|
ChecksumEntry {
|
||||||
|
size: p.size.unwrap_or(0),
|
||||||
|
md5: p.md5.clone().unwrap_or_default(),
|
||||||
|
sha1: p.sha1.clone().unwrap_or_default(),
|
||||||
|
sha256: p.sha256.clone().unwrap_or_default(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partially-known checksums taken from a `.dsc` checksum field.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct PartialDscChecksums {
|
||||||
|
size: Option<u64>,
|
||||||
|
md5: Option<String>,
|
||||||
|
sha1: Option<String>,
|
||||||
|
sha256: Option<String>,
|
||||||
|
}
|
||||||
+13
-8
@@ -28,13 +28,6 @@ struct StatusDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StatusDb {
|
impl StatusDb {
|
||||||
/// Parse a dpkg status file (e.g. `/var/lib/dpkg/status`).
|
|
||||||
fn load(path: &Path) -> Result<StatusDb, Box<dyn std::error::Error>> {
|
|
||||||
let content = std::fs::read_to_string(path)
|
|
||||||
.map_err(|e| format!("cannot read status file '{}': {}", path.display(), e))?;
|
|
||||||
Ok(Self::from_str(&content))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_str(content: &str) -> StatusDb {
|
fn from_str(content: &str) -> StatusDb {
|
||||||
let mut db = StatusDb::default();
|
let mut db = StatusDb::default();
|
||||||
for para in parse_paragraphs(content) {
|
for para in parse_paragraphs(content) {
|
||||||
@@ -132,7 +125,19 @@ pub fn installed_build_depends(
|
|||||||
status_path: &Path,
|
status_path: &Path,
|
||||||
build_depends_fields: &[&str],
|
build_depends_fields: &[&str],
|
||||||
) -> Result<String, Box<dyn std::error::Error>> {
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
let db = StatusDb::load(status_path)?;
|
let content = std::fs::read_to_string(status_path)
|
||||||
|
.map_err(|e| format!("cannot read status file '{}': {}", status_path.display(), e))?;
|
||||||
|
installed_build_depends_from_content(&content, build_depends_fields).map_err(|e| e.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the `Installed-Build-Depends` value from the textual content of a
|
||||||
|
/// dpkg status database (used when the database lives in another context,
|
||||||
|
/// e.g. inside a chroot).
|
||||||
|
pub fn installed_build_depends_from_content(
|
||||||
|
status_content: &str,
|
||||||
|
build_depends_fields: &[&str],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let db = StatusDb::from_str(status_content);
|
||||||
|
|
||||||
let mut work: VecDeque<String> = VecDeque::new();
|
let mut work: VecDeque<String> = VecDeque::new();
|
||||||
for name in &db.essential {
|
for name in &db.essential {
|
||||||
|
|||||||
+16
-35
@@ -1,10 +1,9 @@
|
|||||||
//! Build environment setup: `SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`,
|
//! Build environment setup: `SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`,
|
||||||
//! architecture variables (via `dpkg-architecture`) and the sanitized
|
//! architecture variables (native `dpkg-architecture` equivalent) and the
|
||||||
//! environment recorded in `.buildinfo` files.
|
//! sanitized environment recorded in `.buildinfo` files.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
/// Number of parallel jobs to advertise in `DEB_BUILD_OPTIONS`.
|
/// Number of parallel jobs to advertise in `DEB_BUILD_OPTIONS`.
|
||||||
pub fn num_parallel() -> usize {
|
pub fn num_parallel() -> usize {
|
||||||
@@ -13,20 +12,26 @@ pub fn num_parallel() -> usize {
|
|||||||
.unwrap_or(1)
|
.unwrap_or(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the environment variables exported by `dpkg-buildpackage` before
|
/// Compute the environment variables exported before running any build step.
|
||||||
/// running any build step.
|
|
||||||
///
|
///
|
||||||
/// Mirrors dpkg behavior:
|
/// Mirrors dpkg behavior:
|
||||||
/// - `SOURCE_DATE_EPOCH` from the changelog entry timestamp
|
/// - `SOURCE_DATE_EPOCH` from the changelog entry timestamp
|
||||||
/// (<https://reproducible-builds.org/specs/source-date-epoch/>),
|
/// (<https://reproducible-builds.org/specs/source-date-epoch/>),
|
||||||
/// - `DEB_BUILD_OPTIONS=parallel=N` (auto-detected job count),
|
/// - `DEB_BUILD_OPTIONS=parallel=N` (auto-detected job count),
|
||||||
/// - `DEB_BUILD_PROFILES` when non-default profiles are requested.
|
/// - `DEB_BUILD_PROFILES` when non-default profiles are requested.
|
||||||
|
///
|
||||||
|
/// The locale is pinned to `C` (`LC_ALL`, which takes precedence over any
|
||||||
|
/// inherited session setting, plus `LANG`) so build tools emit deterministic,
|
||||||
|
/// English diagnostics — required for reliable log classification and
|
||||||
|
/// reproducible builds.
|
||||||
pub fn build_env(
|
pub fn build_env(
|
||||||
source_date_epoch: i64,
|
source_date_epoch: i64,
|
||||||
parallel: usize,
|
parallel: usize,
|
||||||
build_profiles: &[String],
|
build_profiles: &[String],
|
||||||
) -> BTreeMap<String, String> {
|
) -> BTreeMap<String, String> {
|
||||||
let mut env = BTreeMap::new();
|
let mut env = BTreeMap::new();
|
||||||
|
env.insert("LANG".to_string(), "C".to_string());
|
||||||
|
env.insert("LC_ALL".to_string(), "C".to_string());
|
||||||
env.insert(
|
env.insert(
|
||||||
"SOURCE_DATE_EPOCH".to_string(),
|
"SOURCE_DATE_EPOCH".to_string(),
|
||||||
source_date_epoch.to_string(),
|
source_date_epoch.to_string(),
|
||||||
@@ -41,41 +46,15 @@ pub fn build_env(
|
|||||||
env
|
env
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Import the full architecture variable set by running
|
/// Import the full architecture variable set, computed natively by
|
||||||
/// `dpkg-architecture -f [-a <host-arch>]` and parsing its `KEY=VALUE` dump.
|
/// [`crate::debian::arch`] (the equivalent of `dpkg-architecture -f
|
||||||
|
/// [-a <host-arch>]`).
|
||||||
///
|
///
|
||||||
/// This exports all `DEB_BUILD_*`, `DEB_HOST_*` and `DEB_TARGET_*` variables
|
/// This exports all `DEB_BUILD_*`, `DEB_HOST_*` and `DEB_TARGET_*` variables
|
||||||
/// (`*_ARCH`, `*_OS`, `*_CPU`, `*_MULTIARCH`, `*_GNU_TYPE`, ...), exactly as
|
/// (`*_ARCH`, `*_OS`, `*_CPU`, `*_MULTIARCH`, `*_GNU_TYPE`, ...), exactly as
|
||||||
/// `dpkg-buildpackage` does.
|
/// `dpkg-buildpackage` does.
|
||||||
pub fn arch_env(host_arch: Option<&str>) -> Result<BTreeMap<String, String>, String> {
|
pub fn arch_env(host_arch: Option<&str>) -> Result<BTreeMap<String, String>, String> {
|
||||||
let mut cmd = Command::new("dpkg-architecture");
|
crate::debian::arch::arch_env(host_arch)
|
||||||
cmd.arg("-f");
|
|
||||||
if let Some(arch) = host_arch {
|
|
||||||
cmd.args(["--host-arch", arch]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = cmd.output().map_err(|e| {
|
|
||||||
format!(
|
|
||||||
"failed to run 'dpkg-architecture': {}. Is 'dpkg-dev' installed?",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
return Err(format!(
|
|
||||||
"dpkg-architecture failed with status {}: {}",
|
|
||||||
output.status,
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut env = BTreeMap::new();
|
|
||||||
for line in String::from_utf8_lossy(&output.stdout).lines() {
|
|
||||||
if let Some((key, value)) = line.split_once('=') {
|
|
||||||
env.insert(key.to_string(), value.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(env)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the current vendor name from `/etc/dpkg/origins/default`
|
/// Read the current vendor name from `/etc/dpkg/origins/default`
|
||||||
@@ -284,6 +263,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn build_env_values() {
|
fn build_env_values() {
|
||||||
let env = build_env(1787392800, 16, &[]);
|
let env = build_env(1787392800, 16, &[]);
|
||||||
|
assert_eq!(env.get("LANG").unwrap(), "C");
|
||||||
|
assert_eq!(env.get("LC_ALL").unwrap(), "C");
|
||||||
assert_eq!(env.get("SOURCE_DATE_EPOCH").unwrap(), "1787392800");
|
assert_eq!(env.get("SOURCE_DATE_EPOCH").unwrap(), "1787392800");
|
||||||
assert_eq!(env.get("DEB_BUILD_OPTIONS").unwrap(), "parallel=16");
|
assert_eq!(env.get("DEB_BUILD_OPTIONS").unwrap(), "parallel=16");
|
||||||
assert!(!env.contains_key("DEB_BUILD_PROFILES"));
|
assert!(!env.contains_key("DEB_BUILD_PROFILES"));
|
||||||
|
|||||||
+528
-25
@@ -6,6 +6,7 @@
|
|||||||
//! source-tree work (tarballs, diffs, patches) to `dpkg-source` as a
|
//! source-tree work (tarballs, diffs, patches) to `dpkg-source` as a
|
||||||
//! subprocess.
|
//! subprocess.
|
||||||
|
|
||||||
|
pub mod binary;
|
||||||
pub mod buildinfo;
|
pub mod buildinfo;
|
||||||
pub mod buildtype;
|
pub mod buildtype;
|
||||||
pub mod changes;
|
pub mod changes;
|
||||||
@@ -14,11 +15,16 @@ pub mod env;
|
|||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::context::capture::pump;
|
||||||
|
use crate::context::{LineSink, Stream};
|
||||||
use crate::debian::{
|
use crate::debian::{
|
||||||
ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList, parse_paragraphs,
|
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.
|
/// Options for a native source-package build.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -28,6 +34,10 @@ pub struct SourceBuildOptions {
|
|||||||
pub sign_keyid: Option<String>,
|
pub sign_keyid: Option<String>,
|
||||||
/// Sign even for an UNRELEASED changelog (`--force-sign`).
|
/// Sign even for an UNRELEASED changelog (`--force-sign`).
|
||||||
pub force_sign: bool,
|
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.
|
/// Artifacts produced by a successful source build.
|
||||||
@@ -47,11 +57,46 @@ pub struct SourceBuildOutput {
|
|||||||
|
|
||||||
/// Build a Debian source package (to a .dsc) using the native pipeline.
|
/// Build a Debian source package (to a .dsc) using the native pipeline.
|
||||||
///
|
///
|
||||||
/// Keeps the historical pkh entry-point signature; see [`run_source_build`]
|
/// When `ui` is set, subprocess output is captured into a live view (status
|
||||||
/// for the configurable version.
|
/// bar + rolling pane) and tee'd to a log file; on failure the view prints a
|
||||||
pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box<dyn Error>> {
|
/// summary of the last captured errors. Without a UI, commands inherit the
|
||||||
|
/// terminal as before.
|
||||||
|
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 cwd = cwd.unwrap_or_else(|| Path::new("."));
|
||||||
let output = run_source_build(cwd, &SourceBuildOptions::default())?;
|
let output = match run_source_build(cwd, &SourceBuildOptions::default(), ui.clone()) {
|
||||||
|
Ok(output) => output,
|
||||||
|
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 {
|
if output.signed {
|
||||||
println!("Package built and signed successfully!");
|
println!("Package built and signed successfully!");
|
||||||
@@ -76,7 +121,9 @@ pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box<dyn Error>> {
|
|||||||
pub fn run_source_build(
|
pub fn run_source_build(
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
opts: &SourceBuildOptions,
|
opts: &SourceBuildOptions,
|
||||||
|
ui: Option<Arc<DebUi>>,
|
||||||
) -> Result<SourceBuildOutput, Box<dyn Error>> {
|
) -> Result<SourceBuildOutput, Box<dyn Error>> {
|
||||||
|
let sink: Option<Arc<dyn LineSink>> = ui.as_ref().map(|u| u.sink());
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 1. Sanity checks
|
// 1. Sanity checks
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -111,9 +158,9 @@ pub fn run_source_build(
|
|||||||
let entry = crate::debian::parse_changelog_entry(&changelog_path)?;
|
let entry = crate::debian::parse_changelog_entry(&changelog_path)?;
|
||||||
let ctrl = ControlInfo::parse(&control_path)?;
|
let ctrl = ControlInfo::parse(&control_path)?;
|
||||||
|
|
||||||
log::info!("source package {}", entry.source);
|
if let Some(u) = &ui {
|
||||||
log::info!("source version {}", entry.version.full());
|
u.set_build_target(&entry.source, &entry.version.full(), &entry.distribution);
|
||||||
log::info!("source distribution {}", entry.distribution);
|
}
|
||||||
|
|
||||||
// binNMU builds reference the *previous* (source) version in their
|
// binNMU builds reference the *previous* (source) version in their
|
||||||
// artifact metadata, like dpkg-genchanges/genbuildinfo do.
|
// artifact metadata, like dpkg-genchanges/genbuildinfo do.
|
||||||
@@ -162,19 +209,19 @@ pub fn run_source_build(
|
|||||||
if signing_key.is_none() {
|
if signing_key.is_none() {
|
||||||
match crate::utils::gpg::find_signing_key_for_email(&entry.maintainer_email) {
|
match crate::utils::gpg::find_signing_key_for_email(&entry.maintainer_email) {
|
||||||
Ok(Some(key)) => {
|
Ok(Some(key)) => {
|
||||||
log::info!("using GPG key {} for signing", key);
|
log::info!("Using GPG key {} for signing", key);
|
||||||
signing_key = Some(key);
|
signing_key = Some(key);
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"no GPG secret key found for {} <{}>, building without signing",
|
"No GPG secret key found for {} <{}>, building without signing",
|
||||||
entry.maintainer_name,
|
entry.maintainer_name,
|
||||||
entry.maintainer_email
|
entry.maintainer_email
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"failed to check for GPG key: {}, building without signing",
|
"Failed to check for GPG key: {}, building without signing",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,7 +230,7 @@ pub fn run_source_build(
|
|||||||
let do_sign = match &signing_key {
|
let do_sign = match &signing_key {
|
||||||
None => false,
|
None => false,
|
||||||
Some(_) if entry.distribution == "UNRELEASED" && !opts.force_sign => {
|
Some(_) if entry.distribution == "UNRELEASED" && !opts.force_sign => {
|
||||||
log::warn!("not signing UNRELEASED build; use force_sign to override");
|
log::warn!("Not signing UNRELEASED build; use force_sign to override");
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
Some(_) => true,
|
Some(_) => true,
|
||||||
@@ -192,13 +239,54 @@ pub fn run_source_build(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 5. dpkg-source lifecycle: before-build + source build
|
// 5. dpkg-source lifecycle: before-build + source build
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
if let Some(u) = &ui {
|
||||||
|
u.phase_custom("Applying patches", Box::new(DpkgSourceClassifier::new()));
|
||||||
|
}
|
||||||
run_command(
|
run_command(
|
||||||
cwd,
|
cwd,
|
||||||
"dpkg-source",
|
"dpkg-source",
|
||||||
&["-I", "-i", "--before-build", "."],
|
&["-I", "-i", "--before-build", "."],
|
||||||
&pipeline_env,
|
&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_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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
run_command(
|
||||||
|
cwd,
|
||||||
|
"dpkg-source",
|
||||||
|
&["-I", "-i", "-b", "."],
|
||||||
|
&pipeline_env,
|
||||||
|
sink.as_ref(),
|
||||||
)?;
|
)?;
|
||||||
run_command(cwd, "dpkg-source", &["-I", "-i", "-b", "."], &pipeline_env)?;
|
|
||||||
|
|
||||||
if !dsc_path.exists() {
|
if !dsc_path.exists() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -232,6 +320,9 @@ pub fn run_source_build(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 6. .buildinfo generation (native dpkg-genbuildinfo equivalent)
|
// 6. .buildinfo generation (native dpkg-genbuildinfo equivalent)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
if let Some(u) = &ui {
|
||||||
|
u.progress_message("Generating .buildinfo");
|
||||||
|
}
|
||||||
let mut checksums = FileChecksums::new();
|
let mut checksums = FileChecksums::new();
|
||||||
checksums.add_file_as(&ref_dsc_path, &ref_dsc_name)?;
|
checksums.add_file_as(&ref_dsc_path, &ref_dsc_name)?;
|
||||||
|
|
||||||
@@ -277,6 +368,9 @@ pub fn run_source_build(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 7. .changes generation (native dpkg-genchanges equivalent)
|
// 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
|
// Pull the tarball checksums out of the referenced .dsc so they are
|
||||||
// distributed through the .changes like dpkg-genchanges does, in the
|
// distributed through the .changes like dpkg-genchanges does, in the
|
||||||
// order the .dsc itself lists them.
|
// order the .dsc itself lists them.
|
||||||
@@ -389,11 +483,15 @@ pub fn run_source_build(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 8. dpkg-source after-build (unapplies quilt patches it applied)
|
// 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(
|
run_command(
|
||||||
cwd,
|
cwd,
|
||||||
"dpkg-source",
|
"dpkg-source",
|
||||||
&["-I", "-i", "--after-build", "."],
|
&["-I", "-i", "--after-build", "."],
|
||||||
&pipeline_env,
|
&pipeline_env,
|
||||||
|
sink.as_ref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -403,7 +501,11 @@ pub fn run_source_build(
|
|||||||
if let Some(keyid) = signing_key.filter(|_| do_sign) {
|
if let Some(keyid) = signing_key.filter(|_| do_sign) {
|
||||||
crate::utils::gpg::validate_key_id(&keyid)?;
|
crate::utils::gpg::validate_key_id(&keyid)?;
|
||||||
|
|
||||||
println!("signfile {}", dsc_name);
|
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)?;
|
crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?;
|
||||||
// The freshly built .dsc changed: refresh its checksums inside the
|
// The freshly built .dsc changed: refresh its checksums inside the
|
||||||
// .buildinfo. For binary-only builds the metadata references the
|
// .buildinfo. For binary-only builds the metadata references the
|
||||||
@@ -414,13 +516,13 @@ pub fn run_source_build(
|
|||||||
}
|
}
|
||||||
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
|
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
|
||||||
|
|
||||||
println!("signfile {}", buildinfo_name);
|
log::info!("Signing {}", buildinfo_name);
|
||||||
crate::utils::gpg::clearsign_file(&buildinfo_path, &keyid)?;
|
crate::utils::gpg::clearsign_file(&buildinfo_path, &keyid)?;
|
||||||
// Both .dsc and .buildinfo changed: refresh the .changes.
|
// Both .dsc and .buildinfo changed: refresh the .changes.
|
||||||
checksums.add_file_as(&buildinfo_path, &buildinfo_name)?;
|
checksums.add_file_as(&buildinfo_path, &buildinfo_name)?;
|
||||||
changes::save_changes(&changes_path, &render_changes_doc(&checksums))?;
|
changes::save_changes(&changes_path, &render_changes_doc(&checksums))?;
|
||||||
|
|
||||||
println!("signfile {}", changes_name);
|
log::info!("Signing {}", changes_name);
|
||||||
crate::utils::gpg::clearsign_file(&changes_path, &keyid)?;
|
crate::utils::gpg::clearsign_file(&changes_path, &keyid)?;
|
||||||
|
|
||||||
signed = true;
|
signed = true;
|
||||||
@@ -445,13 +547,17 @@ struct PartialChecksum {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run a build command in `cwd` with extra environment variables layered on
|
/// Run a build command in `cwd` with extra environment variables layered on
|
||||||
/// top of the inherited environment, with stdio attached to the terminal.
|
/// top of the inherited environment.
|
||||||
|
///
|
||||||
|
/// When `sink` is set, stdout/stderr are piped and every line is forwarded to
|
||||||
|
/// it (live view + tee log); otherwise stdio is inherited from the terminal.
|
||||||
/// Returns an error on non-zero exit status.
|
/// Returns an error on non-zero exit status.
|
||||||
fn run_command(
|
fn run_command(
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
program: &str,
|
program: &str,
|
||||||
args: &[&str],
|
args: &[&str],
|
||||||
env: &BTreeMap<String, String>,
|
env: &BTreeMap<String, String>,
|
||||||
|
sink: Option<&Arc<dyn LineSink>>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"running: {} {} (in {})",
|
"running: {} {} (in {})",
|
||||||
@@ -459,12 +565,44 @@ fn run_command(
|
|||||||
args.join(" "),
|
args.join(" "),
|
||||||
cwd.display()
|
cwd.display()
|
||||||
);
|
);
|
||||||
let status = Command::new(program)
|
|
||||||
.current_dir(cwd)
|
let mut cmd = Command::new(program);
|
||||||
.envs(env)
|
cmd.current_dir(cwd).envs(env).args(args);
|
||||||
.args(args)
|
|
||||||
.status()
|
let status = match sink {
|
||||||
.map_err(|e| format!("failed to run '{}': {}", program, e))?;
|
None => cmd
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("failed to run '{}': {}", program, e))?,
|
||||||
|
Some(sink) => {
|
||||||
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("failed to run '{}': {}", program, e))?;
|
||||||
|
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 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(err) = stderr {
|
||||||
|
pump(err, Stream::Stderr, &*err_sink);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = out_thread.join();
|
||||||
|
let _ = err_thread.join();
|
||||||
|
|
||||||
|
child
|
||||||
|
.wait()
|
||||||
|
.map_err(|e| format!("failed to wait for '{}': {}", program, e))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -747,7 +885,7 @@ mod differential_tests {
|
|||||||
if matches!(key, "Checksums-Sha1" | "Checksums-Sha256" | "Files") {
|
if matches!(key, "Checksums-Sha1" | "Checksums-Sha256" | "Files") {
|
||||||
let without_buildinfo = |v: &str| -> String {
|
let without_buildinfo = |v: &str| -> String {
|
||||||
v.lines()
|
v.lines()
|
||||||
.filter(|l| !l.trim_end().ends_with("_source.buildinfo"))
|
.filter(|l| !l.trim_end().ends_with(".buildinfo"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n")
|
.join("\n")
|
||||||
};
|
};
|
||||||
@@ -825,7 +963,7 @@ mod differential_tests {
|
|||||||
let ours_tree = ours_root.join(&tree_name);
|
let ours_tree = ours_root.join(&tree_name);
|
||||||
|
|
||||||
run_dpkg(&golden_tree);
|
run_dpkg(&golden_tree);
|
||||||
run_source_build(&ours_tree, &SourceBuildOptions::default())
|
run_source_build(&ours_tree, &SourceBuildOptions::default(), None)
|
||||||
.expect("native source pipeline failed");
|
.expect("native source pipeline failed");
|
||||||
|
|
||||||
let entry =
|
let entry =
|
||||||
@@ -859,6 +997,371 @@ mod differential_tests {
|
|||||||
differential_on_tree(&tree);
|
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).
|
||||||
|
let output = Command::new("dpkg-checkbuilddeps")
|
||||||
|
.current_dir(dir.path())
|
||||||
|
.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 opts = crate::debian::deps::CheckOpts {
|
||||||
|
host_arch: crate::debian::arch::native().unwrap_or_else(|_| "amd64".into()),
|
||||||
|
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 = Command::new("dpkg-buildpackage")
|
||||||
|
.current_dir(&golden_tree)
|
||||||
|
.args(["-b", "-d", "--no-sign"])
|
||||||
|
.status()
|
||||||
|
.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: Vec<(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 = Command::new("debian/rules")
|
||||||
|
.current_dir(&ours_tree)
|
||||||
|
.envs(build_env_vars.clone())
|
||||||
|
.arg(target)
|
||||||
|
.status()
|
||||||
|
.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,
|
||||||
|
));
|
||||||
|
let native_arch = crate::debian::arch::native().unwrap_or_else(|_| "amd64".into());
|
||||||
|
let opts = crate::build::binary::BinaryMetadataOptions {
|
||||||
|
profiles,
|
||||||
|
vendor,
|
||||||
|
parallel,
|
||||||
|
source_date_epoch: entry.timestamp,
|
||||||
|
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]
|
#[test]
|
||||||
fn diff_native_minimal() {
|
fn diff_native_minimal() {
|
||||||
differential_case(&FixtureSpec::new("pkh-diff-a", "1.0-1", "unstable"));
|
differential_case(&FixtureSpec::new("pkh-diff-a", "1.0-1", "unstable"));
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
mod api;
|
mod api;
|
||||||
mod capture;
|
pub(crate) mod capture;
|
||||||
mod local;
|
mod local;
|
||||||
mod manager;
|
mod manager;
|
||||||
mod schroot;
|
mod schroot;
|
||||||
|
|||||||
@@ -227,6 +227,17 @@ pub async fn build(
|
|||||||
.to_str()
|
.to_str()
|
||||||
.ok_or("Invalid package directory path")?;
|
.ok_or("Invalid package directory path")?;
|
||||||
|
|
||||||
|
// Reproducibility: export SOURCE_DATE_EPOCH from the changelog entry,
|
||||||
|
// like dpkg-buildpackage does.
|
||||||
|
match ctx.read_file(&package_dir.join("debian/changelog")) {
|
||||||
|
Ok(content) => {
|
||||||
|
if let Ok(entry) = crate::debian::parse_changelog_entry_from_str(&content) {
|
||||||
|
env.insert("SOURCE_DATE_EPOCH".to_string(), entry.timestamp.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => log::debug!("cannot read changelog for SOURCE_DATE_EPOCH: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
// Apply quilt patches if the package provides a patch series
|
// Apply quilt patches if the package provides a patch series
|
||||||
apply_quilt_patches(package_dir_str, &env, ctx.clone(), &ui, &sink)?;
|
apply_quilt_patches(package_dir_str, &env, ctx.clone(), &ui, &sink)?;
|
||||||
|
|
||||||
@@ -318,6 +329,91 @@ pub async fn build(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate the upload metadata (.buildinfo + .changes) natively, the
|
||||||
|
// equivalent of dpkg-genbuildinfo -b + dpkg-genchanges -b, consuming
|
||||||
|
// debian/files produced by the build. Failures are logged but do not
|
||||||
|
// discard the produced binaries.
|
||||||
|
if let Err(e) = generate_upload_metadata(package_dir_str, build_root, arch, cross, &env, &ctx) {
|
||||||
|
warn!("failed to generate .buildinfo/.changes: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate `.buildinfo` and `.changes` for the finished binary build,
|
||||||
|
/// inside the build context.
|
||||||
|
fn generate_upload_metadata(
|
||||||
|
package_dir: &str,
|
||||||
|
build_root: &str,
|
||||||
|
arch: &str,
|
||||||
|
cross: bool,
|
||||||
|
env: &HashMap<String, String>,
|
||||||
|
ctx: &Arc<Context>,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let changelog_path = Path::new(package_dir).join("debian/changelog");
|
||||||
|
let changelog_content = ctx.read_file(&changelog_path)?;
|
||||||
|
let entry = crate::debian::parse_changelog_entry_from_str(&changelog_content)?;
|
||||||
|
|
||||||
|
// Build architecture: the machine inside the build context.
|
||||||
|
let build_arch = ctx
|
||||||
|
.command("dpkg")
|
||||||
|
.arg("--print-architecture")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(crate::get_current_arch);
|
||||||
|
let host_arch = if cross {
|
||||||
|
arch.to_string()
|
||||||
|
} else {
|
||||||
|
build_arch.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vendor resolution inside the context (falls back to the host view).
|
||||||
|
let vendor = ctx
|
||||||
|
.read_file(Path::new("/etc/dpkg/origins/default"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|content| {
|
||||||
|
for line in content.lines() {
|
||||||
|
if let Some(v) = line.strip_prefix("Vendor:") {
|
||||||
|
let v = v.trim();
|
||||||
|
if !v.is_empty() {
|
||||||
|
return Some(v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
})
|
||||||
|
.unwrap_or_else(crate::build::env::current_vendor);
|
||||||
|
|
||||||
|
let profiles = crate::build::env::resolve_build_profiles(&[], &vendor);
|
||||||
|
let source_date_epoch = env
|
||||||
|
.get("SOURCE_DATE_EPOCH")
|
||||||
|
.and_then(|v| v.parse::<i64>().ok())
|
||||||
|
.unwrap_or(entry.timestamp);
|
||||||
|
|
||||||
|
let opts = crate::build::binary::BinaryMetadataOptions {
|
||||||
|
profiles,
|
||||||
|
vendor,
|
||||||
|
parallel: crate::build::env::num_parallel(),
|
||||||
|
source_date_epoch,
|
||||||
|
build_arch,
|
||||||
|
host_arch,
|
||||||
|
};
|
||||||
|
let (buildinfo, changes) = crate::build::binary::generate_binary_metadata(
|
||||||
|
ctx,
|
||||||
|
Path::new(package_dir),
|
||||||
|
Path::new(build_root),
|
||||||
|
&opts,
|
||||||
|
)?;
|
||||||
|
log::info!(
|
||||||
|
"generated upload metadata: {} and {}",
|
||||||
|
buildinfo.display(),
|
||||||
|
changes.display()
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -165,14 +165,22 @@ async fn build_binary_package_impl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve produced .deb files
|
// Retrieve produced artifacts (.deb files plus the upload metadata
|
||||||
|
// (.buildinfo/.changes) generated natively after the build)
|
||||||
if let Some(u) = ui {
|
if let Some(u) = ui {
|
||||||
u.phase(Phase::RetrievingArtifacts);
|
u.phase(Phase::RetrievingArtifacts);
|
||||||
}
|
}
|
||||||
let remote_files = build_ctx.list_files(Path::new(&build_root))?;
|
let remote_files = build_ctx.list_files(Path::new(&build_root))?;
|
||||||
let deb_files: Vec<PathBuf> = remote_files
|
let deb_files: Vec<PathBuf> = remote_files
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|f| f.extension().is_some_and(|ext| ext == "deb"))
|
.filter(|f| {
|
||||||
|
f.extension().is_some_and(|ext| {
|
||||||
|
matches!(
|
||||||
|
ext.to_str(),
|
||||||
|
Some("deb") | Some("buildinfo") | Some("changes")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let total_debs = deb_files.len();
|
let total_debs = deb_files.len();
|
||||||
|
|
||||||
|
|||||||
+1027
File diff suppressed because it is too large
Load Diff
+26
-23
@@ -46,7 +46,15 @@ pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std:
|
|||||||
e
|
e
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
parse_changelog_entry_from_str(&content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the most recent changelog entry from its textual content. `origin`
|
||||||
|
/// is used in error messages only.
|
||||||
|
pub fn parse_changelog_entry_from_str(
|
||||||
|
content: &str,
|
||||||
|
) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
||||||
|
let origin = "changelog";
|
||||||
let mut lines = content.lines().peekable();
|
let mut lines = content.lines().peekable();
|
||||||
|
|
||||||
// --- Header line: `package (version) distributions; urgency=medium[, key=value]`
|
// --- Header line: `package (version) distributions; urgency=medium[, key=value]`
|
||||||
@@ -55,18 +63,14 @@ pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std:
|
|||||||
Some(l) if l.trim().is_empty() => continue,
|
Some(l) if l.trim().is_empty() => continue,
|
||||||
Some(l) => break l.trim_end(),
|
Some(l) => break l.trim_end(),
|
||||||
None => {
|
None => {
|
||||||
return Err(format!("changelog '{}' is empty", path.display()).into());
|
return Err(format!("changelog '{origin}' is empty").into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let open = header.find('(').ok_or_else(|| {
|
let open = header
|
||||||
format!(
|
.find('(')
|
||||||
"invalid changelog header in '{}': {}",
|
.ok_or_else(|| format!("invalid changelog header in '{origin}': {header}"))?;
|
||||||
path.display(),
|
|
||||||
header
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let close = header[open..]
|
let close = header[open..]
|
||||||
.find(')')
|
.find(')')
|
||||||
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{}'", header))?;
|
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{}'", header))?;
|
||||||
@@ -123,9 +127,8 @@ pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std:
|
|||||||
|
|
||||||
let trailer_line = trailer.ok_or_else(|| {
|
let trailer_line = trailer.ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"no maintainer trailer found in '{}': expected a line of the form \
|
"no maintainer trailer found in '{origin}': expected a line of the form \
|
||||||
' -- Name <email> Date'",
|
' -- Name <email> Date'"
|
||||||
path.display()
|
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -147,14 +150,7 @@ pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std:
|
|||||||
let date_raw = trailer_body[gt + 1..].trim().to_string();
|
let date_raw = trailer_body[gt + 1..].trim().to_string();
|
||||||
|
|
||||||
let timestamp = DateTime::parse_from_rfc2822(&date_raw)
|
let timestamp = DateTime::parse_from_rfc2822(&date_raw)
|
||||||
.map_err(|e| {
|
.map_err(|e| format!("cannot parse changelog date '{date_raw}' in '{origin}': {e}"))?
|
||||||
format!(
|
|
||||||
"cannot parse changelog date '{}' in '{}': {}",
|
|
||||||
date_raw,
|
|
||||||
path.display(),
|
|
||||||
e
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
.timestamp();
|
.timestamp();
|
||||||
|
|
||||||
// Changes field value (leading `\n` marks it as a pre-wrapped multiline
|
// Changes field value (leading `\n` marks it as a pre-wrapped multiline
|
||||||
@@ -220,7 +216,14 @@ fn find_closes(body_lines: &[String]) -> Option<String> {
|
|||||||
pub fn parse_previous_version(path: &Path) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
pub fn parse_previous_version(path: &Path) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||||
let content = std::fs::read_to_string(path)
|
let content = std::fs::read_to_string(path)
|
||||||
.map_err(|e| format!("failed to read changelog '{}': {}", path.display(), e))?;
|
.map_err(|e| format!("failed to read changelog '{}': {}", path.display(), e))?;
|
||||||
|
parse_previous_version_from_str(&content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the version of the *previous* changelog entry from the textual
|
||||||
|
/// content of a changelog file.
|
||||||
|
pub fn parse_previous_version_from_str(
|
||||||
|
content: &str,
|
||||||
|
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||||
let mut seen_first = false;
|
let mut seen_first = false;
|
||||||
for line in content.lines() {
|
for line in content.lines() {
|
||||||
let line = line.trim_end();
|
let line = line.trim_end();
|
||||||
@@ -229,12 +232,12 @@ pub fn parse_previous_version(path: &Path) -> Result<Option<String>, Box<dyn std
|
|||||||
seen_first = true;
|
seen_first = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let open = line.find('(').ok_or_else(|| {
|
let open = line
|
||||||
format!("invalid changelog header in '{}': {}", path.display(), line)
|
.find('(')
|
||||||
})?;
|
.ok_or_else(|| format!("invalid changelog header: {line}"))?;
|
||||||
let close = line[open..]
|
let close = line[open..]
|
||||||
.find(')')
|
.find(')')
|
||||||
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{}'", line))?;
|
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{line}'"))?;
|
||||||
return Ok(Some(line[open + 1..open + close].to_string()));
|
return Ok(Some(line[open + 1..open + close].to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1312
File diff suppressed because it is too large
Load Diff
+28
-10
@@ -114,29 +114,30 @@ impl FilesList {
|
|||||||
|
|
||||||
/// Load `debian/files`. A missing file yields an empty registry.
|
/// Load `debian/files`. A missing file yields an empty registry.
|
||||||
pub fn load(path: &Path) -> Result<FilesList, Box<dyn std::error::Error>> {
|
pub fn load(path: &Path) -> Result<FilesList, Box<dyn std::error::Error>> {
|
||||||
let mut list = FilesList::new();
|
|
||||||
let content = match std::fs::read_to_string(path) {
|
let content = match std::fs::read_to_string(path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(list),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(FilesList::new()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Err(format!("cannot read '{}': {}", path.display(), e).into());
|
return Err(format!("cannot read '{}': {}", path.display(), e).into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
FilesList::parse(&content).map_err(|e| format!("in '{}': {}", path.display(), e).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `debian/files` registry from its textual content
|
||||||
|
/// (`filename section priority [key=value...]` lines).
|
||||||
|
pub fn parse(content: &str) -> Result<FilesList, String> {
|
||||||
|
let mut list = FilesList::new();
|
||||||
for line in content.lines() {
|
for line in content.lines() {
|
||||||
if line.trim().is_empty() {
|
if line.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||||
if tokens.len() < 3 {
|
if tokens.len() < 3 {
|
||||||
return Err(format!("badly formed line in '{}': {}", path.display(), line).into());
|
return Err(format!("badly formed line: {line}"));
|
||||||
}
|
}
|
||||||
let mut entry = parse_filename(tokens[0]).ok_or_else(|| {
|
let mut entry = parse_filename(tokens[0])
|
||||||
format!(
|
.ok_or_else(|| format!("badly formed file name: {}", tokens[0]))?;
|
||||||
"badly formed file name in '{}': {}",
|
|
||||||
path.display(),
|
|
||||||
tokens[0]
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
entry.section = tokens[1].to_string();
|
entry.section = tokens[1].to_string();
|
||||||
entry.priority = tokens[2].to_string();
|
entry.priority = tokens[2].to_string();
|
||||||
for attr in &tokens[3..] {
|
for attr in &tokens[3..] {
|
||||||
@@ -181,6 +182,23 @@ impl FilesList {
|
|||||||
self.files.is_empty()
|
self.files.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the registry to its textual `debian/files` representation.
|
||||||
|
pub fn render(&self) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for entry in self.iter() {
|
||||||
|
out.push_str(&entry.filename);
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(&entry.section);
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(&entry.priority);
|
||||||
|
for (k, v) in &entry.attrs {
|
||||||
|
out.push_str(&format!(" {k}={v}"));
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Save atomically: write `<path>.new` then rename over `path`, like
|
/// Save atomically: write `<path>.new` then rename over `path`, like
|
||||||
/// dpkg does.
|
/// dpkg does.
|
||||||
pub fn save_atomic(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn save_atomic(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|||||||
+9
-2
@@ -3,19 +3,26 @@
|
|||||||
//! These components are independent from any build orchestration and can be
|
//! These components are independent from any build orchestration and can be
|
||||||
//! used by any pkh submodule (or external consumers of the library):
|
//! used by any pkh submodule (or external consumers of the library):
|
||||||
//!
|
//!
|
||||||
|
//! - [`arch`]: Debian architecture tables and lookups (dpkg-architecture)
|
||||||
//! - [`control`]: deb822 paragraph parsing/writing and `debian/control`
|
//! - [`control`]: deb822 paragraph parsing/writing and `debian/control`
|
||||||
//! - [`checksums`]: file checksum registry (`Dpkg::Checksums` equivalent)
|
//! - [`checksums`]: file checksum registry (`Dpkg::Checksums` equivalent)
|
||||||
|
//! - [`deps`]: dependency grammar and evaluation (dpkg-checkbuilddeps)
|
||||||
//! - [`files`]: `debian/files` artifact registry (`Dpkg::Dist::Files`)
|
//! - [`files`]: `debian/files` artifact registry (`Dpkg::Dist::Files`)
|
||||||
//! - [`version`]: Debian version splitting/validation
|
//! - [`version`]: Debian version splitting/validation/comparison
|
||||||
//! - [`changelog`]: `debian/changelog` entry parsing
|
//! - [`changelog`]: `debian/changelog` entry parsing
|
||||||
|
|
||||||
|
pub mod arch;
|
||||||
pub mod changelog;
|
pub mod changelog;
|
||||||
pub mod checksums;
|
pub mod checksums;
|
||||||
pub mod control;
|
pub mod control;
|
||||||
|
pub mod deps;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
pub use changelog::{ChangelogEntry, parse_changelog_entry};
|
pub use changelog::{
|
||||||
|
ChangelogEntry, parse_changelog_entry, parse_changelog_entry_from_str,
|
||||||
|
parse_previous_version_from_str,
|
||||||
|
};
|
||||||
pub use checksums::{Entry as ChecksumEntry, FileChecksums};
|
pub use checksums::{Entry as ChecksumEntry, FileChecksums};
|
||||||
pub use control::{ControlInfo, Paragraph, parse_paragraphs, write_paragraph};
|
pub use control::{ControlInfo, Paragraph, parse_paragraphs, write_paragraph};
|
||||||
pub use files::{FilesEntry, FilesList};
|
pub use files::{FilesEntry, FilesList};
|
||||||
|
|||||||
+276
-1
@@ -1,4 +1,4 @@
|
|||||||
//! Debian version handling: splitting and validation of
|
//! Debian version handling: splitting, validation and ordering of
|
||||||
//! `[epoch:]upstream[-revision]` version strings.
|
//! `[epoch:]upstream[-revision]` version strings.
|
||||||
|
|
||||||
/// A Debian version, split into its `[epoch:]upstream[-revision]` parts.
|
/// A Debian version, split into its `[epoch:]upstream[-revision]` parts.
|
||||||
@@ -83,6 +83,217 @@ impl DebianVersion {
|
|||||||
None => self.upstream.clone(),
|
None => self.upstream.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience predicate: whether this version orders strictly later
|
||||||
|
/// than `other`.
|
||||||
|
pub fn later_than(&self, other: &DebianVersion) -> bool {
|
||||||
|
self > other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two versions according to dpkg's ordering algorithm
|
||||||
|
/// (Debian Policy §5.6.1 / `dpkg(1)`):
|
||||||
|
///
|
||||||
|
/// - the epoch compares numerically (a missing epoch counts as `0`),
|
||||||
|
/// - then the upstream version and the Debian revision compare by
|
||||||
|
/// alternating non-digit and digit chunks, from left to right,
|
||||||
|
/// - in non-digit chunks letters sort earlier than non-letters, and `~`
|
||||||
|
/// sorts before anything, including the end of the chunk,
|
||||||
|
/// - digit chunks compare numerically (leading zeroes are irrelevant; an
|
||||||
|
/// empty digit chunk counts as `0`, so a missing revision equals `0`).
|
||||||
|
pub fn compare(a: &DebianVersion, b: &DebianVersion) -> std::cmp::Ordering {
|
||||||
|
a.epoch
|
||||||
|
.unwrap_or(0)
|
||||||
|
.cmp(&b.epoch.unwrap_or(0))
|
||||||
|
.then_with(|| verrevcmp(a.upstream.as_bytes(), b.upstream.as_bytes()))
|
||||||
|
.then_with(|| {
|
||||||
|
verrevcmp(
|
||||||
|
a.debian_revision.as_deref().unwrap_or("").as_bytes(),
|
||||||
|
b.debian_revision.as_deref().unwrap_or("").as_bytes(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort weight of a character inside a non-digit chunk: `~` sorts before the
|
||||||
|
/// end of the chunk, letters before non-letters, everything else by ASCII
|
||||||
|
/// order.
|
||||||
|
fn char_order(c: u8) -> i32 {
|
||||||
|
if c == b'~' {
|
||||||
|
-1
|
||||||
|
} else if c.is_ascii_alphabetic() {
|
||||||
|
i32::from(c)
|
||||||
|
} else {
|
||||||
|
i32::from(c) + 256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare the upstream/revision part of two versions by alternating
|
||||||
|
/// non-digit and digit chunks.
|
||||||
|
fn verrevcmp(mut a: &[u8], mut b: &[u8]) -> std::cmp::Ordering {
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
|
while !a.is_empty() || !b.is_empty() {
|
||||||
|
let mut first_diff: i32 = 0;
|
||||||
|
|
||||||
|
// Non-digit chunks: compare by character weight. A chunk boundary
|
||||||
|
// (end of string or start of a digit run) weighs 0, which sorts
|
||||||
|
// after `~` (-1) and before every real character.
|
||||||
|
while (!a.is_empty() && !a[0].is_ascii_digit()) || (!b.is_empty() && !b[0].is_ascii_digit())
|
||||||
|
{
|
||||||
|
let ac = if !a.is_empty() && !a[0].is_ascii_digit() {
|
||||||
|
char_order(a[0])
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let bc = if !b.is_empty() && !b[0].is_ascii_digit() {
|
||||||
|
char_order(b[0])
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
if ac != bc {
|
||||||
|
return ac.cmp(&bc);
|
||||||
|
}
|
||||||
|
// Reaching here means both sides carried equal real characters.
|
||||||
|
a = &a[1..];
|
||||||
|
b = &b[1..];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digit chunks: strip leading zeroes, then the number whose
|
||||||
|
// remaining digit run is longer is larger; otherwise the first
|
||||||
|
// differing digit decides.
|
||||||
|
while !a.is_empty() && a[0] == b'0' {
|
||||||
|
a = &a[1..];
|
||||||
|
}
|
||||||
|
while !b.is_empty() && b[0] == b'0' {
|
||||||
|
b = &b[1..];
|
||||||
|
}
|
||||||
|
while !a.is_empty() && !b.is_empty() && a[0].is_ascii_digit() && b[0].is_ascii_digit() {
|
||||||
|
if first_diff == 0 {
|
||||||
|
first_diff = i32::from(a[0]) - i32::from(b[0]);
|
||||||
|
}
|
||||||
|
a = &a[1..];
|
||||||
|
b = &b[1..];
|
||||||
|
}
|
||||||
|
if !a.is_empty() && a[0].is_ascii_digit() {
|
||||||
|
return Ordering::Greater;
|
||||||
|
}
|
||||||
|
if !b.is_empty() && b[0].is_ascii_digit() {
|
||||||
|
return Ordering::Less;
|
||||||
|
}
|
||||||
|
if first_diff != 0 {
|
||||||
|
return first_diff.cmp(&0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ordering::Equal
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for DebianVersion {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for DebianVersion {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
compare(self, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test vectors ported from dpkg's `scripts/t/Dpkg_Version.t` (`__DATA__`
|
||||||
|
/// section): `(version_a, version_b, expected_cmp)` with `-1/0/1`. Shared
|
||||||
|
/// with the differential tests against real `dpkg --compare-versions`.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_vectors {
|
||||||
|
/// `(a, b, cmp)` triples.
|
||||||
|
pub(crate) const COMPARE: &[(&str, &str, i32)] = &[
|
||||||
|
("1.0-1", "2.0-2", -1),
|
||||||
|
("2.2~rc-4", "2.2-1", -1),
|
||||||
|
("2.2-1", "2.2~rc-4", 1),
|
||||||
|
("1.0000-1", "1.0-1", 0),
|
||||||
|
("1", "0:1", 0),
|
||||||
|
("0", "0:0-0", 0),
|
||||||
|
("2:2.5", "1:7.5", 1),
|
||||||
|
("1:0foo", "0foo", 1),
|
||||||
|
("0:0foo", "0foo", 0),
|
||||||
|
("0foo", "0foo", 0),
|
||||||
|
("0foo-0", "0foo", 0),
|
||||||
|
("0foo", "0foo-0", 0),
|
||||||
|
("0foo", "0fo", 1),
|
||||||
|
("0foo-0", "0foo+", -1),
|
||||||
|
("0foo~1", "0foo", -1),
|
||||||
|
("0foo~foo+Bar", "0foo~foo+bar", -1),
|
||||||
|
("0foo~~", "0foo~", -1),
|
||||||
|
("1~", "1", -1),
|
||||||
|
(
|
||||||
|
"12345+that-really-is-some-ver-0",
|
||||||
|
"12345+that-really-is-some-ver-10",
|
||||||
|
-1,
|
||||||
|
),
|
||||||
|
("0foo-0", "0foo-01", -1),
|
||||||
|
("0foo.bar", "0foobar", 1),
|
||||||
|
("0foo.bar", "0foo1bar", 1),
|
||||||
|
("0foo.bar", "0foo0bar", 1),
|
||||||
|
("0foo1bar-1", "0foobar-1", -1),
|
||||||
|
("0foo2.0", "0foo2", 1),
|
||||||
|
("0foo2.0.0", "0foo2.10.0", -1),
|
||||||
|
("0foo2.0", "0foo2.0.0", -1),
|
||||||
|
("0foo2.0", "0foo2.10", -1),
|
||||||
|
("0foo2.1", "0foo2.10", -1),
|
||||||
|
("1.09", "1.9", 0),
|
||||||
|
("1.0.8+nmu1", "1.0.8", 1),
|
||||||
|
("3.11", "3.10+nmu1", 1),
|
||||||
|
("0.9j-20080306-4", "0.9i-20070324-2", 1),
|
||||||
|
("1.2.0~b7-1", "1.2.0~b6-1", 1),
|
||||||
|
("1.011-1", "1.06-2", 1),
|
||||||
|
("0.0.9+dfsg1-1", "0.0.8+dfsg1-3", 1),
|
||||||
|
("4.6.99+svn6582-1", "4.6.99+svn6496-1", 1),
|
||||||
|
("53", "52", 1),
|
||||||
|
("0.9.9~pre122-1", "0.9.9~pre111-1", 1),
|
||||||
|
("2:2.3.2-2+lenny2", "2:2.3.2-2", 1),
|
||||||
|
("1:3.8.1-1", "3.8.GA-1", 1),
|
||||||
|
("1.0.1+gpl-1", "1.0.1-2", 1),
|
||||||
|
("1a", "1000a", -1),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Unsorted lists with their expected order under dpkg comparison.
|
||||||
|
pub(crate) const SORTED: &[(&[&str], &[&str])] = &[
|
||||||
|
(
|
||||||
|
&[
|
||||||
|
"4:4-4",
|
||||||
|
"5.0abc",
|
||||||
|
"0.0-0.0alpha0",
|
||||||
|
"10.100.1-1",
|
||||||
|
"0~999.999zeta",
|
||||||
|
"0:1.0-0",
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
"0~999.999zeta",
|
||||||
|
"0.0-0.0alpha0",
|
||||||
|
"0:1.0-0",
|
||||||
|
"5.0abc",
|
||||||
|
"10.100.1-1",
|
||||||
|
"4:4-4",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[
|
||||||
|
"4",
|
||||||
|
"5.0abc",
|
||||||
|
"0.0alpha0",
|
||||||
|
"10.100.1",
|
||||||
|
"0~999.999zeta",
|
||||||
|
"1.0",
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
"0~999.999zeta",
|
||||||
|
"0.0alpha0",
|
||||||
|
"1.0",
|
||||||
|
"4",
|
||||||
|
"5.0abc",
|
||||||
|
"10.100.1",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -117,4 +328,68 @@ mod tests {
|
|||||||
assert!(DebianVersion::parse("1.0").is_ok());
|
assert!(DebianVersion::parse("1.0").is_ok());
|
||||||
assert!(DebianVersion::parse("1.0~rc1-2").is_ok());
|
assert!(DebianVersion::parse("1.0~rc1-2").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmp_sign(a: &DebianVersion, b: &DebianVersion) -> i32 {
|
||||||
|
match a.cmp(b) {
|
||||||
|
std::cmp::Ordering::Less => -1,
|
||||||
|
std::cmp::Ordering::Equal => 0,
|
||||||
|
std::cmp::Ordering::Greater => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All vectors from dpkg's own `Dpkg_Version.t` must pass.
|
||||||
|
#[test]
|
||||||
|
fn comparison_dpkg_vectors() {
|
||||||
|
for (a, b, expected) in test_vectors::COMPARE {
|
||||||
|
let va = DebianVersion::parse(a).unwrap_or_else(|e| panic!("parse {a}: {e}"));
|
||||||
|
let vb = DebianVersion::parse(b).unwrap_or_else(|e| panic!("parse {b}: {e}"));
|
||||||
|
assert_eq!(
|
||||||
|
cmp_sign(&va, &vb),
|
||||||
|
*expected,
|
||||||
|
"{a} cmp {b} must be {expected}"
|
||||||
|
);
|
||||||
|
// Ordering is antisymmetric.
|
||||||
|
assert_eq!(cmp_sign(&vb, &va), -*expected, "{b} cmp {a}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sorting_dpkg_vectors() {
|
||||||
|
for (unsorted, expected) in test_vectors::SORTED {
|
||||||
|
let mut versions: Vec<DebianVersion> = unsorted
|
||||||
|
.iter()
|
||||||
|
.map(|v| DebianVersion::parse(v).unwrap())
|
||||||
|
.collect();
|
||||||
|
versions.sort();
|
||||||
|
let rendered: Vec<String> = versions.iter().map(DebianVersion::full).collect();
|
||||||
|
let expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
|
||||||
|
assert_eq!(rendered, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ubuntu-flavored cases: security updates, backports, PPA versions.
|
||||||
|
#[test]
|
||||||
|
fn comparison_ubuntu_flavored() {
|
||||||
|
let cases: &[(&str, &str, i32)] = &[
|
||||||
|
// Security update on top of a release upload.
|
||||||
|
("1.0-0ubuntu1", "1.0-0ubuntu1.22.04.1", -1),
|
||||||
|
// PPA/backports pre-releases sort before the real upload.
|
||||||
|
("1.0-0ubuntu1~ppa1", "1.0-0ubuntu1", -1),
|
||||||
|
("1.0~bpo22.04.1", "1.0", -1),
|
||||||
|
// Series-specific uploads.
|
||||||
|
("2.3-1ubuntu3.22.04.2", "2.3-1ubuntu3", 1),
|
||||||
|
("1:2.0.4-0ubuntu1", "1:2.0.4-0ubuntu1.1", -1),
|
||||||
|
];
|
||||||
|
for (a, b, expected) in cases {
|
||||||
|
let va = DebianVersion::parse(a).unwrap();
|
||||||
|
let vb = DebianVersion::parse(b).unwrap();
|
||||||
|
assert_eq!(cmp_sign(&va, &vb), *expected, "{a} cmp {b}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// later_than convenience.
|
||||||
|
let old = DebianVersion::parse("1.0-0ubuntu1").unwrap();
|
||||||
|
let new = DebianVersion::parse("1.0-0ubuntu1.22.04.1").unwrap();
|
||||||
|
assert!(new.later_than(&old));
|
||||||
|
assert!(!old.later_than(&old));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-3
@@ -58,7 +58,11 @@ fn main() {
|
|||||||
.arg(arg!(--backport "This changelog is for a backport entry").required(false))
|
.arg(arg!(--backport "This changelog is for a backport entry").required(false))
|
||||||
.arg(arg!(-v --version <version> "Target version").required(false)),
|
.arg(arg!(-v --version <version> "Target version").required(false)),
|
||||||
)
|
)
|
||||||
.subcommand(Command::new("build").about("Build the source package (into a .dsc)"))
|
.subcommand(
|
||||||
|
Command::new("build")
|
||||||
|
.about("Build the source package (into a .dsc)")
|
||||||
|
.arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false)),
|
||||||
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
Command::new("deb")
|
Command::new("deb")
|
||||||
.about("Build the source package into binary package (.deb)")
|
.about("Build the source package into binary package (.deb)")
|
||||||
@@ -248,10 +252,30 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Some(("build", _sub_matches)) => {
|
Some(("build", sub_matches)) => {
|
||||||
let cwd = current_dir_or_exit();
|
let cwd = current_dir_or_exit();
|
||||||
if let Err(e) = pkh::build::build_source_package(Some(&cwd)) {
|
let verbose = sub_matches
|
||||||
|
.get_one::<bool>("verbose")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Live build view: disabled by --verbose or when stdout is not a
|
||||||
|
// terminal (DebUi handles the non-TTY case itself)
|
||||||
|
let ui = if verbose {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(std::sync::Arc::new(pkh::ui::deb::DebUi::new(&multi)))
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = pkh::build::build_source_package(Some(&cwd), ui) {
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
|
// Unmet build dependencies/conflicts exit with status 3,
|
||||||
|
// like dpkg-buildpackage does.
|
||||||
|
if e.downcast_ref::<pkh::debian::deps::UnmetBuildDependencies>()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
std::process::exit(3);
|
||||||
|
}
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,27 @@ use crossterm::{
|
|||||||
};
|
};
|
||||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Render a path for terminal display: relative to the current working
|
||||||
|
/// directory when the target lives inside it or directly next to it
|
||||||
|
/// (`../name`, the usual layout of build artifacts), absolute otherwise.
|
||||||
|
pub fn display_path(path: &Path) -> String {
|
||||||
|
let Ok(cwd) = std::env::current_dir() else {
|
||||||
|
return path.display().to_string();
|
||||||
|
};
|
||||||
|
if let Ok(rel) = path.strip_prefix(&cwd) {
|
||||||
|
return rel.display().to_string();
|
||||||
|
}
|
||||||
|
if let Some(parent) = cwd.parent()
|
||||||
|
&& let Ok(rel) = path.strip_prefix(parent)
|
||||||
|
{
|
||||||
|
return format!("../{}", rel.display());
|
||||||
|
}
|
||||||
|
path.display().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a spinner-style progress bar attached to `multi`, returning the bar
|
/// Create a spinner-style progress bar attached to `multi`, returning the bar
|
||||||
/// and a callback compatible with [`crate::ProgressCallback`]
|
/// and a callback compatible with [`crate::ProgressCallback`]
|
||||||
pub fn create_progress_bar(
|
pub fn create_progress_bar(
|
||||||
|
|||||||
+45
-20
@@ -1,6 +1,6 @@
|
|||||||
//! Live UI for `pkh deb`: a status bar with the current build phase on top
|
//! Live build view (`pkh deb`, `pkh build`): a status bar with the current
|
||||||
//! and a rolling pane of rewritten log lines below ("a terminal in the
|
//! build phase on top and a rolling pane of rewritten log lines below
|
||||||
//! terminal").
|
//! ("a terminal in the terminal").
|
||||||
//!
|
//!
|
||||||
//! Subprocess output is captured through a [`LineSink`] implementation,
|
//! Subprocess output is captured through a [`LineSink`] implementation,
|
||||||
//! rewritten by classifiers ([`crate::ui::logfmt`]) and rendered in place
|
//! rewritten by classifiers ([`crate::ui::logfmt`]) and rendered in place
|
||||||
@@ -127,7 +127,7 @@ struct Shared {
|
|||||||
started: Instant,
|
started: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live build view for `pkh deb`
|
/// Live build view for `pkh deb` / `pkh build`
|
||||||
///
|
///
|
||||||
/// Create one per build (disabled automatically when stdout is not a TTY or
|
/// Create one per build (disabled automatically when stdout is not a TTY or
|
||||||
/// when the user requests verbose output), pass it down as
|
/// when the user requests verbose output), pass it down as
|
||||||
@@ -202,20 +202,35 @@ impl DebUi {
|
|||||||
ui
|
ui
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identify the package being built; names the log file and the status bar
|
/// Identify the binary package being built; names the log file and the
|
||||||
|
/// status bar
|
||||||
pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) {
|
pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) {
|
||||||
if self.shared.enabled {
|
if self.shared.enabled {
|
||||||
self.shared.top.set_prefix(format!(
|
self.shared.top.set_prefix(format!(
|
||||||
"Building {package} ({version}) for {series}/{arch}"
|
"Building {package} ({version}) for {series}/{arch}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
self.open_log("deb", package, version, &format!("for {series}/{arch}"));
|
||||||
|
}
|
||||||
|
|
||||||
// Rename the log file to include the package identity (best-effort),
|
/// Identify the source package being built; names the log file
|
||||||
// then open it so subsequent captured lines are tee'd.
|
/// (`build-<package>-<version>-<timestamp>.log`) and the status bar
|
||||||
|
pub fn set_build_target(&self, package: &str, version: &str, distribution: &str) {
|
||||||
|
if self.shared.enabled {
|
||||||
|
self.shared.top.set_prefix(format!(
|
||||||
|
"Building source package {package} ({version}) for {distribution}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.open_log("build", package, version, &format!("for {distribution}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename the placeholder log file to include the build identity
|
||||||
|
/// (best-effort), then open it so subsequent captured lines are tee'd
|
||||||
|
fn open_log(&self, kind: &str, package: &str, version: &str, detail: &str) {
|
||||||
let old_path = self.shared.log_path.lock().unwrap().clone();
|
let old_path = self.shared.log_path.lock().unwrap().clone();
|
||||||
let log_path = match old_path.parent() {
|
let log_path = match old_path.parent() {
|
||||||
Some(dir) => dir.join(format!(
|
Some(dir) => dir.join(format!(
|
||||||
"deb-{package}-{version}-{}.log",
|
"{kind}-{package}-{version}-{}.log",
|
||||||
self.shared.timestamp
|
self.shared.timestamp
|
||||||
)),
|
)),
|
||||||
None => old_path.clone(),
|
None => old_path.clone(),
|
||||||
@@ -231,11 +246,7 @@ impl DebUi {
|
|||||||
Ok(mut file) => {
|
Ok(mut file) => {
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
file,
|
file,
|
||||||
"# pkh deb {} ({}) for {}/{} started {}",
|
"# pkh {kind} {package} ({version}) {detail} started {}",
|
||||||
package,
|
|
||||||
version,
|
|
||||||
series,
|
|
||||||
arch,
|
|
||||||
chrono::Utc::now().to_rfc3339()
|
chrono::Utc::now().to_rfc3339()
|
||||||
);
|
);
|
||||||
*self.shared.tee.lock().unwrap() = Some(file);
|
*self.shared.tee.lock().unwrap() = Some(file);
|
||||||
@@ -252,12 +263,18 @@ impl DebUi {
|
|||||||
|
|
||||||
/// Switch to a phase, installing its default classifier
|
/// Switch to a phase, installing its default classifier
|
||||||
pub fn phase(&self, phase: Phase) {
|
pub fn phase(&self, phase: Phase) {
|
||||||
self.phase_with(phase, default_classifier(phase));
|
self.phase_custom(phase.label(), default_classifier(phase));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switch to a phase with a custom classifier (e.g. quilt with a known
|
/// Switch to a phase with a custom classifier (e.g. quilt with a known
|
||||||
/// patch count)
|
/// patch count)
|
||||||
pub fn phase_with(&self, phase: Phase, classifier: Box<dyn Classifier>) {
|
pub fn phase_with(&self, phase: Phase, classifier: Box<dyn Classifier>) {
|
||||||
|
self.phase_custom(phase.label(), classifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to an arbitrary status label with a custom classifier; used by
|
||||||
|
/// flows whose phases are not part of [`Phase`] (e.g. source builds)
|
||||||
|
pub fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
|
||||||
{
|
{
|
||||||
let mut st = self.shared.state.lock().unwrap();
|
let mut st = self.shared.state.lock().unwrap();
|
||||||
st.classifier = classifier;
|
st.classifier = classifier;
|
||||||
@@ -267,7 +284,7 @@ impl DebUi {
|
|||||||
}
|
}
|
||||||
if self.shared.enabled {
|
if self.shared.enabled {
|
||||||
self.shared.top.set_style(spinner_style());
|
self.shared.top.set_style(spinner_style());
|
||||||
self.shared.top.set_message(phase.label());
|
self.shared.top.set_message(label.to_string());
|
||||||
self.shared.pane.set_message("");
|
self.shared.pane.set_message("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,6 +316,12 @@ impl DebUi {
|
|||||||
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
|
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the widget renders at all (false on non-TTY stdout); callers
|
||||||
|
/// use this to fall back to plain-line summaries
|
||||||
|
pub fn is_enabled(&self) -> bool {
|
||||||
|
self.shared.enabled
|
||||||
|
}
|
||||||
|
|
||||||
/// Obtain a sink feeding this view; pass it to `ContextCommand::capture`
|
/// Obtain a sink feeding this view; pass it to `ContextCommand::capture`
|
||||||
pub fn sink(self: &Arc<Self>) -> Arc<dyn LineSink> {
|
pub fn sink(self: &Arc<Self>) -> Arc<dyn LineSink> {
|
||||||
Arc::new(Sink {
|
Arc::new(Sink {
|
||||||
@@ -325,14 +348,15 @@ impl DebUi {
|
|||||||
self.shared.pane.finish_and_clear();
|
self.shared.pane.finish_and_clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the widget and print a success summary with the artifacts
|
/// Clear the widget and print a success summary with the artifacts,
|
||||||
|
/// rendered relative to the working directory when possible
|
||||||
pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) {
|
pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) {
|
||||||
self.suspend();
|
self.suspend();
|
||||||
if self.shared.enabled && !artifacts.is_empty() {
|
if self.shared.enabled && !artifacts.is_empty() {
|
||||||
|
println!("Built in {}s:", elapsed.as_secs());
|
||||||
for artifact in artifacts {
|
for artifact in artifacts {
|
||||||
println!(" → {}", artifact.display());
|
println!(" {}", crate::ui::display_path(artifact));
|
||||||
}
|
}
|
||||||
println!(" ✔ Built in {}s", elapsed.as_secs());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,12 +518,13 @@ fn is_stdout_tty() -> bool {
|
|||||||
unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
|
unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default log file path for a given timestamp
|
/// Default (placeholder) log file path for a given timestamp; renamed by
|
||||||
|
/// [`DebUi::set_target`] / [`DebUi::set_build_target`] once the target is known
|
||||||
fn default_log_path(timestamp: &str) -> PathBuf {
|
fn default_log_path(timestamp: &str) -> PathBuf {
|
||||||
let dir = ProjectDirs::from("com", "pkh", "pkh")
|
let dir = ProjectDirs::from("com", "pkh", "pkh")
|
||||||
.map(|dirs| dirs.cache_dir().join("logs"))
|
.map(|dirs| dirs.cache_dir().join("logs"))
|
||||||
.unwrap_or_else(std::env::temp_dir);
|
.unwrap_or_else(std::env::temp_dir);
|
||||||
dir.join(format!("deb-{timestamp}.log"))
|
dir.join(format!("pkh-{timestamp}.log"))
|
||||||
}
|
}
|
||||||
|
|
||||||
static SIGINT_LOG_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
|
static SIGINT_LOG_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||||
|
|||||||
@@ -282,6 +282,54 @@ impl Classifier for MakeClassifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Classifier for `dpkg-source` output (source-build phases)
|
||||||
|
///
|
||||||
|
/// The build pipeline pins `LC_ALL=C`, so dpkg-source emits stable English
|
||||||
|
/// messages prefixed with `info:` / `warning:` / `error:`; the prefix is
|
||||||
|
/// stripped and the severity drives the pane color. Raw `tar:` diagnostics
|
||||||
|
/// emitted while repacking tarballs are surfaced too.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DpkgSourceClassifier {}
|
||||||
|
|
||||||
|
impl DpkgSourceClassifier {
|
||||||
|
/// Create a new classifier
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Classifier for DpkgSourceClassifier {
|
||||||
|
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
|
||||||
|
const PREFIX: &str = "dpkg-source: ";
|
||||||
|
let rest = line.strip_prefix(PREFIX).unwrap_or(line);
|
||||||
|
|
||||||
|
if let Some(rest) = rest.strip_prefix("info: ") {
|
||||||
|
vec![Action::Shown(truncate(rest))]
|
||||||
|
} else if let Some(rest) = rest.strip_prefix("warning: ") {
|
||||||
|
vec![Action::Warning(truncate(rest))]
|
||||||
|
} else if let Some(rest) = rest.strip_prefix("error: ") {
|
||||||
|
vec![Action::Error(truncate(rest))]
|
||||||
|
} else if let Some(tar) = rest.strip_prefix("tar: ") {
|
||||||
|
// Diagnostics from the tarball repacking subprocess; warnings
|
||||||
|
// about unknown header keywords are benign, real failures are not.
|
||||||
|
let lower = tar.to_lowercase();
|
||||||
|
if ["error", "cannot", "failed", "exited"]
|
||||||
|
.iter()
|
||||||
|
.any(|m| lower.contains(m))
|
||||||
|
{
|
||||||
|
vec![Action::Error(truncate(tar))]
|
||||||
|
} else {
|
||||||
|
vec![Action::Warning(truncate(tar))]
|
||||||
|
}
|
||||||
|
} else if line == PREFIX.trim_end() || rest.is_empty() {
|
||||||
|
vec![Action::Hidden]
|
||||||
|
} else {
|
||||||
|
// Unprefixed output from a foreign subprocess: keep it visible
|
||||||
|
vec![Action::Shown(truncate(line))]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Classifier for `mmdebstrap` output (chroot tarball creation)
|
/// Classifier for `mmdebstrap` output (chroot tarball creation)
|
||||||
///
|
///
|
||||||
/// mmdebstrap prefixes its own messages with `I:` / `W:` / `E:`; everything
|
/// mmdebstrap prefixes its own messages with `I:` / `W:` / `E:`; everything
|
||||||
@@ -499,6 +547,88 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dpkg_source_severity_prefixes() {
|
||||||
|
let mut c = DpkgSourceClassifier::new();
|
||||||
|
assert_eq!(
|
||||||
|
feed_one(
|
||||||
|
&mut c,
|
||||||
|
"dpkg-source: info: using patch list from debian/patches/series"
|
||||||
|
),
|
||||||
|
vec![Action::Shown(
|
||||||
|
"using patch list from debian/patches/series".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
feed_one(
|
||||||
|
&mut c,
|
||||||
|
"dpkg-source: info: applying patch debian/patches/reproducible.patch"
|
||||||
|
),
|
||||||
|
vec![Action::Shown(
|
||||||
|
"applying patch debian/patches/reproducible.patch".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
feed_one(
|
||||||
|
&mut c,
|
||||||
|
"dpkg-source: info: building hello in ../hello_2.10-5.dsc"
|
||||||
|
),
|
||||||
|
vec![Action::Shown(
|
||||||
|
"building hello in ../hello_2.10-5.dsc".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
feed_one(
|
||||||
|
&mut c,
|
||||||
|
"dpkg-source: warning: upstream signing key but no upstream signature"
|
||||||
|
),
|
||||||
|
vec![Action::Warning(
|
||||||
|
"upstream signing key but no upstream signature".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
feed_one(
|
||||||
|
&mut c,
|
||||||
|
"dpkg-source: error: unrepresentable changes to source"
|
||||||
|
),
|
||||||
|
vec![Action::Error(
|
||||||
|
"unrepresentable changes to source".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dpkg_source_tar_and_unknown_lines() {
|
||||||
|
let mut c = DpkgSourceClassifier::new();
|
||||||
|
// Benign tar header-keyword warnings stay yellow
|
||||||
|
assert_eq!(
|
||||||
|
feed_one(
|
||||||
|
&mut c,
|
||||||
|
"tar: Ignoring unknown extended header keyword 'SCHILY.xattr.user.foo'"
|
||||||
|
),
|
||||||
|
vec![Action::Warning(
|
||||||
|
"Ignoring unknown extended header keyword 'SCHILY.xattr.user.foo'".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
// Real tar failures are errors
|
||||||
|
assert_eq!(
|
||||||
|
feed_one(
|
||||||
|
&mut c,
|
||||||
|
"tar: ../hello_2.10.orig.tar.xz: Cannot open: No such file or directory"
|
||||||
|
),
|
||||||
|
vec![Action::Error(
|
||||||
|
"../hello_2.10.orig.tar.xz: Cannot open: No such file or directory".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
// Unprefixed foreign output stays visible
|
||||||
|
assert_eq!(
|
||||||
|
feed_one(&mut c, "gpgv: Signature made Tue 01 Jan 2026"),
|
||||||
|
vec![Action::Shown(
|
||||||
|
"gpgv: Signature made Tue 01 Jan 2026".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_truncate_long_lines() {
|
fn test_truncate_long_lines() {
|
||||||
let long = "x".repeat(300);
|
let long = "x".repeat(300);
|
||||||
|
|||||||
Reference in New Issue
Block a user