Files
pkh/src/debian/control.rs
T
vhaudiquet d6bad9fbbe debian: make Paragraph::set drop case-insensitive duplicates
set only replaced the first match and appended otherwise, so a
paragraph holding both 'Depends:' and 'depends:' kept a stale second
value after an update, silently re-emitted on serialization. set now
updates the first match in place and removes any other case-insensitive
duplicate; the parser stays lenient and keeps duplicates reachable via
iter().
2026-09-17 19:16:28 +02:00

486 lines
17 KiB
Rust

//! 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<Item = (&str, &str)> {
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<Paragraph> {
let mut paragraphs = Vec::new();
let mut current = Paragraph::new();
let mut last_field: Option<String> = 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 = &paras[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<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))?;
content
.parse::<ControlInfo>()
.map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into())
}
/// Parse control content from a string.
///
/// Prefer [`std::str::FromStr`] (`"...".parse::<ControlInfo>()`).
pub fn parse_content(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("-")
}
}
impl std::str::FromStr for ControlInfo {
type Err = String;
fn from_str(content: &str) -> Result<Self, Self::Err> {
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 <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(), "-");
}
}