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
+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"]);
}
}