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
+1
View File
@@ -18,6 +18,7 @@ regex = "1"
chrono = "0.4"
tokio = { version = "1.41.1", features = ["full"] }
sha2 = "0.10.8"
sha1 = "0.10"
md-5 = "0.10"
hex = "0.4.3"
log = "0.4.28"
-82
View File
@@ -1,82 +0,0 @@
use std::error::Error;
use std::path::Path;
use std::process::Command;
use crate::changelog::parse_changelog_footer;
use crate::utils::gpg;
/// Build a Debian source package (to a .dsc)
pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box<dyn Error>> {
let cwd = cwd.unwrap_or_else(|| Path::new("."));
// Parse changelog to get maintainer information from the last modification entry
let changelog_path = cwd.join("debian/changelog");
let (maintainer_name, maintainer_email) = parse_changelog_footer(&changelog_path)?;
// Check if a GPG key matching the maintainer's email exists
let signing_key = match gpg::find_signing_key_for_email(&maintainer_email) {
Ok(key) => key,
Err(e) => {
// If GPG is not available or there's an error, continue without signing
log::warn!("Failed to check for GPG key: {}", e);
None
}
};
// Build command arguments
let mut command = Command::new("dpkg-buildpackage");
command
.current_dir(cwd)
.arg("-S")
.arg("-I")
.arg("-i")
.arg("-nc")
.arg("-d");
// If a signing key is found, use it for signing
if let Some(key_id) = &signing_key {
command.arg(format!("--sign-keyid={}", key_id));
log::info!("Using GPG key {} for signing", key_id);
} else {
command.arg("--no-sign");
log::info!(
"No GPG key found for {} ({}), building without signing",
maintainer_name,
maintainer_email
);
}
let status = command.status().map_err(|e| {
format!(
"Failed to run 'dpkg-buildpackage': {}. \
Is 'dpkg-dev' (which provides dpkg-buildpackage) installed?",
e
)
})?;
if !status.success() {
return Err(format!(
"dpkg-buildpackage failed with status: {}. \
Re-run with 'RUST_LOG=debug' for more details, or run \
'dpkg-buildpackage -S -I -i -nc -d' manually in '{}' to see the full output.",
status,
cwd.display()
)
.into());
}
if signing_key.is_some() {
println!("Package built and signed successfully!");
} else {
println!("Package built successfully (unsigned).");
}
Ok(())
}
#[cfg(test)]
mod tests {
// We are not testing the build part, as for now this is just a wrapper
// around dpkg-buildpackage.
}
+410
View File
@@ -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"));
}
}
+160
View File
@@ -0,0 +1,160 @@
//! Debian build types (`dpkg-buildpackage -b/-B/-A/-S/-g/-G/--build=...`)
//! and their mapping to `debian/rules` targets.
/// Build type bit flags, mirroring `Dpkg::BuildTypes`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuildType(u8);
/// Source build component (`-S`, `--build=source`).
pub const SOURCE: BuildType = BuildType(0x1);
/// Arch-dependent binary build component (`-B`, `--build=any`).
pub const ARCH_DEP: BuildType = BuildType(0x2);
/// Arch-independent binary build component (`-A`, `--build=all`).
pub const ARCH_INDEP: BuildType = BuildType(0x4);
/// Any binary component.
pub const BINARY: BuildType = BuildType(ARCH_DEP.0 | ARCH_INDEP.0);
/// Normal full build: source + binaries (`-F`, default).
pub const FULL: BuildType = BuildType(SOURCE.0 | BINARY.0);
/// Source + arch-dependent (`-G`).
pub const SOURCE_ARCH_DEP: BuildType = BuildType(SOURCE.0 | ARCH_DEP.0);
/// Source + arch-indep (`-g`).
pub const SOURCE_ARCH_INDEP: BuildType = BuildType(SOURCE.0 | ARCH_INDEP.0);
impl BuildType {
/// Construct from raw bits.
pub const fn from_bits(bits: u8) -> Self {
BuildType(bits)
}
/// Raw bits.
pub const fn bits(self) -> u8 {
self.0
}
/// True if any of `other`'s components are set.
pub fn has_any(self, other: BuildType) -> bool {
self.0 & other.0 != 0
}
/// True if all of `other`'s components are set.
pub fn has_all(self, other: BuildType) -> bool {
self.0 & other.0 == other.0
}
/// True if none of `other`'s components are set.
pub fn has_none(self, other: BuildType) -> bool {
self.0 & other.0 == 0
}
/// Parse a comma-separated `--build=<type>[,...]` option value.
///
/// Valid components: `full`, `source`, `binary`, `any`, `all`.
pub fn from_options(value: &str) -> Result<BuildType, String> {
let mut result = BuildType(0);
for part in value.split(',') {
match part.trim() {
"full" => result = FULL,
"source" => result = BuildType(result.0 | SOURCE.0),
"binary" => result = BuildType(result.0 | BINARY.0),
"any" => result = BuildType(result.0 | ARCH_DEP.0),
"all" => result = BuildType(result.0 | ARCH_INDEP.0),
other => return Err(format!("unknown build type component '{}'", other)),
}
}
if result.0 == 0 {
return Err("empty build type".to_string());
}
Ok(result)
}
/// Canonical comma-separated representation (as passed to
/// `dpkg-genchanges --build=` / `dpkg-genbuildinfo --build=`).
pub fn to_options(self) -> String {
let mut parts = Vec::new();
if self.has_any(SOURCE) {
parts.push("source");
}
if self.has_all(BINARY) {
parts.push("binary");
} else {
if self.has_any(ARCH_DEP) {
parts.push("any");
}
if self.has_any(ARCH_INDEP) {
parts.push("all");
}
}
parts.join(",")
}
/// The `debian/rules` build target for this type:
/// `build`, `build-arch` or `build-indep`.
pub fn build_target(self) -> &'static str {
if self.has_all(BINARY) || self.has_none(BINARY) {
"build"
} else if self.has_any(ARCH_DEP) {
"build-arch"
} else {
"build-indep"
}
}
/// The `debian/rules` binary target for this type:
/// `binary`, `binary-arch` or `binary-indep`.
pub fn binary_target(self) -> &'static str {
if self.has_all(BINARY) || self.has_none(BINARY) {
"binary"
} else if self.has_any(ARCH_DEP) {
"binary-arch"
} else {
"binary-indep"
}
}
/// The architecture suffix used in artifact file names:
/// host arch, `all` or `source`.
pub fn arch_suffix<'a>(self, host_arch: &'a str) -> &'a str {
if self.has_any(ARCH_DEP) {
host_arch
} else if self.has_any(ARCH_INDEP) {
"all"
} else {
"source"
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_options() {
assert_eq!(BuildType::from_options("full").unwrap(), FULL);
assert_eq!(BuildType::from_options("source").unwrap(), SOURCE);
assert_eq!(BuildType::from_options("source,any").unwrap(), SOURCE_ARCH_DEP);
assert_eq!(BuildType::from_options("any,all").unwrap(), BINARY);
assert!(BuildType::from_options("bogus").is_err());
}
#[test]
fn roundtrip_options() {
for t in [FULL, SOURCE, BINARY, SOURCE_ARCH_DEP, SOURCE_ARCH_INDEP] {
assert_eq!(BuildType::from_options(&t.to_options()).unwrap(), t);
}
}
#[test]
fn targets() {
assert_eq!(FULL.build_target(), "build");
assert_eq!(FULL.binary_target(), "binary");
assert_eq!(ARCH_DEP.build_target(), "build-arch");
assert_eq!(ARCH_DEP.binary_target(), "binary-arch");
assert_eq!(ARCH_INDEP.build_target(), "build-indep");
assert_eq!(ARCH_INDEP.binary_target(), "binary-indep");
assert_eq!(SOURCE.arch_suffix("amd64"), "source");
assert_eq!(ARCH_DEP.arch_suffix("amd64"), "amd64");
assert_eq!(ARCH_INDEP.arch_suffix("amd64"), "all");
}
}
+236
View File
@@ -0,0 +1,236 @@
//! Native `.changes` generation (Format 1.8), mirroring `dpkg-genchanges`
//! for the artifact aggregation part: checksums, per-file sections and
//! priorities, changelog-derived fields.
use std::path::Path;
use super::checksums::FileChecksums;
use super::control::{write_paragraph, Paragraph};
use super::files::FilesList;
/// Everything needed to render a `.changes` file.
#[derive(Debug, Clone)]
pub struct ChangesInput {
/// `Date` field: the changelog entry date (verbatim trailer date).
pub date: String,
/// `Source` field, including the ` (sourceversion)` suffix for binNMUs.
pub source: String,
/// Sorted binary package names with artifacts (empty for source-only).
pub binaries: Vec<String>,
/// Active build profiles (`Built-For-Profiles`); omitted when empty.
pub built_for_profiles: Vec<String>,
/// `Architecture` field value in encounter order (e.g. `source`,
/// `amd64 all`, ...).
pub architecture: String,
/// Full version.
pub version: String,
/// Distribution(s).
pub distribution: String,
/// Urgency.
pub urgency: String,
/// `Maintainer` from the control source stanza.
pub maintainer: Option<String>,
/// `Changed-By` from the changelog maintainer.
pub changed_by: Option<String>,
/// Formatted per-package description lines (empty for source-only).
pub descriptions: Vec<String>,
/// Rendered `Changes` field value from the changelog entry.
pub changes_field: String,
/// Computed artifact checksums (dsc, tarballs, debs, buildinfo).
pub checksums: FileChecksums,
/// Registry providing section/priority per file.
pub files_list: FilesList,
}
/// 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
}
/// Format one `Description` line: `%-10s - %-.65s` plus a ` (type)` suffix
/// for non-deb package types, matching `format_desc()` in dpkg-genchanges.
pub fn format_description(package: &str, package_type: &str, summary: &str) -> String {
let mut line = format!("{:<10} - {:.65}", package, summary);
if package_type != "deb" && !package_type.is_empty() {
line.push_str(&format!(" ({})", package_type));
}
line
}
/// Render the `.changes` document (without signature), with fields in dpkg's
/// canonical order for `CTRL_FILE_CHANGES`.
///
/// Note: the legacy `Files` field carries md5+size+section+priority+name,
/// while `Checksums-Sha1`/`Checksums-Sha256` carry the stronger hashes;
/// `Checksums-Md5` is deliberately omitted as redundant, exactly like
/// dpkg-genchanges does.
pub fn render_changes(input: &ChangesInput) -> Paragraph {
let mut p = Paragraph::new();
p.set("Format", "1.8");
p.set("Date", &input.date);
p.set("Source", &input.source);
if !input.binaries.is_empty() {
let joined = input.binaries.join(" ");
p.set("Binary", &wrap_long(&joined));
}
if !input.built_for_profiles.is_empty() {
p.set("Built-For-Profiles", &input.built_for_profiles.join(" "));
}
p.set("Architecture", &input.architecture);
p.set("Version", &input.version);
p.set("Distribution", &input.distribution);
p.set("Urgency", &input.urgency);
if let Some(maintainer) = &input.maintainer {
p.set("Maintainer", maintainer);
}
if let Some(changed_by) = &input.changed_by {
p.set("Changed-By", changed_by);
}
if !input.descriptions.is_empty() {
let mut sorted = input.descriptions.clone();
sorted.sort();
// Leading `\n`: pre-wrapped multiline field, dpkg-style.
p.set("Description", &format!("\n{}", sorted.join("\n")));
}
p.set("Changes", &input.changes_field);
if !input.checksums.is_empty() {
p.set("Checksums-Sha1", &input.checksums.field_sha1());
p.set("Checksums-Sha256", &input.checksums.field_sha256());
// Legacy Files field: md5 size section priority filename
let mut files = String::new();
for (key, entry) in input.checksums.iter() {
let (section, priority) = input
.files_list
.get(key)
.map(|f| (f.section.as_str(), f.priority.as_str()))
.unwrap_or(("-", "-"));
files.push('\n');
files.push_str(&entry.md5);
files.push(' ');
files.push_str(&entry.size.to_string());
files.push(' ');
files.push_str(section);
files.push(' ');
files.push_str(priority);
files.push(' ');
files.push_str(key);
}
p.set("Files", &files);
}
p
}
/// Serialize and atomically write a `.changes` file.
pub fn save_changes(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 description_formatting() {
assert_eq!(
format_description("hello", "deb", "The classic greeting"),
"hello - The classic greeting"
);
assert_eq!(
format_description("verylongpkgname", "udeb", "short"),
"verylongpkgname - short (udeb)"
);
let long_summary = "x".repeat(100);
assert_eq!(format_description("p", "deb", &long_summary).len(), 10 + 3 + 65);
}
#[test]
fn render_source_only_changes() {
let dir = tempfile::tempdir().unwrap();
let dsc_path = dir.path().join("pkg_1.0.dsc");
std::fs::write(&dsc_path, b"content\n").unwrap();
let mut checksums = FileChecksums::new();
checksums.add_file(&dsc_path).unwrap();
let mut files_list = FilesList::new();
files_list.add(super::super::files::FilesEntry::new(
"pkg_1.0.dsc",
"utils",
"optional",
));
let input = ChangesInput {
date: "Sat, 22 Aug 2026 10:00:00 +0000".to_string(),
source: "pkg".to_string(),
binaries: vec![],
built_for_profiles: vec![],
architecture: "source".to_string(),
version: "1.0".to_string(),
distribution: "unstable".to_string(),
urgency: "medium".to_string(),
maintainer: Some("A B <a@b.c>".to_string()),
changed_by: Some("A B <a@b.c>".to_string()),
descriptions: vec![],
changes_field: "pkg (1.0) unstable; urgency=medium\n.\n * Something.".to_string(),
checksums,
files_list,
};
let p = render_changes(&input);
let keys: Vec<&str> = p.iter().map(|(k, _)| k).collect();
assert_eq!(
keys,
vec![
"Format",
"Date",
"Source",
"Architecture",
"Version",
"Distribution",
"Urgency",
"Maintainer",
"Changed-By",
"Changes",
"Checksums-Sha1",
"Checksums-Sha256",
"Files"
]
);
// No Binary / Description / Checksums-Md5 for source-only uploads.
assert!(p.get("Binary").is_none());
assert!(p.get("Description").is_none());
assert!(p.get("Checksums-Md5").is_none());
let files_value = p.get("Files").unwrap();
assert_eq!(
files_value,
"\n<md5> 8 utils optional pkg_1.0.dsc"
.replace("<md5>", &files_value.split_whitespace().next().unwrap_or(""))
);
}
}
+255
View File
@@ -0,0 +1,255 @@
//! File checksum computation and formatting for `.changes` / `.buildinfo`
//! fields (MD5, SHA-1, SHA-256 + size), mirroring `Dpkg::Checksums`.
use std::collections::HashMap;
use std::io::Read;
use std::path::Path;
use md5::Md5;
use sha1::Sha1;
use sha2::{Digest, Sha256};
/// Checksums and size of a single file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
/// File size in bytes.
pub size: u64,
/// Lowercase hexadecimal MD5 digest.
pub md5: String,
/// Lowercase hexadecimal SHA-1 digest.
pub sha1: String,
/// Lowercase hexadecimal SHA-256 digest.
pub sha256: String,
}
/// Compute all supported checksums of a file.
fn compute(path: &Path) -> Result<Entry, Box<dyn std::error::Error>> {
let mut file = std::fs::File::open(path)
.map_err(|e| format!("cannot open '{}' for checksumming: {}", path.display(), e))?;
let mut md5_hasher = Md5::new();
let mut sha1_hasher = Sha1::new();
let mut sha256_hasher = Sha256::new();
let mut size: u64 = 0;
let mut buf = [0u8; 64 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
md5_hasher.update(&buf[..n]);
sha1_hasher.update(&buf[..n]);
sha256_hasher.update(&buf[..n]);
size += n as u64;
}
Ok(Entry {
size,
md5: hex::encode(md5_hasher.finalize()),
sha1: hex::encode(sha1_hasher.finalize()),
sha256: hex::encode(sha256_hasher.finalize()),
})
}
/// A registry of checksummed files, keyed by the name they are distributed
/// under (which may differ from the on-disk path).
///
/// Insertion order is preserved, matching the order in which
/// `dpkg-genchanges` accumulates artifacts (dsc, tarballs, debs, buildinfo).
#[derive(Debug, Clone, Default)]
pub struct FileChecksums {
entries: Vec<(String, Entry)>,
index: HashMap<String, usize>,
}
impl FileChecksums {
/// Create an empty registry.
pub fn new() -> Self {
Self::default()
}
/// Add a file, registering it under its own file name.
pub fn add_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let key = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| format!("invalid file name: {}", path.display()))?
.to_string();
self.add_file_as(path, &key)
}
/// Add a file, registering it under an explicit distribution key.
pub fn add_file_as(
&mut self,
path: &Path,
key: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let entry = compute(path)?;
self.insert_entry(key, entry);
Ok(())
}
/// Insert a pre-computed entry (e.g. taken from a `.dsc` checksum field).
/// Re-inserting an existing key updates it in place, keeping its position.
pub fn insert_entry(&mut self, key: &str, entry: Entry) {
if let Some(&pos) = self.index.get(key) {
self.entries[pos].1 = entry;
return;
}
self.index.insert(key.to_string(), self.entries.len());
self.entries.push((key.to_string(), entry));
}
/// Remove a file from the registry. Returns true if it was present.
pub fn remove(&mut self, key: &str) -> bool {
match self.index.remove(key) {
Some(pos) => {
self.entries.remove(pos);
// Reindex the shifted tail.
for (i, (k, _)) in self.entries.iter().enumerate().skip(pos) {
self.index.insert(k.clone(), i);
}
true
}
None => false,
}
}
/// Look up the entry for a given key.
pub fn get(&self, key: &str) -> Option<&Entry> {
self.index.get(key).map(|&pos| &self.entries[pos].1)
}
/// Iterate over `(key, entry)` pairs in insertion order.
pub fn iter(&self) -> impl Iterator<Item = (&String, &Entry)> {
self.entries.iter().map(|(k, e)| (k, e))
}
/// Number of registered files.
pub fn len(&self) -> usize {
self.entries.len()
}
/// True if no file is registered.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Format a `Checksums-*` style field value: one `\n`-separated line per
/// file of the form `" <hash> <size> <key>"`.
fn format_field<F>(&self, hash_of: F) -> String
where
F: Fn(&Entry) -> &str,
{
let mut out = String::new();
for (key, e) in self.iter() {
out.push('\n');
out.push_str(hash_of(e));
out.push(' ');
out.push_str(&e.size.to_string());
out.push(' ');
out.push_str(key);
}
out
}
/// Value for the `Checksums-Md5` field (empty string if no file).
pub fn field_md5(&self) -> String {
self.format_field(|e| &e.md5)
}
/// Value for the `Checksums-Sha1` field (empty string if no file).
pub fn field_sha1(&self) -> String {
self.format_field(|e| &e.sha1)
}
/// Value for the `Checksums-Sha256` field (empty string if no file).
pub fn field_sha256(&self) -> String {
self.format_field(|e| &e.sha256)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_digests() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("sample.txt");
std::fs::write(&p, b"hello world\n").unwrap();
let mut cs = FileChecksums::new();
cs.add_file(&p).unwrap();
let e = cs.get("sample.txt").unwrap();
// Verified with coreutils: echo "hello world" | md5sum / sha1sum / sha256sum
assert_eq!(e.md5, "6f5902ac237024bdd0c176cb93063dc4");
assert_eq!(e.sha1, "22596363b3de40b06f981fb85d82312e8c0ed511");
assert_eq!(
e.sha256,
"a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"
);
assert_eq!(e.size, 12);
}
#[test]
fn insertion_order_preserved() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt");
std::fs::write(&a, b"aaa").unwrap();
std::fs::write(&b, b"bb").unwrap();
let mut cs = FileChecksums::new();
// Insert b first: insertion order (not alphabetical) must be kept,
// matching dpkg's artifact accumulation order.
cs.add_file(&b).unwrap();
cs.add_file(&a).unwrap();
let keys: Vec<&str> = cs.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, vec!["b.txt", "a.txt"]);
assert_eq!(
cs.field_md5(),
"\n21ad0bd836b90d08f4cf640b4c298e7c 2 b.txt\n47bce5c74f589f4867dbd57e9ca9f808 3 a.txt"
);
}
#[test]
fn reinsert_updates_in_place() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.txt");
std::fs::write(&a, b"aaa").unwrap();
let mut cs = FileChecksums::new();
cs.add_file(&a).unwrap();
std::fs::write(&a, b"bbbb").unwrap();
cs.add_file(&a).unwrap(); // updated in place, same position
assert_eq!(cs.len(), 1);
assert_eq!(cs.get("a.txt").unwrap().size, 4);
}
#[test]
fn remove_keeps_order() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt");
let c = dir.path().join("c.txt");
std::fs::write(&a, b"1").unwrap();
std::fs::write(&b, b"2").unwrap();
std::fs::write(&c, b"3").unwrap();
let mut cs = FileChecksums::new();
cs.add_file(&a).unwrap();
cs.add_file(&b).unwrap();
cs.add_file(&c).unwrap();
assert!(cs.remove("b.txt"));
assert!(!cs.remove("b.txt"));
let keys: Vec<&str> = cs.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, vec!["a.txt", "c.txt"]);
}
}
+212
View File
@@ -0,0 +1,212 @@
//! Minimal Debian control-file (deb822) paragraph parser and writer.
//!
//! Implements the subset of RFC822-ish parsing needed for `debian/control`,
//! `debian/files`, `.dsc`, `.changes` and `.buildinfo` files: paragraphs
//! separated by blank lines, `Field: value` entries with continuation lines
//! starting by a single space or tab, and `#` comments.
/// A single deb822 paragraph: an ordered list of `(field, value)` pairs.
///
/// Values are stored with continuation-line breaks as `\n` and without the
/// leading whitespace of continuation lines. Serialization re-adds a single
/// leading space in front of every continuation line, matching dpkg output.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Paragraph {
fields: Vec<(String, String)>,
}
impl Paragraph {
/// Create an empty paragraph.
pub fn new() -> Self {
Self::default()
}
/// Look up a field value (case-insensitive field name).
pub fn get(&self, field: &str) -> Option<&str> {
self.fields
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(field))
.map(|(_, v)| v.as_str())
}
/// Set a field value, replacing any previous occurrence (case-insensitive).
/// Appends the field at the end if it did not exist yet.
pub fn set(&mut self, field: &str, value: &str) {
for (k, v) in self.fields.iter_mut() {
if k.eq_ignore_ascii_case(field) {
*v = value.to_string();
return;
}
}
self.fields.push((field.to_string(), value.to_string()));
}
/// Remove a field (case-insensitive). Returns true if it was present.
pub fn remove(&mut self, field: &str) -> bool {
let before = self.fields.len();
self.fields.retain(|(k, _)| !k.eq_ignore_ascii_case(field));
self.fields.len() != before
}
/// Iterate over the `(field, value)` pairs in order.
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
/// Return true if the paragraph holds no field.
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
}
/// Parse a deb822 document into a list of paragraphs.
///
/// Comment lines (starting with `#`) are ignored. Blank lines separate
/// paragraphs. Continuation lines must start with a space or a tab; exactly
/// one leading space (or tab) is stripped from the stored value.
pub fn parse_paragraphs(input: &str) -> Vec<Paragraph> {
let mut paragraphs = Vec::new();
let mut current = Paragraph::new();
let mut last_field: Option<String> = None;
for raw_line in input.lines() {
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
// Comments and blank lines
if line.starts_with('#') {
continue;
}
if line.trim().is_empty() {
if !current.is_empty() {
paragraphs.push(std::mem::take(&mut current));
last_field = None;
}
continue;
}
// Continuation line
if line.starts_with(' ') || line.starts_with('\t') {
let content = line.strip_prefix(' ').unwrap_or(line);
if let Some(field) = &last_field {
if let Some((_, v)) = current
.fields
.iter_mut()
.rev()
.find(|(k, _)| k.eq_ignore_ascii_case(field))
{
v.push('\n');
v.push_str(content);
continue;
}
}
// Continuation without a preceding field line: skip it (malformed)
continue;
}
// Field line: `Name: value`
if let Some(colon) = line.find(':') {
let name = line[..colon].trim();
let value = line[colon + 1..].trim_start();
if name.is_empty() {
continue;
}
current.fields.push((name.to_string(), value.to_string()));
last_field = Some(name.to_string());
}
// Anything else is malformed: ignore the line
}
if !current.is_empty() {
paragraphs.push(current);
}
paragraphs
}
/// Serialize a paragraph to its deb822 textual representation (with a
/// trailing newline).
///
/// A value starting with `\n` is rendered as a field with no inline first
/// line (`Field:` followed by ` line` continuations), matching dpkg output
/// for pre-wrapped values such as `Changes`, `Files` or `Environment`.
pub fn write_paragraph(p: &Paragraph) -> String {
let mut out = String::new();
for (name, value) in p.iter() {
out.push_str(name);
out.push(':');
let mut lines = value.split('\n').peekable();
// An empty first segment means: no value on the field header line;
// discard it so it is not rendered as an empty continuation line.
if lines.peek().is_some_and(|first| !first.is_empty()) {
out.push(' ');
out.push_str(lines.next().unwrap());
} else {
lines.next();
}
for line in lines {
out.push('\n');
out.push(' ');
out.push_str(line);
}
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_control() {
let input = "Source: hello\nSection: devel\n\nPackage: hello\nDepends: libc6\n";
let paras = parse_paragraphs(input);
assert_eq!(paras.len(), 2);
assert_eq!(paras[0].get("Source"), Some("hello"));
assert_eq!(paras[0].get("section"), Some("devel"));
assert_eq!(paras[1].get("Package"), Some("hello"));
assert_eq!(paras[1].get("Depends"), Some("libc6"));
}
#[test]
fn parse_multiline_and_comments() {
let input = "# a comment\nDescription: short\n long description\n" //
.to_string() //
+ " spanning lines\n\nPackage: x\n";
let paras = parse_paragraphs(&input);
assert_eq!(paras.len(), 2);
assert_eq!(
paras[0].get("Description"),
Some("short\nlong description\nspanning lines")
);
}
#[test]
fn roundtrip_multiline() {
let value = "short\nlong description\nspanning lines";
let mut p = Paragraph::new();
p.set("Description", value);
let text = write_paragraph(&p);
assert_eq!(text, "Description: short\n long description\n spanning lines\n");
let reparsed = parse_paragraphs(&text);
assert_eq!(reparsed[0].get("Description"), Some(value));
}
#[test]
fn set_replaces_case_insensitive() {
let mut p = Paragraph::new();
p.set("Source", "a");
p.set("source", "b");
assert_eq!(p.get("SOURCE"), Some("b"));
assert_eq!(p.iter().count(), 1);
}
#[test]
fn remove_field() {
let mut p = Paragraph::new();
p.set("A", "1");
assert!(p.remove("a"));
assert!(!p.remove("a"));
assert!(p.is_empty());
}
}
+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"));
}
}
+251
View File
@@ -0,0 +1,251 @@
//! `debian/files` registry: the contract between the build (`dh_builddeb`,
//! `dpkg-gencontrol`, ...) and the artifact generators, mirroring
//! `Dpkg::Dist::Files`.
use std::collections::BTreeMap;
use std::io::Write;
use std::path::Path;
/// One registered artifact.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilesEntry {
/// File name (relative to the parent directory of the package tree).
pub filename: String,
/// Archive section (e.g. `utils`).
pub section: String,
/// Archive priority (e.g. `optional`).
pub priority: String,
/// Package name parsed from the file name pattern, if any.
pub package: Option<String>,
/// Version parsed from the file name pattern, if any.
pub version: Option<String>,
/// Architecture parsed from the file name pattern, if any.
pub arch: Option<String>,
/// Artifact type parsed from the file name extension
/// (`deb`, `udeb`, `buildinfo`, `changes`, ...).
pub package_type: Option<String>,
/// Extra `key=value` attributes on the line (e.g. `automatic=yes`).
pub attrs: BTreeMap<String, String>,
}
impl FilesEntry {
/// Create a minimal entry with only name/section/priority; the pattern
/// fields are derived from the file name.
pub fn new(filename: &str, section: &str, priority: &str) -> FilesEntry {
let mut entry = parse_filename(filename).unwrap_or_else(|| FilesEntry {
filename: filename.to_string(),
section: "-".to_string(),
priority: "-".to_string(),
package: None,
version: None,
arch: None,
package_type: None,
attrs: BTreeMap::new(),
});
entry.section = section.to_string();
entry.priority = priority.to_string();
entry
}
}
/// Derive metadata from an artifact file name following the common
/// `<package>_<version>_<arch>.<type>` pattern, like
/// `Dpkg::Dist::Files::parse_filename()`.
#[allow(clippy::field_reassign_with_default)]
pub fn parse_filename(name: &str) -> Option<FilesEntry> {
// Common pattern: name_version_arch.type where type is dot-separated
// (e.g. tar.xz must not match here since it has no leading underscores).
let parts: Vec<&str> = name.split('_').collect();
if parts.len() == 3 {
let (pkg, version, rest) = (parts[0], parts[1], parts[2]);
if let Some(dot) = rest.rfind('.') {
let arch = &rest[..dot];
let ptype = &rest[dot + 1..];
let valid = |s: &str| !s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || "-+.:~".contains(c));
if valid(pkg) && valid(version) && valid(arch) && valid(ptype) {
return Some(FilesEntry {
filename: name.to_string(),
section: "-".to_string(),
priority: "-".to_string(),
package: Some(pkg.to_string()),
version: Some(version.to_string()),
arch: Some(arch.to_string()),
package_type: Some(ptype.to_string()),
attrs: BTreeMap::new(),
});
}
}
}
// Fallback: accept a conservative file-name character set.
if !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-+.:,_~".contains(c))
{
return Some(FilesEntry {
filename: name.to_string(),
section: "-".to_string(),
priority: "-".to_string(),
package: None,
version: None,
arch: None,
package_type: None,
attrs: BTreeMap::new(),
});
}
None
}
/// The full `debian/files` registry, ordered by file name.
#[derive(Debug, Clone, Default)]
pub struct FilesList {
files: BTreeMap<String, FilesEntry>,
}
impl FilesList {
/// An empty registry.
pub fn new() -> Self {
Self::default()
}
/// Load `debian/files`. A missing file yields an empty registry.
pub fn load(path: &Path) -> Result<FilesList, Box<dyn std::error::Error>> {
let mut list = FilesList::new();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(list),
Err(e) => {
return Err(format!("cannot read '{}': {}", path.display(), e).into());
}
};
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 3 {
return Err(format!("badly formed line in '{}': {}", path.display(), line).into());
}
let mut entry = parse_filename(tokens[0]).ok_or_else(|| {
format!("badly formed file name in '{}': {}", path.display(), tokens[0])
})?;
entry.section = tokens[1].to_string();
entry.priority = tokens[2].to_string();
for attr in &tokens[3..] {
if let Some((k, v)) = attr.split_once('=') {
entry.attrs.insert(k.to_string(), v.to_string());
}
}
list.files.insert(entry.filename.clone(), entry);
}
Ok(list)
}
/// Register (or replace) an entry.
pub fn add(&mut self, entry: FilesEntry) {
self.files.insert(entry.filename.clone(), entry);
}
/// Remove entries matching a predicate. Returns how many were removed.
pub fn retain<F: FnMut(&FilesEntry) -> bool>(&mut self, mut keep: F) -> usize {
let before = self.files.len();
self.files.retain(|_, e| keep(e));
before - self.files.len()
}
/// Iterate over entries sorted by file name.
pub fn iter(&self) -> impl Iterator<Item = &FilesEntry> {
self.files.values()
}
/// Look up an entry by file name.
pub fn get(&self, filename: &str) -> Option<&FilesEntry> {
self.files.get(filename)
}
/// Number of registered files.
pub fn len(&self) -> usize {
self.files.len()
}
/// True if empty.
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
/// Save atomically: write `<path>.new` then rename over `path`, like
/// dpkg does.
pub fn save_atomic(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let tmp = path.with_extension("new");
{
let mut f = std::fs::File::create(&tmp)
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
for entry in self.iter() {
write!(f, "{} {} {}", entry.filename, entry.section, entry.priority)?;
for (k, v) in &entry.attrs {
write!(f, " {}={}", k, v)?;
}
writeln!(f)?;
}
f.flush()?;
}
std::fs::rename(&tmp, path)
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filename_pattern_parsing() {
let e = parse_filename("hello_2.10-3_amd64.deb").unwrap();
assert_eq!(e.package.as_deref(), Some("hello"));
assert_eq!(e.version.as_deref(), Some("2.10-3"));
assert_eq!(e.arch.as_deref(), Some("amd64"));
assert_eq!(e.package_type.as_deref(), Some("deb"));
let e = parse_filename("hello_0.1_source.buildinfo").unwrap();
assert_eq!(e.package.as_deref(), Some("hello"));
assert_eq!(e.arch.as_deref(), Some("source"));
assert_eq!(e.package_type.as_deref(), Some("buildinfo"));
// Tarballs do not follow the 3-component pattern.
let e = parse_filename("hello_0.1.tar.xz").unwrap();
assert_eq!(e.package, None);
assert_eq!(e.package_type, None);
}
#[test]
fn load_save_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("files");
let mut list = FilesList::new();
list.add(FilesEntry::new("hello_1.0_amd64.deb", "devel", "optional"));
list.add(FilesEntry::new("hello_1.0_source.buildinfo", "-", "-"));
list.save_atomic(&path).unwrap();
let reloaded = FilesList::load(&path).unwrap();
assert_eq!(reloaded.len(), 2);
let deb = reloaded.get("hello_1.0_amd64.deb").unwrap();
assert_eq!(deb.section, "devel");
assert_eq!(deb.priority, "optional");
// Missing file loads as empty.
let missing = FilesList::load(&dir.path().join("nonexistent")).unwrap();
assert!(missing.is_empty());
}
#[test]
fn retain_removes_matching() {
let mut list = FilesList::new();
list.add(FilesEntry::new("x_1_source.buildinfo", "-", "-"));
list.add(FilesEntry::new("x_1_amd64.deb", "-", "-"));
let removed = list.retain(|e| e.package_type.as_deref() != Some("buildinfo"));
assert_eq!(removed, 1);
assert_eq!(list.len(), 1);
}
}
+428
View File
@@ -0,0 +1,428 @@
//! Source package metadata: Debian version splitting, changelog entry
//! parsing and `debian/control` parsing.
use std::path::Path;
use chrono::DateTime;
use super::control::{parse_paragraphs, Paragraph};
/// A Debian version, split into its `[epoch:]upstream[-revision]` parts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DebianVersion {
/// Optional numeric epoch (part before the first `:`).
pub epoch: Option<u32>,
/// Upstream version (may itself contain `-` when there is no revision).
pub upstream: String,
/// Optional Debian revision (part after the last `-`).
pub debian_revision: Option<String>,
}
impl DebianVersion {
/// Parse and validate a Debian version string.
pub fn parse(raw: &str) -> Result<DebianVersion, String> {
let raw = raw.trim();
if raw.is_empty() {
return Err("empty version string".to_string());
}
let (epoch, rest) = match raw.split_once(':') {
Some((e, r)) => {
let epoch: u32 = e
.parse()
.map_err(|_| format!("invalid epoch '{}' in version '{}'", e, raw))?;
(Some(epoch), r)
}
None => (None, raw),
};
// The revision is everything after the last hyphen.
let (upstream, debian_revision) = match rest.rsplit_once('-') {
Some((u, r)) => (u.to_string(), Some(r.to_string())),
None => (rest.to_string(), None),
};
if upstream.is_empty() {
return Err(format!("missing upstream version in '{}'", raw));
}
for c in upstream.chars() {
if !(c.is_ascii_alphanumeric()
|| matches!(c, '.' | '+' | '-' | '~' | ':')
|| !c.is_ascii())
{
return Err(format!("invalid character '{}' in version '{}'", c, raw));
}
}
if let Some(rev) = &debian_revision {
for c in rev.chars() {
if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) {
return Err(format!(
"invalid character '{}' in revision of version '{}'",
c, raw
));
}
}
}
Ok(DebianVersion {
epoch,
upstream,
debian_revision,
})
}
/// Full version string, including the epoch (`[epoch:]upstream[-rev]`).
pub fn full(&self) -> String {
match (&self.epoch, &self.debian_revision) {
(Some(e), Some(r)) => format!("{}:{}-{}", e, self.upstream, r),
(Some(e), None) => format!("{}:{}", e, self.upstream),
(None, Some(r)) => format!("{}-{}", self.upstream, r),
(None, None) => self.upstream.clone(),
}
}
/// Version string without the epoch (`upstream[-rev]`), used in artifact
/// file names.
pub fn no_epoch(&self) -> String {
match &self.debian_revision {
Some(r) => format!("{}-{}", self.upstream, r),
None => self.upstream.clone(),
}
}
}
/// A parsed `debian/changelog` entry (the most recent one).
#[derive(Debug, Clone)]
pub struct ChangelogEntry {
/// Source package name.
pub source: String,
/// Parsed version.
pub version: DebianVersion,
/// Raw distribution(s) field, e.g. `"unstable"` or `"focal"`.
pub distribution: String,
/// Urgency value, e.g. `"medium"`.
pub urgency: String,
/// True for binNMU-style entries (`binary-only=yes` header parameter).
pub binary_only: bool,
/// Maintainer name from the trailer line.
pub maintainer_name: String,
/// Maintainer email from the trailer line.
pub maintainer_email: String,
/// Verbatim trailer date string (RFC2822-ish).
pub date_raw: String,
/// Trailer date parsed as a Unix timestamp.
pub timestamp: i64,
/// Value for the `.changes` `Changes` field: header line, blank lines
/// converted to `.`, body lines verbatim; without the trailer line.
pub changes_field: String,
}
/// Parse the most recent entry of a Debian changelog file.
pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path).map_err(|e| {
format!(
"failed to read changelog '{}': {}. Make sure you are running \
from the root of a source package.",
path.display(),
e
)
})?;
let mut lines = content.lines().peekable();
// --- Header line: `package (version) distributions; urgency=medium[, key=value]`
let header = loop {
match lines.next() {
Some(l) if l.trim().is_empty() => continue,
Some(l) => break l.trim_end(),
None => {
return Err(format!("changelog '{}' is empty", path.display()).into());
}
}
};
let open = header
.find('(')
.ok_or_else(|| format!("invalid changelog header in '{}': {}", path.display(), header))?;
let close = header[open..]
.find(')')
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{}'", header))?;
let source = header[..open].trim().to_string();
if source.is_empty() || source.contains(' ') {
return Err(format!("invalid source name in changelog header '{}'", header).into());
}
let version = DebianVersion::parse(&header[open + 1..open + close])?;
let after_version = &header[open + close + 1..];
let (distributions_part, params_part) = match after_version.split_once(';') {
Some((d, p)) => (d, p),
None => (after_version, ""),
};
let distribution = distributions_part.trim().to_string();
if distribution.is_empty() {
return Err(format!("missing distribution in changelog header '{}'", header).into());
}
let mut urgency = String::from("unknown");
let mut binary_only = false;
for param in params_part.split(',') {
let param = param.trim();
if let Some(value) = param.strip_prefix("urgency=") {
urgency = value.trim().to_string();
} else if param == "binary-only=yes" || param == "binary-only=yes," {
binary_only = true;
}
}
// --- Body until trailer line ` -- Name <email> Date`
let mut body_lines: Vec<String> = Vec::new();
let mut trailer: Option<String> = None;
for line in lines {
let line = line.trim_end();
if line.starts_with(" -- ") {
trailer = Some(line.to_string());
break;
}
// Stop at an emacs local-variables block or a new entry header.
if line.starts_with("Local variables:") {
break;
}
if !line.trim().is_empty() && looks_like_header(line) && !body_lines.is_empty() {
break;
}
// Blank lines become "." like dpkg does for the Changes field.
if line.trim().is_empty() {
body_lines.push(".".to_string());
} else {
body_lines.push(line.to_string());
}
}
let trailer_line = trailer.ok_or_else(|| {
format!(
"no maintainer trailer found in '{}': expected a line of the form \
' -- Name <email> Date'",
path.display()
)
})?;
// Strip trailing "." lines left over from blank lines before the trailer.
while body_lines.last().map(|l| l == ".").unwrap_or(false) {
body_lines.pop();
}
let trailer_body = trailer_line.strip_prefix(" -- ").unwrap_or(&trailer_line);
let lt = trailer_body
.find('<')
.ok_or_else(|| format!("malformed maintainer trailer '{}'", trailer_line))?;
let gt = trailer_body[lt..]
.find('>')
.map(|i| i + lt)
.ok_or_else(|| format!("malformed maintainer trailer '{}'", trailer_line))?;
let maintainer_name = trailer_body[..lt].trim().to_string();
let maintainer_email = trailer_body[lt + 1..gt].trim().to_string();
let date_raw = trailer_body[gt + 1..].trim().to_string();
let timestamp = DateTime::parse_from_rfc2822(&date_raw)
.map_err(|e| {
format!(
"cannot parse changelog date '{}' in '{}': {}",
date_raw,
path.display(),
e
)
})?
.timestamp();
// Changes field value (leading `\n` marks it as a pre-wrapped multiline
// field, like dpkg's own representation): header + blank-as-dot + body,
// without the trailer line.
let mut changes_field = String::from("\n");
changes_field.push_str(header);
if !body_lines.is_empty() {
changes_field.push('\n');
changes_field.push_str(&body_lines.join("\n"));
}
Ok(ChangelogEntry {
source,
version,
distribution,
urgency,
binary_only,
maintainer_name,
maintainer_email,
date_raw,
timestamp,
changes_field,
})
}
/// Heuristic check for a changelog entry header line
/// (`name (version) dist; urgency=...`).
fn looks_like_header(line: &str) -> bool {
// Headers are never indented.
if line.starts_with(' ') || line.starts_with('\t') {
return false;
}
match line.find('(') {
Some(open) => {
let name = line[..open].trim();
!name.is_empty() && !name.contains(' ')
}
None => false,
}
}
/// Parsed `debian/control`: the source stanza plus all binary stanzas.
#[derive(Debug, Clone)]
pub struct ControlInfo {
/// First paragraph (source package stanza).
pub source: Paragraph,
/// Remaining paragraphs (binary package stanzas).
pub binaries: Vec<Paragraph>,
}
impl ControlInfo {
/// Parse a `debian/control` file.
pub fn parse(path: &Path) -> Result<ControlInfo, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read control file '{}': {}", path.display(), e))?;
Self::from_str(&content)
.map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into())
}
/// Parse control content from a string.
pub fn from_str(content: &str) -> Result<ControlInfo, String> {
let paragraphs = parse_paragraphs(content);
let mut iter = paragraphs.into_iter();
let source = iter
.next()
.ok_or_else(|| "control file has no paragraphs".to_string())?;
if source.get("Source").is_none() {
return Err("first control paragraph has no 'Source' field".to_string());
}
let binaries: Vec<Paragraph> = iter.collect();
for bin in &binaries {
if bin.get("Package").is_none() {
return Err("binary control paragraph has no 'Package' field".to_string());
}
}
Ok(ControlInfo { source, binaries })
}
/// The source package name.
pub fn source_name(&self) -> &str {
self.source.get("Source").expect("checked at parse")
}
/// Section from the source stanza, or `'-'`.
pub fn section(&self) -> &str {
self.source.get("Section").unwrap_or("-")
}
/// Priority from the source stanza, or `'-'`.
pub fn priority(&self) -> &str {
self.source.get("Priority").unwrap_or("-")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_splitting() {
let v = DebianVersion::parse("1.2.3-4ubuntu5").unwrap();
assert_eq!(v.epoch, None);
assert_eq!(v.upstream, "1.2.3");
assert_eq!(v.debian_revision.as_deref(), Some("4ubuntu5"));
assert_eq!(v.full(), "1.2.3-4ubuntu5");
assert_eq!(v.no_epoch(), "1.2.3-4ubuntu5");
let v = DebianVersion::parse("3:2.10-3").unwrap();
assert_eq!(v.epoch, Some(3));
assert_eq!(v.upstream, "2.10");
assert_eq!(v.no_epoch(), "2.10-3");
assert_eq!(v.full(), "3:2.10-3");
let v = DebianVersion::parse("1.0").unwrap();
assert_eq!(v.debian_revision, None);
assert_eq!(v.no_epoch(), "1.0");
}
#[test]
fn version_validation() {
assert!(DebianVersion::parse("").is_err());
assert!(DebianVersion::parse(":1.0").is_err());
assert!(DebianVersion::parse("a:_b").is_err());
assert!(DebianVersion::parse("1.0").is_ok());
assert!(DebianVersion::parse("1.0~rc1-2").is_ok());
}
#[test]
fn changelog_parsing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("changelog");
let content = "\
pkh-hello (0.1) unstable; urgency=medium
* Initial release.
* Second change line.
-- Pkh Tester <pkh@example.com> Sat, 22 Aug 2026 10:00:00 +0000
";
std::fs::write(&path, content).unwrap();
let entry = parse_changelog_entry(&path).unwrap();
assert_eq!(entry.source, "pkh-hello");
assert_eq!(entry.version.full(), "0.1");
assert_eq!(entry.distribution, "unstable");
assert_eq!(entry.urgency, "medium");
assert!(!entry.binary_only);
assert_eq!(entry.maintainer_name, "Pkh Tester");
assert_eq!(entry.maintainer_email, "pkh@example.com");
assert_eq!(entry.timestamp, 1787392800);
assert_eq!(
entry.changes_field,
"\npkh-hello (0.1) unstable; urgency=medium\n.\n * Initial release.\n * Second change line."
);
}
#[test]
fn changelog_bin_nmu() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("changelog");
let content = "\
pkg (1.0-1+b1) unstable; urgency=medium, binary-only=yes
* Binary-only non-maintainer upload.
-- Builder <b@example.com> Mon, 01 Jan 2024 00:00:00 +0000
";
std::fs::write(&path, content).unwrap();
let entry = parse_changelog_entry(&path).unwrap();
assert!(entry.binary_only);
assert_eq!(entry.version.full(), "1.0-1+b1");
}
#[test]
fn control_parsing() {
let ci = ControlInfo::from_str(
"Source: hello\nSection: utils\nPriority: optional\nMaintainer: A B <a@b.c>\nBuild-Depends: debhelper\n\nPackage: hello\nArchitecture: any\nDescription: test\n long\n",
)
.unwrap();
assert_eq!(ci.source_name(), "hello");
assert_eq!(ci.section(), "utils");
assert_eq!(ci.priority(), "optional");
assert_eq!(ci.binaries.len(), 1);
assert_eq!(ci.binaries[0].get("Package"), Some("hello"));
}
#[test]
fn control_defaults() {
let ci = ControlInfo::from_str("Source: x\n\nPackage: x\nDescription: d\n").unwrap();
assert_eq!(ci.section(), "-");
assert_eq!(ci.priority(), "-");
}
}
+449
View File
@@ -0,0 +1,449 @@
//! Native Debian source-package build pipeline.
//!
//! Re-implements the orchestration performed by `dpkg-buildpackage -S`
//! (environment setup, `dpkg-source` lifecycle, `.buildinfo` / `.changes`
//! generation and OpenPGP signing) natively in Rust, while delegating the
//! source-tree work (tarballs, diffs, patches) to `dpkg-source` as a
//! subprocess.
pub mod buildinfo;
pub mod buildtype;
pub mod changes;
pub mod checksums;
pub mod control;
pub mod env;
pub mod files;
pub mod metadata;
pub mod sign;
use std::collections::{BTreeMap, HashMap};
use std::error::Error;
use std::path::{Path, PathBuf};
use std::process::Command;
use checksums::{Entry as ChecksumEntry, FileChecksums};
use control::parse_paragraphs;
use files::{FilesEntry, FilesList};
use metadata::ControlInfo;
/// Options for a native source-package build.
#[derive(Debug, Clone, Default)]
pub struct SourceBuildOptions {
/// Explicit signing key id / fingerprint (`-k`). When unset, a secret
/// key matching the changelog maintainer email is searched.
pub sign_keyid: Option<String>,
/// Sign even for an UNRELEASED changelog (`--force-sign`).
pub force_sign: bool,
}
/// Artifacts produced by a successful source build.
#[derive(Debug, Clone)]
pub struct SourceBuildOutput {
/// The generated `.dsc`.
pub dsc: PathBuf,
/// The generated `.buildinfo`.
pub buildinfo: PathBuf,
/// The generated `.changes`.
pub changes: PathBuf,
/// Source tarballs referenced by the `.dsc` (orig, debian tar...).
pub tarballs: Vec<PathBuf>,
/// Whether all artifacts were signed.
pub signed: bool,
}
/// Build a Debian source package (to a .dsc) using the native pipeline.
///
/// Keeps the historical pkh entry-point signature; see [`run_source_build`]
/// for the configurable version.
pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box<dyn Error>> {
let cwd = cwd.unwrap_or_else(|| Path::new("."));
let output = run_source_build(cwd, &SourceBuildOptions::default())?;
if output.signed {
println!("Package built and signed successfully!");
} else {
println!("Package built successfully (unsigned).");
}
Ok(())
}
/// Run the full native source-build pipeline in `cwd`.
///
/// Steps (mirroring `dpkg-buildpackage -S -I -i -nc -d`):
/// 1. sanity checks and metadata resolution (changelog, control),
/// 2. environment setup (`SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`, arch vars),
/// 3. signing decision (key discovery, UNRELEASED handling),
/// 4. `dpkg-source --before-build` then `dpkg-source -b`,
/// 5. native `.buildinfo` generation (+ registration in `debian/files`),
/// 6. native `.changes` generation,
/// 7. `dpkg-source --after-build`,
/// 8. signing cascade: dsc → buildinfo → changes, recomputing checksums of
/// already-generated files at each step.
pub fn run_source_build(
cwd: &Path,
opts: &SourceBuildOptions,
) -> Result<SourceBuildOutput, Box<dyn Error>> {
// ------------------------------------------------------------------
// 1. Sanity checks
// ------------------------------------------------------------------
let parent = cwd
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or_else(|| format!("cannot determine output directory from '{}'", cwd.display()))?
.to_path_buf();
let rules_path = cwd.join("debian/rules");
if !rules_path.exists() {
return Err(format!(
"'{}' not found: '{}' does not look like a Debian source tree",
rules_path.display(),
cwd.display()
)
.into());
}
let changelog_path = cwd.join("debian/changelog");
let control_path = cwd.join("debian/control");
if !changelog_path.exists() {
return Err(format!("'{}' not found", changelog_path.display()).into());
}
if !control_path.exists() {
return Err(format!("'{}' not found", control_path.display()).into());
}
// ------------------------------------------------------------------
// 2. Metadata resolution
// ------------------------------------------------------------------
let entry = metadata::parse_changelog_entry(&changelog_path)?;
let ctrl = ControlInfo::parse(&control_path)?;
log::info!("source package {}", entry.source);
log::info!("source version {}", entry.version.full());
log::info!("source distribution {}", entry.distribution);
let sversion = entry.version.no_epoch();
let dsc_name = format!("{}_{}.dsc", entry.source, sversion);
let dsc_path = parent.join(&dsc_name);
let buildinfo_name = format!("{}_{}_source.buildinfo", entry.source, sversion);
let buildinfo_path = parent.join(&buildinfo_name);
let changes_name = format!("{}_{}_source.changes", entry.source, sversion);
let changes_path = parent.join(&changes_name);
// ------------------------------------------------------------------
// 3. Environment setup
// ------------------------------------------------------------------
let vendor = env::current_vendor();
let profiles = env::resolve_build_profiles(&[], &vendor);
let mut pipeline_env = env::build_env(entry.timestamp, env::num_parallel(), &profiles);
let arch_vars = env::arch_env(None)?;
pipeline_env.extend(arch_vars.clone());
// ------------------------------------------------------------------
// 4. Signing decision
// ------------------------------------------------------------------
let mut signing_key = opts.sign_keyid.clone();
if signing_key.is_none() {
match crate::utils::gpg::find_signing_key_for_email(&entry.maintainer_email) {
Ok(Some(key)) => {
log::info!("using GPG key {} for signing", key);
signing_key = Some(key);
}
Ok(None) => {
log::warn!(
"no GPG secret key found for {} <{}>, building without signing",
entry.maintainer_name,
entry.maintainer_email
);
}
Err(e) => {
log::warn!("failed to check for GPG key: {}, building without signing", e);
}
}
}
let do_sign = match &signing_key {
None => false,
Some(_) if entry.distribution == "UNRELEASED" && !opts.force_sign => {
log::warn!("not signing UNRELEASED build; use force_sign to override");
false
}
Some(_) => true,
};
// ------------------------------------------------------------------
// 5. dpkg-source lifecycle: before-build + source build
// ------------------------------------------------------------------
run_command(cwd, "dpkg-source", &["-I", "-i", "--before-build", "."], &pipeline_env)?;
run_command(cwd, "dpkg-source", &["-I", "-i", "-b", "."], &pipeline_env)?;
if !dsc_path.exists() {
return Err(format!(
"dpkg-source did not produce the expected '{}'",
dsc_path.display()
)
.into());
}
// ------------------------------------------------------------------
// 6. .buildinfo generation (native dpkg-genbuildinfo equivalent)
// ------------------------------------------------------------------
let mut checksums = FileChecksums::new();
checksums.add_file_as(&dsc_path, &dsc_name)?;
let status_path = PathBuf::from("/var/lib/dpkg/status");
let bd_fields = [ctrl.source.get("Build-Depends").unwrap_or("")];
let installed_bd = buildinfo::installed_build_depends(&status_path, &bd_fields)?;
let environment = env::buildinfo_environment(&pipeline_env);
let render_buildinfo_doc = |checksums: &FileChecksums| {
buildinfo::render_buildinfo(&buildinfo::BuildInfoInput {
source: entry.source.clone(),
binaries: Vec::new(), // source-only build
architecture: "source".to_string(),
version: entry.version.full(),
binary_only_changes: None,
build_origin: vendor.clone(),
build_architecture: arch_vars
.get("DEB_BUILD_ARCH")
.cloned()
.unwrap_or_else(crate::get_current_arch),
build_date: chrono::Local::now().to_rfc2822(),
checksums: checksums.clone(),
installed_build_depends: installed_bd.clone(),
environment: environment.clone(),
})
};
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
// Register the .buildinfo in debian/files (as dpkg-genbuildinfo does).
let files_path = cwd.join("debian/files");
let mut files_list = FilesList::load(&files_path)?;
files_list.retain(|e| {
!(e.package.as_deref() == Some(entry.source.as_str())
&& e.package_type.as_deref() == Some("buildinfo"))
});
files_list.add(FilesEntry::new(
&buildinfo_name,
ctrl.section(),
ctrl.priority(),
));
files_list.save_atomic(&files_path)?;
// ------------------------------------------------------------------
// 7. .changes generation (native dpkg-genchanges equivalent)
// ------------------------------------------------------------------
// Pull the tarball checksums out of the generated .dsc so they are
// distributed through the .changes like dpkg-genchanges does, in the
// order the .dsc itself lists them.
let dsc_content = std::fs::read_to_string(&dsc_path)
.map_err(|e| format!("cannot read '{}': {}", dsc_path.display(), e))?;
let dsc_para = parse_paragraphs(&dsc_content)
.into_iter()
.next()
.ok_or_else(|| format!("'{}' is empty", dsc_path.display()))?;
let mut tarball_paths = Vec::new();
let mut dsc_file_names: Vec<String> = Vec::new();
let mut dsc_files: HashMap<String, PartialChecksum> = HashMap::new();
for field in ["Checksums-Sha1", "Checksums-Sha256"] {
if let Some(value) = dsc_para.get(field) {
for line in value.lines() {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() != 3 {
continue;
}
if !dsc_files.contains_key(tokens[2]) {
dsc_file_names.push(tokens[2].to_string());
}
let slot = dsc_files.entry(tokens[2].to_string()).or_default();
match field {
"Checksums-Sha1" => slot.sha1 = Some(tokens[0].to_string()),
_ => slot.sha256 = Some(tokens[0].to_string()),
}
slot.size = tokens[1].parse().ok().or(slot.size);
}
}
}
if let Some(files_value) = dsc_para.get("Files") {
for line in files_value.lines() {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() >= 3 {
let slot = dsc_files.entry(tokens[2].to_string()).or_default();
slot.md5 = Some(tokens[0].to_string());
slot.size = tokens[1].parse().ok().or(slot.size);
}
}
}
for name in &dsc_file_names {
if name == &dsc_name {
continue; // already computed directly above
}
let path = parent.join(name);
if !path.exists() {
return Err(format!(
"file '{}' referenced by '{}' is missing",
name,
dsc_path.display()
)
.into());
}
let partial = &dsc_files[name];
checksums.insert_entry(
name,
ChecksumEntry {
size: partial.size.unwrap_or(0),
md5: partial.md5.clone().unwrap_or_default(),
sha1: partial.sha1.clone().unwrap_or_default(),
sha256: partial.sha256.clone().unwrap_or_default(),
},
);
tarball_paths.push(path);
}
// The .buildinfo itself is distributed through the .changes, last (as
// dpkg-genchanges does when it consumes debian/files).
checksums.add_file_as(&buildinfo_path, &buildinfo_name)?;
// The .changes lists section/priority for every distributed file; the
// dsc and tarballs use the source stanza defaults (not persisted into
// debian/files, matching dpkg).
let mut changes_files = files_list.clone();
changes_files.add(FilesEntry::new(&dsc_name, ctrl.section(), ctrl.priority()));
for name in &dsc_file_names {
if name != &dsc_name {
changes_files.add(FilesEntry::new(name, ctrl.section(), ctrl.priority()));
}
}
let changed_by = format!("{} <{}>", entry.maintainer_name, entry.maintainer_email);
let render_changes_doc = |checksums: &FileChecksums| {
changes::render_changes(&changes::ChangesInput {
date: entry.date_raw.clone(),
source: entry.source.clone(),
binaries: Vec::new(), // source-only upload
built_for_profiles: profiles.clone(),
architecture: "source".to_string(),
version: entry.version.full(),
distribution: entry.distribution.clone(),
urgency: entry.urgency.clone(),
maintainer: ctrl.source.get("Maintainer").map(str::to_string),
changed_by: Some(changed_by.clone()),
descriptions: Vec::new(),
changes_field: entry.changes_field.clone(),
checksums: checksums.clone(),
files_list: changes_files.clone(),
})
};
changes::save_changes(&changes_path, &render_changes_doc(&checksums))?;
// ------------------------------------------------------------------
// 8. dpkg-source after-build (unapplies quilt patches it applied)
// ------------------------------------------------------------------
run_command(cwd, "dpkg-source", &["-I", "-i", "--after-build", "."], &pipeline_env)?;
// ------------------------------------------------------------------
// 9. Signing cascade: dsc -> buildinfo -> changes
// ------------------------------------------------------------------
let mut signed = false;
if let Some(keyid) = signing_key.filter(|_| do_sign) {
sign::validate_key_id(&keyid)?;
println!("signfile {}", dsc_name);
sign::clearsign_file(&dsc_path, &keyid)?;
// The .dsc changed: refresh its checksums inside the .buildinfo.
checksums.add_file_as(&dsc_path, &dsc_name)?;
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
println!("signfile {}", buildinfo_name);
sign::clearsign_file(&buildinfo_path, &keyid)?;
// Both .dsc and .buildinfo changed: refresh the .changes.
checksums.add_file_as(&buildinfo_path, &buildinfo_name)?;
changes::save_changes(&changes_path, &render_changes_doc(&checksums))?;
println!("signfile {}", changes_name);
sign::clearsign_file(&changes_path, &keyid)?;
signed = true;
}
Ok(SourceBuildOutput {
dsc: dsc_path,
buildinfo: buildinfo_path,
changes: changes_path,
tarballs: tarball_paths,
signed,
})
}
/// A partially-known checksum entry taken from a `.dsc` checksum field.
#[derive(Debug, Default)]
struct PartialChecksum {
size: Option<u64>,
md5: Option<String>,
sha1: Option<String>,
sha256: Option<String>,
}
/// Run a build command in `cwd` with extra environment variables layered on
/// top of the inherited environment, with stdio attached to the terminal.
/// Returns an error on non-zero exit status.
fn run_command(
cwd: &Path,
program: &str,
args: &[&str],
env: &BTreeMap<String, String>,
) -> Result<(), Box<dyn Error>> {
log::debug!(
"running: {} {} (in {})",
program,
args.join(" "),
cwd.display()
);
let status = Command::new(program)
.current_dir(cwd)
.envs(env)
.args(args)
.status()
.map_err(|e| format!("failed to run '{}': {}", program, e))?;
if !status.success() {
return Err(format!(
"'{} {}' failed with status: {}",
program,
args.join(" "),
status
)
.into());
}
Ok(())
}
// Re-export commonly used types at the module root.
pub use metadata::{ChangelogEntry, DebianVersion};
#[cfg(test)]
mod tests {
use super::*;
use buildtype::BuildType;
#[test]
fn partial_checksum_defaults() {
let p = PartialChecksum::default();
assert!(p.size.is_none());
assert!(p.md5.is_none());
}
#[test]
fn debian_version_reexport_usable() {
let v = DebianVersion::parse("1.0-2").unwrap();
assert_eq!(v.no_epoch(), "1.0-2");
}
// The full pipeline is exercised end-to-end by running pkh against a
// fixture package; unit tests cover the individual stages above.
#[test]
fn build_type_source_only_pipeline_mapping() {
// Source-only builds always map to the 'source' artifact suffix.
assert_eq!(buildtype::SOURCE.arch_suffix("amd64"), "source");
}
}
+94
View File
@@ -0,0 +1,94 @@
//! OpenPGP inline (clear) signing of `.dsc`, `.buildinfo` and `.changes`
//! files through `gpgme`, mirroring what `dpkg-buildpackage` does via its
//! OpenPGP backends.
use std::error::Error;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use gpgme::{Context, Data, Protocol};
/// Validate an OpenPGP key id / fingerprint like dpkg does.
///
/// Short (<= 8 hex chars) key IDs are rejected outright, long (16 hex chars)
/// key IDs produce a warning; anything else must be a v4 (40) or v6 (64)
/// fingerprint length.
pub fn validate_key_id(keyid: &str) -> Result<(), Box<dyn Error>> {
let len = keyid.len();
if len <= 8 {
return Err(
"short OpenPGP key IDs are broken; use a key fingerprint instead".into(),
);
} else if len <= 16 {
log::warn!(
"long OpenPGP key IDs are strongly discouraged; \
use a key fingerprint instead"
);
} else if len != 40 && len != 64 {
log::warn!("OpenPGP key ID has unknown v4 or v6 fingerprint length");
}
Ok(())
}
/// Find a secret key whose fingerprint matches `keyid` (suffix matching
/// allows passing a long key id instead of the full fingerprint).
fn find_secret_key(ctx: &mut Context, keyid: &str) -> Result<Option<gpgme::Key>, Box<dyn Error>> {
for key_result in ctx.secret_keys()? {
let key = key_result?;
if let Ok(fingerprint) = key.fingerprint() {
if fingerprint.ends_with(keyid) {
return Ok(Some(key));
}
}
}
Ok(None)
}
/// Clearsign a file in place: the original content becomes the payload of an
/// armored inline-signed document which atomically replaces the file.
///
/// This is the same operation as dpkg's `inline_sign` + rename sequence.
pub fn clearsign_file(path: &Path, keyid: &str) -> Result<(), Box<dyn Error>> {
let content = std::fs::read(path)
.map_err(|e| format!("cannot read '{}' for signing: {}", path.display(), e))?;
let mut ctx = Context::from_protocol(Protocol::OpenPgp)
.map_err(|e| format!("cannot initialize GPGME: {}", e))?;
ctx.set_armor(true);
let key = find_secret_key(&mut ctx, keyid)?
.ok_or_else(|| format!("no secret key matching '{}' found", keyid))?;
ctx.add_signer(&key)
.map_err(|e| format!("cannot add signer '{}': {}", keyid, e))?;
let input = Data::from_bytes(&content)?;
let mut output = Data::new()?;
ctx.sign_clear(input, &mut output)
.map_err(|e| format!("clear-signing '{}' failed: {}", path.display(), e))?;
// gpgme leaves the output buffer cursor at the end after writing.
output.seek(SeekFrom::Start(0))?;
let mut signed = Vec::new();
output.read_to_end(&mut signed)?;
// Atomic replace, like dpkg's signfile (write .asc then move).
let tmp = path.with_extension("asc.tmp");
std::fs::write(&tmp, &signed)
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
std::fs::rename(&tmp, path)
.map_err(|e| format!("cannot install signed '{}': {}", path.display(), e).into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_id_validation() {
assert!(validate_key_id("12345678").is_err()); // short: rejected
assert!(validate_key_id("1234567890ABCDEF").is_ok()); // long: warns
assert!(validate_key_id(&"a".repeat(40)).is_ok());
assert!(validate_key_id(&"b".repeat(64)).is_ok());
}
}