diff --git a/crates/metadata/README.md b/crates/metadata/README.md index 309f29638..72ea0063d 100644 --- a/crates/metadata/README.md +++ b/crates/metadata/README.md @@ -18,8 +18,9 @@ are one crate. | `parse_iptc(&[u8]) -> Metadata` | IIM record 2: title (2:5), keywords (2:25), by-line (2:80), copyright (2:116), caption (2:120), date and time created (2:55, 2:60). | | `parse_xmp(&str) -> Result` | `Metadata`, every property (`prefix:name`, structs flattened as `a:b/c:d`) and the structured values. `parse_xmp_with` also canonicalises the caller's own namespaces. | | `write_xmp(&Metadata) -> String` | A complete `` packet. `write_xmp_with` sets the toolkit name and adds simple properties in the caller's namespace. | +| `XmpData::has_location_metadata`, `XmpData::properties_truncated` | Whether a packet names GPS or a place anywhere (array items and repeated structs included), and whether the flattened map is complete, for a caller that must prove a file carries no location. `parse_gps_position` reads an XMP coordinate pair. | | `merge_xmp(existing, fresh, MergeRules)` | Updates a packet another program wrote: owned properties are replaced, everything else stays byte for byte. | -| `parse_gpx(&str) -> Result` | GPX track points; `Tracklog::locate` gives the position at a capture time. | +| `parse_gpx(&str) -> Result` | GPX track points with their track and segment; `Tracklog::locate` gives the position at a capture time, `Tracklog::only_track` one track. | | `embedded(&[u8]) -> Embedded` | Sniffs JPEG, PNG, WebP, TIFF-based files, Fujifilm RAF, Canon CR3 and JPEG XL containers and returns the Exif, XMP (and extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the walkers. | | `cr3::parse_cr3` | The Canon CR3 container: `CMT1`…`CMT4`, the XMP `uuid` box, the thumbnail, the tracks. | | `jxl::metadata` | The `Exif` and `xml ` boxes of a JPEG XL container, plain or Brotli-wrapped in `brob`. | @@ -30,6 +31,10 @@ are one crate. HEIF, PSD and MRW files are read by `craft-heif`, `craft-psd` and `craft-raw`. Put the blocks they return into an `Embedded` and call `from_embedded`. +## Validation + +Only well-formed values become typed metadata; the raw property stays in `XmpData::properties`. XMP orientation must be an integral code 1 to 8; ISO and pixel dimensions exact whole numbers in range (compared as decimal digits); GPS minutes and seconds under 60, the sign from the axis letter. IPTC Time Created counts only as a whole `HHMMSS` or `HHMMSS±HHMM`. A carriage return in written text is ` `. + ## Capture time EXIF candidates are tried in order: DateTimeOriginal, DateTimeDigitized, then IFD0 DateTime. A clock @@ -50,7 +55,7 @@ A foreign namespace that uses a recognised prefix cannot supply recognised prope ## Limits -XMP: 64 levels, 200 000 nodes, 1 MiB of flattened keys and 8 MiB of opaque names per packet +XMP: 64 levels, 200 000 nodes, 512 namespace declarations in scope, 1 MiB of flattened keys and 8 MiB of opaque names per packet (`XmpError::TooComplex`). GPX: 64 levels, 5 000 000 points. MPF: 16 index entries. CR3 and JPEG XL: 4096 boxes; a `brob` box inflates to at most 32 MiB. JPEG ICC is returned only when its APP2 chunks form a complete, consistent, uniquely numbered sequence. TIFF limits are `craft-tiff`'s. No input panics: proptests feed random bytes to every container walker and random text to the XMP parser, and a sample diff --git a/crates/metadata/src/container.rs b/crates/metadata/src/container.rs index 439771b98..bb787c545 100644 --- a/crates/metadata/src/container.rs +++ b/crates/metadata/src/container.rs @@ -25,9 +25,14 @@ pub struct Embedded { pub xmp: Option, /// Extended XMP (JPEG only), reassembled. pub xmp_extended: Option, + /// The GUID every Extended XMP segment carries, set only when all segments share it and + /// their chunks tile exactly the full length they declare (no gap, overlap or excess). + pub xmp_extended_guid: Option, pub icc: Option>, /// IPTC-IIM stream. pub iptc: Option>, + /// True when the PNG/WebP chunk stream could not be fully inspected. + pub chunk_walk_incomplete: bool, } /// Sniff the container and collect its metadata blocks. Unknown containers yield an empty result. @@ -58,11 +63,13 @@ pub fn embedded(bytes: &[u8]) -> Embedded { exif: Some(bytes.to_vec()), xmp: ifd0.bytes(tags::XMP).map(|b| String::from_utf8_lossy(b).into_owned()), xmp_extended: None, + xmp_extended_guid: None, icc: ifd0.bytes(tags::ICC_PROFILE).map(|b| b.to_vec()), iptc: ifd0.value(tags::IPTC_NAA).map(|v| match v { craft_tiff::Value::Byte(b) | craft_tiff::Value::Undefined(b) => b.clone(), other => craft_tiff::writer::encode(t.order, other), }), + chunk_walk_incomplete: false, } } else { Embedded::default() @@ -74,14 +81,27 @@ const XMP_EXT_SIG: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; /// Walk JPEG markers up to SOS and collect APP1 Exif / XMP, APP2 ICC and APP13 IPTC. pub fn jpeg_segments(b: &[u8]) -> Embedded { + jpeg_segments_inner(b, false).0 +} + +/// Walk JPEG metadata and return every ordinary XMP APP1 packet in segment order, along with the +/// usual [`Embedded`] projection (which retains only the first ordinary packet). +pub fn jpeg_segments_with_all_xmp(b: &[u8]) -> (Embedded, Vec) { + jpeg_segments_inner(b, true) +} + +fn jpeg_segments_inner(b: &[u8], collect_all_xmp: bool) -> (Embedded, Vec) { let mut out = Embedded::default(); + let mut xmp_packets = Vec::new(); if !b.starts_with(&[0xff, 0xd8]) { - return out; + return (out, xmp_packets); } let mut icc_parts: [Option<&[u8]>; 255] = [None; 255]; let mut icc_total = None; let mut icc_invalid = false; let mut ext: Vec<(u32, Vec)> = Vec::new(); + // GUID and declared full length of every Extended XMP segment, to verify they belong together + let mut ext_headers: Vec<(&[u8], u32)> = Vec::new(); let mut i = 2usize; while i + 4 <= b.len() { if b[i] != 0xff { @@ -111,13 +131,22 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded { match marker { 0xe1 if payload.starts_with(b"Exif\0") && out.exif.is_none() => out.exif = Some(payload.get(6..).unwrap_or(&[]).to_vec()), 0xe1 if payload.starts_with(XMP_SIG) && out.xmp.is_none() => { - out.xmp = Some(String::from_utf8_lossy(&payload[XMP_SIG.len()..]).into_owned()); + let packet = String::from_utf8_lossy(&payload[XMP_SIG.len()..]).into_owned(); + if collect_all_xmp { + xmp_packets.push(packet.clone()); + } + out.xmp = Some(packet); + } + 0xe1 if payload.starts_with(XMP_SIG) && collect_all_xmp => { + xmp_packets.push(String::from_utf8_lossy(&payload[XMP_SIG.len()..]).into_owned()); } 0xe1 if payload.starts_with(XMP_EXT_SIG) => { // GUID (32) + full length (4) + offset (4) + data let p = &payload[XMP_EXT_SIG.len()..]; if p.len() >= 40 { + let full = u32::from_be_bytes([p[32], p[33], p[34], p[35]]); let off = u32::from_be_bytes([p[36], p[37], p[38], p[39]]); + ext_headers.push((&p[..32], full)); ext.push((off, p[40..].to_vec())); } } @@ -182,9 +211,27 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded { } if !ext.is_empty() { ext.sort_by_key(|p| p.0); + out.xmp_extended_guid = verified_extension_guid(&ext_headers, &ext); out.xmp_extended = Some(String::from_utf8_lossy(&ext.into_iter().flat_map(|p| p.1).collect::>()).into_owned()); } - out + (out, xmp_packets) +} + +/// The GUID shared by all Extended XMP segments when their offset-sorted chunks are contiguous from +/// zero and add up to the full length every segment declares; `None` otherwise. +fn verified_extension_guid(headers: &[(&[u8], u32)], chunks_by_offset: &[(u32, Vec)]) -> Option { + let (&(guid, full), rest) = headers.split_first()?; + if rest.iter().any(|&(other_guid, other_full)| other_guid != guid || other_full != full) { + return None; + } + let mut next = 0u64; + for (offset, data) in chunks_by_offset { + if u64::from(*offset) != next { + return None; + } + next = next.checked_add(u64::try_from(data.len()).ok()?)?; + } + (next == u64::from(full)).then(|| String::from_utf8_lossy(guid).into_owned()) } /// IPTC-IIM block (resource 0x0404) from a Photoshop image-resource stream (`8BIM` records). @@ -209,10 +256,41 @@ fn photoshop_iptc(mut b: &[u8]) -> Option> { pub fn png_chunks(b: &[u8]) -> Embedded { let mut out = Embedded::default(); let mut i = 8usize; - while i + 12 <= b.len() { - let len = u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize; - let typ = &b[i + 4..i + 8]; - let Some(data) = b.get(i + 8..(i + 8).saturating_add(len)) else { break }; + let mut ended = false; + loop { + let Some(header_end) = i.checked_add(8).filter(|end| *end <= b.len()) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(header) = b.get(i..header_end) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(length_bytes) = header.get(..4) else { + out.chunk_walk_incomplete = true; + break; + }; + let Ok(length_bytes) = <[u8; 4]>::try_from(length_bytes) else { + out.chunk_walk_incomplete = true; + break; + }; + let len = u32::from_be_bytes(length_bytes) as usize; + let Some(data_end) = header_end.checked_add(len) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(chunk_end) = data_end.checked_add(4).filter(|end| *end <= b.len()) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(typ) = header.get(4..8) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(data) = b.get(header_end..data_end) else { + out.chunk_walk_incomplete = true; + break; + }; match typ { b"eXIf" => out.exif = Some(crate::strip_exif_header(data).to_vec()), b"iTXt" if data.starts_with(b"XML:com.adobe.xmp\0") => { @@ -227,10 +305,17 @@ pub fn png_chunks(b: &[u8]) -> Embedded { } } } - b"IEND" => break, + b"IEND" => { + ended = len == 0 && chunk_end == b.len(); + out.chunk_walk_incomplete |= !ended; + break; + } _ => {} } - i += 12 + len; + i = chunk_end; + } + if !ended { + out.chunk_walk_incomplete = true; } out } @@ -238,18 +323,69 @@ pub fn png_chunks(b: &[u8]) -> Embedded { /// WebP RIFF chunks `EXIF`, `XMP `, `ICCP`. pub fn webp_chunks(b: &[u8]) -> Embedded { let mut out = Embedded::default(); + let Some(size_bytes) = b.get(4..8) else { + out.chunk_walk_incomplete = true; + return out; + }; + let Ok(size_bytes) = <[u8; 4]>::try_from(size_bytes) else { + out.chunk_walk_incomplete = true; + return out; + }; + let riff_size = u32::from_le_bytes(size_bytes) as usize; + let Some(riff_end) = 8usize.checked_add(riff_size) else { + out.chunk_walk_incomplete = true; + return out; + }; + if riff_size < 4 || riff_end < 12 { + out.chunk_walk_incomplete = true; + return out; + } + if riff_end != b.len() { + out.chunk_walk_incomplete = true; + } + let end = riff_end.min(b.len()); let mut i = 12usize; - while i + 8 <= b.len() { - let id = &b[i..i + 4]; - let len = u32::from_le_bytes([b[i + 4], b[i + 5], b[i + 6], b[i + 7]]) as usize; - let Some(data) = b.get(i + 8..(i + 8).saturating_add(len)) else { break }; + while i < end { + let Some(header_end) = i.checked_add(8).filter(|next| *next <= end) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(header) = b.get(i..header_end) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(length_bytes) = header.get(4..8) else { + out.chunk_walk_incomplete = true; + break; + }; + let Ok(length_bytes) = <[u8; 4]>::try_from(length_bytes) else { + out.chunk_walk_incomplete = true; + break; + }; + let len = u32::from_le_bytes(length_bytes) as usize; + let Some(data_end) = header_end.checked_add(len) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(chunk_end) = data_end.checked_add(len & 1).filter(|next| *next <= end) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(id) = header.get(..4) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(data) = b.get(header_end..data_end) else { + out.chunk_walk_incomplete = true; + break; + }; match id { b"EXIF" => out.exif = Some(crate::strip_exif_header(data).to_vec()), b"XMP " => out.xmp = Some(String::from_utf8_lossy(data).into_owned()), b"ICCP" => out.icc = Some(data.to_vec()), _ => {} } - i += 8 + len + (len & 1); + i = chunk_end; } out } @@ -258,7 +394,7 @@ pub fn webp_chunks(b: &[u8]) -> Embedded { mod tests { use super::*; use crate::exif::tests::sample_exif; - use craft_tiff::ByteOrder; + use craft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; fn seg(marker: u8, payload: &[u8]) -> Vec { let mut v = vec![0xff, marker]; @@ -329,6 +465,150 @@ mod tests { assert_eq!(m.caption.as_deref(), Some("A caption")); } + fn jpeg_with_orientation(exif_orientation: Option, xmp_orientation: &str) -> Vec { + let mut jpeg = vec![0xff, 0xd8]; + if let Some(orientation) = exif_orientation { + let mut exif = b"Exif\0\0".to_vec(); + exif.extend(crate::exif::write_exif(&crate::Metadata { orientation: Some(orientation), ..Default::default() })); + jpeg.extend(seg(0xe1, &exif)); + } + let packet = format!( + r#""# + ); + let mut xmp = XMP_SIG.to_vec(); + xmp.extend_from_slice(packet.as_bytes()); + jpeg.extend(seg(0xe1, &xmp)); + jpeg.extend_from_slice(&[0xff, 0xd9]); + jpeg + } + + #[test] + fn embedded_xmp_orientation_requires_integral_value_and_respects_exif() { + let malformed = crate::extract(&jpeg_with_orientation(None, "6.9")); + assert_eq!(malformed.orientation, None); + + let malformed_xmp_with_exif = crate::extract(&jpeg_with_orientation(Some(crate::Orientation::Rotate180), "6.5")); + assert_eq!(malformed_xmp_with_exif.orientation.map(|orientation| orientation.to_exif()), Some(3)); + + let valid_xmp = crate::extract(&jpeg_with_orientation(None, "6")); + assert_eq!(valid_xmp.orientation.map(|orientation| orientation.to_exif()), Some(6)); + + let valid_exif = crate::extract(&jpeg_with_orientation(Some(crate::Orientation::Rotate180), "6")); + assert_eq!(valid_exif.orientation.map(|orientation| orientation.to_exif()), Some(3)); + } + + #[test] + fn jpeg_invalid_xmp_iso_stays_absent_and_exif_iso_wins() { + let make_jpeg = |exif: Option>, xmp: &str| { + let mut jpeg = vec![0xff, 0xd8]; + if let Some(exif) = exif { + let mut payload = b"Exif\0\0".to_vec(); + payload.extend(exif); + jpeg.extend(seg(0xe1, &payload)); + } + let mut payload = XMP_SIG.to_vec(); + payload.extend_from_slice(xmp.as_bytes()); + jpeg.extend(seg(0xe1, &payload)); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]); + jpeg + }; + let invalid = r#""#; + assert_eq!(crate::extract(&make_jpeg(None, invalid)).iso, None); + + let valid = r#""#; + assert_eq!(crate::extract(&make_jpeg(Some(sample_exif(ByteOrder::Big)), valid)).iso, Some(400)); + } + + #[test] + fn jpeg_rejected_xmp_dimensions_keep_exif_dimensions_and_other_metadata() { + let mut jpeg = vec![0xff, 0xd8]; + let mut exif = b"Exif\0\0".to_vec(); + exif.extend(crate::exif::tests::sample_exif(ByteOrder::Big)); + jpeg.extend(seg(0xe1, &exif)); + let packet = r#" + + "#; + let mut xmp = XMP_SIG.to_vec(); + xmp.extend_from_slice(packet.as_bytes()); + jpeg.extend(seg(0xe1, &xmp)); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + + let metadata = crate::extract(&jpeg); + assert_eq!(metadata.width, Some(6000)); + assert_eq!(metadata.height, Some(4000)); + assert_eq!(metadata.title.as_deref(), Some("Retained")); + } + + #[test] + fn jpeg_rejected_xmp_dimension_falls_back_during_whole_file_extract() { + let mut jpeg = vec![0xff, 0xd8]; + let packet = r#" + + "#; + let mut xmp = XMP_SIG.to_vec(); + xmp.extend_from_slice(packet.as_bytes()); + jpeg.extend(seg(0xe1, &xmp)); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + + assert_eq!(crate::extract(&jpeg).width, Some(800)); + } + + #[test] + fn jpeg_invalid_iptc_time_created_allows_xmp_and_exif_fallbacks() { + fn iim() -> Vec { + let mut data = Vec::new(); + for (dataset, value) in [(55, &b"20260101"[..]), (60, b"121314+01"), (5, b"retained title")] { + data.extend_from_slice(&[0x1c, 2, dataset]); + data.extend_from_slice(&(value.len() as u16).to_be_bytes()); + data.extend_from_slice(value); + } + data + } + + fn jpeg(xmp: &str, exif: Option<&[u8]>) -> Vec { + let mut jpeg = vec![0xff, 0xd8]; + if let Some(exif) = exif { + let mut payload = b"Exif\0\0".to_vec(); + payload.extend_from_slice(exif); + jpeg.extend_from_slice(&seg(0xe1, &payload)); + } + let mut payload = XMP_SIG.to_vec(); + payload.extend_from_slice(xmp.as_bytes()); + jpeg.extend_from_slice(&seg(0xe1, &payload)); + let mut resource = b"Photoshop 3.0\0".to_vec(); + resource.extend_from_slice(b"8BIM\x04\x04\0\0"); + let iim = iim(); + resource.extend_from_slice(&(iim.len() as u32).to_be_bytes()); + resource.extend_from_slice(&iim); + if iim.len() % 2 == 1 { + resource.push(0); + } + jpeg.extend_from_slice(&seg(0xed, &resource)); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + jpeg + } + + let xmp = crate::write_xmp(&crate::Metadata { + capture_time: Some(crate::DateTime { year: 2026, month: 1, day: 2, hour: 10, offset_minutes: Some(0), ..Default::default() }), + ..Default::default() + }); + let fallback = jpeg(&xmp, None); + let embedded = embedded(&fallback); + assert!(embedded.iptc.is_some(), "raw IIM remains available"); + assert_eq!(crate::extract(&fallback).capture_time.map(|time| time.to_iso()).as_deref(), Some("2026-01-02T10:00:00+00:00")); + assert_eq!(crate::extract(&fallback).title.as_deref(), Some("retained title")); + + let mut exif_ifd = IfdBuilder::new(); + exif_ifd.set(0x9003, Value::Ascii("2026:01:03 11:12:13".into())); + let mut ifd0 = IfdBuilder::new(); + ifd0.set_child(0x8769, exif_ifd); + let exif = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + let exif_precedence = jpeg(&xmp, Some(&exif)); + assert_eq!(crate::extract(&exif_precedence).capture_time.map(|time| time.to_iso()).as_deref(), Some("2026-01-03T11:12:13")); + } + #[test] fn jpeg_extended_xmp() { let mut j = vec![0xff, 0xd8]; @@ -341,6 +621,28 @@ mod tests { j.extend(seg(0xe1, &p)); } assert_eq!(jpeg_segments(&j).xmp_extended.as_deref(), Some("helloworld")); + assert_eq!(jpeg_segments(&j).xmp_extended_guid, Some("A".repeat(32))); + } + + /// A GUID mix, a short chunk set or a wrong declared length leaves the extension unverified. + #[test] + fn jpeg_extended_xmp_guid_needs_matching_segments_and_length() { + let build = |segments: &[(u8, u32, u32, &[u8])]| { + let mut j = vec![0xff, 0xd8]; + for &(guid, full, off, part) in segments { + let mut p = XMP_EXT_SIG.to_vec(); + p.extend_from_slice(&[guid; 32]); + p.extend_from_slice(&full.to_be_bytes()); + p.extend_from_slice(&off.to_be_bytes()); + p.extend_from_slice(part); + j.extend(seg(0xe1, &p)); + } + jpeg_segments(&j).xmp_extended_guid + }; + assert!(build(&[(b'A', 10, 0, b"hello"), (b'B', 10, 5, b"world")]).is_none(), "two GUIDs"); + assert!(build(&[(b'A', 10, 0, b"hello")]).is_none(), "half the declared length"); + assert!(build(&[(b'A', 9, 0, b"hello"), (b'A', 9, 5, b"world")]).is_none(), "longer than declared"); + assert!(build(&[(b'A', 10, 0, b"hello"), (b'A', 10, 4, b"oworl")]).is_none(), "overlapping chunks"); } #[test] @@ -404,6 +706,7 @@ mod tests { let m = crate::extract(&p); assert_eq!(m.iso, Some(400)); assert_eq!(m.label.as_deref(), Some("Red")); + assert!(!embedded(&p).chunk_walk_incomplete); let mut w = b"RIFF\0\0\0\0WEBP".to_vec(); let mut body = |id: &[u8], data: &[u8]| { @@ -419,11 +722,30 @@ mod tests { let mut ex = b"Exif\0\0".to_vec(); ex.extend_from_slice(&exif); body(b"EXIF", &ex); + let riff_size = w.len().saturating_sub(8) as u32; + w[4..8].copy_from_slice(&riff_size.to_le_bytes()); let e = webp_chunks(&w); + assert!(!e.chunk_walk_incomplete); assert_eq!(e.icc.as_deref(), Some(&b"abc"[..])); assert_eq!(crate::extract(&w).f_number, Some(2.8)); } + #[test] + fn chunk_walk_reports_truncated_png_and_webp() { + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend_from_slice(&32u32.to_be_bytes()); + png.extend_from_slice(b"iTXtshort"); + assert!(png_chunks(&png).chunk_walk_incomplete); + + let mut webp = b"RIFF\0\0\0\0WEBP".to_vec(); + webp.extend_from_slice(b"XMP "); + webp.extend_from_slice(&32u32.to_le_bytes()); + webp.extend_from_slice(b"short"); + let riff_size = webp.len().saturating_sub(8).saturating_add(27) as u32; + webp[4..8].copy_from_slice(&riff_size.to_le_bytes()); + assert!(webp_chunks(&webp).chunk_walk_incomplete); + } + #[test] fn tiff_file_is_its_own_exif() { let t = sample_exif(ByteOrder::Big); diff --git a/crates/metadata/src/datetime.rs b/crates/metadata/src/datetime.rs index 13ab90bca..9089ad437 100644 --- a/crates/metadata/src/datetime.rs +++ b/crates/metadata/src/datetime.rs @@ -107,7 +107,8 @@ impl DateTime { _ => return None, }; let (h, m) = match rest.split_once(':') { - Some((h, m)) => (num(h)?, num(m)?), + Some((h, m)) if m.len() == 2 => (num(h)?, num(m)?), + Some(_) => return None, None if rest.len() == 4 => (num(rest.get(..2)?)?, num(rest.get(2..)?)?), None => (num(rest)?, 0), }; @@ -159,8 +160,13 @@ impl DateTime { None => (t, None), }; let mut cp = clock.split(':'); - dt.hour = num(cp.next()?)?.try_into().ok()?; - dt.minute = num(cp.next()?)?.try_into().ok()?; + let hour = cp.next()?; + let minute = cp.next()?; + if hour.len() != 2 || minute.len() != 2 { + return None; + } + dt.hour = num(hour)?.try_into().ok()?; + dt.minute = num(minute)?.try_into().ok()?; if let Some(sec) = cp.next() { let (whole, fraction) = match sec.split_once('.') { Some((whole, fraction)) => { @@ -233,6 +239,14 @@ impl DateTime { mod tests { use super::*; + #[test] + fn offset_rejects_short_minute_field() { + assert_eq!(DateTime::parse_offset("+08:5"), None); + assert_eq!(DateTime::parse_offset("+1:2"), None); + assert_eq!(DateTime::parse_offset("+08:05"), Some(485)); + assert_eq!(DateTime::parse_offset("-05:30"), Some(-330)); + } + #[test] fn exif_forms() { let d = DateTime::parse_exif("2024:06:01 13:45:59").unwrap(); @@ -367,6 +381,13 @@ mod tests { } } + #[test] + fn iso_rejects_single_digit_clock_fields() { + for value in ["2024-06-01T1:02:03Z", "2024-06-01T01:2:03Z"] { + assert!(DateTime::parse_iso(value).is_none(), "accepted malformed timestamp {value:?}"); + } + } + #[test] fn iso_validates_clock_while_preserving_supported_forms() { assert_eq!(DateTime::parse_iso("2019").unwrap().to_iso(), "2019"); diff --git a/crates/metadata/src/focus.rs b/crates/metadata/src/focus.rs index b956e667e..1abf3ab4e 100644 --- a/crates/metadata/src/focus.rs +++ b/crates/metadata/src/focus.rs @@ -3,7 +3,8 @@ //! //! **Sony** (the MakerNote IFD: header-less in ARWs, after a `SONY DSC` header in JPEGs). Tag identities and value names come from the public ExifTool //! tag-name documentation (Sony table); every layout below was confirmed on real ILCE-1 (α1) and -//! ILCE-7SM3 (A7S III) ARWs, compared with ExifTool's output as a black box. No third-party code was read. +//! ILCE-7SM3 (A7S III) ARWs, compared with ExifTool's output as a black box, then on the public raw.pixls.us +//! samples of every other body in [`SONY_MODELS`]. No third-party code was read. //! //! | Tag | Layout (confirmed) | Use | //! |---|---|---| @@ -79,7 +80,63 @@ impl FocusPoint { /// Sony bodies whose focus tags have been checked against real files. Another body may store the same /// tags differently (or not mean the same by them): it shows no focus point until its files are checked. -pub const SONY_MODELS: &[&str] = &["ILCE-1", "ILCE-7SM3"]; +/// +/// The α1 and A7S III on 333 files of a photographer's library; the others on the public raw.pixls.us samples (one to +/// six ARWs per body, 103 files of the 44 bodies): every point equal to the one ExifTool's FocusLocation and +/// FocusFrameSize give (77 points, one of them rotated), every refusal (26: manual focus, Wide at the exact centre) +/// equal to what ExifTool's focus modes say, and each body's box drawn on its camera JPEG on the subject it +/// focused on (a pigeon, a face, a leaf, a chart patch). Seven of them (DSC-HX95, DSC-HX99, ILCE-6700, ILCE-7CR, +/// ILCE-7RM2, ILCE-7SM2, ILCE-9M3) only have samples without a point, read as ExifTool reads them. Not listed: the +/// bodies without `0x2027` (every NEX, SLT and DSLR-A; ILCE-3000, 3500, 5000, 5100, 6000 and 6001, QX1, 7, 7R, 7S +/// and 7M2; ILCA-68 and 77M2; RX100 to RX100 III, RX10, RX1 and RX1R; UMC-R10C), and the DSC-RX0, RX100 IV, RX100 V, +/// RX10 II, RX10 III and RX1R II, whose `0x201b` reads 0 (Manual) while the enciphered block says AF-S: ExifTool +/// doesn't decode that tag for them. +pub const SONY_MODELS: &[&str] = &[ + "DSC-HX95", + "DSC-HX99", + "DSC-RX0M2", + "DSC-RX100M5A", + "DSC-RX100M6", + "DSC-RX100M7", + "DSC-RX100M7A", + "DSC-RX10M4", + "DSC-RX1RM3", + "ILCA-99M2", + "ILCE-1", + "ILCE-1M2", + "ILCE-6100", + "ILCE-6300", + "ILCE-6400", + "ILCE-6400A", + "ILCE-6500", + "ILCE-6600", + "ILCE-6700", + "ILCE-7C", + "ILCE-7CM2", + "ILCE-7CR", + "ILCE-7M3", + "ILCE-7M4", + "ILCE-7M5", + "ILCE-7RM2", + "ILCE-7RM3", + "ILCE-7RM3A", + "ILCE-7RM4", + "ILCE-7RM4A", + "ILCE-7RM5", + "ILCE-7SM2", + "ILCE-7SM3", + "ILCE-9", + "ILCE-9M2", + "ILCE-9M3", + "ILME-FX2", + "ILME-FX3", + "ILME-FX30", + "ZV-1", + "ZV-1M2", + "ZV-E1", + "ZV-E10", + "ZV-E10M2", +]; /// Whether a camera as the catalog names it (`"SONY ILCE-1"`: the Exif Make, a space, the Model) is /// one whose files can carry a focus point, so a photo from any other camera is never opened to look. diff --git a/crates/metadata/src/focus/tests.rs b/crates/metadata/src/focus/tests.rs index 148f5ed99..e7644adff 100644 --- a/crates/metadata/src/focus/tests.rs +++ b/crates/metadata/src/focus/tests.rs @@ -261,8 +261,11 @@ fn read_exif_finds_it_through_the_maker_note_and_orientation() { // EXIF 8: sensor x becomes 1 - upright y let (cx, cy) = f.center(); assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}"); - // an unconfirmed body or another maker: nothing - assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none()); + // a body checked on public samples reads the same layout + let f = crate::read_exif(&sony_file("SONY", "ZV-E10", 1, &alpha1_entries())).focus.expect("ZV-E10 focus point"); + assert!((f.center().0 - 3226.0 / 8640.0).abs() < 1e-9, "{:?}", f.center()); + // a body left out (its 0x201b is not the focus mode) or another maker: nothing + assert!(crate::read_exif(&sony_file("SONY", "DSC-RX100M4", 1, &alpha1_entries())).focus.is_none()); assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none()); } @@ -339,7 +342,7 @@ fn header_read_matches_the_whole_file_and_reads_little() { assert_eq!(r.focus, whole); assert!(r.bytes_read <= HEADER_PREFIX); // unsupported bodies agree too (no point either way) - let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries()); + let other = sony_file("SONY", "DSC-RX100M4", 1, &alpha1_entries()); assert_eq!(read(&other).focus, None); } @@ -396,8 +399,10 @@ fn the_synthetic_arw_is_laid_out_as_described() { #[test] fn cameras_that_can_have_a_point() { - assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 ")); - for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] { + for c in ["SONY ILCE-1", "Sony ILCE-7SM3 ", "SONY ILCE-1M2", "SONY ILCE-7M4", "SONY ZV-E10", "SONY DSC-RX100M7"] { + assert!(camera_supported(c), "{c}"); + } + for c in ["SONY ILCE-7M2", "SONY DSC-RX100M4", "SONY NEX-7", "Canon EOS R5", "", "SONY", "ILCE-1", "SON", "SONY ILCE-1M"] { assert!(!camera_supported(c), "{c}"); } } diff --git a/crates/metadata/src/gpx.rs b/crates/metadata/src/gpx.rs index 691a2b7e1..c50454f6b 100644 --- a/crates/metadata/src/gpx.rs +++ b/crates/metadata/src/gpx.rs @@ -7,6 +7,10 @@ //! routes (``) carry no timeline and are ignored. The schema defines a track segment as one //! continuous span of logging, so positions are only interpolated *within* a segment, never across //! the gap where reception was lost or the logger was off. +//! +//! Each point also records which `` it came from ([`TrackPoint::track`], with the tracks' +//! ``s in [`Tracklog::names`]), so a map can draw the tracks of a file apart and tag from +//! one of them ([`Tracklog::only_track`]). use quick_xml::events::Event; @@ -15,6 +19,10 @@ use crate::{DateTime, Gps}; /// Nesting / size limits (a track log is flat; these only stop hostile input). const MAX_DEPTH: usize = 64; const MAX_POINTS: usize = 5_000_000; +/// Most tracks (``) in one file. +const MAX_TRACKS: usize = 10_000; +/// Longest track name kept (characters); the rest is cut. +const MAX_NAME_CHARS: usize = 200; /// One timed point of a track. #[derive(Clone, Copy, Debug, PartialEq)] @@ -27,6 +35,8 @@ pub struct TrackPoint { pub elevation: Option, /// Track segment number, counted across the whole file (continuous logging spans). pub segment: u32, + /// The `` it belongs to, counted from 0 in file order. + pub track: u32, } /// How a position was found by [`Tracklog::locate`]. @@ -44,6 +54,8 @@ pub struct Tracklog { pub points: Vec, /// Tracks (``) in the file. pub tracks: usize, + /// Each track's `` (empty when it has none), in file order: `names.len() == tracks`. + pub names: Vec, /// Track points without a usable time (left out of `points`). pub untimed: usize, } @@ -120,7 +132,13 @@ pub fn parse_gpx(s: &str) -> Result { } match name.as_slice() { b"gpx" if path.is_empty() => seen_gpx = true, - b"trk" if parent == Some(b"gpx") => log.tracks += 1, + b"trk" if parent == Some(b"gpx") => { + if log.tracks >= MAX_TRACKS { + return Err(GpxError::TooComplex); + } + log.tracks += 1; + log.names.push(String::new()); + } b"trkseg" if parent == Some(b"trk") && log.points.last().is_some_and(|p| p.segment == segment) => segment += 1, b"trkpt" if parent == Some(b"trkseg") => { let mut p = Pending::default(); @@ -182,6 +200,11 @@ pub fn parse_gpx(s: &str) -> Result { } } b"trkpt" => finish_point(&mut log, pt.take(), segment)?, + b"name" if parent == Some(b"trk") => { + if let Some(n) = log.names.last_mut() { + *n = text.trim().chars().take(MAX_NAME_CHARS).collect(); + } + } _ => {} } text.clear(); @@ -214,7 +237,9 @@ fn finish_point(log: &mut Tracklog, p: Option, segment: u32) -> Result< if log.points.len() >= MAX_POINTS { return Err(GpxError::TooComplex); } - log.points.push(TrackPoint { time, latitude: lat, longitude: lon, elevation: p.ele, segment }); + let track = u32::try_from(log.tracks.saturating_sub(1)).unwrap_or(u32::MAX); + let elevation = p.ele.filter(|e| e.is_finite()); + log.points.push(TrackPoint { time, latitude: lat, longitude: lon, elevation, segment, track }); Ok(()) } @@ -224,6 +249,16 @@ impl Tracklog { Some((self.points.first()?.time, self.points.last()?.time)) } + /// The points of track `index` alone (its segments as they were), or `None` when there is no + /// such track. + pub fn only_track(&self, index: usize) -> Option { + if index >= self.tracks { + return None; + } + let points = self.points.iter().filter(|p| usize::try_from(p.track).ok() == Some(index)).copied().collect(); + Some(Tracklog { points, tracks: 1, names: vec![self.names.get(index).cloned().unwrap_or_default()], untimed: 0 }) + } + /// Where the logger was at `time` (seconds since the epoch, UTC). /// /// Between two points of the same segment that are at most `max_gap` seconds apart, the position @@ -458,8 +493,8 @@ mod tests { #[test] fn interpolation_across_the_antimeridian() { - let a = TrackPoint { time: 0.0, latitude: 0.0, longitude: 179.0, elevation: None, segment: 0 }; - let b = TrackPoint { time: 10.0, latitude: 0.0, longitude: -179.0, elevation: None, segment: 0 }; + let a = TrackPoint { time: 0.0, latitude: 0.0, longitude: 179.0, elevation: None, segment: 0, track: 0 }; + let b = TrackPoint { time: 10.0, latitude: 0.0, longitude: -179.0, elevation: None, segment: 0, track: 0 }; let g = lerp(&a, &b, 0.75); assert!((g.longitude - -179.5).abs() < 1e-9, "{g:?}"); } @@ -487,5 +522,57 @@ mod tests { ); let deep = "".to_string() + &"".repeat(100); assert_eq!(parse_gpx(&deep), Err(GpxError::TooComplex)); + let many = "".to_string() + &"".repeat(MAX_TRACKS + 1) + ""; + assert_eq!(parse_gpx(&many), Err(GpxError::TooComplex)); + } + + #[test] + fn tracks_are_told_apart_with_their_names() { + let s = r#" Morning + + + + + + + "#; + let log = parse_gpx(s).unwrap(); + assert_eq!(log.tracks, 3); + assert_eq!(log.names, vec!["Morning".to_string(), String::new(), String::new()]); + // sorted by time: the third track's point comes first + assert_eq!(log.points.iter().map(|p| p.track).collect::>(), vec![2, 0, 0]); + let first = log.only_track(0).unwrap(); + assert_eq!((first.points.len(), first.tracks, first.names[0].as_str()), (2, 1, "Morning")); + assert!(log.only_track(1).unwrap().points.is_empty(), "an empty track"); + assert!(log.only_track(3).is_none()); + // a different track is a different segment: never interpolated across + assert_ne!(log.points[0].segment, log.points[1].segment); + } + + #[test] + fn hostile_points_are_left_out() { + let s = r#"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + + + + + NaN + + + + "#; + let log = parse_gpx(s).unwrap(); + assert_eq!(log.points.len(), 3, "{:?}", log.points); + assert_eq!(log.untimed, 1); + assert!(log.points.iter().all(|p| p.latitude.is_finite() && p.longitude.is_finite())); + assert_eq!((log.points[1].latitude, log.points[1].elevation), (90.0, None), "a NaN elevation is no elevation"); + // out of order in the file, in order in the log + assert_eq!((log.points[0].latitude, log.points[2].latitude), (6.0, 5.0)); + assert_eq!(log.names[0].chars().count(), MAX_NAME_CHARS); + // a single point: located at its own time only (within the gap) + let one = parse_gpx(r#""#).unwrap(); + assert_eq!(one.span(), Some((one.points[0].time, one.points[0].time))); + assert!(one.locate(one.points[0].time + 10.0, 60.0).is_some()); + assert!(one.locate(f64::NAN, 60.0).is_none()); } } diff --git a/crates/metadata/src/iptc.rs b/crates/metadata/src/iptc.rs index a398c6fc4..dbde97892 100644 --- a/crates/metadata/src/iptc.rs +++ b/crates/metadata/src/iptc.rs @@ -13,6 +13,67 @@ fn decode(b: &[u8], utf8: bool) -> String { } } +fn decimal_pair(bytes: &[u8]) -> Option { + let [tens, ones] = bytes else { return None }; + if !tens.is_ascii_digit() || !ones.is_ascii_digit() { + return None; + } + Some(u16::from(*tens - b'0') * 10 + u16::from(*ones - b'0')) +} + +fn parse_created(date: Option<&str>, time: Option<&str>) -> Option { + let date = date.filter(|value| value.len() == 8 && value.bytes().all(|byte| byte.is_ascii_digit()))?; + let time = time.filter(|value| !value.is_empty()); + if let Some(time) = time { + let bytes = time.as_bytes(); + let clock = bytes.get(..6)?; + if !clock.iter().all(|byte| byte.is_ascii_digit()) { + return None; + } + let hour = decimal_pair(clock.get(..2)?)?; + let minute = decimal_pair(clock.get(2..4)?)?; + let second = decimal_pair(clock.get(4..6)?)?; + if hour > 23 || minute > 59 || second > 59 { + return None; + } + match bytes.len() { + // IPTC-IIM permits a local wall-clock form, but without an offset + // it is ambiguous; retain the date and omit this time. + 6 => {} + 11 => { + let offset = bytes.get(6..11)?; + if !matches!(offset.first().copied(), Some(b'+' | b'-')) || !offset.get(1..)?.iter().all(|byte| byte.is_ascii_digit()) { + return None; + } + let offset_hour = decimal_pair(offset.get(1..3)?)?; + let offset_minute = decimal_pair(offset.get(3..5)?)?; + if offset_hour > 18 || offset_minute > 59 || (offset_hour == 18 && offset_minute != 0) { + return None; + } + } + _ => return None, + } + } + + let mut iso = format!("{}-{}-{}", date.get(..4)?, date.get(4..6)?, date.get(6..8)?); + if let Some(time) = time.filter(|time| time.len() == 11) { + let bytes = time.as_bytes(); + iso.push('T'); + iso.push_str(time.get(..2)?); + iso.push(':'); + iso.push_str(time.get(2..4)?); + iso.push(':'); + iso.push_str(time.get(4..6)?); + if bytes.len() == 11 { + let offset = time.get(6..)?; + iso.push_str(offset.get(..3)?); + iso.push(':'); + iso.push_str(offset.get(3..)?); + } + } + DateTime::parse_iso(&iso) +} + /// Parse an IIM stream into the fields [`Metadata`] carries (title, keywords, artist, copyright, caption, /// creation date/time). pub fn parse_iptc(b: &[u8]) -> Metadata { @@ -57,16 +118,7 @@ pub fn parse_iptc(b: &[u8]) -> Metadata { } i = at + size; } - if let Some(d) = date.filter(|d| d.len() == 8 && d.bytes().all(|c| c.is_ascii_digit())) { - let mut s = format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..8]); - if let Some(t) = time.filter(|t| t.is_ascii() && t.len() >= 6 && t[..6].bytes().all(|c| c.is_ascii_digit())) { - s += &format!("T{}:{}:{}", &t[..2], &t[2..4], &t[4..6]); - if t.len() >= 11 { - s += &format!("{}:{}", &t[6..9], &t[9..11]); - } - } - m.capture_time = DateTime::parse_iso(&s); - } + m.capture_time = parse_created(date.as_deref(), time.as_deref()); m } @@ -119,6 +171,61 @@ pub(crate) mod tests { assert_eq!(m.capture_time.unwrap().to_iso(), "2021-03-04T10:11:12+01:00"); } + fn capture_time(time: Option<&[u8]>) -> Metadata { + let mut iim = Vec::new(); + ds(&mut iim, 2, 55, b"20260101"); + if let Some(time) = time { + ds(&mut iim, 2, 60, time); + } + ds(&mut iim, 2, 5, b"kept title"); + ds(&mut iim, 2, 25, b"kept keyword"); + parse_iptc(&iim) + } + + #[test] + fn iptc_time_created_rejects_incomplete_or_trailing_text() { + for time in [ + &b"121314+01"[..], + b"121314junk", + b"12A314", + b"12131499", + b"121314+0100junk", + b"121314+", + b"121314+010", + b"121314+01000", + b"12345", + b"240000", + b"250000+0100", + b"126100+0100", + b"235960", + b"235960+0000", + b"121314+1801", + ] { + let metadata = capture_time(Some(time)); + assert_eq!(metadata.capture_time, None, "accepted malformed Time Created {time:?}"); + assert_eq!(metadata.title.as_deref(), Some("kept title"), "unrelated metadata was lost for {time:?}"); + assert_eq!(metadata.keywords, ["kept keyword"], "unrelated metadata was lost for {time:?}"); + } + } + + #[test] + fn iptc_time_created_preserves_supported_forms() { + let cases: &[(Option<&[u8]>, Option<&str>)] = &[ + (None, Some("2026-01-01T00:00:00")), + (Some(b""), Some("2026-01-01T00:00:00")), + (Some(b" \t"), Some("2026-01-01T00:00:00")), + (Some(b"121314"), Some("2026-01-01T00:00:00")), + (Some(b"121314+0100"), Some("2026-01-01T12:13:14+01:00")), + (Some(b"121314-0530"), Some("2026-01-01T12:13:14-05:30")), + (Some(b"121314+1800"), Some("2026-01-01T12:13:14+18:00")), + (Some(b" 121314+0000 \0"), Some("2026-01-01T12:13:14+00:00")), + ]; + for (time, expected) in cases { + let metadata = capture_time(*time); + assert_eq!(metadata.capture_time.map(|value| value.to_iso()).as_deref(), *expected, "Time Created {time:?}"); + } + } + #[test] fn non_utf8_text_drops_trailing_nul() { let mut v = Vec::new(); diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index 7771934c8..baf9fe18c 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -36,7 +36,7 @@ pub mod tags; mod xmp; mod xmp_merge; -pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks}; +pub use container::{Embedded, embedded, jpeg_segments, jpeg_segments_with_all_xmp, png_chunks, webp_chunks}; pub use datetime::{DatePrecision, DateTime}; pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, try_read_exif_with, write_exif}; pub use focus::FocusPoint; @@ -45,7 +45,9 @@ pub use geom::{Orientation, Point, Rect}; pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; pub use iptc::parse_iptc; pub use tags::{TagRow, file_tag_rows, tag_rows}; -pub use xmp::{CRS_NS, XmpData, XmpError, XmpNamespace, XmpValue, XmpWrite, parse_xmp, parse_xmp_with, write_xmp, write_xmp_with}; +pub use xmp::{ + CRS_NS, XmpData, XmpError, XmpNamespace, XmpValue, XmpWrite, parse_gps_position, parse_xmp, parse_xmp_with, write_xmp, write_xmp_with, +}; pub use xmp_merge::{MergeRules, merge_xmp}; use serde::{Deserialize, Serialize}; @@ -383,6 +385,20 @@ pub fn lens_spec_name(spec: [f64; 4]) -> Option { Some(format!("{focal} mm f/{aperture}")) } +/// Whether `s` has the ` mm f/` shape written by [`lens_spec_name`]. +pub fn is_lens_spec_name(s: &str) -> bool { + let Some((focal, aperture)) = s.trim().split_once(" mm f/") else { return false }; + let is_num_or_range = |part: &str| { + let mut pieces = part.split('-'); + let Some(first) = pieces.next() else { return false }; + let second = pieces.next(); + pieces.next().is_none() + && first.parse::().is_ok_and(|v| v.is_finite() && v > 0.0) + && second.is_none_or(|b| b.parse::().is_ok_and(|v| v.is_finite() && v > 0.0)) + }; + is_num_or_range(focal) && is_num_or_range(aperture) +} + /// Lenient number parsing used by XMP/IPTC: `"28/10"`, `"2.8"`, `"+0.7"`. pub(crate) fn parse_number(s: &str) -> Option { let s = s.trim(); @@ -408,6 +424,62 @@ mod tests { assert_eq!(lens_spec_name([f64::NAN, 50.0, 1.8, 1.8]), None); } + #[test] + fn lens_spec_names_are_recognised() { + for spec in [[50.0, 50.0, 1.8, 1.8], [18.0, 135.0, 3.5, 5.6]] { + assert!(is_lens_spec_name(&lens_spec_name(spec).unwrap())); + } + assert!(is_lens_spec_name(" 24.0-70.0 mm f/2.8 ")); + for not in ["EF 50mm f/1.8", "50 mm f/", "0 mm f/2.8", "50-60-70 mm f/2.8", "nan mm f/2", "50 mm f/1.8-"] { + assert!(!is_lens_spec_name(not), "{not}"); + } + } + + #[test] + fn from_embedded_preserves_exif_iptc_xmp_precedence() { + let exif = Metadata { + make: Some("Example".into()), + model: Some("Camera".into()), + lens_model: Some("35mm F2".into()), + artist: Some("EXIF artist".into()), + orientation: Some(Orientation::from_exif(6)), + exposure_time: Some(1.0 / 125.0), + ..Default::default() + }; + let packet = write_xmp(&Metadata { + artist: Some("XMP artist".into()), + caption: Some("XMP caption".into()), + rating: Some(4), + keywords: vec!["xmp-keyword".into()], + ..Default::default() + }); + let mut iptc = Vec::new(); + for (dataset, value) in [(5u8, "IPTC title"), (120u8, "IPTC caption")] { + iptc.extend_from_slice(&[0x1c, 2, dataset]); + iptc.extend_from_slice(&u16::try_from(value.len()).unwrap().to_be_bytes()); + iptc.extend_from_slice(value.as_bytes()); + } + let blocks = Embedded { exif: Some(write_exif(&exif)), iptc: Some(iptc), xmp: Some(packet.clone()), ..Default::default() }; + let parsed = from_embedded(&blocks); + assert_eq!(parsed.make, exif.make); + assert_eq!(parsed.model, exif.model); + assert_eq!(parsed.lens_model, exif.lens_model); + assert_eq!(parsed.orientation, exif.orientation); + assert_eq!(parsed.exposure_time, exif.exposure_time); + assert_eq!(parsed.title.as_deref(), Some("IPTC title")); + assert_eq!(parsed.caption.as_deref(), Some("XMP caption")); + assert_eq!(parsed.artist.as_deref(), Some("XMP artist")); + assert_eq!(parsed.rating, Some(4)); + assert_eq!(parsed.keywords, vec!["xmp-keyword"]); + assert_eq!(blocks.xmp.as_deref(), Some(packet.as_str())); + } + + #[test] + fn from_embedded_ignores_damaged_blocks() { + let blocks = Embedded { exif: Some(vec![1, 2, 3]), iptc: Some(vec![0x1c, 2]), xmp: Some(">, + /// True when flattened properties were omitted after reaching the cumulative key-size limit. + pub properties_truncated: bool, /// Every top-level property with its full structure (arrays of structs such as local /// corrections, which [`XmpData::properties`] flattens). pub values: BTreeMap, /// The `x:xmptk` attribute of `x:xmpmeta` (older packets: `x:xaptk` of `x:xapmeta`), the name /// of the program that wrote the packet. [`write_xmp_with`] writes [`XmpWrite::toolkit`] there. pub toolkit: Option, + /// True when the parsed tree, before flattening or merging repeated properties, names an Exif + /// GPS property anywhere or holds a place property (see `LOCATION_PROPERTIES`) with non-blank + /// text anywhere, including inside array items that [`XmpData::properties`] flattens to their + /// text and repeated structs that [`XmpData::values`] keeps only once. + pub names_location: bool, +} + +/// Properties that name a place; a blank one is not a location. +const LOCATION_PROPERTIES: [&str; 7] = [ + "Iptc4xmpCore:Location", + "Iptc4xmpCore:CountryCode", + "photoshop:City", + "photoshop:State", + "photoshop:Country", + "Iptc4xmpExt:LocationShown", + "Iptc4xmpExt:LocationCreated", +]; + +impl XmpData { + /// Whether the XMP inventory names GPS or a non-empty place field. LocationShown and + /// LocationCreated are IPTC Extension structures; their flattened array entries can be empty + /// strings even when their struct children contain a city or coordinates, so inspect their + /// retained structured value and flattened child fields as well. Callers that must prove + /// absence must check [`Self::properties_truncated`] first. + pub fn has_location_metadata(&self) -> bool { + const IPTC_LOCATION_STRUCTS: [&str; 2] = ["Iptc4xmpExt:LocationShown", "Iptc4xmpExt:LocationCreated"]; + // Every flattened child of one LocationShown/LocationCreated root shares its answer. + let mut checked_roots: BTreeMap<&str, bool> = BTreeMap::new(); + if self.names_location { + return true; + } + + for (key, values) in &self.properties { + let parts = key.split('/'); + if parts.clone().any(is_gps_name) { + // Even an isolated/blank GPS property is not proof that GPS was removed. + return true; + } + + let Some(location_part) = parts.clone().find(|part| LOCATION_PROPERTIES.contains(part)) else { + continue; + }; + + if values.iter().any(|value| !value.trim().is_empty()) { + return true; + } + if IPTC_LOCATION_STRUCTS.contains(&location_part) { + // The scope is the path up to the location field itself, so text elsewhere in an + // enclosing struct doesn't count as a place. + let scope_len = + key.split('/').take_while(|part| *part != location_part).map(|part| part.len() + 1).sum::() + location_part.len(); + let scope = key.get(..scope_len).unwrap_or(key); + let scope_has_text = *checked_roots.entry(scope).or_insert_with(|| self.location_scope_has_text(scope)); + if scope_has_text { + return true; + } + } + } + + false + } + + /// Whether a LocationShown/LocationCreated field (a `/`-separated path from a top-level + /// property) holds text in its retained structured value or any flattened child. `values` + /// keeps the first text-bearing occurrence across RDF descriptions, while flattened child + /// properties retain every occurrence. + fn location_scope_has_text(&self, scope: &str) -> bool { + let mut parts = scope.split('/'); + let structured = parts.next().and_then(|root| self.values.get(root)).and_then(|value| parts.try_fold(value, |value, part| value.field(part))); + if structured.is_some_and(value_has_text) { + return true; + } + let child_prefix = format!("{scope}/"); + self.properties + .range::((std::ops::Bound::Included(child_prefix.as_str()), std::ops::Bound::Unbounded)) + .take_while(|(candidate, _)| candidate.starts_with(&child_prefix)) + .any(|(_, nested_values)| nested_values.iter().any(|value| !value.trim().is_empty())) + } + + /// The GUID of the JPEG Extended XMP continuation the packet announces + /// (`xmpNote:HasExtendedXMP`), matched by local name so an undeclared or foreign prefix can't + /// hide the announcement. `Some("")` when the announcement is blank. + pub fn extended_xmp_guid(&self) -> Option<&str> { + self.properties + .iter() + .find(|(key, _)| !key.contains('/') && key.rsplit([':', '}']).next() == Some("HasExtendedXMP")) + .map(|(_, values)| values.first().map_or("", |value| value.trim())) + } } impl XmpData { @@ -143,15 +234,75 @@ impl XmpValue { } } +/// Whether a structured value holds any non-blank text. A tree larger than the parser's node +/// budget counts as text-bearing (the conservative answer for a privacy check): parsed trees are +/// capped at MAX_NODES, so only a caller-built `XmpData` can reach it, and the walk stays bounded. +fn value_has_text(value: &XmpValue) -> bool { + let mut pending = Vec::new(); + if pending.try_reserve(1).is_err() { + return true; + } + pending.push(value); + let mut visited = 0usize; + while let Some(value) = pending.pop() { + visited = visited.saturating_add(1); + if visited > MAX_NODES { + return true; + } + let children = match value { + XmpValue::Text(text) if !text.trim().is_empty() => return true, + XmpValue::Text(_) => continue, + XmpValue::Array(items) => items.len(), + XmpValue::Struct(fields) => fields.len(), + }; + if pending.len().saturating_add(children) > MAX_NODES || pending.try_reserve(children).is_err() { + return true; + } + match value { + XmpValue::Array(items) => pending.extend(items), + XmpValue::Struct(fields) => pending.extend(fields.values()), + XmpValue::Text(_) => {} + } + } + false +} + +/// Insert a property into a struct or description map, keeping an earlier text-bearing value when +/// the same name repeats with a blank one: an IPTC location struct repeated with an empty +/// `rdf:li` must not hide the place an export's Remove Location has to refuse. `has_text` +/// remembers each key's answer so every value is walked at most once. +fn insert_keeping_text(map: &mut BTreeMap, has_text: &mut BTreeMap, key: String, value: XmpValue) { + match map.entry(key) { + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(value); + } + std::collections::btree_map::Entry::Occupied(mut slot) => { + let existing_has_text = match has_text.get(slot.key()) { + Some(known) => *known, + None => value_has_text(slot.get()), + }; + if existing_has_text { + has_text.insert(slot.key().clone(), true); + return; + } + let new_has_text = value_has_text(&value); + has_text.insert(slot.key().clone(), new_has_text); + slot.insert(value); + } + } +} + +/// A description or struct node as a map of its fields (attributes and child elements). fn node_struct(n: &Node) -> XmpValue { let mut m = BTreeMap::new(); + let mut has_text = BTreeMap::new(); for (k, v) in &n.attrs { if !is_rdf_meta_attr(k) { - m.insert(k.clone(), XmpValue::Text(v.clone())); + insert_keeping_text(&mut m, &mut has_text, k.clone(), XmpValue::Text(v.clone())); } } for c in &n.children { - m.insert(c.name.clone(), node_value(c)); + insert_keeping_text(&mut m, &mut has_text, c.name.clone(), node_value(c)); } XmpValue::Struct(m) } @@ -193,6 +344,18 @@ pub(crate) const MAX_OPAQUE_NAME_BYTES: usize = 8 * 1024 * 1024; /// retains its prefix once per property: node limits alone cannot bound that duplication. const MAX_KEY_BYTES: usize = 1 << 20; +/// Most namespace declarations in scope at once. Prefix lookup scans them, so declarations times the names of a +/// start tag is the work; real packets declare a few dozen. +const MAX_NS_DECLS: usize = 512; + +/// Refuses a scope stack holding more than [`MAX_NS_DECLS`] declarations (one count per start tag over a stack at most [`MAX_DEPTH`] deep). +pub(crate) fn check_scopes(scopes: &[Vec<(String, String)>]) -> Result<(), XmpError> { + if scopes.iter().map(Vec::len).sum::() > MAX_NS_DECLS { + return Err(XmpError::TooComplex); + } + Ok(()) +} + /// Names canonicalised by namespace URI (see [`NAMESPACES`]); conflicting foreign prefixes are opaque. pub(crate) fn canonical_name( qname: &str, @@ -265,6 +428,12 @@ fn xml_safe(s: &str) -> String { s.chars().filter(|&c| is_xml_char(c)).collect() } +/// Escaped text with each carriage return as ` `: XML end-of-line handling turns a literal one +/// into a line feed on read. +fn keep_cr(escaped: std::borrow::Cow<'_, str>) -> String { + escaped.replace('\r', " ") +} + /// Index of the `;` that ends a reference starting at `rest` (its `&`). The scan stops at the first /// byte that cannot appear in a name or a numeric reference, so zero-padded references such as /// `a` are found and each run of name bytes is read once, not once per `&`. @@ -323,6 +492,7 @@ fn parse_tree(s: &str, extra: &[XmpNamespace]) -> Result { } } scopes.push(scope); + check_scopes(&scopes)?; let name = canonical_name(&String::from_utf8_lossy(e.name().as_ref()), &scopes, extra, &mut opaque_budget)?; let attrs = raw_attrs.into_iter().map(|(k, v)| Ok((canonical_name(&k, &scopes, extra, &mut opaque_budget)?, v))).collect:: bool { matches!(k, "rdf:about" | "rdf:ID" | "rdf:nodeID" | "xml:lang" | "rdf:parseType" | "rdf:resource" | "rdf:datatype") || k.starts_with("xmlns") } -fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize) { +fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize, truncated: &mut bool) { for (k, v) in &d.attrs { + if is_rdf_meta_attr(k) { + continue; + } // the budget is checked before the path is built, not after - if is_rdf_meta_attr(k) || *budget < prefix.len().saturating_add(k.len()) { + if *budget < prefix.len().saturating_add(k.len()) { + *truncated = true; continue; } let key = format!("{prefix}{k}"); - if take_key_bytes(&key, budget) { + if take_key_bytes(&key, budget, truncated) { props.entry(key).or_default().push(v.clone()); } } for c in &d.children { - collect_property(c, prefix, props, budget); + collect_property(c, prefix, props, budget, truncated); } } /// Charge one flattened key's length to the key budget; `false` when the budget is spent (the -/// property is then left out, like other elements a limit has cut). -fn take_key_bytes(key: &str, budget: &mut usize) -> bool { +/// property is then left out, like other elements a limit has cut) and the map is marked partial. +fn take_key_bytes(key: &str, budget: &mut usize, dropped: &mut bool) -> bool { let n = key.len(); if *budget < n { + *dropped = true; return false; } *budget -= n; true } -fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize) { +fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize, truncated: &mut bool) { // a path that can't fit the budget isn't built (a long ancestor path shared by many children // would otherwise be copied for each of them after the budget is spent) if *budget < prefix.len().saturating_add(p.name.len()) { + *truncated = true; return; } let key = format!("{prefix}{}", p.name); @@ -432,7 +608,7 @@ fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap = p.attrs.iter().filter(|(k, _)| !is_rdf_meta_attr(k)).collect(); if !field_attrs.is_empty() && p.children.is_empty() && p.text.trim().is_empty() { for (k, v) in field_attrs { let full = format!("{struct_prefix}{k}"); - if take_key_bytes(&full, budget) { + if take_key_bytes(&full, budget, truncated) { props.entry(full).or_default().push(v.clone()); } } return; } let v = p.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| p.text.trim().to_string()); - if take_key_bytes(&key, budget) { + if take_key_bytes(&key, budget, truncated) { props.entry(key).or_default().push(v); } } @@ -479,21 +655,95 @@ fn find_descriptions<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { } } -/// XMP GPS coordinate `DDD,MM,SSk` or `DDD,MM.mmk` (k = N/S/E/W) → signed degrees. -fn parse_gps_coord(s: &str) -> Option { +/// Whether a canonical property name is an Exif GPS property. +fn is_gps_name(name: &str) -> bool { + name.starts_with("exif:GPS") || name.starts_with("exifEX:GPS") +} + +/// Whether the tree names an Exif GPS property anywhere or holds a place property with non-blank +/// text anywhere. Each node is visited at most twice (a place's subtree is scanned once and not +/// walked again), and the parser caps the tree at `MAX_NODES`. +fn tree_names_location(root: &Node) -> bool { + let mut pending = vec![root]; + while let Some(node) = pending.pop() { + if is_gps_name(&node.name) { + return true; + } + for (name, value) in &node.attrs { + if is_gps_name(name) || (LOCATION_PROPERTIES.contains(&name.as_str()) && !value.trim().is_empty()) { + return true; + } + } + if LOCATION_PROPERTIES.contains(&node.name.as_str()) { + if subtree_has_text(node) { + return true; + } + // A blank place may still enclose a GPS property. + if subtree_names_gps(node) { + return true; + } + continue; + } + pending.extend(&node.children); + } + false +} + +/// Whether a node or any descendant holds non-blank text: element text, an `rdf:resource`, or a +/// field attribute (RDF syntax attributes such as `rdf:parseType` and `xml:lang` don't count). +fn subtree_has_text(root: &Node) -> bool { + let mut pending = vec![root]; + while let Some(node) = pending.pop() { + if !node.text.trim().is_empty() { + return true; + } + if node.attrs.iter().any(|(name, value)| (name == "rdf:resource" || !is_rdf_meta_attr(name)) && !value.trim().is_empty()) { + return true; + } + pending.extend(&node.children); + } + false +} + +/// Whether a node or any descendant is an Exif GPS property, as an element or an attribute. +fn subtree_names_gps(root: &Node) -> bool { + let mut pending = vec![root]; + while let Some(node) = pending.pop() { + if is_gps_name(&node.name) || node.attrs.iter().any(|(name, _)| is_gps_name(name)) { + return true; + } + pending.extend(&node.children); + } + false +} + +/// An `exif:GPSLatitude` / `exif:GPSLongitude` pair → (latitude, longitude) in signed degrees; `None` +/// unless both parse and lie on the globe (|latitude| ≤ 90, |longitude| ≤ 180, as the Exif reader checks). +pub fn parse_gps_position(lat: &str, lon: &str) -> Option<(f64, f64)> { + let (lat, lon) = (parse_gps_coord(lat, ['N', 'S'])?, parse_gps_coord(lon, ['E', 'W'])?); + ((-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon)).then_some((lat, lon)) +} + +/// XMP GPS coordinate `DDD,MM,SSk` or `DDD,MM.mmk` → signed degrees, k being `[positive, negative]` of +/// its axis (N/S or E/W, either case). The sign comes only from the letter: a negative part +/// (`-10,30N`) is malformed. +fn parse_gps_coord(s: &str, [pos, neg]: [char; 2]) -> Option { let s = s.trim(); let dir = s.chars().last()?; let body = &s[..s.len() - dir.len_utf8()]; let parts: Vec = body.split(',').map(|p| p.trim().parse::().ok()).collect::>()?; + if parts.iter().any(|p| p.is_sign_negative()) { + return None; + } let v = match parts.as_slice() { [d] => *d, - [d, m] => d + m / 60.0, - [d, m, sec] => d + m / 60.0 + sec / 3600.0, + [d, m] if (0.0..60.0).contains(m) => d + m / 60.0, + [d, m, sec] if (0.0..60.0).contains(m) && (0.0..60.0).contains(sec) => d + m / 60.0 + sec / 3600.0, _ => return None, }; let v = match dir.to_ascii_uppercase() { - 'N' | 'E' => v, - 'S' | 'W' => -v, + c if c == pos => v, + c if c == neg => -v, _ => return None, }; v.is_finite().then_some(v) @@ -581,6 +831,183 @@ fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String { format!("{},{:.6}{}", d as i64, m, if v < 0.0 { neg } else { pos }) } +// Pixel dimensions are integers. Keep decimal coefficients as digits so wide rational components +// remain exact and fractions that `f64` rounds to an integer can still be rejected. +struct ExactDecimal { + negative: bool, + coefficient: String, + exponent: i64, +} + +struct ExactRatio { + negative: bool, + numerator: String, + denominator: String, + exponent: i64, +} + +fn exact_decimal(value: &str) -> Option { + let value = value.trim(); + let (negative, value) = if let Some(value) = value.strip_prefix('-') { + (true, value) + } else if let Some(value) = value.strip_prefix('+') { + (false, value) + } else { + (false, value) + }; + let (mantissa, exponent) = if let Some((mantissa, exponent)) = value.split_once('e').or_else(|| value.split_once('E')) { + if exponent.contains('e') || exponent.contains('E') { + return None; + } + (mantissa, exponent.parse::().ok()?) + } else { + (value, 0) + }; + let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, "")); + if fraction.contains('.') { + return None; + } + let digits = whole.bytes().chain(fraction.bytes()); + if !digits.clone().all(|digit| digit.is_ascii_digit()) { + return None; + } + let count = whole.len().checked_add(fraction.len())?; + if count == 0 { + return None; + } + let leading = digits.clone().take_while(|digit| *digit == b'0').count(); + if leading == count { + return Some(ExactDecimal { negative, coefficient: "0".to_owned(), exponent: 0 }); + } + let trailing = digits.clone().rev().take_while(|digit| *digit == b'0').count(); + let significant = count.checked_sub(leading)?.checked_sub(trailing)?; + let mut coefficient = String::with_capacity(significant); + for digit in digits.skip(leading).take(significant) { + coefficient.push(char::from(digit)); + } + let exponent = exponent.checked_sub(i64::try_from(fraction.len()).ok()?)?.checked_add(i64::try_from(trailing).ok()?)?; + Some(ExactDecimal { negative, coefficient, exponent }) +} + +fn exact_ratio(numerator: ExactDecimal, denominator: ExactDecimal) -> Option { + if denominator.coefficient == "0" { + return None; + } + Some(ExactRatio { + negative: numerator.negative ^ denominator.negative, + numerator: numerator.coefficient, + denominator: denominator.coefficient, + exponent: numerator.exponent.checked_sub(denominator.exponent)?, + }) +} + +fn multiply_decimal_by_u32(value: &str, multiplier: u32) -> String { + if multiplier == 0 { + return "0".to_owned(); + } + let mut digits = Vec::with_capacity(value.len().saturating_add(10)); + let mut carry = 0u64; + for digit in value.bytes().rev() { + let product = u64::from(digit - b'0') * u64::from(multiplier) + carry; + digits.push(b'0' + (product % 10) as u8); + carry = product / 10; + } + while carry != 0 { + digits.push(b'0' + (carry % 10) as u8); + carry /= 10; + } + digits.reverse(); + digits.into_iter().map(char::from).collect() +} + +fn decimal_significand(value: &str) -> Option { + let mut prefix = 0u64; + let mut digits = 0u32; + for digit in value.bytes().take(17) { + prefix = prefix.checked_mul(10)?.checked_add(u64::from(digit.checked_sub(b'0')?))?; + digits = digits.checked_add(1)?; + } + let exponent = i32::try_from(digits.checked_sub(1)?).ok()?; + Some(prefix as f64 / 10.0_f64.powi(exponent)) +} + +fn compare_scaled_decimal(left: &str, left_exponent: i64, right: &str, right_exponent: i64) -> Option { + if left == "0" { + return Some(if right == "0" { std::cmp::Ordering::Equal } else { std::cmp::Ordering::Less }); + } + if right == "0" { + return Some(std::cmp::Ordering::Greater); + } + let left_magnitude = i128::try_from(left.len()).ok()?.checked_add(i128::from(left_exponent))?; + let right_magnitude = i128::try_from(right.len()).ok()?.checked_add(i128::from(right_exponent))?; + match left_magnitude.cmp(&right_magnitude) { + std::cmp::Ordering::Equal => {} + ordering => return Some(ordering), + } + let length = left.len().max(right.len()); + for (left_digit, right_digit) in left.bytes().chain(std::iter::repeat(b'0')).zip(right.bytes().chain(std::iter::repeat(b'0'))).take(length) { + match left_digit.cmp(&right_digit) { + std::cmp::Ordering::Equal => {} + ordering => return Some(ordering), + } + } + Some(std::cmp::Ordering::Equal) +} + +fn exact_ratio_to_u32(value: ExactRatio) -> Option { + if value.negative || value.denominator == "0" { + return None; + } + if value.numerator == "0" { + return Some(0); + } + if value.exponent < -128 || value.exponent > 128 { + return None; + } + let numerator_length = i128::try_from(value.numerator.len()).ok()?; + let denominator_length = i128::try_from(value.denominator.len()).ok()?; + let decimal_scale = numerator_length.checked_add(i128::from(value.exponent))?.checked_sub(denominator_length)?; + if !(0..=10).contains(&decimal_scale) { + return None; + } + // The prefix only estimates a candidate. Acceptance compares the full exact coefficients below. + let approximate = + decimal_significand(&value.numerator)? / decimal_significand(&value.denominator)? * 10.0_f64.powi(i32::try_from(decimal_scale).ok()?); + if !approximate.is_finite() { + return None; + } + let integer = approximate.round().clamp(0.0, f64::from(u32::MAX)) as u32; + let product = multiply_decimal_by_u32(&value.denominator, integer); + let ordering = if product == "0" { + compare_scaled_decimal(&value.numerator, value.exponent, "0", 0)? + } else { + let trailing_zeroes = product.bytes().rev().take_while(|digit| *digit == b'0').count(); + let product_exponent = i64::try_from(trailing_zeroes).ok()?; + compare_scaled_decimal(&value.numerator, value.exponent, product.trim_end_matches('0'), product_exponent)? + }; + (ordering == std::cmp::Ordering::Equal).then_some(integer) +} + +fn exact_u32_from_text(value: &str) -> Option { + let value = value.trim(); + let ratio = if let Some((numerator, denominator)) = value.split_once('/') { + exact_ratio(exact_decimal(numerator)?, exact_decimal(denominator)?)? + } else { + let value = exact_decimal(value)?; + ExactRatio { negative: value.negative, numerator: value.coefficient, denominator: "1".to_owned(), exponent: value.exponent } + }; + exact_ratio_to_u32(ratio) +} + +/// A positive whole-number ISO, read exactly (a fraction or an out-of-range value is not one). +fn iso_from_text(value: &str) -> Option { + exact_u32_from_text(value).filter(|&iso| iso > 0) +} + +fn dimension_candidate(first: impl Fn(&str) -> Option, key: &str) -> Option { + first(key).and_then(|value| exact_u32_from_text(&value)) +} + /// Parse an XMP packet (with or without the `` wrapper / `x:xmpmeta` element). pub fn parse_xmp(s: &str) -> Result { parse_xmp_with(s, &[]) @@ -590,16 +1017,22 @@ pub fn parse_xmp(s: &str) -> Result { pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result { let s = s.trim_start_matches('\u{feff}'); let tree = parse_tree(s, extra)?; + let names_location = tree_names_location(&tree); let mut descs = Vec::new(); find_descriptions(&tree, &mut descs); let mut props = BTreeMap::new(); let mut values = BTreeMap::new(); + let mut properties_truncated = false; + let mut value_has_text_by_key = BTreeMap::new(); // One budget for the whole document: several descriptions cannot each spend a fresh one. let mut key_budget = MAX_KEY_BYTES; for d in descs { - collect_description(d, "", &mut props, &mut key_budget); + collect_description(d, "", &mut props, &mut key_budget, &mut properties_truncated); if let XmpValue::Struct(m) = node_struct(d) { - values.extend(m); + // Later descriptions can repeat a property too; the same rule keeps an earlier place. + for (key, value) in m { + insert_keeping_text(&mut values, &mut value_has_text_by_key, key, value); + } } } let first = |k: &str| props.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); @@ -620,9 +1053,9 @@ pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result= 0.0).or_else(|| num("exif:PixelXDimension").filter(|v| *v >= 0.0)).map(|v| v as u32), - height: num("tiff:ImageLength").filter(|v| *v >= 0.0).or_else(|| num("exif:PixelYDimension").filter(|v| *v >= 0.0)).map(|v| v as u32), + orientation: num("tiff:Orientation").filter(|v| (1.0..=8.0).contains(v) && v.fract() == 0.0).map(|v| Orientation::from_exif(v as u16)), + width: dimension_candidate(first, "tiff:ImageWidth").or_else(|| dimension_candidate(first, "exif:PixelXDimension")), + height: dimension_candidate(first, "tiff:ImageLength").or_else(|| dimension_candidate(first, "exif:PixelYDimension")), title: first("dc:title"), caption: first("dc:description"), alt_text: first("Iptc4xmpCore:AltTextAccessibility"), @@ -643,7 +1076,8 @@ pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result 0.0).map(|v| v as u32); + let iso = |key: &str| first(key).and_then(|value| iso_from_text(&value)); + m.iso = [iso("exif:ISOSpeedRatings"), iso("exifEX:PhotographicSensitivity")].into_iter().flatten().next(); if let Some(spec) = props.get("exifEX:LensSpecification").filter(|v| v.len() == 4) { let v: Vec = spec.iter().filter_map(|s| parse_number(s)).collect(); if v.len() == 4 { @@ -668,11 +1102,7 @@ pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result Result String { @@ -766,13 +1196,13 @@ pub fn write_xmp_with(meta: &Metadata, with: &XmpWrite) -> String { } x.push_str(">\n"); for (k, v) in &simple { - x.push_str(&format!(" <{k}>{}\n", escape(xml_safe(v)))); + x.push_str(&format!(" <{k}>{}\n", keep_cr(escape(xml_safe(v))))); } for (k, v) in with.properties.iter().filter(|_| own.is_some()) { let p = own.map_or("", |n| n.0); if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { // element content: quotes needn't be escaped, which keeps JSON readable - x.push_str(&format!(" <{p}:{k}>{}\n", partial_escape(xml_safe(v)))); + x.push_str(&format!(" <{p}:{k}>{}\n", keep_cr(partial_escape(xml_safe(v))))); } } let array = |x: &mut String, k: &str, kind: &str, items: &[String], lang: bool| { @@ -782,9 +1212,9 @@ pub fn write_xmp_with(meta: &Metadata, with: &XmpWrite) -> String { x.push_str(&format!(" <{k}>\n \n")); for it in items { if lang { - x.push_str(&format!(" {}\n", escape(xml_safe(it)))); + x.push_str(&format!(" {}\n", keep_cr(escape(xml_safe(it))))); } else { - x.push_str(&format!(" {}\n", escape(xml_safe(it)))); + x.push_str(&format!(" {}\n", keep_cr(escape(xml_safe(it))))); } } x.push_str(&format!(" \n \n")); @@ -799,7 +1229,7 @@ pub fn write_xmp_with(meta: &Metadata, with: &XmpWrite) -> String { array(&mut x, "dc:creator", "Seq", &one(&meta.artist), false); array(&mut x, "dc:subject", "Bag", &meta.keywords, false); array(&mut x, "lr:hierarchicalSubject", "Bag", &meta.hierarchical_keywords, false); - array(&mut x, "exif:ISOSpeedRatings", "Seq", &meta.iso.map(|v| vec![v.to_string()]).unwrap_or_default(), false); + array(&mut x, "exif:ISOSpeedRatings", "Seq", &meta.iso.filter(|value| *value > 0).map(|v| vec![v.to_string()]).unwrap_or_default(), false); if let Some(s) = meta.lens_spec { array(&mut x, "exifEX:LensSpecification", "Seq", &s.iter().map(|&v| frac(v)).collect::>(), false); } diff --git a/crates/metadata/src/xmp/tests.rs b/crates/metadata/src/xmp/tests.rs index b8d5807b7..c3c3181c9 100644 --- a/crates/metadata/src/xmp/tests.rs +++ b/crates/metadata/src/xmp/tests.rs @@ -194,6 +194,109 @@ fn non_positive_iso_is_ignored() { assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200)); } +fn iso_packet(attrs: &str, element: &str) -> String { + format!( + r#" + {element} + "# + ) +} + +#[test] +fn xmp_iso_rejects_fractional_values() { + let cases = [ + (iso_packet(r#"exif:ISOSpeedRatings="0.5""#, ""), "exif:ISOSpeedRatings", "0.5"), + (iso_packet(r#"exif:ISOSpeedRatings="1.0000000000000001""#, ""), "exif:ISOSpeedRatings", "1.0000000000000001"), + (iso_packet(r#"exif:ISOSpeedRatings="1.0000000000000001/1""#, ""), "exif:ISOSpeedRatings", "1.0000000000000001/1"), + (iso_packet("", "160.9"), "exif:ISOSpeedRatings", "160.9"), + (iso_packet(r#"exifEX:PhotographicSensitivity="160.9""#, ""), "exifEX:PhotographicSensitivity", "160.9"), + (iso_packet("", "0.5"), "exifEX:PhotographicSensitivity", "0.5"), + ( + iso_packet("", "160.9"), + "exifEX:PhotographicSensitivity", + "160.9", + ), + ]; + for (packet, key, raw) in cases { + let d = parse_xmp(&packet).unwrap(); + assert_eq!(d.metadata.iso, None, "{packet}"); + assert_eq!( + d.properties.get(key).map(|values| values.iter().map(String::as_str).collect::>()), + Some(vec![raw]), + "raw property changed: {packet}" + ); + assert_eq!(d.metadata.make, None); + } +} + +#[test] +fn xmp_iso_rejects_out_of_range_values() { + for value in ["4294967296", "0", "-1", "NaN", "inf", "bad"] { + let packet = iso_packet(&format!(r#"exif:ISOSpeedRatings="{value}""#), ""); + let d = parse_xmp(&packet).unwrap(); + assert_eq!(d.metadata.iso, None, "{value}"); + assert_eq!(d.properties.get("exif:ISOSpeedRatings").map(|v| v.first().map(String::as_str)), Some(Some(value))); + } + let packet = iso_packet("", "4294967296"); + assert_eq!(parse_xmp(&packet).unwrap().metadata.iso, None); +} + +#[test] +fn xmp_iso_invalid_primary_allows_fallback() { + for primary in ["0.5", "1.0000000000000001", "160.9", "4294967296", "0", "-1", "bad", "NaN"] { + let packet = iso_packet(&format!(r#"exif:ISOSpeedRatings="{primary}" exifEX:PhotographicSensitivity="200""#), ""); + assert_eq!(parse_xmp(&packet).unwrap().metadata.iso, Some(200), "{primary}"); + } +} + +#[test] +fn xmp_iso_preserves_valid_values() { + for (value, expected) in [ + ("200", 200), + ("200.0", 200), + ("2e2", 200), + ("400/2", 200), + ("1.5/0.5", 3), + ("0.3/0.1", 3), + ("1.0000000000000001/1.0000000000000001", 1), + ("1e39/1e39", 1), + ("1e39/5e38", 2), + ("1e39/2e38", 5), + ("4294967295", u32::MAX), + ] { + let packet = iso_packet(&format!(r#"exif:ISOSpeedRatings="{value}""#), ""); + assert_eq!(parse_xmp(&packet).unwrap().metadata.iso, Some(expected), "{value}"); + } + assert_eq!(parse_xmp(&iso_packet("", "640")).unwrap().metadata.iso, Some(640)); + let packet = iso_packet(r#"exif:ISOSpeedRatings="100" exifEX:PhotographicSensitivity="200""#, ""); + assert_eq!(parse_xmp(&packet).unwrap().metadata.iso, Some(100)); +} + +#[test] +fn xmp_iso_accepts_equal_coefficients_above_u128() { + let value = "340282366920938463463374607431768211456/340282366920938463463374607431768211456"; + assert_eq!(parse_xmp(&iso_packet(&format!(r#"exif:ISOSpeedRatings="{value}""#), "")).unwrap().metadata.iso, Some(1)); +} + +#[test] +fn xmp_iso_rejected_value_is_not_written() { + let packet = iso_packet(r#"exif:ISOSpeedRatings="0.5""#, r#"kept"#); + let parsed = parse_xmp(&packet).unwrap(); + assert_eq!(parsed.metadata.iso, None); + assert_eq!(parsed.metadata.title.as_deref(), Some("kept")); + let written = write_xmp(&parsed.metadata); + assert!(!written.contains("ISOSpeedRatings"), "{written}"); +} + +#[test] +fn xmp_iso_writer_omits_zero() { + let written = write_xmp(&Metadata { iso: Some(0), ..Default::default() }); + assert!(!written.contains("ISOSpeedRatings"), "{written}"); + + let written = write_xmp(&Metadata { iso: Some(200), ..Default::default() }); + assert!(written.contains("200"), "{written}"); +} + #[test] fn negative_xmp_pixel_width_is_ignored() { let read = |attrs: &str| { @@ -210,6 +313,119 @@ fn negative_xmp_pixel_width_is_ignored() { assert_eq!(read(r#"exif:PixelXDimension="-1""#), None); } +fn xmp_dimensions(body: &str) -> XmpData { + let (attributes, elements) = body.split_once('|').unwrap_or((body, "")); + let packet = format!( + r#" + {elements} + "# + ); + parse_xmp(&packet).expect("procedural XMP dimension fixture parses") +} + +#[test] +fn xmp_dimensions_reject_fractional_values() { + for (field, value) in [ + ("tiff:ImageWidth", "640.9"), + ("exif:PixelXDimension", "640.9"), + ("tiff:ImageLength", "480.9"), + ("exif:PixelYDimension", "480.9"), + ("tiff:ImageWidth", "1.0000000000000001"), + ] { + let data = xmp_dimensions(&format!(r#"{field}="{value}""#)); + assert_eq!(data.metadata.width, None, "{field}={value}"); + assert_eq!(data.metadata.height, None, "{field}={value}"); + assert_eq!(data.properties.get(field).map(Vec::as_slice), Some([value.to_owned()].as_slice()), "raw {field}={value}"); + } +} + +#[test] +fn xmp_dimensions_reject_out_of_range_values() { + for (field, value) in [ + ("tiff:ImageWidth", "4294967296"), + ("exif:PixelXDimension", "4294967296"), + ("tiff:ImageLength", "4294967296"), + ("exif:PixelYDimension", "4294967296"), + ("tiff:ImageWidth", "-1"), + ("tiff:ImageWidth", "-0"), + ("tiff:ImageWidth", "0/0"), + ("exif:PixelXDimension", "-1"), + ("tiff:ImageLength", "-1"), + ("exif:PixelYDimension", "-1"), + ("tiff:ImageWidth", "1e100000"), + ("tiff:ImageWidth", "NaN"), + ("tiff:ImageWidth", "Infinity"), + ("tiff:ImageWidth", "not-a-number"), + ] { + let data = xmp_dimensions(&format!(r#"{field}="{value}""#)); + assert_eq!(data.metadata.width, None, "{field}={value}"); + assert_eq!(data.metadata.height, None, "{field}={value}"); + assert_eq!(data.properties.get(field).map(Vec::as_slice), Some([value.to_owned()].as_slice()), "raw {field}={value}"); + } +} + +#[test] +fn xmp_dimensions_invalid_primary_allows_fallback() { + let width = xmp_dimensions(r#"tiff:ImageWidth="0.5" exif:PixelXDimension="640""#); + assert_eq!(width.metadata.width, Some(640)); + let height = xmp_dimensions(r#"tiff:ImageLength="480.9" exif:PixelYDimension="480""#); + assert_eq!(height.metadata.height, Some(480)); +} + +#[test] +fn xmp_dimensions_preserve_valid_values() { + for (field, value, expected) in [ + ("tiff:ImageWidth", "0", 0), + ("tiff:ImageWidth", "0001", 1), + ("tiff:ImageWidth", "0/1e-129", 0), + ("tiff:ImageWidth", "1e-400/1e-400", 1), + ("tiff:ImageWidth", "1e400/1e400", 1), + ("tiff:ImageWidth", "4294967295", u32::MAX), + ("exif:PixelXDimension", "640.0", 640), + ("tiff:ImageLength", "960/2", 480), + ("exif:PixelYDimension", "48e1", 480), + ("tiff:ImageWidth", "1e39/1e39", 1), + ] { + let data = xmp_dimensions(&format!(r#"{field}="{value}""#)); + let actual = if matches!(field, "tiff:ImageWidth" | "exif:PixelXDimension") { data.metadata.width } else { data.metadata.height }; + assert_eq!(actual, Some(expected), "{field}={value}"); + } + assert_eq!(xmp_dimensions(r#"tiff:ImageWidth="640" exif:PixelXDimension="800""#).metadata.width, Some(640)); + assert_eq!(xmp_dimensions(r#"tiff:ImageLength="480" exif:PixelYDimension="800""#).metadata.height, Some(480)); + let element = xmp_dimensions(r#"|640480"#); + assert_eq!(element.metadata.width, Some(640)); + assert_eq!(element.metadata.height, Some(480)); + let sequence = xmp_dimensions(r#"|640"#); + assert_eq!(sequence.metadata.width, Some(640)); +} + +#[test] +fn xmp_dimensions_preserve_large_exact_ratio() { + for coefficient in ["999999999999999999999999999999999999999".to_owned(), "9".repeat(1024)] { + let data = xmp_dimensions(&format!(r#"tiff:ImageWidth="{coefficient}/{coefficient}""#)); + assert_eq!(data.metadata.width, Some(1), "coefficient length {}", coefficient.len()); + } + let denominator = "9".repeat(1024); + let numerator = format!("1{}8", "9".repeat(1023)); + let data = xmp_dimensions(&format!(r#"tiff:ImageWidth="{numerator}/{denominator}""#)); + assert_eq!(data.metadata.width, Some(2)); + + let numerator = format!("{}8", "9".repeat(1023)); + let data = xmp_dimensions(&format!(r#"tiff:ImageWidth="{numerator}/{denominator}""#)); + assert_eq!(data.metadata.width, None); +} + +#[test] +fn xmp_dimensions_rejected_values_are_not_written() { + let data = xmp_dimensions(r#"tiff:ImageWidth="640.9" dc:title="Keep this""#); + assert_eq!(data.metadata.width, None); + assert_eq!(data.metadata.title.as_deref(), Some("Keep this")); + assert_eq!(data.properties.get("tiff:ImageWidth").map(Vec::as_slice), Some(["640.9".to_owned()].as_slice())); + let written = write_xmp(&data.metadata); + assert!(!written.contains("tiff:ImageWidth")); + assert_eq!(parse_xmp(&written).unwrap().metadata.width, None); +} + #[test] fn malformed_inputs() { assert!(parse_xmp("").is_ok() || parse_xmp("").is_err()); @@ -240,11 +456,20 @@ fn lc_extras_and_crs_prefix() { #[test] fn gps_coords() { - assert_eq!(parse_gps_coord("45,30N"), Some(45.5)); - assert_eq!(parse_gps_coord("45,30,36S"), Some(-45.51)); - assert_eq!(parse_gps_coord("45X"), None); - assert_eq!(parse_gps_coord(""), None); - assert_eq!(parse_gps_coord("é"), None); + let lat = ['N', 'S']; + assert_eq!(parse_gps_coord("45,30N", lat), Some(45.5)); + assert_eq!(parse_gps_coord("45,30,36S", lat), Some(-45.51)); + assert_eq!(parse_gps_coord("45X", lat), None); + assert_eq!(parse_gps_coord("", lat), None); + assert_eq!(parse_gps_coord("é", lat), None); + assert_eq!(parse_gps_coord("-10,30N", lat), None, "the sign is the letter's"); + assert_eq!(parse_gps_coord("10,-30N", lat), None); + assert_eq!(parse_gps_coord("10,30E", lat), None, "E is a longitude's"); + assert_eq!(parse_gps_position("10,30E", "20,15N"), None); + assert_eq!(parse_gps_position("10,30N", "20,15W"), Some((10.5, -20.25))); + assert_eq!(parse_gps_position("95,0N", "20,0E"), None, "off the globe"); + assert_eq!(parse_gps_position("10,0N", "180,0.1E"), None); + assert_eq!(parse_gps_position("90,0S", "180,0W"), Some((-90.0, -180.0))); assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N"); assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W"); @@ -268,6 +493,49 @@ fn xmp_gps_out_of_range_rejected() { assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); } +#[test] +fn xmp_gps_component_bounds() { + let parse = |lat: &str, lon: &str| { + let x = format!( + r#""# + ); + parse_xmp(&x).unwrap() + }; + + for (lat, lon) in [ + ("45,60N", "7,15E"), + ("45,99N", "7,15E"), + ("45,0,60N", "7,15E"), + ("45,0,99N", "7,15E"), + ("45,15N", "7,60E"), + ("45,15N", "7,99E"), + ("45,15N", "7,0,60E"), + ("45,15N", "7,0,99E"), + ] { + let xmp = parse(lat, lon); + assert_eq!(xmp.metadata.gps, None, "{lat}, {lon}"); + assert_eq!(xmp.properties.get("exif:GPSLatitude").and_then(|v| v.first()).map(String::as_str), Some(lat)); + assert_eq!(xmp.properties.get("exif:GPSLongitude").and_then(|v| v.first()).map(String::as_str), Some(lon)); + } + + for (lat, lon, expected) in [ + ("45,59.5N", "7,59.5E", (45.0 + 59.5 / 60.0, 7.0 + 59.5 / 60.0)), + ("45,59,59S", "7,59,59W", (-(45.0 + 59.0 / 60.0 + 59.0 / 3600.0), -(7.0 + 59.0 / 60.0 + 59.0 / 3600.0))), + ("0,0N", "0,0E", (0.0, 0.0)), + ("90,0N", "180,0W", (90.0, -180.0)), + (" 45,30n ", " 7,15e ", (45.5, 7.25)), + ] { + let xmp = parse(lat, lon); + let gps = xmp.metadata.gps.expect("valid XMP GPS components"); + assert!((gps.latitude - expected.0).abs() < 1e-9, "{lat}: {}", gps.latitude); + assert!((gps.longitude - expected.1).abs() < 1e-9, "{lon}: {}", gps.longitude); + } + + assert_eq!(parse("95,0N", "7,0E").metadata.gps, None, "combined latitude range remains checked"); + assert_eq!(parse("45,0N", "200,0E").metadata.gps, None, "combined longitude range remains checked"); +} + /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how /// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one /// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas @@ -506,7 +774,10 @@ fn writer_drops_characters_xml_1_0_forbids() { let illegal: Vec = x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect(); assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}"); - assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}"); + // a carriage return is written as a reference: a literal one would read back as a line feed (XML end-of-line handling) + assert!(x.contains(">abcde\tf\ng h<"), "allowed characters must survive: {x}"); + let back = parse_xmp(&x).unwrap(); + assert_eq!(back.metadata.caption.as_deref(), Some("abcde\tf\ng\rh"), "and read back unchanged"); } #[test] @@ -544,6 +815,7 @@ fn flattened_keys_stay_within_the_cumulative_budget() { assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget"); assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent"); assert!(!d.properties.is_empty(), "properties parsed before the budget are kept"); + assert!(d.properties_truncated, "omitted flattened keys are reported to callers"); } #[test] @@ -579,6 +851,15 @@ fn malformed_capture_time_without_fallback_is_not_projected() { assert_eq!(parsed.properties["exif:DateTimeOriginal"], vec!["2026-01-01T".to_string()]); } +#[test] +fn a_start_tag_with_thousands_of_namespace_declarations_is_refused() { + let decls: String = (0..2000).map(|i| format!(" xmlns:p{i}=\"urn:x{i}\"")).collect(); + let xmp = format!( + "" + ); + assert!(matches!(parse_xmp(&xmp), Err(XmpError::TooComplex))); +} + #[test] fn many_ampersands_before_one_semicolon_are_read_once() { // 200 000 `&` then a `;`: each `&` once searched the whole rest of the attribute for the `;` @@ -617,7 +898,7 @@ fn the_callers_namespace_is_written_and_read_only_when_passed() { let x = write_lc(&Metadata::default(), &[("flag", "pick")]); assert!(x.contains("x:xmptk=\"Editor\"")); assert!(x.contains( - "xmlns:Iptc4xmpCore=\"http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/\"\n xmlns:lc=\"http://ns.example.com/lc/1.0/\"\n xmlns:mwg-rs=" + "xmlns:Iptc4xmpCore=\"http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/\"\n xmlns:Iptc4xmpExt=\"http://iptc.org/std/Iptc4xmpExt/2008-02-29/\"\n xmlns:lc=\"http://ns.example.com/lc/1.0/\"\n xmlns:mwg-rs=" )); assert!(!x.contains("xmlns:crs")); let renamed = x.replace("xmlns:lc=", "xmlns:zz=").replace("lc:flag", "zz:flag"); @@ -671,3 +952,181 @@ fn the_toolkit_is_read_and_written_back() { let bare = r#""#; assert_eq!(crate::parse_xmp(bare).unwrap().toolkit, None); } + +fn orientation_packet(value: &str) -> String { + format!( + r#""# + ) +} + +#[test] +fn xmp_orientation_rejects_fractional_values_and_keeps_raw_properties() { + for value in ["6.5", "6.9", "1.5", "8.9", "13/2"] { + let parsed = parse_xmp(&orientation_packet(value)).unwrap(); + assert_eq!(parsed.metadata.orientation, None, "{value}"); + assert_eq!(parsed.properties["tiff:Orientation"], vec![value.to_string()], "{value}"); + assert!(!write_xmp(&parsed.metadata).contains("tiff:Orientation"), "rejected {value} must not become a written orientation"); + } +} + +#[test] +fn xmp_orientation_integral_forms_keep_their_meaning() { + for code in 1..=8 { + let parsed = parse_xmp(&orientation_packet(&code.to_string())).unwrap(); + assert_eq!(parsed.metadata.orientation.map(|orientation| orientation.to_exif()), Some(code)); + } + for value in ["6.0", "6/1", " 6 ", " 6.0 "] { + let parsed = parse_xmp(&orientation_packet(value)).unwrap(); + assert_eq!(parsed.metadata.orientation.map(|orientation| orientation.to_exif()), Some(6), "{value}"); + } +} + +#[test] +fn xmp_orientation_rejects_existing_invalid_values() { + for value in ["0", "9", "-1", "nan", "inf"] { + let parsed = parse_xmp(&orientation_packet(value)).unwrap(); + assert_eq!(parsed.metadata.orientation, None, "{value}"); + assert_eq!(parsed.properties["tiff:Orientation"], vec![value.to_string()], "{value}"); + } +} + +/// 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&bAB<>"'"), "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("é&日&é;"), "é&日&é;"); + // leading zeros are valid in numeric references, however many there are + assert_eq!(unescape_attr("AB"), "AB"); + let zeros = format!("&#{};", "0".repeat(1 << 20)); + assert_eq!(unescape_attr(&zeros), zeros); +} + +/// A small packet keeps every property and is not marked truncated. +#[test] +fn a_packet_within_the_budget_keeps_a_complete_inventory() { + let d = parse_xmp(r#""#).unwrap(); + assert!(!d.properties_truncated); + assert_eq!(d.properties.len(), 1); +} + +/// A later description that repeats `LocationShown` with an empty item must not erase the city +/// an earlier description named: the packet still holds a place the export has to refuse. +#[test] +fn a_later_empty_location_keeps_an_earlier_city() { + let packet = concat!( + r#""#, + r#""#, + r#"Rome"#, + r#""#, + r#""# + ); + let parsed = parse_xmp(packet).expect("valid XMP"); + assert!(parsed.has_location_metadata(), "the earlier city is still in the packet"); + let city = parsed + .values + .get("Iptc4xmpExt:LocationShown") + .and_then(|value| value.items().first()) + .and_then(|item| item.field("Iptc4xmpExt:City")) + .and_then(XmpValue::text); + assert_eq!(city, Some("Rome")); +} + +/// GPS nested in a struct array item flattens to the item's (empty) text; the tree inventory still +/// names it. +#[test] +fn gps_inside_a_bag_item_counts_as_location() { + let packet = concat!( + r#""#, + r#""#, + r#"37,46.494N"# + ); + let parsed = parse_xmp(packet).expect("valid XMP"); + assert!(parsed.properties.keys().all(|key| !key.contains("GPS")), "flattening drops the item's fields"); + assert!(parsed.names_location); + assert!(parsed.has_location_metadata()); +} + +/// A location struct nested in another struct is judged by its own fields, not by unrelated text +/// next to it. +#[test] +fn a_nested_location_is_judged_by_its_own_fields() { + let packet = |city: &str| { + format!( + r#"Bridge{city}"# + ) + }; + assert!(!parse_xmp(&packet("")).expect("valid XMP").has_location_metadata(), "a blank place beside a title"); + assert!(parse_xmp(&packet("Rome")).expect("valid XMP").has_location_metadata()); +} + +/// A place inside a struct array item has no flattened key of its own; the tree inventory finds +/// it, and a blank one there still doesn't count. +#[test] +fn a_location_inside_an_array_item_counts_unless_blank() { + let packet = |city: &str| { + format!( + r#"{city}"# + ) + }; + let with_city = parse_xmp(&packet("Rome")).expect("valid XMP"); + assert!(with_city.properties.keys().all(|key| !key.contains("Location")), "flattening drops the item's fields"); + assert!(with_city.has_location_metadata()); + assert!(!parse_xmp(&packet(" ")).expect("valid XMP").has_location_metadata(), "a blank place is no location"); +} + +/// A struct repeated in one description keeps its first copy in `values` when that copy has text +/// elsewhere; a place only the later copy names must still be found. +#[test] +fn a_place_in_a_later_repeated_struct_counts() { + let packet = concat!( + r#""#, + r#"Bridge"#, + r#"Rome"#, + r#""# + ); + assert!(parse_xmp(packet).expect("valid XMP").has_location_metadata()); +} + +/// The same `rdf:Description` can repeat `LocationShown`; a later blank item must not replace the +/// city of the earlier one (its `rdf:li` flattens to empty text, so only the structured value has it). +#[test] +fn a_repeated_empty_location_in_one_description_keeps_the_city() { + let packet = concat!( + r#""#, + r#""#, + r#"Rome"#, + r#""#, + r#""# + ); + let parsed = parse_xmp(packet).expect("valid XMP"); + assert!(parsed.has_location_metadata(), "the earlier city is still in the packet"); + let city = parsed + .values + .get("Iptc4xmpExt:LocationShown") + .and_then(|value| value.items().first()) + .and_then(|item| item.field("Iptc4xmpExt:City")) + .and_then(XmpValue::text); + assert_eq!(city, Some("Rome")); +} + +/// One walker answers both the parser and the Remove Location check: nested text counts, blanks +/// don't, and a tree past the node budget counts as text-bearing instead of being walked in full. +#[test] +fn value_has_text_finds_nested_text_and_stops_at_the_node_budget() { + let blank = XmpValue::Array(vec![XmpValue::Text(" ".into()), XmpValue::Struct(BTreeMap::new())]); + assert!(!value_has_text(&blank)); + let nested = XmpValue::Array(vec![XmpValue::Struct(BTreeMap::from([("Iptc4xmpExt:City".to_string(), XmpValue::Text("Rome".into()))]))]); + assert!(value_has_text(&nested)); + let oversized = XmpValue::Array(vec![XmpValue::Text(String::new()); MAX_NODES + 1]); + assert!(value_has_text(&oversized), "a tree past the node budget is treated as text-bearing"); + let data = XmpData { values: BTreeMap::from([("Iptc4xmpExt:LocationShown".to_string(), nested)]), ..XmpData::default() }; + let data = XmpData { properties: BTreeMap::from([("Iptc4xmpExt:LocationShown".to_string(), vec![String::new()])]), ..data }; + assert!(data.has_location_metadata(), "the structured value's city is found"); +} diff --git a/crates/metadata/src/xmp_merge.rs b/crates/metadata/src/xmp_merge.rs index 3056225b0..33714269a 100644 --- a/crates/metadata/src/xmp_merge.rs +++ b/crates/metadata/src/xmp_merge.rs @@ -23,7 +23,7 @@ use std::collections::{BTreeSet, HashSet}; use quick_xml::events::Event; -use crate::xmp::{MAX_OPAQUE_NAME_BYTES, XmpError, XmpNamespace, canonical_name, parse_xmp_with, unescape_attr}; +use crate::xmp::{MAX_OPAQUE_NAME_BYTES, XmpError, XmpNamespace, canonical_name, check_scopes, parse_xmp_with, unescape_attr}; /// Which properties [`merge_xmp`] takes from the fresh packet. Names are canonical /// `prefix:name` (prefixes as [`crate::parse_xmp`] reports them); `prefix:*` is a whole namespace. @@ -77,6 +77,8 @@ struct Scan { descs: Vec, /// Where the first `` starts. rdf_close: Option, + /// The span of a self-closing `` (used only when there is no ``). + rdf_empty: Option<(usize, usize)>, } enum Kind { @@ -134,6 +136,7 @@ fn scan(s: &str, extra: &[XmpNamespace]) -> Result { raw_attrs.push((k, v)); } scopes.push(scope); + check_scopes(&scopes)?; let raw_name = String::from_utf8_lossy(e.name().as_ref()).into_owned(); let name = canonical_name(&raw_name, &scopes, extra, &mut opaque_budget)?; let kind = match stack.last() { @@ -150,6 +153,9 @@ fn scan(s: &str, extra: &[XmpNamespace]) -> Result { _ => Kind::Other, }; if empty { + if matches!(kind, Kind::Rdf) { + out.rdf_empty = out.rdf_empty.or(Some((before, after))); + } scopes.pop(); if let Kind::Prop { desc, start, name } = kind && let Some(d) = out.descs.get_mut(desc) @@ -215,7 +221,9 @@ pub fn merge_xmp(existing: &str, fresh: &str, rules: MergeRules) -> Result = fresh_desc.props.iter().map(|p| p.name.as_str()).collect(); @@ -263,8 +271,16 @@ pub fn merge_xmp(existing: &str, fresh: &str, rules: MergeRules) -> Result"); if written > 0 { - let at = old.get(..rdf_close).map_or(rdf_close, |h| h.trim_end().len()); - edits.push((at, at, block)); + if let Some(rdf_close) = ex.rdf_close { + let at = old.get(..rdf_close).map_or(rdf_close, |h| h.trim_end().len()); + edits.push((at, at, block)); + } else if let Some((s, e)) = ex.rdf_empty { + // a self-closing `` is opened up around the new description + let tag = old.get(s..e).ok_or_else(|| XmpError::Xml("bad span".into()))?; + let open = tag.trim_end().strip_suffix("/>").ok_or_else(|| XmpError::Xml("bad rdf:RDF tag".into()))?; + let name = tag.get(1..).and_then(|t| t.split(|c: char| c.is_whitespace() || c == '/' || c == '>').next()).unwrap_or("rdf:RDF"); + edits.push((s, e, format!("{open}>{block}\n"))); + } } edits.sort_by_key(|e| (e.0, e.1)); // overlapping edits can't happen (properties sit inside their description, which is either @@ -404,6 +420,16 @@ mod tests { assert_eq!(merge_xmp(&again, &fresh(1, &[]), RULES).unwrap(), again, "idempotent"); } + #[test] + fn a_self_closing_rdf_element_is_opened_not_replaced() { + let ex = ""; + let out = merge_xmp(ex, &fresh(3, &[]), RULES).unwrap(); + assert!(out.starts_with(""), "{out}"); + assert_eq!(parse_xmp(&out).unwrap().properties.get("xmp:Rating"), Some(&vec!["3".to_string()])); + assert!(merge_xmp("", &fresh(3, &[]), RULES).is_err(), "no RDF element at all"); + } + #[test] fn owned_if_present_and_prefix_independence() { // the existing packet binds the namespaces to other prefixes, and has a capture time