build: record the actual build environment in .buildinfo

The binary build exported DEB_BUILD_OPTIONS='parallel=<context nproc>
nocheck' (or the -j override) but the generated .buildinfo recomputed
the environment from host state: host core count, no nocheck, and
vendor profiles that ignored DEB_BUILD_PROFILES (a cross build recorded
no 'cross' profile). generate_binary_metadata now records the exact env
map that was exported to the build steps, and the recorded profiles
come from the exported DEB_BUILD_PROFILES when set.

Also unifies vendor parsing on one helper (the context-side copy lacked
the Origin: fallback of the source-build path).
This commit is contained in:
2026-09-16 04:22:43 +02:00
parent 50ae12cafe
commit 3a454b0811
4 changed files with 98 additions and 54 deletions
+44 -24
View File
@@ -36,10 +36,13 @@ pub struct BinaryMetadataOptions {
pub profiles: Vec<String>, pub profiles: Vec<String>,
/// Vendor name (`Build-Origin`). /// Vendor name (`Build-Origin`).
pub vendor: String, pub vendor: String,
/// Parallel job count advertised in `DEB_BUILD_OPTIONS`. /// Environment variables pkh exported to the build steps (e.g. `LANG`,
pub parallel: usize, /// `DEB_BUILD_OPTIONS` with the real parallel count and `nocheck`,
/// Reproducible-builds epoch exported to the build. /// `SOURCE_DATE_EPOCH`, cross `DEB_*` variables). Recorded — filtered to
pub source_date_epoch: i64, /// 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<String, String>,
/// Build architecture (the machine inside the build context). /// Build architecture (the machine inside the build context).
pub build_arch: String, pub build_arch: String,
/// Host architecture (the packages' target); equals the build /// Host architecture (the packages' target); equals the build
@@ -210,8 +213,10 @@ pub fn generate_binary_metadata(
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// .buildinfo generation, then registration in debian/files // .buildinfo generation, then registration in debian/files
// ------------------------------------------------------------------ // ------------------------------------------------------------------
let pipeline_env = pipeline_environment(opts); // Record exactly the environment that was exported to the build steps,
let environment = crate::build::env::buildinfo_environment(&pipeline_env); // 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-genbuildinfo sorts the accumulated architecture values, while
// dpkg-genchanges keeps encounter order. // dpkg-genchanges keeps encounter order.
@@ -294,24 +299,6 @@ pub fn generate_binary_metadata(
Ok((buildinfo_path, changes_path)) Ok((buildinfo_path, changes_path))
} }
/// Environment exported to the build steps; recorded (filtered) in the
/// `.buildinfo` `Environment` field.
fn pipeline_environment(opts: &BinaryMetadataOptions) -> BTreeMap<String, String> {
let mut env = BTreeMap::new();
env.insert(
"SOURCE_DATE_EPOCH".to_string(),
opts.source_date_epoch.to_string(),
);
env.insert(
"DEB_BUILD_OPTIONS".to_string(),
format!("parallel={}", opts.parallel),
);
if !opts.profiles.is_empty() {
env.insert("DEB_BUILD_PROFILES".to_string(), opts.profiles.join(","));
}
env
}
/// Compute md5/sha1/sha256 digests and sizes for the named files inside the /// Compute md5/sha1/sha256 digests and sizes for the named files inside the
/// context directory `dir`, using coreutils. /// context directory `dir`, using coreutils.
fn hashes_in_context( fn hashes_in_context(
@@ -463,6 +450,39 @@ fn include_dsc_artifacts(
mod tests { mod tests {
use super::*; 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 minimal previous-version `.dsc` with a 3-column Checksums-Sha1 /// A minimal previous-version `.dsc` with a 3-column Checksums-Sha1
/// field, a 4-column Checksums-Sha256 line and a 3-column `Files`. /// field, a 4-column Checksums-Sha256 line and a 3-column `Files`.
/// Regression: the old code filled `names` from any line with a third /// Regression: the old code filled `names` from any line with a third
+27 -3
View File
@@ -60,11 +60,16 @@ pub fn arch_env(host_arch: Option<&str>) -> Result<BTreeMap<String, String>, Str
/// Read the current vendor name from `/etc/dpkg/origins/default` /// Read the current vendor name from `/etc/dpkg/origins/default`
/// (its `Vendor:` or `Origin:` field), defaulting to `"debian"`. /// (its `Vendor:` or `Origin:` field), defaulting to `"debian"`.
pub fn current_vendor() -> String { pub fn current_vendor() -> String {
read_vendor_from(Path::new("/etc/dpkg/origins/default")).unwrap_or_else(|| "debian".to_string()) std::fs::read_to_string(Path::new("/etc/dpkg/origins/default"))
.ok()
.and_then(|content| vendor_from_origins_content(&content))
.unwrap_or_else(|| "debian".to_string())
} }
fn read_vendor_from(path: &Path) -> Option<String> { /// Extract the vendor name from the content of a dpkg origins file: its
let content = std::fs::read_to_string(path).ok()?; /// `Vendor:` field, falling back to `Origin:` when absent. `None` when
/// neither field carries a non-empty value.
pub fn vendor_from_origins_content(content: &str) -> Option<String> {
for line in content.lines() { for line in content.lines() {
if let Some(value) = line.strip_prefix("Vendor:") { if let Some(value) = line.strip_prefix("Vendor:") {
let v = value.trim(); let v = value.trim();
@@ -282,6 +287,25 @@ mod tests {
); );
} }
#[test]
fn vendor_from_origins_content_prefers_vendor_then_origin() {
assert_eq!(
vendor_from_origins_content("Vendor: Ubuntu\nSuite: noble\n"),
Some("Ubuntu".to_string())
);
// Origin fallback when no Vendor field is present.
assert_eq!(
vendor_from_origins_content("Origin: Debian\nSuite: stable\n"),
Some("Debian".to_string())
);
// Empty Vendor falls through to Origin.
assert_eq!(
vendor_from_origins_content("Vendor: \nOrigin: Debian\n"),
Some("Debian".to_string())
);
assert_eq!(vendor_from_origins_content("Suite: stable\n"), None);
}
#[test] #[test]
fn environment_escaping() { fn environment_escaping() {
// The function reads the process env; just verify formatting helpers // The function reads the process env; just verify formatting helpers
+4 -3
View File
@@ -1439,7 +1439,7 @@ Provides: virtual-thing (= 2.0), plain-virtual
let vendor = env::current_vendor(); let vendor = env::current_vendor();
let profiles = env::resolve_build_profiles(&[], &vendor); let profiles = env::resolve_build_profiles(&[], &vendor);
let parallel = env::num_parallel(); let parallel = env::num_parallel();
let build_env_vars: Vec<(String, String)> = [ let build_env_vars: BTreeMap<String, String> = [
("LANG".to_string(), "C".to_string()), ("LANG".to_string(), "C".to_string()),
( (
"DEB_BUILD_OPTIONS".to_string(), "DEB_BUILD_OPTIONS".to_string(),
@@ -1467,8 +1467,9 @@ Provides: virtual-thing (= 2.0), plain-virtual
let opts = crate::build::binary::BinaryMetadataOptions { let opts = crate::build::binary::BinaryMetadataOptions {
profiles, profiles,
vendor, vendor,
parallel, // The metadata records exactly the environment exported to the
source_date_epoch: entry.timestamp, // build steps above.
exported_env: build_env_vars,
build_arch: native_arch.clone(), build_arch: native_arch.clone(),
host_arch: native_arch, host_arch: native_arch,
}; };
+23 -24
View File
@@ -5,7 +5,7 @@ use crate::deb::find_dsc_file;
use crate::ui::deb::{DebUi, Phase}; use crate::ui::deb::{DebUi, Phase};
use crate::ui::logfmt::QuiltClassifier; use crate::ui::logfmt::QuiltClassifier;
use log::warn; use log::warn;
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::error::Error; use std::error::Error;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -420,10 +420,6 @@ fn generate_upload_metadata(
env: &HashMap<String, String>, env: &HashMap<String, String>,
ctx: &Arc<Context>, ctx: &Arc<Context>,
) -> Result<(PathBuf, PathBuf), Box<dyn Error>> { ) -> Result<(PathBuf, PathBuf), Box<dyn Error>> {
let changelog_path = Path::new(package_dir).join("debian/changelog");
let changelog_content = ctx.read_file(&changelog_path)?;
let entry = crate::debian::parse_changelog_entry_from_str(&changelog_content)?;
// Build architecture: the machine inside the build context. // Build architecture: the machine inside the build context.
let build_arch = ctx let build_arch = ctx
.command("dpkg") .command("dpkg")
@@ -440,34 +436,37 @@ fn generate_upload_metadata(
build_arch.clone() build_arch.clone()
}; };
// Vendor resolution inside the context (falls back to the host view). // Vendor resolution inside the context (falls back to the host view);
// shared `Vendor:`/`Origin:` parsing with the source-build path.
let vendor = ctx let vendor = ctx
.read_file(Path::new("/etc/dpkg/origins/default")) .read_file(Path::new("/etc/dpkg/origins/default"))
.ok() .ok()
.and_then(|content| { .and_then(|content| crate::build::env::vendor_from_origins_content(&content))
for line in content.lines() {
if let Some(v) = line.strip_prefix("Vendor:") {
let v = v.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
None
})
.unwrap_or_else(crate::build::env::current_vendor); .unwrap_or_else(crate::build::env::current_vendor);
let profiles = crate::build::env::resolve_build_profiles(&[], &vendor); // The recorded profiles must describe what the build actually ran with:
let source_date_epoch = env // the DEB_BUILD_PROFILES exported to the build steps ('cross' for cross
.get("SOURCE_DATE_EPOCH") // builds), else the vendor defaults.
.and_then(|v| v.parse::<i64>().ok()) let profiles = match env.get("DEB_BUILD_PROFILES") {
.unwrap_or(entry.timestamp); Some(value) => value
.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect(),
None => crate::build::env::resolve_build_profiles(&[], &vendor),
};
// Record exactly the environment exported to the build steps
// (DEB_BUILD_OPTIONS with the real parallel count and 'nocheck', LANG=C,
// SOURCE_DATE_EPOCH, cross DEB_* variables, ...), not values recomputed
// from host state; buildinfo_environment filters out non-dpkg variables.
let exported_env: BTreeMap<String, String> =
env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
let opts = crate::build::binary::BinaryMetadataOptions { let opts = crate::build::binary::BinaryMetadataOptions {
profiles, profiles,
vendor, vendor,
parallel: crate::build::env::num_parallel(), exported_env,
source_date_epoch,
build_arch, build_arch,
host_arch, host_arch,
}; };