From c2e1288bc5d49fab0971ba6cdea296e5dc7fe2fb Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sun, 23 Aug 2026 01:43:41 +0200 Subject: [PATCH] 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. --- src/build/buildinfo.rs | 4 +- src/build/changes.rs | 8 +- src/build/mod.rs | 37 +--- src/build/sign.rs | 94 --------- src/changelog.rs | 79 +------- .../metadata.rs => debian/changelog.rs} | 191 +----------------- src/{build => debian}/checksums.rs | 0 src/{build => debian}/control.rs | 83 +++++++- src/{build => debian}/files.rs | 0 src/debian/mod.rs | 22 ++ src/debian/version.rs | 120 +++++++++++ src/lib.rs | 3 + src/utils/gpg.rs | 97 ++++++++- 13 files changed, 346 insertions(+), 392 deletions(-) delete mode 100644 src/build/sign.rs rename src/{build/metadata.rs => debian/changelog.rs} (53%) rename src/{build => debian}/checksums.rs (100%) rename src/{build => debian}/control.rs (71%) rename src/{build => debian}/files.rs (100%) create mode 100644 src/debian/mod.rs create mode 100644 src/debian/version.rs diff --git a/src/build/buildinfo.rs b/src/build/buildinfo.rs index 7944c20..853b387 100644 --- a/src/build/buildinfo.rs +++ b/src/build/buildinfo.rs @@ -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)] diff --git a/src/build/changes.rs b/src/build/changes.rs index 4b484e4..c066c88 100644 --- a/src/build/changes.rs +++ b/src/build/changes.rs @@ -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", diff --git a/src/build/mod.rs b/src/build/mod.rs index 79db944..9189f90 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -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"); - } } diff --git a/src/build/sign.rs b/src/build/sign.rs deleted file mode 100644 index 5fcf6d4..0000000 --- a/src/build/sign.rs +++ /dev/null @@ -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> { - let len = keyid.len(); - if len <= 8 { - return Err( - "short OpenPGP key IDs are broken; use a key fingerprint instead".into(), - ); - } else if len <= 16 { - log::warn!( - "long OpenPGP key IDs are strongly discouraged; \ - use a key fingerprint instead" - ); - } else if len != 40 && len != 64 { - log::warn!("OpenPGP key ID has unknown v4 or v6 fingerprint length"); - } - Ok(()) -} - -/// Find a secret key whose fingerprint matches `keyid` (suffix matching -/// allows passing a long key id instead of the full fingerprint). -fn find_secret_key(ctx: &mut Context, keyid: &str) -> Result, Box> { - for key_result in ctx.secret_keys()? { - let key = key_result?; - if let Ok(fingerprint) = key.fingerprint() { - if fingerprint.ends_with(keyid) { - return Ok(Some(key)); - } - } - } - Ok(None) -} - -/// Clearsign a file in place: the original content becomes the payload of an -/// armored inline-signed document which atomically replaces the file. -/// -/// This is the same operation as dpkg's `inline_sign` + rename sequence. -pub fn clearsign_file(path: &Path, keyid: &str) -> Result<(), Box> { - let content = std::fs::read(path) - .map_err(|e| format!("cannot read '{}' for signing: {}", path.display(), e))?; - - let mut ctx = Context::from_protocol(Protocol::OpenPgp) - .map_err(|e| format!("cannot initialize GPGME: {}", e))?; - ctx.set_armor(true); - - let key = find_secret_key(&mut ctx, keyid)? - .ok_or_else(|| format!("no secret key matching '{}' found", keyid))?; - - ctx.add_signer(&key) - .map_err(|e| format!("cannot add signer '{}': {}", keyid, e))?; - - let input = Data::from_bytes(&content)?; - let mut output = Data::new()?; - ctx.sign_clear(input, &mut output) - .map_err(|e| format!("clear-signing '{}' failed: {}", path.display(), e))?; - - // gpgme leaves the output buffer cursor at the end after writing. - output.seek(SeekFrom::Start(0))?; - let mut signed = Vec::new(); - output.read_to_end(&mut signed)?; - - // Atomic replace, like dpkg's signfile (write .asc then move). - let tmp = path.with_extension("asc.tmp"); - std::fs::write(&tmp, &signed) - .map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?; - std::fs::rename(&tmp, path) - .map_err(|e| format!("cannot install signed '{}': {}", path.display(), e).into()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn key_id_validation() { - assert!(validate_key_id("12345678").is_err()); // short: rejected - assert!(validate_key_id("1234567890ABCDEF").is_ok()); // long: warns - assert!(validate_key_id(&"a".repeat(40)).is_ok()); - assert!(validate_key_id(&"b".repeat(64)).is_ok()); - } -} diff --git a/src/changelog.rs b/src/changelog.rs index 2e3cc47..9eb13db 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -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> { - 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> { - 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 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 Date', \ - but none was found.", - path.display() - ) - .into()) - } + let entry = crate::debian::parse_changelog_entry(path)?; + Ok((entry.maintainer_name, entry.maintainer_email)) } /* diff --git a/src/build/metadata.rs b/src/debian/changelog.rs similarity index 53% rename from src/build/metadata.rs rename to src/debian/changelog.rs index 7760118..e8ef4c7 100644 --- a/src/build/metadata.rs +++ b/src/debian/changelog.rs @@ -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, - /// Upstream version (may itself contain `-` when there is no revision). - pub upstream: String, - /// Optional Debian revision (part after the last `-`). - pub debian_revision: Option, -} - -impl DebianVersion { - /// Parse and validate a Debian version string. - pub fn parse(raw: &str) -> Result { - let raw = raw.trim(); - if raw.is_empty() { - return Err("empty version string".to_string()); - } - - let (epoch, rest) = match raw.split_once(':') { - Some((e, r)) => { - let epoch: u32 = e - .parse() - .map_err(|_| format!("invalid epoch '{}' in version '{}'", e, raw))?; - (Some(epoch), r) - } - None => (None, raw), - }; - - // The revision is everything after the last hyphen. - let (upstream, debian_revision) = match rest.rsplit_once('-') { - Some((u, r)) => (u.to_string(), Some(r.to_string())), - None => (rest.to_string(), None), - }; - - if upstream.is_empty() { - return Err(format!("missing upstream version in '{}'", raw)); - } - for c in upstream.chars() { - if !(c.is_ascii_alphanumeric() - || matches!(c, '.' | '+' | '-' | '~' | ':') - || !c.is_ascii()) - { - return Err(format!("invalid character '{}' in version '{}'", c, raw)); - } - } - if let Some(rev) = &debian_revision { - for c in rev.chars() { - if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) { - return Err(format!( - "invalid character '{}' in revision of version '{}'", - c, raw - )); - } - } - } - - Ok(DebianVersion { - epoch, - upstream, - debian_revision, - }) - } - - /// Full version string, including the epoch (`[epoch:]upstream[-rev]`). - pub fn full(&self) -> String { - match (&self.epoch, &self.debian_revision) { - (Some(e), Some(r)) => format!("{}:{}-{}", e, self.upstream, r), - (Some(e), None) => format!("{}:{}", e, self.upstream), - (None, Some(r)) => format!("{}-{}", self.upstream, r), - (None, None) => self.upstream.clone(), - } - } - - /// Version string without the epoch (`upstream[-rev]`), used in artifact - /// file names. - pub fn no_epoch(&self) -> String { - match &self.debian_revision { - Some(r) => format!("{}-{}", self.upstream, r), - None => self.upstream.clone(), - } - } -} +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, -} - -impl ControlInfo { - /// Parse a `debian/control` file. - pub fn parse(path: &Path) -> Result> { - let content = std::fs::read_to_string(path) - .map_err(|e| format!("failed to read control file '{}': {}", path.display(), e))?; - Self::from_str(&content) - .map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into()) - } - - /// Parse control content from a string. - pub fn from_str(content: &str) -> Result { - let paragraphs = parse_paragraphs(content); - let mut iter = paragraphs.into_iter(); - let source = iter - .next() - .ok_or_else(|| "control file has no paragraphs".to_string())?; - if source.get("Source").is_none() { - return Err("first control paragraph has no 'Source' field".to_string()); - } - let binaries: Vec = iter.collect(); - for bin in &binaries { - if bin.get("Package").is_none() { - return Err("binary control paragraph has no 'Package' field".to_string()); - } - } - Ok(ControlInfo { source, binaries }) - } - - /// The source package name. - pub fn source_name(&self) -> &str { - self.source.get("Source").expect("checked at parse") - } - - /// Section from the source stanza, or `'-'`. - pub fn section(&self) -> &str { - self.source.get("Section").unwrap_or("-") - } - - /// Priority from the source stanza, or `'-'`. - pub fn priority(&self) -> &str { - self.source.get("Priority").unwrap_or("-") - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn version_splitting() { - let v = DebianVersion::parse("1.2.3-4ubuntu5").unwrap(); - assert_eq!(v.epoch, None); - assert_eq!(v.upstream, "1.2.3"); - assert_eq!(v.debian_revision.as_deref(), Some("4ubuntu5")); - assert_eq!(v.full(), "1.2.3-4ubuntu5"); - assert_eq!(v.no_epoch(), "1.2.3-4ubuntu5"); - - let v = DebianVersion::parse("3:2.10-3").unwrap(); - assert_eq!(v.epoch, Some(3)); - assert_eq!(v.upstream, "2.10"); - assert_eq!(v.no_epoch(), "2.10-3"); - assert_eq!(v.full(), "3:2.10-3"); - - let v = DebianVersion::parse("1.0").unwrap(); - assert_eq!(v.debian_revision, None); - assert_eq!(v.no_epoch(), "1.0"); - } - - #[test] - fn version_validation() { - assert!(DebianVersion::parse("").is_err()); - assert!(DebianVersion::parse(":1.0").is_err()); - assert!(DebianVersion::parse("a:_b").is_err()); - assert!(DebianVersion::parse("1.0").is_ok()); - assert!(DebianVersion::parse("1.0~rc1-2").is_ok()); - } - #[test] fn changelog_parsing() { let dir = tempfile::tempdir().unwrap(); @@ -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 \nBuild-Depends: debhelper\n\nPackage: hello\nArchitecture: any\nDescription: test\n long\n", - ) - .unwrap(); - assert_eq!(ci.source_name(), "hello"); - assert_eq!(ci.section(), "utils"); - assert_eq!(ci.priority(), "optional"); - assert_eq!(ci.binaries.len(), 1); - assert_eq!(ci.binaries[0].get("Package"), Some("hello")); - } - - #[test] - fn control_defaults() { - let ci = ControlInfo::from_str("Source: x\n\nPackage: x\nDescription: d\n").unwrap(); - assert_eq!(ci.section(), "-"); - assert_eq!(ci.priority(), "-"); - } } diff --git a/src/build/checksums.rs b/src/debian/checksums.rs similarity index 100% rename from src/build/checksums.rs rename to src/debian/checksums.rs diff --git a/src/build/control.rs b/src/debian/control.rs similarity index 71% rename from src/build/control.rs rename to src/debian/control.rs index 8c80e8a..4bb14c3 100644 --- a/src/build/control.rs +++ b/src/debian/control.rs @@ -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, +} + +impl ControlInfo { + /// Parse a `debian/control` file. + pub fn parse(path: &Path) -> Result> { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read control file '{}': {}", path.display(), e))?; + Self::from_str(&content) + .map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into()) + } + + /// Parse control content from a string. + pub fn from_str(content: &str) -> Result { + let paragraphs = parse_paragraphs(content); + let mut iter = paragraphs.into_iter(); + let source = iter + .next() + .ok_or_else(|| "control file has no paragraphs".to_string())?; + if source.get("Source").is_none() { + return Err("first control paragraph has no 'Source' field".to_string()); + } + let binaries: Vec = iter.collect(); + for bin in &binaries { + if bin.get("Package").is_none() { + return Err("binary control paragraph has no 'Package' field".to_string()); + } + } + Ok(ControlInfo { source, binaries }) + } + + /// The source package name. + pub fn source_name(&self) -> &str { + self.source.get("Source").expect("checked at parse") + } + + /// Section from the source stanza, or `'-'`. + pub fn section(&self) -> &str { + self.source.get("Section").unwrap_or("-") + } + + /// Priority from the source stanza, or `'-'`. + pub fn priority(&self) -> &str { + self.source.get("Priority").unwrap_or("-") + } +} + +#[cfg(test)] +mod control_info_tests { + use super::*; + + #[test] + fn control_parsing() { + let ci = ControlInfo::from_str( + "Source: hello\nSection: utils\nPriority: optional\nMaintainer: A B \nBuild-Depends: debhelper\n\nPackage: hello\nArchitecture: any\nDescription: test\n long\n", + ) + .unwrap(); + assert_eq!(ci.source_name(), "hello"); + assert_eq!(ci.section(), "utils"); + assert_eq!(ci.priority(), "optional"); + assert_eq!(ci.binaries.len(), 1); + assert_eq!(ci.binaries[0].get("Package"), Some("hello")); + } + + #[test] + fn control_defaults() { + let ci = ControlInfo::from_str("Source: x\n\nPackage: x\nDescription: d\n").unwrap(); + assert_eq!(ci.section(), "-"); + assert_eq!(ci.priority(), "-"); + } +} diff --git a/src/build/files.rs b/src/debian/files.rs similarity index 100% rename from src/build/files.rs rename to src/debian/files.rs diff --git a/src/debian/mod.rs b/src/debian/mod.rs new file mode 100644 index 0000000..2214d81 --- /dev/null +++ b/src/debian/mod.rs @@ -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; diff --git a/src/debian/version.rs b/src/debian/version.rs new file mode 100644 index 0000000..23a0f15 --- /dev/null +++ b/src/debian/version.rs @@ -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, + /// Upstream version (may itself contain `-` when there is no revision). + pub upstream: String, + /// Optional Debian revision (part after the last `-`). + pub debian_revision: Option, +} + +impl DebianVersion { + /// Parse and validate a Debian version string. + pub fn parse(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Err("empty version string".to_string()); + } + + let (epoch, rest) = match raw.split_once(':') { + Some((e, r)) => { + let epoch: u32 = e + .parse() + .map_err(|_| format!("invalid epoch '{}' in version '{}'", e, raw))?; + (Some(epoch), r) + } + None => (None, raw), + }; + + // The revision is everything after the last hyphen. + let (upstream, debian_revision) = match rest.rsplit_once('-') { + Some((u, r)) => (u.to_string(), Some(r.to_string())), + None => (rest.to_string(), None), + }; + + if upstream.is_empty() { + return Err(format!("missing upstream version in '{}'", raw)); + } + for c in upstream.chars() { + if !(c.is_ascii_alphanumeric() + || matches!(c, '.' | '+' | '-' | '~' | ':') + || !c.is_ascii()) + { + return Err(format!("invalid character '{}' in version '{}'", c, raw)); + } + } + if let Some(rev) = &debian_revision { + for c in rev.chars() { + if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) { + return Err(format!( + "invalid character '{}' in revision of version '{}'", + c, raw + )); + } + } + } + + Ok(DebianVersion { + epoch, + upstream, + debian_revision, + }) + } + + /// Full version string, including the epoch (`[epoch:]upstream[-rev]`). + pub fn full(&self) -> String { + match (&self.epoch, &self.debian_revision) { + (Some(e), Some(r)) => format!("{}:{}-{}", e, self.upstream, r), + (Some(e), None) => format!("{}:{}", e, self.upstream), + (None, Some(r)) => format!("{}-{}", self.upstream, r), + (None, None) => self.upstream.clone(), + } + } + + /// Version string without the epoch (`upstream[-rev]`), used in artifact + /// file names. + pub fn no_epoch(&self) -> String { + match &self.debian_revision { + Some(r) => format!("{}-{}", self.upstream, r), + None => self.upstream.clone(), + } + } +} + +#[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()); + } +} diff --git a/src/lib.rs b/src/lib.rs index d82d265..596a8b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 diff --git a/src/utils/gpg.rs b/src/utils/gpg.rs index 4efe81f..2de9be3 100644 --- a/src/utils/gpg.rs +++ b/src/utils/gpg.rs @@ -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> { + let len = keyid.len(); + if len <= 8 { + return Err( + "short OpenPGP key IDs are broken; use a key fingerprint instead".into(), + ); + } else if len <= 16 { + log::warn!( + "long OpenPGP key IDs are strongly discouraged; \ + use a key fingerprint instead" + ); + } else if len != 40 && len != 64 { + log::warn!("OpenPGP key ID has unknown v4 or v6 fingerprint length"); + } + Ok(()) +} + +/// Find a secret key whose fingerprint matches `keyid` (suffix matching +/// allows passing a long key id instead of the full fingerprint). +fn find_secret_key(ctx: &mut Context, keyid: &str) -> Result, Box> { + for key_result in ctx.secret_keys()? { + let key = key_result?; + if let Ok(fingerprint) = key.fingerprint() { + if fingerprint.ends_with(keyid) { + return Ok(Some(key)); + } + } + } + Ok(None) +} + +/// Clearsign a file in place: the original content becomes the payload of an +/// armored inline-signed document which atomically replaces the file. +/// +/// This is the same operation as dpkg's `inline_sign` + rename sequence used +/// when signing `.dsc`, `.buildinfo` or `.changes` files. +pub fn clearsign_file(path: &Path, keyid: &str) -> Result<(), Box> { + let content = std::fs::read(path) + .map_err(|e| format!("cannot read '{}' for signing: {}", path.display(), e))?; + + let mut ctx = Context::from_protocol(Protocol::OpenPgp) + .map_err(|e| format!("cannot initialize GPGME: {}", e))?; + ctx.set_armor(true); + + let key = find_secret_key(&mut ctx, keyid)? + .ok_or_else(|| format!("no secret key matching '{}' found", keyid))?; + + ctx.add_signer(&key) + .map_err(|e| format!("cannot add signer '{}': {}", keyid, e))?; + + let input = Data::from_bytes(&content)?; + let mut output = Data::new()?; + ctx.sign_clear(input, &mut output) + .map_err(|e| format!("clear-signing '{}' failed: {}", path.display(), e))?; + + // gpgme leaves the output buffer cursor at the end after writing. + 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()); + } +}