Replace parse_previous_version/parse_previous_version_from_str with parse_changelog_entries(path, limit: Option<usize>), parsing up to the given number of entries (None: the whole file) newest-first through the same strict entry parser instead of a header-only scan. The single-entry helpers stay as thin wrappers, and callers needing the previous entry now get its full source name and version, not just the raw string.
743 lines
29 KiB
Rust
743 lines
29 KiB
Rust
//! 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};
|
|
|
|
use super::parse_checksum_field;
|
|
|
|
/// 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,
|
|
/// Environment variables pkh exported to the build steps (e.g. `LANG`,
|
|
/// `DEB_BUILD_OPTIONS` with the real parallel count and `nocheck`,
|
|
/// `SOURCE_DATE_EPOCH`, cross `DEB_*` variables). Recorded — filtered to
|
|
/// dpkg's allow-list — in the `.buildinfo` `Environment` field, taking
|
|
/// precedence over whatever the host process inherited, so the metadata
|
|
/// describes the environment the build actually ran in.
|
|
pub exported_env: BTreeMap<String, String>,
|
|
/// 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 mut entries =
|
|
crate::debian::changelog::parse_changelog_entries_from_str(&changelog_content, Some(2))?;
|
|
let entry = entries.remove(0);
|
|
let previous_entry = entries.into_iter().next();
|
|
|
|
let control_content = ctx.read_file(&package_dir.join("debian/control"))?;
|
|
let control = ControlInfo::parse_content(&control_content)?;
|
|
|
|
// A missing `debian/files` is tolerated (first binary build in a fresh
|
|
// tree has nothing registered yet; that surfaces below as the "no binary
|
|
// artifacts" error), like `FilesList::load`. Any other read failure must
|
|
// not be silently mistaken for an empty registry.
|
|
let files_path = package_dir.join("debian/files");
|
|
let files_content = if ctx.exists(&files_path)? {
|
|
ctx.read_file(&files_path)
|
|
.map_err(|e| format!("cannot read '{}': {}", files_path.display(), e))?
|
|
} else {
|
|
String::new()
|
|
};
|
|
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"))?;
|
|
// SHA-512 stays unknown here: like dpkg-genbuildinfo, no SHA-512
|
|
// digest is computed for the artifacts, and an empty digest keeps
|
|
// the `Checksums-Sha512` field of the `.buildinfo` omitted.
|
|
checksums.insert_entry(
|
|
name,
|
|
ChecksumEntry {
|
|
size: entry_hashes.size,
|
|
md5: entry_hashes.md5,
|
|
sha1: entry_hashes.sha1,
|
|
sha256: entry_hashes.sha256,
|
|
sha512: String::new(),
|
|
},
|
|
);
|
|
// 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 {
|
|
// A binary-only upload must reference the previous source version;
|
|
// a changelog that cannot yield it is a hard error, like in the
|
|
// source-build path. Reuse the changelog read above instead of
|
|
// reading the file a second time.
|
|
if let Some(prev) = &previous_entry {
|
|
source_display = format!("{} ({})", entry.source, prev.version.full());
|
|
binary_only_changes = Some(format!(
|
|
"{}\n\n -- {} <{}> {}",
|
|
entry.changes_field, entry.maintainer_name, entry.maintainer_email, entry.date_raw
|
|
));
|
|
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
|
|
// ------------------------------------------------------------------
|
|
// Like the source-build path, a status database that cannot be read is
|
|
// a hard error: silently treating it as empty would drop (or gut) the
|
|
// `Installed-Build-Depends` field of the produced metadata.
|
|
let status_path = Path::new("/var/lib/dpkg/status");
|
|
let status_content = ctx
|
|
.read_file(status_path)
|
|
.map_err(|e| format!("cannot read status file '{}': {}", status_path.display(), e))?;
|
|
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
|
|
// ------------------------------------------------------------------
|
|
// Record exactly the environment that was exported to the build steps,
|
|
// overriding any host-inherited value (dpkg-style allowed-variable
|
|
// filtering, export precedence).
|
|
let environment = crate::build::env::buildinfo_environment(&opts.exported_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(),
|
|
// No SHA-512 digest available (see above); keeps the
|
|
// `Checksums-Sha512` `.buildinfo` field omitted.
|
|
sha512: String::new(),
|
|
},
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// .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))
|
|
}
|
|
|
|
/// 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. A failed `stat` must fail the metadata generation: an unchecked
|
|
// exit status would leave the default size 0 in the produced
|
|
// `.changes`/`.buildinfo` checksum entries.
|
|
let output = ctx
|
|
.command("stat")
|
|
.current_dir(dir)
|
|
.arg("-c")
|
|
.arg("%s %n")
|
|
.args(names)
|
|
.output()
|
|
.map_err(|e| format!("failed to run 'stat' inside the build context: {e}"))?;
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"'stat' 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((size, name)) = line.trim().split_once(' ') else {
|
|
continue;
|
|
};
|
|
let size = size
|
|
.parse::<u64>()
|
|
.map_err(|_| format!("'stat' reported an invalid size '{size}' for '{name}'"))?;
|
|
if let Some(slot) = out.get_mut(name) {
|
|
slot.size = size;
|
|
}
|
|
}
|
|
|
|
// 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(
|
|
crate::debian::control::strip_clearsigned_armour(&dsc_content),
|
|
)
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| format!("'{dsc_name}' is empty"))?;
|
|
|
|
// Names and partial checksums are filled from the very same validated
|
|
// lines, so a listed name can never miss its checksum entry.
|
|
// Distribution order follows the Checksums fields (Checksums-Sha1 then
|
|
// Checksums-Sha256), like dpkg-genchanges; the `Files` field only
|
|
// supplements the md5 digests.
|
|
let mut names: Vec<String> = Vec::new();
|
|
let mut partials: BTreeMap<String, super::PartialChecksum> = BTreeMap::new();
|
|
for field in ["Checksums-Sha1", "Checksums-Sha256", "Files"] {
|
|
let Some(value) = para.get(field) else {
|
|
continue;
|
|
};
|
|
for cl in parse_checksum_field(field, value)
|
|
.map_err(|e| format!("cannot parse '{dsc_name}': {e}"))?
|
|
{
|
|
let slot = partials.entry(cl.name.clone()).or_default();
|
|
match field {
|
|
"Checksums-Sha1" => slot.sha1 = Some(cl.digest),
|
|
"Checksums-Sha256" => slot.sha256 = Some(cl.digest),
|
|
_ => slot.md5 = Some(cl.digest),
|
|
}
|
|
slot.size = Some(cl.size);
|
|
if field != "Files" && !names.contains(&cl.name) {
|
|
names.push(cl.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(),
|
|
// No SHA-512 digest available (see above).
|
|
sha512: String::new(),
|
|
},
|
|
);
|
|
}
|
|
for name in &names {
|
|
if name == dsc_name {
|
|
continue;
|
|
}
|
|
let p = partials
|
|
.get(name)
|
|
.ok_or_else(|| format!("file '{name}' listed in '{dsc_name}' has no checksum entry"))?;
|
|
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(),
|
|
// The `.dsc` records no SHA-512 (dpkg only writes
|
|
// sha1/sha256 there).
|
|
sha512: String::new(),
|
|
},
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The recorded `.buildinfo` `Environment` must carry the environment
|
|
/// actually exported to the build steps (`parallel=N nocheck`, `LANG=C`,
|
|
/// ...), taking precedence over any host-inherited value, instead of
|
|
/// values recomputed from host state at generation time.
|
|
#[test]
|
|
fn environment_records_exported_env_not_host_defaults() {
|
|
let mut exported_env = BTreeMap::new();
|
|
exported_env.insert("LANG".to_string(), "C".to_string());
|
|
exported_env.insert(
|
|
"DEB_BUILD_OPTIONS".to_string(),
|
|
"parallel=7 nocheck".to_string(),
|
|
);
|
|
let opts = BinaryMetadataOptions {
|
|
profiles: Vec::new(),
|
|
vendor: "debian".to_string(),
|
|
exported_env,
|
|
build_arch: "amd64".to_string(),
|
|
host_arch: "amd64".to_string(),
|
|
};
|
|
|
|
let environment = crate::build::env::buildinfo_environment(&opts.exported_env);
|
|
assert!(
|
|
environment.contains("DEB_BUILD_OPTIONS=\"parallel=7 nocheck\""),
|
|
"recorded Environment must carry the exported DEB_BUILD_OPTIONS: {environment}"
|
|
);
|
|
assert!(
|
|
environment.contains("LANG=\"C\""),
|
|
"recorded Environment must carry the exported LANG: {environment}"
|
|
);
|
|
// Not in dpkg's allowed-variable list: never recorded.
|
|
assert!(!environment.contains("DEBIAN_FRONTEND"), "{environment}");
|
|
}
|
|
|
|
/// A minimal previous-version `.dsc` with a 3-column Checksums-Sha1
|
|
/// field, a 4-column Checksums-Sha256 line and a 3-column `Files`.
|
|
/// Regression: the old code filled `names` from any line with a third
|
|
/// column but `partials` only from exactly-3-column lines, so the
|
|
/// "bogus" name landed in `names` alone and `&partials["bogus"]`
|
|
/// panicked. It must produce a build error instead.
|
|
#[test]
|
|
fn dsc_four_column_checksum_line_errors_instead_of_panicking() {
|
|
let dsc_name = "hello_1.0-1.dsc";
|
|
let dsc = "\
|
|
Format: 3.0 (native)
|
|
Source: hello
|
|
Binary: hello
|
|
Architecture: any
|
|
Version: 1.0-1
|
|
Maintainer: A B <a@b.c>
|
|
Checksums-Sha1:
|
|
aaa111 12 hello_1.0.orig.tar.xz
|
|
Checksums-Sha256:
|
|
bbb222 12 bogus hello_1.0-1.debian.tar.xz
|
|
Files:
|
|
ddd333 12 hello_1.0.orig.tar.xz
|
|
";
|
|
let base = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(base.path().join(dsc_name), dsc).expect("write dsc");
|
|
|
|
let ctx = Arc::new(
|
|
crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"),
|
|
);
|
|
let mut checksums = FileChecksums::new();
|
|
let err = include_dsc_artifacts(&ctx, base.path(), dsc_name, &mut checksums)
|
|
.expect_err("malformed Checksums-Sha256 line must fail the build");
|
|
let err = err.to_string();
|
|
assert!(err.contains("Checksums-Sha256"), "{err}");
|
|
assert!(err.contains("hello_1.0-1.debian.tar.xz"), "{err}");
|
|
}
|
|
|
|
/// Happy path: tarball entries are assembled from the Checksums fields
|
|
/// (sha1/sha256) and merged with the legacy 5-column `Files` md5, in
|
|
/// Checksums-Sha1 order, with the `.dsc` itself hashed fresh first.
|
|
#[test]
|
|
fn include_dsc_artifacts_merges_legacy_files_layout() {
|
|
let dsc_name = "hello_1.0-1.dsc";
|
|
let tarball = "hello_1.0.orig.tar.xz";
|
|
let dsc = "\
|
|
Format: 3.0 (quilt)
|
|
Source: hello
|
|
Binary: hello
|
|
Architecture: any
|
|
Version: 1.0-1
|
|
Maintainer: A B <a@b.c>
|
|
Checksums-Sha1:
|
|
aaa111 12 hello_1.0.orig.tar.xz
|
|
Checksums-Sha256:
|
|
bbb222 12 hello_1.0.orig.tar.xz
|
|
Files:
|
|
ddd333 12 devel optional hello_1.0.orig.tar.xz
|
|
";
|
|
let base = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(base.path().join(dsc_name), dsc).expect("write dsc");
|
|
std::fs::write(base.path().join(tarball), "tarball bytes").expect("write tarball");
|
|
|
|
let ctx = Arc::new(
|
|
crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"),
|
|
);
|
|
let mut checksums = FileChecksums::new();
|
|
include_dsc_artifacts(&ctx, base.path(), dsc_name, &mut checksums)
|
|
.expect("valid dsc must parse");
|
|
|
|
let collected: Vec<(String, crate::debian::ChecksumEntry)> = checksums
|
|
.iter()
|
|
.map(|(k, e)| (k.clone(), e.clone()))
|
|
.collect();
|
|
assert_eq!(
|
|
collected
|
|
.iter()
|
|
.map(|(k, _)| k.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec![dsc_name, tarball],
|
|
".dsc first, then Checksums-Sha1 order"
|
|
);
|
|
|
|
// The .dsc is hashed fresh from disk.
|
|
let dsc_entry = &collected[0].1;
|
|
assert_eq!(dsc_entry.size, dsc.len() as u64);
|
|
assert_eq!(dsc_entry.md5.len(), 32);
|
|
assert_eq!(dsc_entry.sha1.len(), 40);
|
|
assert_eq!(dsc_entry.sha256.len(), 64);
|
|
|
|
// The tarball reuses the .dsc-recorded digests, including the
|
|
// legacy 5-column `Files` md5 (section/priority skipped).
|
|
let tar_entry = &collected[1].1;
|
|
assert_eq!(tar_entry.size, 12);
|
|
assert_eq!(tar_entry.md5, "ddd333");
|
|
assert_eq!(tar_entry.sha1, "aaa111");
|
|
assert_eq!(tar_entry.sha256, "bbb222");
|
|
}
|
|
|
|
/// A binary-only (binNMU) build whose changelog cannot yield the
|
|
/// previous entry (malformed second header, unbalanced parenthesis) must
|
|
/// fail the metadata generation with a diagnostic naming the problem,
|
|
/// instead of silently emitting a plain `Source:` `.changes` with no
|
|
/// `Binary-Only-Changes` and no redistributed previous `.dsc`.
|
|
#[test]
|
|
fn binary_only_prev_version_parse_failure_errors_instead_of_wrong_metadata() {
|
|
let changelog = "\
|
|
hello (1.0-1+b1) unstable; urgency=medium, binary-only=yes
|
|
|
|
* Binary-only rebuild.
|
|
|
|
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000
|
|
|
|
hello (1.0-1 unstable; urgency=medium
|
|
|
|
* Previous entry with an unbalanced parenthesis.
|
|
|
|
-- A B <a@b.c> Sun, 31 Dec 2023 00:00:00 +0000
|
|
";
|
|
let control = "\
|
|
Source: hello
|
|
Section: devel
|
|
Priority: optional
|
|
Maintainer: A B <a@b.c>
|
|
|
|
Package: hello
|
|
Architecture: all
|
|
Description: test package
|
|
";
|
|
let base = tempfile::tempdir().expect("tempdir");
|
|
let tree = base.path().join("hello-1.0");
|
|
std::fs::create_dir_all(tree.join("debian")).expect("mkdir tree");
|
|
std::fs::write(tree.join("debian/changelog"), changelog).expect("write changelog");
|
|
std::fs::write(tree.join("debian/control"), control).expect("write control");
|
|
std::fs::write(
|
|
tree.join("debian/files"),
|
|
"hello_1.0-1+b1_all.deb devel optional\n",
|
|
)
|
|
.expect("write files");
|
|
std::fs::write(base.path().join("hello_1.0-1+b1_all.deb"), "deb payload")
|
|
.expect("write deb");
|
|
|
|
let ctx = Arc::new(
|
|
crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"),
|
|
);
|
|
let opts = BinaryMetadataOptions {
|
|
profiles: Vec::new(),
|
|
vendor: "debian".to_string(),
|
|
exported_env: BTreeMap::new(),
|
|
build_arch: "amd64".to_string(),
|
|
host_arch: "amd64".to_string(),
|
|
};
|
|
let err = generate_binary_metadata(&ctx, &tree, base.path(), &opts)
|
|
.expect_err("binary-only build with an unparseable changelog must fail");
|
|
let err = err.to_string();
|
|
assert!(err.contains("unbalanced parenthesis"), "{err}");
|
|
assert!(err.contains("1.0-1 unstable"), "{err}");
|
|
}
|
|
|
|
/// An unreadable `debian/files` (e.g. permissions) must fail the
|
|
/// metadata generation with an error naming the read failure, instead of
|
|
/// being silently treated as an empty registry and reported as "no
|
|
/// binary artifacts found". A *missing* file stays tolerated (first
|
|
/// build in a fresh tree); the distinction matters.
|
|
#[test]
|
|
fn unreadable_debian_files_errors_instead_of_empty_registry() {
|
|
if crate::utils::root::is_root().unwrap_or(false) {
|
|
// Root can read files regardless of permissions.
|
|
return;
|
|
}
|
|
let changelog = "\
|
|
hello (1.0-1) unstable; urgency=medium
|
|
|
|
* Regular build.
|
|
|
|
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000
|
|
";
|
|
let control = "\
|
|
Source: hello
|
|
Section: devel
|
|
Priority: optional
|
|
Maintainer: A B <a@b.c>
|
|
|
|
Package: hello
|
|
Architecture: all
|
|
Description: test package
|
|
";
|
|
let base = tempfile::tempdir().expect("tempdir");
|
|
let tree = base.path().join("hello-1.0");
|
|
std::fs::create_dir_all(tree.join("debian")).expect("mkdir tree");
|
|
std::fs::write(tree.join("debian/changelog"), changelog).expect("write changelog");
|
|
std::fs::write(tree.join("debian/control"), control).expect("write control");
|
|
let files_path = tree.join("debian/files");
|
|
std::fs::write(&files_path, "hello_1.0-1_all.deb devel optional\n").expect("write files");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&files_path, std::fs::Permissions::from_mode(0o000))
|
|
.expect("chmod files");
|
|
}
|
|
|
|
let ctx = Arc::new(
|
|
crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"),
|
|
);
|
|
let opts = BinaryMetadataOptions {
|
|
profiles: Vec::new(),
|
|
vendor: "debian".to_string(),
|
|
exported_env: BTreeMap::new(),
|
|
build_arch: "amd64".to_string(),
|
|
host_arch: "amd64".to_string(),
|
|
};
|
|
let err = generate_binary_metadata(&ctx, &tree, base.path(), &opts)
|
|
.expect_err("unreadable debian/files must fail with a read error");
|
|
let err = err.to_string();
|
|
assert!(err.contains("cannot read"), "{err}");
|
|
assert!(err.contains("debian/files"), "{err}");
|
|
#[cfg(unix)]
|
|
assert!(err.contains("Permission denied"), "{err}");
|
|
}
|
|
}
|