Compare commits
11
Commits
e5adf600c3
...
9f47e7dae8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f47e7dae8
|
||
|
|
c9b48d4573 | ||
|
|
429429e414 | ||
|
|
42fcfc2dfa | ||
|
|
dfaab0606a | ||
|
|
489b2aa29b | ||
|
|
4c52336000 | ||
|
|
0ad020ae19
|
||
|
|
4a8ff9ac0d
|
||
|
|
c2e1288bc5
|
||
|
|
9d2519ed7b
|
@@ -18,6 +18,7 @@ regex = "1"
|
||||
chrono = "0.4"
|
||||
tokio = { version = "1.41.1", features = ["full"] }
|
||||
sha2 = "0.10.8"
|
||||
sha1 = "0.10"
|
||||
md-5 = "0.10"
|
||||
hex = "0.4.3"
|
||||
log = "0.4.28"
|
||||
|
||||
@@ -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,475 @@
|
||||
//! Binary-build metadata generation: native `.buildinfo` / `.changes`
|
||||
//! production for binary-only builds (`pkh deb`), the equivalent of
|
||||
//! `dpkg-genbuildinfo -b` + `dpkg-genchanges -b`.
|
||||
//!
|
||||
//! All tree/database access goes through a [`Context`] so the generation can
|
||||
//! run against a build tree living in a local directory, an ephemeral
|
||||
//! chroot or a remote host. Artifact digests are computed inside the context
|
||||
//! with coreutils (`md5sum`, `sha1sum`, `sha256sum`, `stat`), keeping the
|
||||
//! flow binary-safe regardless of the transport.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::error::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::debian::{
|
||||
ChecksumEntry, ControlInfo, FileChecksums, FilesList, parse_changelog_entry_from_str,
|
||||
};
|
||||
|
||||
/// Digests of one artifact.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ArtifactHashes {
|
||||
size: u64,
|
||||
md5: String,
|
||||
sha1: String,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
/// Options driving binary metadata generation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BinaryMetadataOptions {
|
||||
/// Active build profiles (`Built-For-Profiles`).
|
||||
pub profiles: Vec<String>,
|
||||
/// Vendor name (`Build-Origin`).
|
||||
pub vendor: String,
|
||||
/// Parallel job count advertised in `DEB_BUILD_OPTIONS`.
|
||||
pub parallel: usize,
|
||||
/// Reproducible-builds epoch exported to the build.
|
||||
pub source_date_epoch: i64,
|
||||
/// Build architecture (the machine inside the build context).
|
||||
pub build_arch: String,
|
||||
/// Host architecture (the packages' target); equals the build
|
||||
/// architecture except for cross builds.
|
||||
pub host_arch: String,
|
||||
}
|
||||
|
||||
/// Generate `<pkg>_<ver>_<arch>.buildinfo` and `.changes` for a finished
|
||||
/// binary build, consuming `debian/files` from `package_dir` and the
|
||||
/// artifacts sitting in `upload_dir`. Returns both paths (inside the
|
||||
/// context).
|
||||
///
|
||||
/// Mirrors the observable behavior of `dpkg-genbuildinfo -b` and
|
||||
/// `dpkg-genchanges -b`: sorted `Binary` list, encounter-order `Architecture`
|
||||
/// accumulation, sorted `Description` lines formatted like dpkg, `.buildinfo`
|
||||
/// registration in `debian/files`, and binary-NMU handling (`Source:
|
||||
/// pkg (prev)` + previous `.dsc` redistribution when present).
|
||||
pub fn generate_binary_metadata(
|
||||
ctx: &Arc<Context>,
|
||||
package_dir: &Path,
|
||||
upload_dir: &Path,
|
||||
opts: &BinaryMetadataOptions,
|
||||
) -> Result<(PathBuf, PathBuf), Box<dyn Error>> {
|
||||
// ------------------------------------------------------------------
|
||||
// Metadata sources inside the context
|
||||
// ------------------------------------------------------------------
|
||||
let changelog_content = ctx.read_file(&package_dir.join("debian/changelog"))?;
|
||||
let entry = parse_changelog_entry_from_str(&changelog_content)?;
|
||||
|
||||
let control_content = ctx.read_file(&package_dir.join("debian/control"))?;
|
||||
let control = ControlInfo::parse_content(&control_content)?;
|
||||
|
||||
let files_content = ctx
|
||||
.read_file(&package_dir.join("debian/files"))
|
||||
.unwrap_or_default();
|
||||
let mut files_list = FilesList::parse(&files_content)?;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Collect binary artifacts registered in debian/files
|
||||
// ------------------------------------------------------------------
|
||||
let artifact_names: Vec<String> = files_list
|
||||
.iter()
|
||||
.filter(|e| matches!(e.package_type.as_deref(), Some("deb") | Some("udeb")))
|
||||
.map(|e| e.filename.clone())
|
||||
.collect();
|
||||
|
||||
if artifact_names.is_empty() {
|
||||
return Err("binary build with no binary artifacts found; cannot distribute".into());
|
||||
}
|
||||
|
||||
let mut hashes = hashes_in_context(ctx, upload_dir, &artifact_names)?;
|
||||
|
||||
let mut checksums = FileChecksums::new();
|
||||
let mut arch_values: Vec<String> = Vec::new();
|
||||
let mut arch_seen = std::collections::HashSet::new();
|
||||
for name in &artifact_names {
|
||||
let entry_hashes = hashes
|
||||
.remove(name)
|
||||
.ok_or_else(|| format!("artifact '{name}' listed in debian/files but not found"))?;
|
||||
checksums.insert_entry(
|
||||
name,
|
||||
ChecksumEntry {
|
||||
size: entry_hashes.size,
|
||||
md5: entry_hashes.md5,
|
||||
sha1: entry_hashes.sha1,
|
||||
sha256: entry_hashes.sha256,
|
||||
},
|
||||
);
|
||||
// Architecture accumulation in encounter order (dpkg-genchanges).
|
||||
if let Some(file_entry) = files_list.get(name)
|
||||
&& let Some(arch) = file_entry
|
||||
.arch
|
||||
.as_ref()
|
||||
.or_else(|| file_entry.attrs.get("architecture"))
|
||||
&& arch_seen.insert(arch.clone())
|
||||
{
|
||||
arch_values.push(arch.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Binary-NMU: redistribute the previous source when present
|
||||
// ------------------------------------------------------------------
|
||||
let sversion = entry.version.no_epoch();
|
||||
let mut source_display = entry.source.clone();
|
||||
let mut binary_only_changes = None;
|
||||
|
||||
if entry.binary_only
|
||||
&& let Ok(prev_entry) = crate::debian::changelog::parse_previous_version_from_str(
|
||||
&ctx.read_file(&package_dir.join("debian/changelog"))?,
|
||||
)
|
||||
&& let Some(prev) = prev_entry
|
||||
{
|
||||
source_display = format!("{} ({})", entry.source, prev);
|
||||
binary_only_changes = Some(format!(
|
||||
"{}\n\n -- {} <{}> {}",
|
||||
entry.changes_field, entry.maintainer_name, entry.maintainer_email, entry.date_raw
|
||||
));
|
||||
let prev_version = crate::debian::DebianVersion::parse(&prev)?;
|
||||
let dsc_name = format!("{}_{}.dsc", entry.source, prev_version.no_epoch());
|
||||
let dsc_path = upload_dir.join(&dsc_name);
|
||||
if ctx.exists(&dsc_path)? {
|
||||
include_dsc_artifacts(ctx, upload_dir, &dsc_name, &mut checksums)?;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Binary package names and descriptions
|
||||
// ------------------------------------------------------------------
|
||||
let mut binaries: Vec<String> = Vec::new();
|
||||
for name in &artifact_names {
|
||||
if let Some(e) = files_list.get(name)
|
||||
&& let Some(pkg) = &e.package
|
||||
&& !binaries.contains(pkg)
|
||||
{
|
||||
binaries.push(pkg.clone());
|
||||
}
|
||||
}
|
||||
binaries.sort();
|
||||
|
||||
// Description lines: first line of each binary stanza's Description,
|
||||
// formatted exactly like dpkg-genchanges, sorted.
|
||||
let mut descriptions = Vec::new();
|
||||
for stanza in &control.binaries {
|
||||
let Some(pkg) = stanza.get("Package") else {
|
||||
continue;
|
||||
};
|
||||
if !binaries.contains(&pkg.to_string()) {
|
||||
continue;
|
||||
}
|
||||
let summary = stanza
|
||||
.get("Description")
|
||||
.unwrap_or("no description available")
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("no description available");
|
||||
// Package-Type overrides the artifact-derived type (deb default).
|
||||
let pkg_type = stanza
|
||||
.get("Package-Type")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
files_list
|
||||
.iter()
|
||||
.find(|f| f.package.as_deref() == Some(pkg))
|
||||
.and_then(|f| f.package_type.clone())
|
||||
.unwrap_or_else(|| "deb".to_string())
|
||||
});
|
||||
descriptions.push(crate::build::changes::format_description(
|
||||
pkg, &pkg_type, summary,
|
||||
));
|
||||
}
|
||||
descriptions.sort();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Installed-Build-Depends closure over the context status database
|
||||
// ------------------------------------------------------------------
|
||||
let status_content = ctx
|
||||
.read_file(Path::new("/var/lib/dpkg/status"))
|
||||
.unwrap_or_default();
|
||||
let bd_fields = [
|
||||
control.source.get("Build-Depends").unwrap_or(""),
|
||||
control.source.get("Build-Depends-Arch").unwrap_or(""),
|
||||
control.source.get("Build-Depends-Indep").unwrap_or(""),
|
||||
];
|
||||
let installed_build_depends =
|
||||
crate::build::buildinfo::installed_build_depends_from_content(&status_content, &bd_fields)?;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// .buildinfo generation, then registration in debian/files
|
||||
// ------------------------------------------------------------------
|
||||
let pipeline_env = pipeline_environment(opts);
|
||||
let environment = crate::build::env::buildinfo_environment(&pipeline_env);
|
||||
|
||||
// dpkg-genbuildinfo sorts the accumulated architecture values, while
|
||||
// dpkg-genchanges keeps encounter order.
|
||||
let mut buildinfo_arch_values = arch_values.clone();
|
||||
buildinfo_arch_values.sort();
|
||||
|
||||
let buildinfo_name = format!("{}_{}_{}.buildinfo", entry.source, sversion, opts.host_arch);
|
||||
let buildinfo_doc =
|
||||
crate::build::buildinfo::render_buildinfo(&crate::build::buildinfo::BuildInfoInput {
|
||||
source: source_display.clone(),
|
||||
binaries: binaries.clone(),
|
||||
architecture: buildinfo_arch_values.join(" "),
|
||||
version: entry.version.full(),
|
||||
binary_only_changes: binary_only_changes.clone(),
|
||||
build_origin: opts.vendor.clone(),
|
||||
build_architecture: opts.build_arch.clone(),
|
||||
build_date: chrono::Local::now().to_rfc2822(),
|
||||
checksums: checksums.clone(),
|
||||
installed_build_depends,
|
||||
environment,
|
||||
});
|
||||
let buildinfo_path = upload_dir.join(&buildinfo_name);
|
||||
ctx.write_file(
|
||||
&buildinfo_path,
|
||||
&crate::debian::control::write_paragraph(&buildinfo_doc),
|
||||
)?;
|
||||
|
||||
// Register the .buildinfo in debian/files, like dpkg-genbuildinfo does,
|
||||
// so the .changes distributes it.
|
||||
files_list.add(crate::debian::FilesEntry::new(
|
||||
&buildinfo_name,
|
||||
control.section(),
|
||||
control.priority(),
|
||||
));
|
||||
ctx.write_file(&package_dir.join("debian/files"), &files_list.render())?;
|
||||
|
||||
// Hash the freshly written .buildinfo inside the context.
|
||||
let buildinfo_hashes =
|
||||
hashes_in_context(ctx, upload_dir, std::slice::from_ref(&buildinfo_name))?;
|
||||
if let Some(h) = buildinfo_hashes.get(&buildinfo_name) {
|
||||
checksums.insert_entry(
|
||||
&buildinfo_name,
|
||||
ChecksumEntry {
|
||||
size: h.size,
|
||||
md5: h.md5.clone(),
|
||||
sha1: h.sha1.clone(),
|
||||
sha256: h.sha256.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// .changes generation
|
||||
// ------------------------------------------------------------------
|
||||
let changes_name = format!("{}_{}_{}.changes", entry.source, sversion, opts.host_arch);
|
||||
let changed_by = format!("{} <{}>", entry.maintainer_name, entry.maintainer_email);
|
||||
let changes_doc = crate::build::changes::render_changes(&crate::build::changes::ChangesInput {
|
||||
date: entry.date_raw.clone(),
|
||||
source: source_display,
|
||||
binaries,
|
||||
built_for_profiles: opts.profiles.clone(),
|
||||
architecture: arch_values.join(" "),
|
||||
version: entry.version.full(),
|
||||
distribution: entry.distribution.clone(),
|
||||
urgency: entry.urgency.clone(),
|
||||
maintainer: control.source.get("Maintainer").map(str::to_string),
|
||||
changed_by: Some(changed_by),
|
||||
descriptions,
|
||||
closes: entry.closes.clone(),
|
||||
changes_field: entry.changes_field.clone(),
|
||||
checksums,
|
||||
files_list,
|
||||
});
|
||||
let changes_path = upload_dir.join(&changes_name);
|
||||
ctx.write_file(
|
||||
&changes_path,
|
||||
&crate::debian::control::write_paragraph(&changes_doc),
|
||||
)?;
|
||||
|
||||
Ok((buildinfo_path, changes_path))
|
||||
}
|
||||
|
||||
/// Environment exported to the build steps; recorded (filtered) in the
|
||||
/// `.buildinfo` `Environment` field.
|
||||
fn pipeline_environment(opts: &BinaryMetadataOptions) -> BTreeMap<String, String> {
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert(
|
||||
"SOURCE_DATE_EPOCH".to_string(),
|
||||
opts.source_date_epoch.to_string(),
|
||||
);
|
||||
env.insert(
|
||||
"DEB_BUILD_OPTIONS".to_string(),
|
||||
format!("parallel={}", opts.parallel),
|
||||
);
|
||||
if !opts.profiles.is_empty() {
|
||||
env.insert("DEB_BUILD_PROFILES".to_string(), opts.profiles.join(","));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// Compute md5/sha1/sha256 digests and sizes for the named files inside the
|
||||
/// context directory `dir`, using coreutils.
|
||||
fn hashes_in_context(
|
||||
ctx: &Arc<Context>,
|
||||
dir: &Path,
|
||||
names: &[String],
|
||||
) -> Result<BTreeMap<String, ArtifactHashes>, Box<dyn Error>> {
|
||||
let mut out: BTreeMap<String, ArtifactHashes> = names
|
||||
.iter()
|
||||
.map(|n| (n.clone(), ArtifactHashes::default()))
|
||||
.collect();
|
||||
|
||||
// Sizes.
|
||||
let output = ctx
|
||||
.command("stat")
|
||||
.current_dir(dir)
|
||||
.arg("-c")
|
||||
.arg("%s %n")
|
||||
.args(names)
|
||||
.output()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
let Some((size, name)) = line.trim().split_once(' ') else {
|
||||
continue;
|
||||
};
|
||||
if let Some(slot) = out.get_mut(name) {
|
||||
slot.size = size.parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Digests.
|
||||
for (tool, field) in [
|
||||
("md5sum", 0usize),
|
||||
("sha1sum", 1usize),
|
||||
("sha256sum", 2usize),
|
||||
] {
|
||||
let output = ctx
|
||||
.command(tool)
|
||||
.current_dir(dir)
|
||||
.args(names)
|
||||
.output()
|
||||
.map_err(|e| format!("failed to run '{tool}' inside the build context: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"'{tool}' failed inside the build context: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
let Some((digest, name)) = line.trim().split_once(" ") else {
|
||||
continue;
|
||||
};
|
||||
let name = name.trim_start_matches('*');
|
||||
if let Some(slot) = out.get_mut(name) {
|
||||
match field {
|
||||
0 => slot.md5 = digest.to_string(),
|
||||
1 => slot.sha1 = digest.to_string(),
|
||||
_ => slot.sha256 = digest.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Pull the `.dsc` checksums (and its referenced tarballs) into the
|
||||
/// checksum registry, mirroring how binary-NMU uploads redistribute the
|
||||
/// previous source.
|
||||
fn include_dsc_artifacts(
|
||||
ctx: &Arc<Context>,
|
||||
upload_dir: &Path,
|
||||
dsc_name: &str,
|
||||
checksums: &mut FileChecksums,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let dsc_content = ctx.read_file(&upload_dir.join(dsc_name))?;
|
||||
let para = crate::debian::control::parse_paragraphs(&dsc_content)
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| format!("'{dsc_name}' is empty"))?;
|
||||
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut partials: BTreeMap<String, PartialDscChecksums> = BTreeMap::new();
|
||||
for field in ["Checksums-Sha1", "Checksums-Sha256"] {
|
||||
if let Some(value) = para.get(field) {
|
||||
for line in value.lines() {
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.len() != 3 {
|
||||
continue;
|
||||
}
|
||||
let slot = partials.entry(tokens[2].to_string()).or_default();
|
||||
if field == "Checksums-Sha1" {
|
||||
slot.sha1 = Some(tokens[0].to_string());
|
||||
} else {
|
||||
slot.sha256 = Some(tokens[0].to_string());
|
||||
}
|
||||
slot.size = tokens[1].parse().ok().or(slot.size);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(files_value) = para.get("Files") {
|
||||
for line in files_value.lines() {
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.len() >= 3 {
|
||||
let slot = partials.entry(tokens[2].to_string()).or_default();
|
||||
slot.md5 = Some(tokens[0].to_string());
|
||||
slot.size = tokens[1].parse().ok().or(slot.size);
|
||||
}
|
||||
}
|
||||
}
|
||||
for field in ["Checksums-Sha1", "Checksums-Sha256"] {
|
||||
if let Some(value) = para.get(field) {
|
||||
for line in value.lines() {
|
||||
if let Some(name) = line.split_whitespace().nth(2) {
|
||||
names.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The .dsc itself is hashed fresh (it may be signed/rewritten); the
|
||||
// tarballs reuse the .dsc-recorded digests, like dpkg-genchanges does.
|
||||
let dsc_hashes =
|
||||
hashes_in_context(ctx, upload_dir, std::slice::from_ref(&dsc_name.to_string()))?;
|
||||
if let Some(h) = dsc_hashes.get(dsc_name) {
|
||||
checksums.insert_entry(
|
||||
dsc_name,
|
||||
ChecksumEntry {
|
||||
size: h.size,
|
||||
md5: h.md5.clone(),
|
||||
sha1: h.sha1.clone(),
|
||||
sha256: h.sha256.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
for name in &names {
|
||||
if name == dsc_name {
|
||||
continue;
|
||||
}
|
||||
let p = &partials[name];
|
||||
checksums.insert_entry(
|
||||
name,
|
||||
ChecksumEntry {
|
||||
size: p.size.unwrap_or(0),
|
||||
md5: p.md5.clone().unwrap_or_default(),
|
||||
sha1: p.sha1.clone().unwrap_or_default(),
|
||||
sha256: p.sha256.clone().unwrap_or_default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Partially-known checksums taken from a `.dsc` checksum field.
|
||||
#[derive(Debug, Default)]
|
||||
struct PartialDscChecksums {
|
||||
size: Option<u64>,
|
||||
md5: Option<String>,
|
||||
sha1: Option<String>,
|
||||
sha256: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Native `.buildinfo` generation (Format 1.0), mirroring
|
||||
//! `dpkg-genbuildinfo`: artifact checksums, a snapshot of installed build
|
||||
//! dependencies and the sanitized build environment.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::debian::checksums::FileChecksums;
|
||||
use crate::debian::control::{Paragraph, parse_paragraphs, write_paragraph};
|
||||
|
||||
/// One installed package relevant for dependency resolution.
|
||||
#[derive(Debug, Clone)]
|
||||
struct InstalledPkg {
|
||||
version: String,
|
||||
arch: String,
|
||||
}
|
||||
|
||||
/// A snapshot of the dpkg status database, restricted to what the
|
||||
/// `Installed-Build-Depends` computation needs.
|
||||
#[derive(Debug, Default)]
|
||||
struct StatusDb {
|
||||
/// Installed packages grouped by name.
|
||||
pkgs: HashMap<String, Vec<InstalledPkg>>,
|
||||
/// Raw `Depends`/`Pre-Depends` strings keyed by `package:arch`.
|
||||
depends: HashMap<String, Vec<String>>,
|
||||
/// Names of installed essential packages.
|
||||
essential: Vec<String>,
|
||||
}
|
||||
|
||||
impl StatusDb {
|
||||
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 content = std::fs::read_to_string(status_path)
|
||||
.map_err(|e| format!("cannot read status file '{}': {}", status_path.display(), e))?;
|
||||
installed_build_depends_from_content(&content, build_depends_fields).map_err(|e| e.into())
|
||||
}
|
||||
|
||||
/// Compute the `Installed-Build-Depends` value from the textual content of a
|
||||
/// dpkg status database (used when the database lives in another context,
|
||||
/// e.g. inside a chroot).
|
||||
pub fn installed_build_depends_from_content(
|
||||
status_content: &str,
|
||||
build_depends_fields: &[&str],
|
||||
) -> Result<String, String> {
|
||||
let db = StatusDb::from_str(status_content);
|
||||
|
||||
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,163 @@
|
||||
//! Debian build types (`dpkg-buildpackage -b/-B/-A/-S/-g/-G/--build=...`)
|
||||
//! and their mapping to `debian/rules` targets.
|
||||
|
||||
/// Build type bit flags, mirroring `Dpkg::BuildTypes`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BuildType(u8);
|
||||
|
||||
/// Source build component (`-S`, `--build=source`).
|
||||
pub const SOURCE: BuildType = BuildType(0x1);
|
||||
/// Arch-dependent binary build component (`-B`, `--build=any`).
|
||||
pub const ARCH_DEP: BuildType = BuildType(0x2);
|
||||
/// Arch-independent binary build component (`-A`, `--build=all`).
|
||||
pub const ARCH_INDEP: BuildType = BuildType(0x4);
|
||||
|
||||
/// Any binary component.
|
||||
pub const BINARY: BuildType = BuildType(ARCH_DEP.0 | ARCH_INDEP.0);
|
||||
/// Normal full build: source + binaries (`-F`, default).
|
||||
pub const FULL: BuildType = BuildType(SOURCE.0 | BINARY.0);
|
||||
/// Source + arch-dependent (`-G`).
|
||||
pub const SOURCE_ARCH_DEP: BuildType = BuildType(SOURCE.0 | ARCH_DEP.0);
|
||||
/// Source + arch-indep (`-g`).
|
||||
pub const SOURCE_ARCH_INDEP: BuildType = BuildType(SOURCE.0 | ARCH_INDEP.0);
|
||||
|
||||
impl BuildType {
|
||||
/// Construct from raw bits.
|
||||
pub const fn from_bits(bits: u8) -> Self {
|
||||
BuildType(bits)
|
||||
}
|
||||
|
||||
/// Raw bits.
|
||||
pub const fn bits(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// True if any of `other`'s components are set.
|
||||
pub fn has_any(self, other: BuildType) -> bool {
|
||||
self.0 & other.0 != 0
|
||||
}
|
||||
|
||||
/// True if all of `other`'s components are set.
|
||||
pub fn has_all(self, other: BuildType) -> bool {
|
||||
self.0 & other.0 == other.0
|
||||
}
|
||||
|
||||
/// True if none of `other`'s components are set.
|
||||
pub fn has_none(self, other: BuildType) -> bool {
|
||||
self.0 & other.0 == 0
|
||||
}
|
||||
|
||||
/// Parse a comma-separated `--build=<type>[,...]` option value.
|
||||
///
|
||||
/// Valid components: `full`, `source`, `binary`, `any`, `all`.
|
||||
pub fn from_options(value: &str) -> Result<BuildType, String> {
|
||||
let mut result = BuildType(0);
|
||||
for part in value.split(',') {
|
||||
match part.trim() {
|
||||
"full" => result = FULL,
|
||||
"source" => result = BuildType(result.0 | SOURCE.0),
|
||||
"binary" => result = BuildType(result.0 | BINARY.0),
|
||||
"any" => result = BuildType(result.0 | ARCH_DEP.0),
|
||||
"all" => result = BuildType(result.0 | ARCH_INDEP.0),
|
||||
other => return Err(format!("unknown build type component '{}'", other)),
|
||||
}
|
||||
}
|
||||
if result.0 == 0 {
|
||||
return Err("empty build type".to_string());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Canonical comma-separated representation (as passed to
|
||||
/// `dpkg-genchanges --build=` / `dpkg-genbuildinfo --build=`).
|
||||
pub fn to_options(self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if self.has_any(SOURCE) {
|
||||
parts.push("source");
|
||||
}
|
||||
if self.has_all(BINARY) {
|
||||
parts.push("binary");
|
||||
} else {
|
||||
if self.has_any(ARCH_DEP) {
|
||||
parts.push("any");
|
||||
}
|
||||
if self.has_any(ARCH_INDEP) {
|
||||
parts.push("all");
|
||||
}
|
||||
}
|
||||
parts.join(",")
|
||||
}
|
||||
|
||||
/// The `debian/rules` build target for this type:
|
||||
/// `build`, `build-arch` or `build-indep`.
|
||||
pub fn build_target(self) -> &'static str {
|
||||
if self.has_all(BINARY) || self.has_none(BINARY) {
|
||||
"build"
|
||||
} else if self.has_any(ARCH_DEP) {
|
||||
"build-arch"
|
||||
} else {
|
||||
"build-indep"
|
||||
}
|
||||
}
|
||||
|
||||
/// The `debian/rules` binary target for this type:
|
||||
/// `binary`, `binary-arch` or `binary-indep`.
|
||||
pub fn binary_target(self) -> &'static str {
|
||||
if self.has_all(BINARY) || self.has_none(BINARY) {
|
||||
"binary"
|
||||
} else if self.has_any(ARCH_DEP) {
|
||||
"binary-arch"
|
||||
} else {
|
||||
"binary-indep"
|
||||
}
|
||||
}
|
||||
|
||||
/// The architecture suffix used in artifact file names:
|
||||
/// host arch, `all` or `source`.
|
||||
pub fn arch_suffix(self, host_arch: &str) -> &str {
|
||||
if self.has_any(ARCH_DEP) {
|
||||
host_arch
|
||||
} else if self.has_any(ARCH_INDEP) {
|
||||
"all"
|
||||
} else {
|
||||
"source"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_options() {
|
||||
assert_eq!(BuildType::from_options("full").unwrap(), FULL);
|
||||
assert_eq!(BuildType::from_options("source").unwrap(), SOURCE);
|
||||
assert_eq!(
|
||||
BuildType::from_options("source,any").unwrap(),
|
||||
SOURCE_ARCH_DEP
|
||||
);
|
||||
assert_eq!(BuildType::from_options("any,all").unwrap(), BINARY);
|
||||
assert!(BuildType::from_options("bogus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_options() {
|
||||
for t in [FULL, SOURCE, BINARY, SOURCE_ARCH_DEP, SOURCE_ARCH_INDEP] {
|
||||
assert_eq!(BuildType::from_options(&t.to_options()).unwrap(), t);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets() {
|
||||
assert_eq!(FULL.build_target(), "build");
|
||||
assert_eq!(FULL.binary_target(), "binary");
|
||||
assert_eq!(ARCH_DEP.build_target(), "build-arch");
|
||||
assert_eq!(ARCH_DEP.binary_target(), "binary-arch");
|
||||
assert_eq!(ARCH_INDEP.build_target(), "build-indep");
|
||||
assert_eq!(ARCH_INDEP.binary_target(), "binary-indep");
|
||||
assert_eq!(SOURCE.arch_suffix("amd64"), "source");
|
||||
assert_eq!(ARCH_DEP.arch_suffix("amd64"), "amd64");
|
||||
assert_eq!(ARCH_INDEP.arch_suffix("amd64"), "all");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Native `.changes` generation (Format 1.8), mirroring `dpkg-genchanges`
|
||||
//! for the artifact aggregation part: checksums, per-file sections and
|
||||
//! priorities, changelog-derived fields.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::debian::checksums::FileChecksums;
|
||||
use crate::debian::control::{Paragraph, write_paragraph};
|
||||
use crate::debian::files::FilesList;
|
||||
|
||||
/// Everything needed to render a `.changes` file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChangesInput {
|
||||
/// `Date` field: the changelog entry date (verbatim trailer date).
|
||||
pub date: String,
|
||||
/// `Source` field, including the ` (sourceversion)` suffix for binNMUs.
|
||||
pub source: String,
|
||||
/// Sorted binary package names with artifacts (empty for source-only).
|
||||
pub binaries: Vec<String>,
|
||||
/// Active build profiles (`Built-For-Profiles`); omitted when empty.
|
||||
pub built_for_profiles: Vec<String>,
|
||||
/// `Architecture` field value in encounter order (e.g. `source`,
|
||||
/// `amd64 all`, ...).
|
||||
pub architecture: String,
|
||||
/// Full version.
|
||||
pub version: String,
|
||||
/// Distribution(s).
|
||||
pub distribution: String,
|
||||
/// Urgency.
|
||||
pub urgency: String,
|
||||
/// `Maintainer` from the control source stanza.
|
||||
pub maintainer: Option<String>,
|
||||
/// `Changed-By` from the changelog maintainer.
|
||||
pub changed_by: Option<String>,
|
||||
/// Formatted per-package description lines (empty for source-only).
|
||||
pub descriptions: Vec<String>,
|
||||
/// Bug numbers collected from the changelog (`Closes` field), if any.
|
||||
pub closes: Option<String>,
|
||||
/// Rendered `Changes` field value from the changelog entry.
|
||||
pub changes_field: String,
|
||||
/// Computed artifact checksums (dsc, tarballs, debs, buildinfo).
|
||||
pub checksums: FileChecksums,
|
||||
/// Registry providing section/priority per file.
|
||||
pub files_list: FilesList,
|
||||
}
|
||||
|
||||
/// Wrap an overly long single-line field value (> 980 characters) over
|
||||
/// multiple lines at spaces, like dpkg does for `Binary`.
|
||||
fn wrap_long(value: &str) -> String {
|
||||
if value.len() <= 980 {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(value.len() + 8);
|
||||
let mut line_len = 0usize;
|
||||
for (i, word) in value.split(' ').enumerate() {
|
||||
if i > 0 {
|
||||
if line_len + 1 + word.len() > 980 {
|
||||
out.push('\n');
|
||||
line_len = 0;
|
||||
} else {
|
||||
out.push(' ');
|
||||
line_len += 1;
|
||||
}
|
||||
}
|
||||
out.push_str(word);
|
||||
line_len += word.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Format one `Description` line: `%-10s - %-.65s` plus a ` (type)` suffix
|
||||
/// for non-deb package types, matching `format_desc()` in dpkg-genchanges.
|
||||
pub fn format_description(package: &str, package_type: &str, summary: &str) -> String {
|
||||
let mut line = format!("{:<10} - {:.65}", package, summary);
|
||||
if package_type != "deb" && !package_type.is_empty() {
|
||||
line.push_str(&format!(" ({})", package_type));
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
/// Render the `.changes` document (without signature), with fields in dpkg's
|
||||
/// canonical order for `CTRL_FILE_CHANGES`.
|
||||
///
|
||||
/// Note: the legacy `Files` field carries md5+size+section+priority+name,
|
||||
/// while `Checksums-Sha1`/`Checksums-Sha256` carry the stronger hashes;
|
||||
/// `Checksums-Md5` is deliberately omitted as redundant, exactly like
|
||||
/// dpkg-genchanges does.
|
||||
pub fn render_changes(input: &ChangesInput) -> Paragraph {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Format", "1.8");
|
||||
p.set("Date", &input.date);
|
||||
p.set("Source", &input.source);
|
||||
if !input.binaries.is_empty() {
|
||||
let joined = input.binaries.join(" ");
|
||||
p.set("Binary", &wrap_long(&joined));
|
||||
}
|
||||
if !input.built_for_profiles.is_empty() {
|
||||
p.set("Built-For-Profiles", &input.built_for_profiles.join(" "));
|
||||
}
|
||||
p.set("Architecture", &input.architecture);
|
||||
p.set("Version", &input.version);
|
||||
p.set("Distribution", &input.distribution);
|
||||
p.set("Urgency", &input.urgency);
|
||||
if let Some(maintainer) = &input.maintainer {
|
||||
p.set("Maintainer", maintainer);
|
||||
}
|
||||
if let Some(changed_by) = &input.changed_by {
|
||||
p.set("Changed-By", changed_by);
|
||||
}
|
||||
if !input.descriptions.is_empty() {
|
||||
let mut sorted = input.descriptions.clone();
|
||||
sorted.sort();
|
||||
// Leading `\n`: pre-wrapped multiline field, dpkg-style.
|
||||
p.set("Description", &format!("\n{}", sorted.join("\n")));
|
||||
}
|
||||
if let Some(closes) = &input.closes {
|
||||
p.set("Closes", closes);
|
||||
}
|
||||
p.set("Changes", &input.changes_field);
|
||||
|
||||
if !input.checksums.is_empty() {
|
||||
p.set("Checksums-Sha1", &input.checksums.field_sha1());
|
||||
p.set("Checksums-Sha256", &input.checksums.field_sha256());
|
||||
|
||||
// Legacy Files field: md5 size section priority filename
|
||||
let mut files = String::new();
|
||||
for (key, entry) in input.checksums.iter() {
|
||||
let (section, priority) = input
|
||||
.files_list
|
||||
.get(key)
|
||||
.map(|f| (f.section.as_str(), f.priority.as_str()))
|
||||
.unwrap_or(("-", "-"));
|
||||
files.push('\n');
|
||||
files.push_str(&entry.md5);
|
||||
files.push(' ');
|
||||
files.push_str(&entry.size.to_string());
|
||||
files.push(' ');
|
||||
files.push_str(section);
|
||||
files.push(' ');
|
||||
files.push_str(priority);
|
||||
files.push(' ');
|
||||
files.push_str(key);
|
||||
}
|
||||
p.set("Files", &files);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Serialize and atomically write a `.changes` file.
|
||||
pub fn save_changes(path: &Path, paragraph: &Paragraph) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tmp = path.with_extension("new");
|
||||
std::fs::write(&tmp, write_paragraph(paragraph))
|
||||
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
|
||||
std::fs::rename(&tmp, path)
|
||||
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn description_formatting() {
|
||||
assert_eq!(
|
||||
format_description("hello", "deb", "The classic greeting"),
|
||||
"hello - The classic greeting"
|
||||
);
|
||||
assert_eq!(
|
||||
format_description("verylongpkgname", "udeb", "short"),
|
||||
"verylongpkgname - short (udeb)"
|
||||
);
|
||||
let long_summary = "x".repeat(100);
|
||||
assert_eq!(
|
||||
format_description("p", "deb", &long_summary).len(),
|
||||
10 + 3 + 65
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_source_only_changes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dsc_path = dir.path().join("pkg_1.0.dsc");
|
||||
std::fs::write(&dsc_path, b"content\n").unwrap();
|
||||
|
||||
let mut checksums = FileChecksums::new();
|
||||
checksums.add_file(&dsc_path).unwrap();
|
||||
|
||||
let mut files_list = FilesList::new();
|
||||
files_list.add(crate::debian::FilesEntry::new(
|
||||
"pkg_1.0.dsc",
|
||||
"utils",
|
||||
"optional",
|
||||
));
|
||||
|
||||
let input = ChangesInput {
|
||||
date: "Sat, 22 Aug 2026 10:00:00 +0000".to_string(),
|
||||
source: "pkg".to_string(),
|
||||
binaries: vec![],
|
||||
built_for_profiles: vec![],
|
||||
architecture: "source".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
distribution: "unstable".to_string(),
|
||||
urgency: "medium".to_string(),
|
||||
maintainer: Some("A B <a@b.c>".to_string()),
|
||||
changed_by: Some("A B <a@b.c>".to_string()),
|
||||
descriptions: vec![],
|
||||
closes: None,
|
||||
changes_field: "pkg (1.0) unstable; urgency=medium\n.\n * Something.".to_string(),
|
||||
checksums,
|
||||
files_list,
|
||||
};
|
||||
|
||||
let p = render_changes(&input);
|
||||
let keys: Vec<&str> = p.iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
"Format",
|
||||
"Date",
|
||||
"Source",
|
||||
"Architecture",
|
||||
"Version",
|
||||
"Distribution",
|
||||
"Urgency",
|
||||
"Maintainer",
|
||||
"Changed-By",
|
||||
"Changes",
|
||||
"Checksums-Sha1",
|
||||
"Checksums-Sha256",
|
||||
"Files"
|
||||
]
|
||||
);
|
||||
// No Binary / Description / Checksums-Md5 for source-only uploads.
|
||||
assert!(p.get("Binary").is_none());
|
||||
assert!(p.get("Description").is_none());
|
||||
assert!(p.get("Checksums-Md5").is_none());
|
||||
|
||||
let files_value = p.get("Files").unwrap();
|
||||
assert_eq!(
|
||||
files_value,
|
||||
"\n<md5> 8 utils optional pkg_1.0.dsc"
|
||||
.replace("<md5>", files_value.split_whitespace().next().unwrap_or(""))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Build environment setup: `SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`,
|
||||
//! architecture variables (native `dpkg-architecture` equivalent) and the
|
||||
//! sanitized environment recorded in `.buildinfo` files.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// 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 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.
|
||||
///
|
||||
/// The locale is pinned to `C` (`LC_ALL`, which takes precedence over any
|
||||
/// inherited session setting, plus `LANG`) so build tools emit deterministic,
|
||||
/// English diagnostics — required for reliable log classification and
|
||||
/// reproducible builds.
|
||||
pub fn build_env(
|
||||
source_date_epoch: i64,
|
||||
parallel: usize,
|
||||
build_profiles: &[String],
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("LANG".to_string(), "C".to_string());
|
||||
env.insert("LC_ALL".to_string(), "C".to_string());
|
||||
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, computed natively by
|
||||
/// [`crate::debian::arch`] (the equivalent of `dpkg-architecture -f
|
||||
/// [-a <host-arch>]`).
|
||||
///
|
||||
/// 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> {
|
||||
crate::debian::arch::arch_env(host_arch)
|
||||
}
|
||||
|
||||
/// 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("LANG").unwrap(), "C");
|
||||
assert_eq!(env.get("LC_ALL").unwrap(), "C");
|
||||
assert_eq!(env.get("SOURCE_DATE_EPOCH").unwrap(), "1787392800");
|
||||
assert_eq!(env.get("DEB_BUILD_OPTIONS").unwrap(), "parallel=16");
|
||||
assert!(!env.contains_key("DEB_BUILD_PROFILES"));
|
||||
|
||||
let env = build_env(1, 4, &["nodoc".to_string(), "cross".to_string()]);
|
||||
assert_eq!(env.get("DEB_BUILD_PROFILES").unwrap(), "nodoc,cross");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_defaults() {
|
||||
assert!(default_build_profiles("debian").is_empty());
|
||||
assert_eq!(
|
||||
default_build_profiles("ubuntu"),
|
||||
vec!["derivative.ubuntu".to_string(), "noudeb".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_escaping() {
|
||||
// The function reads the process env; just verify formatting helpers
|
||||
// through a controlled subprocess-free path is not possible, so check
|
||||
// the constant list contains essentials.
|
||||
assert!(ENV_ALLOWED.contains(&"SOURCE_DATE_EPOCH"));
|
||||
assert!(ENV_ALLOWED.contains(&"DEB_BUILD_OPTIONS"));
|
||||
assert!(!ENV_ALLOWED.contains(&"HOME"));
|
||||
assert!(!ENV_ALLOWED.contains(&"PATH"));
|
||||
}
|
||||
}
|
||||
+1600
File diff suppressed because it is too large
Load Diff
+5
-74
@@ -2,7 +2,7 @@ use chrono::Local;
|
||||
use git2::{Oid, Repository, Sort};
|
||||
use regex::Regex;
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufRead, Read, Write};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// Automatically generate a changelog entry from a commit history and previous changelog
|
||||
@@ -121,84 +121,15 @@ fn increment_suffix(version: &str, suffix: &str) -> String {
|
||||
pub fn parse_changelog_header(
|
||||
path: &Path,
|
||||
) -> Result<(String, String, String), Box<dyn std::error::Error>> {
|
||||
let file = File::open(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read changelog '{}': {}. \
|
||||
Make sure you are running this command from the root of a source package \
|
||||
(a directory containing a 'debian/' subdirectory with a 'changelog' file).",
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
let mut reader = io::BufReader::new(file);
|
||||
let mut first_line = String::new();
|
||||
reader.read_line(&mut first_line).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read first line of changelog '{}': {}",
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
// Format: package (version) series; urgency=urgency
|
||||
let re = Regex::new(r"^(\S+) \(([^)]+)\) (.*); .*")?;
|
||||
if let Some(caps) = re.captures(&first_line) {
|
||||
let package = caps.get(1).map_or("", |m| m.as_str()).to_string();
|
||||
let version = caps.get(2).map_or("", |m| m.as_str()).to_string();
|
||||
let series = caps.get(3).map_or("", |m| m.as_str()).to_string();
|
||||
Ok((package, version, series))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid changelog header format in '{}'. \
|
||||
The first line must look like: `package (version) series; urgency=...`, \
|
||||
but got: {:?}",
|
||||
path.display(),
|
||||
first_line.trim_end()
|
||||
)
|
||||
.into())
|
||||
}
|
||||
let entry = crate::debian::parse_changelog_entry(path)?;
|
||||
Ok((entry.source, entry.version.full(), entry.distribution))
|
||||
}
|
||||
|
||||
/// Parse a changelog file footer to extract maintainer information
|
||||
/// Returns (name, email) tuple from the last modification entry
|
||||
pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn std::error::Error>> {
|
||||
let mut file = File::open(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read changelog '{}': {}. \
|
||||
Make sure you are running this command from the root of a source package \
|
||||
(a directory containing a 'debian/' subdirectory with a 'changelog' file).",
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content)
|
||||
.map_err(|e| format!("Failed to read changelog '{}': {}", path.display(), e))?;
|
||||
|
||||
// Find the last maintainer line (format: -- Name <email> Date)
|
||||
let re = Regex::new(r"--\s*([^<]+?)\s*<([^>]+)>\s*")?;
|
||||
|
||||
if let Some(first_match) = re.captures_iter(&content).next() {
|
||||
let name = first_match
|
||||
.get(1)
|
||||
.map_or("", |m| m.as_str())
|
||||
.trim()
|
||||
.to_string();
|
||||
let email = first_match
|
||||
.get(2)
|
||||
.map_or("", |m| m.as_str())
|
||||
.trim()
|
||||
.to_string();
|
||||
Ok((name, email))
|
||||
} else {
|
||||
Err(format!(
|
||||
"No maintainer information found in '{}'. \
|
||||
The changelog must contain a line of the form '-- Name <email> Date', \
|
||||
but none was found.",
|
||||
path.display()
|
||||
)
|
||||
.into())
|
||||
}
|
||||
let entry = crate::debian::parse_changelog_entry(path)?;
|
||||
Ok((entry.maintainer_name, entry.maintainer_email))
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
mod api;
|
||||
mod capture;
|
||||
pub(crate) mod capture;
|
||||
mod local;
|
||||
mod manager;
|
||||
mod schroot;
|
||||
|
||||
@@ -227,6 +227,17 @@ pub async fn build(
|
||||
.to_str()
|
||||
.ok_or("Invalid package directory path")?;
|
||||
|
||||
// Reproducibility: export SOURCE_DATE_EPOCH from the changelog entry,
|
||||
// like dpkg-buildpackage does.
|
||||
match ctx.read_file(&package_dir.join("debian/changelog")) {
|
||||
Ok(content) => {
|
||||
if let Ok(entry) = crate::debian::parse_changelog_entry_from_str(&content) {
|
||||
env.insert("SOURCE_DATE_EPOCH".to_string(), entry.timestamp.to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => log::debug!("cannot read changelog for SOURCE_DATE_EPOCH: {}", e),
|
||||
}
|
||||
|
||||
// Apply quilt patches if the package provides a patch series
|
||||
apply_quilt_patches(package_dir_str, &env, ctx.clone(), &ui, &sink)?;
|
||||
|
||||
@@ -318,6 +329,91 @@ pub async fn build(
|
||||
);
|
||||
}
|
||||
|
||||
// Generate the upload metadata (.buildinfo + .changes) natively, the
|
||||
// equivalent of dpkg-genbuildinfo -b + dpkg-genchanges -b, consuming
|
||||
// debian/files produced by the build. Failures are logged but do not
|
||||
// discard the produced binaries.
|
||||
if let Err(e) = generate_upload_metadata(package_dir_str, build_root, arch, cross, &env, &ctx) {
|
||||
warn!("failed to generate .buildinfo/.changes: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate `.buildinfo` and `.changes` for the finished binary build,
|
||||
/// inside the build context.
|
||||
fn generate_upload_metadata(
|
||||
package_dir: &str,
|
||||
build_root: &str,
|
||||
arch: &str,
|
||||
cross: bool,
|
||||
env: &HashMap<String, String>,
|
||||
ctx: &Arc<Context>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
use std::path::Path;
|
||||
|
||||
let changelog_path = Path::new(package_dir).join("debian/changelog");
|
||||
let changelog_content = ctx.read_file(&changelog_path)?;
|
||||
let entry = crate::debian::parse_changelog_entry_from_str(&changelog_content)?;
|
||||
|
||||
// Build architecture: the machine inside the build context.
|
||||
let build_arch = ctx
|
||||
.command("dpkg")
|
||||
.arg("--print-architecture")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(crate::get_current_arch);
|
||||
let host_arch = if cross {
|
||||
arch.to_string()
|
||||
} else {
|
||||
build_arch.clone()
|
||||
};
|
||||
|
||||
// Vendor resolution inside the context (falls back to the host view).
|
||||
let vendor = ctx
|
||||
.read_file(Path::new("/etc/dpkg/origins/default"))
|
||||
.ok()
|
||||
.and_then(|content| {
|
||||
for line in content.lines() {
|
||||
if let Some(v) = line.strip_prefix("Vendor:") {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap_or_else(crate::build::env::current_vendor);
|
||||
|
||||
let profiles = crate::build::env::resolve_build_profiles(&[], &vendor);
|
||||
let source_date_epoch = env
|
||||
.get("SOURCE_DATE_EPOCH")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(entry.timestamp);
|
||||
|
||||
let opts = crate::build::binary::BinaryMetadataOptions {
|
||||
profiles,
|
||||
vendor,
|
||||
parallel: crate::build::env::num_parallel(),
|
||||
source_date_epoch,
|
||||
build_arch,
|
||||
host_arch,
|
||||
};
|
||||
let (buildinfo, changes) = crate::build::binary::generate_binary_metadata(
|
||||
ctx,
|
||||
Path::new(package_dir),
|
||||
Path::new(build_root),
|
||||
&opts,
|
||||
)?;
|
||||
log::info!(
|
||||
"generated upload metadata: {} and {}",
|
||||
buildinfo.display(),
|
||||
changes.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+10
-2
@@ -165,14 +165,22 @@ async fn build_binary_package_impl(
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve produced .deb files
|
||||
// Retrieve produced artifacts (.deb files plus the upload metadata
|
||||
// (.buildinfo/.changes) generated natively after the build)
|
||||
if let Some(u) = ui {
|
||||
u.phase(Phase::RetrievingArtifacts);
|
||||
}
|
||||
let remote_files = build_ctx.list_files(Path::new(&build_root))?;
|
||||
let deb_files: Vec<PathBuf> = remote_files
|
||||
.into_iter()
|
||||
.filter(|f| f.extension().is_some_and(|ext| ext == "deb"))
|
||||
.filter(|f| {
|
||||
f.extension().is_some_and(|ext| {
|
||||
matches!(
|
||||
ext.to_str(),
|
||||
Some("deb") | Some("buildinfo") | Some("changes")
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let total_debs = deb_files.len();
|
||||
|
||||
|
||||
+1027
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
||||
//! Debian changelog entry parsing (`debian/changelog`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::DateTime;
|
||||
use regex::Regex;
|
||||
|
||||
use super::version::DebianVersion;
|
||||
|
||||
/// A parsed `debian/changelog` entry (the most recent one).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChangelogEntry {
|
||||
/// Source package name.
|
||||
pub source: String,
|
||||
/// Parsed version.
|
||||
pub version: DebianVersion,
|
||||
/// Raw distribution(s) field, e.g. `"unstable"` or `"focal"`.
|
||||
pub distribution: String,
|
||||
/// Urgency value, e.g. `"medium"`.
|
||||
pub urgency: String,
|
||||
/// True for binNMU-style entries (`binary-only=yes` header parameter).
|
||||
pub binary_only: bool,
|
||||
/// Maintainer name from the trailer line.
|
||||
pub maintainer_name: String,
|
||||
/// Maintainer email from the trailer line.
|
||||
pub maintainer_email: String,
|
||||
/// Verbatim trailer date string (RFC2822-ish).
|
||||
pub date_raw: String,
|
||||
/// Trailer date parsed as a Unix timestamp.
|
||||
pub timestamp: i64,
|
||||
/// Value for the `.changes` `Changes` field: header line, blank lines
|
||||
/// converted to `.`, body lines verbatim; without the trailer line.
|
||||
pub changes_field: String,
|
||||
/// Bug numbers collected from `(Closes: #NNN)` mentions in the body,
|
||||
/// sorted numerically and de-duplicated (like dpkg's `find_closes`).
|
||||
pub closes: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse the most recent entry of a Debian changelog file.
|
||||
pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path).map_err(|e| {
|
||||
format!(
|
||||
"failed to read changelog '{}': {}. Make sure you are running \
|
||||
from the root of a source package.",
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
parse_changelog_entry_from_str(&content)
|
||||
}
|
||||
|
||||
/// Parse the most recent changelog entry from its textual content. `origin`
|
||||
/// is used in error messages only.
|
||||
pub fn parse_changelog_entry_from_str(
|
||||
content: &str,
|
||||
) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
||||
let origin = "changelog";
|
||||
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 '{origin}' is empty").into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let open = header
|
||||
.find('(')
|
||||
.ok_or_else(|| format!("invalid changelog header in '{origin}': {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 '{origin}': expected a line of the form \
|
||||
' -- Name <email> Date'"
|
||||
)
|
||||
})?;
|
||||
|
||||
// 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 '{date_raw}' in '{origin}': {e}"))?
|
||||
.timestamp();
|
||||
|
||||
// Changes field value (leading `\n` marks it as a pre-wrapped multiline
|
||||
// field, like dpkg's own representation): header + blank-as-dot + body,
|
||||
// without the trailer line.
|
||||
let mut changes_field = String::from("\n");
|
||||
changes_field.push_str(header);
|
||||
if !body_lines.is_empty() {
|
||||
changes_field.push('\n');
|
||||
changes_field.push_str(&body_lines.join("\n"));
|
||||
}
|
||||
|
||||
let closes = find_closes(&body_lines);
|
||||
|
||||
Ok(ChangelogEntry {
|
||||
source,
|
||||
version,
|
||||
distribution,
|
||||
urgency,
|
||||
binary_only,
|
||||
maintainer_name,
|
||||
maintainer_email,
|
||||
date_raw,
|
||||
timestamp,
|
||||
changes_field,
|
||||
closes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract bug numbers from `(Closes: #NNN)` mentions in changelog body
|
||||
/// lines, sorted numerically and de-duplicated (a lenient port of dpkg's
|
||||
/// `find_closes`).
|
||||
fn find_closes(body_lines: &[String]) -> Option<String> {
|
||||
let re = Regex::new(r"(?i)\(closes:\s*([^)]*)\)").ok()?;
|
||||
let mut numbers: Vec<u64> = Vec::new();
|
||||
for line in body_lines {
|
||||
for capture in re.captures_iter(line) {
|
||||
if let Some(inner) = capture.get(1) {
|
||||
for token in inner.as_str().split(|c: char| !c.is_ascii_digit()) {
|
||||
if let Ok(n) = token.parse::<u64>() {
|
||||
numbers.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if numbers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
numbers.sort_unstable();
|
||||
numbers.dedup();
|
||||
Some(
|
||||
numbers
|
||||
.iter()
|
||||
.map(u64::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the version of the *previous* changelog entry (the second header
|
||||
/// in the file), or `None` when only one entry exists.
|
||||
pub fn parse_previous_version(path: &Path) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read changelog '{}': {}", path.display(), e))?;
|
||||
parse_previous_version_from_str(&content)
|
||||
}
|
||||
|
||||
/// Return the version of the *previous* changelog entry from the textual
|
||||
/// content of a changelog file.
|
||||
pub fn parse_previous_version_from_str(
|
||||
content: &str,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
let mut seen_first = false;
|
||||
for line in content.lines() {
|
||||
let line = line.trim_end();
|
||||
if looks_like_header(line) {
|
||||
if !seen_first {
|
||||
seen_first = true;
|
||||
continue;
|
||||
}
|
||||
let open = line
|
||||
.find('(')
|
||||
.ok_or_else(|| format!("invalid changelog header: {line}"))?;
|
||||
let close = line[open..]
|
||||
.find(')')
|
||||
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{line}'"))?;
|
||||
return Ok(Some(line[open + 1..open + close].to_string()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Heuristic check for a changelog entry header line
|
||||
/// (`name (version) dist; urgency=...`).
|
||||
fn looks_like_header(line: &str) -> bool {
|
||||
// Headers are never indented.
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
return false;
|
||||
}
|
||||
match line.find('(') {
|
||||
Some(open) => {
|
||||
let name = line[..open].trim();
|
||||
!name.is_empty() && !name.contains(' ')
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn changelog_parsing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("changelog");
|
||||
let content = "\
|
||||
pkh-hello (0.1) unstable; urgency=medium
|
||||
|
||||
* Initial release.
|
||||
* Second change line.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Sat, 22 Aug 2026 10:00:00 +0000
|
||||
";
|
||||
std::fs::write(&path, content).unwrap();
|
||||
|
||||
let entry = parse_changelog_entry(&path).unwrap();
|
||||
assert_eq!(entry.source, "pkh-hello");
|
||||
assert_eq!(entry.version.full(), "0.1");
|
||||
assert_eq!(entry.distribution, "unstable");
|
||||
assert_eq!(entry.urgency, "medium");
|
||||
assert!(!entry.binary_only);
|
||||
assert_eq!(entry.maintainer_name, "Pkh Tester");
|
||||
assert_eq!(entry.maintainer_email, "pkh@example.com");
|
||||
assert_eq!(entry.timestamp, 1787392800);
|
||||
assert_eq!(
|
||||
entry.changes_field,
|
||||
"\npkh-hello (0.1) unstable; urgency=medium\n.\n * Initial release.\n * Second change line."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_bin_nmu() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("changelog");
|
||||
let content = "\
|
||||
pkg (1.0-1+b1) unstable; urgency=medium, binary-only=yes
|
||||
|
||||
* Binary-only non-maintainer upload.
|
||||
-- Builder <b@example.com> Mon, 01 Jan 2024 00:00:00 +0000
|
||||
";
|
||||
std::fs::write(&path, content).unwrap();
|
||||
|
||||
let entry = parse_changelog_entry(&path).unwrap();
|
||||
assert!(entry.binary_only);
|
||||
assert_eq!(entry.version.full(), "1.0-1+b1");
|
||||
}
|
||||
}
|
||||
@@ -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,307 @@
|
||||
//! Debian control-file handling: a minimal deb822 paragraph parser/writer
|
||||
//! plus a `debian/control` model.
|
||||
//!
|
||||
//! Implements the subset of RFC822-ish parsing needed for `debian/control`,
|
||||
//! `debian/files`, `.dsc`, `.changes` and `.buildinfo` files: paragraphs
|
||||
//! separated by blank lines, `Field: value` entries with continuation lines
|
||||
//! starting by a single space or tab, and `#` comments.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// A single deb822 paragraph: an ordered list of `(field, value)` pairs.
|
||||
///
|
||||
/// Values are stored with continuation-line breaks as `\n` and without the
|
||||
/// leading whitespace of continuation lines. Serialization re-adds a single
|
||||
/// leading space in front of every continuation line, matching dpkg output.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Paragraph {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Paragraph {
|
||||
/// Create an empty paragraph.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Look up a field value (case-insensitive field name).
|
||||
pub fn get(&self, field: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(field))
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// Set a field value, replacing any previous occurrence (case-insensitive).
|
||||
/// Appends the field at the end if it did not exist yet.
|
||||
pub fn set(&mut self, field: &str, value: &str) {
|
||||
for (k, v) in self.fields.iter_mut() {
|
||||
if k.eq_ignore_ascii_case(field) {
|
||||
*v = value.to_string();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.fields.push((field.to_string(), value.to_string()));
|
||||
}
|
||||
|
||||
/// Remove a field (case-insensitive). Returns true if it was present.
|
||||
pub fn remove(&mut self, field: &str) -> bool {
|
||||
let before = self.fields.len();
|
||||
self.fields.retain(|(k, _)| !k.eq_ignore_ascii_case(field));
|
||||
self.fields.len() != before
|
||||
}
|
||||
|
||||
/// Iterate over the `(field, value)` pairs in order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
}
|
||||
|
||||
/// Return true if the paragraph holds no field.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.fields.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a deb822 document into a list of paragraphs.
|
||||
///
|
||||
/// Comment lines (starting with `#`) are ignored. Blank lines separate
|
||||
/// paragraphs. Continuation lines must start with a space or a tab; exactly
|
||||
/// one leading space (or tab) is stripped from the stored value.
|
||||
pub fn parse_paragraphs(input: &str) -> Vec<Paragraph> {
|
||||
let mut paragraphs = Vec::new();
|
||||
let mut current = Paragraph::new();
|
||||
let mut last_field: Option<String> = None;
|
||||
|
||||
for raw_line in input.lines() {
|
||||
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
|
||||
|
||||
// Comments and blank lines
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
if !current.is_empty() {
|
||||
paragraphs.push(std::mem::take(&mut current));
|
||||
last_field = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Continuation line
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
let content = line.strip_prefix(' ').unwrap_or(line);
|
||||
if let Some(field) = &last_field
|
||||
&& let Some((_, v)) = current
|
||||
.fields
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(field))
|
||||
{
|
||||
v.push('\n');
|
||||
v.push_str(content);
|
||||
continue;
|
||||
}
|
||||
// Continuation without a preceding field line: skip it (malformed)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Field line: `Name: value`
|
||||
if let Some(colon) = line.find(':') {
|
||||
let name = line[..colon].trim();
|
||||
let value = line[colon + 1..].trim_start();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
current.fields.push((name.to_string(), value.to_string()));
|
||||
last_field = Some(name.to_string());
|
||||
}
|
||||
// Anything else is malformed: ignore the line
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
paragraphs.push(current);
|
||||
}
|
||||
|
||||
paragraphs
|
||||
}
|
||||
|
||||
/// Serialize a paragraph to its deb822 textual representation (with a
|
||||
/// trailing newline).
|
||||
///
|
||||
/// A value starting with `\n` is rendered as a field with no inline first
|
||||
/// line (`Field:` followed by ` line` continuations), matching dpkg output
|
||||
/// for pre-wrapped values such as `Changes`, `Files` or `Environment`.
|
||||
pub fn write_paragraph(p: &Paragraph) -> String {
|
||||
let mut out = String::new();
|
||||
for (name, value) in p.iter() {
|
||||
out.push_str(name);
|
||||
out.push(':');
|
||||
let mut lines = value.split('\n').peekable();
|
||||
// An empty first segment means: no value on the field header line;
|
||||
// discard it so it is not rendered as an empty continuation line.
|
||||
if lines.peek().is_some_and(|first| !first.is_empty()) {
|
||||
out.push(' ');
|
||||
out.push_str(lines.next().unwrap());
|
||||
} else {
|
||||
lines.next();
|
||||
}
|
||||
for line in lines {
|
||||
out.push('\n');
|
||||
out.push(' ');
|
||||
out.push_str(line);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_control() {
|
||||
let input = "Source: hello\nSection: devel\n\nPackage: hello\nDepends: libc6\n";
|
||||
let paras = parse_paragraphs(input);
|
||||
assert_eq!(paras.len(), 2);
|
||||
assert_eq!(paras[0].get("Source"), Some("hello"));
|
||||
assert_eq!(paras[0].get("section"), Some("devel"));
|
||||
assert_eq!(paras[1].get("Package"), Some("hello"));
|
||||
assert_eq!(paras[1].get("Depends"), Some("libc6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiline_and_comments() {
|
||||
let input = "# a comment\nDescription: short\n long description\n" //
|
||||
.to_string()
|
||||
+ " spanning lines\n\nPackage: x\n";
|
||||
let paras = parse_paragraphs(&input);
|
||||
assert_eq!(paras.len(), 2);
|
||||
assert_eq!(
|
||||
paras[0].get("Description"),
|
||||
Some("short\nlong description\nspanning lines")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_multiline() {
|
||||
let value = "short\nlong description\nspanning lines";
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Description", value);
|
||||
let text = write_paragraph(&p);
|
||||
assert_eq!(
|
||||
text,
|
||||
"Description: short\n long description\n spanning lines\n"
|
||||
);
|
||||
let reparsed = parse_paragraphs(&text);
|
||||
assert_eq!(reparsed[0].get("Description"), Some(value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_replaces_case_insensitive() {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Source", "a");
|
||||
p.set("source", "b");
|
||||
assert_eq!(p.get("SOURCE"), Some("b"));
|
||||
assert_eq!(p.iter().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_field() {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("A", "1");
|
||||
assert!(p.remove("a"));
|
||||
assert!(!p.remove("a"));
|
||||
assert!(p.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed `debian/control`: the source stanza plus all binary stanzas.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ControlInfo {
|
||||
/// First paragraph (source package stanza).
|
||||
pub source: Paragraph,
|
||||
/// Remaining paragraphs (binary package stanzas).
|
||||
pub binaries: Vec<Paragraph>,
|
||||
}
|
||||
|
||||
impl ControlInfo {
|
||||
/// Parse a `debian/control` file.
|
||||
pub fn parse(path: &Path) -> Result<ControlInfo, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read control file '{}': {}", path.display(), e))?;
|
||||
content
|
||||
.parse::<ControlInfo>()
|
||||
.map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
/// Parse control content from a string.
|
||||
///
|
||||
/// Prefer [`std::str::FromStr`] (`"...".parse::<ControlInfo>()`).
|
||||
pub fn parse_content(content: &str) -> Result<ControlInfo, String> {
|
||||
let paragraphs = parse_paragraphs(content);
|
||||
let mut iter = paragraphs.into_iter();
|
||||
let source = iter
|
||||
.next()
|
||||
.ok_or_else(|| "control file has no paragraphs".to_string())?;
|
||||
if source.get("Source").is_none() {
|
||||
return Err("first control paragraph has no 'Source' field".to_string());
|
||||
}
|
||||
let binaries: Vec<Paragraph> = iter.collect();
|
||||
for bin in &binaries {
|
||||
if bin.get("Package").is_none() {
|
||||
return Err("binary control paragraph has no 'Package' field".to_string());
|
||||
}
|
||||
}
|
||||
Ok(ControlInfo { source, binaries })
|
||||
}
|
||||
|
||||
/// The source package name.
|
||||
pub fn source_name(&self) -> &str {
|
||||
self.source.get("Source").expect("checked at parse")
|
||||
}
|
||||
|
||||
/// Section from the source stanza, or `'-'`.
|
||||
pub fn section(&self) -> &str {
|
||||
self.source.get("Section").unwrap_or("-")
|
||||
}
|
||||
|
||||
/// Priority from the source stanza, or `'-'`.
|
||||
pub fn priority(&self) -> &str {
|
||||
self.source.get("Priority").unwrap_or("-")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ControlInfo {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(content: &str) -> Result<Self, Self::Err> {
|
||||
ControlInfo::parse_content(content)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod control_info_tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn control_parsing() {
|
||||
let ci = ControlInfo::from_str(
|
||||
"Source: hello\nSection: utils\nPriority: optional\nMaintainer: A B <a@b.c>\nBuild-Depends: debhelper\n\nPackage: hello\nArchitecture: any\nDescription: test\n long\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ci.source_name(), "hello");
|
||||
assert_eq!(ci.section(), "utils");
|
||||
assert_eq!(ci.priority(), "optional");
|
||||
assert_eq!(ci.binaries.len(), 1);
|
||||
assert_eq!(ci.binaries[0].get("Package"), Some("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_defaults() {
|
||||
let ci = ControlInfo::from_str("Source: x\n\nPackage: x\nDescription: d\n").unwrap();
|
||||
assert_eq!(ci.section(), "-");
|
||||
assert_eq!(ci.priority(), "-");
|
||||
}
|
||||
}
|
||||
+1312
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
//! `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 content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(FilesList::new()),
|
||||
Err(e) => {
|
||||
return Err(format!("cannot read '{}': {}", path.display(), e).into());
|
||||
}
|
||||
};
|
||||
FilesList::parse(&content).map_err(|e| format!("in '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
/// Parse a `debian/files` registry from its textual content
|
||||
/// (`filename section priority [key=value...]` lines).
|
||||
pub fn parse(content: &str) -> Result<FilesList, String> {
|
||||
let mut list = FilesList::new();
|
||||
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: {line}"));
|
||||
}
|
||||
let mut entry = parse_filename(tokens[0])
|
||||
.ok_or_else(|| format!("badly formed file name: {}", 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()
|
||||
}
|
||||
|
||||
/// Render the registry to its textual `debian/files` representation.
|
||||
pub fn render(&self) -> String {
|
||||
let mut out = String::new();
|
||||
for entry in self.iter() {
|
||||
out.push_str(&entry.filename);
|
||||
out.push(' ');
|
||||
out.push_str(&entry.section);
|
||||
out.push(' ');
|
||||
out.push_str(&entry.priority);
|
||||
for (k, v) in &entry.attrs {
|
||||
out.push_str(&format!(" {k}={v}"));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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,29 @@
|
||||
//! Reusable Debian format primitives.
|
||||
//!
|
||||
//! These components are independent from any build orchestration and can be
|
||||
//! used by any pkh submodule (or external consumers of the library):
|
||||
//!
|
||||
//! - [`arch`]: Debian architecture tables and lookups (dpkg-architecture)
|
||||
//! - [`control`]: deb822 paragraph parsing/writing and `debian/control`
|
||||
//! - [`checksums`]: file checksum registry (`Dpkg::Checksums` equivalent)
|
||||
//! - [`deps`]: dependency grammar and evaluation (dpkg-checkbuilddeps)
|
||||
//! - [`files`]: `debian/files` artifact registry (`Dpkg::Dist::Files`)
|
||||
//! - [`version`]: Debian version splitting/validation/comparison
|
||||
//! - [`changelog`]: `debian/changelog` entry parsing
|
||||
|
||||
pub mod arch;
|
||||
pub mod changelog;
|
||||
pub mod checksums;
|
||||
pub mod control;
|
||||
pub mod deps;
|
||||
pub mod files;
|
||||
pub mod version;
|
||||
|
||||
pub use changelog::{
|
||||
ChangelogEntry, parse_changelog_entry, parse_changelog_entry_from_str,
|
||||
parse_previous_version_from_str,
|
||||
};
|
||||
pub use checksums::{Entry as ChecksumEntry, FileChecksums};
|
||||
pub use control::{ControlInfo, Paragraph, parse_paragraphs, write_paragraph};
|
||||
pub use files::{FilesEntry, FilesList};
|
||||
pub use version::DebianVersion;
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Debian version handling: splitting, validation and ordering of
|
||||
//! `[epoch:]upstream[-revision]` version strings.
|
||||
|
||||
/// A Debian version, split into its `[epoch:]upstream[-revision]` parts.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DebianVersion {
|
||||
/// Optional numeric epoch (part before the first `:`).
|
||||
pub epoch: Option<u32>,
|
||||
/// Upstream version (may itself contain `-` when there is no revision).
|
||||
pub upstream: String,
|
||||
/// Optional Debian revision (part after the last `-`).
|
||||
pub debian_revision: Option<String>,
|
||||
}
|
||||
|
||||
impl DebianVersion {
|
||||
/// Parse and validate a Debian version string.
|
||||
pub fn parse(raw: &str) -> Result<DebianVersion, String> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return Err("empty version string".to_string());
|
||||
}
|
||||
|
||||
let (epoch, rest) = match raw.split_once(':') {
|
||||
Some((e, r)) => {
|
||||
let epoch: u32 = e
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid epoch '{}' in version '{}'", e, raw))?;
|
||||
(Some(epoch), r)
|
||||
}
|
||||
None => (None, raw),
|
||||
};
|
||||
|
||||
// The revision is everything after the last hyphen.
|
||||
let (upstream, debian_revision) = match rest.rsplit_once('-') {
|
||||
Some((u, r)) => (u.to_string(), Some(r.to_string())),
|
||||
None => (rest.to_string(), None),
|
||||
};
|
||||
|
||||
if upstream.is_empty() {
|
||||
return Err(format!("missing upstream version in '{}'", raw));
|
||||
}
|
||||
for c in upstream.chars() {
|
||||
if !(c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '.' | '+' | '-' | '~' | ':')
|
||||
|| !c.is_ascii())
|
||||
{
|
||||
return Err(format!("invalid character '{}' in version '{}'", c, raw));
|
||||
}
|
||||
}
|
||||
if let Some(rev) = &debian_revision {
|
||||
for c in rev.chars() {
|
||||
if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) {
|
||||
return Err(format!(
|
||||
"invalid character '{}' in revision of version '{}'",
|
||||
c, raw
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DebianVersion {
|
||||
epoch,
|
||||
upstream,
|
||||
debian_revision,
|
||||
})
|
||||
}
|
||||
|
||||
/// Full version string, including the epoch (`[epoch:]upstream[-rev]`).
|
||||
pub fn full(&self) -> String {
|
||||
match (&self.epoch, &self.debian_revision) {
|
||||
(Some(e), Some(r)) => format!("{}:{}-{}", e, self.upstream, r),
|
||||
(Some(e), None) => format!("{}:{}", e, self.upstream),
|
||||
(None, Some(r)) => format!("{}-{}", self.upstream, r),
|
||||
(None, None) => self.upstream.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Version string without the epoch (`upstream[-rev]`), used in artifact
|
||||
/// file names.
|
||||
pub fn no_epoch(&self) -> String {
|
||||
match &self.debian_revision {
|
||||
Some(r) => format!("{}-{}", self.upstream, r),
|
||||
None => self.upstream.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience predicate: whether this version orders strictly later
|
||||
/// than `other`.
|
||||
pub fn later_than(&self, other: &DebianVersion) -> bool {
|
||||
self > other
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare two versions according to dpkg's ordering algorithm
|
||||
/// (Debian Policy §5.6.1 / `dpkg(1)`):
|
||||
///
|
||||
/// - the epoch compares numerically (a missing epoch counts as `0`),
|
||||
/// - then the upstream version and the Debian revision compare by
|
||||
/// alternating non-digit and digit chunks, from left to right,
|
||||
/// - in non-digit chunks letters sort earlier than non-letters, and `~`
|
||||
/// sorts before anything, including the end of the chunk,
|
||||
/// - digit chunks compare numerically (leading zeroes are irrelevant; an
|
||||
/// empty digit chunk counts as `0`, so a missing revision equals `0`).
|
||||
pub fn compare(a: &DebianVersion, b: &DebianVersion) -> std::cmp::Ordering {
|
||||
a.epoch
|
||||
.unwrap_or(0)
|
||||
.cmp(&b.epoch.unwrap_or(0))
|
||||
.then_with(|| verrevcmp(a.upstream.as_bytes(), b.upstream.as_bytes()))
|
||||
.then_with(|| {
|
||||
verrevcmp(
|
||||
a.debian_revision.as_deref().unwrap_or("").as_bytes(),
|
||||
b.debian_revision.as_deref().unwrap_or("").as_bytes(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Sort weight of a character inside a non-digit chunk: `~` sorts before the
|
||||
/// end of the chunk, letters before non-letters, everything else by ASCII
|
||||
/// order.
|
||||
fn char_order(c: u8) -> i32 {
|
||||
if c == b'~' {
|
||||
-1
|
||||
} else if c.is_ascii_alphabetic() {
|
||||
i32::from(c)
|
||||
} else {
|
||||
i32::from(c) + 256
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare the upstream/revision part of two versions by alternating
|
||||
/// non-digit and digit chunks.
|
||||
fn verrevcmp(mut a: &[u8], mut b: &[u8]) -> std::cmp::Ordering {
|
||||
use std::cmp::Ordering;
|
||||
|
||||
while !a.is_empty() || !b.is_empty() {
|
||||
let mut first_diff: i32 = 0;
|
||||
|
||||
// Non-digit chunks: compare by character weight. A chunk boundary
|
||||
// (end of string or start of a digit run) weighs 0, which sorts
|
||||
// after `~` (-1) and before every real character.
|
||||
while (!a.is_empty() && !a[0].is_ascii_digit()) || (!b.is_empty() && !b[0].is_ascii_digit())
|
||||
{
|
||||
let ac = if !a.is_empty() && !a[0].is_ascii_digit() {
|
||||
char_order(a[0])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let bc = if !b.is_empty() && !b[0].is_ascii_digit() {
|
||||
char_order(b[0])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if ac != bc {
|
||||
return ac.cmp(&bc);
|
||||
}
|
||||
// Reaching here means both sides carried equal real characters.
|
||||
a = &a[1..];
|
||||
b = &b[1..];
|
||||
}
|
||||
|
||||
// Digit chunks: strip leading zeroes, then the number whose
|
||||
// remaining digit run is longer is larger; otherwise the first
|
||||
// differing digit decides.
|
||||
while !a.is_empty() && a[0] == b'0' {
|
||||
a = &a[1..];
|
||||
}
|
||||
while !b.is_empty() && b[0] == b'0' {
|
||||
b = &b[1..];
|
||||
}
|
||||
while !a.is_empty() && !b.is_empty() && a[0].is_ascii_digit() && b[0].is_ascii_digit() {
|
||||
if first_diff == 0 {
|
||||
first_diff = i32::from(a[0]) - i32::from(b[0]);
|
||||
}
|
||||
a = &a[1..];
|
||||
b = &b[1..];
|
||||
}
|
||||
if !a.is_empty() && a[0].is_ascii_digit() {
|
||||
return Ordering::Greater;
|
||||
}
|
||||
if !b.is_empty() && b[0].is_ascii_digit() {
|
||||
return Ordering::Less;
|
||||
}
|
||||
if first_diff != 0 {
|
||||
return first_diff.cmp(&0);
|
||||
}
|
||||
}
|
||||
Ordering::Equal
|
||||
}
|
||||
|
||||
impl PartialOrd for DebianVersion {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for DebianVersion {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
compare(self, other)
|
||||
}
|
||||
}
|
||||
|
||||
/// Test vectors ported from dpkg's `scripts/t/Dpkg_Version.t` (`__DATA__`
|
||||
/// section): `(version_a, version_b, expected_cmp)` with `-1/0/1`. Shared
|
||||
/// with the differential tests against real `dpkg --compare-versions`.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_vectors {
|
||||
/// `(a, b, cmp)` triples.
|
||||
pub(crate) const COMPARE: &[(&str, &str, i32)] = &[
|
||||
("1.0-1", "2.0-2", -1),
|
||||
("2.2~rc-4", "2.2-1", -1),
|
||||
("2.2-1", "2.2~rc-4", 1),
|
||||
("1.0000-1", "1.0-1", 0),
|
||||
("1", "0:1", 0),
|
||||
("0", "0:0-0", 0),
|
||||
("2:2.5", "1:7.5", 1),
|
||||
("1:0foo", "0foo", 1),
|
||||
("0:0foo", "0foo", 0),
|
||||
("0foo", "0foo", 0),
|
||||
("0foo-0", "0foo", 0),
|
||||
("0foo", "0foo-0", 0),
|
||||
("0foo", "0fo", 1),
|
||||
("0foo-0", "0foo+", -1),
|
||||
("0foo~1", "0foo", -1),
|
||||
("0foo~foo+Bar", "0foo~foo+bar", -1),
|
||||
("0foo~~", "0foo~", -1),
|
||||
("1~", "1", -1),
|
||||
(
|
||||
"12345+that-really-is-some-ver-0",
|
||||
"12345+that-really-is-some-ver-10",
|
||||
-1,
|
||||
),
|
||||
("0foo-0", "0foo-01", -1),
|
||||
("0foo.bar", "0foobar", 1),
|
||||
("0foo.bar", "0foo1bar", 1),
|
||||
("0foo.bar", "0foo0bar", 1),
|
||||
("0foo1bar-1", "0foobar-1", -1),
|
||||
("0foo2.0", "0foo2", 1),
|
||||
("0foo2.0.0", "0foo2.10.0", -1),
|
||||
("0foo2.0", "0foo2.0.0", -1),
|
||||
("0foo2.0", "0foo2.10", -1),
|
||||
("0foo2.1", "0foo2.10", -1),
|
||||
("1.09", "1.9", 0),
|
||||
("1.0.8+nmu1", "1.0.8", 1),
|
||||
("3.11", "3.10+nmu1", 1),
|
||||
("0.9j-20080306-4", "0.9i-20070324-2", 1),
|
||||
("1.2.0~b7-1", "1.2.0~b6-1", 1),
|
||||
("1.011-1", "1.06-2", 1),
|
||||
("0.0.9+dfsg1-1", "0.0.8+dfsg1-3", 1),
|
||||
("4.6.99+svn6582-1", "4.6.99+svn6496-1", 1),
|
||||
("53", "52", 1),
|
||||
("0.9.9~pre122-1", "0.9.9~pre111-1", 1),
|
||||
("2:2.3.2-2+lenny2", "2:2.3.2-2", 1),
|
||||
("1:3.8.1-1", "3.8.GA-1", 1),
|
||||
("1.0.1+gpl-1", "1.0.1-2", 1),
|
||||
("1a", "1000a", -1),
|
||||
];
|
||||
|
||||
/// Unsorted lists with their expected order under dpkg comparison.
|
||||
pub(crate) const SORTED: &[(&[&str], &[&str])] = &[
|
||||
(
|
||||
&[
|
||||
"4:4-4",
|
||||
"5.0abc",
|
||||
"0.0-0.0alpha0",
|
||||
"10.100.1-1",
|
||||
"0~999.999zeta",
|
||||
"0:1.0-0",
|
||||
],
|
||||
&[
|
||||
"0~999.999zeta",
|
||||
"0.0-0.0alpha0",
|
||||
"0:1.0-0",
|
||||
"5.0abc",
|
||||
"10.100.1-1",
|
||||
"4:4-4",
|
||||
],
|
||||
),
|
||||
(
|
||||
&[
|
||||
"4",
|
||||
"5.0abc",
|
||||
"0.0alpha0",
|
||||
"10.100.1",
|
||||
"0~999.999zeta",
|
||||
"1.0",
|
||||
],
|
||||
&[
|
||||
"0~999.999zeta",
|
||||
"0.0alpha0",
|
||||
"1.0",
|
||||
"4",
|
||||
"5.0abc",
|
||||
"10.100.1",
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
fn cmp_sign(a: &DebianVersion, b: &DebianVersion) -> i32 {
|
||||
match a.cmp(b) {
|
||||
std::cmp::Ordering::Less => -1,
|
||||
std::cmp::Ordering::Equal => 0,
|
||||
std::cmp::Ordering::Greater => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// All vectors from dpkg's own `Dpkg_Version.t` must pass.
|
||||
#[test]
|
||||
fn comparison_dpkg_vectors() {
|
||||
for (a, b, expected) in test_vectors::COMPARE {
|
||||
let va = DebianVersion::parse(a).unwrap_or_else(|e| panic!("parse {a}: {e}"));
|
||||
let vb = DebianVersion::parse(b).unwrap_or_else(|e| panic!("parse {b}: {e}"));
|
||||
assert_eq!(
|
||||
cmp_sign(&va, &vb),
|
||||
*expected,
|
||||
"{a} cmp {b} must be {expected}"
|
||||
);
|
||||
// Ordering is antisymmetric.
|
||||
assert_eq!(cmp_sign(&vb, &va), -*expected, "{b} cmp {a}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_dpkg_vectors() {
|
||||
for (unsorted, expected) in test_vectors::SORTED {
|
||||
let mut versions: Vec<DebianVersion> = unsorted
|
||||
.iter()
|
||||
.map(|v| DebianVersion::parse(v).unwrap())
|
||||
.collect();
|
||||
versions.sort();
|
||||
let rendered: Vec<String> = versions.iter().map(DebianVersion::full).collect();
|
||||
let expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(rendered, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ubuntu-flavored cases: security updates, backports, PPA versions.
|
||||
#[test]
|
||||
fn comparison_ubuntu_flavored() {
|
||||
let cases: &[(&str, &str, i32)] = &[
|
||||
// Security update on top of a release upload.
|
||||
("1.0-0ubuntu1", "1.0-0ubuntu1.22.04.1", -1),
|
||||
// PPA/backports pre-releases sort before the real upload.
|
||||
("1.0-0ubuntu1~ppa1", "1.0-0ubuntu1", -1),
|
||||
("1.0~bpo22.04.1", "1.0", -1),
|
||||
// Series-specific uploads.
|
||||
("2.3-1ubuntu3.22.04.2", "2.3-1ubuntu3", 1),
|
||||
("1:2.0.4-0ubuntu1", "1:2.0.4-0ubuntu1.1", -1),
|
||||
];
|
||||
for (a, b, expected) in cases {
|
||||
let va = DebianVersion::parse(a).unwrap();
|
||||
let vb = DebianVersion::parse(b).unwrap();
|
||||
assert_eq!(cmp_sign(&va, &vb), *expected, "{a} cmp {b}");
|
||||
}
|
||||
|
||||
// later_than convenience.
|
||||
let old = DebianVersion::parse("1.0-0ubuntu1").unwrap();
|
||||
let new = DebianVersion::parse("1.0-0ubuntu1.22.04.1").unwrap();
|
||||
assert!(new.later_than(&old));
|
||||
assert!(!old.later_than(&old));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ pub mod build;
|
||||
pub mod changelog;
|
||||
/// Build a Debian package into a binary (.deb)
|
||||
pub mod deb;
|
||||
/// Reusable Debian format primitives (control/deb822, checksums, versions,
|
||||
/// changelog entries, artifact registries)
|
||||
pub mod debian;
|
||||
/// Obtain general information about distribution, series, etc
|
||||
pub mod distro_info;
|
||||
/// Obtain information about one or multiple packages
|
||||
|
||||
+27
-3
@@ -58,7 +58,11 @@ fn main() {
|
||||
.arg(arg!(--backport "This changelog is for a backport entry").required(false))
|
||||
.arg(arg!(-v --version <version> "Target version").required(false)),
|
||||
)
|
||||
.subcommand(Command::new("build").about("Build the source package (into a .dsc)"))
|
||||
.subcommand(
|
||||
Command::new("build")
|
||||
.about("Build the source package (into a .dsc)")
|
||||
.arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false)),
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("deb")
|
||||
.about("Build the source package into binary package (.deb)")
|
||||
@@ -248,10 +252,30 @@ fn main() {
|
||||
std::process::exit(1);
|
||||
});
|
||||
}
|
||||
Some(("build", _sub_matches)) => {
|
||||
Some(("build", sub_matches)) => {
|
||||
let cwd = current_dir_or_exit();
|
||||
if let Err(e) = pkh::build::build_source_package(Some(&cwd)) {
|
||||
let verbose = sub_matches
|
||||
.get_one::<bool>("verbose")
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
|
||||
// Live build view: disabled by --verbose or when stdout is not a
|
||||
// terminal (DebUi handles the non-TTY case itself)
|
||||
let ui = if verbose {
|
||||
None
|
||||
} else {
|
||||
Some(std::sync::Arc::new(pkh::ui::deb::DebUi::new(&multi)))
|
||||
};
|
||||
|
||||
if let Err(e) = pkh::build::build_source_package(Some(&cwd), ui) {
|
||||
error!("{}", e);
|
||||
// Unmet build dependencies/conflicts exit with status 3,
|
||||
// like dpkg-buildpackage does.
|
||||
if e.downcast_ref::<pkh::debian::deps::UnmetBuildDependencies>()
|
||||
.is_some()
|
||||
{
|
||||
std::process::exit(3);
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,27 @@ use crossterm::{
|
||||
};
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Render a path for terminal display: relative to the current working
|
||||
/// directory when the target lives inside it or directly next to it
|
||||
/// (`../name`, the usual layout of build artifacts), absolute otherwise.
|
||||
pub fn display_path(path: &Path) -> String {
|
||||
let Ok(cwd) = std::env::current_dir() else {
|
||||
return path.display().to_string();
|
||||
};
|
||||
if let Ok(rel) = path.strip_prefix(&cwd) {
|
||||
return rel.display().to_string();
|
||||
}
|
||||
if let Some(parent) = cwd.parent()
|
||||
&& let Ok(rel) = path.strip_prefix(parent)
|
||||
{
|
||||
return format!("../{}", rel.display());
|
||||
}
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
/// Create a spinner-style progress bar attached to `multi`, returning the bar
|
||||
/// and a callback compatible with [`crate::ProgressCallback`]
|
||||
pub fn create_progress_bar(
|
||||
|
||||
+45
-20
@@ -1,6 +1,6 @@
|
||||
//! Live UI for `pkh deb`: a status bar with the current build phase on top
|
||||
//! and a rolling pane of rewritten log lines below ("a terminal in the
|
||||
//! terminal").
|
||||
//! Live build view (`pkh deb`, `pkh build`): a status bar with the current
|
||||
//! build phase on top and a rolling pane of rewritten log lines below
|
||||
//! ("a terminal in the terminal").
|
||||
//!
|
||||
//! Subprocess output is captured through a [`LineSink`] implementation,
|
||||
//! rewritten by classifiers ([`crate::ui::logfmt`]) and rendered in place
|
||||
@@ -127,7 +127,7 @@ struct Shared {
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
/// Live build view for `pkh deb`
|
||||
/// Live build view for `pkh deb` / `pkh build`
|
||||
///
|
||||
/// Create one per build (disabled automatically when stdout is not a TTY or
|
||||
/// when the user requests verbose output), pass it down as
|
||||
@@ -202,20 +202,35 @@ impl DebUi {
|
||||
ui
|
||||
}
|
||||
|
||||
/// Identify the package being built; names the log file and the status bar
|
||||
/// Identify the binary package being built; names the log file and the
|
||||
/// status bar
|
||||
pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) {
|
||||
if self.shared.enabled {
|
||||
self.shared.top.set_prefix(format!(
|
||||
"Building {package} ({version}) for {series}/{arch}"
|
||||
));
|
||||
}
|
||||
self.open_log("deb", package, version, &format!("for {series}/{arch}"));
|
||||
}
|
||||
|
||||
// Rename the log file to include the package identity (best-effort),
|
||||
// then open it so subsequent captured lines are tee'd.
|
||||
/// Identify the source package being built; names the log file
|
||||
/// (`build-<package>-<version>-<timestamp>.log`) and the status bar
|
||||
pub fn set_build_target(&self, package: &str, version: &str, distribution: &str) {
|
||||
if self.shared.enabled {
|
||||
self.shared.top.set_prefix(format!(
|
||||
"Building source package {package} ({version}) for {distribution}"
|
||||
));
|
||||
}
|
||||
self.open_log("build", package, version, &format!("for {distribution}"));
|
||||
}
|
||||
|
||||
/// Rename the placeholder log file to include the build identity
|
||||
/// (best-effort), then open it so subsequent captured lines are tee'd
|
||||
fn open_log(&self, kind: &str, package: &str, version: &str, detail: &str) {
|
||||
let old_path = self.shared.log_path.lock().unwrap().clone();
|
||||
let log_path = match old_path.parent() {
|
||||
Some(dir) => dir.join(format!(
|
||||
"deb-{package}-{version}-{}.log",
|
||||
"{kind}-{package}-{version}-{}.log",
|
||||
self.shared.timestamp
|
||||
)),
|
||||
None => old_path.clone(),
|
||||
@@ -231,11 +246,7 @@ impl DebUi {
|
||||
Ok(mut file) => {
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"# pkh deb {} ({}) for {}/{} started {}",
|
||||
package,
|
||||
version,
|
||||
series,
|
||||
arch,
|
||||
"# pkh {kind} {package} ({version}) {detail} started {}",
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
);
|
||||
*self.shared.tee.lock().unwrap() = Some(file);
|
||||
@@ -252,12 +263,18 @@ impl DebUi {
|
||||
|
||||
/// Switch to a phase, installing its default classifier
|
||||
pub fn phase(&self, phase: Phase) {
|
||||
self.phase_with(phase, default_classifier(phase));
|
||||
self.phase_custom(phase.label(), default_classifier(phase));
|
||||
}
|
||||
|
||||
/// Switch to a phase with a custom classifier (e.g. quilt with a known
|
||||
/// patch count)
|
||||
pub fn phase_with(&self, phase: Phase, classifier: Box<dyn Classifier>) {
|
||||
self.phase_custom(phase.label(), classifier);
|
||||
}
|
||||
|
||||
/// Switch to an arbitrary status label with a custom classifier; used by
|
||||
/// flows whose phases are not part of [`Phase`] (e.g. source builds)
|
||||
pub fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
|
||||
{
|
||||
let mut st = self.shared.state.lock().unwrap();
|
||||
st.classifier = classifier;
|
||||
@@ -267,7 +284,7 @@ impl DebUi {
|
||||
}
|
||||
if self.shared.enabled {
|
||||
self.shared.top.set_style(spinner_style());
|
||||
self.shared.top.set_message(phase.label());
|
||||
self.shared.top.set_message(label.to_string());
|
||||
self.shared.pane.set_message("");
|
||||
}
|
||||
}
|
||||
@@ -299,6 +316,12 @@ impl DebUi {
|
||||
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Whether the widget renders at all (false on non-TTY stdout); callers
|
||||
/// use this to fall back to plain-line summaries
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.shared.enabled
|
||||
}
|
||||
|
||||
/// Obtain a sink feeding this view; pass it to `ContextCommand::capture`
|
||||
pub fn sink(self: &Arc<Self>) -> Arc<dyn LineSink> {
|
||||
Arc::new(Sink {
|
||||
@@ -325,14 +348,15 @@ impl DebUi {
|
||||
self.shared.pane.finish_and_clear();
|
||||
}
|
||||
|
||||
/// Clear the widget and print a success summary with the artifacts
|
||||
/// Clear the widget and print a success summary with the artifacts,
|
||||
/// rendered relative to the working directory when possible
|
||||
pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) {
|
||||
self.suspend();
|
||||
if self.shared.enabled && !artifacts.is_empty() {
|
||||
println!("Built in {}s:", elapsed.as_secs());
|
||||
for artifact in artifacts {
|
||||
println!(" → {}", artifact.display());
|
||||
println!(" {}", crate::ui::display_path(artifact));
|
||||
}
|
||||
println!(" ✔ Built in {}s", elapsed.as_secs());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,12 +518,13 @@ fn is_stdout_tty() -> bool {
|
||||
unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
|
||||
}
|
||||
|
||||
/// Default log file path for a given timestamp
|
||||
/// Default (placeholder) log file path for a given timestamp; renamed by
|
||||
/// [`DebUi::set_target`] / [`DebUi::set_build_target`] once the target is known
|
||||
fn default_log_path(timestamp: &str) -> PathBuf {
|
||||
let dir = ProjectDirs::from("com", "pkh", "pkh")
|
||||
.map(|dirs| dirs.cache_dir().join("logs"))
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
dir.join(format!("deb-{timestamp}.log"))
|
||||
dir.join(format!("pkh-{timestamp}.log"))
|
||||
}
|
||||
|
||||
static SIGINT_LOG_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||
|
||||
@@ -282,6 +282,54 @@ impl Classifier for MakeClassifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier for `dpkg-source` output (source-build phases)
|
||||
///
|
||||
/// The build pipeline pins `LC_ALL=C`, so dpkg-source emits stable English
|
||||
/// messages prefixed with `info:` / `warning:` / `error:`; the prefix is
|
||||
/// stripped and the severity drives the pane color. Raw `tar:` diagnostics
|
||||
/// emitted while repacking tarballs are surfaced too.
|
||||
#[derive(Default)]
|
||||
pub struct DpkgSourceClassifier {}
|
||||
|
||||
impl DpkgSourceClassifier {
|
||||
/// Create a new classifier
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Classifier for DpkgSourceClassifier {
|
||||
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
|
||||
const PREFIX: &str = "dpkg-source: ";
|
||||
let rest = line.strip_prefix(PREFIX).unwrap_or(line);
|
||||
|
||||
if let Some(rest) = rest.strip_prefix("info: ") {
|
||||
vec![Action::Shown(truncate(rest))]
|
||||
} else if let Some(rest) = rest.strip_prefix("warning: ") {
|
||||
vec![Action::Warning(truncate(rest))]
|
||||
} else if let Some(rest) = rest.strip_prefix("error: ") {
|
||||
vec![Action::Error(truncate(rest))]
|
||||
} else if let Some(tar) = rest.strip_prefix("tar: ") {
|
||||
// Diagnostics from the tarball repacking subprocess; warnings
|
||||
// about unknown header keywords are benign, real failures are not.
|
||||
let lower = tar.to_lowercase();
|
||||
if ["error", "cannot", "failed", "exited"]
|
||||
.iter()
|
||||
.any(|m| lower.contains(m))
|
||||
{
|
||||
vec![Action::Error(truncate(tar))]
|
||||
} else {
|
||||
vec![Action::Warning(truncate(tar))]
|
||||
}
|
||||
} else if line == PREFIX.trim_end() || rest.is_empty() {
|
||||
vec![Action::Hidden]
|
||||
} else {
|
||||
// Unprefixed output from a foreign subprocess: keep it visible
|
||||
vec![Action::Shown(truncate(line))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier for `mmdebstrap` output (chroot tarball creation)
|
||||
///
|
||||
/// mmdebstrap prefixes its own messages with `I:` / `W:` / `E:`; everything
|
||||
@@ -499,6 +547,88 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dpkg_source_severity_prefixes() {
|
||||
let mut c = DpkgSourceClassifier::new();
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: info: using patch list from debian/patches/series"
|
||||
),
|
||||
vec![Action::Shown(
|
||||
"using patch list from debian/patches/series".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: info: applying patch debian/patches/reproducible.patch"
|
||||
),
|
||||
vec![Action::Shown(
|
||||
"applying patch debian/patches/reproducible.patch".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: info: building hello in ../hello_2.10-5.dsc"
|
||||
),
|
||||
vec![Action::Shown(
|
||||
"building hello in ../hello_2.10-5.dsc".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: warning: upstream signing key but no upstream signature"
|
||||
),
|
||||
vec![Action::Warning(
|
||||
"upstream signing key but no upstream signature".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: error: unrepresentable changes to source"
|
||||
),
|
||||
vec![Action::Error(
|
||||
"unrepresentable changes to source".to_string()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dpkg_source_tar_and_unknown_lines() {
|
||||
let mut c = DpkgSourceClassifier::new();
|
||||
// Benign tar header-keyword warnings stay yellow
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"tar: Ignoring unknown extended header keyword 'SCHILY.xattr.user.foo'"
|
||||
),
|
||||
vec![Action::Warning(
|
||||
"Ignoring unknown extended header keyword 'SCHILY.xattr.user.foo'".to_string()
|
||||
)]
|
||||
);
|
||||
// Real tar failures are errors
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"tar: ../hello_2.10.orig.tar.xz: Cannot open: No such file or directory"
|
||||
),
|
||||
vec![Action::Error(
|
||||
"../hello_2.10.orig.tar.xz: Cannot open: No such file or directory".to_string()
|
||||
)]
|
||||
);
|
||||
// Unprefixed foreign output stays visible
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "gpgv: Signature made Tue 01 Jan 2026"),
|
||||
vec![Action::Shown(
|
||||
"gpgv: Signature made Tue 01 Jan 2026".to_string()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_long_lines() {
|
||||
let long = "x".repeat(300);
|
||||
|
||||
+94
-1
@@ -1,4 +1,11 @@
|
||||
use gpgme::{Context, Protocol};
|
||||
//! GPG / OpenPGP helpers: secret key discovery and inline (clear) signing
|
||||
//! of Debian artifacts such as `.dsc`, `.buildinfo` and `.changes` files.
|
||||
|
||||
use std::error::Error;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use gpgme::{Context, Data, Protocol};
|
||||
|
||||
/// Check if a GPG key matching 'email' exists
|
||||
/// Returns the key ID if found, None otherwise
|
||||
@@ -30,3 +37,89 @@ pub fn find_signing_key_for_email(
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Validate an OpenPGP key id / fingerprint like dpkg does.
|
||||
///
|
||||
/// Short (<= 8 hex chars) key IDs are rejected outright, long (16 hex chars)
|
||||
/// key IDs produce a warning; anything else must be a v4 (40) or v6 (64)
|
||||
/// fingerprint length.
|
||||
pub fn validate_key_id(keyid: &str) -> Result<(), Box<dyn Error>> {
|
||||
let len = keyid.len();
|
||||
if len <= 8 {
|
||||
return Err("short OpenPGP key IDs are broken; use a key fingerprint instead".into());
|
||||
} else if len <= 16 {
|
||||
log::warn!(
|
||||
"long OpenPGP key IDs are strongly discouraged; \
|
||||
use a key fingerprint instead"
|
||||
);
|
||||
} else if len != 40 && len != 64 {
|
||||
log::warn!("OpenPGP key ID has unknown v4 or v6 fingerprint length");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find a secret key whose fingerprint matches `keyid` (suffix matching
|
||||
/// allows passing a long key id instead of the full fingerprint).
|
||||
fn find_secret_key(ctx: &mut Context, keyid: &str) -> Result<Option<gpgme::Key>, Box<dyn Error>> {
|
||||
for key_result in ctx.secret_keys()? {
|
||||
let key = key_result?;
|
||||
if let Ok(fingerprint) = key.fingerprint()
|
||||
&& fingerprint.ends_with(keyid)
|
||||
{
|
||||
return Ok(Some(key));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Clearsign a file in place: the original content becomes the payload of an
|
||||
/// armored inline-signed document which atomically replaces the file.
|
||||
///
|
||||
/// This is the same operation as dpkg's `inline_sign` + rename sequence used
|
||||
/// when signing `.dsc`, `.buildinfo` or `.changes` files.
|
||||
pub fn clearsign_file(path: &Path, keyid: &str) -> Result<(), Box<dyn Error>> {
|
||||
let content = std::fs::read(path)
|
||||
.map_err(|e| format!("cannot read '{}' for signing: {}", path.display(), e))?;
|
||||
|
||||
let mut ctx = Context::from_protocol(Protocol::OpenPgp)
|
||||
.map_err(|e| format!("cannot initialize GPGME: {}", e))?;
|
||||
ctx.set_armor(true);
|
||||
|
||||
let key = find_secret_key(&mut ctx, keyid)?
|
||||
.ok_or_else(|| format!("no secret key matching '{}' found", keyid))?;
|
||||
|
||||
ctx.add_signer(&key)
|
||||
.map_err(|e| format!("cannot add signer '{}': {}", keyid, e))?;
|
||||
|
||||
let input = Data::from_bytes(&content)?;
|
||||
let mut output = Data::new()?;
|
||||
ctx.sign_clear(input, &mut output)
|
||||
.map_err(|e| format!("clear-signing '{}' failed: {}", path.display(), e))?;
|
||||
|
||||
// gpgme leaves the output buffer cursor at the end after writing.
|
||||
use std::io::Seek;
|
||||
use std::io::SeekFrom;
|
||||
output.seek(SeekFrom::Start(0))?;
|
||||
let mut signed = Vec::new();
|
||||
output.read_to_end(&mut signed)?;
|
||||
|
||||
// Atomic replace, like dpkg's signfile (write .asc then move).
|
||||
let tmp = path.with_extension("asc.tmp");
|
||||
std::fs::write(&tmp, &signed)
|
||||
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
|
||||
std::fs::rename(&tmp, path)
|
||||
.map_err(|e| format!("cannot install signed '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn key_id_validation() {
|
||||
assert!(validate_key_id("12345678").is_err()); // short: rejected
|
||||
assert!(validate_key_id("1234567890ABCDEF").is_ok()); // long: warns
|
||||
assert!(validate_key_id(&"a".repeat(40)).is_ok());
|
||||
assert!(validate_key_id(&"b".repeat(64)).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user