//! 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. /// /// The parser is lenient: duplicate field names are kept as separate entries /// (accessors see the first one; `set` collapses them back to a single one). /// /// Values are stored with continuation-line breaks as `\n` and without the /// leading whitespace of continuation lines. Serialization re-adds a single /// leading space in front of every continuation line, matching dpkg output; /// blank lines inside a value are encoded as ` .` (and decoded back) so they /// survive a write/parse round-trip. #[derive(Debug, Clone, Default, PartialEq)] pub struct Paragraph { fields: Vec<(String, String)>, } impl Paragraph { /// Create an empty paragraph. pub fn new() -> Self { Self::default() } /// Look up a field value (case-insensitive field name). /// /// Returns the first match. The parser is lenient and keeps duplicate /// field names as-is; use [`Paragraph::iter`] to reach the other /// occurrences. [`Paragraph::set`] collapses them. pub fn get(&self, field: &str) -> Option<&str> { self.fields .iter() .find(|(k, _)| k.eq_ignore_ascii_case(field)) .map(|(_, v)| v.as_str()) } /// Set a field value, replacing all case-insensitive duplicates: after /// the call at most one entry with this field name remains — the updated /// one, kept at its original position. Appends the field at the end if /// no entry existed yet. pub fn set(&mut self, field: &str, value: &str) { let mut updated = false; self.fields.retain_mut(|(k, v)| { if k.eq_ignore_ascii_case(field) { if updated { return false; } *v = value.to_string(); updated = true; } true }); if !updated { self.fields.push((field.to_string(), value.to_string())); } } /// Remove a field (case-insensitive). Returns true if it was present. pub fn remove(&mut self, field: &str) -> bool { let before = self.fields.len(); self.fields.retain(|(k, _)| !k.eq_ignore_ascii_case(field)); self.fields.len() != before } /// Iterate over the `(field, value)` pairs in order. pub fn iter(&self) -> impl Iterator { self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str())) } /// Return true if the paragraph holds no field. pub fn is_empty(&self) -> bool { self.fields.is_empty() } } /// Parse a deb822 document into a list of paragraphs. /// /// Comment lines (starting with `#`) are ignored. Blank lines separate /// paragraphs. Continuation lines must start with a space or a tab; exactly /// one leading space (or tab) is stripped from the stored value, and a /// continuation whose content is a lone `.` decodes to an empty line /// (dpkg's encoding for blank lines inside field values). pub fn parse_paragraphs(input: &str) -> Vec { let mut paragraphs = Vec::new(); let mut current = Paragraph::new(); let mut last_field: Option = None; for raw_line in input.lines() { let line = raw_line.strip_suffix('\r').unwrap_or(raw_line); // Comments and blank lines if line.starts_with('#') { continue; } if line.trim().is_empty() { if !current.is_empty() { paragraphs.push(std::mem::take(&mut current)); last_field = None; } continue; } // Continuation line if line.starts_with(' ') || line.starts_with('\t') { // Exactly one leading space or tab is stripped. let content = line .strip_prefix(' ') .or_else(|| line.strip_prefix('\t')) .unwrap_or(line); // dpkg encodes a blank line inside a value as a lone `.` after // the leading whitespace; mirror that on read. let content = if content == "." { "" } else { content }; if let Some(field) = &last_field && let Some((_, v)) = current .fields .iter_mut() .rev() .find(|(k, _)| k.eq_ignore_ascii_case(field)) { v.push('\n'); v.push_str(content); continue; } // Continuation without a preceding field line: skip it (malformed) continue; } // Field line: `Name: value` if let Some(colon) = line.find(':') { let name = line[..colon].trim(); let value = line[colon + 1..].trim_start(); if name.is_empty() { continue; } current.fields.push((name.to_string(), value.to_string())); last_field = Some(name.to_string()); } // Anything else is malformed: ignore the line } if !current.is_empty() { paragraphs.push(current); } paragraphs } /// Serialize a paragraph to its deb822 textual representation (with a /// trailing newline). /// /// A value starting with `\n` is rendered as a field with no inline first /// line (`Field:` followed by ` line` continuations), matching dpkg output /// for pre-wrapped values such as `Changes`, `Files` or `Environment`. pub fn write_paragraph(p: &Paragraph) -> String { let mut out = String::new(); for (name, value) in p.iter() { out.push_str(name); out.push(':'); let mut lines = value.split('\n').peekable(); // An empty first segment means: no value on the field header line; // discard it so it is not rendered as an empty continuation line. if lines.peek().is_some_and(|first| !first.is_empty()) { out.push(' '); out.push_str(lines.next().unwrap()); } else { lines.next(); } for line in lines { out.push('\n'); if line.is_empty() { // dpkg encodes a blank line inside a value as ` .`; writing a // bare continuation line would be mistaken for a paragraph // separator on re-parse and silently drop the rest. out.push_str(" ."); } else { out.push(' '); out.push_str(line); } } out.push('\n'); } out } /// Return the signed body of a clearsigned message, as a slice of `text`. /// /// If `text` starts with the OpenPGP clearsigned-marker line, the armor /// header block (the `Hash: ...` line and any `Comment:` lines, up to and /// including the blank line that closes the header) is skipped, and the /// result is cut at the `-----BEGIN PGP SIGNATURE-----` marker so the /// signature trailer is dropped as well. This keeps the armor metadata from /// being parsed as deb822 fields (`Hash:` would otherwise land in the first /// stanza and `Comment:` in the last one). /// /// Input that is not clearsigned is returned unchanged, so callers can apply /// this unconditionally before parsing. pub fn strip_clearsigned_armour(text: &str) -> &str { const BEGIN_SIGNED: &str = "-----BEGIN PGP SIGNED MESSAGE-----"; const BEGIN_SIGNATURE: &str = "-----BEGIN PGP SIGNATURE-----"; if !text.starts_with(BEGIN_SIGNED) { return text; } // Walk past the armor headers to the blank line that precedes the body. let mut body = text; loop { match body.split_once('\n') { Some((line, remainder)) => { body = remainder; // An empty line ends the armor header block (`\r` covers a // CRLF-terminated blank line). if line.is_empty() || line == "\r" { break; } } // Malformed armor: no body at all. None => return "", } } // Cut off the signature block, if present. match body.find(BEGIN_SIGNATURE) { Some(i) => &body[..i], None => body, } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_simple_control() { let input = "Source: hello\nSection: devel\n\nPackage: hello\nDepends: libc6\n"; let paras = parse_paragraphs(input); assert_eq!(paras.len(), 2); assert_eq!(paras[0].get("Source"), Some("hello")); assert_eq!(paras[0].get("section"), Some("devel")); assert_eq!(paras[1].get("Package"), Some("hello")); assert_eq!(paras[1].get("Depends"), Some("libc6")); } #[test] fn parse_multiline_and_comments() { let input = "# a comment\nDescription: short\n long description\n" // .to_string() + " spanning lines\n\nPackage: x\n"; let paras = parse_paragraphs(&input); assert_eq!(paras.len(), 2); assert_eq!( paras[0].get("Description"), Some("short\nlong description\nspanning lines") ); } #[test] fn roundtrip_multiline() { let value = "short\nlong description\nspanning lines"; let mut p = Paragraph::new(); p.set("Description", value); let text = write_paragraph(&p); assert_eq!( text, "Description: short\n long description\n spanning lines\n" ); let reparsed = parse_paragraphs(&text); assert_eq!(reparsed[0].get("Description"), Some(value)); } #[test] fn blank_lines_survive_roundtrip() { let mut p = Paragraph::new(); p.set("Description", "a\n\nb"); let text = write_paragraph(&p); // dpkg encoding: a blank line inside a value is written as ` .`. assert_eq!(text, "Description: a\n .\n b\n"); // parse -> write -> parse must not lose data. let reparsed = parse_paragraphs(&text); assert_eq!(reparsed[0].get("Description"), Some("a\n\nb")); assert_eq!( parse_paragraphs(&write_paragraph(&reparsed[0]))[0].get("Description"), Some("a\n\nb") ); } #[test] fn parse_lone_dot_continuation_is_blank_line() { let paras = parse_paragraphs("Description:\n a\n .\n b\n"); assert_eq!(paras[0].get("Description"), Some("\na\n\nb")); } #[test] fn tab_continuation_strips_exactly_one_tab() { let paras = parse_paragraphs("Description: a\n\tb\n\t\tdeep\n"); assert_eq!(paras[0].get("Description"), Some("a\nb\n\tdeep")); } #[test] fn strip_armour_extracts_signed_dsc_body() { let signed = "\ -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 Format: 3.0 (native) Source: hello Binary: hello Architecture: any Version: 1.0-1 Checksums-Sha256: abc 100 hello_1.0.tar.gz -----BEGIN PGP SIGNATURE----- iQEcBAABCgAGBQJabcdAAoJEL abc -----END PGP SIGNATURE----- "; let body = strip_clearsigned_armour(signed); assert!(body.starts_with("Format:")); assert!(!body.contains("SIGNATURE")); let paras = parse_paragraphs(body); assert_eq!(paras.len(), 1); // The armor `Hash:` header must not land in the stanza... assert!(paras[0].get("Hash").is_none()); assert_eq!(paras[0].get("Source"), Some("hello")); // ...and the signature trailer must not contribute a `Comment:` field. assert!(paras[0].get("Comment").is_none()); assert_eq!( paras[0].get("Checksums-Sha256"), Some("\nabc 100 hello_1.0.tar.gz") ); } #[test] fn strip_armour_passes_unsigned_text_through() { let plain = "Source: hello\nVersion: 1.0\n"; assert_eq!(strip_clearsigned_armour(plain), plain); } #[test] fn set_replaces_case_insensitive() { let mut p = Paragraph::new(); p.set("Source", "a"); p.set("source", "b"); assert_eq!(p.get("SOURCE"), Some("b")); assert_eq!(p.iter().count(), 1); } #[test] fn lenient_parse_keeps_duplicate_fields() { let paras = parse_paragraphs("Package: hello\nDepends: a\ndepends: b\n"); let p = ¶s[0]; // deb822 forbids duplicate fields but the parser is lenient and keeps // both entries; `get` returns the first. let depends: Vec<_> = p .iter() .filter(|(k, _)| k.eq_ignore_ascii_case("Depends")) .collect(); assert_eq!(depends, [("Depends", "a"), ("depends", "b")]); assert_eq!(p.get("Depends"), Some("a")); } #[test] fn set_removes_case_insensitive_duplicates() { let mut paras = parse_paragraphs("Package: hello\nDepends: a\ndepends: b\n"); let mut p = paras.remove(0); p.set("Depends", "c"); // Exactly one depends-family entry remains, with the new value. let depends: Vec<_> = p .iter() .filter(|(k, _)| k.eq_ignore_ascii_case("Depends")) .collect(); assert_eq!(depends, [("Depends", "c")]); assert_eq!(p.get("depends"), Some("c")); // ...kept at its original position, and a write round-trip no longer // leaks the stale duplicate. assert_eq!(write_paragraph(&p), "Package: hello\nDepends: c\n"); } #[test] fn remove_field() { let mut p = Paragraph::new(); p.set("A", "1"); assert!(p.remove("a")); assert!(!p.remove("a")); 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))?; content .parse::() .map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into()) } /// Parse control content from a string. /// /// Prefer [`std::str::FromStr`] (`"...".parse::()`). pub fn parse_content(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("-") } } impl std::str::FromStr for ControlInfo { type Err = String; fn from_str(content: &str) -> Result { ControlInfo::parse_content(content) } } #[cfg(test)] mod control_info_tests { use super::*; use std::str::FromStr; #[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(), "-"); } }