From 6413bccbfda007d8886db2c0cbd9e6bfbb3ed014 Mon Sep 17 00:00:00 2001 From: ronibalthazar <61232335+ronibalthazar@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:20:53 +0100 Subject: [PATCH 1/5] #2519: read PSD and PSB metadata --- crates/engine/src/files.rs | 16 +++ crates/engine/src/import.rs | 2 +- crates/engine/src/tests_export.rs | 18 ++- crates/engine/src/tests_xmp.rs | 75 ++++++++++- crates/meta/README.md | 5 +- crates/meta/src/container.rs | 4 + crates/meta/src/lib.rs | 3 +- crates/meta/src/psd.rs | 208 ++++++++++++++++++++++++++++++ crates/meta/src/tags.rs | 2 +- docs/gaps.md | 5 +- docs/parity-checklist.md | 7 +- docs/whats-new.md | 7 + 12 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 crates/meta/src/psd.rs diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 0670fb763..a8125b67e 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1177,6 +1177,22 @@ pub(crate) mod tests { assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]); } + #[test] + fn psd_probe_reads_embedded_capture_time_gps_and_title() { + let capture = lightcraft_meta::DateTime::parse_iso("2023-05-14T14:40:39").unwrap(); + let metadata = lightcraft_meta::Metadata { + capture_time: Some(capture), + gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), + title: Some("PSD metadata".into()), + ..Default::default() + }; + let bytes = crate::tests_xmp::synthetic_psd_with_metadata(&metadata); + let probe = probe_bytes("metadata.psd", &bytes).unwrap(); + assert_eq!(probe.captured.as_deref(), Some("2023-05-14T14:40:39")); + assert_eq!(probe.meta.gps, Some((45.0, 7.0))); + assert_eq!(probe.meta.title, "PSD metadata"); + } + use super::*; use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index bc8c1c962..62bd33820 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -36,7 +36,7 @@ use crate::media::ProbeInfo; /// This includes preview-only raws; the file probe decides whether sensor decoding is available. pub const EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef", - "srw", "psd", "jxl", "gif", "bmp", + "srw", "psd", "psb", "jxl", "gif", "bmp", // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as // failed with the reason ("HEIC/HEIF support isn't included in this build"). "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 4830490b5..8113cd70c 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -4,7 +4,7 @@ use serde_json::json; use crate::Session; use crate::export::{ExportFormat, ExportOptions, export_photo}; -use crate::tests_xmp::{synthetic_dng_with, temp_dir}; +use crate::tests_xmp::{synthetic_dng_with, synthetic_psd_with_metadata, temp_dir}; #[test] fn full_size_export_of_a_cropped_photo_is_not_upscaled() { @@ -657,6 +657,22 @@ fn remove_location_refuses_a_raw_that_embeds_gps() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn remove_location_refuses_a_psd_that_embeds_gps() { + let dir = temp_dir("export-noloc-psd"); + let gps = lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), ..Default::default() }; + let path = dir.join("Geo.psd"); + let bytes = synthetic_psd_with_metadata(&gps); + assert_eq!(lightcraft_meta::extract(&bytes).gps, gps.gps, "the fixture embeds EXIF GPS"); + std::fs::write(&path, &bytes).unwrap(); + let mut session = Session::new().with_fs(); + session.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = session.catalog.photos().next().unwrap().id; + let err = export_photo(&mut session, id, &ExportOptions::from_json(&json!({"format": "original", "removeLocation": true})), 1).err().unwrap(); + assert!(err.contains("can't remove the location") && err.contains("export as JPEG"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn hdr_png_export_is_16_bit_pq_rec2020() { let mut s = Session::with_demo(); diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 1a4d86fa3..6f2148d4f 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -43,6 +43,34 @@ fn write_png(path: &Path, seed: u8) { std::fs::write(path, bytes).unwrap(); } +pub(crate) fn synthetic_psd_with_metadata(metadata: &lightcraft_meta::Metadata) -> Vec { + let exif = lightcraft_meta::write_exif(metadata); + let xmp = lightcraft_meta::write_xmp(metadata, None); + let pixels = [40, 80, 120]; + let image = lightcraft_codecs::EncodeImage::new(1, 1, 3, lightcraft_codecs::Samples::U8(&pixels)); + let embedded = lightcraft_codecs::EncodeMeta { exif: Some(&exif), xmp: Some(&xmp), ..Default::default() }; + lightcraft_codecs::encode_psd(&image, &embedded).unwrap() +} + +pub(crate) fn synthetic_psb_with_metadata(metadata: &lightcraft_meta::Metadata) -> Vec { + let exif = lightcraft_meta::write_exif(metadata); + let xmp = lightcraft_meta::write_xmp(metadata, None); + let pixels = [1024u16, 2048, 3072]; + let image = lightcraft_codecs::EncodeImage::new(1, 1, 3, lightcraft_codecs::Samples::U16(&pixels)); + let embedded = lightcraft_codecs::EncodeMeta { exif: Some(&exif), xmp: Some(&xmp), ..Default::default() }; + let psd = lightcraft_codecs::encode_psd(&image, &embedded).unwrap(); + let colour_length = usize::try_from(u32::from_be_bytes(psd[26..30].try_into().unwrap())).unwrap(); + let resource_length_offset = 30 + colour_length; + let resource_length = usize::try_from(u32::from_be_bytes(psd[resource_length_offset..resource_length_offset + 4].try_into().unwrap())).unwrap(); + let resource_end = resource_length_offset + 4 + resource_length; + + let mut psb = psd[..resource_end].to_vec(); + psb[4..6].copy_from_slice(&2u16.to_be_bytes()); + psb.extend_from_slice(&0u64.to_be_bytes()); // PSB layer and mask information length + psb.extend_from_slice(&psd[resource_end + 4..]); // compression and uncompressed 16-bit pixel planes + psb +} + fn only(s: &Session) -> PhotoId { s.catalog.photos().next().unwrap().id } @@ -1036,10 +1064,21 @@ fn external_edit_copies_every_selected_photo() { fn external_edit_copy_as_psd_round_trips() { let dir = temp_dir("extedit-psd"); let src = dir.join("pier.png"); - write_png(&src, 40); + let capture = lightcraft_meta::DateTime::parse_iso("2023-05-14T14:40:39").unwrap(); + let source_meta = lightcraft_meta::Metadata { capture_time: Some(capture), title: Some("Dated original".into()), ..Default::default() }; + let exif = lightcraft_meta::write_exif(&source_meta); + let xmp = lightcraft_meta::write_xmp(&source_meta, None); + let rgba = lightcraft_raster::Rgba8::from_fn(40, 24, |x, y| [(x * 6) as u8, (y * 9) as u8, 40, 255]); + let png = lightcraft_codecs::encode_png( + &lightcraft_codecs::EncodeImage::rgba8(&rgba), + &lightcraft_codecs::EncodeMeta { exif: Some(&exif), xmp: Some(&xmp), ..Default::default() }, + ) + .unwrap(); + std::fs::write(&src, png).unwrap(); let mut s = Session::new().with_fs(); s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); let orig = s.active().unwrap(); + assert_eq!(s.catalog.photo(orig).unwrap().captured.as_deref(), Some("2023-05-14T14:40:39")); s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap(); let r = s.execute("photo.editExternal", &json!({"format": "psd", "bitDepth": 16})).unwrap(); let path = r["path"].as_str().unwrap().to_string(); @@ -1048,6 +1087,13 @@ fn external_edit_copy_as_psd_round_trips() { assert_eq!(d.bit_depth, 16); assert!(d.icc.is_some(), "the colour space travels with the file"); let new = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap()); + assert_eq!(s.catalog.photo(new).unwrap().captured.as_deref(), Some("2023-05-14T14:40:39")); + let all_metadata = s.execute("photo.allMetadata", &json!({"id": new.0})).unwrap(); + assert!(all_metadata["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003)), "{all_metadata}"); + assert!( + all_metadata["xmp"].as_array().unwrap().iter().any(|row| row["name"] == "dc:title" && row["value"] == "Dated original"), + "{all_metadata}" + ); assert_eq!(s.catalog.stack_of(new).map(|st| (st.top(), st.photos.contains(&orig))), Some((new, true))); // the editor saves the document back over the same file: Reload shows its pixels let before = s.render_now(new, 16, 16).unwrap().image.data; @@ -1069,6 +1115,33 @@ fn external_edit_copy_as_psd_round_trips() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn psb_import_retains_capture_time_and_all_metadata() { + let dir = temp_dir("import-psb-metadata"); + let path = dir.join("dated.psb"); + let capture = lightcraft_meta::DateTime::parse_exif("2023:05:14 14:40:39"); + let metadata = lightcraft_meta::Metadata { + capture_time: capture, + gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), + title: Some("PSB original".into()), + ..Default::default() + }; + let bytes = synthetic_psb_with_metadata(&metadata); + assert_eq!(lightcraft_meta::extract(&bytes).capture_time, capture); + assert_eq!(lightcraft_meta::extract(&bytes).gps, metadata.gps); + std::fs::write(&path, bytes).unwrap(); + + let mut session = Session::new().with_fs(); + let imported = session.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert_eq!(imported["imported"].as_array().map(Vec::len), Some(1), "{imported}"); + let id = session.catalog.photos().next().unwrap().id; + assert_eq!(session.catalog.photo(id).unwrap().captured.as_deref(), Some("2023-05-14T14:40:39")); + let all_metadata = session.execute("photo.allMetadata", &json!({"id": id.0})).unwrap(); + assert!(all_metadata["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003)), "{all_metadata}"); + assert!(all_metadata["xmp"].as_array().unwrap().iter().any(|row| row["name"] == "dc:title" && row["value"] == "PSB original"), "{all_metadata}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Reload: a file changed on disk (an external editor saved it) gets its new size / hash, the /// cached source is dropped and renders change; unchanged files are left alone. #[test] diff --git a/crates/meta/README.md b/crates/meta/README.md index 83ee9e601..b2642963c 100644 --- a/crates/meta/README.md +++ b/crates/meta/README.md @@ -8,7 +8,7 @@ Photo metadata: EXIF, XMP (read + write), IPTC-IIM, and the containers that carr |---|---| | `read_exif(&[u8]) -> Metadata` | TIFF-structured Exif block (whole TIFF/DNG/raw file, or JPEG APP1 payload with/without `Exif\0\0`). `try_read_exif` reports header errors. | | `from_tiff(&Tiff) -> Metadata` | Same, from an already parsed stream (used by `lightcraft-raw`). | -| `embedded(&[u8]) -> Embedded` | Sniffs JPEG / PNG / WebP / TIFF and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers. | +| `embedded(&[u8]) -> Embedded` | Sniffs JPEG / PNG / WebP / TIFF / PSD / PSB and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. PSD/PSB EXIF resource 1058 and XMP resource 1060 are read from the bounded image-resource section without decoding pixels. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers. | | `parse_xmp(&str) -> Result` | Metadata + the opaque `lc:settings` JSON + every property (`prefix:name`, structs flattened as `a:b/c:d`). | | `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). | @@ -30,7 +30,8 @@ and node count are limited. ## Robustness No panics on malformed input: proptests feed random bytes to every container walker and random text to the -XMP parser; truncation tests cut a sample JPEG at every byte. +XMP parser; truncation tests cut a sample JPEG at every byte and exercise truncated PSD/PSB headers and +image-resource records with hostile declared lengths. ## Sources diff --git a/crates/meta/src/container.rs b/crates/meta/src/container.rs index 3ac60907f..ec233c52c 100644 --- a/crates/meta/src/container.rs +++ b/crates/meta/src/container.rs @@ -7,6 +7,7 @@ //! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks. //! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and //! IPTC (33723) from IFD0. +//! - Photoshop PSD/PSB: EXIF (image resource 1058) and XMP (image resource 1060) from the bounded image-resource section. //! - Minolta MRW (see [`crate::mrw`]): the `TTW` block is the Exif block. //! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the //! XMP `uuid` box. @@ -29,6 +30,9 @@ pub struct Embedded { /// Sniff the container and collect its metadata blocks. Unknown containers yield an empty result. pub fn embedded(bytes: &[u8]) -> Embedded { + if bytes.starts_with(b"8BPS") { + return crate::psd::embedded(bytes); + } if bytes.starts_with(b"FUJIFILMCCD-RAW") { // Fujifilm RAF: metadata lives in the embedded JPEG whose (offset, length) are big-endian u32s at byte 84 let be = |i: usize| bytes.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize); diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 59062dfda..d54354838 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -7,7 +7,7 @@ //! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON). //! - [`parse_iptc`] — IPTC-IIM record 2 datasets. //! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time. -//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3). +//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, PSD/PSB, Canon CR3). //! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks). #![forbid(unsafe_code)] @@ -20,6 +20,7 @@ mod focus_read; mod gpx; mod iptc; pub mod mrw; +mod psd; pub mod tags; mod xmp; mod xmp_merge; diff --git a/crates/meta/src/psd.rs b/crates/meta/src/psd.rs new file mode 100644 index 000000000..52acaf735 --- /dev/null +++ b/crates/meta/src/psd.rs @@ -0,0 +1,208 @@ +//! EXIF and XMP image resources in Photoshop PSD and PSB files. + +use crate::container::Embedded; + +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Reader<'a> { + fn take(&mut self, length: usize) -> Option<&'a [u8]> { + let end = self.offset.checked_add(length)?; + let bytes = self.bytes.get(self.offset..end)?; + self.offset = end; + Some(bytes) + } + + fn u16(&mut self) -> Option { + let bytes = self.take(2)?; + Some(u16::from_be_bytes([*bytes.first()?, *bytes.get(1)?])) + } + + fn u32(&mut self) -> Option { + let bytes = self.take(4)?; + Some(u32::from_be_bytes([*bytes.first()?, *bytes.get(1)?, *bytes.get(2)?, *bytes.get(3)?])) + } +} + +pub(crate) fn embedded(bytes: &[u8]) -> Embedded { + parse(bytes).unwrap_or_default() +} + +fn parse(bytes: &[u8]) -> Option { + let mut reader = Reader { bytes, offset: 0 }; + if reader.take(4)? != b"8BPS" || !matches!(reader.u16()?, 1 | 2) { + return None; + } + // The fixed header after the version is reserved bytes, channels, dimensions, depth and colour mode. + reader.take(6 + 2 + 4 + 4 + 2 + 2)?; + let colour_mode_length = usize::try_from(reader.u32()?).ok()?; + reader.take(colour_mode_length)?; + let resource_length = usize::try_from(reader.u32()?).ok()?; + let resources = reader.take(resource_length)?; + parse_resources(resources) +} + +fn parse_resources(bytes: &[u8]) -> Option { + let mut reader = Reader { bytes, offset: 0 }; + let (mut exif, mut xmp) = (None, None); + while reader.offset < bytes.len() { + if reader.take(4)? != b"8BIM" { + return None; + } + let id = reader.u16()?; + let name_length = usize::from(*reader.take(1)?.first()?); + let pascal_length = 1usize.checked_add(name_length)?.checked_add(1)? & !1; + reader.take(pascal_length.checked_sub(1)?)?; + let data_length = usize::try_from(reader.u32()?).ok()?; + let data = reader.take(data_length)?; + if !data_length.is_multiple_of(2) { + reader.take(1)?; + } + match id { + 1058 => exif = Some(data), + 1060 => xmp = Some(data), + _ => {} + } + } + + Some(Embedded { + exif: exif.map(crate::strip_exif_header).map(<[u8]>::to_vec), + xmp: xmp.map(|bytes| String::from_utf8_lossy(bytes).trim_end_matches('\0').to_string()), + ..Default::default() + }) +} + +#[cfg(test)] +mod tests { + use super::embedded; + use crate::container::Embedded; + use crate::{DateTime, Gps, Metadata}; + + fn resource(id: u16, name: &[u8], data: &[u8]) -> Vec { + let mut out = b"8BIM".to_vec(); + out.extend_from_slice(&id.to_be_bytes()); + out.push(u8::try_from(name.len()).unwrap()); + out.extend_from_slice(name); + if !(name.len() + 1).is_multiple_of(2) { + out.push(0); + } + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(data); + if !data.len().is_multiple_of(2) { + out.push(0); + } + out + } + + fn image(version: u16, colour_data: &[u8], resources: &[u8]) -> Vec { + let mut out = b"8BPS".to_vec(); + out.extend_from_slice(&version.to_be_bytes()); + out.extend_from_slice(&[0; 6]); + out.extend_from_slice(&3u16.to_be_bytes()); // channels + out.extend_from_slice(&1u32.to_be_bytes()); // height + out.extend_from_slice(&1u32.to_be_bytes()); // width + out.extend_from_slice(&8u16.to_be_bytes()); // depth + out.extend_from_slice(&3u16.to_be_bytes()); // RGB + out.extend_from_slice(&u32::try_from(colour_data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(colour_data); + out.extend_from_slice(&u32::try_from(resources.len()).unwrap().to_be_bytes()); + out.extend_from_slice(resources); + out + } + + fn dated_metadata() -> Metadata { + Metadata { + capture_time: DateTime::parse_exif("2023:05:14 14:40:39"), + gps: Some(Gps { latitude: 45.0, longitude: 7.0, altitude: None }), + ..Default::default() + } + } + + fn dated_image(version: u16) -> Vec { + let exif = crate::write_exif(&dated_metadata()); + image(version, &[], &resource(1058, b"", &exif)) + } + + #[test] + fn psd_capture_time_is_extracted() { + let expected = dated_metadata().capture_time; + let actual = crate::extract(&dated_image(1)); + assert_eq!(actual.capture_time, expected); + assert_eq!(actual.gps, dated_metadata().gps); + } + + #[test] + fn psb_capture_time_is_extracted() { + let expected = dated_metadata().capture_time; + let actual = crate::extract(&dated_image(2)); + assert_eq!(actual.capture_time, expected); + assert_eq!(actual.gps, dated_metadata().gps); + } + + #[test] + fn psd_extracts_xmp_without_exif() { + let metadata = Metadata { + capture_time: DateTime::parse_iso("2024-02-03T04:05:06Z"), + gps: Some(Gps { latitude: -33.9, longitude: 151.2, altitude: Some(25.0) }), + title: Some("Embedded XMP".into()), + ..Default::default() + }; + let xmp = crate::write_xmp(&metadata, None); + let file = image(1, &[], &resource(1060, b"", xmp.as_bytes())); + let actual = crate::extract(&file); + assert_eq!(actual.capture_time, metadata.capture_time); + assert_eq!(actual.gps, metadata.gps); + assert_eq!(actual.title, metadata.title); + } + + #[test] + fn psd_normalizes_the_optional_exif_resource_prefix() { + let mut exif = b"Exif\0\0".to_vec(); + exif.extend(crate::write_exif(&dated_metadata())); + let file = image(1, &[], &resource(1058, b"", &exif)); + assert_eq!(crate::extract(&file).capture_time, dated_metadata().capture_time); + } + + #[test] + fn psd_skips_unknown_resources_with_pascal_name_and_payload_padding() { + let exif = crate::write_exif(&dated_metadata()); + let mut resources = resource(4000, b"xy", &[1, 2, 3]); + resources.extend(resource(1058, b"", &exif)); + let file = image(1, &[8, 7, 6], &resources); + assert_eq!(crate::extract(&file).capture_time, dated_metadata().capture_time); + } + + #[test] + fn psd_truncation_and_hostile_lengths_return_no_embedded_metadata() { + let file = dated_image(1); + for end in 0..file.len() { + assert_eq!(embedded(&file[..end]), Embedded::default(), "truncated at {end}"); + } + + let mut hostile_colour_length = file.clone(); + hostile_colour_length[26..30].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(embedded(&hostile_colour_length), Embedded::default()); + + let mut hostile_resource_length = file.clone(); + hostile_resource_length[30..34].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(embedded(&hostile_resource_length), Embedded::default()); + + let mut hostile_payload_length = file; + hostile_payload_length[42..46].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(embedded(&hostile_payload_length), Embedded::default()); + } + + #[test] + fn psd_rejects_unknown_versions_and_incomplete_resource_records() { + let file = dated_image(1); + let mut unknown_version = file.clone(); + unknown_version[4..6].copy_from_slice(&3u16.to_be_bytes()); + assert_eq!(embedded(&unknown_version), Embedded::default()); + + let mut truncated_record = file; + truncated_record.pop(); + assert_eq!(embedded(&truncated_record), Embedded::default()); + } +} diff --git a/crates/meta/src/tags.rs b/crates/meta/src/tags.rs index 526348b15..30069dea1 100644 --- a/crates/meta/src/tags.rs +++ b/crates/meta/src/tags.rs @@ -351,7 +351,7 @@ pub fn tag_rows(tiff: &Tiff) -> Vec { } /// [`tag_rows`] of a file's bytes: a TIFF-based file directly, else its embedded EXIF block -/// (JPEG, PNG, WebP, HEIF…). Empty when there is none. +/// (JPEG, PNG, WebP, HEIF, PSD/PSB…). Empty when there is none. pub fn file_tag_rows(bytes: &[u8]) -> Vec { if Tiff::sniff(bytes).is_some() && let Ok(t) = Tiff::parse(bytes) diff --git a/docs/gaps.md b/docs/gaps.md index be411a205..05a9dad3f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** patch (PSD/PSB metadata issue #2519: embedded EXIF/XMP extraction and PSB import support; ranked gaps and readiness counts unchanged) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -60,6 +60,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Closed recently (kept for calibration) +- 2026-10-22: PSD/PSB imports and metadata readers now expose embedded EXIF resource 1058 and XMP resource 1060 for capture dates, GPS and All Metadata (#2519); imported PSD edit copies retain their capture date, and the `removeLocation` Original-export guard sees embedded PSD/PSB GPS. No ranked gap or headline feature count closes. + - 2026-10-20: the import review album picker follows the sidebar folder hierarchy and searches album paths (#2362, LR-IMP-ADD-DIALOG), retaining None/New album and existing destination IDs. This closes a usability subgap; no ranked gap or headline feature count closes. - 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like @@ -79,6 +81,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-22 | patch | Issue #2519: PSD/PSB EXIF resource 1058 and XMP resource 1060 now use the common metadata extractor, and `.psb` files are accepted by import; no ranked gap or readiness count changes | | 2026-10-21 | minor | Gap 28 shrinks: F8 shows / hides the right panel (`view.rightPanel`); rating `[` `]`, flag cycling and F1 help were already bound, so the entry lists only filter-bar keys, Tab / ⇧Tab and F5; 2–4 h (was 4–8) | | 2026-10-20 | minor | #2362: import destination albums use the shared hierarchy/search picker instead of a flat name list; existing import workflows retained; ranked gaps/readiness counts unchanged | | 2026-10-19 | minor | Gap 21: Indonesian (`id`) shipped with every catalog message but What's New; four new languages left (Hindi, Arabic, Korean, Vietnamese), 135–239 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 99f17561e..654c00b8c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** patch (LR-EXP-PSD / LR-META-EXIF-FULL: PSD/PSB imports and image resources 1058/1060 now feed EXIF/XMP metadata; status and feature counts unchanged) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -169,7 +169,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | | LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | -| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | +| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; PSD/PSB EXIF resource 1058 and XMP resource 1060 feed the same metadata rows and preserve sidecar precedence; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -407,7 +407,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). No "Edit a copy / Edit original" choice for non-raw files yet | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). Imported PSD/PSB photos retain embedded capture dates; PSD edit copies retain EXIF/XMP fields in the catalog and All Metadata. Original with Remove Location Info is refused when embedded PSD/PSB GPS is detected. No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) @@ -798,6 +798,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-22 | patch | LR-EXP-PSD, LR-META-EXIF-FULL: PSD/PSB imports and image resources 1058/1060 now supply EXIF and XMP to metadata readers and All Metadata; Original with Remove Location Info refuses detected PSD/PSB GPS; statuses and feature counts unchanged | | 2026-10-21 | patch | LR-IMP-ADD-DIALOG: reconcile #2362 with current main; deleted-album guard and hierarchical picker retained; counts unchanged | | 2026-10-20 | minor | LR-IMP-ADD-DIALOG: #2362 gives the import review the shared hierarchy/search album picker while retaining None/New album and existing destination IDs; status and feature counts unchanged | | 2026-10-21 | patch | KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the slider's row; leaving it (even between two frames) or Tab focus alone does not arm them (issue #1355); status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index cffff83e2..5ae80a2ff 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-22 + +### PSD and PSB metadata stays available +- LightCraft now imports PSD and PSB files with EXIF and XMP image resources. Capture dates, GPS and descriptive fields + stay available in All Metadata; PSD edit copies keep their capture date. Original exports with Remove Location Info enabled + are refused when a PSD or PSB embeds GPS, since LightCraft cannot strip metadata from a byte-for-byte original copy. + ## 2026-10-21 ### Sharper resampling in HDR and panorama merges From c8390afbd25d5d3361b096794b6d67b95d0ff1fe Mon Sep 17 00:00:00 2001 From: ronibalthazar <61232335+ronibalthazar@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:21:32 +0100 Subject: [PATCH 2/5] Fail closed on malformed PSD metadata during original export --- crates/engine/src/export.rs | 12 ++++++++---- crates/engine/src/tests_export.rs | 26 ++++++++++++++++++++++++++ crates/meta/README.md | 1 + crates/meta/src/container.rs | 6 ++++++ crates/meta/src/lib.rs | 29 ++++++++++++++++++++++++++++- crates/meta/src/psd.rs | 2 +- 6 files changed, 70 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index bfeade59c..0e2254afd 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1827,10 +1827,14 @@ impl PreparedExport { }; let Some(dng) = dng else { // the file is copied byte for byte: refuse rather than leak a location it embeds - if remove_location && lightcraft_meta::extract(&bytes).gps.is_some() { - return Err(format!( - "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead" - )); + if remove_location { + let metadata = lightcraft_meta::try_extract(&bytes) + .map_err(|e| format!("{label}: can't verify location metadata in this original file: {e}"))?; + if metadata.gps.is_some() { + return Err(format!( + "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead" + )); + } } return Ok(Exported { file_name, diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 8113cd70c..74bd7269e 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -673,6 +673,32 @@ fn remove_location_refuses_a_psd_that_embeds_gps() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn remove_location_refuses_a_psd_with_unreadable_resources() { + let dir = temp_dir("export-noloc-psd-malformed"); + let gps = lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), ..Default::default() }; + let path = dir.join("Geo.psd"); + let valid_bytes = synthetic_psd_with_metadata(&gps); + assert_eq!(lightcraft_meta::extract(&valid_bytes).gps, gps.gps, "the fixture embeds EXIF GPS before corruption"); + let mut bytes = valid_bytes; + let colour_length = usize::try_from(u32::from_be_bytes(bytes[26..30].try_into().unwrap())).unwrap(); + let resource_length_at = 30 + colour_length; + let resource_length = usize::try_from(u32::from_be_bytes(bytes[resource_length_at..resource_length_at + 4].try_into().unwrap())).unwrap(); + let resource_start = resource_length_at + 4; + let resource_end = resource_start + resource_length; + bytes.splice(resource_end..resource_end, *b"BAD!"); + bytes[resource_length_at..resource_length_at + 4].copy_from_slice(&u32::try_from(resource_length + 4).unwrap().to_be_bytes()); + + assert!(lightcraft_meta::extract(&bytes).gps.is_none(), "the malformed trailing resource reproduces the metadata gap"); + std::fs::write(&path, bytes).unwrap(); + let mut session = Session::new().with_fs(); + session.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = session.catalog.photos().next().unwrap().id; + let err = export_photo(&mut session, id, &ExportOptions::from_json(&json!({"format": "original", "removeLocation": true})), 1).err().unwrap(); + assert!(err.contains("can't verify location metadata") && err.contains("malformed PSD/PSB"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn hdr_png_export_is_16_bit_pq_rec2020() { let mut s = Session::with_demo(); diff --git a/crates/meta/README.md b/crates/meta/README.md index b2642963c..401887c6d 100644 --- a/crates/meta/README.md +++ b/crates/meta/README.md @@ -13,6 +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. | `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/container.rs b/crates/meta/src/container.rs index ec233c52c..3cfb7e287 100644 --- a/crates/meta/src/container.rs +++ b/crates/meta/src/container.rs @@ -75,6 +75,12 @@ pub fn embedded(bytes: &[u8]) -> Embedded { } } +/// Locate metadata blocks while preserving PSD/PSB resource-structure errors for callers that +/// must establish whether embedded GPS is absent. Other containers keep their best-effort walk. +pub(crate) fn try_embedded(bytes: &[u8]) -> Result { + if bytes.starts_with(b"8BPS") { crate::psd::parse(bytes).ok_or(crate::ExtractError::MalformedPsd) } else { Ok(embedded(bytes)) } +} + const XMP_SIG: &[u8] = b"http://ns.adobe.com/xap/1.0/\0"; const XMP_EXT_SIG: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index d54354838..21dfb3598 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -282,7 +282,17 @@ 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 { - let e = embedded(bytes); + extract_embedded(embedded(bytes)) +} + +/// Read metadata while reporting malformed PSD/PSB resource sections 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)) +} + +fn extract_embedded(e: Embedded) -> Metadata { let mut m = e.exif.as_deref().map(read_exif).unwrap_or_default(); if let Some(i) = e.iptc.as_deref() { m.fill_missing(&parse_iptc(i)); @@ -294,6 +304,23 @@ pub fn extract(bytes: &[u8]) -> Metadata { m } +/// 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. + 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"), + } + } +} + +impl std::error::Error for ExtractError {} + /// A lens name from its focal and aperture range (Exif LensSpecification, or a Nikon maker note's Lens), written the /// way Lightroom shows a lens it has no name for: `50.0 mm f/1.8`, `18.0-135.0 mm f/3.5-5.6`. pub fn lens_spec_name(spec: [f64; 4]) -> Option { diff --git a/crates/meta/src/psd.rs b/crates/meta/src/psd.rs index 52acaf735..3a3bfc6f7 100644 --- a/crates/meta/src/psd.rs +++ b/crates/meta/src/psd.rs @@ -30,7 +30,7 @@ pub(crate) fn embedded(bytes: &[u8]) -> Embedded { parse(bytes).unwrap_or_default() } -fn parse(bytes: &[u8]) -> Option { +pub(crate) fn parse(bytes: &[u8]) -> Option { let mut reader = Reader { bytes, offset: 0 }; if reader.take(4)? != b"8BPS" || !matches!(reader.u16()?, 1 | 2) { return None; 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 3/5] 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()); From 26a24eb22e1024ea74c3340d44f94587a8e65122 Mon Sep 17 00:00:00 2001 From: ronibalthazar <61232335+ronibalthazar@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:48:40 +0100 Subject: [PATCH 4/5] Fail closed on malformed PSD XMP and duplicate resources --- crates/engine/src/tests_export.rs | 20 ++++++++++++++++++++ crates/meta/README.md | 2 +- crates/meta/src/lib.rs | 29 ++++++++++++++++++----------- crates/meta/src/psd.rs | 28 ++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 74bd7269e..0d9b8c7ce 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -673,6 +673,26 @@ fn remove_location_refuses_a_psd_that_embeds_gps() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn remove_location_refuses_a_psd_with_unreadable_xmp() { + let dir = temp_dir("export-noloc-psd-xmp"); + let path = dir.join("Unreadable.psd"); + let pixels = [40, 80, 120]; + let image = lightcraft_codecs::EncodeImage::new(1, 1, 3, lightcraft_codecs::Samples::U8(&pixels)); + let xmp = ") -> 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, image-resource sections, or embedded EXIF structure 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/XMP structure (including duplicate EXIF/XMP resources) 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 b24c9ddf2..baebbfe0f 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -284,28 +284,35 @@ impl Metadata { pub fn extract(bytes: &[u8]) -> Metadata { let e = embedded(bytes); let exif = e.exif.as_deref().map(read_exif).unwrap_or_default(); - extract_embedded(e, exif) + let xmp = e.xmp.as_deref().and_then(|xmp| parse_xmp(xmp).ok()).map(|xmp| xmp.metadata); + extract_embedded(e, exif, xmp) } -/// 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. +/// Read metadata while reporting malformed PSD/PSB containers, embedded EXIF, or embedded XMP 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)?; + let is_psd = bytes.starts_with(b"8BPS"); 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) if is_psd => try_read_exif(exif).map_err(|_| ExtractError::MalformedPsd)?, Some(exif) => read_exif(exif), None => Metadata::default(), }; - Ok(extract_embedded(e, exif)) + let xmp = match e.xmp.as_deref() { + Some(xmp) if is_psd => Some(parse_xmp(xmp).map_err(|_| ExtractError::MalformedPsd)?.metadata), + Some(xmp) => parse_xmp(xmp).ok().map(|xmp| xmp.metadata), + None => None, + }; + Ok(extract_embedded(e, exif, xmp)) } -fn extract_embedded(e: Embedded, mut m: Metadata) -> Metadata { +fn extract_embedded(e: Embedded, mut m: Metadata, xmp: Option) -> Metadata { if let Some(i) = e.iptc.as_deref() { m.fill_missing(&parse_iptc(i)); } - if let Some(x) = e.xmp.as_deref().and_then(|x| parse_xmp(x).ok()) { - m.overlay_user_fields(&x.metadata); - m.fill_missing(&x.metadata); + if let Some(xmp) = xmp { + m.overlay_user_fields(&xmp); + m.fill_missing(&xmp); } m } @@ -313,14 +320,14 @@ fn extract_embedded(e: Embedded, mut m: Metadata) -> Metadata { /// A container-level error reported by [`try_extract`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ExtractError { - /// The PSD/PSB header, image-resource section, or embedded EXIF structure is malformed. + /// The PSD/PSB header, image-resource section, or embedded EXIF/XMP 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, image-resource section, or embedded EXIF structure"), + Self::MalformedPsd => f.write_str("malformed PSD/PSB header, image-resource section, or embedded EXIF/XMP structure"), } } } diff --git a/crates/meta/src/psd.rs b/crates/meta/src/psd.rs index 003726dbb..a314ff54d 100644 --- a/crates/meta/src/psd.rs +++ b/crates/meta/src/psd.rs @@ -61,7 +61,9 @@ fn parse_resources(bytes: &[u8]) -> Option { reader.take(1)?; } match id { + 1058 if exif.is_some() => return None, 1058 => exif = Some(data), + 1060 if xmp.is_some() => return None, 1060 => xmp = Some(data), _ => {} } @@ -182,6 +184,32 @@ mod tests { assert!(crate::try_extract(&file).is_ok()); } + #[test] + fn try_extract_rejects_malformed_psd_xmp_but_extract_remains_best_effort() { + let file = image(1, &[], &resource(1060, b"", b" Date: Thu, 22 Oct 2026 16:15:39 +0100 Subject: [PATCH 5/5] #2519: reject invalid UTF-8 in PSD XMP resources --- crates/engine/src/tests_export.rs | 24 +++++++++++++++++++++ crates/meta/README.md | 1 + crates/meta/src/psd.rs | 36 +++++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 0d9b8c7ce..a835ecba4 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -693,6 +693,30 @@ fn remove_location_refuses_a_psd_with_unreadable_xmp() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn remove_location_refuses_a_psd_with_invalid_utf8_xmp() { + let dir = temp_dir("export-noloc-psd-invalid-utf8"); + let path = dir.join("Invalid.psd"); + let gps = lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), ..Default::default() }; + let xmp = lightcraft_meta::write_xmp(&gps, None); + let pixels = [40, 80, 120]; + let image = lightcraft_codecs::EncodeImage::new(1, 1, 3, lightcraft_codecs::Samples::U8(&pixels)); + let embedded = lightcraft_codecs::EncodeMeta { xmp: Some(&xmp), ..Default::default() }; + let mut bytes = lightcraft_codecs::encode_psd(&image, &embedded).unwrap(); + assert_eq!(lightcraft_meta::extract(&bytes).gps, gps.gps); + let latitude = bytes.windows(2).position(|bytes| bytes == b"45").unwrap(); + bytes[latitude] = 0xff; + assert!(lightcraft_meta::extract(&bytes).gps.is_none()); + std::fs::write(&path, bytes).unwrap(); + + let mut session = Session::new().with_fs(); + session.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = session.catalog.photos().next().unwrap().id; + let err = export_photo(&mut session, id, &ExportOptions::from_json(&json!({"format": "original", "removeLocation": true})), 1).err().unwrap(); + assert!(err.contains("can't verify location metadata") && err.contains("malformed PSD/PSB"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn remove_location_refuses_a_psd_with_unreadable_resources() { let dir = temp_dir("export-noloc-psd-malformed"); diff --git a/crates/meta/README.md b/crates/meta/README.md index c7199cd03..d724049ec 100644 --- a/crates/meta/README.md +++ b/crates/meta/README.md @@ -33,6 +33,7 @@ and node count are limited. No panics on malformed input: proptests feed random bytes to every container walker and random text to the XMP parser; truncation tests cut a sample JPEG at every byte and exercise truncated PSD/PSB headers and image-resource records with hostile declared lengths. +PSD/PSB XMP resources require valid UTF-8; trailing NUL padding is trimmed after validation. ## Sources diff --git a/crates/meta/src/psd.rs b/crates/meta/src/psd.rs index a314ff54d..bda16d960 100644 --- a/crates/meta/src/psd.rs +++ b/crates/meta/src/psd.rs @@ -64,14 +64,14 @@ fn parse_resources(bytes: &[u8]) -> Option { 1058 if exif.is_some() => return None, 1058 => exif = Some(data), 1060 if xmp.is_some() => return None, - 1060 => xmp = Some(data), + 1060 => xmp = Some(std::str::from_utf8(data).ok()?), _ => {} } } Some(Embedded { exif: exif.map(crate::strip_exif_header).map(<[u8]>::to_vec), - xmp: xmp.map(|bytes| String::from_utf8_lossy(bytes).trim_end_matches('\0').to_string()), + xmp: xmp.map(|text| text.trim_end_matches('\0').to_string()), ..Default::default() }) } @@ -192,6 +192,38 @@ mod tests { assert!(matches!(crate::try_extract(&file), Err(crate::ExtractError::MalformedPsd))); } + #[test] + fn try_extract_rejects_invalid_utf8_psd_xmp() { + let mut xmp = crate::write_xmp(&dated_metadata(), None).into_bytes(); + let latitude = xmp.windows(2).position(|bytes| bytes == b"45").unwrap(); + xmp[latitude] = 0xff; + let lossy = crate::parse_xmp(&String::from_utf8_lossy(&xmp)).unwrap(); + assert!(lossy.metadata.gps.is_none(), "lossy decoding hides the corrupted GPS value without an XML error"); + + for version in [1, 2] { + let file = image(version, &[], &resource(1060, b"", &xmp)); + assert!(matches!(crate::try_extract(&file), Err(crate::ExtractError::MalformedPsd))); + assert_eq!(embedded(&file), Embedded::default()); + assert_eq!(crate::extract(&file), Metadata::default()); + } + } + + #[test] + fn psd_xmp_preserves_valid_utf8_and_trailing_nul_trimming() { + let metadata = Metadata { title: Some("Café 山".into()), ..dated_metadata() }; + let xmp = crate::write_xmp(&metadata, None); + let mut padded = xmp.as_bytes().to_vec(); + padded.extend_from_slice(b"\0\0"); + + for version in [1, 2] { + let file = image(version, &[], &resource(1060, b"", &padded)); + assert_eq!(embedded(&file).xmp.as_deref(), Some(xmp.as_str())); + let actual = crate::try_extract(&file).unwrap(); + assert_eq!(actual.gps, metadata.gps); + assert_eq!(actual.title, metadata.title); + } + } + #[test] fn try_extract_rejects_duplicate_psd_exif_and_xmp_resources() { let gps = dated_metadata();