From d18fb06aa8d514115274aa4bdd4709e54dc1f5b2 Mon Sep 17 00:00:00 2001 From: ronibalthazar <61232335+ronibalthazar@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:59:39 +0100 Subject: [PATCH] Fail closed on malformed PSD EXIF during extraction --- crates/meta/README.md | 2 +- crates/meta/src/lib.rs | 31 ++++++++++++++++++++++++------- crates/meta/src/psd.rs | 17 +++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/meta/README.md b/crates/meta/README.md index 401887c6d..5971c97d2 100644 --- a/crates/meta/README.md +++ b/crates/meta/README.md @@ -13,7 +13,7 @@ Photo metadata: EXIF, XMP (read + write), IPTC-IIM, and the containers that carr | `write_xmp(&Metadata, Option<&str>) -> String` | Complete `` packet; the second argument is LightCraft's full develop settings JSON. | | `parse_iptc(&[u8]) -> Metadata` | IIM record 2: title (2:5), keywords (2:25), by-line (2:80), copyright (2:116), caption (2:120), date/time created (2:55/2:60). | | `extract(&[u8]) -> Metadata` | Everything for a whole file. Precedence: EXIF → IPTC fills gaps → XMP overrides user fields (rating, label, title, caption, artist, copyright, keywords, GPS) and fills the rest. | -| `try_extract(&[u8]) -> Result` | Like `extract`, but reports malformed PSD/PSB headers or image-resource sections so callers can fail closed when location absence cannot be established. Other formats remain best-effort. | +| `try_extract(&[u8]) -> Result` | Like `extract`, but reports malformed PSD/PSB headers, image-resource sections, or embedded EXIF structure so callers can fail closed when location absence cannot be established. Other formats remain best-effort. | `Metadata` carries camera (make, model, serial, software), lens (make, model, serial, specification), capture (date-time with UTC offset and milliseconds, exposure time, f-number, ISO incl. extended ISO, focal length and diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 21dfb3598..b24c9ddf2 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -282,18 +282,24 @@ impl Metadata { /// Read everything we understand from a whole file (JPEG, PNG, WebP, TIFF-based incl. DNG/raw). /// Precedence: EXIF, then IPTC fills gaps, then XMP overrides user fields and fills the rest. pub fn extract(bytes: &[u8]) -> Metadata { - extract_embedded(embedded(bytes)) + let e = embedded(bytes); + let exif = e.exif.as_deref().map(read_exif).unwrap_or_default(); + extract_embedded(e, exif) } -/// Read metadata while reporting malformed PSD/PSB resource sections instead of treating them as empty. +/// Read metadata while reporting malformed PSD/PSB containers or embedded EXIF instead of treating them as empty. /// Other container formats retain [`extract`]'s best-effort behavior. pub fn try_extract(bytes: &[u8]) -> Result { let e = container::try_embedded(bytes)?; - Ok(extract_embedded(e)) + let exif = match e.exif.as_deref() { + Some(exif) if bytes.starts_with(b"8BPS") => try_read_exif(exif).map_err(|_| ExtractError::MalformedPsd)?, + Some(exif) => read_exif(exif), + None => Metadata::default(), + }; + Ok(extract_embedded(e, exif)) } -fn extract_embedded(e: Embedded) -> Metadata { - let mut m = e.exif.as_deref().map(read_exif).unwrap_or_default(); +fn extract_embedded(e: Embedded, mut m: Metadata) -> Metadata { if let Some(i) = e.iptc.as_deref() { m.fill_missing(&parse_iptc(i)); } @@ -307,14 +313,14 @@ fn extract_embedded(e: Embedded) -> Metadata { /// A container-level error reported by [`try_extract`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ExtractError { - /// The PSD/PSB header or image-resource section is malformed. + /// The PSD/PSB header, image-resource section, or embedded EXIF structure is malformed. MalformedPsd, } impl std::fmt::Display for ExtractError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::MalformedPsd => f.write_str("malformed PSD/PSB header or image-resource section"), + Self::MalformedPsd => f.write_str("malformed PSD/PSB header, image-resource section, or embedded EXIF structure"), } } } @@ -379,6 +385,17 @@ mod tests { assert_eq!(m.exposure_display(), None); } + #[test] + fn try_extract_keeps_non_psd_exif_best_effort() { + let payload = b"Exif\0\0not a TIFF stream"; + let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe1]; + jpeg.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes()); + jpeg.extend_from_slice(payload); + jpeg.extend_from_slice(&[0xff, 0xd9]); + + assert!(matches!(try_extract(&jpeg), Ok(metadata) if metadata == Metadata::default())); + } + #[test] fn merge_rules() { let mut a = Metadata { make: Some("A".into()), rating: Some(1), ..Default::default() }; diff --git a/crates/meta/src/psd.rs b/crates/meta/src/psd.rs index 3a3bfc6f7..003726dbb 100644 --- a/crates/meta/src/psd.rs +++ b/crates/meta/src/psd.rs @@ -165,6 +165,23 @@ mod tests { assert_eq!(crate::extract(&file).capture_time, dated_metadata().capture_time); } + #[test] + fn try_extract_rejects_structurally_malformed_psd_exif() { + for version in [1, 2] { + let file = image(version, &[], &resource(1058, b"", b"not a TIFF stream")); + assert!(matches!(crate::try_extract(&file), Err(crate::ExtractError::MalformedPsd))); + } + } + + #[test] + fn try_extract_accepts_unknown_exif_tags_in_psd() { + let mut ifd0 = lightcraft_tiff::IfdBuilder::new(); + ifd0.set(0xc001, lightcraft_tiff::Value::Ascii("vendor metadata".into())); + let exif = lightcraft_tiff::TiffWriter::default().write(&[ifd0]).unwrap(); + let file = image(1, &[], &resource(1058, b"", &exif)); + assert!(crate::try_extract(&file).is_ok()); + } + #[test] fn psd_skips_unknown_resources_with_pascal_name_and_payload_padding() { let exif = crate::write_exif(&dated_metadata());