build,debian: extract reusable Debian format primitives from build/

Move the generic components out of src/build/ into a new src/debian/
module so they can be reused independently of the build pipeline:
deb822 control parsing (plus the debian/control model), file checksum
registry, debian/files registry, Debian version handling and changelog
entry parsing.

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

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

src/build/ keeps only build-specific logic: the pipeline driver,
build types, environment setup and the .buildinfo/.changes writers.
This commit is contained in:
2026-08-23 01:43:41 +02:00
parent 9d2519ed7b
commit c2e1288bc5
13 changed files with 346 additions and 392 deletions
+2 -2
View File
@@ -5,8 +5,8 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use super::checksums::FileChecksums;
use super::control::{parse_paragraphs, write_paragraph, Paragraph};
use crate::debian::checksums::FileChecksums;
use crate::debian::control::{parse_paragraphs, write_paragraph, Paragraph};
/// One installed package relevant for dependency resolution.
#[derive(Debug, Clone)]
+4 -4
View File
@@ -4,9 +4,9 @@
use std::path::Path;
use super::checksums::FileChecksums;
use super::control::{write_paragraph, Paragraph};
use super::files::FilesList;
use crate::debian::checksums::FileChecksums;
use crate::debian::control::{write_paragraph, Paragraph};
use crate::debian::files::FilesList;
/// Everything needed to render a `.changes` file.
#[derive(Debug, Clone)]
@@ -178,7 +178,7 @@ mod tests {
checksums.add_file(&dsc_path).unwrap();
let mut files_list = FilesList::new();
files_list.add(super::super::files::FilesEntry::new(
files_list.add(crate::debian::FilesEntry::new(
"pkg_1.0.dsc",
"utils",
"optional",
+10 -27
View File
@@ -9,22 +9,16 @@
pub mod buildinfo;
pub mod buildtype;
pub mod changes;
pub mod checksums;
pub mod control;
pub mod env;
pub mod files;
pub mod metadata;
pub mod sign;
use std::collections::{BTreeMap, HashMap};
use std::error::Error;
use std::path::{Path, PathBuf};
use std::process::Command;
use checksums::{Entry as ChecksumEntry, FileChecksums};
use control::parse_paragraphs;
use files::{FilesEntry, FilesList};
use metadata::ControlInfo;
use crate::debian::{
parse_paragraphs, ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList,
};
/// Options for a native source-package build.
#[derive(Debug, Clone, Default)]
@@ -114,7 +108,7 @@ pub fn run_source_build(
// ------------------------------------------------------------------
// 2. Metadata resolution
// ------------------------------------------------------------------
let entry = metadata::parse_changelog_entry(&changelog_path)?;
let entry = crate::debian::parse_changelog_entry(&changelog_path)?;
let ctrl = ControlInfo::parse(&control_path)?;
log::info!("source package {}", entry.source);
@@ -346,22 +340,22 @@ pub fn run_source_build(
// ------------------------------------------------------------------
let mut signed = false;
if let Some(keyid) = signing_key.filter(|_| do_sign) {
sign::validate_key_id(&keyid)?;
crate::utils::gpg::validate_key_id(&keyid)?;
println!("signfile {}", dsc_name);
sign::clearsign_file(&dsc_path, &keyid)?;
crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?;
// The .dsc changed: refresh its checksums inside the .buildinfo.
checksums.add_file_as(&dsc_path, &dsc_name)?;
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
println!("signfile {}", buildinfo_name);
sign::clearsign_file(&buildinfo_path, &keyid)?;
crate::utils::gpg::clearsign_file(&buildinfo_path, &keyid)?;
// Both .dsc and .buildinfo changed: refresh the .changes.
checksums.add_file_as(&buildinfo_path, &buildinfo_name)?;
changes::save_changes(&changes_path, &render_changes_doc(&checksums))?;
println!("signfile {}", changes_name);
sign::clearsign_file(&changes_path, &keyid)?;
crate::utils::gpg::clearsign_file(&changes_path, &keyid)?;
signed = true;
}
@@ -418,13 +412,10 @@ fn run_command(
Ok(())
}
// Re-export commonly used types at the module root.
pub use metadata::{ChangelogEntry, DebianVersion};
#[cfg(test)]
mod tests {
use super::*;
use buildtype::BuildType;
use crate::debian::DebianVersion;
#[test]
fn partial_checksum_defaults() {
@@ -434,16 +425,8 @@ mod tests {
}
#[test]
fn debian_version_reexport_usable() {
fn debian_version_from_debian_module_usable() {
let v = DebianVersion::parse("1.0-2").unwrap();
assert_eq!(v.no_epoch(), "1.0-2");
}
// The full pipeline is exercised end-to-end by running pkh against a
// fixture package; unit tests cover the individual stages above.
#[test]
fn build_type_source_only_pipeline_mapping() {
// Source-only builds always map to the 'source' artifact suffix.
assert_eq!(buildtype::SOURCE.arch_suffix("amd64"), "source");
}
}
-94
View File
@@ -1,94 +0,0 @@
//! OpenPGP inline (clear) signing of `.dsc`, `.buildinfo` and `.changes`
//! files through `gpgme`, mirroring what `dpkg-buildpackage` does via its
//! OpenPGP backends.
use std::error::Error;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use gpgme::{Context, Data, Protocol};
/// Validate an OpenPGP key id / fingerprint like dpkg does.
///
/// Short (<= 8 hex chars) key IDs are rejected outright, long (16 hex chars)
/// key IDs produce a warning; anything else must be a v4 (40) or v6 (64)
/// fingerprint length.
pub fn validate_key_id(keyid: &str) -> Result<(), Box<dyn Error>> {
let len = keyid.len();
if len <= 8 {
return Err(
"short OpenPGP key IDs are broken; use a key fingerprint instead".into(),
);
} else if len <= 16 {
log::warn!(
"long OpenPGP key IDs are strongly discouraged; \
use a key fingerprint instead"
);
} else if len != 40 && len != 64 {
log::warn!("OpenPGP key ID has unknown v4 or v6 fingerprint length");
}
Ok(())
}
/// Find a secret key whose fingerprint matches `keyid` (suffix matching
/// allows passing a long key id instead of the full fingerprint).
fn find_secret_key(ctx: &mut Context, keyid: &str) -> Result<Option<gpgme::Key>, Box<dyn Error>> {
for key_result in ctx.secret_keys()? {
let key = key_result?;
if let Ok(fingerprint) = key.fingerprint() {
if fingerprint.ends_with(keyid) {
return Ok(Some(key));
}
}
}
Ok(None)
}
/// Clearsign a file in place: the original content becomes the payload of an
/// armored inline-signed document which atomically replaces the file.
///
/// This is the same operation as dpkg's `inline_sign` + rename sequence.
pub fn clearsign_file(path: &Path, keyid: &str) -> Result<(), Box<dyn Error>> {
let content = std::fs::read(path)
.map_err(|e| format!("cannot read '{}' for signing: {}", path.display(), e))?;
let mut ctx = Context::from_protocol(Protocol::OpenPgp)
.map_err(|e| format!("cannot initialize GPGME: {}", e))?;
ctx.set_armor(true);
let key = find_secret_key(&mut ctx, keyid)?
.ok_or_else(|| format!("no secret key matching '{}' found", keyid))?;
ctx.add_signer(&key)
.map_err(|e| format!("cannot add signer '{}': {}", keyid, e))?;
let input = Data::from_bytes(&content)?;
let mut output = Data::new()?;
ctx.sign_clear(input, &mut output)
.map_err(|e| format!("clear-signing '{}' failed: {}", path.display(), e))?;
// gpgme leaves the output buffer cursor at the end after writing.
output.seek(SeekFrom::Start(0))?;
let mut signed = Vec::new();
output.read_to_end(&mut signed)?;
// Atomic replace, like dpkg's signfile (write .asc then move).
let tmp = path.with_extension("asc.tmp");
std::fs::write(&tmp, &signed)
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
std::fs::rename(&tmp, path)
.map_err(|e| format!("cannot install signed '{}': {}", path.display(), e).into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_id_validation() {
assert!(validate_key_id("12345678").is_err()); // short: rejected
assert!(validate_key_id("1234567890ABCDEF").is_ok()); // long: warns
assert!(validate_key_id(&"a".repeat(40)).is_ok());
assert!(validate_key_id(&"b".repeat(64)).is_ok());
}
}
+5 -74
View File
@@ -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,95 +1,10 @@
//! Source package metadata: Debian version splitting, changelog entry
//! parsing and `debian/control` parsing.
//! Debian changelog entry parsing (`debian/changelog`).
use std::path::Path;
use chrono::DateTime;
use super::control::{parse_paragraphs, Paragraph};
/// A Debian version, split into its `[epoch:]upstream[-revision]` parts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DebianVersion {
/// Optional numeric epoch (part before the first `:`).
pub epoch: Option<u32>,
/// Upstream version (may itself contain `-` when there is no revision).
pub upstream: String,
/// Optional Debian revision (part after the last `-`).
pub debian_revision: Option<String>,
}
impl DebianVersion {
/// Parse and validate a Debian version string.
pub fn parse(raw: &str) -> Result<DebianVersion, String> {
let raw = raw.trim();
if raw.is_empty() {
return Err("empty version string".to_string());
}
let (epoch, rest) = match raw.split_once(':') {
Some((e, r)) => {
let epoch: u32 = e
.parse()
.map_err(|_| format!("invalid epoch '{}' in version '{}'", e, raw))?;
(Some(epoch), r)
}
None => (None, raw),
};
// The revision is everything after the last hyphen.
let (upstream, debian_revision) = match rest.rsplit_once('-') {
Some((u, r)) => (u.to_string(), Some(r.to_string())),
None => (rest.to_string(), None),
};
if upstream.is_empty() {
return Err(format!("missing upstream version in '{}'", raw));
}
for c in upstream.chars() {
if !(c.is_ascii_alphanumeric()
|| matches!(c, '.' | '+' | '-' | '~' | ':')
|| !c.is_ascii())
{
return Err(format!("invalid character '{}' in version '{}'", c, raw));
}
}
if let Some(rev) = &debian_revision {
for c in rev.chars() {
if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) {
return Err(format!(
"invalid character '{}' in revision of version '{}'",
c, raw
));
}
}
}
Ok(DebianVersion {
epoch,
upstream,
debian_revision,
})
}
/// Full version string, including the epoch (`[epoch:]upstream[-rev]`).
pub fn full(&self) -> String {
match (&self.epoch, &self.debian_revision) {
(Some(e), Some(r)) => format!("{}:{}-{}", e, self.upstream, r),
(Some(e), None) => format!("{}:{}", e, self.upstream),
(None, Some(r)) => format!("{}-{}", self.upstream, r),
(None, None) => self.upstream.clone(),
}
}
/// Version string without the epoch (`upstream[-rev]`), used in artifact
/// file names.
pub fn no_epoch(&self) -> String {
match &self.debian_revision {
Some(r) => format!("{}-{}", self.upstream, r),
None => self.upstream.clone(),
}
}
}
use super::version::DebianVersion;
/// A parsed `debian/changelog` entry (the most recent one).
#[derive(Debug, Clone)]
@@ -274,92 +189,10 @@ fn looks_like_header(line: &str) -> bool {
}
}
/// Parsed `debian/control`: the source stanza plus all binary stanzas.
#[derive(Debug, Clone)]
pub struct ControlInfo {
/// First paragraph (source package stanza).
pub source: Paragraph,
/// Remaining paragraphs (binary package stanzas).
pub binaries: Vec<Paragraph>,
}
impl ControlInfo {
/// Parse a `debian/control` file.
pub fn parse(path: &Path) -> Result<ControlInfo, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read control file '{}': {}", path.display(), e))?;
Self::from_str(&content)
.map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into())
}
/// Parse control content from a string.
pub fn from_str(content: &str) -> Result<ControlInfo, String> {
let paragraphs = parse_paragraphs(content);
let mut iter = paragraphs.into_iter();
let source = iter
.next()
.ok_or_else(|| "control file has no paragraphs".to_string())?;
if source.get("Source").is_none() {
return Err("first control paragraph has no 'Source' field".to_string());
}
let binaries: Vec<Paragraph> = iter.collect();
for bin in &binaries {
if bin.get("Package").is_none() {
return Err("binary control paragraph has no 'Package' field".to_string());
}
}
Ok(ControlInfo { source, binaries })
}
/// The source package name.
pub fn source_name(&self) -> &str {
self.source.get("Source").expect("checked at parse")
}
/// Section from the source stanza, or `'-'`.
pub fn section(&self) -> &str {
self.source.get("Section").unwrap_or("-")
}
/// Priority from the source stanza, or `'-'`.
pub fn priority(&self) -> &str {
self.source.get("Priority").unwrap_or("-")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_splitting() {
let v = DebianVersion::parse("1.2.3-4ubuntu5").unwrap();
assert_eq!(v.epoch, None);
assert_eq!(v.upstream, "1.2.3");
assert_eq!(v.debian_revision.as_deref(), Some("4ubuntu5"));
assert_eq!(v.full(), "1.2.3-4ubuntu5");
assert_eq!(v.no_epoch(), "1.2.3-4ubuntu5");
let v = DebianVersion::parse("3:2.10-3").unwrap();
assert_eq!(v.epoch, Some(3));
assert_eq!(v.upstream, "2.10");
assert_eq!(v.no_epoch(), "2.10-3");
assert_eq!(v.full(), "3:2.10-3");
let v = DebianVersion::parse("1.0").unwrap();
assert_eq!(v.debian_revision, None);
assert_eq!(v.no_epoch(), "1.0");
}
#[test]
fn version_validation() {
assert!(DebianVersion::parse("").is_err());
assert!(DebianVersion::parse(":1.0").is_err());
assert!(DebianVersion::parse("a:_b").is_err());
assert!(DebianVersion::parse("1.0").is_ok());
assert!(DebianVersion::parse("1.0~rc1-2").is_ok());
}
#[test]
fn changelog_parsing() {
let dir = tempfile::tempdir().unwrap();
@@ -405,24 +238,4 @@ pkg (1.0-1+b1) unstable; urgency=medium, binary-only=yes
assert!(entry.binary_only);
assert_eq!(entry.version.full(), "1.0-1+b1");
}
#[test]
fn control_parsing() {
let ci = ControlInfo::from_str(
"Source: hello\nSection: utils\nPriority: optional\nMaintainer: A B <a@b.c>\nBuild-Depends: debhelper\n\nPackage: hello\nArchitecture: any\nDescription: test\n long\n",
)
.unwrap();
assert_eq!(ci.source_name(), "hello");
assert_eq!(ci.section(), "utils");
assert_eq!(ci.priority(), "optional");
assert_eq!(ci.binaries.len(), 1);
assert_eq!(ci.binaries[0].get("Package"), Some("hello"));
}
#[test]
fn control_defaults() {
let ci = ControlInfo::from_str("Source: x\n\nPackage: x\nDescription: d\n").unwrap();
assert_eq!(ci.section(), "-");
assert_eq!(ci.priority(), "-");
}
}
+82 -1
View File
@@ -1,10 +1,13 @@
//! Minimal Debian control-file (deb822) paragraph parser and writer.
//! 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
@@ -210,3 +213,81 @@ mod tests {
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))?;
Self::from_str(&content)
.map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into())
}
/// Parse control content from a string.
pub fn from_str(content: &str) -> Result<ControlInfo, String> {
let paragraphs = parse_paragraphs(content);
let mut iter = paragraphs.into_iter();
let source = iter
.next()
.ok_or_else(|| "control file has no paragraphs".to_string())?;
if source.get("Source").is_none() {
return Err("first control paragraph has no 'Source' field".to_string());
}
let binaries: Vec<Paragraph> = iter.collect();
for bin in &binaries {
if bin.get("Package").is_none() {
return Err("binary control paragraph has no 'Package' field".to_string());
}
}
Ok(ControlInfo { source, binaries })
}
/// The source package name.
pub fn source_name(&self) -> &str {
self.source.get("Source").expect("checked at parse")
}
/// Section from the source stanza, or `'-'`.
pub fn section(&self) -> &str {
self.source.get("Section").unwrap_or("-")
}
/// Priority from the source stanza, or `'-'`.
pub fn priority(&self) -> &str {
self.source.get("Priority").unwrap_or("-")
}
}
#[cfg(test)]
mod control_info_tests {
use super::*;
#[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(), "-");
}
}
+22
View File
@@ -0,0 +1,22 @@
//! Reusable Debian format primitives.
//!
//! These components are independent from any build orchestration and can be
//! used by any pkh submodule (or external consumers of the library):
//!
//! - [`control`]: deb822 paragraph parsing/writing and `debian/control`
//! - [`checksums`]: file checksum registry (`Dpkg::Checksums` equivalent)
//! - [`files`]: `debian/files` artifact registry (`Dpkg::Dist::Files`)
//! - [`version`]: Debian version splitting/validation
//! - [`changelog`]: `debian/changelog` entry parsing
pub mod changelog;
pub mod checksums;
pub mod control;
pub mod files;
pub mod version;
pub use changelog::{parse_changelog_entry, ChangelogEntry};
pub use checksums::{Entry as ChecksumEntry, FileChecksums};
pub use control::{parse_paragraphs, write_paragraph, ControlInfo, Paragraph};
pub use files::{FilesEntry, FilesList};
pub use version::DebianVersion;
+120
View File
@@ -0,0 +1,120 @@
//! Debian version handling: splitting and validation of
//! `[epoch:]upstream[-revision]` version strings.
/// A Debian version, split into its `[epoch:]upstream[-revision]` parts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DebianVersion {
/// Optional numeric epoch (part before the first `:`).
pub epoch: Option<u32>,
/// Upstream version (may itself contain `-` when there is no revision).
pub upstream: String,
/// Optional Debian revision (part after the last `-`).
pub debian_revision: Option<String>,
}
impl DebianVersion {
/// Parse and validate a Debian version string.
pub fn parse(raw: &str) -> Result<DebianVersion, String> {
let raw = raw.trim();
if raw.is_empty() {
return Err("empty version string".to_string());
}
let (epoch, rest) = match raw.split_once(':') {
Some((e, r)) => {
let epoch: u32 = e
.parse()
.map_err(|_| format!("invalid epoch '{}' in version '{}'", e, raw))?;
(Some(epoch), r)
}
None => (None, raw),
};
// The revision is everything after the last hyphen.
let (upstream, debian_revision) = match rest.rsplit_once('-') {
Some((u, r)) => (u.to_string(), Some(r.to_string())),
None => (rest.to_string(), None),
};
if upstream.is_empty() {
return Err(format!("missing upstream version in '{}'", raw));
}
for c in upstream.chars() {
if !(c.is_ascii_alphanumeric()
|| matches!(c, '.' | '+' | '-' | '~' | ':')
|| !c.is_ascii())
{
return Err(format!("invalid character '{}' in version '{}'", c, raw));
}
}
if let Some(rev) = &debian_revision {
for c in rev.chars() {
if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) {
return Err(format!(
"invalid character '{}' in revision of version '{}'",
c, raw
));
}
}
}
Ok(DebianVersion {
epoch,
upstream,
debian_revision,
})
}
/// Full version string, including the epoch (`[epoch:]upstream[-rev]`).
pub fn full(&self) -> String {
match (&self.epoch, &self.debian_revision) {
(Some(e), Some(r)) => format!("{}:{}-{}", e, self.upstream, r),
(Some(e), None) => format!("{}:{}", e, self.upstream),
(None, Some(r)) => format!("{}-{}", self.upstream, r),
(None, None) => self.upstream.clone(),
}
}
/// Version string without the epoch (`upstream[-rev]`), used in artifact
/// file names.
pub fn no_epoch(&self) -> String {
match &self.debian_revision {
Some(r) => format!("{}-{}", self.upstream, r),
None => self.upstream.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_splitting() {
let v = DebianVersion::parse("1.2.3-4ubuntu5").unwrap();
assert_eq!(v.epoch, None);
assert_eq!(v.upstream, "1.2.3");
assert_eq!(v.debian_revision.as_deref(), Some("4ubuntu5"));
assert_eq!(v.full(), "1.2.3-4ubuntu5");
assert_eq!(v.no_epoch(), "1.2.3-4ubuntu5");
let v = DebianVersion::parse("3:2.10-3").unwrap();
assert_eq!(v.epoch, Some(3));
assert_eq!(v.upstream, "2.10");
assert_eq!(v.no_epoch(), "2.10-3");
assert_eq!(v.full(), "3:2.10-3");
let v = DebianVersion::parse("1.0").unwrap();
assert_eq!(v.debian_revision, None);
assert_eq!(v.no_epoch(), "1.0");
}
#[test]
fn version_validation() {
assert!(DebianVersion::parse("").is_err());
assert!(DebianVersion::parse(":1.0").is_err());
assert!(DebianVersion::parse("a:_b").is_err());
assert!(DebianVersion::parse("1.0").is_ok());
assert!(DebianVersion::parse("1.0~rc1-2").is_ok());
}
}
+3
View File
@@ -9,6 +9,9 @@ pub mod apt;
pub mod build;
/// Parse or edit a Debian changelog of a source package
pub mod changelog;
/// Reusable Debian format primitives (control/deb822, checksums, versions,
/// changelog entries, artifact registries)
pub mod debian;
/// Build a Debian package into a binary (.deb)
pub mod deb;
/// Obtain general information about distribution, series, etc
+96 -1
View File
@@ -1,4 +1,11 @@
use gpgme::{Context, Protocol};
//! GPG / OpenPGP helpers: secret key discovery and inline (clear) signing
//! of Debian artifacts such as `.dsc`, `.buildinfo` and `.changes` files.
use std::error::Error;
use std::io::Read;
use std::path::Path;
use gpgme::{Context, Data, Protocol};
/// Check if a GPG key matching 'email' exists
/// Returns the key ID if found, None otherwise
@@ -30,3 +37,91 @@ 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() {
if fingerprint.ends_with(keyid) {
return Ok(Some(key));
}
}
}
Ok(None)
}
/// Clearsign a file in place: the original content becomes the payload of an
/// armored inline-signed document which atomically replaces the file.
///
/// This is the same operation as dpkg's `inline_sign` + rename sequence 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());
}
}