Skip to content

Some fallible-looking or infallible conversion paths can still panic on extreme inputs #1802

Open

Description

@xc01

Summary

I found a few chrono panic paths that look worth either hardening or documenting more directly.

I am not including the many deprecated constructors and operator-overflow cases as main findings, because most of those already have # Panics docs or *_opt / checked_* alternatives. The main points below are the ones that surprised me more:

  1. DateTime::<Utc>::from(SystemTime) can panic for extreme but representable SystemTime values.
  2. TimeDelta::checked_sub can panic instead of returning None on some extreme inputs.
  3. Malformed TZif data can panic during local timezone parsing through unchecked count arithmetic.

Tested on:

  • chrono 0.4.44

Findings

Area API / function Panic site Trigger
Infallible conversion DateTime::<Utc>::from(SystemTime) src/datetime/mod.rs:1928, src/datetime/mod.rs:1931 extreme pre/post epoch SystemTime
Checked duration arithmetic TimeDelta::checked_sub src/time_delta.rs:398 TimeDelta::MIN.checked_sub(&TimeDelta::MAX)
TZif parser local timezone loading via Local src/offset/local/tz_info/parser.rs:136 malformed TZif header with huge type_count

Example 1: DateTime::<Utc>::from(SystemTime) can panic

The From<SystemTime> impl is infallible at the type level, but it unwraps the result of Utc.timestamp_opt:

// src/datetime/mod.rs:1920-1931
impl From<SystemTime> for DateTime<Utc> {
    fn from(t: SystemTime) -> DateTime<Utc> {
        let (sec, nsec) = match t.duration_since(UNIX_EPOCH) {
            Ok(dur) => (dur.as_secs() as i64, dur.subsec_nanos()),
            Err(e) => {
                let dur = e.duration();
                let (sec, nsec) = (dur.as_secs() as i64, dur.subsec_nanos());
                if nsec == 0 { (-sec, 0) } else { (-sec - 1, 1_000_000_000 - nsec) }
            }
        };
        Utc.timestamp_opt(sec, nsec).unwrap()
    }
}

For extreme SystemTime values, the conversion can panic instead of being documented as fallible or saturating.

Reproducer

use chrono::{DateTime, Utc};
use std::time::{Duration, UNIX_EPOCH};

#[test]
#[should_panic]
fn systemtime_to_datetime_utc_can_panic() {
    let t = UNIX_EPOCH
        .checked_add(Duration::from_secs(i64::MAX as u64 + 1))
        .unwrap();

    let _: DateTime<Utc> = DateTime::<Utc>::from(t);
}

A pre-epoch variant can also panic:

use chrono::{DateTime, Utc};
use std::time::{Duration, UNIX_EPOCH};

#[test]
#[should_panic]
fn pre_epoch_systemtime_to_datetime_utc_can_panic() {
    let t = UNIX_EPOCH - Duration::new(1u64 << 63, 1);
    let _: DateTime<Utc> = DateTime::<Utc>::from(t);
}

Expected:

Either document the panic on out-of-range SystemTime, or expose/point users to a fallible conversion path so callers can handle values outside Chrono's representable range without unwinding.

Example 2: TimeDelta::checked_sub can panic instead of returning None

checked_sub is documented as returning None if overflow occurs, but it first performs unchecked i64 subtraction:

// src/time_delta.rs:393-404
/// Subtract two `TimeDelta`s, returning `None` if overflow occurred.
#[must_use]
pub const fn checked_sub(&self, rhs: &TimeDelta) -> Option<TimeDelta> {
    // No overflow checks here because we stay comfortably within the range of an `i64`.
    // Range checks happen in `TimeDelta::new`.
    let mut secs = self.secs - rhs.secs;
    let mut nanos = self.nanos - rhs.nanos;
    if nanos < 0 {
        nanos += NANOS_PER_SEC;
        secs -= 1;
    }
    TimeDelta::new(secs, nanos as u32)
}

That means the "checked" method itself can panic before it reaches TimeDelta::new.

Reproducer

use chrono::TimeDelta;

#[test]
#[should_panic]
fn checked_sub_can_panic_on_extreme_values() {
    let _ = TimeDelta::MIN.checked_sub(&TimeDelta::MAX);
}

Expected:

checked_sub should return None for this overflow case, or its docs should say it can panic on internal i64 arithmetic overflow.

The same area may be worth reviewing for checked_add, since it also performs unchecked self.secs + rhs.secs before range-checking through TimeDelta::new:

// src/time_delta.rs:381-390
pub const fn checked_add(&self, rhs: &TimeDelta) -> Option<TimeDelta> {
    let mut secs = self.secs + rhs.secs;
    let mut nanos = self.nanos + rhs.nanos;
    ...
    TimeDelta::new(secs, nanos as u32)
}

Example 3: malformed TZif data can panic during local timezone parsing

The TZif parser multiplies counts from the file header before reading sections:

// src/offset/local/tz_info/parser.rs:132-140
Ok(Self {
    time_size,
    transition_times: cursor.read_exact(header.transition_count * time_size)?,
    transition_types: cursor.read_exact(header.transition_count)?,
    local_time_types: cursor.read_exact(header.type_count * 6)?,
    names: cursor.read_exact(header.char_count)?,
    leap_seconds: cursor.read_exact(header.leap_count * (time_size + 4))?,
    std_walls: cursor.read_exact(header.std_wall_count)?,
    ut_locals: cursor.read_exact(header.ut_local_count)?,
    header,
})

