mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user