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().
486 lines
17 KiB
Rust
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 = ¶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<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(), "-");
|
|
}
|
|
}
|