mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Fail closed on malformed PSD EXIF during extraction
This commit is contained in:
@@ -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
@@ -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() };
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user