From 8d3f245d2a5cf6431642ed8302244b815d514849 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 21:03:44 -0500 Subject: [PATCH] craft-metadata: validation and location fixes ported from open LightCraft pull requests (#1042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * craft-metadata: EXIF, IPTC-IIM, XMP and GPX from LightCraft lightcraft-meta at 5b61ce1e, as one crate with a module per format: exif (read, write, maker-note lens, focus point, tag listing), iptc, xmp (parse, write, merge), gpx, and the JPEG, PNG, WebP, TIFF, RAF and CR3 containers. They share Metadata, DateTime, Gps and Orientation. Changed in the move: - TIFF comes from craft-tiff; Point, Rect and Orientation are the crate's own. - The app namespace is a parameter: parse_xmp_with, write_xmp_with and MergeRules::namespaces take it, and XmpData no longer has lc_settings. - The HEIF, PSD and MRW readers stay out, since craft-heif, craft-psd and craft-raw read those containers; from_embedded takes the blocks they find. * craft-metadata: a RW2 reads ISO and the rest of its Exif from the JpgFromRaw A RW2's own Exif IFD is a short subset. What it lacks is filled from the Exif of the embedded JpgFromRaw, then from the ISO tags of the Panasonic IFD0 (0x0017, 0x0037), each only in its recorded field type. 65535 in a SHORT ISOSpeedRatings is the overflow marker and states no ISO. The private tags are read only in a RW2 stream, under the caller's parse options. Ported from storytold/lightcraft#3518. * craft-metadata: the Exif rating is written, and 65535 reads as rejected write_exif writes Rating (0x4746), with 0xFFFF for a rejected photo, and from_tiff reads 65535 as -1 instead of clamping it to 5. Ported from storytold/lightcraft#1995. * craft-metadata: one shutter-speed formatter, exact to four digits format_exposure_time shows a duration within 0.1% of 1/n second as 1/n and anything else to four significant digits, so 0.3 s, 1/2.8 s and 0.625 s are no longer rounded to a reciprocal or one decimal. Metadata::exposure_display and the ExposureTime tag row both use it. Ported from storytold/lightcraft#1692. * craft-metadata: JPEG XL containers give their Exif and xml boxes The first Exif and xml box of a JPEG XL container, plain or Brotli-wrapped in brob, honouring the Exif box's TIFF header offset. A brob payload is capped at 32 MiB. A bare codestream has no metadata. Ported from storytold/lightcraft#3756. * craft-metadata: XMP Resource structs flatten their attribute fields A property with rdf:parseType="Resource" may give its fields as attributes as well as child elements. Only the children were flattened into XmpData::properties; the attributes now are too, as for a nested rdf:Description. Ported from storytold/lightcraft#1226 (the XMP part). * craft-metadata: a capture date of just a year or a month stays as stated XMP dates may stop at the year (1983) or the month (1983-04). DateTime now has a DatePrecision; such a date writes back to XMP as stated and only in photoshop:DateCreated, and write_exif leaves it out, since an Exif date cannot say it. Ported from storytold/lightcraft#3502. * craft-metadata: Fujifilm in-camera recipes from the maker note fuji module: the film simulation and the rest of the in-camera recipe (grain, colour chrome, tone, dynamic range, white balance and its shift, sharpness, noise reduction, clarity) from a Fujifilm maker note. recipe_from_exif, recipe_from_bytes (TIFF, JPEG, RAF) and read_recipe_file, which reads only as far as the metadata. from_embedded fills Metadata::fuji_recipe and file_tag_rows lists the recipe under Fujifilm. Ported from storytold/lightcraft#1209. * craft-metadata: the linear-time scan test compares times, not a fixed bound The test asserted that 200 000 attributes scan in under 5 s, which failed on a loaded machine. It now scans 25 000 and 100 000 attributes, takes the best of three runs of each, and asserts that four times the attributes take less than ten times as long. Linear scans give about 4; a quadratic duplicate check gave 18.6 and fails. * craft-metadata: interleave the linear-time scan test's runs The best-of-three ratio still failed once under load. The test now alternates five runs of 50 000 and 200 000 attributes and compares the fastest of each, so a burst of load slows both sizes. Six runs at load average 55 gave ratios of 3.9 to 5.4 against the bound of 10. * craft-metadata: XmpData::toolkit, the x:xmptk of the packet The name of the program that wrote a packet, from x:xmptk on x:xmpmeta (or x:xaptk on the older x:xapmeta), whatever prefix the packet binds to adobe:ns:meta/. write_xmp_with writes XmpWrite::toolkit there, and the test reads it back, escaped characters included. * craft-metadata: mpf, the Multi-Picture index of a JPEG mpf::images lists the images an APP2 MPF index (CIPA DC-007) puts after the primary image, with their file offsets; the index is read with craft-tiff, in either byte order, up to 16 entries. mpf::write inserts an index after the primary's leading APPn segments and appends the other images, with the primary listed as the representative baseline image: the layout LightCraft's gain map JPEG writer uses, for any number of images. mpf::markers lists the marker segments before the first scan, for APPn payloads such as ISO 21496-1 gain map metadata. * craft-metadata: edit, byte-preserving Exif and XMP edits from PhotoCraft PhotoCraft's orientation, resolution and Exif colour-space code (codecs orientation.rs without the pixel transforms, resolution.rs, exif_color.rs), at photocraft f1b5e7d72, as the edit module: - upright_exif and upright_xmp set Orientation to 1 in place, in the entry's own type and width, every well-formed duplicate included. - exif_with_resolution and xmp_with_resolution set the resolution in place and remove entries that cannot be rewritten; the _adding_missing variant appends a new IFD0 when there are none. - export_exif and export_xmp do both and drop the source Exif thumbnail, with the overlap scan bounded. - exif_orientation, exif_resolution, xmp_resolution, photoshop_resolution, exif_color_space and resolution read the same values with the editors' rules (first entry wins, SHORT or LONG only, BigTIFF accepted). The tests that call these functions directly move with them (the codec round trips stay in PhotoCraft): the orientation, resolution, thumbnail and compaction tests, the comparison-counting complexity test and the colour space tests. * craft-metadata: exif_with_pixel_size, the written size in PixelX/YDimension A camera records its sensor's size in the Exif IFD's PixelXDimension and PixelYDimension, which after a crop, resize or rotation names a size the exported file does not have. exif_with_pixel_size rewrites each one that holds one SHORT or LONG, in place and as a LONG, in classic and BigTIFF blocks, with the JPEG Exif prefix kept. Ported from storytold/photocraft#12407 (the format part). * craft-metadata: an ISO 8601 clock needs two-digit hours and minutes Ported from storytold/lightcraft#3523. * craft-metadata: a UTC offset needs a two-digit minute field Ported from storytold/lightcraft#3467. * craft-metadata: tiff:Orientation is read only as an integral code 1 to 8 A fractional or out-of-range value stays a raw property and does not become the photo's orientation. Ported from storytold/lightcraft#3495. * craft-metadata: an XMP ISO is read only when it is a whole number in range A fractional, non-positive or out-of-range ISO is not truncated or saturated into one; the next candidate property is tried instead. Ported from storytold/lightcraft#3633. * craft-metadata: XMP pixel dimensions are read only as exact whole numbers A malformed, fractional, negative or out-of-range tiff:ImageWidth or ImageLength is no longer truncated or saturated, and lets the exif:Pixel dimension take its place. Numbers are compared as exact decimal digits, so wide rationals stay exact. The ISO reader now uses the same exact reader (both PRs added one). Ported from storytold/lightcraft#3690. * craft-metadata: XMP GPS minutes and seconds must be under 60; tell when a read was cut short A coordinate such as 45,60N is no longer normalised to a plausible point; the raw property stays. XmpData::properties_truncated says the flattened property map ran past its key budget, Embedded::chunk_walk_incomplete says a PNG or WebP chunk walk stopped early, and jpeg_segments_with_all_xmp returns every ordinary XMP packet of a JPEG, so a caller that must prove a file carries no location can refuse what it could not inspect. Ported from storytold/lightcraft#3054. * craft-metadata: IPTC Time Created counts only when it is a whole, valid time A date with a six-digit clock and no zone stays date-only; HHMMSS±HHMM carries an offset; clocks and offsets are range-checked. A partial offset, extra digits, a leap second or trailing text no longer becomes a capture time, so it cannot block valid XMP or Exif. Ported from storytold/lightcraft#3448. * craft-metadata: test hostile XMP attribute references The one-pass reference scan (entity_end) already covers the bug that storytold/lightcraft#3007 fixed another way; its test is added here and passes on this implementation: unknown, unterminated and far-away references stay verbatim, a value of a million ampersands or of a million zeros is read once, and multi-byte text around a reference stays whole. Test from storytold/lightcraft#3007. * craft-metadata: parse_gps_position; an XMP coordinate's sign comes from its letter parse_gps_position reads an exif:GPSLatitude and GPSLongitude pair and checks both axes. A negative degree, minute or second part, or a letter of the other axis (10,30E as a latitude), is malformed; the letter may be either case. Ported from storytold/lightcraft#2088 (the format part). * craft-metadata: bound the XMP namespace declarations in scope; merge into an empty rdf:RDF Prefix lookup scans every declaration in scope, so one start tag with thousands of xmlns declarations made parsing and merging quadratic. More than 512 declarations in scope is now TooComplex, in parse_xmp and in merge_xmp's scan. merge_xmp also opens a self-closing around the new description instead of failing. Ported from storytold/lightcraft#3696. * craft-metadata: XmpData::has_location_metadata, every embedded XMP location has_location_metadata answers whether a packet names any Exif GPS property or a non-blank place (IPTC Core location fields, photoshop City/State/Country, IPTC Extension LocationShown and LocationCreated with a place or coordinates), including inside array items and repeated structs that the flattened map and the structured values keep only once (XmpData::names_location, from the parsed tree). The IPTC Extension namespace is now canonicalised and written. extended_xmp_guid reports the announced Extended XMP GUID. A caller that must prove a file has no location checks properties_truncated first. The source also listed LightCraft's own lc:location; a caller with its own namespace checks that itself. Ported from storytold/lightcraft#4121 (the format part). * craft-metadata: is_lens_spec_name, the inverse check of lens_spec_name Whether a lens name has the ' mm f/' shape lens_spec_name writes, so a caller can tell a made-up name from a camera's. The test is new. Ported from storytold/lightcraft#2644 (the format part). * craft-metadata: Sony focus points for 44 bodies SONY_MODELS grows from the alpha 1 and A7S III to the 44 bodies whose focus tags were checked on the public raw.pixls.us samples against ExifTool's reading; bodies whose 0x201b is not the focus mode stay out. This also covers the ILCE-6400 and ILCE-7M3 that storytold/lightcraft#3109 adds. Ported from storytold/lightcraft#1891 (the focus part). * craft-metadata: GPX points say which track they are on; tracks keep their names TrackPoint::track counts the a point came from, Tracklog::names holds each track's (trimmed, at most 200 characters), and only_track gives one track's points, so a map can draw a file's tracks apart and tag from one. At most 10 000 tracks per file (TooComplex past that); a non-finite elevation is dropped. Ported from storytold/lightcraft#921 (the GPX part; #916 carries the same). * craft-metadata: test that from_embedded keeps the EXIF, IPTC, XMP precedence storytold/lightcraft#2211 adds the same function as extract_embedded; this crate already has it as from_embedded. Its tests are added: EXIF camera fields stay, IPTC fills gaps, XMP overrides the user fields, and damaged blocks read as nothing. Tests from storytold/lightcraft#2211. * craft-metadata: the XMP writer keeps a carriage return as XML end-of-line handling turns a literal CR into a line feed when a packet is read, so text with one did not round-trip through other readers. Element text now writes it as a character reference. Ported from storytold/lightcraft#4119 (the writer fix only; that PR also moves LightCraft to quick-xml 0.42, which this crate does not take here, as craft-xml uses 0.41). * craft-metadata: README rows for the ported validation and location checks --------- Co-authored-by: radialmonster Co-authored-by: Sebastian Berschneider Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Michael Anderson <1427851+endthestart@users.noreply.github.com> Co-authored-by: Ilya Dorman Co-authored-by: Clyde C Brown Co-authored-by: MohammadTeimori Co-authored-by: vangel76 Co-authored-by: Max Mittelstädt <62167861+TheGalitube@users.noreply.github.com> Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Co-authored-by: ronibalthazar <61232335+ronibalthazar@users.noreply.github.com> Co-authored-by: Andrew Frahn Co-authored-by: jkalkstein <76634771+jkalkstein@users.noreply.github.com> Co-authored-by: d-rouge Co-authored-by: espoon Co-authored-by: Yoann Laissus Co-authored-by: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Co-authored-by: ingolfteetz Co-authored-by: smi-leonivkovic --- crates/metadata/README.md | 9 +- crates/metadata/src/container.rs | 352 +++++++++++++++++++- crates/metadata/src/datetime.rs | 27 +- crates/metadata/src/focus.rs | 61 +++- crates/metadata/src/focus/tests.rs | 15 +- crates/metadata/src/gpx.rs | 95 +++++- crates/metadata/src/iptc.rs | 127 +++++++- crates/metadata/src/lib.rs | 76 ++++- crates/metadata/src/xmp.rs | 506 ++++++++++++++++++++++++++--- crates/metadata/src/xmp/tests.rs | 473 ++++++++++++++++++++++++++- crates/metadata/src/xmp_merge.rs | 34 +- 11 files changed, 1683 insertions(+), 92 deletions(-) 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