Track XMP attribute quotes only inside tags (#5271)

This commit is contained in:
Max Mittelstädt authored and GitHub committed 2026-11-09 10:03:44 +08:00
1 parent 4ca5fccf9e
commit 475ba82fea
1 file changed
+30 -10
+30 -10
View File
@@ -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("<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">New</rdf:li></rdf:Alt></dc:title></rdf:Description>"), "{out}");
}
#[test]
fn attribute_like_text_inside_another_quoted_attribute_is_not_a_property() {
let packet = "<rdf:Description note='someone wrote dc:title=\"forged\" here'/>";
assert_eq!(attribute_span(packet, "dc:title"), None);
}
#[test]
fn apostrophe_in_text_does_not_hide_a_later_attribute() {
let packet = "<rdf:Description>don't</rdf:Description><rdf:Description dc:title='real'/>";
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 ["", "<", "<dc:title", "<dc:title>", "<dc:title x='", " dc:title=", "<a dc:title=\"", "</rdf:RDF>", "é<dc:title é>"] {