LRC-LIB-SMARTCOLL: "in the last N" can't overflow; shift_iso stays in years 0–9999

Crash fix. A huge or negative "in the last N" from an agent or a saved
file ({"n": -1e30}) panicked with "attempt to negate with overflow" in
debug builds. Release builds wrapped instead, and the rule matched the
wrong photos.

- last_secs: a zero, negative, NaN or unreadable N is None (the rule
  matches nothing, either way round). Spans are capped at 10,000 years,
  which means "ever": every dated photo is in it and none is outside it.
- dates::shift_iso: checked_add, and a result outside years 0000–9999
  (which iso_seconds can't read back) is None. It never overflows,
  whatever the seconds.

Regression tests cover both and panicked before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 10:54:56 -04:00
co-authored by Claude Opus 5.5
parent 62aef59178
commit a08d0f6b03
2 changed files with 54 additions and 6 deletions
+17 -3
View File
@@ -40,11 +40,17 @@ pub fn weekday(iso: &str) -> Option<&'static str> {
}
/// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was.
/// A date without a time gets one. `None` if it doesn't parse.
/// A date without a time gets one. `None` if it doesn't parse, or if the result leaves years
/// 0000–9999 (an ISO date here has four year digits, so it couldn't be read back); `secs` may be
/// anything, it never overflows.
pub fn shift_iso(iso: &str, secs: i64) -> Option<String> {
let t = iso_seconds(iso)?;
let t = iso_seconds(iso)?.checked_add(secs)?;
let (first, last) = (iso_seconds("0000-01-01T00:00:00")?, iso_seconds("9999-12-31T23:59:59")?);
if !(first..=last).contains(&t) {
return None;
}
let tail = iso.trim().get(19..).unwrap_or("");
Some(format!("{}{tail}", civil(t + secs)))
Some(format!("{}{tail}", civil(t)))
}
/// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing
@@ -204,6 +210,14 @@ mod tests {
assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00"));
assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00"));
assert_eq!(shift_iso("nope", 5), None);
// hostile shifts never overflow, and a result outside years 0000–9999 (which no ISO date
// here can read back) is refused rather than stored
for secs in [i64::MAX, i64::MIN, i64::MAX / 2, -(i64::MAX / 2)] {
assert_eq!(shift_iso("2026-01-01T00:00:00", secs), None, "{secs}");
}
assert_eq!(shift_iso("9999-12-31T23:59:59", 1), None);
assert_eq!(shift_iso("0000-01-01T00:00:00", -1), None);
assert_eq!(shift_iso("9999-12-31T23:59:58", 1).as_deref(), Some("9999-12-31T23:59:59"));
assert_eq!(normalize_iso("2026-04-01 10:05").as_deref(), Some("2026-04-01T10:05:00"));
assert_eq!(normalize_iso("2026-02-30"), None);
}
+37 -3
View File
@@ -340,7 +340,12 @@ fn shutter_value(value: &Value) -> Value {
}
}
/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days.
/// The longest "in the last…" span: 10,000 years, beyond any capture date ("ever").
const MAX_LAST_SECS: f64 = 10_000.0 * 365.25 * 86_400.0;
/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days, in
/// seconds. `None` for a zero, negative or unreadable `n`; a longer span than [`MAX_LAST_SECS`]
/// is that (so the arithmetic on it can't overflow).
fn last_secs(value: &Value) -> Option<i64> {
let (n, unit) = match value {
Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").and_then(Value::as_str).unwrap_or("days")),
@@ -354,7 +359,11 @@ fn last_secs(value: &Value) -> Option<i64> {
"years" => 365.25 * day,
_ => day,
};
Some((n * per) as i64)
let secs = n * per;
if secs.is_nan() || secs <= 0.0 {
return None;
}
Some(secs.min(MAX_LAST_SECS) as i64)
}
fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
@@ -386,7 +395,8 @@ fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
},
"inLast" | "notInLast" => {
let Some(secs) = last_secs(value) else { return false };
let from = crate::dates::shift_iso(&now(), -secs).unwrap_or_default();
// a span reaching before year 0 is "ever": every dated photo is in it
let Some(from) = crate::dates::shift_iso(&now(), -secs) else { return op == "inLast" };
(h >= from.as_str()) == (op == "inLast")
}
_ => false,
@@ -938,6 +948,30 @@ mod tests {
assert_eq!(bad.problems(), vec!["`edited` is yes or no, not \"maybe\"".to_string()]);
}
/// "In the last N" never overflows, whatever N an agent or a saved file holds: a zero,
/// negative or unreadable N matches nothing (either way round); an N beyond 10,000 years means
/// "ever", so every dated photo is in it and none is outside it.
#[test]
fn in_the_last_survives_hostile_counts() {
set_now(Some("2026-09-01T00:00:00".into()));
let cat = Catalog::new();
let p = photo(1); // captured 2026-08-14
let m = |op: &str, n: serde_json::Value, unit: &str| {
rs(json!({"rules": [{"field": "captureDate", "op": op, "value": {"n": n, "unit": unit}}]})).matches(&p, &cat)
};
for n in [json!(0), json!(-5), json!(-1e30), json!(-9.3e18), json!("soon")] {
for op in ["inLast", "notInLast"] {
assert!(!m(op, n.clone(), "years"), "{op} {n}");
}
}
for n in [json!(1e30), json!(9.3e18), json!(u64::MAX), json!(20_000)] {
assert!(m("inLast", n.clone(), "years"), "{n} years is ever");
assert!(!m("notInLast", n.clone(), "years"), "{n}");
}
assert!(m("inLast", json!(1e30), "hours") && m("inLast", json!(30), "days"));
set_now(None);
}
/// The field menu shows every rule field once: at the top level or in exactly one group,
/// and [`FIELDS`] lists them in menu order.
#[test]