build: re-implement source builds natively, drop dpkg-buildpackage shell-out
Replace the 'dpkg-buildpackage -S' wrapper with a native pipeline in src/build/: - deb822 control parser/writer with dpkg-compatible multiline rendering (control.rs) - md5/sha1/sha256 checksum registry, insertion-ordered like dpkg's artifact accumulation (checksums.rs) - Debian version splitting/validation and full changelog entry parsing, including binNMU binary-only entries (metadata.rs) - build-type bitflags and rules-target/artifact-suffix mapping (buildtype.rs) - environment setup: SOURCE_DATE_EPOCH, DEB_BUILD_OPTIONS, dpkg-architecture env dump, vendor default profiles and the sanitized Environment field recorded in .buildinfo (env.rs) - debian/files registry with atomic saves (files.rs) - native .buildinfo writer, including the Installed-Build-Depends closure computed over the dpkg status database (buildinfo.rs) - native .changes writer emitting dpkg's canonical field order with legacy Files + Checksums-Sha1/Sha256 (changes.rs) - gpgme clearsigning with the transitive checksum cascade (dsc -> buildinfo -> changes), key discovery from the changelog maintainer and UNRELEASED no-sign handling (sign.rs) dpkg-source (-b/--before-build/--after-build) intentionally remains a subprocess; debian/rules execution is unchanged. Validated differentially against real dpkg-buildpackage -S -I -i -nc -d on native and 3.0 (quilt) fixture packages: .dsc byte-identical, .changes payload matches modulo machine-dependent Installed-Build-Depends and Environment content, all signatures verify with gpg, artifact ordering and UNRELEASED no-sign behavior match dpkg.
This commit is contained in:
@@ -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.
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
//! Native `.buildinfo` generation (Format 1.0), mirroring
|
||||
//! `dpkg-genbuildinfo`: artifact checksums, a snapshot of installed build
|
||||
//! dependencies and the sanitized build environment.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::Path;
|
||||
|
||||
use super::checksums::FileChecksums;
|
||||
use super::control::{parse_paragraphs, write_paragraph, Paragraph};
|
||||
|
||||
/// One installed package relevant for dependency resolution.
|
||||
#[derive(Debug, Clone)]
|
||||
struct InstalledPkg {
|
||||
version: String,
|
||||
arch: String,
|
||||
}
|
||||
|
||||
/// A snapshot of the dpkg status database, restricted to what the
|
||||
/// `Installed-Build-Depends` computation needs.
|
||||
#[derive(Debug, Default)]
|
||||
struct StatusDb {
|
||||
/// Installed packages grouped by name.
|
||||
pkgs: HashMap<String, Vec<InstalledPkg>>,
|
||||
/// Raw `Depends`/`Pre-Depends` strings keyed by `package:arch`.
|
||||
depends: HashMap<String, Vec<String>>,
|
||||
/// Names of installed essential packages.
|
||||
essential: Vec<String>,
|
||||
}
|
||||
|
||||
impl StatusDb {
|
||||
/// Parse a dpkg status file (e.g. `/var/lib/dpkg/status`).
|
||||
fn load(path: &Path) -> Result<StatusDb, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("cannot read status file '{}': {}", path.display(), e))?;
|
||||
Ok(Self::from_str(&content))
|
||||
}
|
||||
|
||||
fn from_str(content: &str) -> StatusDb {
|
||||
let mut db = StatusDb::default();
|
||||
for para in parse_paragraphs(content) {
|
||||
// Only fully installed packages participate.
|
||||
let status = para.get("Status").unwrap_or("");
|
||||
if !status.split_whitespace().eq(["install", "ok", "installed"]) {
|
||||
// Accept any status containing 'ok installed' like dpkg's
|
||||
// `/^Status: .*ok installed$/` check.
|
||||
if !status.contains("ok installed") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(package) = para.get("Package") else {
|
||||
continue;
|
||||
};
|
||||
let arch = para.get("Architecture").unwrap_or("").to_string();
|
||||
if let (Some(version), false) = (para.get("Version"), arch.is_empty()) {
|
||||
db.pkgs.entry(package.to_string()).or_default().push(InstalledPkg {
|
||||
version: version.to_string(),
|
||||
arch: arch.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if para.get("Essential").map(|v| v.eq_ignore_ascii_case("yes")).unwrap_or(false) {
|
||||
db.essential.push(package.to_string());
|
||||
}
|
||||
|
||||
let qualified = format!("{}:{}", package, arch);
|
||||
for field in ["Pre-Depends", "Depends"] {
|
||||
if let Some(value) = para.get(field) {
|
||||
db.depends
|
||||
.entry(qualified.clone())
|
||||
.or_default()
|
||||
.push(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
db
|
||||
}
|
||||
|
||||
/// Find an installed package by name, optionally restricted to an exact
|
||||
/// architecture.
|
||||
fn find(&self, name: &str, arch: Option<&str>) -> Option<&InstalledPkg> {
|
||||
self.pkgs.get(name)?.iter().find(|p| match arch {
|
||||
Some(a) => p.arch == a,
|
||||
None => true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract candidate package names from a dependency field value.
|
||||
///
|
||||
/// Every alternative of every clause is returned (dpkg cannot know which one
|
||||
/// was actually used), with version constraints and build-profile
|
||||
/// restrictions stripped but `:arch` qualifiers preserved.
|
||||
fn dep_candidates(dep_value: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for clause in dep_value.split(',') {
|
||||
for alternative in clause.split('|') {
|
||||
// Drop build-profile restrictions `[...]` (they may follow any
|
||||
// individual alternative).
|
||||
let alternative = match alternative.find('[') {
|
||||
Some(i) => &alternative[..i],
|
||||
None => alternative,
|
||||
};
|
||||
// Drop version constraints `(>= 1.0)`.
|
||||
let name = match alternative.find('(') {
|
||||
Some(i) => &alternative[..i],
|
||||
None => alternative,
|
||||
};
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(name.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the `Installed-Build-Depends` value: the transitive closure of
|
||||
/// installed packages reachable from the essential set and the active
|
||||
/// `Build-Depends*` fields, formatted as `name (= version)` pairs.
|
||||
///
|
||||
/// Mirrors `collect_installed_builddeps()` in `dpkg-genbuildinfo`, including
|
||||
/// the foreign-architecture qualification of dependencies.
|
||||
pub fn installed_build_depends(
|
||||
status_path: &Path,
|
||||
build_depends_fields: &[&str],
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let db = StatusDb::load(status_path)?;
|
||||
|
||||
let mut work: VecDeque<String> = VecDeque::new();
|
||||
for name in &db.essential {
|
||||
work.push_back(name.clone());
|
||||
}
|
||||
for field in build_depends_fields {
|
||||
if !field.trim().is_empty() {
|
||||
for candidate in dep_candidates(field) {
|
||||
work.push_back(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut entries: Vec<(String, String)> = Vec::new();
|
||||
|
||||
while let Some(entry) = work.pop_front() {
|
||||
if !seen.insert(entry.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (name, qual) = match entry.split_once(':') {
|
||||
Some((n, q)) => (n.to_string(), Some(q.to_string())),
|
||||
None => (entry.clone(), None),
|
||||
};
|
||||
|
||||
// `all`, `any` and `native` qualifiers do not pin an architecture.
|
||||
let required_arch = qual.filter(|q| !matches!(q.as_str(), "all" | "any" | "native"));
|
||||
|
||||
let Some(installed) = db.find(&name, required_arch.as_deref()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let qualified_name = if required_arch.is_none() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{}:{}", name, installed.arch)
|
||||
};
|
||||
entries.push((
|
||||
qualified_name.clone(),
|
||||
format!("{} (= {})", qualified_name, installed.version),
|
||||
));
|
||||
|
||||
// Enqueue dependencies of the visited package.
|
||||
let dep_key = format!("{}:{}", name, installed.arch);
|
||||
let foreign = required_arch.is_some();
|
||||
for raw in db.depends.get(&dep_key).into_iter().flatten() {
|
||||
for mut candidate in dep_candidates(raw) {
|
||||
if foreign && !candidate.contains(':') {
|
||||
// Dependencies of foreign packages are foreign too (or
|
||||
// Arch:all); qualify them when such an install exists.
|
||||
let base = candidate.as_str();
|
||||
let has_foreign_arch = db
|
||||
.pkgs
|
||||
.get(base)
|
||||
.map(|v| v.iter().any(|p| p.arch == installed.arch))
|
||||
.unwrap_or(false);
|
||||
if has_foreign_arch {
|
||||
candidate = format!("{}:{}", candidate, installed.arch);
|
||||
}
|
||||
}
|
||||
work.push_back(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
entries.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
// Leading `\n`: pre-wrapped multiline field, dpkg-style.
|
||||
let mut out = String::from("\n");
|
||||
out.push_str(
|
||||
&entries
|
||||
.into_iter()
|
||||
.map(|(_, formatted)| formatted)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n"),
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Everything needed to render a `.buildinfo` file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BuildInfoInput {
|
||||
/// `Source` field, including the ` (sourceversion)` suffix for binNMUs.
|
||||
pub source: String,
|
||||
/// Sorted binary package names included in the build (may be empty).
|
||||
pub binaries: Vec<String>,
|
||||
/// `Architecture` field value (e.g. `source`, `amd64`, `all amd64`).
|
||||
pub architecture: String,
|
||||
/// Full binary version.
|
||||
pub version: String,
|
||||
/// `Binary-Only-Changes` payload for binNMU builds.
|
||||
pub binary_only_changes: Option<String>,
|
||||
/// `Build-Origin` (vendor name).
|
||||
pub build_origin: String,
|
||||
/// `Build-Architecture` (machine the build ran on).
|
||||
pub build_architecture: String,
|
||||
/// `Build-Date`, RFC2822.
|
||||
pub build_date: String,
|
||||
/// Computed artifact checksums.
|
||||
pub checksums: FileChecksums,
|
||||
/// Rendered `Installed-Build-Depends` value.
|
||||
pub installed_build_depends: String,
|
||||
/// Rendered `Environment` value.
|
||||
pub environment: String,
|
||||
}
|
||||
|
||||
/// Wrap an overly long single-line field value (> 980 characters) over
|
||||
/// multiple lines at spaces, like dpkg does for `Binary`.
|
||||
fn wrap_long(value: &str) -> String {
|
||||
if value.len() <= 980 {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(value.len() + 8);
|
||||
let mut line_len = 0usize;
|
||||
for (i, word) in value.split(' ').enumerate() {
|
||||
if i > 0 {
|
||||
if line_len + 1 + word.len() > 980 {
|
||||
out.push('\n');
|
||||
line_len = 0;
|
||||
} else {
|
||||
out.push(' ');
|
||||
line_len += 1;
|
||||
}
|
||||
}
|
||||
out.push_str(word);
|
||||
line_len += word.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render the `.buildinfo` document (without trailing signature), with fields
|
||||
/// in dpkg's canonical order for `CTRL_FILE_BUILDINFO`.
|
||||
pub fn render_buildinfo(input: &BuildInfoInput) -> Paragraph {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Format", "1.0");
|
||||
p.set("Source", &input.source);
|
||||
if !input.binaries.is_empty() {
|
||||
let joined = input.binaries.join(" ");
|
||||
p.set("Binary", &wrap_long(&joined));
|
||||
}
|
||||
p.set("Architecture", &input.architecture);
|
||||
p.set("Version", &input.version);
|
||||
if let Some(boc) = &input.binary_only_changes {
|
||||
p.set("Binary-Only-Changes", boc);
|
||||
}
|
||||
if !input.checksums.is_empty() {
|
||||
p.set("Checksums-Md5", &input.checksums.field_md5());
|
||||
p.set("Checksums-Sha1", &input.checksums.field_sha1());
|
||||
p.set("Checksums-Sha256", &input.checksums.field_sha256());
|
||||
}
|
||||
p.set("Build-Origin", &input.build_origin);
|
||||
p.set("Build-Architecture", &input.build_architecture);
|
||||
p.set("Build-Date", &input.build_date);
|
||||
if !input.installed_build_depends.is_empty() {
|
||||
p.set("Installed-Build-Depends", &input.installed_build_depends);
|
||||
}
|
||||
if !input.environment.is_empty() {
|
||||
p.set("Environment", &input.environment);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Serialize and atomically write a `.buildinfo` file.
|
||||
pub fn save_buildinfo(path: &Path, paragraph: &Paragraph) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tmp = path.with_extension("new");
|
||||
std::fs::write(&tmp, write_paragraph(paragraph))
|
||||
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
|
||||
std::fs::rename(&tmp, path)
|
||||
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dep_candidate_extraction() {
|
||||
assert_eq!(
|
||||
dep_candidates("debhelper-compat (= 13), pkg:any [!profile] | alt (>= 2)"),
|
||||
vec![
|
||||
"debhelper-compat".to_string(),
|
||||
"pkg:any".to_string(),
|
||||
"alt".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(dep_candidates(""), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_over_status_db() {
|
||||
let status = "\
|
||||
Package: build-essential
|
||||
Status: install ok installed
|
||||
Version: 12.10
|
||||
Architecture: amd64
|
||||
Essential: no
|
||||
Depends: gcc, make
|
||||
|
||||
Package: gcc
|
||||
Status: install ok installed
|
||||
Version: 13.2
|
||||
Architecture: amd64
|
||||
Depends: cpp-13
|
||||
|
||||
Package: cpp-13
|
||||
Status: install ok installed
|
||||
Version: 13.2
|
||||
Architecture: amd64
|
||||
|
||||
Package: make
|
||||
Status: install ok installed
|
||||
Version: 4.3
|
||||
Architecture: amd64
|
||||
|
||||
Package: not-installed
|
||||
Status: deinstall ok config-files
|
||||
Version: 9.9
|
||||
Architecture: amd64
|
||||
";
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("status");
|
||||
std::fs::write(&path, status).unwrap();
|
||||
|
||||
let ibd = installed_build_depends(&path, &["build-essential"]).unwrap();
|
||||
let names: Vec<&str> = ibd
|
||||
.trim_start()
|
||||
.lines()
|
||||
.map(|l| l.split(' ').next().unwrap())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["build-essential", "cpp-13", "gcc", "make"]);
|
||||
assert!(ibd.contains("gcc (= 13.2)"));
|
||||
assert!(ibd.contains("cpp-13 (= 13.2)"));
|
||||
assert!(!ibd.contains("not-installed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_binary_field() {
|
||||
assert_eq!(wrap_long("abc"), "abc");
|
||||
let long = (0..500).map(|i| i.to_string()).collect::<Vec<_>>().join(" ");
|
||||
let wrapped = wrap_long(&long);
|
||||
assert!(wrapped.contains('\n'));
|
||||
for line in wrapped.lines() {
|
||||
assert!(line.len() <= 980);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_minimal_source_buildinfo() {
|
||||
let input = BuildInfoInput {
|
||||
source: "hello".to_string(),
|
||||
binaries: vec![],
|
||||
architecture: "source".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
binary_only_changes: None,
|
||||
build_origin: "Ubuntu".to_string(),
|
||||
build_architecture: "amd64".to_string(),
|
||||
build_date: "Sat, 22 Aug 2026 23:08:42 +0200".to_string(),
|
||||
checksums: FileChecksums::new(),
|
||||
installed_build_depends: "gcc (= 13)".to_string(),
|
||||
environment: "DEB_BUILD_OPTIONS=\"parallel=8\"".to_string(),
|
||||
};
|
||||
let p = render_buildinfo(&input);
|
||||
let keys: Vec<&str> = p.iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
"Format",
|
||||
"Source",
|
||||
"Architecture",
|
||||
"Version",
|
||||
"Build-Origin",
|
||||
"Build-Architecture",
|
||||
"Build-Date",
|
||||
"Installed-Build-Depends",
|
||||
"Environment"
|
||||
]
|
||||
);
|
||||
assert_eq!(p.get("Format"), Some("1.0"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Debian build types (`dpkg-buildpackage -b/-B/-A/-S/-g/-G/--build=...`)
|
||||
//! and their mapping to `debian/rules` targets.
|
||||
|
||||
/// Build type bit flags, mirroring `Dpkg::BuildTypes`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BuildType(u8);
|
||||
|
||||
/// Source build component (`-S`, `--build=source`).
|
||||
pub const SOURCE: BuildType = BuildType(0x1);
|
||||
/// Arch-dependent binary build component (`-B`, `--build=any`).
|
||||
pub const ARCH_DEP: BuildType = BuildType(0x2);
|
||||
/// Arch-independent binary build component (`-A`, `--build=all`).
|
||||
pub const ARCH_INDEP: BuildType = BuildType(0x4);
|
||||
|
||||
/// Any binary component.
|
||||
pub const BINARY: BuildType = BuildType(ARCH_DEP.0 | ARCH_INDEP.0);
|
||||
/// Normal full build: source + binaries (`-F`, default).
|
||||
pub const FULL: BuildType = BuildType(SOURCE.0 | BINARY.0);
|
||||
/// Source + arch-dependent (`-G`).
|
||||
pub const SOURCE_ARCH_DEP: BuildType = BuildType(SOURCE.0 | ARCH_DEP.0);
|
||||
/// Source + arch-indep (`-g`).
|
||||
pub const SOURCE_ARCH_INDEP: BuildType = BuildType(SOURCE.0 | ARCH_INDEP.0);
|
||||
|
||||
impl BuildType {
|
||||
/// Construct from raw bits.
|
||||
pub const fn from_bits(bits: u8) -> Self {
|
||||
BuildType(bits)
|
||||
}
|
||||
|
||||
/// Raw bits.
|
||||
pub const fn bits(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// True if any of `other`'s components are set.
|
||||
pub fn has_any(self, other: BuildType) -> bool {
|
||||
self.0 & other.0 != 0
|
||||
}
|
||||
|
||||
/// True if all of `other`'s components are set.
|
||||
pub fn has_all(self, other: BuildType) -> bool {
|
||||
self.0 & other.0 == other.0
|
||||
}
|
||||
|
||||
/// True if none of `other`'s components are set.
|
||||
pub fn has_none(self, other: BuildType) -> bool {
|
||||
self.0 & other.0 == 0
|
||||
}
|
||||
|
||||
/// Parse a comma-separated `--build=<type>[,...]` option value.
|
||||
///
|
||||
/// Valid components: `full`, `source`, `binary`, `any`, `all`.
|
||||
pub fn from_options(value: &str) -> Result<BuildType, String> {
|
||||
let mut result = BuildType(0);
|
||||
for part in value.split(',') {
|
||||
match part.trim() {
|
||||
"full" => result = FULL,
|
||||
"source" => result = BuildType(result.0 | SOURCE.0),
|
||||
"binary" => result = BuildType(result.0 | BINARY.0),
|
||||
"any" => result = BuildType(result.0 | ARCH_DEP.0),
|
||||
"all" => result = BuildType(result.0 | ARCH_INDEP.0),
|
||||
other => return Err(format!("unknown build type component '{}'", other)),
|
||||
}
|
||||
}
|
||||
if result.0 == 0 {
|
||||
return Err("empty build type".to_string());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Canonical comma-separated representation (as passed to
|
||||
/// `dpkg-genchanges --build=` / `dpkg-genbuildinfo --build=`).
|
||||
pub fn to_options(self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if self.has_any(SOURCE) {
|
||||
parts.push("source");
|
||||
}
|
||||
if self.has_all(BINARY) {
|
||||
parts.push("binary");
|
||||
} else {
|
||||
if self.has_any(ARCH_DEP) {
|
||||
parts.push("any");
|
||||
}
|
||||
if self.has_any(ARCH_INDEP) {
|
||||
parts.push("all");
|
||||
}
|
||||
}
|
||||
parts.join(",")
|
||||
}
|
||||
|
||||
/// The `debian/rules` build target for this type:
|
||||
/// `build`, `build-arch` or `build-indep`.
|
||||
pub fn build_target(self) -> &'static str {
|
||||
if self.has_all(BINARY) || self.has_none(BINARY) {
|
||||
"build"
|
||||
} else if self.has_any(ARCH_DEP) {
|
||||
"build-arch"
|
||||
} else {
|
||||
"build-indep"
|
||||
}
|
||||
}
|
||||
|
||||
/// The `debian/rules` binary target for this type:
|
||||
/// `binary`, `binary-arch` or `binary-indep`.
|
||||
pub fn binary_target(self) -> &'static str {
|
||||
if self.has_all(BINARY) || self.has_none(BINARY) {
|
||||
"binary"
|
||||
} else if self.has_any(ARCH_DEP) {
|
||||
"binary-arch"
|
||||
} else {
|
||||
"binary-indep"
|
||||
}
|
||||
}
|
||||
|
||||
/// The architecture suffix used in artifact file names:
|
||||
/// host arch, `all` or `source`.
|
||||
pub fn arch_suffix<'a>(self, host_arch: &'a str) -> &'a str {
|
||||
if self.has_any(ARCH_DEP) {
|
||||
host_arch
|
||||
} else if self.has_any(ARCH_INDEP) {
|
||||
"all"
|
||||
} else {
|
||||
"source"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_options() {
|
||||
assert_eq!(BuildType::from_options("full").unwrap(), FULL);
|
||||
assert_eq!(BuildType::from_options("source").unwrap(), SOURCE);
|
||||
assert_eq!(BuildType::from_options("source,any").unwrap(), SOURCE_ARCH_DEP);
|
||||
assert_eq!(BuildType::from_options("any,all").unwrap(), BINARY);
|
||||
assert!(BuildType::from_options("bogus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_options() {
|
||||
for t in [FULL, SOURCE, BINARY, SOURCE_ARCH_DEP, SOURCE_ARCH_INDEP] {
|
||||
assert_eq!(BuildType::from_options(&t.to_options()).unwrap(), t);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets() {
|
||||
assert_eq!(FULL.build_target(), "build");
|
||||
assert_eq!(FULL.binary_target(), "binary");
|
||||
assert_eq!(ARCH_DEP.build_target(), "build-arch");
|
||||
assert_eq!(ARCH_DEP.binary_target(), "binary-arch");
|
||||
assert_eq!(ARCH_INDEP.build_target(), "build-indep");
|
||||
assert_eq!(ARCH_INDEP.binary_target(), "binary-indep");
|
||||
assert_eq!(SOURCE.arch_suffix("amd64"), "source");
|
||||
assert_eq!(ARCH_DEP.arch_suffix("amd64"), "amd64");
|
||||
assert_eq!(ARCH_INDEP.arch_suffix("amd64"), "all");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Native `.changes` generation (Format 1.8), mirroring `dpkg-genchanges`
|
||||
//! for the artifact aggregation part: checksums, per-file sections and
|
||||
//! priorities, changelog-derived fields.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::checksums::FileChecksums;
|
||||
use super::control::{write_paragraph, Paragraph};
|
||||
use super::files::FilesList;
|
||||
|
||||
/// Everything needed to render a `.changes` file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChangesInput {
|
||||
/// `Date` field: the changelog entry date (verbatim trailer date).
|
||||
pub date: String,
|
||||
/// `Source` field, including the ` (sourceversion)` suffix for binNMUs.
|
||||
pub source: String,
|
||||
/// Sorted binary package names with artifacts (empty for source-only).
|
||||
pub binaries: Vec<String>,
|
||||
/// Active build profiles (`Built-For-Profiles`); omitted when empty.
|
||||
pub built_for_profiles: Vec<String>,
|
||||
/// `Architecture` field value in encounter order (e.g. `source`,
|
||||
/// `amd64 all`, ...).
|
||||
pub architecture: String,
|
||||
/// Full version.
|
||||
pub version: String,
|
||||
/// Distribution(s).
|
||||
pub distribution: String,
|
||||
/// Urgency.
|
||||
pub urgency: String,
|
||||
/// `Maintainer` from the control source stanza.
|
||||
pub maintainer: Option<String>,
|
||||
/// `Changed-By` from the changelog maintainer.
|
||||
pub changed_by: Option<String>,
|
||||
/// Formatted per-package description lines (empty for source-only).
|
||||
pub descriptions: Vec<String>,
|
||||
/// Rendered `Changes` field value from the changelog entry.
|
||||
pub changes_field: String,
|
||||
/// Computed artifact checksums (dsc, tarballs, debs, buildinfo).
|
||||
pub checksums: FileChecksums,
|
||||
/// Registry providing section/priority per file.
|
||||
pub files_list: FilesList,
|
||||
}
|
||||
|
||||
/// Wrap an overly long single-line field value (> 980 characters) over
|
||||
/// multiple lines at spaces, like dpkg does for `Binary`.
|
||||
fn wrap_long(value: &str) -> String {
|
||||
if value.len() <= 980 {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(value.len() + 8);
|
||||
let mut line_len = 0usize;
|
||||
for (i, word) in value.split(' ').enumerate() {
|
||||
if i > 0 {
|
||||
if line_len + 1 + word.len() > 980 {
|
||||
out.push('\n');
|
||||
line_len = 0;
|
||||
} else {
|
||||
out.push(' ');
|
||||
line_len += 1;
|
||||
}
|
||||
}
|
||||
out.push_str(word);
|
||||
line_len += word.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Format one `Description` line: `%-10s - %-.65s` plus a ` (type)` suffix
|
||||
/// for non-deb package types, matching `format_desc()` in dpkg-genchanges.
|
||||
pub fn format_description(package: &str, package_type: &str, summary: &str) -> String {
|
||||
let mut line = format!("{:<10} - {:.65}", package, summary);
|
||||
if package_type != "deb" && !package_type.is_empty() {
|
||||
line.push_str(&format!(" ({})", package_type));
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
/// Render the `.changes` document (without signature), with fields in dpkg's
|
||||
/// canonical order for `CTRL_FILE_CHANGES`.
|
||||
///
|
||||
/// Note: the legacy `Files` field carries md5+size+section+priority+name,
|
||||
/// while `Checksums-Sha1`/`Checksums-Sha256` carry the stronger hashes;
|
||||
/// `Checksums-Md5` is deliberately omitted as redundant, exactly like
|
||||
/// dpkg-genchanges does.
|
||||
pub fn render_changes(input: &ChangesInput) -> Paragraph {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Format", "1.8");
|
||||
p.set("Date", &input.date);
|
||||
p.set("Source", &input.source);
|
||||
if !input.binaries.is_empty() {
|
||||
let joined = input.binaries.join(" ");
|
||||
p.set("Binary", &wrap_long(&joined));
|
||||
}
|
||||
if !input.built_for_profiles.is_empty() {
|
||||
p.set("Built-For-Profiles", &input.built_for_profiles.join(" "));
|
||||
}
|
||||
p.set("Architecture", &input.architecture);
|
||||
p.set("Version", &input.version);
|
||||
p.set("Distribution", &input.distribution);
|
||||
p.set("Urgency", &input.urgency);
|
||||
if let Some(maintainer) = &input.maintainer {
|
||||
p.set("Maintainer", maintainer);
|
||||
}
|
||||
if let Some(changed_by) = &input.changed_by {
|
||||
p.set("Changed-By", changed_by);
|
||||
}
|
||||
if !input.descriptions.is_empty() {
|
||||
let mut sorted = input.descriptions.clone();
|
||||
sorted.sort();
|
||||
// Leading `\n`: pre-wrapped multiline field, dpkg-style.
|
||||
p.set("Description", &format!("\n{}", sorted.join("\n")));
|
||||
}
|
||||
p.set("Changes", &input.changes_field);
|
||||
|
||||
if !input.checksums.is_empty() {
|
||||
p.set("Checksums-Sha1", &input.checksums.field_sha1());
|
||||
p.set("Checksums-Sha256", &input.checksums.field_sha256());
|
||||
|
||||
// Legacy Files field: md5 size section priority filename
|
||||
let mut files = String::new();
|
||||
for (key, entry) in input.checksums.iter() {
|
||||
let (section, priority) = input
|
||||
.files_list
|
||||
.get(key)
|
||||
.map(|f| (f.section.as_str(), f.priority.as_str()))
|
||||
.unwrap_or(("-", "-"));
|
||||
files.push('\n');
|
||||
files.push_str(&entry.md5);
|
||||
files.push(' ');
|
||||
files.push_str(&entry.size.to_string());
|
||||
files.push(' ');
|
||||
files.push_str(section);
|
||||
files.push(' ');
|
||||
files.push_str(priority);
|
||||
files.push(' ');
|
||||
files.push_str(key);
|
||||
}
|
||||
p.set("Files", &files);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Serialize and atomically write a `.changes` file.
|
||||
pub fn save_changes(path: &Path, paragraph: &Paragraph) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tmp = path.with_extension("new");
|
||||
std::fs::write(&tmp, write_paragraph(paragraph))
|
||||
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
|
||||
std::fs::rename(&tmp, path)
|
||||
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn description_formatting() {
|
||||
assert_eq!(
|
||||
format_description("hello", "deb", "The classic greeting"),
|
||||
"hello - The classic greeting"
|
||||
);
|
||||
assert_eq!(
|
||||
format_description("verylongpkgname", "udeb", "short"),
|
||||
"verylongpkgname - short (udeb)"
|
||||
);
|
||||
let long_summary = "x".repeat(100);
|
||||
assert_eq!(format_description("p", "deb", &long_summary).len(), 10 + 3 + 65);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_source_only_changes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dsc_path = dir.path().join("pkg_1.0.dsc");
|
||||
std::fs::write(&dsc_path, b"content\n").unwrap();
|
||||
|
||||
let mut checksums = FileChecksums::new();
|
||||
checksums.add_file(&dsc_path).unwrap();
|
||||
|
||||
let mut files_list = FilesList::new();
|
||||
files_list.add(super::super::files::FilesEntry::new(
|
||||
"pkg_1.0.dsc",
|
||||
"utils",
|
||||
"optional",
|
||||
));
|
||||
|
||||
let input = ChangesInput {
|
||||
date: "Sat, 22 Aug 2026 10:00:00 +0000".to_string(),
|
||||
source: "pkg".to_string(),
|
||||
binaries: vec![],
|
||||
built_for_profiles: vec![],
|
||||
architecture: "source".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
distribution: "unstable".to_string(),
|
||||
urgency: "medium".to_string(),
|
||||
maintainer: Some("A B <a@b.c>".to_string()),
|
||||
changed_by: Some("A B <a@b.c>".to_string()),
|
||||
descriptions: vec![],
|
||||
changes_field: "pkg (1.0) unstable; urgency=medium\n.\n * Something.".to_string(),
|
||||
checksums,
|
||||
files_list,
|
||||
};
|
||||
|
||||
let p = render_changes(&input);
|
||||
let keys: Vec<&str> = p.iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
"Format",
|
||||
"Date",
|
||||
"Source",
|
||||
"Architecture",
|
||||
"Version",
|
||||
"Distribution",
|
||||
"Urgency",
|
||||
"Maintainer",
|
||||
"Changed-By",
|
||||
"Changes",
|
||||
"Checksums-Sha1",
|
||||
"Checksums-Sha256",
|
||||
"Files"
|
||||
]
|
||||
);
|
||||
// No Binary / Description / Checksums-Md5 for source-only uploads.
|
||||
assert!(p.get("Binary").is_none());
|
||||
assert!(p.get("Description").is_none());
|
||||
assert!(p.get("Checksums-Md5").is_none());
|
||||
|
||||
let files_value = p.get("Files").unwrap();
|
||||
assert_eq!(
|
||||
files_value,
|
||||
"\n<md5> 8 utils optional pkg_1.0.dsc"
|
||||
.replace("<md5>", &files_value.split_whitespace().next().unwrap_or(""))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Minimal Debian control-file (deb822) paragraph parser and writer.
|
||||
//!
|
||||
//! Implements the subset of RFC822-ish parsing needed for `debian/control`,
|
||||
//! `debian/files`, `.dsc`, `.changes` and `.buildinfo` files: paragraphs
|
||||
//! separated by blank lines, `Field: value` entries with continuation lines
|
||||
//! starting by a single space or tab, and `#` comments.
|
||||
|
||||
/// A single deb822 paragraph: an ordered list of `(field, value)` pairs.
|
||||
///
|
||||
/// Values are stored with continuation-line breaks as `\n` and without the
|
||||
/// leading whitespace of continuation lines. Serialization re-adds a single
|
||||
/// leading space in front of every continuation line, matching dpkg output.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Paragraph {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Paragraph {
|
||||
/// Create an empty paragraph.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Look up a field value (case-insensitive field name).
|
||||
pub fn get(&self, field: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(field))
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// Set a field value, replacing any previous occurrence (case-insensitive).
|
||||
/// Appends the field at the end if it did not exist yet.
|
||||
pub fn set(&mut self, field: &str, value: &str) {
|
||||
for (k, v) in self.fields.iter_mut() {
|
||||
if k.eq_ignore_ascii_case(field) {
|
||||
*v = value.to_string();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.fields.push((field.to_string(), value.to_string()));
|
||||
}
|
||||
|
||||
/// Remove a field (case-insensitive). Returns true if it was present.
|
||||
pub fn remove(&mut self, field: &str) -> bool {
|
||||
let before = self.fields.len();
|
||||
self.fields.retain(|(k, _)| !k.eq_ignore_ascii_case(field));
|
||||
self.fields.len() != before
|
||||
}
|
||||
|
||||
/// Iterate over the `(field, value)` pairs in order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
}
|
||||
|
||||
/// Return true if the paragraph holds no field.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.fields.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a deb822 document into a list of paragraphs.
|
||||
///
|
||||
/// Comment lines (starting with `#`) are ignored. Blank lines separate
|
||||
/// paragraphs. Continuation lines must start with a space or a tab; exactly
|
||||
/// one leading space (or tab) is stripped from the stored value.
|
||||
pub fn parse_paragraphs(input: &str) -> Vec<Paragraph> {
|
||||
let mut paragraphs = Vec::new();
|
||||
let mut current = Paragraph::new();
|
||||
let mut last_field: Option<String> = None;
|
||||
|
||||
for raw_line in input.lines() {
|
||||
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
|
||||
|
||||
// Comments and blank lines
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
if !current.is_empty() {
|
||||
paragraphs.push(std::mem::take(&mut current));
|
||||
last_field = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Continuation line
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
let content = line.strip_prefix(' ').unwrap_or(line);
|
||||
if let Some(field) = &last_field {
|
||||
if let Some((_, v)) = current
|
||||
.fields
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(field))
|
||||
{
|
||||
v.push('\n');
|
||||
v.push_str(content);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Continuation without a preceding field line: skip it (malformed)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Field line: `Name: value`
|
||||
if let Some(colon) = line.find(':') {
|
||||
let name = line[..colon].trim();
|
||||
let value = line[colon + 1..].trim_start();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
current.fields.push((name.to_string(), value.to_string()));
|
||||
last_field = Some(name.to_string());
|
||||
}
|
||||
// Anything else is malformed: ignore the line
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
paragraphs.push(current);
|
||||
}
|
||||
|
||||
paragraphs
|
||||
}
|
||||
|
||||
/// Serialize a paragraph to its deb822 textual representation (with a
|
||||
/// trailing newline).
|
||||
///
|
||||
/// A value starting with `\n` is rendered as a field with no inline first
|
||||
/// line (`Field:` followed by ` line` continuations), matching dpkg output
|
||||
/// for pre-wrapped values such as `Changes`, `Files` or `Environment`.
|
||||
pub fn write_paragraph(p: &Paragraph) -> String {
|
||||
let mut out = String::new();
|
||||
for (name, value) in p.iter() {
|
||||
out.push_str(name);
|
||||
out.push(':');
|
||||
let mut lines = value.split('\n').peekable();
|
||||
// An empty first segment means: no value on the field header line;
|
||||
// discard it so it is not rendered as an empty continuation line.
|
||||
if lines.peek().is_some_and(|first| !first.is_empty()) {
|
||||
out.push(' ');
|
||||
out.push_str(lines.next().unwrap());
|
||||
} else {
|
||||
lines.next();
|
||||
}
|
||||
for line in lines {
|
||||
out.push('\n');
|
||||
out.push(' ');
|
||||
out.push_str(line);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_control() {
|
||||
let input = "Source: hello\nSection: devel\n\nPackage: hello\nDepends: libc6\n";
|
||||
let paras = parse_paragraphs(input);
|
||||
assert_eq!(paras.len(), 2);
|
||||
assert_eq!(paras[0].get("Source"), Some("hello"));
|
||||
assert_eq!(paras[0].get("section"), Some("devel"));
|
||||
assert_eq!(paras[1].get("Package"), Some("hello"));
|
||||
assert_eq!(paras[1].get("Depends"), Some("libc6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiline_and_comments() {
|
||||
let input = "# a comment\nDescription: short\n long description\n" //
|
||||
.to_string() //
|
||||
+ " spanning lines\n\nPackage: x\n";
|
||||
let paras = parse_paragraphs(&input);
|
||||
assert_eq!(paras.len(), 2);
|
||||
assert_eq!(
|
||||
paras[0].get("Description"),
|
||||
Some("short\nlong description\nspanning lines")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_multiline() {
|
||||
let value = "short\nlong description\nspanning lines";
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Description", value);
|
||||
let text = write_paragraph(&p);
|
||||
assert_eq!(text, "Description: short\n long description\n spanning lines\n");
|
||||
let reparsed = parse_paragraphs(&text);
|
||||
assert_eq!(reparsed[0].get("Description"), Some(value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_replaces_case_insensitive() {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Source", "a");
|
||||
p.set("source", "b");
|
||||
assert_eq!(p.get("SOURCE"), Some("b"));
|
||||
assert_eq!(p.iter().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_field() {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("A", "1");
|
||||
assert!(p.remove("a"));
|
||||
assert!(!p.remove("a"));
|
||||
assert!(p.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Build environment setup: `SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`,
|
||||
//! architecture variables (via `dpkg-architecture`) and the sanitized
|
||||
//! environment recorded in `.buildinfo` files.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Number of parallel jobs to advertise in `DEB_BUILD_OPTIONS`.
|
||||
pub fn num_parallel() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// Compute the environment variables exported by `dpkg-buildpackage` before
|
||||
/// running any build step.
|
||||
///
|
||||
/// Mirrors dpkg behavior:
|
||||
/// - `SOURCE_DATE_EPOCH` from the changelog entry timestamp
|
||||
/// (<https://reproducible-builds.org/specs/source-date-epoch/>),
|
||||
/// - `DEB_BUILD_OPTIONS=parallel=N` (auto-detected job count),
|
||||
/// - `DEB_BUILD_PROFILES` when non-default profiles are requested.
|
||||
pub fn build_env(
|
||||
source_date_epoch: i64,
|
||||
parallel: usize,
|
||||
build_profiles: &[String],
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert(
|
||||
"SOURCE_DATE_EPOCH".to_string(),
|
||||
source_date_epoch.to_string(),
|
||||
);
|
||||
env.insert(
|
||||
"DEB_BUILD_OPTIONS".to_string(),
|
||||
format!("parallel={}", parallel),
|
||||
);
|
||||
if !build_profiles.is_empty() {
|
||||
env.insert("DEB_BUILD_PROFILES".to_string(), build_profiles.join(","));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// Import the full architecture variable set by running
|
||||
/// `dpkg-architecture -f [-a <host-arch>]` and parsing its `KEY=VALUE` dump.
|
||||
///
|
||||
/// This exports all `DEB_BUILD_*`, `DEB_HOST_*` and `DEB_TARGET_*` variables
|
||||
/// (`*_ARCH`, `*_OS`, `*_CPU`, `*_MULTIARCH`, `*_GNU_TYPE`, ...), exactly as
|
||||
/// `dpkg-buildpackage` does.
|
||||
pub fn arch_env(host_arch: Option<&str>) -> Result<BTreeMap<String, String>, String> {
|
||||
let mut cmd = Command::new("dpkg-architecture");
|
||||
cmd.arg("-f");
|
||||
if let Some(arch) = host_arch {
|
||||
cmd.args(["--host-arch", arch]);
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"failed to run 'dpkg-architecture': {}. Is 'dpkg-dev' installed?",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"dpkg-architecture failed with status {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
|
||||
let mut env = BTreeMap::new();
|
||||
for line in String::from_utf8_lossy(&output.stdout).lines() {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
env.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
/// Read the current vendor name from `/etc/dpkg/origins/default`
|
||||
/// (its `Vendor:` or `Origin:` field), defaulting to `"debian"`.
|
||||
pub fn current_vendor() -> String {
|
||||
read_vendor_from(Path::new("/etc/dpkg/origins/default"))
|
||||
.unwrap_or_else(|| "debian".to_string())
|
||||
}
|
||||
|
||||
fn read_vendor_from(path: &Path) -> Option<String> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
for line in content.lines() {
|
||||
if let Some(value) = line.strip_prefix("Vendor:") {
|
||||
let v = value.trim();
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to Origin if no Vendor field is present.
|
||||
for line in content.lines() {
|
||||
if let Some(value) = line.strip_prefix("Origin:") {
|
||||
let v = value.trim();
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Default build profiles applied by vendor hooks.
|
||||
///
|
||||
/// The Ubuntu vendor module activates `derivative.ubuntu noudeb` by default;
|
||||
/// Debian applies none. This mirrors what `Dpkg::BuildProfiles` resolves when
|
||||
/// `DEB_BUILD_PROFILES` is unset.
|
||||
pub fn default_build_profiles(vendor: &str) -> Vec<String> {
|
||||
if vendor.eq_ignore_ascii_case("ubuntu") {
|
||||
vec!["derivative.ubuntu".to_string(), "noudeb".to_string()]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the active build profiles: explicit `-P` profiles take precedence,
|
||||
/// then `DEB_BUILD_PROFILES` from the environment, then vendor defaults.
|
||||
pub fn resolve_build_profiles(explicit: &[String], vendor: &str) -> Vec<String> {
|
||||
if !explicit.is_empty() {
|
||||
return explicit.to_vec();
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEB_BUILD_PROFILES") {
|
||||
let profiles: Vec<String> = value
|
||||
.split(',')
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
if !profiles.is_empty() {
|
||||
return profiles;
|
||||
}
|
||||
}
|
||||
default_build_profiles(vendor)
|
||||
}
|
||||
|
||||
/// Environment variables that may affect a build without leaking private
|
||||
/// information; only these are recorded in the `.buildinfo` `Environment`
|
||||
/// field. Mirrors `Dpkg::BuildInfo::get_build_env_allowed()`.
|
||||
const ENV_ALLOWED: &[&str] = &[
|
||||
// Tool behavior.
|
||||
"POSIXLY_CORRECT",
|
||||
"GETCONF_DIR",
|
||||
// Resolver.
|
||||
"RESOLV_HOST_CONF",
|
||||
"RESOLV_MULTI",
|
||||
"RESOLV_REORDER",
|
||||
"RES_OPTIONS",
|
||||
// Toolchain.
|
||||
"CC",
|
||||
"CPP",
|
||||
"CXX",
|
||||
"OBJC",
|
||||
"OBJCXX",
|
||||
"PC",
|
||||
"FC",
|
||||
"M2C",
|
||||
"AS",
|
||||
"LD",
|
||||
"AR",
|
||||
"RANLIB",
|
||||
"MAKE",
|
||||
"AWK",
|
||||
"LEX",
|
||||
"YACC",
|
||||
// Toolchain flags.
|
||||
"ASFLAGS",
|
||||
"ASFLAGS_FOR_BUILD",
|
||||
"CFLAGS",
|
||||
"CFLAGS_FOR_BUILD",
|
||||
"CPPFLAGS",
|
||||
"CPPFLAGS_FOR_BUILD",
|
||||
"CXXFLAGS",
|
||||
"CXXFLAGS_FOR_BUILD",
|
||||
"OBJCFLAGS",
|
||||
"OBJCFLAGS_FOR_BUILD",
|
||||
"OBJCXXFLAGS",
|
||||
"OBJCXXFLAGS_FOR_BUILD",
|
||||
"DFLAGS",
|
||||
"DFLAGS_FOR_BUILD",
|
||||
"FFLAGS",
|
||||
"FFLAGS_FOR_BUILD",
|
||||
"LDFLAGS",
|
||||
"LDFLAGS_FOR_BUILD",
|
||||
"ARFLAGS",
|
||||
"LFLAGS",
|
||||
"YFLAGS",
|
||||
"MAKEFLAGS",
|
||||
"GNUMAKEFLAGS",
|
||||
// Dynamic linker.
|
||||
"LD_ASSUME_KERNEL",
|
||||
"LD_AUDIT",
|
||||
"LD_BIND_NOT",
|
||||
"LD_BIND_NOW",
|
||||
"LD_DYNAMIC_WEAK",
|
||||
"LD_LIBRARY_PATH",
|
||||
"LD_ORIGIN_PATH",
|
||||
"LD_PREFER_MAP_32BIT_EXEC",
|
||||
"LD_PRELOAD",
|
||||
// Timezone.
|
||||
"TZ",
|
||||
"TZDIR",
|
||||
// Dates.
|
||||
"DATEMSK",
|
||||
// Locale.
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"LC_NUMERIC",
|
||||
"LC_TIME",
|
||||
"LC_COLLATE",
|
||||
"LC_MONETARY",
|
||||
"LC_MESSAGES",
|
||||
"LC_PAPER",
|
||||
"LC_NAME",
|
||||
"LC_ADDRESS",
|
||||
"LC_TELEPHONE",
|
||||
"LC_MEASUREMENT",
|
||||
"LC_IDENTIFICATION",
|
||||
// Locale paths.
|
||||
"LOCPATH",
|
||||
"I18NPATH",
|
||||
"NLSPATH",
|
||||
"GCONV_PATH",
|
||||
// Build flags.
|
||||
"DEB_BUILD_OPTIONS",
|
||||
"DEB_BUILD_PROFILES",
|
||||
"DEB_VENDOR",
|
||||
// dpkg.
|
||||
"DPKG_ROOT",
|
||||
"DPKG_ADMINDIR",
|
||||
"DPKG_DATADIR",
|
||||
"DPKG_ORIGINS_DIR",
|
||||
// dpkg-deb.
|
||||
"DPKG_DEB_COMPRESSOR_TYPE",
|
||||
"DPKG_DEB_COMPRESSOR_LEVEL",
|
||||
// dpkg-gensymbols.
|
||||
"DPKG_GENSYMBOLS_CHECK_LEVEL",
|
||||
// Reproducible builds.
|
||||
"SOURCE_DATE_EPOCH",
|
||||
];
|
||||
|
||||
/// Build the `.buildinfo` `Environment` field value: allowed variables from
|
||||
/// the current process environment plus the `extra` overrides exported to
|
||||
/// build steps (e.g. `SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`), sorted by
|
||||
/// name, quoted and escaped, one per line.
|
||||
///
|
||||
/// Matches `cleansed_environment()` in `dpkg-genbuildinfo` (minus
|
||||
/// `dpkg-buildflags` origin tracking).
|
||||
pub fn buildinfo_environment(extra: &BTreeMap<String, String>) -> String {
|
||||
let mut values: BTreeMap<String, String> = BTreeMap::new();
|
||||
for var in ENV_ALLOWED {
|
||||
if let Ok(value) = std::env::var(var) {
|
||||
values.insert(var.to_string(), value);
|
||||
}
|
||||
}
|
||||
// Variables we export ourselves always take precedence.
|
||||
for (key, value) in extra {
|
||||
if ENV_ALLOWED.contains(&key.as_str()) {
|
||||
values.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
// Leading `\n`: pre-wrapped multiline field, dpkg-style.
|
||||
let mut out = String::from("\n");
|
||||
out.push_str(
|
||||
&values
|
||||
.into_iter()
|
||||
.map(|(var, value)| format!("{}=\"{}\"", var, value.replace('"', "\\\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_env_values() {
|
||||
let env = build_env(1787392800, 16, &[]);
|
||||
assert_eq!(env.get("SOURCE_DATE_EPOCH").unwrap(), "1787392800");
|
||||
assert_eq!(env.get("DEB_BUILD_OPTIONS").unwrap(), "parallel=16");
|
||||
assert!(env.get("DEB_BUILD_PROFILES").is_none());
|
||||
|
||||
let env = build_env(1, 4, &["nodoc".to_string(), "cross".to_string()]);
|
||||
assert_eq!(env.get("DEB_BUILD_PROFILES").unwrap(), "nodoc,cross");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_defaults() {
|
||||
assert!(default_build_profiles("debian").is_empty());
|
||||
assert_eq!(
|
||||
default_build_profiles("ubuntu"),
|
||||
vec!["derivative.ubuntu".to_string(), "noudeb".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_escaping() {
|
||||
// The function reads the process env; just verify formatting helpers
|
||||
// through a controlled subprocess-free path is not possible, so check
|
||||
// the constant list contains essentials.
|
||||
assert!(ENV_ALLOWED.contains(&"SOURCE_DATE_EPOCH"));
|
||||
assert!(ENV_ALLOWED.contains(&"DEB_BUILD_OPTIONS"));
|
||||
assert!(!ENV_ALLOWED.contains(&"HOME"));
|
||||
assert!(!ENV_ALLOWED.contains(&"PATH"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! `debian/files` registry: the contract between the build (`dh_builddeb`,
|
||||
//! `dpkg-gencontrol`, ...) and the artifact generators, mirroring
|
||||
//! `Dpkg::Dist::Files`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
/// One registered artifact.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FilesEntry {
|
||||
/// File name (relative to the parent directory of the package tree).
|
||||
pub filename: String,
|
||||
/// Archive section (e.g. `utils`).
|
||||
pub section: String,
|
||||
/// Archive priority (e.g. `optional`).
|
||||
pub priority: String,
|
||||
/// Package name parsed from the file name pattern, if any.
|
||||
pub package: Option<String>,
|
||||
/// Version parsed from the file name pattern, if any.
|
||||
pub version: Option<String>,
|
||||
/// Architecture parsed from the file name pattern, if any.
|
||||
pub arch: Option<String>,
|
||||
/// Artifact type parsed from the file name extension
|
||||
/// (`deb`, `udeb`, `buildinfo`, `changes`, ...).
|
||||
pub package_type: Option<String>,
|
||||
/// Extra `key=value` attributes on the line (e.g. `automatic=yes`).
|
||||
pub attrs: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl FilesEntry {
|
||||
/// Create a minimal entry with only name/section/priority; the pattern
|
||||
/// fields are derived from the file name.
|
||||
pub fn new(filename: &str, section: &str, priority: &str) -> FilesEntry {
|
||||
let mut entry = parse_filename(filename).unwrap_or_else(|| FilesEntry {
|
||||
filename: filename.to_string(),
|
||||
section: "-".to_string(),
|
||||
priority: "-".to_string(),
|
||||
package: None,
|
||||
version: None,
|
||||
arch: None,
|
||||
package_type: None,
|
||||
attrs: BTreeMap::new(),
|
||||
});
|
||||
entry.section = section.to_string();
|
||||
entry.priority = priority.to_string();
|
||||
entry
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive metadata from an artifact file name following the common
|
||||
/// `<package>_<version>_<arch>.<type>` pattern, like
|
||||
/// `Dpkg::Dist::Files::parse_filename()`.
|
||||
#[allow(clippy::field_reassign_with_default)]
|
||||
pub fn parse_filename(name: &str) -> Option<FilesEntry> {
|
||||
// Common pattern: name_version_arch.type where type is dot-separated
|
||||
// (e.g. tar.xz must not match here since it has no leading underscores).
|
||||
let parts: Vec<&str> = name.split('_').collect();
|
||||
if parts.len() == 3 {
|
||||
let (pkg, version, rest) = (parts[0], parts[1], parts[2]);
|
||||
if let Some(dot) = rest.rfind('.') {
|
||||
let arch = &rest[..dot];
|
||||
let ptype = &rest[dot + 1..];
|
||||
let valid = |s: &str| !s.is_empty()
|
||||
&& s.chars().all(|c| c.is_ascii_alphanumeric() || "-+.:~".contains(c));
|
||||
if valid(pkg) && valid(version) && valid(arch) && valid(ptype) {
|
||||
return Some(FilesEntry {
|
||||
filename: name.to_string(),
|
||||
section: "-".to_string(),
|
||||
priority: "-".to_string(),
|
||||
package: Some(pkg.to_string()),
|
||||
version: Some(version.to_string()),
|
||||
arch: Some(arch.to_string()),
|
||||
package_type: Some(ptype.to_string()),
|
||||
attrs: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: accept a conservative file-name character set.
|
||||
if !name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "-+.:,_~".contains(c))
|
||||
{
|
||||
return Some(FilesEntry {
|
||||
filename: name.to_string(),
|
||||
section: "-".to_string(),
|
||||
priority: "-".to_string(),
|
||||
package: None,
|
||||
version: None,
|
||||
arch: None,
|
||||
package_type: None,
|
||||
attrs: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The full `debian/files` registry, ordered by file name.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FilesList {
|
||||
files: BTreeMap<String, FilesEntry>,
|
||||
}
|
||||
|
||||
impl FilesList {
|
||||
/// An empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Load `debian/files`. A missing file yields an empty registry.
|
||||
pub fn load(path: &Path) -> Result<FilesList, Box<dyn std::error::Error>> {
|
||||
let mut list = FilesList::new();
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(list),
|
||||
Err(e) => {
|
||||
return Err(format!("cannot read '{}': {}", path.display(), e).into());
|
||||
}
|
||||
};
|
||||
for line in content.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.len() < 3 {
|
||||
return Err(format!("badly formed line in '{}': {}", path.display(), line).into());
|
||||
}
|
||||
let mut entry = parse_filename(tokens[0]).ok_or_else(|| {
|
||||
format!("badly formed file name in '{}': {}", path.display(), tokens[0])
|
||||
})?;
|
||||
entry.section = tokens[1].to_string();
|
||||
entry.priority = tokens[2].to_string();
|
||||
for attr in &tokens[3..] {
|
||||
if let Some((k, v)) = attr.split_once('=') {
|
||||
entry.attrs.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
list.files.insert(entry.filename.clone(), entry);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Register (or replace) an entry.
|
||||
pub fn add(&mut self, entry: FilesEntry) {
|
||||
self.files.insert(entry.filename.clone(), entry);
|
||||
}
|
||||
|
||||
/// Remove entries matching a predicate. Returns how many were removed.
|
||||
pub fn retain<F: FnMut(&FilesEntry) -> bool>(&mut self, mut keep: F) -> usize {
|
||||
let before = self.files.len();
|
||||
self.files.retain(|_, e| keep(e));
|
||||
before - self.files.len()
|
||||
}
|
||||
|
||||
/// Iterate over entries sorted by file name.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &FilesEntry> {
|
||||
self.files.values()
|
||||
}
|
||||
|
||||
/// Look up an entry by file name.
|
||||
pub fn get(&self, filename: &str) -> Option<&FilesEntry> {
|
||||
self.files.get(filename)
|
||||
}
|
||||
|
||||
/// Number of registered files.
|
||||
pub fn len(&self) -> usize {
|
||||
self.files.len()
|
||||
}
|
||||
|
||||
/// True if empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
|
||||
/// Save atomically: write `<path>.new` then rename over `path`, like
|
||||
/// dpkg does.
|
||||
pub fn save_atomic(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tmp = path.with_extension("new");
|
||||
{
|
||||
let mut f = std::fs::File::create(&tmp)
|
||||
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
|
||||
for entry in self.iter() {
|
||||
write!(f, "{} {} {}", entry.filename, entry.section, entry.priority)?;
|
||||
for (k, v) in &entry.attrs {
|
||||
write!(f, " {}={}", k, v)?;
|
||||
}
|
||||
writeln!(f)?;
|
||||
}
|
||||
f.flush()?;
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filename_pattern_parsing() {
|
||||
let e = parse_filename("hello_2.10-3_amd64.deb").unwrap();
|
||||
assert_eq!(e.package.as_deref(), Some("hello"));
|
||||
assert_eq!(e.version.as_deref(), Some("2.10-3"));
|
||||
assert_eq!(e.arch.as_deref(), Some("amd64"));
|
||||
assert_eq!(e.package_type.as_deref(), Some("deb"));
|
||||
|
||||
let e = parse_filename("hello_0.1_source.buildinfo").unwrap();
|
||||
assert_eq!(e.package.as_deref(), Some("hello"));
|
||||
assert_eq!(e.arch.as_deref(), Some("source"));
|
||||
assert_eq!(e.package_type.as_deref(), Some("buildinfo"));
|
||||
|
||||
// Tarballs do not follow the 3-component pattern.
|
||||
let e = parse_filename("hello_0.1.tar.xz").unwrap();
|
||||
assert_eq!(e.package, None);
|
||||
assert_eq!(e.package_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_save_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("files");
|
||||
|
||||
let mut list = FilesList::new();
|
||||
list.add(FilesEntry::new("hello_1.0_amd64.deb", "devel", "optional"));
|
||||
list.add(FilesEntry::new("hello_1.0_source.buildinfo", "-", "-"));
|
||||
list.save_atomic(&path).unwrap();
|
||||
|
||||
let reloaded = FilesList::load(&path).unwrap();
|
||||
assert_eq!(reloaded.len(), 2);
|
||||
let deb = reloaded.get("hello_1.0_amd64.deb").unwrap();
|
||||
assert_eq!(deb.section, "devel");
|
||||
assert_eq!(deb.priority, "optional");
|
||||
|
||||
// Missing file loads as empty.
|
||||
let missing = FilesList::load(&dir.path().join("nonexistent")).unwrap();
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_removes_matching() {
|
||||
let mut list = FilesList::new();
|
||||
list.add(FilesEntry::new("x_1_source.buildinfo", "-", "-"));
|
||||
list.add(FilesEntry::new("x_1_amd64.deb", "-", "-"));
|
||||
let removed = list.retain(|e| e.package_type.as_deref() != Some("buildinfo"));
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(list.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Source package metadata: Debian version splitting, changelog entry
|
||||
//! parsing and `debian/control` parsing.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::DateTime;
|
||||
|
||||
use super::control::{parse_paragraphs, Paragraph};
|
||||
|
||||
/// A Debian version, split into its `[epoch:]upstream[-revision]` parts.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DebianVersion {
|
||||
/// Optional numeric epoch (part before the first `:`).
|
||||
pub epoch: Option<u32>,
|
||||
/// Upstream version (may itself contain `-` when there is no revision).
|
||||
pub upstream: String,
|
||||
/// Optional Debian revision (part after the last `-`).
|
||||
pub debian_revision: Option<String>,
|
||||
}
|
||||
|
||||
impl DebianVersion {
|
||||
/// Parse and validate a Debian version string.
|
||||
pub fn parse(raw: &str) -> Result<DebianVersion, String> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return Err("empty version string".to_string());
|
||||
}
|
||||
|
||||
let (epoch, rest) = match raw.split_once(':') {
|
||||
Some((e, r)) => {
|
||||
let epoch: u32 = e
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid epoch '{}' in version '{}'", e, raw))?;
|
||||
(Some(epoch), r)
|
||||
}
|
||||
None => (None, raw),
|
||||
};
|
||||
|
||||
// The revision is everything after the last hyphen.
|
||||
let (upstream, debian_revision) = match rest.rsplit_once('-') {
|
||||
Some((u, r)) => (u.to_string(), Some(r.to_string())),
|
||||
None => (rest.to_string(), None),
|
||||
};
|
||||
|
||||
if upstream.is_empty() {
|
||||
return Err(format!("missing upstream version in '{}'", raw));
|
||||
}
|
||||
for c in upstream.chars() {
|
||||
if !(c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '.' | '+' | '-' | '~' | ':')
|
||||
|| !c.is_ascii())
|
||||
{
|
||||
return Err(format!("invalid character '{}' in version '{}'", c, raw));
|
||||
}
|
||||
}
|
||||
if let Some(rev) = &debian_revision {
|
||||
for c in rev.chars() {
|
||||
if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) {
|
||||
return Err(format!(
|
||||
"invalid character '{}' in revision of version '{}'",
|
||||
c, raw
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DebianVersion {
|
||||
epoch,
|
||||
upstream,
|
||||
debian_revision,
|
||||
})
|
||||
}
|
||||
|
||||
/// Full version string, including the epoch (`[epoch:]upstream[-rev]`).
|
||||
pub fn full(&self) -> String {
|
||||
match (&self.epoch, &self.debian_revision) {
|
||||
(Some(e), Some(r)) => format!("{}:{}-{}", e, self.upstream, r),
|
||||
(Some(e), None) => format!("{}:{}", e, self.upstream),
|
||||
(None, Some(r)) => format!("{}-{}", self.upstream, r),
|
||||
(None, None) => self.upstream.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Version string without the epoch (`upstream[-rev]`), used in artifact
|
||||
/// file names.
|
||||
pub fn no_epoch(&self) -> String {
|
||||
match &self.debian_revision {
|
||||
Some(r) => format!("{}-{}", self.upstream, r),
|
||||
None => self.upstream.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `debian/changelog` entry (the most recent one).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChangelogEntry {
|
||||
/// Source package name.
|
||||
pub source: String,
|
||||
/// Parsed version.
|
||||
pub version: DebianVersion,
|
||||
/// Raw distribution(s) field, e.g. `"unstable"` or `"focal"`.
|
||||
pub distribution: String,
|
||||
/// Urgency value, e.g. `"medium"`.
|
||||
pub urgency: String,
|
||||
/// True for binNMU-style entries (`binary-only=yes` header parameter).
|
||||
pub binary_only: bool,
|
||||
/// Maintainer name from the trailer line.
|
||||
pub maintainer_name: String,
|
||||
/// Maintainer email from the trailer line.
|
||||
pub maintainer_email: String,
|
||||
/// Verbatim trailer date string (RFC2822-ish).
|
||||
pub date_raw: String,
|
||||
/// Trailer date parsed as a Unix timestamp.
|
||||
pub timestamp: i64,
|
||||
/// Value for the `.changes` `Changes` field: header line, blank lines
|
||||
/// converted to `.`, body lines verbatim; without the trailer line.
|
||||
pub changes_field: String,
|
||||
}
|
||||
|
||||
/// Parse the most recent entry of a Debian changelog file.
|
||||
pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path).map_err(|e| {
|
||||
format!(
|
||||
"failed to read changelog '{}': {}. Make sure you are running \
|
||||
from the root of a source package.",
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut lines = content.lines().peekable();
|
||||
|
||||
// --- Header line: `package (version) distributions; urgency=medium[, key=value]`
|
||||
let header = loop {
|
||||
match lines.next() {
|
||||
Some(l) if l.trim().is_empty() => continue,
|
||||
Some(l) => break l.trim_end(),
|
||||
None => {
|
||||
return Err(format!("changelog '{}' is empty", path.display()).into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let open = header
|
||||
.find('(')
|
||||
.ok_or_else(|| format!("invalid changelog header in '{}': {}", path.display(), header))?;
|
||||
let close = header[open..]
|
||||
.find(')')
|
||||
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{}'", header))?;
|
||||
let source = header[..open].trim().to_string();
|
||||
if source.is_empty() || source.contains(' ') {
|
||||
return Err(format!("invalid source name in changelog header '{}'", header).into());
|
||||
}
|
||||
let version = DebianVersion::parse(&header[open + 1..open + close])?;
|
||||
|
||||
let after_version = &header[open + close + 1..];
|
||||
let (distributions_part, params_part) = match after_version.split_once(';') {
|
||||
Some((d, p)) => (d, p),
|
||||
None => (after_version, ""),
|
||||
};
|
||||
let distribution = distributions_part.trim().to_string();
|
||||
if distribution.is_empty() {
|
||||
return Err(format!("missing distribution in changelog header '{}'", header).into());
|
||||
}
|
||||
|
||||
let mut urgency = String::from("unknown");
|
||||
let mut binary_only = false;
|
||||
for param in params_part.split(',') {
|
||||
let param = param.trim();
|
||||
if let Some(value) = param.strip_prefix("urgency=") {
|
||||
urgency = value.trim().to_string();
|
||||
} else if param == "binary-only=yes" || param == "binary-only=yes," {
|
||||
binary_only = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Body until trailer line ` -- Name <email> Date`
|
||||
let mut body_lines: Vec<String> = Vec::new();
|
||||
let mut trailer: Option<String> = None;
|
||||
for line in lines {
|
||||
let line = line.trim_end();
|
||||
if line.starts_with(" -- ") {
|
||||
trailer = Some(line.to_string());
|
||||
break;
|
||||
}
|
||||
// Stop at an emacs local-variables block or a new entry header.
|
||||
if line.starts_with("Local variables:") {
|
||||
break;
|
||||
}
|
||||
if !line.trim().is_empty() && looks_like_header(line) && !body_lines.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Blank lines become "." like dpkg does for the Changes field.
|
||||
if line.trim().is_empty() {
|
||||
body_lines.push(".".to_string());
|
||||
} else {
|
||||
body_lines.push(line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let trailer_line = trailer.ok_or_else(|| {
|
||||
format!(
|
||||
"no maintainer trailer found in '{}': expected a line of the form \
|
||||
' -- Name <email> Date'",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Strip trailing "." lines left over from blank lines before the trailer.
|
||||
while body_lines.last().map(|l| l == ".").unwrap_or(false) {
|
||||
body_lines.pop();
|
||||
}
|
||||
|
||||
let trailer_body = trailer_line.strip_prefix(" -- ").unwrap_or(&trailer_line);
|
||||
let lt = trailer_body
|
||||
.find('<')
|
||||
.ok_or_else(|| format!("malformed maintainer trailer '{}'", trailer_line))?;
|
||||
let gt = trailer_body[lt..]
|
||||
.find('>')
|
||||
.map(|i| i + lt)
|
||||
.ok_or_else(|| format!("malformed maintainer trailer '{}'", trailer_line))?;
|
||||
let maintainer_name = trailer_body[..lt].trim().to_string();
|
||||
let maintainer_email = trailer_body[lt + 1..gt].trim().to_string();
|
||||
let date_raw = trailer_body[gt + 1..].trim().to_string();
|
||||
|
||||
let timestamp = DateTime::parse_from_rfc2822(&date_raw)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"cannot parse changelog date '{}' in '{}': {}",
|
||||
date_raw,
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})?
|
||||
.timestamp();
|
||||
|
||||
// Changes field value (leading `\n` marks it as a pre-wrapped multiline
|
||||
// field, like dpkg's own representation): header + blank-as-dot + body,
|
||||
// without the trailer line.
|
||||
let mut changes_field = String::from("\n");
|
||||
changes_field.push_str(header);
|
||||
if !body_lines.is_empty() {
|
||||
changes_field.push('\n');
|
||||
changes_field.push_str(&body_lines.join("\n"));
|
||||
}
|
||||
|
||||
Ok(ChangelogEntry {
|
||||
source,
|
||||
version,
|
||||
distribution,
|
||||
urgency,
|
||||
binary_only,
|
||||
maintainer_name,
|
||||
maintainer_email,
|
||||
date_raw,
|
||||
timestamp,
|
||||
changes_field,
|
||||
})
|
||||
}
|
||||
|
||||
/// Heuristic check for a changelog entry header line
|
||||
/// (`name (version) dist; urgency=...`).
|
||||
fn looks_like_header(line: &str) -> bool {
|
||||
// Headers are never indented.
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
return false;
|
||||
}
|
||||
match line.find('(') {
|
||||
Some(open) => {
|
||||
let name = line[..open].trim();
|
||||
!name.is_empty() && !name.contains(' ')
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed `debian/control`: the source stanza plus all binary stanzas.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ControlInfo {
|
||||
/// First paragraph (source package stanza).
|
||||
pub source: Paragraph,
|
||||
/// Remaining paragraphs (binary package stanzas).
|
||||
pub binaries: Vec<Paragraph>,
|
||||
}
|
||||
|
||||
impl ControlInfo {
|
||||
/// Parse a `debian/control` file.
|
||||
pub fn parse(path: &Path) -> Result<ControlInfo, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read control file '{}': {}", path.display(), e))?;
|
||||
Self::from_str(&content)
|
||||
.map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
/// Parse control content from a string.
|
||||
pub fn from_str(content: &str) -> Result<ControlInfo, String> {
|
||||
let paragraphs = parse_paragraphs(content);
|
||||
let mut iter = paragraphs.into_iter();
|
||||
let source = iter
|
||||
.next()
|
||||
.ok_or_else(|| "control file has no paragraphs".to_string())?;
|
||||
if source.get("Source").is_none() {
|
||||
return Err("first control paragraph has no 'Source' field".to_string());
|
||||
}
|
||||
let binaries: Vec<Paragraph> = iter.collect();
|
||||
for bin in &binaries {
|
||||
if bin.get("Package").is_none() {
|
||||
return Err("binary control paragraph has no 'Package' field".to_string());
|
||||
}
|
||||
}
|
||||
Ok(ControlInfo { source, binaries })
|
||||
}
|
||||
|
||||
/// The source package name.
|
||||
pub fn source_name(&self) -> &str {
|
||||
self.source.get("Source").expect("checked at parse")
|
||||
}
|
||||
|
||||
/// Section from the source stanza, or `'-'`.
|
||||
pub fn section(&self) -> &str {
|
||||
self.source.get("Section").unwrap_or("-")
|
||||
}
|
||||
|
||||
/// Priority from the source stanza, or `'-'`.
|
||||
pub fn priority(&self) -> &str {
|
||||
self.source.get("Priority").unwrap_or("-")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_splitting() {
|
||||
let v = DebianVersion::parse("1.2.3-4ubuntu5").unwrap();
|
||||
assert_eq!(v.epoch, None);
|
||||
assert_eq!(v.upstream, "1.2.3");
|
||||
assert_eq!(v.debian_revision.as_deref(), Some("4ubuntu5"));
|
||||
assert_eq!(v.full(), "1.2.3-4ubuntu5");
|
||||
assert_eq!(v.no_epoch(), "1.2.3-4ubuntu5");
|
||||
|
||||
let v = DebianVersion::parse("3:2.10-3").unwrap();
|
||||
assert_eq!(v.epoch, Some(3));
|
||||
assert_eq!(v.upstream, "2.10");
|
||||
assert_eq!(v.no_epoch(), "2.10-3");
|
||||
assert_eq!(v.full(), "3:2.10-3");
|
||||
|
||||
let v = DebianVersion::parse("1.0").unwrap();
|
||||
assert_eq!(v.debian_revision, None);
|
||||
assert_eq!(v.no_epoch(), "1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_validation() {
|
||||
assert!(DebianVersion::parse("").is_err());
|
||||
assert!(DebianVersion::parse(":1.0").is_err());
|
||||
assert!(DebianVersion::parse("a:_b").is_err());
|
||||
assert!(DebianVersion::parse("1.0").is_ok());
|
||||
assert!(DebianVersion::parse("1.0~rc1-2").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_parsing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("changelog");
|
||||
let content = "\
|
||||
pkh-hello (0.1) unstable; urgency=medium
|
||||
|
||||
* Initial release.
|
||||
* Second change line.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Sat, 22 Aug 2026 10:00:00 +0000
|
||||
";
|
||||
std::fs::write(&path, content).unwrap();
|
||||
|
||||
let entry = parse_changelog_entry(&path).unwrap();
|
||||
assert_eq!(entry.source, "pkh-hello");
|
||||
assert_eq!(entry.version.full(), "0.1");
|
||||
assert_eq!(entry.distribution, "unstable");
|
||||
assert_eq!(entry.urgency, "medium");
|
||||
assert!(!entry.binary_only);
|
||||
assert_eq!(entry.maintainer_name, "Pkh Tester");
|
||||
assert_eq!(entry.maintainer_email, "pkh@example.com");
|
||||
assert_eq!(entry.timestamp, 1787392800);
|
||||
assert_eq!(
|
||||
entry.changes_field,
|
||||
"\npkh-hello (0.1) unstable; urgency=medium\n.\n * Initial release.\n * Second change line."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_bin_nmu() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("changelog");
|
||||
let content = "\
|
||||
pkg (1.0-1+b1) unstable; urgency=medium, binary-only=yes
|
||||
|
||||
* Binary-only non-maintainer upload.
|
||||
-- Builder <b@example.com> Mon, 01 Jan 2024 00:00:00 +0000
|
||||
";
|
||||
std::fs::write(&path, content).unwrap();
|
||||
|
||||
let entry = parse_changelog_entry(&path).unwrap();
|
||||
assert!(entry.binary_only);
|
||||
assert_eq!(entry.version.full(), "1.0-1+b1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_parsing() {
|
||||
let ci = ControlInfo::from_str(
|
||||
"Source: hello\nSection: utils\nPriority: optional\nMaintainer: A B <a@b.c>\nBuild-Depends: debhelper\n\nPackage: hello\nArchitecture: any\nDescription: test\n long\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ci.source_name(), "hello");
|
||||
assert_eq!(ci.section(), "utils");
|
||||
assert_eq!(ci.priority(), "optional");
|
||||
assert_eq!(ci.binaries.len(), 1);
|
||||
assert_eq!(ci.binaries[0].get("Package"), Some("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_defaults() {
|
||||
let ci = ControlInfo::from_str("Source: x\n\nPackage: x\nDescription: d\n").unwrap();
|
||||
assert_eq!(ci.section(), "-");
|
||||
assert_eq!(ci.priority(), "-");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Native Debian source-package build pipeline.
|
||||
//!
|
||||
//! Re-implements the orchestration performed by `dpkg-buildpackage -S`
|
||||
//! (environment setup, `dpkg-source` lifecycle, `.buildinfo` / `.changes`
|
||||
//! generation and OpenPGP signing) natively in Rust, while delegating the
|
||||
//! source-tree work (tarballs, diffs, patches) to `dpkg-source` as a
|
||||
//! subprocess.
|
||||
|
||||
pub mod buildinfo;
|
||||
pub mod buildtype;
|
||||
pub mod changes;
|
||||
pub mod checksums;
|
||||
pub mod control;
|
||||
pub mod env;
|
||||
pub mod files;
|
||||
pub mod metadata;
|
||||
pub mod sign;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::error::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use checksums::{Entry as ChecksumEntry, FileChecksums};
|
||||
use control::parse_paragraphs;
|
||||
use files::{FilesEntry, FilesList};
|
||||
use metadata::ControlInfo;
|
||||
|
||||
/// Options for a native source-package build.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SourceBuildOptions {
|
||||
/// Explicit signing key id / fingerprint (`-k`). When unset, a secret
|
||||
/// key matching the changelog maintainer email is searched.
|
||||
pub sign_keyid: Option<String>,
|
||||
/// Sign even for an UNRELEASED changelog (`--force-sign`).
|
||||
pub force_sign: bool,
|
||||
}
|
||||
|
||||
/// Artifacts produced by a successful source build.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SourceBuildOutput {
|
||||
/// The generated `.dsc`.
|
||||
pub dsc: PathBuf,
|
||||
/// The generated `.buildinfo`.
|
||||
pub buildinfo: PathBuf,
|
||||
/// The generated `.changes`.
|
||||
pub changes: PathBuf,
|
||||
/// Source tarballs referenced by the `.dsc` (orig, debian tar...).
|
||||
pub tarballs: Vec<PathBuf>,
|
||||
/// Whether all artifacts were signed.
|
||||
pub signed: bool,
|
||||
}
|
||||
|
||||
/// Build a Debian source package (to a .dsc) using the native pipeline.
|
||||
///
|
||||
/// Keeps the historical pkh entry-point signature; see [`run_source_build`]
|
||||
/// for the configurable version.
|
||||
pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box<dyn Error>> {
|
||||
let cwd = cwd.unwrap_or_else(|| Path::new("."));
|
||||
let output = run_source_build(cwd, &SourceBuildOptions::default())?;
|
||||
|
||||
if output.signed {
|
||||
println!("Package built and signed successfully!");
|
||||
} else {
|
||||
println!("Package built successfully (unsigned).");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the full native source-build pipeline in `cwd`.
|
||||
///
|
||||
/// Steps (mirroring `dpkg-buildpackage -S -I -i -nc -d`):
|
||||
/// 1. sanity checks and metadata resolution (changelog, control),
|
||||
/// 2. environment setup (`SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`, arch vars),
|
||||
/// 3. signing decision (key discovery, UNRELEASED handling),
|
||||
/// 4. `dpkg-source --before-build` then `dpkg-source -b`,
|
||||
/// 5. native `.buildinfo` generation (+ registration in `debian/files`),
|
||||
/// 6. native `.changes` generation,
|
||||
/// 7. `dpkg-source --after-build`,
|
||||
/// 8. signing cascade: dsc → buildinfo → changes, recomputing checksums of
|
||||
/// already-generated files at each step.
|
||||
pub fn run_source_build(
|
||||
cwd: &Path,
|
||||
opts: &SourceBuildOptions,
|
||||
) -> Result<SourceBuildOutput, Box<dyn Error>> {
|
||||
// ------------------------------------------------------------------
|
||||
// 1. Sanity checks
|
||||
// ------------------------------------------------------------------
|
||||
let parent = cwd
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.ok_or_else(|| format!("cannot determine output directory from '{}'", cwd.display()))?
|
||||
.to_path_buf();
|
||||
|
||||
let rules_path = cwd.join("debian/rules");
|
||||
if !rules_path.exists() {
|
||||
return Err(format!(
|
||||
"'{}' not found: '{}' does not look like a Debian source tree",
|
||||
rules_path.display(),
|
||||
cwd.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let changelog_path = cwd.join("debian/changelog");
|
||||
let control_path = cwd.join("debian/control");
|
||||
if !changelog_path.exists() {
|
||||
return Err(format!("'{}' not found", changelog_path.display()).into());
|
||||
}
|
||||
if !control_path.exists() {
|
||||
return Err(format!("'{}' not found", control_path.display()).into());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. Metadata resolution
|
||||
// ------------------------------------------------------------------
|
||||
let entry = metadata::parse_changelog_entry(&changelog_path)?;
|
||||
let ctrl = ControlInfo::parse(&control_path)?;
|
||||
|
||||
log::info!("source package {}", entry.source);
|
||||
log::info!("source version {}", entry.version.full());
|
||||
log::info!("source distribution {}", entry.distribution);
|
||||
|
||||
let sversion = entry.version.no_epoch();
|
||||
let dsc_name = format!("{}_{}.dsc", entry.source, sversion);
|
||||
let dsc_path = parent.join(&dsc_name);
|
||||
let buildinfo_name = format!("{}_{}_source.buildinfo", entry.source, sversion);
|
||||
let buildinfo_path = parent.join(&buildinfo_name);
|
||||
let changes_name = format!("{}_{}_source.changes", entry.source, sversion);
|
||||
let changes_path = parent.join(&changes_name);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. Environment setup
|
||||
// ------------------------------------------------------------------
|
||||
let vendor = env::current_vendor();
|
||||
let profiles = env::resolve_build_profiles(&[], &vendor);
|
||||
let mut pipeline_env = env::build_env(entry.timestamp, env::num_parallel(), &profiles);
|
||||
|
||||
let arch_vars = env::arch_env(None)?;
|
||||
pipeline_env.extend(arch_vars.clone());
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. Signing decision
|
||||
// ------------------------------------------------------------------
|
||||
let mut signing_key = opts.sign_keyid.clone();
|
||||
if signing_key.is_none() {
|
||||
match crate::utils::gpg::find_signing_key_for_email(&entry.maintainer_email) {
|
||||
Ok(Some(key)) => {
|
||||
log::info!("using GPG key {} for signing", key);
|
||||
signing_key = Some(key);
|
||||
}
|
||||
Ok(None) => {
|
||||
log::warn!(
|
||||
"no GPG secret key found for {} <{}>, building without signing",
|
||||
entry.maintainer_name,
|
||||
entry.maintainer_email
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("failed to check for GPG key: {}, building without signing", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let do_sign = match &signing_key {
|
||||
None => false,
|
||||
Some(_) if entry.distribution == "UNRELEASED" && !opts.force_sign => {
|
||||
log::warn!("not signing UNRELEASED build; use force_sign to override");
|
||||
false
|
||||
}
|
||||
Some(_) => true,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5. dpkg-source lifecycle: before-build + source build
|
||||
// ------------------------------------------------------------------
|
||||
run_command(cwd, "dpkg-source", &["-I", "-i", "--before-build", "."], &pipeline_env)?;
|
||||
run_command(cwd, "dpkg-source", &["-I", "-i", "-b", "."], &pipeline_env)?;
|
||||
|
||||
if !dsc_path.exists() {
|
||||
return Err(format!(
|
||||
"dpkg-source did not produce the expected '{}'",
|
||||
dsc_path.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 6. .buildinfo generation (native dpkg-genbuildinfo equivalent)
|
||||
// ------------------------------------------------------------------
|
||||
let mut checksums = FileChecksums::new();
|
||||
checksums.add_file_as(&dsc_path, &dsc_name)?;
|
||||
|
||||
let status_path = PathBuf::from("/var/lib/dpkg/status");
|
||||
let bd_fields = [ctrl.source.get("Build-Depends").unwrap_or("")];
|
||||
let installed_bd = buildinfo::installed_build_depends(&status_path, &bd_fields)?;
|
||||
let environment = env::buildinfo_environment(&pipeline_env);
|
||||
|
||||
let render_buildinfo_doc = |checksums: &FileChecksums| {
|
||||
buildinfo::render_buildinfo(&buildinfo::BuildInfoInput {
|
||||
source: entry.source.clone(),
|
||||
binaries: Vec::new(), // source-only build
|
||||
architecture: "source".to_string(),
|
||||
version: entry.version.full(),
|
||||
binary_only_changes: None,
|
||||
build_origin: vendor.clone(),
|
||||
build_architecture: arch_vars
|
||||
.get("DEB_BUILD_ARCH")
|
||||
.cloned()
|
||||
.unwrap_or_else(crate::get_current_arch),
|
||||
build_date: chrono::Local::now().to_rfc2822(),
|
||||
checksums: checksums.clone(),
|
||||
installed_build_depends: installed_bd.clone(),
|
||||
environment: environment.clone(),
|
||||
})
|
||||
};
|
||||
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
|
||||
|
||||
// Register the .buildinfo in debian/files (as dpkg-genbuildinfo does).
|
||||
let files_path = cwd.join("debian/files");
|
||||
let mut files_list = FilesList::load(&files_path)?;
|
||||
files_list.retain(|e| {
|
||||
!(e.package.as_deref() == Some(entry.source.as_str())
|
||||
&& e.package_type.as_deref() == Some("buildinfo"))
|
||||
});
|
||||
files_list.add(FilesEntry::new(
|
||||
&buildinfo_name,
|
||||
ctrl.section(),
|
||||
ctrl.priority(),
|
||||
));
|
||||
files_list.save_atomic(&files_path)?;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 7. .changes generation (native dpkg-genchanges equivalent)
|
||||
// ------------------------------------------------------------------
|
||||
// Pull the tarball checksums out of the generated .dsc so they are
|
||||
// distributed through the .changes like dpkg-genchanges does, in the
|
||||
// order the .dsc itself lists them.
|
||||
let dsc_content = std::fs::read_to_string(&dsc_path)
|
||||
.map_err(|e| format!("cannot read '{}': {}", dsc_path.display(), e))?;
|
||||
let dsc_para = parse_paragraphs(&dsc_content)
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| format!("'{}' is empty", dsc_path.display()))?;
|
||||
|
||||
let mut tarball_paths = Vec::new();
|
||||
let mut dsc_file_names: Vec<String> = Vec::new();
|
||||
let mut dsc_files: HashMap<String, PartialChecksum> = HashMap::new();
|
||||
for field in ["Checksums-Sha1", "Checksums-Sha256"] {
|
||||
if let Some(value) = dsc_para.get(field) {
|
||||
for line in value.lines() {
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.len() != 3 {
|
||||
continue;
|
||||
}
|
||||
if !dsc_files.contains_key(tokens[2]) {
|
||||
dsc_file_names.push(tokens[2].to_string());
|
||||
}
|
||||
let slot = dsc_files.entry(tokens[2].to_string()).or_default();
|
||||
match field {
|
||||
"Checksums-Sha1" => slot.sha1 = Some(tokens[0].to_string()),
|
||||
_ => slot.sha256 = Some(tokens[0].to_string()),
|
||||
}
|
||||
slot.size = tokens[1].parse().ok().or(slot.size);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(files_value) = dsc_para.get("Files") {
|
||||
for line in files_value.lines() {
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.len() >= 3 {
|
||||
let slot = dsc_files.entry(tokens[2].to_string()).or_default();
|
||||
slot.md5 = Some(tokens[0].to_string());
|
||||
slot.size = tokens[1].parse().ok().or(slot.size);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in &dsc_file_names {
|
||||
if name == &dsc_name {
|
||||
continue; // already computed directly above
|
||||
}
|
||||
let path = parent.join(name);
|
||||
if !path.exists() {
|
||||
return Err(format!(
|
||||
"file '{}' referenced by '{}' is missing",
|
||||
name,
|
||||
dsc_path.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let partial = &dsc_files[name];
|
||||
checksums.insert_entry(
|
||||
name,
|
||||
ChecksumEntry {
|
||||
size: partial.size.unwrap_or(0),
|
||||
md5: partial.md5.clone().unwrap_or_default(),
|
||||
sha1: partial.sha1.clone().unwrap_or_default(),
|
||||
sha256: partial.sha256.clone().unwrap_or_default(),
|
||||
},
|
||||
);
|
||||
tarball_paths.push(path);
|
||||
}
|
||||
|
||||
// The .buildinfo itself is distributed through the .changes, last (as
|
||||
// dpkg-genchanges does when it consumes debian/files).
|
||||
checksums.add_file_as(&buildinfo_path, &buildinfo_name)?;
|
||||
|
||||
// The .changes lists section/priority for every distributed file; the
|
||||
// dsc and tarballs use the source stanza defaults (not persisted into
|
||||
// debian/files, matching dpkg).
|
||||
let mut changes_files = files_list.clone();
|
||||
changes_files.add(FilesEntry::new(&dsc_name, ctrl.section(), ctrl.priority()));
|
||||
for name in &dsc_file_names {
|
||||
if name != &dsc_name {
|
||||
changes_files.add(FilesEntry::new(name, ctrl.section(), ctrl.priority()));
|
||||
}
|
||||
}
|
||||
|
||||
let changed_by = format!("{} <{}>", entry.maintainer_name, entry.maintainer_email);
|
||||
let render_changes_doc = |checksums: &FileChecksums| {
|
||||
changes::render_changes(&changes::ChangesInput {
|
||||
date: entry.date_raw.clone(),
|
||||
source: entry.source.clone(),
|
||||
binaries: Vec::new(), // source-only upload
|
||||
built_for_profiles: profiles.clone(),
|
||||
architecture: "source".to_string(),
|
||||
version: entry.version.full(),
|
||||
distribution: entry.distribution.clone(),
|
||||
urgency: entry.urgency.clone(),
|
||||
maintainer: ctrl.source.get("Maintainer").map(str::to_string),
|
||||
changed_by: Some(changed_by.clone()),
|
||||
descriptions: Vec::new(),
|
||||
changes_field: entry.changes_field.clone(),
|
||||
checksums: checksums.clone(),
|
||||
files_list: changes_files.clone(),
|
||||
})
|
||||
};
|
||||
changes::save_changes(&changes_path, &render_changes_doc(&checksums))?;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 8. dpkg-source after-build (unapplies quilt patches it applied)
|
||||
// ------------------------------------------------------------------
|
||||
run_command(cwd, "dpkg-source", &["-I", "-i", "--after-build", "."], &pipeline_env)?;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 9. Signing cascade: dsc -> buildinfo -> changes
|
||||
// ------------------------------------------------------------------
|
||||
let mut signed = false;
|
||||
if let Some(keyid) = signing_key.filter(|_| do_sign) {
|
||||
sign::validate_key_id(&keyid)?;
|
||||
|
||||
println!("signfile {}", dsc_name);
|
||||
sign::clearsign_file(&dsc_path, &keyid)?;
|
||||
// The .dsc changed: refresh its checksums inside the .buildinfo.
|
||||
checksums.add_file_as(&dsc_path, &dsc_name)?;
|
||||
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
|
||||
|
||||
println!("signfile {}", buildinfo_name);
|
||||
sign::clearsign_file(&buildinfo_path, &keyid)?;
|
||||
// Both .dsc and .buildinfo changed: refresh the .changes.
|
||||
checksums.add_file_as(&buildinfo_path, &buildinfo_name)?;
|
||||
changes::save_changes(&changes_path, &render_changes_doc(&checksums))?;
|
||||
|
||||
println!("signfile {}", changes_name);
|
||||
sign::clearsign_file(&changes_path, &keyid)?;
|
||||
|
||||
signed = true;
|
||||
}
|
||||
|
||||
Ok(SourceBuildOutput {
|
||||
dsc: dsc_path,
|
||||
buildinfo: buildinfo_path,
|
||||
changes: changes_path,
|
||||
tarballs: tarball_paths,
|
||||
signed,
|
||||
})
|
||||
}
|
||||
|
||||
/// A partially-known checksum entry taken from a `.dsc` checksum field.
|
||||
#[derive(Debug, Default)]
|
||||
struct PartialChecksum {
|
||||
size: Option<u64>,
|
||||
md5: Option<String>,
|
||||
sha1: Option<String>,
|
||||
sha256: Option<String>,
|
||||
}
|
||||
|
||||
/// Run a build command in `cwd` with extra environment variables layered on
|
||||
/// top of the inherited environment, with stdio attached to the terminal.
|
||||
/// Returns an error on non-zero exit status.
|
||||
fn run_command(
|
||||
cwd: &Path,
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
env: &BTreeMap<String, String>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
log::debug!(
|
||||
"running: {} {} (in {})",
|
||||
program,
|
||||
args.join(" "),
|
||||
cwd.display()
|
||||
);
|
||||
let status = Command::new(program)
|
||||
.current_dir(cwd)
|
||||
.envs(env)
|
||||
.args(args)
|
||||
.status()
|
||||
.map_err(|e| format!("failed to run '{}': {}", program, e))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(format!(
|
||||
"'{} {}' failed with status: {}",
|
||||
program,
|
||||
args.join(" "),
|
||||
status
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Re-export commonly used types at the module root.
|
||||
pub use metadata::{ChangelogEntry, DebianVersion};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buildtype::BuildType;
|
||||
|
||||
#[test]
|
||||
fn partial_checksum_defaults() {
|
||||
let p = PartialChecksum::default();
|
||||
assert!(p.size.is_none());
|
||||
assert!(p.md5.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debian_version_reexport_usable() {
|
||||
let v = DebianVersion::parse("1.0-2").unwrap();
|
||||
assert_eq!(v.no_epoch(), "1.0-2");
|
||||
}
|
||||
|
||||
// The full pipeline is exercised end-to-end by running pkh against a
|
||||
// fixture package; unit tests cover the individual stages above.
|
||||
#[test]
|
||||
fn build_type_source_only_pipeline_mapping() {
|
||||
// Source-only builds always map to the 'source' artifact suffix.
|
||||
assert_eq!(buildtype::SOURCE.arch_suffix("amd64"), "source");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! OpenPGP inline (clear) signing of `.dsc`, `.buildinfo` and `.changes`
|
||||
//! files through `gpgme`, mirroring what `dpkg-buildpackage` does via its
|
||||
//! OpenPGP backends.
|
||||
|
||||
use std::error::Error;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
use gpgme::{Context, Data, Protocol};
|
||||
|
||||
/// Validate an OpenPGP key id / fingerprint like dpkg does.
|
||||
///
|
||||
/// Short (<= 8 hex chars) key IDs are rejected outright, long (16 hex chars)
|
||||
/// key IDs produce a warning; anything else must be a v4 (40) or v6 (64)
|
||||
/// fingerprint length.
|
||||
pub fn validate_key_id(keyid: &str) -> Result<(), Box<dyn Error>> {
|
||||
let len = keyid.len();
|
||||
if len <= 8 {
|
||||
return Err(
|
||||
"short OpenPGP key IDs are broken; use a key fingerprint instead".into(),
|
||||
);
|
||||
} else if len <= 16 {
|
||||
log::warn!(
|
||||
"long OpenPGP key IDs are strongly discouraged; \
|
||||
use a key fingerprint instead"
|
||||
);
|
||||
} else if len != 40 && len != 64 {
|
||||
log::warn!("OpenPGP key ID has unknown v4 or v6 fingerprint length");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find a secret key whose fingerprint matches `keyid` (suffix matching
|
||||
/// allows passing a long key id instead of the full fingerprint).
|
||||
fn find_secret_key(ctx: &mut Context, keyid: &str) -> Result<Option<gpgme::Key>, Box<dyn Error>> {
|
||||
for key_result in ctx.secret_keys()? {
|
||||
let key = key_result?;
|
||||
if let Ok(fingerprint) = key.fingerprint() {
|
||||
if fingerprint.ends_with(keyid) {
|
||||
return Ok(Some(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Clearsign a file in place: the original content becomes the payload of an
|
||||
/// armored inline-signed document which atomically replaces the file.
|
||||
///
|
||||
/// This is the same operation as dpkg's `inline_sign` + rename sequence.
|
||||
pub fn clearsign_file(path: &Path, keyid: &str) -> Result<(), Box<dyn Error>> {
|
||||
let content = std::fs::read(path)
|
||||
.map_err(|e| format!("cannot read '{}' for signing: {}", path.display(), e))?;
|
||||
|
||||
let mut ctx = Context::from_protocol(Protocol::OpenPgp)
|
||||
.map_err(|e| format!("cannot initialize GPGME: {}", e))?;
|
||||
ctx.set_armor(true);
|
||||
|
||||
let key = find_secret_key(&mut ctx, keyid)?
|
||||
.ok_or_else(|| format!("no secret key matching '{}' found", keyid))?;
|
||||
|
||||
ctx.add_signer(&key)
|
||||
.map_err(|e| format!("cannot add signer '{}': {}", keyid, e))?;
|
||||
|
||||
let input = Data::from_bytes(&content)?;
|
||||
let mut output = Data::new()?;
|
||||
ctx.sign_clear(input, &mut output)
|
||||
.map_err(|e| format!("clear-signing '{}' failed: {}", path.display(), e))?;
|
||||
|
||||
// gpgme leaves the output buffer cursor at the end after writing.
|
||||
output.seek(SeekFrom::Start(0))?;
|
||||
let mut signed = Vec::new();
|
||||
output.read_to_end(&mut signed)?;
|
||||
|
||||
// Atomic replace, like dpkg's signfile (write .asc then move).
|
||||
let tmp = path.with_extension("asc.tmp");
|
||||
std::fs::write(&tmp, &signed)
|
||||
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
|
||||
std::fs::rename(&tmp, path)
|
||||
.map_err(|e| format!("cannot install signed '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn key_id_validation() {
|
||||
assert!(validate_key_id("12345678").is_err()); // short: rejected
|
||||
assert!(validate_key_id("1234567890ABCDEF").is_ok()); // long: warns
|
||||
assert!(validate_key_id(&"a".repeat(40)).is_ok());
|
||||
assert!(validate_key_id(&"b".repeat(64)).is_ok());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user