diff --git a/examples/add-mo-out-of-range.ilo b/examples/add-mo-out-of-range.ilo new file mode 100644 index 00000000..91ee46c6 --- /dev/null +++ b/examples/add-mo-out-of-range.ilo @@ -0,0 +1,12 @@ +-- add-mo handles large but representable month offsets without +-- panicking or wrapping. Pre-fix, the internal arithmetic used i32 +-- and overflowed for big offsets (panic in debug, silent wrap in +-- release). Post-fix it widens to i64 and either returns a sensible +-- epoch or surfaces a clean ILO-R009 "result out of calendar range". +-- run: thousand-years +-- out: 31556995200 + +-- 1000 years forward of 1970-01-01 = 12000 calendar months. +-- 2970-01-01 00:00 UTC = 31556995200. +thousand-years > n + add-mo 0 12000 diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index bb45f72b..74f494b1 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -2006,12 +2006,20 @@ fn add_mo_impl(epoch_arg: &Value, months_arg: &Value) -> Result { }; let date = dt.date_naive(); fn add_months_snap(date: NaiveDate, months: i32) -> Option { - let total = date.year() * 12 + (date.month() as i32 - 1) + months; - let y = total.div_euclid(12); + // Compute total months since year-0 in i64 so adding i32::MAX (or + // i32::MIN) months to any chrono-representable year cannot wrap. + // Pre-fix this was i32 arithmetic: `date.year() * 12 + months` + // overflowed at i32::MAX months, panicking in debug and silently + // wrapping to a valid date in release. The widened path now either + // produces a valid NaiveDate or returns None, which the caller + // surfaces as a clean ILO-R009 "result out of calendar range". + let total: i64 = (date.year() as i64) * 12 + (date.month() as i64 - 1) + (months as i64); + let y_i64 = total.div_euclid(12); let m = (total.rem_euclid(12) + 1) as u32; + let y: i32 = i32::try_from(y_i64).ok()?; let max_day = { let next = if m == 12 { - NaiveDate::from_ymd_opt(y + 1, 1, 1) + NaiveDate::from_ymd_opt(y.checked_add(1)?, 1, 1) } else { NaiveDate::from_ymd_opt(y, m + 1, 1) }; diff --git a/tests/regression_calendar_arithmetic.rs b/tests/regression_calendar_arithmetic.rs index 41f7c786..9cd5a7f0 100644 --- a/tests/regression_calendar_arithmetic.rs +++ b/tests/regression_calendar_arithmetic.rs @@ -405,6 +405,9 @@ fn add_mo_epoch_out_of_range() { fn add_mo_result_out_of_calendar_range() { // Max i32 months from epoch 0 pushes far past chrono's max year (262143). // Exercises the `None` arm of `match add_months_snap(date, months)`. + // Regression: the year-as-months arithmetic used to be i32 and silently + // wrapped in release / panicked in debug at i32::MAX; it now widens to + // i64 and surfaces a clean ILO-R009. check_err( "f>n;add-mo 0 2147483647", &["f"], @@ -412,6 +415,31 @@ fn add_mo_result_out_of_calendar_range() { ); } +#[test] +fn add_mo_result_out_of_calendar_range_negative() { + // Symmetric negative case: i32::MIN months from epoch 0 pushes far before + // chrono's min year. Guards against an asymmetric overflow fix that only + // handles the positive side. + check_err( + "f>n;add-mo 0 -2147483648", + &["f"], + "add-mo: result out of calendar range", + ); +} + +#[test] +fn add_mo_large_but_valid_offset() { + // 1000 years forward of 1970-01-01 lands on 2970-01-01, well within + // chrono's representable range. Guards against the overflow fix being + // too aggressive and rejecting plausible long offsets. + // 2970-01-01 00:00 UTC = 31556995200 + check_num( + "f dt:n n:n>n;add-mo dt n", + &["f", "0", "12000"], + 31556995200.0, + ); +} + #[test] fn last_dom_epoch_out_of_range() { check_err(