From 8a37512a664badc17aaba416656af3311cf386bd Mon Sep 17 00:00:00 2001 From: Zane Wang Date: Tue, 14 Jul 2026 13:18:16 -0700 Subject: [PATCH] fix(sqlite): correct sub-second decoding of pre-epoch REAL datetimes Datetimes stored as a REAL Julian day number (e.g. via SQLite's julianday()) were decoded by splitting the UNIX timestamp with trunc()/fract().abs(). timestamp_opt() always adds the nanoseconds forward in time, so for values before 1970 this pushed the result up to ~2 seconds off and could move it onto the wrong side of the epoch (e.g. 1969-12-31 23:59:59.5 decoded as 1970-01-01 00:00:00.5). Round seconds toward negative infinity with floor() and take the sub-second remainder relative to that, so nanos is always a valid forward offset. Post-epoch values are unaffected. Adds unit tests for the pre- and post-epoch paths. --- sqlx-sqlite/src/types/chrono.rs | 68 +++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/sqlx-sqlite/src/types/chrono.rs b/sqlx-sqlite/src/types/chrono.rs index 8d987538d0..8805f0f56a 100644 --- a/sqlx-sqlite/src/types/chrono.rs +++ b/sqlx-sqlite/src/types/chrono.rs @@ -176,10 +176,16 @@ fn decode_datetime_from_float(value: f64) -> Option> { // We checked above if the value is infinite or NaN which could otherwise cause problems #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] { - let seconds = timestamp.trunc() as i64; - let nanos = (timestamp.fract() * 1E9).abs() as u32; - - Utc.fix().timestamp_opt(seconds, nanos).single() + // Split into whole seconds and a non-negative sub-second remainder. + // `timestamp_opt` always adds `nanos` *forward* in time, so we must round + // `seconds` toward negative infinity (not toward zero) and take the + // fraction relative to that. Using `trunc()`/`fract().abs()` here would + // push pre-epoch timestamps up to ~2 seconds off (and onto the wrong side + // of the epoch), since the fractional part is negative below zero. + let seconds = timestamp.floor(); + let nanos = ((timestamp - seconds) * 1E9) as u32; + + Utc.fix().timestamp_opt(seconds as i64, nanos).single() } } @@ -218,3 +224,57 @@ impl<'r> Decode<'r, Sqlite> for NaiveTime { Err(format!("invalid time: {value}").into()) } } + +#[cfg(test)] +mod tests { + use super::decode_datetime_from_float; + use chrono::{Offset, TimeZone, Utc}; + + // SQLite may store a datetime as a REAL holding a Julian day number, e.g. the + // result of `julianday(...)`. This mirrors that conversion so a test can feed + // `decode_datetime_from_float` the value for a known instant. + fn julian_day(unix_seconds: f64) -> f64 { + 2_440_587.5 + unix_seconds / 86_400.0 + } + + // Assert that the Julian day for `unix_seconds + subsec_nanos` decodes back to + // that same instant. The tolerance only absorbs f64 round-off in the + // round-trip (tens of microseconds), well below the errors under test. + #[track_caller] + fn assert_decodes_near(unix_seconds: i64, subsec_nanos: u32) { + let input = julian_day(unix_seconds as f64 + f64::from(subsec_nanos) / 1e9); + let decoded = decode_datetime_from_float(input).expect("valid Julian day should decode"); + let expected = Utc + .fix() + .timestamp_opt(unix_seconds, subsec_nanos) + .single() + .unwrap(); + + let diff_us = (decoded - expected) + .num_microseconds() + .expect("difference fits in microseconds") + .abs(); + assert!( + diff_us < 1_000, + "decoded {decoded} differs from expected {expected} by {diff_us} us", + ); + } + + // A Julian day landing before the UNIX epoch must keep its sub-second part in + // the correct direction. Before the fix these decoded up to ~2 seconds off, + // and could even cross to the wrong side of the epoch. + #[test] + fn decodes_pre_epoch_float_datetime() { + // 1969-12-31 23:59:59.500 UTC + assert_decodes_near(-1, 500_000_000); + // 1969-12-31 23:59:58.750 UTC + assert_decodes_near(-2, 750_000_000); + } + + // Post-epoch values were already correct; guard against a regression. + #[test] + fn decodes_post_epoch_float_datetime() { + // 1970-01-01 00:00:01.500 UTC + assert_decodes_near(1, 500_000_000); + } +}