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:
2026-08-23 01:29:35 +02:00
parent e5adf600c3
commit 9d2519ed7b
12 changed files with 2813 additions and 82 deletions
+317
View File
@@ -0,0 +1,317 @@
//! Build environment setup: `SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`,
//! architecture variables (via `dpkg-architecture`) and the sanitized
//! environment recorded in `.buildinfo` files.
use std::collections::BTreeMap;
use std::path::Path;
use std::process::Command;
/// Number of parallel jobs to advertise in `DEB_BUILD_OPTIONS`.
pub fn num_parallel() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
}
/// Compute the environment variables exported by `dpkg-buildpackage` before
/// running any build step.
///
/// Mirrors dpkg behavior:
/// - `SOURCE_DATE_EPOCH` from the changelog entry timestamp
/// (<https://reproducible-builds.org/specs/source-date-epoch/>),
/// - `DEB_BUILD_OPTIONS=parallel=N` (auto-detected job count),
/// - `DEB_BUILD_PROFILES` when non-default profiles are requested.
pub fn build_env(
source_date_epoch: i64,
parallel: usize,
build_profiles: &[String],
) -> BTreeMap<String, String> {
let mut env = BTreeMap::new();
env.insert(
"SOURCE_DATE_EPOCH".to_string(),
source_date_epoch.to_string(),
);
env.insert(
"DEB_BUILD_OPTIONS".to_string(),
format!("parallel={}", parallel),
);
if !build_profiles.is_empty() {
env.insert("DEB_BUILD_PROFILES".to_string(), build_profiles.join(","));
}
env
}
/// Import the full architecture variable set by running
/// `dpkg-architecture -f [-a <host-arch>]` and parsing its `KEY=VALUE` dump.
///
/// This exports all `DEB_BUILD_*`, `DEB_HOST_*` and `DEB_TARGET_*` variables
/// (`*_ARCH`, `*_OS`, `*_CPU`, `*_MULTIARCH`, `*_GNU_TYPE`, ...), exactly as
/// `dpkg-buildpackage` does.
pub fn arch_env(host_arch: Option<&str>) -> Result<BTreeMap<String, String>, String> {
let mut cmd = Command::new("dpkg-architecture");
cmd.arg("-f");
if let Some(arch) = host_arch {
cmd.args(["--host-arch", arch]);
}
let output = cmd
.output()
.map_err(|e| {
format!(
"failed to run 'dpkg-architecture': {}. Is 'dpkg-dev' installed?",
e
)
})?;
if !output.status.success() {
return Err(format!(
"dpkg-architecture failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
let mut env = BTreeMap::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
if let Some((key, value)) = line.split_once('=') {
env.insert(key.to_string(), value.to_string());
}
}
Ok(env)
}
/// Read the current vendor name from `/etc/dpkg/origins/default`
/// (its `Vendor:` or `Origin:` field), defaulting to `"debian"`.
pub fn current_vendor() -> String {
read_vendor_from(Path::new("/etc/dpkg/origins/default"))
.unwrap_or_else(|| "debian".to_string())
}
fn read_vendor_from(path: &Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
if let Some(value) = line.strip_prefix("Vendor:") {
let v = value.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
// Fall back to Origin if no Vendor field is present.
for line in content.lines() {
if let Some(value) = line.strip_prefix("Origin:") {
let v = value.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
None
}
/// Default build profiles applied by vendor hooks.
///
/// The Ubuntu vendor module activates `derivative.ubuntu noudeb` by default;
/// Debian applies none. This mirrors what `Dpkg::BuildProfiles` resolves when
/// `DEB_BUILD_PROFILES` is unset.
pub fn default_build_profiles(vendor: &str) -> Vec<String> {
if vendor.eq_ignore_ascii_case("ubuntu") {
vec!["derivative.ubuntu".to_string(), "noudeb".to_string()]
} else {
Vec::new()
}
}
/// Resolve the active build profiles: explicit `-P` profiles take precedence,
/// then `DEB_BUILD_PROFILES` from the environment, then vendor defaults.
pub fn resolve_build_profiles(explicit: &[String], vendor: &str) -> Vec<String> {
if !explicit.is_empty() {
return explicit.to_vec();
}
if let Ok(value) = std::env::var("DEB_BUILD_PROFILES") {
let profiles: Vec<String> = value
.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect();
if !profiles.is_empty() {
return profiles;
}
}
default_build_profiles(vendor)
}
/// Environment variables that may affect a build without leaking private
/// information; only these are recorded in the `.buildinfo` `Environment`
/// field. Mirrors `Dpkg::BuildInfo::get_build_env_allowed()`.
const ENV_ALLOWED: &[&str] = &[
// Tool behavior.
"POSIXLY_CORRECT",
"GETCONF_DIR",
// Resolver.
"RESOLV_HOST_CONF",
"RESOLV_MULTI",
"RESOLV_REORDER",
"RES_OPTIONS",
// Toolchain.
"CC",
"CPP",
"CXX",
"OBJC",
"OBJCXX",
"PC",
"FC",
"M2C",
"AS",
"LD",
"AR",
"RANLIB",
"MAKE",
"AWK",
"LEX",
"YACC",
// Toolchain flags.
"ASFLAGS",
"ASFLAGS_FOR_BUILD",
"CFLAGS",
"CFLAGS_FOR_BUILD",
"CPPFLAGS",
"CPPFLAGS_FOR_BUILD",
"CXXFLAGS",
"CXXFLAGS_FOR_BUILD",
"OBJCFLAGS",
"OBJCFLAGS_FOR_BUILD",
"OBJCXXFLAGS",
"OBJCXXFLAGS_FOR_BUILD",
"DFLAGS",
"DFLAGS_FOR_BUILD",
"FFLAGS",
"FFLAGS_FOR_BUILD",
"LDFLAGS",
"LDFLAGS_FOR_BUILD",
"ARFLAGS",
"LFLAGS",
"YFLAGS",
"MAKEFLAGS",
"GNUMAKEFLAGS",
// Dynamic linker.
"LD_ASSUME_KERNEL",
"LD_AUDIT",
"LD_BIND_NOT",
"LD_BIND_NOW",
"LD_DYNAMIC_WEAK",
"LD_LIBRARY_PATH",
"LD_ORIGIN_PATH",
"LD_PREFER_MAP_32BIT_EXEC",
"LD_PRELOAD",
// Timezone.
"TZ",
"TZDIR",
// Dates.
"DATEMSK",
// Locale.
"LANG",
"LANGUAGE",
"LC_ALL",
"LC_CTYPE",
"LC_NUMERIC",
"LC_TIME",
"LC_COLLATE",
"LC_MONETARY",
"LC_MESSAGES",
"LC_PAPER",
"LC_NAME",
"LC_ADDRESS",
"LC_TELEPHONE",
"LC_MEASUREMENT",
"LC_IDENTIFICATION",
// Locale paths.
"LOCPATH",
"I18NPATH",
"NLSPATH",
"GCONV_PATH",
// Build flags.
"DEB_BUILD_OPTIONS",
"DEB_BUILD_PROFILES",
"DEB_VENDOR",
// dpkg.
"DPKG_ROOT",
"DPKG_ADMINDIR",
"DPKG_DATADIR",
"DPKG_ORIGINS_DIR",
// dpkg-deb.
"DPKG_DEB_COMPRESSOR_TYPE",
"DPKG_DEB_COMPRESSOR_LEVEL",
// dpkg-gensymbols.
"DPKG_GENSYMBOLS_CHECK_LEVEL",
// Reproducible builds.
"SOURCE_DATE_EPOCH",
];
/// Build the `.buildinfo` `Environment` field value: allowed variables from
/// the current process environment plus the `extra` overrides exported to
/// build steps (e.g. `SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`), sorted by
/// name, quoted and escaped, one per line.
///
/// Matches `cleansed_environment()` in `dpkg-genbuildinfo` (minus
/// `dpkg-buildflags` origin tracking).
pub fn buildinfo_environment(extra: &BTreeMap<String, String>) -> String {
let mut values: BTreeMap<String, String> = BTreeMap::new();
for var in ENV_ALLOWED {
if let Ok(value) = std::env::var(var) {
values.insert(var.to_string(), value);
}
}
// Variables we export ourselves always take precedence.
for (key, value) in extra {
if ENV_ALLOWED.contains(&key.as_str()) {
values.insert(key.clone(), value.clone());
}
}
// Leading `\n`: pre-wrapped multiline field, dpkg-style.
let mut out = String::from("\n");
out.push_str(
&values
.into_iter()
.map(|(var, value)| format!("{}=\"{}\"", var, value.replace('"', "\\\"")))
.collect::<Vec<_>>()
.join("\n"),
);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_env_values() {
let env = build_env(1787392800, 16, &[]);
assert_eq!(env.get("SOURCE_DATE_EPOCH").unwrap(), "1787392800");
assert_eq!(env.get("DEB_BUILD_OPTIONS").unwrap(), "parallel=16");
assert!(env.get("DEB_BUILD_PROFILES").is_none());
let env = build_env(1, 4, &["nodoc".to_string(), "cross".to_string()]);
assert_eq!(env.get("DEB_BUILD_PROFILES").unwrap(), "nodoc,cross");
}
#[test]
fn vendor_defaults() {
assert!(default_build_profiles("debian").is_empty());
assert_eq!(
default_build_profiles("ubuntu"),
vec!["derivative.ubuntu".to_string(), "noudeb".to_string()]
);
}
#[test]
fn environment_escaping() {
// The function reads the process env; just verify formatting helpers
// through a controlled subprocess-free path is not possible, so check
// the constant list contains essentials.
assert!(ENV_ALLOWED.contains(&"SOURCE_DATE_EPOCH"));
assert!(ENV_ALLOWED.contains(&"DEB_BUILD_OPTIONS"));
assert!(!ENV_ALLOWED.contains(&"HOME"));
assert!(!ENV_ALLOWED.contains(&"PATH"));
}
}