//! `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, /// Version parsed from the file name pattern, if any. pub version: Option, /// Architecture parsed from the file name pattern, if any. pub arch: Option, /// Artifact type parsed from the file name extension /// (`deb`, `udeb`, `buildinfo`, `changes`, ...). pub package_type: Option, /// Extra `key=value` attributes on the line (e.g. `automatic=yes`). pub attrs: BTreeMap, } 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 /// `__.` pattern, like /// `Dpkg::Dist::Files::parse_filename()`. #[allow(clippy::field_reassign_with_default)] pub fn parse_filename(name: &str) -> Option { // 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, } 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> { 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 { 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 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 { 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 `.new` then rename over `path`, like /// dpkg does. pub fn save_atomic(&self, path: &Path) -> Result<(), Box> { 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); } }