diff --git a/src/config_edit/mod.rs b/src/config_edit/mod.rs index 9812be4..0cfd9ea 100644 --- a/src/config_edit/mod.rs +++ b/src/config_edit/mod.rs @@ -311,7 +311,10 @@ impl ConfigEditor { let xml_data = if had_bom { &data[3..] } else { data }; let (xml_data_cow, digit_attr_renames) = Self::sanitize_digit_attrs(xml_data); - let reader = BufReader::new(Cursor::new(xml_data_cow.as_ref())); + // Loxone emits the same attribute twice on some elements (value-identical). + // A strict parser rejects that; drop the redundant copy losslessly before parsing. + let deduped = Self::dedupe_duplicate_attrs(xml_data_cow.as_ref()); + let reader = BufReader::new(Cursor::new(deduped.as_ref())); let root = Element::parse(reader).context("Failed to parse Loxone XML")?; Ok(ConfigEditor { root, @@ -373,6 +376,112 @@ impl ConfigEditor { (std::borrow::Cow::Owned(result.into_bytes()), renames) } + /// Remove **value-identical** duplicate attributes from element start tags. + /// + /// Loxone Config emits the same attribute twice on some elements (observed on API + /// Connector elements: `Title` appears twice with the same value). A strict XML parser + /// rejects that ("Attribute 'Title' is redefined"). Since the duplicates are + /// value-identical, dropping the redundant copy is lossless. Duplicates with *different* + /// values are left untouched so a genuine conflict still surfaces at parse time. + /// + /// Quote-aware: attribute values may legitimately contain `<`, `>` and `=` + /// (e.g. `Unit="%"`), so tag boundaries are found respecting `"…"`. + fn dedupe_duplicate_attrs(data: &[u8]) -> std::borrow::Cow<'_, [u8]> { + let text = match std::str::from_utf8(data) { + Ok(s) => s, + Err(_) => return std::borrow::Cow::Borrowed(data), + }; + let bytes = text.as_bytes(); + let len = bytes.len(); + let ws = |b: u8| matches!(b, b' ' | b'\t' | b'\r' | b'\n'); + let mut deletes: Vec<(usize, usize)> = Vec::new(); + let mut i = 0; + while i < len { + // Start of an element tag: '<' not followed by '/', '?' or '!'. + if bytes[i] == b'<' && i + 1 < len && !matches!(bytes[i + 1], b'/' | b'?' | b'!') { + // Skip the element name. + let mut j = i + 1; + while j < len + && (bytes[j].is_ascii_alphanumeric() + || matches!(bytes[j], b'_' | b':' | b'-')) + { + j += 1; + } + // Scan attributes until the end of the start tag. + let mut seen: Vec<(&str, &str)> = Vec::new(); + loop { + while j < len && ws(bytes[j]) { + j += 1; + } + if j >= len || matches!(bytes[j], b'>' | b'/') { + break; + } + let name_start = j; + while j < len && !ws(bytes[j]) && !matches!(bytes[j], b'=' | b'>' | b'/') { + j += 1; + } + let name_end = j; + while j < len && ws(bytes[j]) { + j += 1; + } + if j >= len || bytes[j] != b'=' { + break; // not `name=…`, stop scanning this tag + } + j += 1; + while j < len && ws(bytes[j]) { + j += 1; + } + if j >= len || bytes[j] != b'"' { + break; + } + let val_start = j + 1; + j = val_start; + while j < len && bytes[j] != b'"' { + j += 1; + } + if j >= len { + break; + } + let val_end = j; + j += 1; // past closing quote + let attr_end = j; + let name = &text[name_start..name_end]; + let value = &text[val_start..val_end]; + if let Some(&(_, prev)) = seen.iter().find(|(n, _)| *n == name) { + if prev == value { + // Redundant copy → delete it plus one leading whitespace char. + let del_start = if name_start > 0 && ws(bytes[name_start - 1]) { + name_start - 1 + } else { + name_start + }; + deletes.push((del_start, attr_end)); + } + // Different value: leave it, let the parser flag the conflict. + } else { + seen.push((name, value)); + } + } + i = j; + continue; + } + i += 1; + } + if deletes.is_empty() { + return std::borrow::Cow::Borrowed(data); + } + let mut out = String::with_capacity(text.len()); + let mut last = 0; + for (s, e) in deletes { + if s >= last { + out.push_str(&text[last..s]); + last = e; + } + } + out.push_str(&text[last..]); + std::borrow::Cow::Owned(out.into_bytes()) + } + /// Get a mutable reference to an element by path. pub fn get_element_mut(&mut self, path: &[usize]) -> &mut Element { let mut current = &mut self.root; @@ -572,6 +681,38 @@ mod tests { let _ = ConfigEditor::load(&output).unwrap(); } + #[test] + fn test_dedupe_value_identical_duplicate_attr() { + // Loxone emits the same attribute twice, value-identical → must parse, redundant copy dropped. + let xml = br#" + + +"#; + let editor = ConfigEditor::load(xml).unwrap(); + let out = String::from_utf8(editor.to_bytes().unwrap()).unwrap(); + assert_eq!(out.matches(r#"Title="Kitchen""#).count(), 1); + } + + #[test] + fn test_dedupe_leaves_conflicting_duplicate() { + // Different values = a real conflict; must still surface at parse time. + let xml = br#""#; + assert!(ConfigEditor::load(xml).is_err()); + } + + #[test] + fn test_dedupe_is_quote_aware() { + // A value may contain escaped angle brackets (e.g. Unit="<v>%"); tag scanning + // must not be confused, and an unrelated value-identical dup still collapses. + let xml = br#" + +"#; + let editor = ConfigEditor::load(xml).unwrap(); + let out = String::from_utf8(editor.to_bytes().unwrap()).unwrap(); + assert_eq!(out.matches(r#"Nio="1""#).count(), 1); + assert!(out.contains("<v>%")); + } + #[test] fn test_bom_preservation() { let mut with_bom = Vec::new();