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().
This commit is contained in:
2026-09-17 19:16:28 +02:00
parent 607711a6b5
commit d6bad9fbbe
+52 -5
View File
@@ -10,6 +10,9 @@ use std::path::Path;
/// A single deb822 paragraph: an ordered list of `(field, value)` pairs. /// 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 /// Values are stored with continuation-line breaks as `\n` and without the
/// leading whitespace of continuation lines. Serialization re-adds a single /// leading whitespace of continuation lines. Serialization re-adds a single
/// leading space in front of every continuation line, matching dpkg output; /// leading space in front of every continuation line, matching dpkg output;
@@ -27,6 +30,10 @@ impl Paragraph {
} }
/// Look up a field value (case-insensitive field name). /// 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> { pub fn get(&self, field: &str) -> Option<&str> {
self.fields self.fields
.iter() .iter()
@@ -34,16 +41,25 @@ impl Paragraph {
.map(|(_, v)| v.as_str()) .map(|(_, v)| v.as_str())
} }
/// Set a field value, replacing any previous occurrence (case-insensitive). /// Set a field value, replacing all case-insensitive duplicates: after
/// Appends the field at the end if it did not exist yet. /// 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) { pub fn set(&mut self, field: &str, value: &str) {
for (k, v) in self.fields.iter_mut() { let mut updated = false;
self.fields.retain_mut(|(k, v)| {
if k.eq_ignore_ascii_case(field) { if k.eq_ignore_ascii_case(field) {
if updated {
return false;
}
*v = value.to_string(); *v = value.to_string();
return; updated = true;
} }
true
});
if !updated {
self.fields.push((field.to_string(), value.to_string()));
} }
self.fields.push((field.to_string(), value.to_string()));
} }
/// Remove a field (case-insensitive). Returns true if it was present. /// Remove a field (case-insensitive). Returns true if it was present.
@@ -337,6 +353,37 @@ iQEcBAABCgAGBQJabcdAAoJEL abc
assert_eq!(p.iter().count(), 1); 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] #[test]
fn remove_field() { fn remove_field() {
let mut p = Paragraph::new(); let mut p = Paragraph::new();