From 475ba82feaf5ef67ae70963c4922f98331b3095a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:03:44 -0800 Subject: [PATCH] Track XMP attribute quotes only inside tags (#5271) --- crates/organize/src/xmp.rs | 40 ++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index 5b6f3930c8..60e08571a1 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -294,24 +294,31 @@ fn element_span(xmp: &str, name: &str) -> Option<(usize, usize)> { /// start and end of its value, and its quote character. Only matches inside a start tag. fn attribute_span(xmp: &str, name: &str) -> Option<(usize, usize, usize, char)> { let mut from = 0; - // The last `<` and `>` before the match, found by one pass over the packet rather than by - // searching back from every match. - let (mut scanned, mut last_open, mut last_close) = (0, None, None); + // Track tag and quote state in one pass rather than searching back from every match. + let (mut scanned, mut in_tag, mut quote) = (0, false, None); while let Some(i) = xmp.get(from..).and_then(|s| s.find(name)).map(|i| i + from) { from = i + name.len(); let before = xmp.get(..i)?; let gap = xmp.get(scanned..i)?; #[cfg(test)] crate::work(gap.len()); - if let Some(at) = gap.rfind('<') { - last_open = Some(scanned + at); - } - if let Some(at) = gap.rfind('>') { - last_close = Some(scanned + at); + for c in gap.chars() { + if let Some(q) = quote { + if c == q { + quote = None; + } + } else { + match c { + '<' => in_tag = true, + '>' if in_tag => in_tag = false, + '\'' | '\"' if in_tag => quote = Some(c), + _ => {} + } + } } scanned = i; - // Preceded by whitespace, and inside a tag (the last `<` comes after the last `>`). - if !before.ends_with(char::is_whitespace) || last_open < last_close || last_open.is_none() { + // Preceded by whitespace, and inside a start tag. + if !before.ends_with(char::is_whitespace) || !in_tag || quote.is_some() { continue; } let rest = xmp.get(from..)?; @@ -417,6 +424,19 @@ mod tests { assert!(out.contains("New"), "{out}"); } + #[test] + fn attribute_like_text_inside_another_quoted_attribute_is_not_a_property() { + let packet = ""; + assert_eq!(attribute_span(packet, "dc:title"), None); + } + + #[test] + fn apostrophe_in_text_does_not_hide_a_later_attribute() { + let packet = "don't"; + let found = attribute_span(packet, "dc:title").and_then(|(_, start, end, quote)| packet.get(start..end).map(|value| (value, quote))); + assert_eq!(found, Some(("real", '\''))); + } + #[test] fn hostile_packets_do_not_panic() { for packet in ["", "<", "", "