dh_strip registers debug-symbol packages in debian/files under their renamed .ddeb name; they are deliverables of the build just like the regular .debs. Both artifact-type filters, the one collecting the build's artifacts and the one feeding the .buildinfo/.changes checksum sections, accepted only deb and udeb, so every ddeb a build produced was silently discarded: nothing exported next to the .debs, nothing in the upload metadata. The filter is now a shared FilesEntry::is_binary_artifact() predicate (plus unit tests) used by both call sites, so the two cannot drift apart again. One dpkg distinction has to be copied rather than shared: dpkg-genchanges lists only deb/udeb packages in the changes' Binary: and Description (a ddeb travels in Files and the checksum sections but is not a listed binary of the upload), while dpkg-genbuildinfo lists every binary artifact's package, ddebs included. The package list is therefore accumulated twice, once per consumer. The binary-metadata differential fixture gains a Package-Type: ddeb package, so the native .changes/.buildinfo generation is compared against real dpkg with a ddeb present. Without the fix the comparison fails on the checksum sections (golden distributes the ddeb, ours dropped it); with it every field matches.
307 lines
11 KiB
Rust
307 lines
11 KiB
Rust
//! `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
|
|
}
|
|
|
|
/// Whether this entry is a binary-package artifact that must be
|
|
/// exported from the build: a `.deb`, a `.udeb`, or a `.ddeb`.
|
|
///
|
|
/// Debug-symbol packages are registered in `debian/files` by
|
|
/// `dh_strip` under their renamed `.ddeb` name; they are as much
|
|
/// a deliverable of the build as the regular `.deb`s (they belong
|
|
/// in the upload set), so any filter keyed on artifact type has
|
|
/// to accept them.
|
|
pub fn is_binary_artifact(&self) -> bool {
|
|
matches!(
|
|
self.package_type.as_deref(),
|
|
Some("deb") | Some("udeb") | Some("ddeb")
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Derive metadata from an artifact file name following the common
|
|
/// `<package>_<version>_<arch>.<type>` pattern, like
|
|
/// `Dpkg::Dist::Files::parse_filename()`.
|
|
#[allow(clippy::field_reassign_with_default)]
|
|
pub fn parse_filename(name: &str) -> Option<FilesEntry> {
|
|
// Common pattern: name_version_arch.type where type is dot-separated
|
|
// (e.g. tar.xz must not match here since it has no leading underscores).
|
|
let parts: Vec<&str> = name.split('_').collect();
|
|
if parts.len() == 3 {
|
|
let (pkg, version, rest) = (parts[0], parts[1], parts[2]);
|
|
if let Some(dot) = rest.rfind('.') {
|
|
let arch = &rest[..dot];
|
|
let ptype = &rest[dot + 1..];
|
|
let valid = |s: &str| {
|
|
!s.is_empty()
|
|
&& s.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || "-+.:~".contains(c))
|
|
};
|
|
if valid(pkg) && valid(version) && valid(arch) && valid(ptype) {
|
|
return Some(FilesEntry {
|
|
filename: name.to_string(),
|
|
section: "-".to_string(),
|
|
priority: "-".to_string(),
|
|
package: Some(pkg.to_string()),
|
|
version: Some(version.to_string()),
|
|
arch: Some(arch.to_string()),
|
|
package_type: Some(ptype.to_string()),
|
|
attrs: BTreeMap::new(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// Fallback: accept a conservative file-name character set.
|
|
if !name.is_empty()
|
|
&& name
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || "-+.:,_~".contains(c))
|
|
{
|
|
return Some(FilesEntry {
|
|
filename: name.to_string(),
|
|
section: "-".to_string(),
|
|
priority: "-".to_string(),
|
|
package: None,
|
|
version: None,
|
|
arch: None,
|
|
package_type: None,
|
|
attrs: BTreeMap::new(),
|
|
});
|
|
}
|
|
None
|
|
}
|
|
|
|
/// The full `debian/files` registry, ordered by file name.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct FilesList {
|
|
files: BTreeMap<String, FilesEntry>,
|
|
}
|
|
|
|
impl FilesList {
|
|
/// An empty registry.
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Load `debian/files`. A missing file yields an empty registry.
|
|
pub fn load(path: &Path) -> Result<FilesList, Box<dyn std::error::Error>> {
|
|
let content = match std::fs::read_to_string(path) {
|
|
Ok(c) => c,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(FilesList::new()),
|
|
Err(e) => {
|
|
return Err(format!("cannot read '{}': {}", path.display(), e).into());
|
|
}
|
|
};
|
|
FilesList::parse(&content).map_err(|e| format!("in '{}': {}", path.display(), e).into())
|
|
}
|
|
|
|
/// Parse a `debian/files` registry from its textual content
|
|
/// (`filename section priority [key=value...]` lines).
|
|
pub fn parse(content: &str) -> Result<FilesList, String> {
|
|
let mut list = FilesList::new();
|
|
for line in content.lines() {
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let tokens: Vec<&str> = line.split_whitespace().collect();
|
|
if tokens.len() < 3 {
|
|
return Err(format!("badly formed line: {line}"));
|
|
}
|
|
let mut entry = parse_filename(tokens[0])
|
|
.ok_or_else(|| format!("badly formed file name: {}", tokens[0]))?;
|
|
entry.section = tokens[1].to_string();
|
|
entry.priority = tokens[2].to_string();
|
|
for attr in &tokens[3..] {
|
|
if let Some((k, v)) = attr.split_once('=') {
|
|
entry.attrs.insert(k.to_string(), v.to_string());
|
|
}
|
|
}
|
|
list.files.insert(entry.filename.clone(), entry);
|
|
}
|
|
Ok(list)
|
|
}
|
|
|
|
/// Register (or replace) an entry.
|
|
pub fn add(&mut self, entry: FilesEntry) {
|
|
self.files.insert(entry.filename.clone(), entry);
|
|
}
|
|
|
|
/// Remove entries matching a predicate. Returns how many were removed.
|
|
pub fn retain<F: FnMut(&FilesEntry) -> bool>(&mut self, mut keep: F) -> usize {
|
|
let before = self.files.len();
|
|
self.files.retain(|_, e| keep(e));
|
|
before - self.files.len()
|
|
}
|
|
|
|
/// Iterate over entries sorted by file name.
|
|
pub fn iter(&self) -> impl Iterator<Item = &FilesEntry> {
|
|
self.files.values()
|
|
}
|
|
|
|
/// Look up an entry by file name.
|
|
pub fn get(&self, filename: &str) -> Option<&FilesEntry> {
|
|
self.files.get(filename)
|
|
}
|
|
|
|
/// Number of registered files.
|
|
pub fn len(&self) -> usize {
|
|
self.files.len()
|
|
}
|
|
|
|
/// True if empty.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.files.is_empty()
|
|
}
|
|
|
|
/// Render the registry to its textual `debian/files` representation.
|
|
pub fn render(&self) -> String {
|
|
let mut out = String::new();
|
|
for entry in self.iter() {
|
|
out.push_str(&entry.filename);
|
|
out.push(' ');
|
|
out.push_str(&entry.section);
|
|
out.push(' ');
|
|
out.push_str(&entry.priority);
|
|
for (k, v) in &entry.attrs {
|
|
out.push_str(&format!(" {k}={v}"));
|
|
}
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Save atomically: write `<path>.new` then rename over `path`, like
|
|
/// dpkg does.
|
|
pub fn save_atomic(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
let tmp = path.with_extension("new");
|
|
{
|
|
let mut f = std::fs::File::create(&tmp)
|
|
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
|
|
for entry in self.iter() {
|
|
write!(f, "{} {} {}", entry.filename, entry.section, entry.priority)?;
|
|
for (k, v) in &entry.attrs {
|
|
write!(f, " {}={}", k, v)?;
|
|
}
|
|
writeln!(f)?;
|
|
}
|
|
f.flush()?;
|
|
}
|
|
std::fs::rename(&tmp, path)
|
|
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn filename_pattern_parsing() {
|
|
let e = parse_filename("hello_2.10-3_amd64.deb").unwrap();
|
|
assert_eq!(e.package.as_deref(), Some("hello"));
|
|
assert_eq!(e.version.as_deref(), Some("2.10-3"));
|
|
assert_eq!(e.arch.as_deref(), Some("amd64"));
|
|
assert_eq!(e.package_type.as_deref(), Some("deb"));
|
|
|
|
let e = parse_filename("hello_0.1_source.buildinfo").unwrap();
|
|
assert_eq!(e.package.as_deref(), Some("hello"));
|
|
assert_eq!(e.arch.as_deref(), Some("source"));
|
|
assert_eq!(e.package_type.as_deref(), Some("buildinfo"));
|
|
|
|
// Tarballs do not follow the 3-component pattern.
|
|
let e = parse_filename("hello_0.1.tar.xz").unwrap();
|
|
assert_eq!(e.package, None);
|
|
assert_eq!(e.package_type, None);
|
|
}
|
|
|
|
#[test]
|
|
fn binary_artifact_filter_accepts_ddebs() {
|
|
let deb = parse_filename("hello_2.10-3_amd64.deb").unwrap();
|
|
let udeb = parse_filename("hello_2.10-3_amd64.udeb").unwrap();
|
|
let ddeb = parse_filename("hello-dbgsym_2.10-3_amd64.ddeb").unwrap();
|
|
let buildinfo = parse_filename("hello_2.10-3_amd64.buildinfo").unwrap();
|
|
let changes = parse_filename("hello_2.10-3_amd64.changes").unwrap();
|
|
|
|
assert!(deb.is_binary_artifact());
|
|
assert!(udeb.is_binary_artifact());
|
|
assert!(ddeb.is_binary_artifact(), "ddebs must be exported");
|
|
assert!(!buildinfo.is_binary_artifact());
|
|
assert!(!changes.is_binary_artifact());
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|