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