//! 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}; /// 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, /// 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, /// 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 `__.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)` + `Binary-Only-Changes`, with no source files distributed). pub fn generate_binary_metadata( ctx: &Arc, package_dir: &Path, upload_dir: &Path, opts: &BinaryMetadataOptions, ) -> Result<(PathBuf, PathBuf), Box> { // ------------------------------------------------------------------ // 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 = 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 = 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: reference the previous source version, textually only // ------------------------------------------------------------------ let sversion = entry.version.no_epoch(); let mut source_display = entry.source.clone(); let mut binary_only_changes = None; if entry.binary_only { // Like dpkg-genchanges/genbuildinfo, a binary-only upload references // the previous source version in the `Source` field and records the // entry in `Binary-Only-Changes`, but distributes NO source files: // the previous `.dsc` and its tarballs already sit in the archive, // and are not re-uploaded even when present next to the tree. 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 )); } } // ------------------------------------------------------------------ // Binary package names and descriptions // ------------------------------------------------------------------ let mut binaries: Vec = 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, binary_only: entry.binary_only, 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, dir: &Path, names: &[String], ) -> Result, Box> { let mut out: BTreeMap = 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::() .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) } #[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 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 previous-version reference. #[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 Mon, 01 Jan 2024 00:00:00 +0000 hello (1.0-1 unstable; urgency=medium * Previous entry with an unbalanced parenthesis. -- A B Sun, 31 Dec 2023 00:00:00 +0000 "; let control = "\ Source: hello Section: devel Priority: optional Maintainer: A B 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 Mon, 01 Jan 2024 00:00:00 +0000 "; let control = "\ Source: hello Section: devel Priority: optional Maintainer: A B 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}"); } /// A binary-only (binNMU) build references the previous source version /// (`Source: pkg (prev)`, `Binary-Only-Changes`) but must NOT /// redistribute any source file: like dpkg-genchanges/genbuildinfo, the /// previous `.dsc` and its tarballs stay out of both documents even when /// they exist next to the artifacts. #[test] fn binary_only_metadata_references_previous_source_without_redistributing_it() { let changelog = "\ hello (1.0-1+b1) unstable; urgency=medium, binary-only=yes * Binary-only rebuild. -- A B Mon, 01 Jan 2024 00:00:00 +0000 hello (1.0-1) unstable; urgency=medium * Initial release. -- A B Sun, 31 Dec 2023 00:00:00 +0000 "; let control = "\ Source: hello Section: devel Priority: optional Maintainer: A B 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"); // The trap: the previous source artifacts sit right next to the // binaries, as they would after a source build. dpkg does not // redistribute them for a binary-only upload, and neither must we. std::fs::write( base.path().join("hello_1.0-1.dsc"), "Format: 3.0 (quilt)\nSource: hello\nBinary: hello\nArchitecture: any\nVersion: \ 1.0-1\nMaintainer: A B \nChecksums-Sha1:\n aaa111 12 \ hello_1.0.orig.tar.xz\n", ) .expect("write previous dsc"); std::fs::write(base.path().join("hello_1.0.orig.tar.xz"), "tarball bytes") .expect("write previous tarball"); 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 (buildinfo_path, changes_path) = generate_binary_metadata(&ctx, &tree, base.path(), &opts) .expect("binNMU metadata generation must succeed"); let changes = std::fs::read_to_string(&changes_path).expect("read changes"); let buildinfo = std::fs::read_to_string(&buildinfo_path).expect("read buildinfo"); // The previous version is referenced textually. assert!( changes.contains("Source: hello (1.0-1)"), "changes must reference the previous version: {changes}" ); assert!( buildinfo.contains("Binary-Only-Changes"), "buildinfo must record the binary-only entry: {buildinfo}" ); // ... but no source file is distributed, on either side. for (doc, text) in [("changes", &changes), ("buildinfo", &buildinfo)] { assert!( !text.contains("hello_1.0-1.dsc"), "{doc} must not redistribute the previous .dsc: {text}" ); assert!( !text.contains("hello_1.0.orig.tar.xz"), "{doc} must not redistribute the previous tarball: {text}" ); } // The distributed set is exactly the binary artifacts + buildinfo. assert!( changes.contains("hello_1.0-1+b1_all.deb") && changes.contains(".buildinfo"), "changes must distribute the deb and the buildinfo: {changes}" ); } }