diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index d13023b6e..c7b6c437b 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -159,29 +159,31 @@ pub fn get(model: &str) -> Option> { /// keys, so thumbnails rendered before a profile existed are not reused after. pub fn cache_key() -> u64 { static KEY: OnceLock = OnceLock::new(); - *KEY.get_or_init(|| { - let entries = dir().and_then(|d| std::fs::read_dir(d).ok()); - let mut files: Vec<(String, u64, u64)> = entries - .into_iter() - .flatten() - .flatten() - .filter(|e| e.path().extension().is_some_and(|x| x == "json")) - .filter_map(|e| { - let meta = e.metadata().ok()?; - let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); - Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified)) - }) - .collect(); - files.sort(); - let mut h = lightcraft_preview::Hasher128::new(); - for (name, len, modified) in &files { - h.str(name).u64(*len).u64(*modified); - } - for (model, json) in BUNDLED { - h.str(model).str(json); - } - h.finish().0 as u64 - }) + *KEY.get_or_init(|| cache_key_for(dir().as_deref())) +} + +fn cache_key_for(directory: Option<&Path>) -> u64 { + let entries = directory.and_then(|d| std::fs::read_dir(d).ok()); + let mut files: Vec<(String, u64, std::time::Duration)> = entries + .into_iter() + .flatten() + .flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .filter_map(|e| { + let meta = e.metadata().ok()?; + let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; + Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified)) + }) + .collect(); + files.sort(); + let mut h = lightcraft_preview::Hasher128::new(); + for (name, len, modified) in &files { + h.str(name).u64(*len).u64(modified.as_secs()).u64(u64::from(modified.subsec_nanos())); + } + for (model, json) in BUNDLED { + h.str(model).str(json); + } + h.finish().0 as u64 } /// Per camera model: the photos read and their pooled colour pairs. @@ -294,6 +296,38 @@ mod tests { assert!(bundled("No Such Camera").is_none()); } + #[test] + fn same_second_profile_replacement_changes_render_cache_key() { + let dir = std::env::temp_dir().join(format!("lc-camera-profile-cache-{}", std::process::id())); + let mut first = profile(); + first.model = "ILCE-7CR".into(); + let path = save(&first, &dir).unwrap(); + let set_modified = |millis| { + let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000) + std::time::Duration::from_millis(millis); + let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); + file.set_times(std::fs::FileTimes::new().set_modified(time)).unwrap(); + file.metadata().unwrap() + }; + let before = set_modified(100); + assert_eq!(load(&path).unwrap(), first); + let before_key = cache_key_for(Some(&dir)); + + let mut second = first; + second.matrix[0][0] = 1.7; + save(&second, &dir).unwrap(); + let after = set_modified(350); + assert_eq!(load(&path).unwrap(), second); + assert_eq!(before.len(), after.len()); + let before_time = before.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap(); + let after_time = after.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap(); + assert_eq!(before_time.as_secs(), after_time.as_secs()); + assert_ne!(before_time.subsec_nanos(), after_time.subsec_nanos()); + let after_key = cache_key_for(Some(&dir)); + let _ = std::fs::remove_dir_all(dir); + + assert_ne!(before_key, after_key, "a changed profile must not reuse the previous render cache"); + } + #[test] fn file_names_are_safe() { assert_eq!(file_name("ILCE-7M4"), "ILCE-7M4.json");