Files
pkh/src/build/env.rs
T

441 lines
15 KiB
Rust

//! Build environment setup: `SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`,
//! architecture variables (native `dpkg-architecture` equivalent) and the
//! sanitized environment recorded in `.buildinfo` files.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// 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)
}
/// Merge an inherited `DEB_BUILD_OPTIONS` value with options pkh computes
/// itself.
///
/// `dpkg-buildpackage` prepends the environment's `DEB_BUILD_OPTIONS` to the
/// options it derives (`parallel=N`, ...), so caller-set options such as
/// `terse` or `nocheck` survive alongside pkh's own. The result is therefore
/// the inherited options followed by `computed`, space-separated; each side is
/// trimmed and its internal whitespace runs collapsed. An unset or blank
/// inherited value yields just `computed`.
pub fn merge_deb_build_options(inherited: Option<&str>, computed: &str) -> String {
let computed = normalize_build_options(computed);
match inherited.map(normalize_build_options) {
Some(inherited) if !inherited.is_empty() => format!("{} {}", inherited, computed),
_ => computed,
}
}
/// Trim and collapse internal whitespace in a `DEB_BUILD_OPTIONS` fragment.
fn normalize_build_options(options: &str) -> String {
options.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Compute the environment variables exported 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`: any value inherited from the invoking environment
/// (dpkg-buildpackage prepends it) followed by `parallel=N` (auto-detected
/// job count),
/// - `DEB_BUILD_PROFILES` when non-default profiles are requested.
///
/// The locale is pinned to `C` (`LC_ALL`, which takes precedence over any
/// inherited session setting, plus `LANG`) so build tools emit deterministic,
/// English diagnostics — required for reliable log classification and
/// reproducible builds.
pub fn build_env(
source_date_epoch: i64,
parallel: usize,
build_profiles: &[String],
) -> BTreeMap<String, String> {
let mut env = BTreeMap::new();
env.insert("LANG".to_string(), "C".to_string());
env.insert("LC_ALL".to_string(), "C".to_string());
env.insert(
"SOURCE_DATE_EPOCH".to_string(),
source_date_epoch.to_string(),
);
env.insert(
"DEB_BUILD_OPTIONS".to_string(),
merge_deb_build_options(
std::env::var("DEB_BUILD_OPTIONS").ok().as_deref(),
&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, computed natively by
/// [`crate::debian::arch`] (the equivalent of `dpkg-architecture -f
/// [-a <host-arch>]`).
///
/// 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> {
crate::debian::arch::arch_env(host_arch)
}
/// Read the current vendor name from the active dpkg origins `default` file
/// (`$DPKG_ORIGINS_DIR/default`, falling back to `/etc/dpkg/origins/default`;
/// its `Vendor:` or `Origin:` field), defaulting to `"debian"`.
pub fn current_vendor() -> String {
let path = resolve_origins_default(
std::env::var("DPKG_ORIGINS_DIR").ok().as_deref(),
"/etc/dpkg/origins",
);
std::fs::read_to_string(path)
.ok()
.and_then(|content| vendor_from_origins_content(&content))
.unwrap_or_else(|| "debian".to_string())
}
/// Resolve the path of the active dpkg origins file from the
/// `DPKG_ORIGINS_DIR` value (the directory holding the origin files, where
/// `default` selects the active one) and the fallback directory
/// (`/etc/dpkg/origins`). An unset or empty directory value falls back.
fn resolve_origins_default(origins_dir: Option<&str>, fallback_dir: &str) -> PathBuf {
let dir = origins_dir
.filter(|d| !d.is_empty())
.unwrap_or(fallback_dir);
Path::new(dir).join("default")
}
/// Extract the vendor name from the content of a dpkg origins file: its
/// `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() {
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 distro data carries them (`build_profiles` of the vendor's
/// distribution in `data/distro_info.yml` — the Ubuntu vendor activates
/// `derivative.ubuntu noudeb`, Debian applies none), mirroring what
/// `Dpkg::BuildProfiles` resolves when `DEB_BUILD_PROFILES` is unset. The
/// vendor is matched case-insensitively against the distro data keys
/// (dpkg's `Vendor:` field keeps its original casing); a vendor with no
/// distro entry gets no profiles.
pub fn default_build_profiles(vendor: &str) -> Vec<String> {
crate::distro_info::get_build_profiles(&vendor.to_lowercase()).unwrap_or_default()
}
/// 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("LANG").unwrap(), "C");
assert_eq!(env.get("LC_ALL").unwrap(), "C");
assert_eq!(env.get("SOURCE_DATE_EPOCH").unwrap(), "1787392800");
// Reading the var is race-free; the expected value goes through the
// same merge so the assertion holds whatever the ambient environment
// carries.
let expected = merge_deb_build_options(
std::env::var("DEB_BUILD_OPTIONS").ok().as_deref(),
"parallel=16",
);
assert_eq!(env.get("DEB_BUILD_OPTIONS").unwrap(), &expected);
assert!(!env.contains_key("DEB_BUILD_PROFILES"));
let env = build_env(1, 4, &["nodoc".to_string(), "cross".to_string()]);
assert_eq!(env.get("DEB_BUILD_PROFILES").unwrap(), "nodoc,cross");
}
/// dpkg-buildpackage prepends the inherited `DEB_BUILD_OPTIONS`, so
/// user-set options survive alongside the computed ones.
#[test]
fn merge_prepends_inherited_options() {
assert_eq!(
merge_deb_build_options(Some("terse"), "parallel=16"),
"terse parallel=16"
);
assert_eq!(
merge_deb_build_options(Some("nocheck terse"), "parallel=4"),
"nocheck terse parallel=4"
);
}
/// An unset, empty or blank inherited value yields just the computed
/// options.
#[test]
fn merge_skips_empty_inherited() {
assert_eq!(merge_deb_build_options(None, "parallel=8"), "parallel=8");
assert_eq!(
merge_deb_build_options(Some(""), "parallel=8"),
"parallel=8"
);
assert_eq!(
merge_deb_build_options(Some(" "), "parallel=8"),
"parallel=8"
);
}
/// Both sides are trimmed and internal whitespace runs collapsed: no
/// leading/trailing space, no double spaces in the merged result.
#[test]
fn merge_normalizes_whitespace() {
assert_eq!(
merge_deb_build_options(Some(" terse "), "parallel=2"),
"terse parallel=2"
);
assert_eq!(
merge_deb_build_options(Some("nocheck\t terse"), "parallel=2"),
"nocheck terse parallel=2"
);
}
#[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()]
);
// dpkg's Vendor field keeps its original casing; the distro data
// keys are lowercase.
assert_eq!(
default_build_profiles("Ubuntu"),
vec!["derivative.ubuntu".to_string(), "noudeb".to_string()]
);
// A vendor without a distro entry gets no profiles.
assert!(default_build_profiles("some-derivative").is_empty());
}
#[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);
}
/// `current_vendor` must honor `DPKG_ORIGINS_DIR` (already on the
/// `.buildinfo` allow-list) when locating the `default` origins file,
/// falling back to `/etc/dpkg/origins/default` when unset or empty.
#[test]
fn origins_default_path_honors_dpkg_origins_dir() {
assert_eq!(
resolve_origins_default(Some("/custom/origins"), "/etc/dpkg/origins"),
PathBuf::from("/custom/origins/default")
);
assert_eq!(
resolve_origins_default(None, "/etc/dpkg/origins"),
PathBuf::from("/etc/dpkg/origins/default")
);
// An empty value behaves as unset, like dpkg's `$dir || $default`.
assert_eq!(
resolve_origins_default(Some(""), "/etc/dpkg/origins"),
PathBuf::from("/etc/dpkg/origins/default")
);
}
#[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"));
}
}