Skip to main content

areev_core/
time.rs

1//! Timestamp parsing — hand-rolled on purpose (invariant 6: the workspace
2//! takes no datetime dependency).
3//!
4//! This lives in `areev-core` rather than in the crate that first needed it
5//! (`areev-store`'s importers) because the credential map's `expires_at`
6//! needs the same parse, and `authz` sits below the store. One parser, not
7//! two: a second implementation would drift on exactly the inputs that
8//! matter least often and cost most — leap years, offsets, fractional
9//! seconds.
10
11/// Days from 1970-01-01 for a civil date (Howard Hinnant's algorithm).
12fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
13    let y = if m <= 2 { y - 1 } else { y };
14    let era = if y >= 0 { y } else { y - 399 } / 400;
15    let yoe = y - era * 400;
16    let mp = if m > 2 { m - 3 } else { m + 9 };
17    let doy = (153 * mp + 2) / 5 + d - 1;
18    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
19    era * 146097 + doe - 719468
20}
21
22/// Parse an ISO-8601 timestamp ("2024-07-26T10:29:11.982509-07:00",
23/// "2026-01-05 09:00:00Z", "2024-07-26") to epoch milliseconds.
24pub fn iso8601_to_ms(s: &str) -> Option<i64> {
25    let s = s.trim();
26    let b = s.as_bytes();
27    if b.len() < 10 || b[4] != b'-' || b[7] != b'-' {
28        return None;
29    }
30    let num = |r: std::ops::Range<usize>| -> Option<i64> { s.get(r)?.parse().ok() };
31    let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
32    if !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
33        return None;
34    }
35    let mut ms = days_from_civil(y, mo, d) * 86_400_000;
36    let mut i = 10;
37    if b.len() > i && (b[i] == b'T' || b[i] == b' ') {
38        i += 1;
39        if b.len() < i + 8 || b[i + 2] != b':' || b[i + 5] != b':' {
40            return None;
41        }
42        let (h, mi, sec) = (num(i..i + 2)?, num(i + 3..i + 5)?, num(i + 6..i + 8)?);
43        ms += (h * 3600 + mi * 60 + sec) * 1000;
44        i += 8;
45        // fractional seconds: keep milliseconds, skip the rest
46        if b.len() > i && b[i] == b'.' {
47            i += 1;
48            let start = i;
49            while i < b.len() && b[i].is_ascii_digit() {
50                i += 1;
51            }
52            let frac = &s[start..i];
53            if !frac.is_empty() {
54                let ms_str: String = frac.chars().chain("000".chars()).take(3).collect();
55                ms += ms_str.parse::<i64>().ok()?;
56            }
57        }
58        // offset: Z | ±HH:MM | ±HHMM | ±HH
59        if b.len() > i {
60            match b[i] {
61                b'Z' | b'z' => {}
62                b'+' | b'-' => {
63                    let sign: i64 = if b[i] == b'+' { 1 } else { -1 };
64                    i += 1;
65                    let oh = num(i..i + 2)?;
66                    i += 2;
67                    if b.len() > i && b[i] == b':' {
68                        i += 1;
69                    }
70                    let om = if b.len() >= i + 2 { num(i..i + 2).unwrap_or(0) } else { 0 };
71                    ms -= sign * (oh * 3600 + om * 60) * 1000;
72                }
73                _ => return None,
74            }
75        }
76    }
77    Some(ms)
78}
79
80/// Wall-clock epoch milliseconds.
81///
82/// Kept beside the parser so the few host-plane callers that legitimately
83/// need "now" (credential expiry, session lifetimes) share one spelling. The
84/// grain path deliberately does NOT use this — `audit_observation` and the
85/// serializers take `now_ms` as a parameter so content addresses stay
86/// reproducible in tests.
87pub fn now_ms() -> i64 {
88    std::time::SystemTime::now()
89        .duration_since(std::time::UNIX_EPOCH)
90        .map(|d| d.as_millis() as i64)
91        .unwrap_or(0)
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn iso8601_parses_common_shapes() {
100        assert_eq!(iso8601_to_ms("1970-01-01T00:00:00Z"), Some(0));
101        assert_eq!(iso8601_to_ms("2024-01-01"), Some(1_704_067_200_000));
102        assert_eq!(iso8601_to_ms("2024-01-01T00:00:00Z"), Some(1_704_067_200_000));
103        assert_eq!(iso8601_to_ms("2024-01-01 00:00:00"), Some(1_704_067_200_000));
104        assert_eq!(iso8601_to_ms("2024-01-01T00:00:00.500Z"), Some(1_704_067_200_500));
105        assert_eq!(iso8601_to_ms("2023-12-31T17:00:00-07:00"), Some(1_704_067_200_000));
106        assert_eq!(iso8601_to_ms("2024-01-01T01:00:00+01:00"), Some(1_704_067_200_000));
107        assert_eq!(iso8601_to_ms("2024-01-01T00:00:00.982509Z"), Some(1_704_067_200_982));
108        assert_eq!(iso8601_to_ms("not a date"), None);
109        assert_eq!(iso8601_to_ms("2024-13-01"), None);
110    }
111
112    /// The leap-year path the two-implementation risk lived on.
113    #[test]
114    fn leap_days_and_century_rules() {
115        // 2024 is a leap year: Feb 29 exists and March 1 is the next day.
116        let feb29 = iso8601_to_ms("2024-02-29T00:00:00Z").unwrap();
117        let mar01 = iso8601_to_ms("2024-03-01T00:00:00Z").unwrap();
118        assert_eq!(mar01 - feb29, 86_400_000);
119        // 2000 was a leap year (divisible by 400); 1900 was not.
120        assert_eq!(
121            iso8601_to_ms("2000-03-01T00:00:00Z").unwrap()
122                - iso8601_to_ms("2000-02-28T00:00:00Z").unwrap(),
123            2 * 86_400_000
124        );
125    }
126}