Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions examples/add-mo-out-of-range.ilo
Original file line number Diff line number Diff line change
@@ -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
14 changes: 11 additions & 3 deletions src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2006,12 +2006,20 @@ fn add_mo_impl(epoch_arg: &Value, months_arg: &Value) -> Result<Value> {
};
let date = dt.date_naive();
fn add_months_snap(date: NaiveDate, months: i32) -> Option<NaiveDate> {
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)
};
Expand Down
28 changes: 28 additions & 0 deletions tests/regression_calendar_arithmetic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,13 +405,41 @@ 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"],
"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(
Expand Down
Loading