collective

Browse / github.com/storytold/lightcraft / patch

Never crash: CR2 slice widths that overflow are an error; XMP attribute unescape in one pass

Never crash: CR2 slice widths that overflow are an error; XMP attribute unescape in one pass ## Problem - A corrupt Canon CR2 whose slice-width tag (0xc640) holds huge values made `widths.iter().sum()` wrap. The wrapped width can still divide the frame, and the slice copy then indexes outside the buffer: the app panics on opening the file instead of reporting it as corrupt. - `unescape_attr` in the XMP reader searched the rest of the attribute value for `;` at every `&`, so a large value full of `&` took quadratic time (a sidecar that stalls import). ## Change - `crates/raw/src/vendor/cr2.rs`: checked sum of the slice widths (`RawError::Corrupt` on overflow) and slice copies through

Status
open
Score
0.00
Adopters
0 other operators
Author
emmertex (imported from pull request #3007, open)
Base
387bed199fca44d76af5aa1c24dd7cc3de4c8db2
Size
+61 −9 in 3 files
Review
pending
Shared
2026-10-28
Id
90408115a4ae2031f300ddf2

How it went

No other operator has reported using it yet.

Diff

raw · crates/meta/src/xmp.rs crates/raw/src/vendor/cr2.rs crates/raw/tests/it/vendor_robust.rs

diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs--- a/crates/meta/src/xmp.rs+++ b/crates/meta/src/xmp.rs@@ -234,7 +234,18 @@ pub(crate) fn unescape_attr(s: &str) -> String {     while let Some(i) = rest.find('&') {         out.push_str(&rest[..i]);         rest = &rest[i..];-        match rest.find(';').and_then(|j| entity(&rest[1..j]).map(|c| (c, j))) {+        // the longest reference is `&#x10FFFF;` plus leading zeros: skip the zeros (each byte once, since a+        // miss resumes after the `&`), then search a short window, not the rest of the value per `&`+        let b = rest.as_bytes();+        let digits = match (b.get(1), b.get(2)) {+            (Some(b'#'), Some(b'x' | b'X')) => 3,+            (Some(b'#'), _) => 2,+            _ => 1,+        };+        let zeros = b.iter().skip(digits).take_while(|&&c| c == b'0').count();+        let skip = digits + zeros.saturating_sub(1);+        let end = b.iter().skip(skip).take(12).position(|&c| c == b';').map(|j| j + skip);+        match end.and_then(|j| entity(&rest[1..j]).map(|c| (c, j))) {             Some((c, j)) => {                 out.push(c);                 rest = &rest[j + 1..];@@ -741,6 +752,24 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { mod tests {     use super::*; +    /// References resolve, anything else (unknown, unterminated, or a `;` too far away to end one) stays as+    /// written, and a value that is nothing but `&` is handled in one pass.+    #[test]+    fn attribute_references_resolve_and_hostile_ones_stay_verbatim() {+        assert_eq!(unescape_attr("a&amp;b&#65;&#x42;&lt;&gt;&quot;&apos;"), "a&bAB<>\"'");+        assert_eq!(unescape_attr("&bogus; & &#xZZ; tail&"), "&bogus; & &#xZZ; tail&");+        let far = format!("&{};", "x".repeat(40));+        assert_eq!(unescape_attr(&far), far);+        let many = "&".repeat(1 << 20);+        assert_eq!(unescape_attr(&many), many);+        // multi-byte text around a reference is never cut inside a character+        assert_eq!(unescape_attr("é&amp;日&é;"), "é&日&é;");+        // leading zeros are valid in numeric references, however many there are+        assert_eq!(unescape_attr("&#00000000000000000065;&#x000000000000000042;"), "AB");+        let zeros = format!("&#{};", "0".repeat(1 << 20));+        assert_eq!(unescape_attr(&zeros), zeros);+    }+     fn full() -> Metadata {         Metadata {             // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so thediff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs--- a/crates/raw/src/vendor/cr2.rs+++ b/crates/raw/src/vendor/cr2.rs@@ -226,16 +226,19 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {     let widths: Vec<usize> = match &slices {         Some(s) => {             let n = s[0].min(64) as usize;-            let mut v = vec![s[1] as usize; n];-            v.push(s[2] as usize);+            // `as usize` would truncate a 64-bit width on 32-bit targets (wasm32, x86) and hide the overflow+            let width = |w: u64| usize::try_from(w).map_err(|_| RawError::Corrupt("CR2 slice widths overflow".into()));+            let mut v = vec![width(s[1])?; n];+            v.push(width(s[2])?);             v         }         None => {             let w = (fw as u64).checked_mul(nc as u64).ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?;-            vec![w as usize]+            vec![usize::try_from(w).map_err(|_| RawError::Corrupt("bad CR2 frame size".into()))?]         }     };-    let width: usize = widths.iter().sum();+    // slice widths come from the file: a sum that overflows is a corrupt file, not a wrapped width+    let width = widths.iter().try_fold(0usize, |acc, &w| acc.checked_add(w)).ok_or_else(|| RawError::Corrupt("CR2 slice widths overflow".into()))?;     if width == 0 || total % width != 0 {         return Err(RawError::Corrupt(format!("CR2 slices ({width}) do not divide the frame ({total} samples)")));     }@@ -247,7 +250,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {         let mut x0 = 0;         for &sw in &widths {             for y in 0..height {-                data[y * width + x0..y * width + x0 + sw].copy_from_slice(&frame.data[i..i + sw]);+                // sums to `width`, so these are in range; `get` keeps a surprise an error, not a panic+                let (Some(dst), Some(src)) = (data.get_mut(y * width + x0..y * width + x0 + sw), frame.data.get(i..i + sw)) else {+                    return Err(RawError::Corrupt("CR2 slices outside the frame".into()));+                };+                dst.copy_from_slice(src);                 i += sw;             }             x0 += sw;diff --git a/crates/raw/tests/it/vendor_robust.rs b/crates/raw/tests/it/vendor_robust.rs--- a/crates/raw/tests/it/vendor_robust.rs+++ b/crates/raw/tests/it/vendor_robust.rs@@ -145,16 +145,21 @@ fn pef(compression: u16) -> Vec<u8> {  /// A Canon CR2: three placeholder IFDs and IFD3 with a 2-component lossless JPEG frame. fn cr2() -> Vec<u8> {+    cr2_with_slices(Value::Short(vec![1, 16, 16]), false)+}++/// The same CR2 with its slice-width tag (0xc640) replaced, optionally in a BigTIFF (for 64-bit widths).+fn cr2_with_slices(slices: Value, bigtiff: bool) -> Vec<u8> {     let img: Vec<u16> = (0..(W * H) as usize).map(|i| 1024 + (i * 7919 % 12000) as u16).collect();     let enc = lightcraft_raw::ljpeg::encode(&img, W as usize / 2, H as usize, 2, 14, 1, 0);     let mut ifd0 = IfdBuilder::new();     ifd0.set(t::MAKE, Value::Ascii("Canon".into()));     let mut ifd3 = IfdBuilder::new();     ifd3.set(t::COMPRESSION, Value::Short(vec![6]));-    ifd3.set(0xc640, Value::Short(vec![1, 16, 16]));+    ifd3.set(0xc640, slices);     ifd3.set_image(ImageData::Strips { rows_per_strip: H, strips: vec![enc] });     let blank = || IfdBuilder::new().with(1, Value::Short(vec![0]));-    TiffWriter::new(ByteOrder::Little, false).write(&[ifd0, blank(), blank(), ifd3]).unwrap()+    TiffWriter::new(ByteOrder::Little, bigtiff).write(&[ifd0, blank(), blank(), ifd3]).unwrap() }  /// A Fujifilm RAF: header, records, raw block (LE TIFF whose IFD0 points to a 0xf000 raw IFD).@@ -241,6 +246,8 @@ fn samples() -> Vec<Vec<u8>> {         rw2(),         cr2(),         raf(),+        cr2_with_slices(Value::Long8(vec![64, u64::MAX, u64::MAX]), true),+        cr2_with_slices(Value::Long8(vec![1, 1 << 40, 16]), true),     ]     .into_iter()     .chain(rw2_formats())@@ -283,7 +290,7 @@ proptest! {     #![proptest_config(ProptestConfig { cases: 2000, .. ProptestConfig::default() })]      #[test]-    fn mutated_vendor_files_never_panic(kind in 0usize..18, flips in proptest::collection::vec((any::<usize>(), any::<u8>()), 1..16), cut in any::<usize>()) {+    fn mutated_vendor_files_never_panic(kind in 0usize..20, flips in proptest::collection::vec((any::<usize>(), any::<u8>()), 1..16), cut in any::<usize>()) {         let mut data = samples().swap_remove(kind);         let n = data.len();         for (i, v) in flips {@@ -302,3 +309,12 @@ fn rw2_samples_decode_unmutated() {         assert_eq!((r.width, r.height), (W as usize, H as usize));     } }++/// Slice widths come from the file: widths whose sum overflows (or exceeds the frame) are an error, not a+/// wrapped width that indexes outside the frame.+#[test]+fn cr2_slice_widths_that_overflow_are_an_error() {+    for slices in [vec![64, u64::MAX, u64::MAX], vec![1, 1 << 40, 16], vec![2, u64::MAX, 1]] {+        assert!(lightcraft_raw::decode(&cr2_with_slices(Value::Long8(slices.clone()), true)).is_err(), "{slices:?}");+    }+}