diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 1a4d86fa3..998bd985a 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -1375,6 +1375,38 @@ const OTHER_APP_SIDECAR: &str = r#" "#; +/// A foreign namespace may use a familiar prefix without becoming LightCraft-owned metadata. +#[test] +fn saving_preserves_foreign_namespace_prefix_collisions() { + let src = temp_dir("namespace-src"); + let lib = temp_dir("namespace-lib"); + write_png(&src.join("shot.png"), 1); + let foreign = r#"also keep"#; + let packet = format!(r#"{foreign}"#); + let path = src.join("shot.xmp"); + std::fs::write(&path, packet).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.execute("library.import", &json!({"paths": [src.join("shot.png")]})).unwrap(); + let id = only(&s); + assert_eq!(s.catalog.photo(id).unwrap().rating, 0, "a foreign rating must not become catalog metadata"); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 5})).unwrap(); + for _ in 0..2 { + let result = s.execute("photo.saveMetadataToFile", &json!({})).unwrap(); + assert_eq!(result["failed"], json!([]), "{result}"); + assert_eq!(result["backups"], json!([]), "a valid packet must merge: {result}"); + assert_eq!(result["merged"], json!([path.to_string_lossy()]), "{result}"); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(saved.contains(foreign), "lost foreign bytes: {saved}"); + assert_eq!(lightcraft_meta::parse_xmp(&saved).unwrap().metadata.rating, Some(5)); + } + drop(s); + std::fs::remove_dir_all(src).unwrap(); + std::fs::remove_dir_all(lib).unwrap(); +} + /// Issue #92: saving merges into another app's sidecar instead of replacing it. #[test] fn saving_merges_into_another_apps_sidecar() { diff --git a/crates/meta/README.md b/crates/meta/README.md index 83ee9e601..5c002ff1b 100644 --- a/crates/meta/README.md +++ b/crates/meta/README.md @@ -27,6 +27,13 @@ prefixes by namespace URI, handles attribute and element forms, `rdf:Seq`/`Bag`/ `rdf:parseType="Resource"` and nested-description structs, predefined and numeric entities. Nesting depth and node count are limited. +An explicitly foreign namespace using a recognized prefix (for example `lc` or `xmp`) cannot supply +recognized properties. Its keys use `Q{hex-encoded namespace URI}local` in `properties` and `values`; +hex encoding keeps `/` reserved for struct paths. Other unknown prefixes keep their existing QName keys. +Merging preserves the original foreign XML rather than writing these opaque keys into the packet. +The parser and merge scanner each cap generated opaque names at 8 MiB per packet, returning `TooComplex` +if repeated use of a long namespace URI would exceed that budget. + ## Robustness No panics on malformed input: proptests feed random bytes to every container walker and random text to the diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 1fb789164..2dbf59482 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -69,7 +69,8 @@ pub struct XmpData { /// The opaque `lc:settings` JSON string, if present. pub lc_settings: Option, /// Every property found, keyed `prefix:name` (struct fields as `prefix:name/prefix:field`); arrays keep - /// item order, language alternatives put `x-default` first. + /// item order, language alternatives put `x-default` first. Foreign namespaces that reuse a recognized + /// prefix use `Q{hex-encoded namespace URI}local` instead, so they cannot impersonate known properties. pub properties: BTreeMap>, /// Every top-level property with its full structure (arrays of structs such as local /// corrections, which [`XmpData::properties`] flattens). @@ -150,26 +151,46 @@ impl Node { const MAX_DEPTH: usize = 64; const MAX_NODES: usize = 200_000; +// Expanding a long namespace URI on many short names must not amplify a small packet without bound. +pub(crate) const MAX_OPAQUE_NAME_BYTES: usize = 8 * 1024 * 1024; -/// `prefix:local` with the prefix canonicalised by namespace URI (see [`NAMESPACES`]). -pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>]) -> String { - canonical(qname, scopes) +/// Names canonicalised by namespace URI (see [`NAMESPACES`]); conflicting foreign prefixes are opaque. +pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>], opaque_budget: &mut usize) -> Result { + canonical(qname, scopes, opaque_budget) } -fn canonical(qname: &str, scopes: &[Vec<(String, String)>]) -> String { +fn canonical(qname: &str, scopes: &[Vec<(String, String)>], opaque_budget: &mut usize) -> Result { let (prefix, local) = qname.split_once(':').unwrap_or(("", qname)); if prefix == "xml" || prefix == "xmlns" { - return qname.to_string(); + return Ok(qname.to_string()); } let uri = scopes.iter().rev().flat_map(|s| s.iter().rev()).find(|(p, _)| p == prefix).map(|(_, u)| u.as_str()); - match uri { + Ok(match uri { Some(u) => match NAMESPACES.iter().find(|(_, nu)| *nu == u) { Some((p, _)) => format!("{p}:{local}"), + None if NAMESPACES.iter().any(|(p, _)| *p == prefix) => { + // A foreign `lc:settings` must never match our `lc:*` ownership rule (nor may a + // foreign `xmp:Rating` become a rating). Braces cannot occur in an XML QName; + // hex keeps URI slashes out of the flattened property's `/` path separators. + let size = + u.len().checked_mul(2).and_then(|n| n.checked_add(3)).and_then(|n| n.checked_add(local.len())).ok_or(XmpError::TooComplex)?; + *opaque_budget = opaque_budget.checked_sub(size).ok_or(XmpError::TooComplex)?; + let mut key = String::with_capacity(size); + key.push_str("Q{"); + for byte in u.bytes() { + for nibble in [byte >> 4, byte & 15] { + key.push(char::from(if nibble < 10 { b'0' + nibble } else { b'a' + (nibble - 10) })); + } + } + key.push('}'); + key.push_str(local); + key + } None if prefix.is_empty() => local.to_string(), None => qname.to_string(), }, None => qname.to_string(), - } + }) } fn entity(name: &str) -> Option { @@ -202,7 +223,7 @@ fn xml_safe(s: &str) -> String { } /// Unescape attribute values (predefined + numeric references; unknown references kept verbatim). -fn unescape_attr(s: &str) -> String { +pub(crate) fn unescape_attr(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut rest = s; while let Some(i) = rest.find('&') { @@ -229,6 +250,7 @@ fn parse_tree(s: &str) -> Result { let mut stack: Vec = vec![Node { name: "#root".into(), ..Default::default() }]; let mut scopes: Vec> = vec![vec![("xml".into(), "http://www.w3.org/XML/1998/namespace".into())]]; let mut count = 0usize; + let mut opaque_budget = MAX_OPAQUE_NAME_BYTES; loop { let ev = reader.read_event().map_err(|e| XmpError::Xml(e.to_string()))?; match ev { @@ -251,8 +273,9 @@ fn parse_tree(s: &str) -> Result { } } scopes.push(scope); - let name = canonical(&String::from_utf8_lossy(e.name().as_ref()), &scopes); - let attrs = raw_attrs.into_iter().map(|(k, v)| (canonical(&k, &scopes), v)).collect(); + let name = canonical(&String::from_utf8_lossy(e.name().as_ref()), &scopes, &mut opaque_budget)?; + let attrs = + raw_attrs.into_iter().map(|(k, v)| Ok((canonical(&k, &scopes, &mut opaque_budget)?, v))).collect::>()?; let node = Node { name, attrs, ..Default::default() }; if matches!(ev, Event::Empty(_)) { scopes.pop(); @@ -767,6 +790,43 @@ mod tests { assert_eq!(d.lc_settings, None); } + #[test] + fn foreign_namespace_cannot_impersonate_known_metadata() { + for body in [ + r#"xmp:Rating="2" lc:settings="foreign settings"/>"#, + r#">2foreign settings"#, + ] { + let packet = format!( + r#""# + ); + let d = parse_xmp(&packet).unwrap(); + assert_eq!(d.metadata.rating, None, "foreign rating: {packet}"); + assert_eq!(d.lc_settings, None, "foreign settings: {packet}"); + assert!(!d.properties.contains_key("xmp:Rating")); + assert!(!d.values.contains_key("lc:settings")); + assert!(d.properties.values().any(|v| v == &["foreign settings"])); + assert!(d.properties.keys().all(|key| !key.contains('/')), "opaque names must not create struct paths"); + } + } + + #[test] + fn foreign_namespace_names_are_distinct_and_scoped() { + let packet = r#" + + 1 + 2 + 4 + "#; + let d = parse_xmp(packet).unwrap(); + assert_eq!(d.metadata.rating, Some(4), "a local rebinding must not escape its scope"); + assert_eq!(d.properties.len(), 3, "different foreign URIs must not collide"); + assert_eq!(d.values.len(), 3); + assert_eq!(d.properties["xmp:Rating"], vec!["4"]); + assert!(d.properties.values().any(|v| v == &["1"])); + assert!(d.properties.values().any(|v| v == &["2"])); + } + #[test] fn attribute_form_foreign_prefixes_and_structs() { let x = r#" diff --git a/crates/meta/src/xmp_merge.rs b/crates/meta/src/xmp_merge.rs index 022f962ec..e1e4bca5e 100644 --- a/crates/meta/src/xmp_merge.rs +++ b/crates/meta/src/xmp_merge.rs @@ -23,7 +23,7 @@ use std::collections::{BTreeSet, HashSet}; use quick_xml::events::Event; -use crate::xmp::{XmpError, canonical_name, parse_xmp}; +use crate::xmp::{MAX_OPAQUE_NAME_BYTES, XmpError, canonical_name, parse_xmp, 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. @@ -101,6 +101,7 @@ fn scan(s: &str) -> Result { let mut stack: Vec = Vec::new(); let mut scopes: Vec> = vec![vec![("xml".into(), "http://www.w3.org/XML/1998/namespace".into())]]; let mut count = 0usize; + let mut opaque_budget = MAX_OPAQUE_NAME_BYTES; loop { let before = pos(&reader); let ev = reader.read_event().map_err(|e| XmpError::Xml(e.to_string()))?; @@ -124,18 +125,21 @@ fn scan(s: &str) -> Result { } let v = String::from_utf8_lossy(&a.value).into_owned(); if k == "xmlns" { - scope.push((String::new(), v.clone())); + scope.push((String::new(), unescape_attr(&v))); } else if let Some(p) = k.strip_prefix("xmlns:") { - scope.push((p.to_string(), v.clone())); + scope.push((p.to_string(), unescape_attr(&v))); } raw_attrs.push((k, v)); } scopes.push(scope); let raw_name = String::from_utf8_lossy(e.name().as_ref()).into_owned(); - let name = canonical_name(&raw_name, &scopes); + let name = canonical_name(&raw_name, &scopes, &mut opaque_budget)?; let kind = match stack.last() { Some(Kind::Rdf) if name == "rdf:Description" => { - let attrs = raw_attrs.into_iter().map(|(raw, value)| Attr { name: canonical_name(&raw, &scopes), raw, value }).collect(); + let attrs = raw_attrs + .into_iter() + .map(|(raw, value)| Ok(Attr { name: canonical_name(&raw, &scopes, &mut opaque_budget)?, raw, value })) + .collect::>()?; out.descs.push(Desc { tag_start: before, tag_end: after, raw_name, empty, attrs, props: Vec::new(), end: after }); Kind::Desc(out.descs.len() - 1) } @@ -399,6 +403,47 @@ mod tests { assert_eq!(d.properties.get("exif:DateTimeOriginal"), Some(&vec!["2024-05-06T07:08:09".to_string()])); } + #[test] + fn preserves_foreign_properties_using_known_prefixes() { + for property in [r#"lc:Secret="keep-me"/>"#, r#">keep-me"#] { + let desc = format!(r#"{desc}"#); + let out = merge_xmp(&old, &fresh(4, &[]), RULES).unwrap(); + assert!(out.contains(&desc), "lost foreign description: {out}"); + assert_eq!(parse_xmp(&out).unwrap().metadata.rating, Some(4)); + assert_eq!(merge_xmp(&out, &fresh(4, &[]), RULES).unwrap(), out); + } + } + + #[test] + fn namespace_aliases_remain_distinct_during_merge() { + // The alias URI includes a character reference: scanning and parsing must resolve it identically. + let foreign = r#""#; + let old = format!( + r#"{foreign} + + "# + ); + let out = merge_xmp(&old, &fresh(5, &[]), RULES).unwrap(); + assert!(out.contains(foreign), "lost foreign rating: {out}"); + assert!(!out.contains("a:Rating="), "genuine alias must be replaced: {out}"); + assert_eq!(parse_xmp(&out).unwrap().metadata.rating, Some(5)); + assert_eq!(merge_xmp(&out, &fresh(5, &[]), RULES).unwrap(), out); + } + + #[test] + fn opaque_namespace_expansion_is_bounded() { + // A roughly 40 KiB packet must not expand the same long URI into every property name indefinitely. + let packet = format!( + r#"{}"#, + "a".repeat(1024), + "".repeat(5000) + ); + assert_eq!(parse_xmp(&packet).unwrap_err(), XmpError::TooComplex); + assert!(matches!(scan(&packet), Err(XmpError::TooComplex))); + } + #[test] fn many_attributes_scan_in_linear_time() { // quick-xml's duplicate-name check compares each attribute with all earlier ones: 200 000 attributes on diff --git a/docs/gaps.md b/docs/gaps.md index 51d93abef..3bcac4363 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -60,6 +60,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Closed recently (kept for calibration) +- 2026-10-22: saving XMP preserves foreign namespaces even when they use `lc:` or `xmp:` prefixes; importing them no longer interprets their properties as native settings or ratings. Parser, merge and save-command regressions cover the correction; no ranked gap or readiness estimate changes. + - 2026-10-20: the import review album picker follows the sidebar folder hierarchy and searches album paths (#2362, LR-IMP-ADD-DIALOG), retaining None/New album and existing destination IDs. This closes a usability subgap; no ranked gap or headline feature count closes. - 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like @@ -79,6 +81,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-22 | patch | XMP namespace-prefix collisions no longer overwrite foreign metadata; ranked gaps and estimates unchanged | | 2026-10-21 | minor | Gap 28 shrinks: F8 shows / hides the right panel (`view.rightPanel`); rating `[` `]`, flag cycling and F1 help were already bound, so the entry lists only filter-bar keys, Tab / ⇧Tab and F5; 2–4 h (was 4–8) | | 2026-10-20 | minor | #2362: import destination albums use the shared hierarchy/search picker instead of a flat name list; existing import workflows retained; ranked gaps/readiness counts unchanged | | 2026-10-19 | minor | Gap 21: Indonesian (`id`) shipped with every catalog message but What's New; four new languages left (Hindi, Arabic, Korean, Vietnamese), 135–239 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8cec4b9ac..55bd68314 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -168,7 +168,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte (ownership follows namespace URI: foreign namespaces using `lc:` or `xmp:` cannot supply native settings or ratings and survive saving), an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -798,6 +798,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-22 | patch | LR-META-XMP: namespace URI determines ownership; foreign namespaces using recognized prefixes survive saves and cannot supply native settings or ratings; status and counts unchanged | | 2026-10-21 | patch | LR-IMP-ADD-DIALOG: reconcile #2362 with current main; deleted-album guard and hierarchical picker retained; counts unchanged | | 2026-10-20 | minor | LR-IMP-ADD-DIALOG: #2362 gives the import review the shared hierarchy/search album picker while retaining None/New album and existing destination IDs; status and feature counts unchanged | | 2026-10-21 | patch | KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the slider's row; leaving it (even between two frames) or Tab focus alone does not arm them (issue #1355); status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 252a834a5..dcf203893 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -23,6 +23,10 @@ Before, the field took the focus back every frame, so only Return and Esc got you out of it. Return still confirms and Esc still cancels. +### Foreign XMP metadata survives namespace-prefix collisions +- Saving a sidecar keeps another application's properties even when its namespace uses prefixes such as `lc` + or `xmp`. Those foreign properties no longer become LightCraft settings or ratings when imported. + ## 2026-10-21 ### Sharper resampling in HDR and panorama merges diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index b90e76c40..7fd11a172 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -81,6 +81,9 @@ the only copy of those edits outside that application's catalog. Saving never dr ones), `xmp:CreatorTool`, comments, the packet wrapper and padding. LightCraft's properties go into one `rdf:Description` of their own; owned properties written by another application (in attribute or element form) are removed from its description, which otherwise stays as it was. +- Ownership follows the namespace URI, not the prefix spelling. A foreign namespace bound to `lc:` or `xmp:` + stays foreign: its properties are preserved and do not become LightCraft settings or ratings. Genuine namespace + aliases remain interchangeable, including namespace declarations containing XML character references. - A sidecar that can't be read as XMP (not well-formed, not UTF-8, no `rdf:RDF`) is first copied to `.xmp.bak-