Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix DateTime out-of-range panics #1048

Closed
wants to merge 4 commits into from
Closed
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
18 changes: 11 additions & 7 deletions src/datetime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ impl<Tz: TimeZone> DateTime<Tz> {
/// daylight saving time transition.
#[must_use]
pub fn checked_add_months(self, rhs: Months) -> Option<DateTime<Tz>> {
self.naive_local()
self.overflowing_naive_local()
.checked_add_months(rhs)?
.and_local_timezone(Tz::from_offset(&self.offset))
.single()
Expand Down Expand Up @@ -415,7 +415,7 @@ impl<Tz: TimeZone> DateTime<Tz> {
/// daylight saving time transition.
#[must_use]
pub fn checked_sub_months(self, rhs: Months) -> Option<DateTime<Tz>> {
self.naive_local()
self.overflowing_naive_local()
.checked_sub_months(rhs)?
.and_local_timezone(Tz::from_offset(&self.offset))
.single()
Expand All @@ -431,8 +431,9 @@ impl<Tz: TimeZone> DateTime<Tz> {
/// daylight saving time transition.
#[must_use]
pub fn checked_add_days(self, days: Days) -> Option<Self> {
self.naive_local()
.checked_add_days(days)?
self.overflowing_naive_local()
.checked_add_days(days)
.filter(|d| d.date() <= NaiveDate::AFTER_MAX)?
.and_local_timezone(TimeZone::from_offset(&self.offset))
.single()
}
Expand All @@ -447,8 +448,9 @@ impl<Tz: TimeZone> DateTime<Tz> {
/// daylight saving time transition.
#[must_use]
pub fn checked_sub_days(self, days: Days) -> Option<Self> {
self.naive_local()
.checked_sub_days(days)?
self.overflowing_naive_local()
.checked_sub_days(days)
.filter(|d| d.date() >= NaiveDate::BEFORE_MIN)?
.and_local_timezone(TimeZone::from_offset(&self.offset))
.single()
}
Expand Down Expand Up @@ -972,7 +974,9 @@ impl<Tz: TimeZone> Datelike for DateTime<Tz> {
/// - The local time at the resulting date does not exist or is ambiguous, for example during a
/// daylight saving time transition.
fn with_year(&self, year: i32) -> Option<DateTime<Tz>> {
map_local(self, |datetime| datetime.with_year(year))
map_local(self, |dt| {
dt.date().overflowing_with_year(year).map(|d| NaiveDateTime::new(d, dt.time()))
})
}

/// Makes a new `DateTime` with the month number (starting from 1) changed.
Expand Down
59 changes: 59 additions & 0 deletions src/datetime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,65 @@ fn test_min_max_setters() {
assert_eq!(beyond_max.with_nanosecond(beyond_max.nanosecond()), Some(beyond_max));
}

#[test]
fn test_min_max_complex() {
let offset_min = FixedOffset::west_opt(2 * 60 * 60).unwrap();
let beyond_min = offset_min.from_utc_datetime(&NaiveDateTime::MIN);
let offset_max = FixedOffset::east_opt(2 * 60 * 60).unwrap();
let beyond_max = offset_max.from_utc_datetime(&NaiveDateTime::MAX);
let max_time = NaiveTime::from_hms_nano_opt(23, 59, 59, 999_999_999).unwrap();

assert_eq!(beyond_min.checked_add_days(Days::new(0)), Some(beyond_min));
assert_eq!(
beyond_min.checked_add_days(Days::new(1)),
Some(offset_min.from_utc_datetime(&(NaiveDate::MIN + Days(1)).and_time(NaiveTime::MIN)))
);
assert_eq!(beyond_min.checked_sub_days(Days::new(0)), Some(beyond_min));
assert_eq!(beyond_min.checked_sub_days(Days::new(1)), None);
assert_eq!(beyond_min.checked_add_months(Months::new(0)), Some(beyond_min));
assert_eq!(
beyond_min.checked_add_months(Months::new(1)),
Some(offset_min.from_utc_datetime(&(NaiveDate::MIN + Months(1)).and_time(NaiveTime::MIN)))
);
assert_eq!(beyond_min.checked_sub_months(Months::new(0)), Some(beyond_min));
assert_eq!(beyond_min.checked_sub_months(Months::new(1)), None);
assert_eq!(beyond_min.with_year(beyond_min.year()), Some(beyond_min));
let res = NaiveDate::MIN.with_year(2021).unwrap().and_time(NaiveTime::MIN) + offset_min;
assert_eq!(beyond_min.with_year(2020), offset_min.from_local_datetime(&res).single());
assert_eq!(
offset_min
.from_utc_datetime(
&NaiveDate::from_ymd_opt(2023, 1, 1).unwrap().and_time(NaiveTime::MIN)
)
.with_year(NaiveDate::MIN.year() - 1),
Some(beyond_min)
);

assert_eq!(beyond_max.checked_add_days(Days::new(0)), Some(beyond_max));
assert_eq!(beyond_max.checked_add_days(Days::new(1)), None);
assert_eq!(beyond_max.checked_sub_days(Days::new(0)), Some(beyond_max));
assert_eq!(
beyond_max.checked_sub_days(Days::new(1)),
Some(offset_max.from_utc_datetime(&(NaiveDate::MAX - Days(1)).and_time(max_time)))
);
assert_eq!(beyond_max.checked_add_months(Months::new(0)), Some(beyond_max));
assert_eq!(beyond_max.checked_add_months(Months::new(1)), None);
assert_eq!(beyond_max.checked_sub_months(Months::new(0)), Some(beyond_max));
assert_eq!(
beyond_max.checked_sub_months(Months::new(1)),
Some(offset_max.from_utc_datetime(&(NaiveDate::MAX - Months(1)).and_time(max_time)))
);
assert_eq!(beyond_max.with_year(beyond_max.year()), Some(beyond_max));
let res = NaiveDate::MAX.with_year(2019).unwrap().and_time(max_time) + offset_max;
assert_eq!(beyond_max.with_year(2020), offset_max.from_local_datetime(&res).single());
assert_eq!(
offset_max
.from_utc_datetime(&NaiveDate::from_ymd_opt(2023, 12, 31).unwrap().and_time(max_time))
.with_year(NaiveDate::MAX.year() + 1),
Some(beyond_max)
);
}

#[test]
#[should_panic]
fn test_local_beyond_min_datetime() {
Expand Down
20 changes: 18 additions & 2 deletions src/naive/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -784,10 +784,13 @@ impl NaiveDate {

/// Add a duration of `i32` days to the date.
pub(crate) const fn add_days(self, days: i32) -> Option<Self> {
// fast path if the result is within the same year
// Fast path if the result is within the same year.
// Also `DateTime::checked_(add|sub)_days` relies on this path, because if the value remains
// within the year it doesn't do a check if the year is in range.
// That is useful when working with values near `DateTime::MIN` or `DateTime::MAX`.
const ORDINAL_MASK: i32 = 0b1_1111_1111_0000;
if let Some(ordinal) = ((self.ymdf & ORDINAL_MASK) >> 4).checked_add(days) {
if ordinal > 0 && ordinal <= 365 {
if ordinal > 0 && ordinal <= (365 + self.leap_year() as i32) {
let year_and_flags = self.ymdf & !ORDINAL_MASK;
return Some(NaiveDate { ymdf: year_and_flags | (ordinal << 4) });
}
Expand Down Expand Up @@ -1455,6 +1458,19 @@ impl NaiveDate {
self.of().weekday()
}

// Similar to `Datelike::with_year()`, but allows creating a `NaiveDate` beyond `MIN` or `MAX`.
// Only used by `DateTime::with_year`.
pub(crate) fn overflowing_with_year(&self, year: i32) -> Option<NaiveDate> {
// we need to operate with `mdf` since we should keep the month and day number as is
let mdf = self.mdf();

// adjust the flags as needed
let flags = YearFlags::from_year(year);
let mdf = mdf.with_flags(flags);

mdf.to_of().map(|of| NaiveDate { ymdf: (year << 13) | (of.inner() as DateImpl) })
}

/// The minimum possible `NaiveDate` (January 1, 262144 BCE).
pub const MIN: NaiveDate = NaiveDate { ymdf: (MIN_YEAR << 13) | (1 << 4) | 0o12 /*D*/ };
/// The maximum possible `NaiveDate` (December 31, 262142 CE).
Expand Down
Loading