//! 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>, /// Raw `Depends`/`Pre-Depends` strings keyed by `package:arch`. depends: HashMap>, /// Names of installed essential packages. essential: Vec, } impl StatusDb { /// Parse a dpkg status file (e.g. `/var/lib/dpkg/status`). fn load(path: &Path) -> Result> { let content = std::fs::read_to_string(path) .map_err(|e| format!("cannot read status file '{}': {}", path.display(), e))?; Ok(Self::from_str(&content)) } fn from_str(content: &str) -> StatusDb { 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 { 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> { let db = StatusDb::load(status_path)?; let mut work: VecDeque = 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 = 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); // Leading `\n`: pre-wrapped multiline field, dpkg-style. let mut out = String::from("\n"); out.push_str( &entries .into_iter() .map(|(_, formatted)| formatted) .collect::>() .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, /// `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, /// `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()); } 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> { 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::::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")); } #[test] fn wrap_binary_field() { assert_eq!(wrap_long("abc"), "abc"); let long = (0..500) .map(|i| i.to_string()) .collect::>() .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")); } }