Merge PR #2977: LR-META-EXIF-FULL: reject malformed EXIF clocks

docs/gaps.md, docs/parity-checklist.md, docs/whats-new.md: both sides kept; the PR's sentences added to main's rows and entries.
docs/xmp-interop.md: capture-time paragraph combines both sides.
The PR's test file crates/engine/src/tests_import.rs landed in crates/engine/src/tests/import.rs (moved on main).
This commit is contained in:
aiongg
2026-10-31 02:08:25 +00:00
10 changed files with 242 additions and 17 deletions
+128
View File
@@ -30,6 +30,34 @@ fn write_png(path: &Path, seed: u8) {
std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
}
fn exif_clock_block(original: Option<&str>, digitized: Option<&str>) -> Vec<u8> {
use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value};
let mut exif = IfdBuilder::new();
if let Some(value) = original {
exif.set(0x9003, Value::Ascii(value.into()));
}
if let Some(value) = digitized {
exif.set(0x9004, Value::Ascii(value.into()));
}
let mut ifd0 = IfdBuilder::new();
ifd0.set_child(0x8769, exif);
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()
}
fn write_png_with_metadata(path: &Path, seed: u8, exif: Option<&[u8]>, xmp: Option<&str>) {
let (w, h) = (48usize, 32usize);
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
let metadata = lightcraft_codecs::EncodeMeta { exif, xmp, ..Default::default() };
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &metadata).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
// Keep the same known file-date fallback as write_png, distinct from the regression timestamp.
let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328);
std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
}
fn ids(v: &Value, key: &str) -> usize {
v[key].as_array().map(Vec::len).unwrap_or(0)
}
@@ -128,6 +156,106 @@ fn civil_dates() {
assert_eq!(crate::import::civil(1_790_000_000), "2026-09-21T14:13:20");
}
#[test]
fn exif_clock_import_uses_valid_fallback_dates_and_keeps_raw_tags() {
const INVALID: &str = "2026:01:01 12:34:56:99";
const XMP: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:CreateDate="2026-01-05T10:00:00Z"/></rdf:RDF></x:xmpmeta>"#;
let dir = temp_dir("exif-clock-fallbacks");
let digitized_path = dir.join("digitized.png");
let digitized_exif = exif_clock_block(Some(INVALID), Some("2026:01:02 10:00:00"));
write_png_with_metadata(&digitized_path, 1, Some(&digitized_exif), None);
let xmp_path = dir.join("xmp.png");
let malformed_only_exif = exif_clock_block(Some(INVALID), None);
write_png_with_metadata(&xmp_path, 2, Some(&malformed_only_exif), Some(XMP));
let mut s = Session::new().with_fs();
let imported = s.execute("library.import", &json!({"paths": [digitized_path.to_string_lossy(), xmp_path.to_string_lossy()]})).unwrap();
assert_eq!(ids(&imported, "imported"), 2, "{imported}");
let digitized = s.catalog.photos().find(|photo| photo.file_name == "digitized.png").unwrap();
let digitized_id = digitized.id.0;
assert_eq!(digitized.captured.as_deref(), Some("2026-01-02T10:00:00"));
let xmp = s.catalog.photos().find(|photo| photo.file_name == "xmp.png").unwrap();
let xmp_id = xmp.id.0;
assert_eq!(xmp.captured.as_deref(), Some("2026-01-05T10:00:00+00:00"));
let all = s.execute("photo.allMetadata", &json!({"id": digitized_id})).unwrap();
assert!(all["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003) && row["value"] == INVALID), "{all}");
let all = s.execute("photo.allMetadata", &json!({"id": xmp_id})).unwrap();
assert!(all["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003) && row["value"] == INVALID), "{all}");
assert!(all["xmp"].as_array().unwrap().iter().any(|row| row["name"] == "xmp:CreateDate" && row["value"] == "2026-01-05T10:00:00Z"), "{all}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exif_clock_malformed_capture_cannot_fabricate_tracklog_match() {
use lightcraft_catalog::PhotoId;
const INVALID: &str = "2026:01:01 12:34:56:99";
const GPX: &str = r#"<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg><trkpt lat="45" lon="7"><time>2026-01-01T12:34:56Z</time></trkpt></trkseg></trk></gpx>"#;
let dir = temp_dir("exif-clock-tracklog");
let malformed_path = dir.join("malformed.png");
let malformed_exif = exif_clock_block(Some(INVALID), None);
write_png_with_metadata(&malformed_path, 3, Some(&malformed_exif), None);
let valid_path = dir.join("valid.png");
let valid_exif = exif_clock_block(Some("2026:01:01 12:34:56"), None);
write_png_with_metadata(&valid_path, 4, Some(&valid_exif), None);
let mut s = Session::new().with_fs();
let imported = s.execute("library.import", &json!({"paths": [malformed_path.to_string_lossy()]})).unwrap();
assert_eq!(ids(&imported, "imported"), 1, "{imported}");
let bad_id = imported["imported"][0].as_u64().unwrap();
assert_eq!(
s.catalog.photo(PhotoId(bad_id)).unwrap().captured.as_deref(),
Some("2026-01-14T05:58:48"),
"malformed EXIF must leave the normal file-date fallback in effect"
);
let all = s.execute("photo.allMetadata", &json!({"id": bad_id})).unwrap();
assert!(
all["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003) && row["value"] == INVALID),
"raw EXIF remains available: {all}"
);
// Leave both undo and redo populated so a no-match call must preserve the existing history branch.
s.execute("photo.setMeta", &json!({"ids": [bad_id], "title": "history control"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("library.select", &json!({"ids": [bad_id]})).unwrap();
assert!(!s.undo.is_empty() && !s.redo.is_empty());
let state = |session: &Session| {
(
session.catalog.to_snapshot(),
session.catalog.revision,
session.selection.clone(),
session.commits(),
session.undo.iter().map(|entry| (entry.label.clone(), entry.op.clone())).collect::<Vec<_>>(),
session.redo.iter().map(|entry| (entry.label.clone(), entry.op.clone())).collect::<Vec<_>>(),
)
};
let before = state(&s);
let dry = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [bad_id], "maxGap": 0, "dryRun": true})).unwrap();
assert_eq!(dry["tagged"], 0, "{dry}");
assert_eq!(dry["skipped"]["outside"], 1, "the file-date fallback is outside the one-point track: {dry}");
assert_eq!(state(&s), before, "dry-run changed catalog, selection or history");
let actual = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [bad_id], "maxGap": 0})).unwrap();
assert_eq!(actual["tagged"], 0, "{actual}");
assert_eq!(actual["skipped"]["outside"], 1, "{actual}");
assert_eq!(state(&s), before, "unmatched tracklog call changed catalog, selection or history");
assert_eq!(s.catalog.photo(PhotoId(bad_id)).unwrap().meta.gps, None);
let imported = s.execute("library.import", &json!({"paths": [valid_path.to_string_lossy()]})).unwrap();
assert_eq!(ids(&imported, "imported"), 1, "{imported}");
let valid_id = imported["imported"][0].as_u64().unwrap();
assert_eq!(s.catalog.photo(PhotoId(valid_id)).unwrap().captured.as_deref(), Some("2026-01-01T12:34:56"));
s.execute("library.select", &json!({"ids": [valid_id]})).unwrap();
let undo_before = s.undo.len();
let tagged = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [valid_id], "maxGap": 0})).unwrap();
assert_eq!(tagged["tagged"], 1, "{tagged}");
assert_eq!(s.catalog.photo(PhotoId(valid_id)).unwrap().meta.gps, Some((45.0, 7.0)));
assert_eq!(s.undo.len(), undo_before + 1, "valid tagging is one undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(PhotoId(valid_id)).unwrap().meta.gps, None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn browsing_a_folder_lists_its_photos_without_adding_them() {
use crate::LibrarySource;