Compare commits

...
4 Commits
Author SHA1 Message Date
vhaudiquet 0ad020ae19 build: add differential tests against real dpkg-buildpackage
Add an automated differential test harness in build/mod.rs that builds
the same source tree twice - once with real 'dpkg-buildpackage -S
-I -i -nc -d --no-sign', once with the native pipeline - and compares
all produced artifacts:

- .dsc payload byte-for-byte,
- .changes field-by-field (checksum lines of the .buildinfo itself
  excluded, as its content legitimately differs on machine-dependent
  fields),
- .buildinfo structure (Installed-Build-Depends, Environment,
  Build-Date and Build-Tainted-By excluded).
2026-08-23 11:05:36 +02:00
vhaudiquet 4a8ff9ac0d fmt, clippy 2026-08-23 01:46:04 +02:00
vhaudiquet c2e1288bc5 build,debian: extract reusable Debian format primitives from build/
Move the generic components out of src/build/ into a new src/debian/
module so they can be reused independently of the build pipeline:
deb822 control parsing (plus the debian/control model), file checksum
registry, debian/files registry, Debian version handling and changelog
entry parsing.

Merge OpenPGP clearsigning into utils/gpg.rs next to the existing key
discovery helper, making signing available outside of builds.

Delegate changelog.rs header/footer parsing to the new
debian::changelog parser, removing the duplicate regex implementation.

