From 9d2519ed7bf3df03e15acce778f02209e9401ac9 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sun, 23 Aug 2026 01:29:35 +0200 Subject: [PATCH] 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. --- Cargo.toml | 1 + src/build.rs | 82 -------- src/build/buildinfo.rs | 410 +++++++++++++++++++++++++++++++++++++ src/build/buildtype.rs | 160 +++++++++++++++ src/build/changes.rs | 236 ++++++++++++++++++++++ src/build/checksums.rs | 255 +++++++++++++++++++++++ src/build/control.rs | 212 +++++++++++++++++++ src/build/env.rs | 317 +++++++++++++++++++++++++++++ src/build/files.rs | 251 +++++++++++++++++++++++ src/build/metadata.rs | 428 +++++++++++++++++++++++++++++++++++++++ src/build/mod.rs | 449 +++++++++++++++++++++++++++++++++++++++++ src/build/sign.rs | 94 +++++++++ 12 files changed, 2813 insertions(+), 82 deletions(-) delete mode 100644 src/build.rs create mode 100644 src/build/buildinfo.rs create mode 100644 src/build/buildtype.rs create mode 100644 src/build/changes.rs create mode 100644 src/build/checksums.rs create mode 100644 src/build/control.rs create mode 100644 src/build/env.rs create mode 100644 src/build/files.rs create mode 100644 src/build/metadata.rs create mode 100644 src/build/mod.rs create mode 100644 src/build/sign.rs diff --git a/Cargo.toml b/Cargo.toml index a50d764..48e8ce6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/build.rs b/src/build.rs deleted file mode 100644 index b5096eb..0000000 --- a/src/build.rs +++ /dev/null @@ -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> { - 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. -} diff --git a/src/build/buildinfo.rs b/src/build/buildinfo.rs new file mode 100644 index 0000000..7944c20 --- /dev/null +++ b/src/build/buildinfo.rs @@ -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>, + /// Raw `Depends`/`Pre-Depends` strings keyed by `package:arch`. + depends: HashMap>, + /// Names of installed essential packages. + essential: Vec, +} + +impl StatusDb { + /// Parse a dpkg status file (e.g. `/var/lib/dpkg/status`). + fn load(path: &Path) -> Result> { + 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 { + 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> { + let db = StatusDb::load(status_path)?; + + let mut work: VecDeque = 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 = 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::>() + .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, + /// `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, + /// `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> { + 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::::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::>().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")); + } +} diff --git a/src/build/buildtype.rs b/src/build/buildtype.rs new file mode 100644 index 0000000..a2240f4 --- /dev/null +++ b/src/build/buildtype.rs @@ -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=[,...]` option value. + /// + /// Valid components: `full`, `source`, `binary`, `any`, `all`. + pub fn from_options(value: &str) -> Result { + 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"); + } +} diff --git a/src/build/changes.rs b/src/build/changes.rs new file mode 100644 index 0000000..4b484e4 --- /dev/null +++ b/src/build/changes.rs @@ -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, + /// Active build profiles (`Built-For-Profiles`); omitted when empty. + pub built_for_profiles: Vec, + /// `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, + /// `Changed-By` from the changelog maintainer. + pub changed_by: Option, + /// Formatted per-package description lines (empty for source-only). + pub descriptions: Vec, + /// 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> { + 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 ".to_string()), + changed_by: Some("A B ".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 8 utils optional pkg_1.0.dsc" + .replace("", &files_value.split_whitespace().next().unwrap_or("")) + ); + } +} diff --git a/src/build/checksums.rs b/src/build/checksums.rs new file mode 100644 index 0000000..7ce9dc1 --- /dev/null +++ b/src/build/checksums.rs @@ -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> { + 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, +} + +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> { + 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> { + 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 { + 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 `" "`. + fn format_field(&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"]); + } +} diff --git a/src/build/control.rs b/src/build/control.rs new file mode 100644 index 0000000..8c80e8a --- /dev/null +++ b/src/build/control.rs @@ -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 { + 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 { + let mut paragraphs = Vec::new(); + let mut current = Paragraph::new(); + let mut last_field: Option = 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()); + } +} diff --git a/src/build/env.rs b/src/build/env.rs new file mode 100644 index 0000000..380b58f --- /dev/null +++ b/src/build/env.rs @@ -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 +/// (), +/// - `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 { + 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 ]` 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, 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 { + 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 { + 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 { + if !explicit.is_empty() { + return explicit.to_vec(); + } + if let Ok(value) = std::env::var("DEB_BUILD_PROFILES") { + let profiles: Vec = 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 { + let mut values: BTreeMap = 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::>() + .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")); + } +} diff --git a/src/build/files.rs b/src/build/files.rs new file mode 100644 index 0000000..77c4a4f --- /dev/null +++ b/src/build/files.rs @@ -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, + /// Version parsed from the file name pattern, if any. + pub version: Option, + /// Architecture parsed from the file name pattern, if any. + pub arch: Option, + /// Artifact type parsed from the file name extension + /// (`deb`, `udeb`, `buildinfo`, `changes`, ...). + pub package_type: Option, + /// Extra `key=value` attributes on the line (e.g. `automatic=yes`). + pub attrs: BTreeMap, +} + +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 +/// `__.` pattern, like +/// `Dpkg::Dist::Files::parse_filename()`. +#[allow(clippy::field_reassign_with_default)] +pub fn parse_filename(name: &str) -> Option { + // 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, +} + +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> { + 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 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 { + 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 `.new` then rename over `path`, like + /// dpkg does. + pub fn save_atomic(&self, path: &Path) -> Result<(), Box> { + 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); + } +} diff --git a/src/build/metadata.rs b/src/build/metadata.rs new file mode 100644 index 0000000..7760118 --- /dev/null +++ b/src/build/metadata.rs @@ -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, + /// Upstream version (may itself contain `-` when there is no revision). + pub upstream: String, + /// Optional Debian revision (part after the last `-`). + pub debian_revision: Option, +} + +impl DebianVersion { + /// Parse and validate a Debian version string. + pub fn parse(raw: &str) -> Result { + 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> { + 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 Date` + let mut body_lines: Vec = Vec::new(); + let mut trailer: Option = 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 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, +} + +impl ControlInfo { + /// Parse a `debian/control` file. + pub fn parse(path: &Path) -> Result> { + 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 { + 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 = 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 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 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 \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(), "-"); + } +} diff --git a/src/build/mod.rs b/src/build/mod.rs new file mode 100644 index 0000000..79db944 --- /dev/null +++ b/src/build/mod.rs @@ -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, + /// 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, + /// 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> { + 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> { + // ------------------------------------------------------------------ + // 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 = Vec::new(); + let mut dsc_files: HashMap = 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, + md5: Option, + sha1: Option, + sha256: Option, +} + +/// 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, +) -> Result<(), Box> { + 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"); + } +} diff --git a/src/build/sign.rs b/src/build/sign.rs new file mode 100644 index 0000000..5fcf6d4 --- /dev/null +++ b/src/build/sign.rs @@ -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> { + 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, Box> { + 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> { + 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()); + } +}