diff --git a/crates/codecs/src/resolution.rs b/crates/codecs/src/resolution.rs
index cb86001ac9..a787cacbc3 100644
--- a/crates/codecs/src/resolution.rs
+++ b/crates/codecs/src/resolution.rs
@@ -429,8 +429,14 @@ fn xmp_values(xmp: &str, key: &str) -> Vec<(usize, usize)> {
continue;
};
let start = xmp.len() - value.len();
- let Some(len) = value.find(close) else { continue };
+ // Advance past this value before searching for the next key. Without this, `from`
+ // only moved past the key text, so property-like text repeated inside an unrelated
+ // attribute re-ran this full-suffix `find(close)` for every occurrence, which is
+ // quadratic in the packet size. A missing close means the value is unterminated, so
+ // no later value can parse either; stop scanning in that case.
+ let Some(len) = value.find(close) else { break };
out.push((start, start + len));
+ from = start + len + close.len_utf8();
}
out
}
diff --git a/crates/codecs/tests/resolution.rs b/crates/codecs/tests/resolution.rs
index 15ddc95749..28c8203c96 100644
--- a/crates/codecs/tests/resolution.rs
+++ b/crates/codecs/tests/resolution.rs
@@ -284,6 +284,17 @@ fn truncated_metadata_never_panics_and_falls_back() {
assert_eq!(xmp_resolution(x), None, "{x}");
let _ = xmp_with_resolution(x, (1.0, 1.0));
}
+
+ // Property-like text repeated inside the packet must not trigger a full-suffix rescan
+ // per occurrence (it used to be quadratic). With the cursor advancing past each located
+ // value, this completes well under a second even with many decoys; a timeout here would
+ // indicate the quadratic scan returned. A clean packet still extracts correctly.
+ let start = std::time::Instant::now();
+ let decoys = " tiff:ResolutionUnit>".repeat(50_000);
+ let _ = xmp_resolution(&decoys);
+ assert!(start.elapsed() < std::time::Duration::from_secs(2), "xmp scan took too long: {:?}", start.elapsed());
+ let clean = "300/1300/12";
+ near(xmp_resolution(clean), 300.0);
}
proptest! {