src/build/ keeps only build-specific logic: the pipeline driver,
build types, environment setup and the .buildinfo/.changes writers.
2026-08-23 01:43:41 +02:00
vhaudiquet 9d2519ed7b 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.
2026-08-23 01:29:35 +02:00
16 changed files with 3616 additions and 157 deletions
+1
View File
@@ -18,6 +18,7 @@ regex = "1"
chrono = "0.4" chrono = "0.4"
tokio = { version = "1.41.1", features = ["full"] } tokio = { version = "1.41.1", features = ["full"] }
sha2 = "0.10.8" sha2 = "0.10.8"
sha1 = "0.10"
md-5 = "0.10" md-5 = "0.10"
hex = "0.4.3" hex = "0.4.3"
log = "0.4.28" 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.
}
+423
View File
@@ -0,0 +1,423 @@
//! 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 crate::debian::checksums::FileChecksums;
use crate::debian::control::{Paragraph, parse_paragraphs, write_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"));
}
}
+163
View File
@@ -0,0 +1,163 @@
//! 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(self, host_arch: &str) -> &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");
}
}
+245
View File
@@ -0,0 +1,245 @@
//! 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 crate::debian::checksums::FileChecksums;
use crate::debian::control::{Paragraph, write_paragraph};
use crate::debian::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>,
/// Bug numbers collected from the changelog (`Closes` field), if any.
pub closes: Option<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")));
}
if let Some(closes) = &input.closes {
p.set("Closes", closes);
}
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(crate::debian::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![],
closes: None,
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(""))
);
}
}
+314
View File
@@ -0,0 +1,314 @@
//! 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.contains_key("DEB_BUILD_PROFILES"));
let env = build_env(1, 4, &["nodoc".to_string(), "cross".to_string()]);
assert_eq!(env.get("DEB_BUILD_PROFILES").unwrap(), "nodoc,cross");
}
#[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"));
}
}
+1097
View File
File diff suppressed because it is too large Load Diff
+5 -74
View File
@@ -2,7 +2,7 @@ use chrono::Local;
use git2::{Oid, Repository, Sort}; use git2::{Oid, Repository, Sort};
use regex::Regex; use regex::Regex;
use std::fs::File; use std::fs::File;
use std::io::{self, BufRead, Read, Write}; use std::io::{Read, Write};
use std::path::Path; use std::path::Path;
/// Automatically generate a changelog entry from a commit history and previous changelog /// Automatically generate a changelog entry from a commit history and previous changelog
@@ -121,84 +121,15 @@ fn increment_suffix(version: &str, suffix: &str) -> String {
pub fn parse_changelog_header( pub fn parse_changelog_header(
path: &Path, path: &Path,
) -> Result<(String, String, String), Box<dyn std::error::Error>> { ) -> Result<(String, String, String), Box<dyn std::error::Error>> {
let file = File::open(path).map_err(|e| { let entry = crate::debian::parse_changelog_entry(path)?;
format!( Ok((entry.source, entry.version.full(), entry.distribution))
"Failed to read changelog '{}': {}. \
Make sure you are running this command from the root of a source package \
(a directory containing a 'debian/' subdirectory with a 'changelog' file).",
path.display(),
e
)
})?;
let mut reader = io::BufReader::new(file);
let mut first_line = String::new();
reader.read_line(&mut first_line).map_err(|e| {
format!(
"Failed to read first line of changelog '{}': {}",
path.display(),
e
)
})?;
// Format: package (version) series; urgency=urgency
let re = Regex::new(r"^(\S+) \(([^)]+)\) (.*); .*")?;
if let Some(caps) = re.captures(&first_line) {
let package = caps.get(1).map_or("", |m| m.as_str()).to_string();
let version = caps.get(2).map_or("", |m| m.as_str()).to_string();
let series = caps.get(3).map_or("", |m| m.as_str()).to_string();
Ok((package, version, series))
} else {
Err(format!(
"Invalid changelog header format in '{}'. \
The first line must look like: `package (version) series; urgency=...`, \
but got: {:?}",
path.display(),
first_line.trim_end()
)
.into())
}
} }
/// Parse a changelog file footer to extract maintainer information /// Parse a changelog file footer to extract maintainer information
/// Returns (name, email) tuple from the last modification entry /// Returns (name, email) tuple from the last modification entry
pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn std::error::Error>> { pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn std::error::Error>> {
let mut file = File::open(path).map_err(|e| { let entry = crate::debian::parse_changelog_entry(path)?;
format!( Ok((entry.maintainer_name, entry.maintainer_email))
"Failed to read changelog '{}': {}. \
Make sure you are running this command from the root of a source package \
(a directory containing a 'debian/' subdirectory with a 'changelog' file).",
path.display(),
e
)
})?;
let mut content = String::new();
file.read_to_string(&mut content)
.map_err(|e| format!("Failed to read changelog '{}': {}", path.display(), e))?;
// Find the last maintainer line (format: -- Name <email> Date)
let re = Regex::new(r"--\s*([^<]+?)\s*<([^>]+)>\s*")?;
if let Some(first_match) = re.captures_iter(&content).next() {
let name = first_match
.get(1)
.map_or("", |m| m.as_str())
.trim()
.to_string();
let email = first_match
.get(2)
.map_or("", |m| m.as_str())
.trim()
.to_string();
Ok((name, email))
} else {
Err(format!(
"No maintainer information found in '{}'. \
The changelog must contain a line of the form '-- Name <email> Date', \
but none was found.",
path.display()
)
.into())
}
} }
/* /*
+309
View File
@@ -0,0 +1,309 @@
//! Debian changelog entry parsing (`debian/changelog`).
use std::path::Path;
use chrono::DateTime;
use regex::Regex;
use super::version::DebianVersion;
/// 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,
/// Bug numbers collected from `(Closes: #NNN)` mentions in the body,
/// sorted numerically and de-duplicated (like dpkg's `find_closes`).
pub closes: Option<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"));
}
let closes = find_closes(&body_lines);
Ok(ChangelogEntry {
source,
version,
distribution,
urgency,
binary_only,
maintainer_name,
maintainer_email,
date_raw,
timestamp,
changes_field,
closes,
})
}
/// Extract bug numbers from `(Closes: #NNN)` mentions in changelog body
/// lines, sorted numerically and de-duplicated (a lenient port of dpkg's
/// `find_closes`).
fn find_closes(body_lines: &[String]) -> Option<String> {
let re = Regex::new(r"(?i)\(closes:\s*([^)]*)\)").ok()?;
let mut numbers: Vec<u64> = Vec::new();
for line in body_lines {
for capture in re.captures_iter(line) {
if let Some(inner) = capture.get(1) {
for token in inner.as_str().split(|c: char| !c.is_ascii_digit()) {
if let Ok(n) = token.parse::<u64>() {
numbers.push(n);
}
}
}
}
}
if numbers.is_empty() {
return None;
}
numbers.sort_unstable();
numbers.dedup();
Some(
numbers
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(" "),
)
}
/// Return the version of the *previous* changelog entry (the second header
/// in the file), or `None` when only one entry exists.
pub fn parse_previous_version(path: &Path) -> Result<Option<String>, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read changelog '{}': {}", path.display(), e))?;
let mut seen_first = false;
for line in content.lines() {
let line = line.trim_end();
if looks_like_header(line) {
if !seen_first {
seen_first = true;
continue;
}
let open = line.find('(').ok_or_else(|| {
format!("invalid changelog header in '{}': {}", path.display(), line)
})?;
let close = line[open..]
.find(')')
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{}'", line))?;
return Ok(Some(line[open + 1..open + close].to_string()));
}
}
Ok(None)
}
/// 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,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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");
}
}
+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"]);
}
}
+307
View File
@@ -0,0 +1,307 @@
//! Debian control-file handling: a minimal deb822 paragraph parser/writer
//! plus a `debian/control` model.
//!
//! 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.
use std::path::Path;
/// 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
&& 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());
}
}
/// 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))?;
content
.parse::<ControlInfo>()
.map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into())
}
/// Parse control content from a string.
///
/// Prefer [`std::str::FromStr`] (`"...".parse::<ControlInfo>()`).
pub fn parse_content(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("-")
}
}
impl std::str::FromStr for ControlInfo {
type Err = String;
fn from_str(content: &str) -> Result<Self, Self::Err> {
ControlInfo::parse_content(content)
}
}
#[cfg(test)]
mod control_info_tests {
use super::*;
use std::str::FromStr;
#[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(), "-");
}
}
+258
View File
@@ -0,0 +1,258 @@
//! `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);
}
}
+22
View File
@@ -0,0 +1,22 @@
//! Reusable Debian format primitives.
//!
//! These components are independent from any build orchestration and can be
//! used by any pkh submodule (or external consumers of the library):
//!
//! - [`control`]: deb822 paragraph parsing/writing and `debian/control`
//! - [`checksums`]: file checksum registry (`Dpkg::Checksums` equivalent)
//! - [`files`]: `debian/files` artifact registry (`Dpkg::Dist::Files`)
//! - [`version`]: Debian version splitting/validation
//! - [`changelog`]: `debian/changelog` entry parsing
pub mod changelog;
pub mod checksums;
pub mod control;
pub mod files;
pub mod version;
pub use changelog::{ChangelogEntry, parse_changelog_entry};
pub use checksums::{Entry as ChecksumEntry, FileChecksums};
pub use control::{ControlInfo, Paragraph, parse_paragraphs, write_paragraph};
pub use files::{FilesEntry, FilesList};
pub use version::DebianVersion;
+120
View File
@@ -0,0 +1,120 @@
//! Debian version handling: splitting and validation of
//! `[epoch:]upstream[-revision]` version strings.
/// 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(),
}
}
}
#[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());
}
}
+3
View File
@@ -11,6 +11,9 @@ pub mod build;
pub mod changelog; pub mod changelog;
/// Build a Debian package into a binary (.deb) /// Build a Debian package into a binary (.deb)
pub mod deb; pub mod deb;
/// Reusable Debian format primitives (control/deb822, checksums, versions,
/// changelog entries, artifact registries)
pub mod debian;
/// Obtain general information about distribution, series, etc /// Obtain general information about distribution, series, etc
pub mod distro_info; pub mod distro_info;
/// Obtain information about one or multiple packages /// Obtain information about one or multiple packages
+94 -1
View File
@@ -1,4 +1,11 @@
use gpgme::{Context, Protocol}; //! GPG / OpenPGP helpers: secret key discovery and inline (clear) signing
//! of Debian artifacts such as `.dsc`, `.buildinfo` and `.changes` files.
use std::error::Error;
use std::io::Read;
use std::path::Path;
use gpgme::{Context, Data, Protocol};
/// Check if a GPG key matching 'email' exists /// Check if a GPG key matching 'email' exists
/// Returns the key ID if found, None otherwise /// Returns the key ID if found, None otherwise
@@ -30,3 +37,89 @@ pub fn find_signing_key_for_email(
Ok(None) Ok(None)
} }
/// 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()
&& 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 used
/// when signing `.dsc`, `.buildinfo` or `.changes` files.
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.
use std::io::Seek;
use std::io::SeekFrom;
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());
}
}