Files
pkh/src/build/buildinfo.rs
T
vhaudiquet 93296c26c1 checksums: add SHA-512 and a Checksums-* field parser
The checksum model only carried md5/sha1/sha256 while deb-buildinfo(5)
defines Checksums-Sha512, and there was no way to parse a Checksums-*
field body back into entries. Add ChecksumKind::Sha512 (computed
alongside the others), a field parser validating the
'<hex> <size> <name>' grammar, and an only-if-populated
Checksums-Sha512 emission in .buildinfo — deliberately dormant in the
dpkg-parity flows, which never emit it, and .dsc/.changes untouched.
2026-09-17 19:44:54 +02:00

578 lines
20 KiB
Rust

//! Native `.buildinfo` generation (Format 1.0), mirroring
//! `dpkg-genbuildinfo`: artifact checksums, a snapshot of installed build
//! dependencies and the sanitized build environment.
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use crate::debian::checksums::FileChecksums;
use crate::debian::control::{Paragraph, parse_paragraphs, write_paragraph};
/// One installed package relevant for dependency resolution.
#[derive(Debug, Clone)]
struct InstalledPkg {
version: String,
arch: String,
}
/// A snapshot of the dpkg status database, restricted to what the
/// `Installed-Build-Depends` computation needs.
#[derive(Debug, Default)]
struct StatusDb {
/// Installed packages grouped by name.
pkgs: HashMap<String, Vec<InstalledPkg>>,
/// Raw `Depends`/`Pre-Depends` strings keyed by `package:arch`.
depends: HashMap<String, Vec<String>>,
/// Names of installed essential packages.
essential: Vec<String>,
}
impl StatusDb {
fn from_str(content: &str) -> StatusDb {
let mut db = StatusDb::default();
for para in parse_paragraphs(content) {
// Only fully installed packages participate.
let status = para.get("Status").unwrap_or("");
if !status.split_whitespace().eq(["install", "ok", "installed"]) {
// Accept any status containing 'ok installed' like dpkg's
// `/^Status: .*ok installed$/` check.
if !status.contains("ok installed") {
continue;
}
}
let Some(package) = para.get("Package") else {
continue;
};
let arch = para.get("Architecture").unwrap_or("").to_string();
if let (Some(version), false) = (para.get("Version"), arch.is_empty()) {
db.pkgs
.entry(package.to_string())
.or_default()
.push(InstalledPkg {
version: version.to_string(),
arch: arch.clone(),
});
}
if para
.get("Essential")
.map(|v| v.eq_ignore_ascii_case("yes"))
.unwrap_or(false)
{
db.essential.push(package.to_string());
}
let qualified = format!("{}:{}", package, arch);
for field in ["Pre-Depends", "Depends"] {
if let Some(value) = para.get(field) {
db.depends
.entry(qualified.clone())
.or_default()
.push(value.to_string());
}
}
}
db
}
/// Find an installed package by name, optionally restricted to an exact
/// architecture.
fn find(&self, name: &str, arch: Option<&str>) -> Option<&InstalledPkg> {
self.pkgs.get(name)?.iter().find(|p| match arch {
Some(a) => p.arch == a,
None => true,
})
}
}
/// Extract candidate package names from a dependency field value.
///
/// Every alternative of every clause is returned (dpkg cannot know which one
/// was actually used), with version constraints and build-profile
/// restrictions stripped but `:arch` qualifiers preserved.
fn dep_candidates(dep_value: &str) -> Vec<String> {
let mut out = Vec::new();
for clause in dep_value.split(',') {
for alternative in clause.split('|') {
// Drop build-profile restrictions `[...]` (they may follow any
// individual alternative).
let alternative = match alternative.find('[') {
Some(i) => &alternative[..i],
None => alternative,
};
// Drop version constraints `(>= 1.0)`.
let name = match alternative.find('(') {
Some(i) => &alternative[..i],
None => alternative,
};
let name = name.trim();
if name.is_empty() {
continue;
}
out.push(name.to_string());
}
}
out
}
/// Compute the `Installed-Build-Depends` value: the transitive closure of
/// installed packages reachable from the essential set and the active
/// `Build-Depends*` fields, formatted as `name (= version)` pairs.
///
/// Mirrors `collect_installed_builddeps()` in `dpkg-genbuildinfo`, including
/// the foreign-architecture qualification of dependencies.
pub fn installed_build_depends(
status_path: &Path,
build_depends_fields: &[&str],
) -> Result<String, Box<dyn std::error::Error>> {
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();
for name in &db.essential {
work.push_back(name.clone());
}
for field in build_depends_fields {
if !field.trim().is_empty() {
for candidate in dep_candidates(field) {
work.push_back(candidate);
}
}
}
let mut seen: HashSet<String> = HashSet::new();
let mut entries: Vec<(String, String)> = Vec::new();
while let Some(entry) = work.pop_front() {
if !seen.insert(entry.clone()) {
continue;
}
let (name, qual) = match entry.split_once(':') {
Some((n, q)) => (n.to_string(), Some(q.to_string())),
None => (entry.clone(), None),
};
// `all`, `any` and `native` qualifiers do not pin an architecture.
let required_arch = qual.filter(|q| !matches!(q.as_str(), "all" | "any" | "native"));
let Some(installed) = db.find(&name, required_arch.as_deref()) else {
continue;
};
let qualified_name = if required_arch.is_none() {
name.clone()
} else {
format!("{}:{}", name, installed.arch)
};
entries.push((
qualified_name.clone(),
format!("{} (= {})", qualified_name, installed.version),
));
// Enqueue dependencies of the visited package.
let dep_key = format!("{}:{}", name, installed.arch);
let foreign = required_arch.is_some();
for raw in db.depends.get(&dep_key).into_iter().flatten() {
for mut candidate in dep_candidates(raw) {
if foreign && !candidate.contains(':') {
// Dependencies of foreign packages are foreign too (or
// Arch:all); qualify them when such an install exists.
let base = candidate.as_str();
let has_foreign_arch = db
.pkgs
.get(base)
.map(|v| v.iter().any(|p| p.arch == installed.arch))
.unwrap_or(false);
if has_foreign_arch {
candidate = format!("{}:{}", candidate, installed.arch);
}
}
work.push_back(candidate);
}
}
}
entries.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
entries.dedup_by(|a, b| a.0 == b.0);
// With no reachable entries, return an empty value so `render_buildinfo`
// omits the field entirely; a leading `\n` alone would render a
// malformed `Installed-Build-Depends:` with only a blank continuation.
if entries.is_empty() {
return Ok(String::new());
}
// Leading `\n`: pre-wrapped multiline field, dpkg-style.
let mut out = String::from("\n");
out.push_str(
&entries
.into_iter()
.map(|(_, formatted)| formatted)
.collect::<Vec<_>>()
.join(",\n"),
);
Ok(out)
}
/// Everything needed to render a `.buildinfo` file.
#[derive(Debug, Clone)]
pub struct BuildInfoInput {
/// `Source` field, including the ` (sourceversion)` suffix for binNMUs.
pub source: String,
/// Sorted binary package names included in the build (may be empty).
pub binaries: Vec<String>,
/// `Architecture` field value (e.g. `source`, `amd64`, `all amd64`).
pub architecture: String,
/// Full binary version.
pub version: String,
/// `Binary-Only-Changes` payload for binNMU builds.
pub binary_only_changes: Option<String>,
/// `Build-Origin` (vendor name).
pub build_origin: String,
/// `Build-Architecture` (machine the build ran on).
pub build_architecture: String,
/// `Build-Date`, RFC2822.
pub build_date: String,
/// Computed artifact checksums.
pub checksums: FileChecksums,
/// Rendered `Installed-Build-Depends` value.
pub installed_build_depends: String,
/// Rendered `Environment` value.
pub environment: String,
}
/// Wrap an overly long single-line field value (> 980 characters) over
/// multiple lines at spaces, like dpkg does for `Binary`.
fn wrap_long(value: &str) -> String {
if value.len() <= 980 {
return value.to_string();
}
let mut out = String::with_capacity(value.len() + 8);
let mut line_len = 0usize;
for (i, word) in value.split(' ').enumerate() {
if i > 0 {
if line_len + 1 + word.len() > 980 {
out.push('\n');
line_len = 0;
} else {
out.push(' ');
line_len += 1;
}
}
out.push_str(word);
line_len += word.len();
}
out
}
/// Render the `.buildinfo` document (without trailing signature), with fields
/// in dpkg's canonical order for `CTRL_FILE_BUILDINFO`.
pub fn render_buildinfo(input: &BuildInfoInput) -> Paragraph {
let mut p = Paragraph::new();
p.set("Format", "1.0");
p.set("Source", &input.source);
if !input.binaries.is_empty() {
let joined = input.binaries.join(" ");
p.set("Binary", &wrap_long(&joined));
}
p.set("Architecture", &input.architecture);
p.set("Version", &input.version);
if let Some(boc) = &input.binary_only_changes {
p.set("Binary-Only-Changes", boc);
}
if !input.checksums.is_empty() {
p.set("Checksums-Md5", &input.checksums.field_md5());
p.set("Checksums-Sha1", &input.checksums.field_sha1());
p.set("Checksums-Sha256", &input.checksums.field_sha256());
// Only-if-populated: entries merged from a `.dsc` carry no SHA-512
// (dpkg only records sha1/sha256 there), and an incomplete checksum
// list must never be rendered.
if let Some(sha512) = input.checksums.field_sha512() {
p.set("Checksums-Sha512", &sha512);
}
}
p.set("Build-Origin", &input.build_origin);
p.set("Build-Architecture", &input.build_architecture);
p.set("Build-Date", &input.build_date);
if !input.installed_build_depends.is_empty() {
p.set("Installed-Build-Depends", &input.installed_build_depends);
}
if !input.environment.is_empty() {
p.set("Environment", &input.environment);
}
p
}
/// Serialize and atomically write a `.buildinfo` file.
pub fn save_buildinfo(
path: &Path,
paragraph: &Paragraph,
) -> Result<(), Box<dyn std::error::Error>> {
let tmp = path.with_extension("new");
std::fs::write(&tmp, write_paragraph(paragraph))
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
std::fs::rename(&tmp, path)
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dep_candidate_extraction() {
assert_eq!(
dep_candidates("debhelper-compat (= 13), pkg:any [!profile] | alt (>= 2)"),
vec![
"debhelper-compat".to_string(),
"pkg:any".to_string(),
"alt".to_string()
]
);
assert_eq!(dep_candidates(""), Vec::<String>::new());
}
#[test]
fn closure_over_status_db() {
let status = "\
Package: build-essential
Status: install ok installed
Version: 12.10
Architecture: amd64
Essential: no
Depends: gcc, make
Package: gcc
Status: install ok installed
Version: 13.2
Architecture: amd64
Depends: cpp-13
Package: cpp-13
Status: install ok installed
Version: 13.2
Architecture: amd64
Package: make
Status: install ok installed
Version: 4.3
Architecture: amd64
Package: not-installed
Status: deinstall ok config-files
Version: 9.9
Architecture: amd64
";
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("status");
std::fs::write(&path, status).unwrap();
let ibd = installed_build_depends(&path, &["build-essential"]).unwrap();
let names: Vec<&str> = ibd
.trim_start()
.lines()
.map(|l| l.split(' ').next().unwrap())
.collect();
assert_eq!(names, vec!["build-essential", "cpp-13", "gcc", "make"]);
assert!(ibd.contains("gcc (= 13.2)"));
assert!(ibd.contains("cpp-13 (= 13.2)"));
assert!(!ibd.contains("not-installed"));
}
/// With no installed entries reachable (empty status database), the
/// computed value must be EMPTY so `render_buildinfo` omits the
/// `Installed-Build-Depends` field entirely, instead of emitting a
/// malformed field with only a blank continuation line.
#[test]
fn installed_build_depends_without_entries_is_empty_and_omitted() {
let ibd = installed_build_depends_from_content("", &["libc6"]).unwrap();
assert_eq!(
ibd, "",
"zero entries must yield an empty value, not \"\\n\""
);
let input = BuildInfoInput {
source: "hello".to_string(),
binaries: vec!["hello".to_string()],
architecture: "amd64".to_string(),
version: "1.0".to_string(),
binary_only_changes: None,
build_origin: "debian".to_string(),
build_architecture: "amd64".to_string(),
build_date: "Sat, 22 Aug 2026 23:08:42 +0200".to_string(),
checksums: FileChecksums::new(),
installed_build_depends: ibd,
environment: String::new(),
};
let p = render_buildinfo(&input);
assert!(
p.get("Installed-Build-Depends").is_none(),
"empty value must omit the field entirely"
);
}
/// With installed entries, the value keeps the dpkg-style leading `\n`
/// (pre-wrapped multiline field) and the field is rendered.
#[test]
fn installed_build_depends_with_entries_renders_field() {
let status = "\
Package: gcc
Status: install ok installed
Version: 13.2
Architecture: amd64
";
let ibd = installed_build_depends_from_content(status, &["gcc"]).unwrap();
assert_eq!(ibd, "\ngcc (= 13.2)");
let input = BuildInfoInput {
source: "hello".to_string(),
binaries: vec!["hello".to_string()],
architecture: "amd64".to_string(),
version: "1.0".to_string(),
binary_only_changes: None,
build_origin: "debian".to_string(),
build_architecture: "amd64".to_string(),
build_date: "Sat, 22 Aug 2026 23:08:42 +0200".to_string(),
checksums: FileChecksums::new(),
installed_build_depends: ibd,
environment: String::new(),
};
let p = render_buildinfo(&input);
assert_eq!(p.get("Installed-Build-Depends"), Some("\ngcc (= 13.2)"));
}
#[test]
fn wrap_binary_field() {
assert_eq!(wrap_long("abc"), "abc");
let long = (0..500)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(" ");
let wrapped = wrap_long(&long);
assert!(wrapped.contains('\n'));
for line in wrapped.lines() {
assert!(line.len() <= 980);
}
}
#[test]
fn render_minimal_source_buildinfo() {
let input = BuildInfoInput {
source: "hello".to_string(),
binaries: vec![],
architecture: "source".to_string(),
version: "1.0".to_string(),
binary_only_changes: None,
build_origin: "Ubuntu".to_string(),
build_architecture: "amd64".to_string(),
build_date: "Sat, 22 Aug 2026 23:08:42 +0200".to_string(),
checksums: FileChecksums::new(),
installed_build_depends: "gcc (= 13)".to_string(),
environment: "DEB_BUILD_OPTIONS=\"parallel=8\"".to_string(),
};
let p = render_buildinfo(&input);
let keys: Vec<&str> = p.iter().map(|(k, _)| k).collect();
assert_eq!(
keys,
vec![
"Format",
"Source",
"Architecture",
"Version",
"Build-Origin",
"Build-Architecture",
"Build-Date",
"Installed-Build-Depends",
"Environment"
]
);
assert_eq!(p.get("Format"), Some("1.0"));
}
/// `Checksums-Sha512` is emitted (after `Checksums-Sha256`) only when
/// every distributed file has a SHA-512 digest; entries merged without
/// one (e.g. taken from a `.dsc`) omit the field entirely instead of
/// rendering an incomplete checksum list.
#[test]
fn checksums_sha512_emitted_only_when_populated() {
let dir = tempfile::tempdir().unwrap();
let artifact = dir.path().join("hello_1.0_all.deb");
std::fs::write(&artifact, b"deb payload").unwrap();
let mut checksums = FileChecksums::new();
checksums.add_file(&artifact).unwrap();
let mk_input = |checksums: FileChecksums| BuildInfoInput {
source: "hello".to_string(),
binaries: vec![],
architecture: "all".to_string(),
version: "1.0".to_string(),
binary_only_changes: None,
build_origin: "debian".to_string(),
build_architecture: "amd64".to_string(),
build_date: "Sat, 22 Aug 2026 23:08:42 +0200".to_string(),
checksums,
installed_build_depends: String::new(),
environment: String::new(),
};
// All digests computed: the field is present and parses back.
let p = render_buildinfo(&mk_input(checksums.clone()));
let keys: Vec<&str> = p.iter().map(|(k, _)| k).collect();
assert_eq!(
keys,
vec![
"Format",
"Source",
"Architecture",
"Version",
"Checksums-Md5",
"Checksums-Sha1",
"Checksums-Sha256",
"Checksums-Sha512",
"Build-Origin",
"Build-Architecture",
"Build-Date",
]
);
let sha512_field = p.get("Checksums-Sha512").unwrap();
let parsed =
FileChecksums::parse_field(crate::debian::ChecksumKind::Sha512, sha512_field).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].0, "hello_1.0_all.deb");
assert_eq!(
parsed[0].1.sha512,
checksums.get("hello_1.0_all.deb").unwrap().sha512
);
// An entry without SHA-512 (as merged from a `.dsc`) suppresses the
// field; the other Checksums fields keep listing every file.
checksums.insert_entry(
"hello_1.0.orig.tar.xz",
crate::debian::ChecksumEntry {
size: 3,
md5: checksums.get("hello_1.0_all.deb").unwrap().md5.clone(),
sha1: String::new(),
sha256: String::new(),
sha512: String::new(),
},
);
let p = render_buildinfo(&mk_input(checksums));
assert!(p.get("Checksums-Sha512").is_none());
let sha256_lines = p.get("Checksums-Sha256").unwrap().lines();
assert_eq!(sha256_lines.filter(|l| !l.is_empty()).count(), 2);
}
}