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:
@@ -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,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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(), "-");
|
||||
}
|
||||
}
|
||||
@@ -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(), "-");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user