Fail closed on malformed PSD EXIF during extraction

This commit is contained in:
ronibalthazar
2026-10-22 14:59:39 +01:00
parent c8390afbd2
commit d18fb06aa8
3 changed files with 42 additions and 8 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ Photo metadata: EXIF, XMP (read + write), IPTC-IIM, and the containers that carr
| `write_xmp(&Metadata, Option<&str>) -> String` | Complete `<?xpacket?>` 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<Metadata, ExtractError>` | 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<Metadata, ExtractError>` | 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
+24 -7
View File
@@ -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<Metadata, ExtractError> {
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() };
+17
View File
@@ -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());