With a malformed TZif header, header.type_count * 6 can overflow before the parser returns an error.

Reproducer

use chrono::Local;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

fn crafted_tzif_overflow_header() -> Vec<u8> {
    let mut bytes = Vec::with_capacity(44);
    bytes.extend_from_slice(b"TZif");
    bytes.push(0x00); // V1
    bytes.extend_from_slice(&[0u8; 15]);

    // ut_local_count, std_wall_count, leap_count, transition_count
    bytes.extend_from_slice(&0u32.to_be_bytes());
    bytes.extend_from_slice(&0u32.to_be_bytes());
    bytes.extend_from_slice(&0u32.to_be_bytes());
    bytes.extend_from_slice(&0u32.to_be_bytes());

    // type_count chosen to overflow `type_count * 6`
    bytes.extend_from_slice(&u32::MAX.to_be_bytes());

    // char_count must be non-zero to pass header validation
    bytes.extend_from_slice(&1u32.to_be_bytes());
    bytes
}

#[test]
#[should_panic]
fn malformed_tzif_header_can_panic() {
    let mut path: PathBuf = std::env::temp_dir();
    let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
    path.push(format!("chrono_tzif_overflow_{nanos}.tzif"));

    fs::write(&path, crafted_tzif_overflow_header()).unwrap();
    std::env::set_var("TZ", &path);

    let _ = Local::now();
}

Expected:

Malformed TZif data should be rejected with an error/fallback path instead of panicking during section-size arithmetic. Using checked multiplication before read_exact would make this path easier to contain.

Additional parser / formatting edge cases from the audit

These are similar robustness edges, but I would keep them secondary unless you want one larger tracking issue.

Parsed::to_naive_date week-date overflow

Parsed has public fields, and to_naive_date returns a parse result. A manually constructed extreme week value can overflow while resolving a week date:

// src/format/parsed.rs:1187
let ordinal = first_week_start + (week as i32 - 1) * 7 + weekday;

Reproducer:

use chrono::format::Parsed;
use chrono::Weekday;

#[test]
#[should_panic]
fn parsed_week_date_can_overflow() {
    let parsed = Parsed {
        year: Some(2024),
        week_from_sun: Some(i32::MAX as u32),
        weekday: Some(Weekday::Sun),
        ..Parsed::new()
    };

    let _ = parsed.to_naive_date();
}

Expected: return a parse error such as OUT_OF_RANGE / IMPOSSIBLE, or document that manually inconsistent Parsed values can panic.

Rounding near timestamp limits

duration_trunc / duration_round_up return Result, but they call original +/- TimeDelta::nanoseconds(...) after calculating a delta. Near timestamp boundaries, that addition/subtraction can panic through the underlying Add / Sub implementation instead of returning RoundingError.

Examples from the audit:

use chrono::{DurationRound, NaiveDate, TimeDelta};

#[test]
#[should_panic]
fn duration_trunc_can_panic_near_min_timestamp() {
    let dt = NaiveDate::from_ymd_opt(1677, 9, 21)
        .unwrap()
        .and_hms_nano_opt(0, 12, 43, 145_224_192)
        .unwrap();

    let _ = dt.duration_trunc(TimeDelta::nanoseconds(i64::MAX));
}
use chrono::{DateTime, DurationRound, TimeDelta};

#[test]
#[should_panic]
fn duration_round_up_can_panic_near_max_timestamp() {
    let dt = DateTime::from_timestamp_nanos(i64::MAX);
    let _ = dt.duration_round_up(TimeDelta::nanoseconds(2));
}

Expected: return RoundingError for out-of-range rounded results, or document the remaining panic path.

Mostly documented or deprecated panic paths I would not put in the main report

The audit also found many panics that are real, but mostly already match existing docs or deprecated API behavior:

  • DateTime<Tz> + TimeDelta, DateTime<Tz> - TimeDelta, and assign variants: docs say the operation panics if the resulting date is out of range and point to checked_add_signed / checked_sub_signed.
  • Date<Tz>::and_hms*, succ, pred, FixedOffset::east/west, TimeZone::ymd/yo/isoywd/timestamp/timestamp_millis: deprecated APIs with documented Panics sections and _opt replacements.
  • NaiveDate / NaiveDateTime operator overflow paths: same basic overflow behavior as the date/datetime operator impls.
  • TimeDelta operator impls like +, -, *, /, unary -: many of these are operator contract panics; the stronger point is the checked_* behavior above.
  • Datelike / Timelike default methods such as quarter, num_days_in_month, num_days_from_ce, and num_seconds_from_midnight: generated cases use custom trait implementations that return invalid month/year/hour values. Those are better framed as trait-invariant docs, not primary bugs.
  • Formatting / local-time conversion panics that depend on manually inconsistent datetime/timezone states should be reviewed case-by-case, but I would keep them out of the first issue unless maintainers ask for the full audit list.

Activity

  1. djc commented on Jun 23, 2026

    @djc
    Member
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions