mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 19:17:02 -06:00
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:
co-authored by
Claude Opus 5.5
parent
62aef59178
commit
a08d0f6b03
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user