From d6bad9fbbe35e079a4e571879508024e44ecc19e Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Thu, 17 Sep 2026 19:16:28 +0200 Subject: [PATCH] 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(). --- src/debian/control.rs | 57 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/src/debian/control.rs b/src/debian/control.rs index 2096778..91b4401 100644 --- a/src/debian/control.rs +++ b/src/debian/control.rs @@ -10,6 +10,9 @@ 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; @@ -27,6 +30,10 @@ impl Paragraph { } /// 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() @@ -34,16 +41,25 @@ impl Paragraph { .map(|(_, v)| v.as_str()) } - /// Set a field value, replacing any previous occurrence (case-insensitive). - /// Appends the field at the end if it did not exist yet. + /// 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) { - 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 updated { + return false; + } *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. @@ -337,6 +353,37 @@ iQEcBAABCgAGBQJabcdAAoJEL abc 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();