Skip to main content

buffa_types/
timestamp_ext.rs

1//! Ergonomic helpers for [`google::protobuf::Timestamp`](crate::google::protobuf::Timestamp).
2
3use crate::google::protobuf::Timestamp;
4
5/// Maximum value of the `nanos` field per the protobuf `Timestamp` spec.
6///
7/// `Timestamp.nanos` is constrained to `[0, NANOS_MAX]`. Shared with the
8/// `timestamp_chrono` / `timestamp_jiff` conversions so the validation range
9/// cannot drift between files.
10pub(crate) const NANOS_MAX: i32 = 999_999_999;
11
12/// Errors that can occur when converting a [`Timestamp`] to a Rust time type.
13///
14/// Deliberately shared by the `std` conversion (`Timestamp` →
15/// `std::time::SystemTime`), the `chrono` conversion (`Timestamp` →
16/// `chrono::DateTime<Utc>`), and the `jiff` conversion (`Timestamp` →
17/// `jiff::Timestamp`): the failure modes map identically for all three
18/// targets, so a separate error enum per target would add API surface
19/// without adding information.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
21pub enum TimestampError {
22    /// The nanoseconds field is outside the valid range `[0, 999_999_999]`.
23    #[error("nanos field must be in [0, 999_999_999]")]
24    InvalidNanos,
25    /// The timestamp is too far in the past or future for the target type.
26    #[error("timestamp is out of range for the target type")]
27    Overflow,
28}
29
30impl Timestamp {
31    /// Create a [`Timestamp`] from a Unix epoch offset.
32    ///
33    /// `seconds` is the number of seconds since (or before, if negative) the
34    /// Unix epoch.  `nanos` must be in `[0, 999_999_999]`.
35    ///
36    /// # Panics
37    ///
38    /// Panics in debug mode if `nanos` is outside `[0, 999_999_999]`.
39    /// In release mode the value is stored as-is, producing an invalid
40    /// timestamp.  Use [`Timestamp::from_unix_checked`] for a checked
41    /// variant that returns `None` on invalid input.
42    pub fn from_unix(seconds: i64, nanos: i32) -> Self {
43        debug_assert!(
44            (0..=NANOS_MAX).contains(&nanos),
45            "nanos ({nanos}) must be in [0, 999_999_999]"
46        );
47        Self {
48            seconds,
49            nanos,
50            ..Default::default()
51        }
52    }
53
54    /// Create a [`Timestamp`] from a whole number of Unix seconds (nanoseconds = 0).
55    ///
56    /// This is a convenience shorthand for `Timestamp::from_unix(seconds, 0)`.
57    pub fn from_unix_secs(seconds: i64) -> Self {
58        Self {
59            seconds,
60            nanos: 0,
61            ..Default::default()
62        }
63    }
64
65    /// Create a [`Timestamp`] from a Unix epoch offset, returning `None` if
66    /// `nanos` is outside `[0, 999_999_999]`.
67    pub fn from_unix_checked(seconds: i64, nanos: i32) -> Option<Self> {
68        if (0..=NANOS_MAX).contains(&nanos) {
69            Some(Self {
70                seconds,
71                nanos,
72                ..Default::default()
73            })
74        } else {
75            None
76        }
77    }
78
79    /// Return the current wall-clock time as a [`Timestamp`].
80    ///
81    /// Requires the `std` feature.
82    #[cfg(feature = "std")]
83    pub fn now() -> Self {
84        std::time::SystemTime::now().into()
85    }
86}
87
88#[cfg(feature = "std")]
89impl TryFrom<Timestamp> for std::time::SystemTime {
90    type Error = TimestampError;
91
92    /// Convert a protobuf [`Timestamp`] to a [`std::time::SystemTime`].
93    ///
94    /// # Errors
95    ///
96    /// Returns [`TimestampError::InvalidNanos`] if `nanos` is outside
97    /// `[0, 999_999_999]`, or [`TimestampError::Overflow`] if the result
98    /// does not fit in a [`std::time::SystemTime`].
99    fn try_from(ts: Timestamp) -> Result<Self, Self::Error> {
100        if ts.nanos < 0 || ts.nanos > NANOS_MAX {
101            return Err(TimestampError::InvalidNanos);
102        }
103
104        if ts.seconds >= 0 {
105            let offset = std::time::Duration::new(ts.seconds as u64, ts.nanos as u32);
106            std::time::UNIX_EPOCH
107                .checked_add(offset)
108                .ok_or(TimestampError::Overflow)
109        } else {
110            // ts.seconds is negative: move backward from epoch, then forward by nanos.
111            //
112            // For example, ts.seconds = -2, ts.nanos = 500_000_000 represents
113            // -1.5 seconds from epoch (i.e. 1.5 s before epoch):
114            //   result = UNIX_EPOCH - 2s + 0.5s = UNIX_EPOCH - 1.5s
115            //
116            // unsigned_abs() avoids the overflow that `(-ts.seconds) as u64` would
117            // cause when ts.seconds == i64::MIN (which cannot be negated in i64).
118            let neg_secs = ts.seconds.unsigned_abs();
119            let base = std::time::UNIX_EPOCH
120                .checked_sub(std::time::Duration::from_secs(neg_secs))
121                .ok_or(TimestampError::Overflow)?;
122            if ts.nanos == 0 {
123                Ok(base)
124            } else {
125                base.checked_add(std::time::Duration::from_nanos(ts.nanos as u64))
126                    .ok_or(TimestampError::Overflow)
127            }
128        }
129    }
130}
131
132#[cfg(feature = "std")]
133impl From<std::time::SystemTime> for Timestamp {
134    /// Convert a [`std::time::SystemTime`] to a protobuf [`Timestamp`].
135    ///
136    /// Pre-epoch times (where `t < UNIX_EPOCH`) are represented with a
137    /// negative `seconds` field and a non-negative `nanos` field, following
138    /// the protobuf convention that `nanos` is always in `[0, 999_999_999]`.
139    ///
140    /// # Saturation
141    ///
142    /// Times more than ~292 billion years from the epoch (beyond `i64::MAX`
143    /// seconds) are saturated to `i64::MAX` seconds rather than wrapping,
144    /// which would produce a semantically incorrect negative timestamp.
145    fn from(t: std::time::SystemTime) -> Self {
146        match t.duration_since(std::time::UNIX_EPOCH) {
147            Ok(d) => Self {
148                // Saturate at i64::MAX to avoid wrapping for times far in the future.
149                seconds: d.as_secs().min(i64::MAX as u64) as i64,
150                nanos: d.subsec_nanos() as i32,
151                ..Default::default()
152            },
153            Err(e) => {
154                // `e.duration()` is how far `t` is *before* the epoch.
155                // We need: seconds = floor(t - epoch), nanos = (t - epoch) - seconds.
156                //
157                // Example: t is 1.5s before epoch → duration = 1.5s
158                //   floor = -2 (the largest integer ≤ -1.5)
159                //   nanos = -1.5 - (-2) = 0.5s = 500_000_000 ns
160                //
161                // In terms of the subtraction duration `dur = e.duration()`:
162                //   If dur.subsec_nanos() == 0:
163                //     seconds = -(dur.as_secs() as i64), nanos = 0
164                //   Else:
165                //     seconds = -(dur.as_secs() as i64 + 1)
166                //     nanos = 1_000_000_000 - dur.subsec_nanos()
167                //
168                // Saturate at i64::MAX to avoid wrapping for extreme pre-epoch times.
169                let dur = e.duration();
170                if dur.subsec_nanos() == 0 {
171                    let secs = dur.as_secs().min(i64::MAX as u64) as i64;
172                    Self {
173                        seconds: -secs,
174                        nanos: 0,
175                        ..Default::default()
176                    }
177                } else {
178                    // saturating_add avoids overflow when dur.as_secs() == u64::MAX,
179                    // then clamp to i64::MAX before converting.
180                    let neg_secs = dur.as_secs().saturating_add(1).min(i64::MAX as u64) as i64;
181                    Self {
182                        seconds: -neg_secs,
183                        nanos: (1_000_000_000u32 - dur.subsec_nanos()) as i32,
184                        ..Default::default()
185                    }
186                }
187            }
188        }
189    }
190}
191
192// ── RFC 3339 formatting ──────────────────────────────────────────────────────
193//
194// The shared formatting and parsing primitives live in
195// `buffa::json_helpers::wkt`. Both this typed serde impl and `buffa-descriptor`'s
196// reflective JSON codec call into the same code, so the two paths can't drift
197// on edge cases the conformance suite exercises. The functions below are thin
198// adapters that preserve the `Option`-returning private API the test suite
199// targets.
200
201#[cfg(feature = "json")]
202use buffa::json_helpers::wkt::{MAX_TIMESTAMP_SECS, MIN_TIMESTAMP_SECS};
203// The civil-calendar helpers are exercised directly by the test module.
204#[cfg(all(test, feature = "json"))]
205use buffa::json_helpers::wkt::{date_to_days, days_to_date};
206
207#[cfg(feature = "json")]
208fn timestamp_to_rfc3339(secs: i64, nanos: i32) -> alloc::string::String {
209    // The serde `Serialize` impl validates `seconds` and `nanos` bounds
210    // before calling this; `expect` documents the invariant.
211    buffa::json_helpers::wkt::fmt_timestamp(secs, nanos)
212        .expect("Timestamp validated before formatting")
213}
214
215#[cfg(feature = "json")]
216fn parse_rfc3339(s: &str) -> Option<(i64, i32)> {
217    buffa::json_helpers::wkt::parse_timestamp(s).ok()
218}
219
220// ── serde impls ──────────────────────────────────────────────────────────────
221
222#[cfg(feature = "json")]
223impl serde::Serialize for Timestamp {
224    /// Serializes as an RFC 3339 string (e.g. `"2021-01-01T00:00:00Z"`).
225    ///
226    /// # Errors
227    ///
228    /// Returns a serialization error if `nanos` is outside `[0, 999_999_999]`
229    /// or if `seconds` is outside the proto spec range (years 0001–9999).
230    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
231        use alloc::format;
232        if !(0..=NANOS_MAX).contains(&self.nanos) {
233            return Err(serde::ser::Error::custom(format!(
234                "invalid Timestamp: nanos {} is outside [0, {NANOS_MAX}]",
235                self.nanos
236            )));
237        }
238        if !(MIN_TIMESTAMP_SECS..=MAX_TIMESTAMP_SECS).contains(&self.seconds) {
239            return Err(serde::ser::Error::custom(format!(
240                "invalid Timestamp: seconds {} is outside [{}, {}]",
241                self.seconds, MIN_TIMESTAMP_SECS, MAX_TIMESTAMP_SECS
242            )));
243        }
244        s.serialize_str(&timestamp_to_rfc3339(self.seconds, self.nanos))
245    }
246}
247
248#[cfg(feature = "json")]
249impl<'de> serde::Deserialize<'de> for Timestamp {
250    /// Deserializes from an RFC 3339 string.
251    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
252        use alloc::{format, string::String};
253        let s: String = serde::Deserialize::deserialize(d)?;
254        let (secs, nanos) = parse_rfc3339(&s)
255            .ok_or_else(|| serde::de::Error::custom(format!("invalid RFC 3339 timestamp: {s}")))?;
256        Ok(Self {
257            seconds: secs,
258            nanos,
259            ..Default::default()
260        })
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn from_unix_secs_sets_nanos_to_zero() {
270        let ts = Timestamp::from_unix_secs(1_700_000_000);
271        assert_eq!(ts.seconds, 1_700_000_000);
272        assert_eq!(ts.nanos, 0);
273    }
274
275    #[test]
276    fn from_unix_secs_zero() {
277        let ts = Timestamp::from_unix_secs(0);
278        assert_eq!(ts.seconds, 0);
279        assert_eq!(ts.nanos, 0);
280    }
281
282    #[test]
283    fn from_unix_secs_negative() {
284        let ts = Timestamp::from_unix_secs(-1);
285        assert_eq!(ts.seconds, -1);
286        assert_eq!(ts.nanos, 0);
287    }
288
289    #[test]
290    fn from_unix_secs_i64_min() {
291        let ts = Timestamp::from_unix_secs(i64::MIN);
292        assert_eq!(ts.seconds, i64::MIN);
293        assert_eq!(ts.nanos, 0);
294    }
295
296    #[test]
297    fn from_unix_secs_i64_max() {
298        let ts = Timestamp::from_unix_secs(i64::MAX);
299        assert_eq!(ts.seconds, i64::MAX);
300        assert_eq!(ts.nanos, 0);
301    }
302
303    #[test]
304    fn from_unix_basic() {
305        let ts = Timestamp::from_unix(1_000_000_000, 500_000_000);
306        assert_eq!(ts.seconds, 1_000_000_000);
307        assert_eq!(ts.nanos, 500_000_000);
308    }
309
310    #[test]
311    fn from_unix_zero() {
312        let ts = Timestamp::from_unix(0, 0);
313        assert_eq!(ts.seconds, 0);
314        assert_eq!(ts.nanos, 0);
315    }
316
317    #[test]
318    fn from_unix_checked_valid() {
319        assert!(Timestamp::from_unix_checked(0, 0).is_some());
320        assert!(Timestamp::from_unix_checked(-100, 999_999_999).is_some());
321    }
322
323    #[test]
324    fn from_unix_checked_invalid_nanos() {
325        assert!(Timestamp::from_unix_checked(0, -1).is_none());
326        assert!(Timestamp::from_unix_checked(0, 1_000_000_000).is_none());
327    }
328
329    #[cfg(feature = "std")]
330    #[test]
331    fn systemtime_roundtrip_post_epoch() {
332        let ts = Timestamp::from_unix(1_700_000_000, 123_456_789);
333        let st: std::time::SystemTime = ts.clone().try_into().unwrap();
334        let ts2: Timestamp = st.into();
335        assert_eq!(ts, ts2);
336    }
337
338    #[cfg(feature = "std")]
339    #[test]
340    fn systemtime_roundtrip_pre_epoch() {
341        // -1.5 seconds before epoch: seconds = -2, nanos = 500_000_000
342        let ts = Timestamp::from_unix(-2, 500_000_000);
343        let st: std::time::SystemTime = ts.clone().try_into().unwrap();
344        let ts2: Timestamp = st.into();
345        assert_eq!(ts, ts2);
346    }
347
348    #[cfg(feature = "std")]
349    #[test]
350    fn systemtime_roundtrip_exact_pre_epoch() {
351        // Exactly 2 seconds before epoch.
352        let ts = Timestamp::from_unix(-2, 0);
353        let st: std::time::SystemTime = ts.clone().try_into().unwrap();
354        let ts2: Timestamp = st.into();
355        assert_eq!(ts, ts2);
356    }
357
358    #[cfg(feature = "std")]
359    #[test]
360    fn systemtime_roundtrip_epoch() {
361        let ts = Timestamp::from_unix(0, 0);
362        let st: std::time::SystemTime = ts.clone().try_into().unwrap();
363        let ts2: Timestamp = st.into();
364        assert_eq!(ts, ts2);
365    }
366
367    #[cfg(feature = "std")]
368    #[test]
369    fn invalid_nanos_rejected() {
370        let ts = Timestamp {
371            seconds: 0,
372            nanos: -1,
373            ..Default::default()
374        };
375        let result: Result<std::time::SystemTime, _> = ts.try_into();
376        assert_eq!(result, Err(TimestampError::InvalidNanos));
377
378        let ts2 = Timestamp {
379            seconds: 0,
380            nanos: 1_000_000_000,
381            ..Default::default()
382        };
383        let result2: Result<std::time::SystemTime, _> = ts2.try_into();
384        assert_eq!(result2, Err(TimestampError::InvalidNanos));
385    }
386
387    #[cfg(feature = "std")]
388    #[test]
389    fn i64_min_seconds_does_not_panic() {
390        // i64::MIN cannot be negated in i64; unsigned_abs() must be used.
391        let ts = Timestamp {
392            seconds: i64::MIN,
393            nanos: 0,
394            ..Default::default()
395        };
396        // The conversion should either succeed or return Overflow, never panic.
397        let _: Result<std::time::SystemTime, _> = ts.try_into();
398    }
399
400    #[cfg(feature = "std")]
401    #[test]
402    fn now_is_positive() {
403        let ts = Timestamp::now();
404        assert!(ts.seconds > 0, "current time should be after Unix epoch");
405    }
406
407    #[test]
408    fn timestamp_view_round_trip() {
409        use crate::google::protobuf::__buffa::view::TimestampView;
410        use crate::google::protobuf::Timestamp;
411        use buffa::{Message, MessageView};
412
413        let ts = Timestamp {
414            seconds: 1_700_000_000,
415            nanos: 123_456_789,
416            ..Default::default()
417        };
418        let bytes = ts.encode_to_vec();
419        let view = TimestampView::decode_view(&bytes).expect("decode_view");
420        assert_eq!(view.seconds, ts.seconds);
421        assert_eq!(view.nanos, ts.nanos);
422
423        let owned = view.to_owned_message().unwrap();
424        assert_eq!(owned, ts);
425    }
426
427    #[cfg(feature = "json")]
428    mod serde_tests {
429        use super::*;
430
431        // ---- RFC 3339 helper unit tests -----------------------------------
432
433        #[test]
434        fn days_to_date_epoch() {
435            assert_eq!(days_to_date(0), (1970, 1, 1));
436        }
437
438        #[test]
439        fn days_to_date_known_date() {
440            // 2021-01-01: days since epoch = 18628
441            assert_eq!(days_to_date(18628), (2021, 1, 1));
442        }
443
444        #[test]
445        fn date_to_days_roundtrip() {
446            let (y, m, d) = days_to_date(18628);
447            assert_eq!(date_to_days(y, m, d), Some(18628));
448        }
449
450        #[test]
451        fn date_to_days_invalid_month() {
452            assert_eq!(date_to_days(2021, 13, 1), None);
453            assert_eq!(date_to_days(2021, 0, 1), None);
454        }
455
456        #[test]
457        fn rfc3339_epoch() {
458            assert_eq!(timestamp_to_rfc3339(0, 0), "1970-01-01T00:00:00Z");
459        }
460
461        #[test]
462        fn rfc3339_half_second() {
463            assert_eq!(
464                timestamp_to_rfc3339(0, 500_000_000),
465                "1970-01-01T00:00:00.500Z"
466            );
467        }
468
469        #[test]
470        fn rfc3339_one_nanosecond() {
471            assert_eq!(timestamp_to_rfc3339(0, 1), "1970-01-01T00:00:00.000000001Z");
472        }
473
474        #[test]
475        fn parse_epoch() {
476            assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some((0, 0)));
477        }
478
479        #[test]
480        fn parse_with_fractional_seconds() {
481            assert_eq!(
482                parse_rfc3339("1970-01-01T00:00:00.5Z"),
483                Some((0, 500_000_000))
484            );
485        }
486
487        #[test]
488        fn parse_with_positive_offset() {
489            // +05:00 means local is 5h ahead, so UTC = local - 5h
490            assert_eq!(parse_rfc3339("1970-01-01T05:00:00+05:00"), Some((0, 0)));
491        }
492
493        #[test]
494        fn parse_invalid() {
495            assert_eq!(parse_rfc3339("not-a-date"), None);
496            assert_eq!(parse_rfc3339("1970-01-01T00:00:00"), None); // missing tz
497        }
498
499        // ---- serde roundtrips ---------------------------------------------
500
501        #[test]
502        fn timestamp_epoch_roundtrip() {
503            let ts = Timestamp::from_unix(0, 0);
504            let json = serde_json::to_string(&ts).unwrap();
505            assert_eq!(json, r#""1970-01-01T00:00:00Z""#);
506            let back: Timestamp = serde_json::from_str(&json).unwrap();
507            assert_eq!(back.seconds, 0);
508            assert_eq!(back.nanos, 0);
509        }
510
511        #[test]
512        fn timestamp_with_nanos_roundtrip() {
513            let ts = Timestamp::from_unix(1_000_000_000, 500_000_000);
514            let json = serde_json::to_string(&ts).unwrap();
515            let back: Timestamp = serde_json::from_str(&json).unwrap();
516            assert_eq!(back.seconds, ts.seconds);
517            assert_eq!(back.nanos, ts.nanos);
518        }
519
520        #[test]
521        fn timestamp_pre_epoch_roundtrip() {
522            // -1.5 seconds before epoch: seconds = -2, nanos = 500_000_000
523            let ts = Timestamp::from_unix(-2, 500_000_000);
524            let json = serde_json::to_string(&ts).unwrap();
525            let back: Timestamp = serde_json::from_str(&json).unwrap();
526            assert_eq!(back.seconds, ts.seconds);
527            assert_eq!(back.nanos, ts.nanos);
528        }
529
530        #[test]
531        fn timestamp_invalid_string_is_error() {
532            let result: Result<Timestamp, _> = serde_json::from_str(r#""not-a-date""#);
533            assert!(result.is_err());
534        }
535
536        #[test]
537        fn timestamp_invalid_nanos_is_serialize_error() {
538            let ts = Timestamp {
539                seconds: 0,
540                nanos: -1,
541                ..Default::default()
542            };
543            let result = serde_json::to_string(&ts);
544            assert!(result.is_err(), "negative nanos must fail serialization");
545        }
546
547        #[test]
548        fn parse_lowercase_separators_rejected() {
549            // Proto3 JSON spec requires uppercase 'T' and 'Z'.
550            assert_eq!(parse_rfc3339("1970-01-01T00:00:00z"), None);
551            assert_eq!(parse_rfc3339("1970-01-01t00:00:00Z"), None);
552            assert_eq!(parse_rfc3339("1970-01-01t00:00:00z"), None);
553        }
554
555        #[test]
556        fn parse_date_to_days_rejects_feb_30() {
557            // "Feb 30" is not a real date; parse_rfc3339 must return None.
558            assert_eq!(parse_rfc3339("2021-02-30T00:00:00Z"), None);
559        }
560
561        #[test]
562        fn parse_time_component_range_rejected() {
563            // Hour, minute, second must be in valid ranges.
564            assert_eq!(parse_rfc3339("2021-01-01T24:00:00Z"), None, "hour 24");
565            assert_eq!(parse_rfc3339("2021-01-01T25:00:00Z"), None, "hour 25");
566            assert_eq!(parse_rfc3339("2021-01-01T00:60:00Z"), None, "min 60");
567            assert_eq!(parse_rfc3339("2021-01-01T00:99:00Z"), None, "min 99");
568            assert_eq!(parse_rfc3339("2021-01-01T00:00:60Z"), None, "sec 60 (leap)");
569            assert_eq!(parse_rfc3339("2021-01-01T00:00:99Z"), None, "sec 99");
570            // Valid boundaries.
571            assert!(parse_rfc3339("2021-01-01T23:59:59Z").is_some());
572            assert!(parse_rfc3339("2021-01-01T00:00:00Z").is_some());
573        }
574
575        #[test]
576        fn parse_offset_range_rejected() {
577            assert_eq!(parse_rfc3339("2021-01-01T00:00:00+24:00"), None, "oh 24");
578            assert_eq!(parse_rfc3339("2021-01-01T00:00:00+99:00"), None, "oh 99");
579            assert_eq!(parse_rfc3339("2021-01-01T00:00:00+00:60"), None, "om 60");
580            assert_eq!(parse_rfc3339("2021-01-01T00:00:00+99:99"), None, "both");
581            // Valid boundaries.
582            assert!(parse_rfc3339("2021-01-01T00:00:00+23:59").is_some());
583            assert!(parse_rfc3339("2021-01-01T00:00:00-23:59").is_some());
584        }
585
586        #[test]
587        fn parse_separator_chars_rejected() {
588            // Hyphens in date, colons in time, colon in offset are required.
589            assert_eq!(parse_rfc3339("2021X01-01T00:00:00Z"), None, "date[4]");
590            assert_eq!(parse_rfc3339("2021-01X01T00:00:00Z"), None, "date[7]");
591            assert_eq!(parse_rfc3339("2021-01-01T00X00:00Z"), None, "time[2]");
592            assert_eq!(parse_rfc3339("2021-01-01T00:00X00Z"), None, "time[5]");
593            assert_eq!(parse_rfc3339("2021-01-01T00:00:00+05X30"), None, "off");
594            // All separators wrong at once.
595            assert_eq!(parse_rfc3339("2021X01X01T00X00X00Z"), None);
596        }
597
598        #[test]
599        fn parse_fractional_seconds_rejects_non_digits() {
600            // Regression (fuzzer-found): i32::parse accepts '-' and '+',
601            // which previously allowed "T23:59:59.-3Z" → nanos = -30_000_000.
602            assert_eq!(parse_rfc3339("1970-01-01T00:00:00.-3Z"), None, "minus");
603            assert_eq!(parse_rfc3339("1970-01-01T00:00:00.+3Z"), None, "plus");
604            assert_eq!(parse_rfc3339("1970-01-01T00:00:00.3aZ"), None, "alpha");
605            assert_eq!(parse_rfc3339("1970-01-01T00:00:00. Z"), None, "space");
606            // Edge: 9999-12-31T23:59:59.-3Z — the fuzzer's original crash input.
607            assert_eq!(parse_rfc3339("9999-12-31T23:59:59.-3Z"), None);
608            // Valid digits still work.
609            assert_eq!(
610                parse_rfc3339("1970-01-01T00:00:00.5Z"),
611                Some((0, 500_000_000))
612            );
613            assert_eq!(
614                parse_rfc3339("1970-01-01T00:00:00.000000001Z"),
615                Some((0, 1))
616            );
617        }
618
619        #[test]
620        fn parse_offset_pushes_past_boundary_rejected() {
621            // Year is 9999 (passes pre-offset check), but -23:59 offset means
622            // UTC is in year 10000 — must be rejected per proto Timestamp range.
623            assert_eq!(parse_rfc3339("9999-12-31T23:59:59-23:59"), None);
624            // Year is 0001 (passes), but +23:59 offset means UTC is in year 0.
625            assert_eq!(parse_rfc3339("0001-01-01T00:00:00+23:59"), None);
626            // Boundary values that just fit are OK.
627            assert_eq!(
628                parse_rfc3339("9999-12-31T23:59:59Z"),
629                Some((MAX_TIMESTAMP_SECS, 0))
630            );
631            assert_eq!(
632                parse_rfc3339("0001-01-01T00:00:00Z"),
633                Some((MIN_TIMESTAMP_SECS, 0))
634            );
635        }
636    }
637}