Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 142 additions & 1 deletion src/config_edit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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="<v>%"`), 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;
Expand Down Expand Up @@ -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#"<?xml version="1.0" encoding="utf-8"?>
<ControlList Version="267">
<C Type="Place" U="room-1" Title="Kitchen" WF="16384" Title="Kitchen"/>
</ControlList>"#;
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#"<ControlList><C U="x" Title="A" Title="B"/></ControlList>"#;
assert!(ConfigEditor::load(xml).is_err());
}

#[test]
fn test_dedupe_is_quote_aware() {
// A value may contain escaped angle brackets (e.g. Unit="&lt;v&gt;%"); tag scanning
// must not be confused, and an unrelated value-identical dup still collapses.
let xml = br#"<ControlList>
<C U="x" Unit="&lt;v&gt;%" Nio="1" Nio="1"/>
</ControlList>"#;
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("&lt;v&gt;%"));
}

#[test]
fn test_bom_preservation() {
let mut with_bom = Vec::new();
Expand Down