Files
pkh/src/build/binary.rs
T
vhaudiquet c18f1fe9c2 changelog: parse entries with a shared limit-based helper
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.
2026-09-17 23:34:42 +02:00

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}");
}
}