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
+251
View File
@@ -0,0 +1,251 @@
//! `debian/files` registry: the contract between the build (`dh_builddeb`,
//! `dpkg-gencontrol`, ...) and the artifact generators, mirroring
//! `Dpkg::Dist::Files`.
use std::collections::BTreeMap;
use std::io::Write;
use std::path::Path;
/// One registered artifact.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilesEntry {
/// File name (relative to the parent directory of the package tree).
pub filename: String,
/// Archive section (e.g. `utils`).
pub section: String,
/// Archive priority (e.g. `optional`).
pub priority: String,
/// Package name parsed from the file name pattern, if any.
pub package: Option<String>,
/// Version parsed from the file name pattern, if any.
pub version: Option<String>,
/// Architecture parsed from the file name pattern, if any.
pub arch: Option<String>,
/// Artifact type parsed from the file name extension
/// (`deb`, `udeb`, `buildinfo`, `changes`, ...).
pub package_type: Option<String>,
/// Extra `key=value` attributes on the line (e.g. `automatic=yes`).
pub attrs: BTreeMap<String, String>,
}
impl FilesEntry {
/// Create a minimal entry with only name/section/priority; the pattern
/// fields are derived from the file name.
pub fn new(filename: &str, section: &str, priority: &str) -> FilesEntry {
let mut entry = parse_filename(filename).unwrap_or_else(|| FilesEntry {
filename: filename.to_string(),
section: "-".to_string(),
priority: "-".to_string(),
package: None,
version: None,
arch: None,
package_type: None,
attrs: BTreeMap::new(),
});
entry.section = section.to_string();
entry.priority = priority.to_string();
entry
}
}
/// Derive metadata from an artifact file name following the common
/// `<package>_<version>_<arch>.<type>` pattern, like
/// `Dpkg::Dist::Files::parse_filename()`.
#[allow(clippy::field_reassign_with_default)]
pub fn parse_filename(name: &str) -> Option<FilesEntry> {
// Common pattern: name_version_arch.type where type is dot-separated
// (e.g. tar.xz must not match here since it has no leading underscores).
let parts: Vec<&str> = name.split('_').collect();
if parts.len() == 3 {
let (pkg, version, rest) = (parts[0], parts[1], parts[2]);
if let Some(dot) = rest.rfind('.') {
let arch = &rest[..dot];
let ptype = &rest[dot + 1..];
let valid = |s: &str| !s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || "-+.:~".contains(c));
if valid(pkg) && valid(version) && valid(arch) && valid(ptype) {
return Some(FilesEntry {
filename: name.to_string(),
section: "-".to_string(),
priority: "-".to_string(),
package: Some(pkg.to_string()),
version: Some(version.to_string()),
arch: Some(arch.to_string()),
package_type: Some(ptype.to_string()),
attrs: BTreeMap::new(),
});
}
}
}
// Fallback: accept a conservative file-name character set.
if !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-+.:,_~".contains(c))
{
return Some(FilesEntry {
filename: name.to_string(),
section: "-".to_string(),
priority: "-".to_string(),
package: None,
version: None,
arch: None,
package_type: None,
attrs: BTreeMap::new(),
});
}
None
}
/// The full `debian/files` registry, ordered by file name.
#[derive(Debug, Clone, Default)]
pub struct FilesList {
files: BTreeMap<String, FilesEntry>,
}
impl FilesList {
/// An empty registry.
pub fn new() -> Self {
Self::default()
}
/// Load `debian/files`. A missing file yields an empty registry.
pub fn load(path: &Path) -> Result<FilesList, Box<dyn std::error::Error>> {
let mut list = FilesList::new();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(list),
Err(e) => {
return Err(format!("cannot read '{}': {}", path.display(), e).into());
}
};
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 3 {
return Err(format!("badly formed line in '{}': {}", path.display(), line).into());
}
let mut entry = parse_filename(tokens[0]).ok_or_else(|| {
format!("badly formed file name in '{}': {}", path.display(), tokens[0])
})?;
entry.section = tokens[1].to_string();
entry.priority = tokens[2].to_string();
for attr in &tokens[3..] {
if let Some((k, v)) = attr.split_once('=') {
entry.attrs.insert(k.to_string(), v.to_string());
}
}
list.files.insert(entry.filename.clone(), entry);
}
Ok(list)
}
/// Register (or replace) an entry.
pub fn add(&mut self, entry: FilesEntry) {
self.files.insert(entry.filename.clone(), entry);
}
/// Remove entries matching a predicate. Returns how many were removed.
pub fn retain<F: FnMut(&FilesEntry) -> bool>(&mut self, mut keep: F) -> usize {
let before = self.files.len();
self.files.retain(|_, e| keep(e));
before - self.files.len()
}
/// Iterate over entries sorted by file name.
pub fn iter(&self) -> impl Iterator<Item = &FilesEntry> {
self.files.values()
}
/// Look up an entry by file name.
pub fn get(&self, filename: &str) -> Option<&FilesEntry> {
self.files.get(filename)
}
/// Number of registered files.
pub fn len(&self) -> usize {
self.files.len()
}
/// True if empty.
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
/// Save atomically: write `<path>.new` then rename over `path`, like
/// dpkg does.
pub fn save_atomic(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let tmp = path.with_extension("new");
{
let mut f = std::fs::File::create(&tmp)
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
for entry in self.iter() {
write!(f, "{} {} {}", entry.filename, entry.section, entry.priority)?;
for (k, v) in &entry.attrs {
write!(f, " {}={}", k, v)?;
}
writeln!(f)?;
}
f.flush()?;
}
std::fs::rename(&tmp, path)
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filename_pattern_parsing() {
let e = parse_filename("hello_2.10-3_amd64.deb").unwrap();
assert_eq!(e.package.as_deref(), Some("hello"));
assert_eq!(e.version.as_deref(), Some("2.10-3"));
assert_eq!(e.arch.as_deref(), Some("amd64"));
assert_eq!(e.package_type.as_deref(), Some("deb"));
let e = parse_filename("hello_0.1_source.buildinfo").unwrap();
assert_eq!(e.package.as_deref(), Some("hello"));
assert_eq!(e.arch.as_deref(), Some("source"));
assert_eq!(e.package_type.as_deref(), Some("buildinfo"));
// Tarballs do not follow the 3-component pattern.
let e = parse_filename("hello_0.1.tar.xz").unwrap();
assert_eq!(e.package, None);
assert_eq!(e.package_type, None);
}
#[test]
fn load_save_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("files");
let mut list = FilesList::new();
list.add(FilesEntry::new("hello_1.0_amd64.deb", "devel", "optional"));
list.add(FilesEntry::new("hello_1.0_source.buildinfo", "-", "-"));
list.save_atomic(&path).unwrap();
let reloaded = FilesList::load(&path).unwrap();
assert_eq!(reloaded.len(), 2);
let deb = reloaded.get("hello_1.0_amd64.deb").unwrap();
assert_eq!(deb.section, "devel");
assert_eq!(deb.priority, "optional");
// Missing file loads as empty.
let missing = FilesList::load(&dir.path().join("nonexistent")).unwrap();
assert!(missing.is_empty());
}
#[test]
fn retain_removes_matching() {
let mut list = FilesList::new();
list.add(FilesEntry::new("x_1_source.buildinfo", "-", "-"));
list.add(FilesEntry::new("x_1_amd64.deb", "-", "-"));
let removed = list.retain(|e| e.package_type.as_deref() != Some("buildinfo"));
assert_eq!(removed, 1);
assert_eq!(list.len(), 1);
}
}