From 14d321c107c690f1de7f2d97f880a9cd771adff2 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:20:34 +0300 Subject: [PATCH] catalog: slice captured dates on char boundaries so non-ASCII dates don't panic --- crates/catalog/src/query.rs | 11 +++++++---- crates/catalog/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index a9956703d..99d14deea 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -397,10 +397,13 @@ impl Catalog { let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new(); for p in self.photos().filter(|p| p.in_library()) { let Some(d) = p.captured.as_deref() else { continue }; - if d.len() >= 10 { - let (months, days) = years.entry(&d[..4]).or_default(); - *months.entry(&d[..7]).or_default() += 1; - *days.entry(&d[..10]).or_default() += 1; + // `get`, not `[..]`: a corrupt or foreign `captured` (a Lightroom catalog's + // captureTime is copied verbatim) may not be ASCII, and slicing it on a byte index + // would panic — the same rule as `dates::group_label`. + if let (Some(year), Some(month), Some(day)) = (d.get(..4), d.get(..7), d.get(..10)) { + let (months, days) = years.entry(year).or_default(); + *months.entry(month).or_default() += 1; + *days.entry(day).or_default() += 1; } } let newest_first = |m: BTreeMap<&str, usize>| m.into_iter().rev().map(|(k, n)| (k.to_string(), n)).collect::>(); diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index acfbb1bb1..d2c0d0c64 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -123,6 +123,23 @@ fn filter_search_sort() { assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]); } +/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an +/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be +/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores +/// the rest instead of panicking on a char boundary. +#[test] +fn date_groups_ignores_a_non_ascii_captured() { + let mut c = Catalog::new(); + let ok = photo(&mut c, "ok.jpg", "2026-05-01T10:00:00"); + // `AddPhoto` takes the field as it is, like `library.importLightroom` building the op + let bad = photo(&mut c, "bad.jpg", "日本語日本語"); + assert_ne!(ok, bad); + let g = c.date_groups(); + assert_eq!(g.len(), 1, "the one well-formed date still groups"); + assert_eq!((g[0].year.as_str(), g[0].count), ("2026", 1)); + assert_eq!(g[0].days.iter().map(|d| d.1).sum::(), 1); +} + #[test] fn snapshot_and_log_replay() { let mut c = Catalog::new();