Skip to main content

waitprims_core/
rfc3339.rs

1//! Fail-closed RFC3339 profile used by `contract: agent-wait/v0`.
2//!
3//! Accepted: extended date/time with seconds and `Z`/`z` or a
4//! colon-separated numeric offset. Leap seconds are rejected, not clamped.
5//! Equivalent offsets compare as the same instant. Fractional seconds are
6//! padded and truncated to six digits for comparison, matching the pinned
7//! Crucible oracle.
8//!
9//! Construction and comparison go through [`Timestamp`]. The `time` crate
10//! is an implementation ingredient, not the public gate.
11
12use std::cmp::Ordering;
13use std::fmt;
14
15use serde::{Deserialize, Deserializer, Serialize, Serializer};
16use time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};
17
18use crate::error::{NormativeReason, ValidationError};
19
20/// Contract timestamp on the pinned RFC3339 profile.
21///
22/// Equality and ordering use the normalized UTC instant, so equivalent
23/// offsets compare equal. The original wire spelling is preserved for
24/// serialization.
25#[derive(Clone)]
26pub struct Timestamp {
27    raw: String,
28    instant: OffsetDateTime,
29}
30
31impl Timestamp {
32    /// Parse a fail-closed RFC3339 timestamp.
33    pub fn parse(text: &str) -> Result<Self, ValidationError> {
34        let instant = parse_instant(text).map_err(|_| {
35            ValidationError::normative(
36                "/timestamp",
37                "rfc3339_profile",
38                NormativeReason::UnparseableTimestamp,
39            )
40        })?;
41        Ok(Self {
42            raw: text.to_string(),
43            instant,
44        })
45    }
46
47    /// Borrow the original wire spelling.
48    pub fn as_str(&self) -> &str {
49        &self.raw
50    }
51
52    /// Compare two timestamps after normalizing to UTC instants.
53    ///
54    /// Returns -1, 0, or 1.
55    pub fn compare(&self, other: &Self) -> i8 {
56        match self.instant.cmp(&other.instant) {
57            Ordering::Less => -1,
58            Ordering::Equal => 0,
59            Ordering::Greater => 1,
60        }
61    }
62
63    /// Add `duration`, saturating at this instant if the sum is unrepresentable.
64    pub fn saturating_add(&self, duration: std::time::Duration) -> Self {
65        let extra = time::Duration::new(duration.as_secs() as i64, duration.subsec_nanos() as i32);
66        match self.instant.checked_add(extra) {
67            Some(instant) => Self {
68                raw: format_utc(instant),
69                instant,
70            },
71            None => self.clone(),
72        }
73    }
74
75    /// Elapsed time from this instant until `later`, or zero if `later` is not after.
76    pub fn duration_until(&self, later: &Self) -> std::time::Duration {
77        if later.instant <= self.instant {
78            return std::time::Duration::ZERO;
79        }
80        let delta = later.instant - self.instant;
81        let secs = delta.whole_seconds().max(0) as u64;
82        let nanos = delta.subsec_nanoseconds();
83        let nanos = if nanos < 0 { 0 } else { nanos as u32 };
84        std::time::Duration::new(secs, nanos)
85    }
86}
87
88fn format_utc(instant: OffsetDateTime) -> String {
89    let utc = instant.to_offset(UtcOffset::UTC);
90    let date = utc.date();
91    let t = utc.time();
92    let ns = t.nanosecond();
93    if ns == 0 {
94        format!(
95            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
96            date.year(),
97            u8::from(date.month()),
98            date.day(),
99            t.hour(),
100            t.minute(),
101            t.second()
102        )
103    } else {
104        format!(
105            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
106            date.year(),
107            u8::from(date.month()),
108            date.day(),
109            t.hour(),
110            t.minute(),
111            t.second(),
112            ns / 1000
113        )
114    }
115}
116
117impl PartialEq for Timestamp {
118    fn eq(&self, other: &Self) -> bool {
119        self.instant == other.instant
120    }
121}
122
123impl Eq for Timestamp {}
124
125impl PartialOrd for Timestamp {
126    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
127        Some(self.cmp(other))
128    }
129}
130
131impl Ord for Timestamp {
132    fn cmp(&self, other: &Self) -> Ordering {
133        self.instant.cmp(&other.instant)
134    }
135}
136
137impl fmt::Debug for Timestamp {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        f.debug_struct("Timestamp")
140            .field("profile", &self.raw)
141            .finish()
142    }
143}
144
145impl fmt::Display for Timestamp {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.write_str(&self.raw)
148    }
149}
150
151impl Serialize for Timestamp {
152    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
153        serializer.serialize_str(&self.raw)
154    }
155}
156
157impl<'de> Deserialize<'de> for Timestamp {
158    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
159        let raw = String::deserialize(deserializer)?;
160        Timestamp::parse(&raw).map_err(serde::de::Error::custom)
161    }
162}
163
164/// Parse a fail-closed RFC3339 timestamp.
165pub fn parse(text: &str) -> Result<Timestamp, ValidationError> {
166    Timestamp::parse(text)
167}
168
169/// Compare two timestamps after normalizing to UTC instants.
170///
171/// Returns -1, 0, or 1.
172pub fn compare(left: &str, right: &str) -> Result<i8, ValidationError> {
173    Ok(Timestamp::parse(left)?.compare(&Timestamp::parse(right)?))
174}
175
176fn parse_instant(text: &str) -> Result<OffsetDateTime, ()> {
177    if text.is_empty() || text != text.trim() {
178        return Err(());
179    }
180
181    let bytes = text.as_bytes();
182    // YYYY-MM-DDTHH:MM:SS + timezone, minimum 20 chars (…SSZ).
183    if bytes.len() < 20 {
184        return Err(());
185    }
186
187    let year = parse_n_digits(&bytes[0..4], 4)?;
188    expect(bytes, 4, b'-')?;
189    let month = parse_n_digits(&bytes[5..7], 2)?;
190    expect(bytes, 7, b'-')?;
191    let day = parse_n_digits(&bytes[8..10], 2)?;
192    if bytes[10] != b'T' && bytes[10] != b't' {
193        return Err(());
194    }
195    let hour = parse_n_digits(&bytes[11..13], 2)?;
196    expect(bytes, 13, b':')?;
197    let minute = parse_n_digits(&bytes[14..16], 2)?;
198    expect(bytes, 16, b':')?;
199    let second = parse_n_digits(&bytes[17..19], 2)?;
200
201    if hour > 23 || minute > 59 {
202        return Err(());
203    }
204    if second == 60 {
205        // Leap second: reject, do not clamp.
206        return Err(());
207    }
208    if second > 60 {
209        return Err(());
210    }
211
212    let mut idx = 19;
213    let mut nanosecond = 0u32;
214    if idx < bytes.len() && bytes[idx] == b'.' {
215        idx += 1;
216        let start = idx;
217        while idx < bytes.len() && bytes[idx].is_ascii_digit() {
218            idx += 1;
219        }
220        if idx == start {
221            return Err(());
222        }
223        // Pinned oracle (crucible rfc3339-instant.py at f191295) pads and
224        // truncates the fraction to six digits (microseconds). Extra digits
225        // are discarded, not rounded.
226        let digits = &text[start..idx];
227        let mut padded = digits.to_string();
228        while padded.len() < 6 {
229            padded.push('0');
230        }
231        if padded.len() > 6 {
232            padded.truncate(6);
233        }
234        let microsecond: u32 = padded.parse().map_err(|_| ())?;
235        nanosecond = microsecond.checked_mul(1000).ok_or(())?;
236    }
237
238    if idx >= bytes.len() {
239        return Err(());
240    }
241
242    let offset = if bytes[idx] == b'Z' || bytes[idx] == b'z' {
243        if idx + 1 != bytes.len() {
244            return Err(());
245        }
246        UtcOffset::UTC
247    } else if bytes[idx] == b'+' || bytes[idx] == b'-' {
248        let sign = if bytes[idx] == b'+' { 1i8 } else { -1i8 };
249        idx += 1;
250        if idx + 5 != bytes.len() {
251            return Err(());
252        }
253        let off_h = parse_n_digits(&bytes[idx..idx + 2], 2)?;
254        if bytes[idx + 2] != b':' {
255            return Err(());
256        }
257        let off_m = parse_n_digits(&bytes[idx + 3..idx + 5], 2)?;
258        if off_h > 23 || off_m > 59 {
259            return Err(());
260        }
261        UtcOffset::from_hms(sign * off_h as i8, sign * off_m as i8, 0).map_err(|_| ())?
262    } else {
263        return Err(());
264    };
265
266    let month = Month::try_from(month as u8).map_err(|_| ())?;
267    let date = Date::from_calendar_date(year as i32, month, day as u8).map_err(|_| ())?;
268    let time =
269        Time::from_hms_nano(hour as u8, minute as u8, second as u8, nanosecond).map_err(|_| ())?;
270    Ok(PrimitiveDateTime::new(date, time)
271        .assume_offset(offset)
272        .to_offset(UtcOffset::UTC))
273}
274
275fn expect(bytes: &[u8], idx: usize, want: u8) -> Result<(), ()> {
276    if idx < bytes.len() && bytes[idx] == want {
277        Ok(())
278    } else {
279        Err(())
280    }
281}
282
283fn parse_n_digits(slice: &[u8], n: usize) -> Result<u32, ()> {
284    if slice.len() != n || !slice.iter().all(|b| b.is_ascii_digit()) {
285        return Err(());
286    }
287    let mut value = 0u32;
288    for b in slice {
289        value = value * 10 + u32::from(b - b'0');
290    }
291    Ok(value)
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn reject(label: &str, text: &str) {
299        assert!(
300            Timestamp::parse(text).is_err(),
301            "{label}: expected reject of timestamp"
302        );
303    }
304
305    fn accept(label: &str, text: &str) {
306        assert!(
307            Timestamp::parse(text).is_ok(),
308            "{label}: expected accept of timestamp"
309        );
310    }
311
312    #[test]
313    fn profile_self_test() {
314        reject("empty", "");
315        reject("whitespace", "   ");
316        reject("garbage", "not-a-timestamp");
317        reject("date-only", "2026-08-15");
318        reject("naive", "2026-08-15T17:00:00");
319        reject("space-separator", "2026-08-15 17:00:00Z");
320        reject("basic-date-time", "20260815T170000Z");
321        reject("week-date", "2026-W33-6T17:00:00Z");
322        reject("ordinal-date", "2026-227T17:00:00Z");
323        reject("missing-seconds", "2026-08-15T17:00Z");
324        reject("offset-without-colon", "2026-08-15T17:00:00+0000");
325        reject("comma-fraction", "2026-08-15T17:00:00,123Z");
326        reject("invalid-calendar", "2026-02-30T17:00:00Z");
327        reject("hour-24", "2026-08-15T24:00:00Z");
328        reject("leap-second-utc", "2016-12-31T23:59:60Z");
329        reject("leap-second-offset", "2016-12-31T23:59:60+00:00");
330
331        accept("zulu", "2026-08-15T17:00:00Z");
332        accept("lowercase", "2026-08-15t17:00:00z");
333        accept("numeric-offset", "2026-08-15T17:00:00+00:00");
334        accept("negative-offset", "2026-08-15T13:00:00-04:00");
335        accept("fractional-seconds", "2026-08-15T17:00:00.123Z");
336
337        assert_eq!(
338            compare("1970-01-01T00:00:00Z", "1970-01-01T00:00:00+00:00").unwrap(),
339            0
340        );
341        assert_eq!(
342            compare("1970-01-01T01:00:00+01:00", "1970-01-01T00:00:00Z").unwrap(),
343            0
344        );
345        assert_eq!(
346            compare("2026-08-15T17:00:00+00:00", "2026-08-15T17:00:00Z").unwrap(),
347            0
348        );
349        assert_eq!(
350            compare("1970-01-01T00:00:00Z", "1970-01-01T00:00:01Z").unwrap(),
351            -1
352        );
353        assert_eq!(
354            compare("1970-01-01T00:00:01Z", "1970-01-01T00:00:00Z").unwrap(),
355            1
356        );
357        assert_eq!(
358            compare("2026-08-15T17:00:00.100Z", "2026-08-15T17:00:00Z").unwrap(),
359            1
360        );
361        assert_eq!(
362            compare("2026-08-15T16:00:10Z", "2026-08-15T17:00:00+01:00").unwrap(),
363            1
364        );
365    }
366
367    #[test]
368    fn equivalent_offsets_are_equal_on_the_newtype() {
369        let z = Timestamp::parse("2026-08-15T17:00:00Z").unwrap();
370        let offset = Timestamp::parse("2026-08-15T17:00:00+00:00").unwrap();
371        assert_eq!(z, offset);
372        assert_eq!(z.as_str(), "2026-08-15T17:00:00Z");
373        assert_eq!(offset.as_str(), "2026-08-15T17:00:00+00:00");
374    }
375
376    #[test]
377    fn seven_digit_fractions_compare_equal_after_six_digit_truncate() {
378        assert_eq!(
379            compare(
380                "2026-08-15T17:00:00.1234567Z",
381                "2026-08-15T17:00:00.1234568Z"
382            )
383            .unwrap(),
384            0
385        );
386    }
387
388    #[test]
389    fn fraction_compare_matches_pinned_six_digit_oracle() {
390        // Equal after pad/truncate to six digits.
391        assert_eq!(
392            compare("2026-08-15T17:00:00.123Z", "2026-08-15T17:00:00.123000Z").unwrap(),
393            0
394        );
395        assert_eq!(
396            compare(
397                "2026-08-15T17:00:00.1234569Z",
398                "2026-08-15T17:00:00.1234560Z"
399            )
400            .unwrap(),
401            0
402        );
403        // Unequal at the sixth digit.
404        assert_eq!(
405            compare("2026-08-15T17:00:00.123456Z", "2026-08-15T17:00:00.123457Z").unwrap(),
406            -1
407        );
408        assert_eq!(
409            compare(
410                "2026-08-15T17:00:00.1234559Z",
411                "2026-08-15T17:00:00.1234560Z"
412            )
413            .unwrap(),
414            -1
415        );
416        assert_eq!(
417            compare("2026-08-15T17:00:00.100Z", "2026-08-15T17:00:00Z").unwrap(),
418            1
419        );
420    }
421
422    #[test]
423    fn rfc3339_six_digit_compare_pads_truncates_and_normalizes_offsets() {
424        // Pad to six digits.
425        assert_eq!(
426            compare("2026-08-15T17:00:00.1Z", "2026-08-15T17:00:00.100000Z").unwrap(),
427            0
428        );
429        assert_eq!(
430            compare("2026-08-15T17:00:00.12Z", "2026-08-15T17:00:00.120000Z").unwrap(),
431            0
432        );
433        // Truncate past six digits; no rounding.
434        assert_eq!(
435            compare(
436                "2026-08-15T17:00:00.123456999Z",
437                "2026-08-15T17:00:00.123456000Z"
438            )
439            .unwrap(),
440            0
441        );
442        assert_eq!(
443            compare(
444                "2026-08-15T17:00:00.123456000Z",
445                "2026-08-15T17:00:00.123457000Z"
446            )
447            .unwrap(),
448            -1
449        );
450        // Offset + fraction normalize to the same UTC instant.
451        assert_eq!(
452            compare(
453                "2026-08-15T17:00:00.123456Z",
454                "2026-08-15T18:00:00.123456+01:00"
455            )
456            .unwrap(),
457            0
458        );
459        let left = Timestamp::parse("2026-08-15T17:00:00.1234567Z").unwrap();
460        let right = Timestamp::parse("2026-08-15T17:00:00.1234568Z").unwrap();
461        assert_eq!(left.compare(&right), 0);
462        assert_eq!(left, right);
463    }
464
465    #[test]
466    fn one_millisecond_is_a_distinct_logical_instant() {
467        // Contract timestamps stay distinct. FakeClock is logical; this is
468        // not a wall-sleep uniqueness key.
469        let a = Timestamp::parse("2026-08-15T16:05:00.001000Z").unwrap();
470        let b = Timestamp::parse("2026-08-15T16:05:00.002000Z").unwrap();
471        assert_ne!(a, b);
472        assert_eq!(a.compare(&b), -1);
473        let micro_a = Timestamp::parse("2026-08-15T16:05:00.000001Z").unwrap();
474        let micro_b = Timestamp::parse("2026-08-15T16:05:00.000002Z").unwrap();
475        assert_ne!(micro_a, micro_b);
476        assert_eq!(micro_a.compare(&micro_b), -1);
477        let padded = Timestamp::parse("2026-08-15T16:05:00.001Z").unwrap();
478        assert_eq!(a, padded);
479    }
480
481    #[test]
482    fn saturating_add_and_duration_until_round_trip() {
483        let start = Timestamp::parse("2026-08-15T16:00:00Z").unwrap();
484        let later = start.saturating_add(std::time::Duration::from_secs(90));
485        assert_eq!(later.as_str(), "2026-08-15T16:01:30Z");
486        assert_eq!(
487            start.duration_until(&later),
488            std::time::Duration::from_secs(90)
489        );
490        assert_eq!(later.duration_until(&start), std::time::Duration::ZERO);
491    }
492}