build: re-implement source builds natively, drop dpkg-buildpackage shell-out
Replace the 'dpkg-buildpackage -S' wrapper with a native pipeline in src/build/: - deb822 control parser/writer with dpkg-compatible multiline rendering (control.rs) - md5/sha1/sha256 checksum registry, insertion-ordered like dpkg's artifact accumulation (checksums.rs) - Debian version splitting/validation and full changelog entry parsing, including binNMU binary-only entries (metadata.rs) - build-type bitflags and rules-target/artifact-suffix mapping (buildtype.rs) - environment setup: SOURCE_DATE_EPOCH, DEB_BUILD_OPTIONS, dpkg-architecture env dump, vendor default profiles and the sanitized Environment field recorded in .buildinfo (env.rs) - debian/files registry with atomic saves (files.rs) - native .buildinfo writer, including the Installed-Build-Depends closure computed over the dpkg status database (buildinfo.rs) - native .changes writer emitting dpkg's canonical field order with legacy Files + Checksums-Sha1/Sha256 (changes.rs) - gpgme clearsigning with the transitive checksum cascade (dsc -> buildinfo -> changes), key discovery from the changelog maintainer and UNRELEASED no-sign handling (sign.rs) dpkg-source (-b/--before-build/--after-build) intentionally remains a subprocess; debian/rules execution is unchanged. Validated differentially against real dpkg-buildpackage -S -I -i -nc -d on native and 3.0 (quilt) fixture packages: .dsc byte-identical, .changes payload matches modulo machine-dependent Installed-Build-Depends and Environment content, all signatures verify with gpg, artifact ordering and UNRELEASED no-sign behavior match dpkg.
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
//! 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 super::checksums::FileChecksums;
|
||||
use super::control::{parse_paragraphs, write_paragraph, 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 {
|
||||
/// Parse a dpkg status file (e.g. `/var/lib/dpkg/status`).
|
||||
fn load(path: &Path) -> Result<StatusDb, Box<dyn std::error::Error>> {
|
||||
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<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 db = StatusDb::load(status_path)?;
|
||||
|
||||
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);
|
||||
|
||||
// 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());
|
||||
}
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user