Skip to main content

aitp_core/
time.rs

1//! Unix-second timestamps with freshness checks.
2
3use crate::DEFAULT_TIMESTAMP_TOLERANCE_SECS;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7/// A Unix timestamp in seconds.
8///
9/// AITP timestamps are integers — never floats. This newtype enforces that at
10/// the type level: it serializes and deserializes as a JSON integer.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[serde(transparent)]
13pub struct Timestamp(pub i64);
14
15impl Timestamp {
16    /// The current time as a [`Timestamp`].
17    pub fn now() -> Self {
18        Self(Utc::now().timestamp())
19    }
20
21    /// Add seconds, saturating on overflow.
22    pub fn plus_secs(self, secs: i64) -> Self {
23        Self(self.0.saturating_add(secs))
24    }
25
26    /// True if `self` is within `±tolerance` seconds of `reference`.
27    ///
28    /// `self.0` is deserialized verbatim from the wire, so the difference
29    /// is computed with `checked_sub` + `unsigned_abs` to avoid an `i64`
30    /// overflow (e.g. `i64::MIN - reference`, or `i64::MIN.abs()`) on
31    /// attacker-controlled extremes — a debug panic / release wraparound.
32    /// A negative `tolerance_secs` is treated as zero tolerance.
33    pub fn is_within_tolerance_of(self, reference: Timestamp, tolerance_secs: i64) -> bool {
34        let tolerance = tolerance_secs.max(0) as u64;
35        match self.0.checked_sub(reference.0) {
36            Some(diff) => diff.unsigned_abs() <= tolerance,
37            // Difference doesn't fit in i64 → far outside any tolerance.
38            None => false,
39        }
40    }
41
42    /// True if `self` is within the default ±300s window of `reference`.
43    pub fn is_fresh(self, reference: Timestamp) -> bool {
44        self.is_within_tolerance_of(reference, DEFAULT_TIMESTAMP_TOLERANCE_SECS)
45    }
46
47    /// True if `self` is in the past (relative to `reference`).
48    pub fn is_in_the_past(self, reference: Timestamp) -> bool {
49        self.0 < reference.0
50    }
51
52    /// True if `self` is in the future (relative to `reference`).
53    pub fn is_in_the_future(self, reference: Timestamp) -> bool {
54        self.0 > reference.0
55    }
56}
57
58impl From<DateTime<Utc>> for Timestamp {
59    fn from(dt: DateTime<Utc>) -> Self {
60        Self(dt.timestamp())
61    }
62}
63
64impl From<i64> for Timestamp {
65    fn from(secs: i64) -> Self {
66        Self(secs)
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn freshness_within_tolerance() {
76        let now = Timestamp(1_700_000_000);
77        assert!(Timestamp(1_700_000_100).is_fresh(now));
78        assert!(Timestamp(1_699_999_900).is_fresh(now));
79        assert!(!Timestamp(1_700_000_400).is_fresh(now));
80    }
81
82    #[test]
83    fn tolerance_does_not_overflow_on_extremes() {
84        let now = Timestamp(1_700_000_000);
85        // These would panic in debug / wrap in release with naive
86        // `(a - b).abs()`. Must simply be "not fresh".
87        assert!(!Timestamp(i64::MIN).is_within_tolerance_of(now, 300));
88        assert!(!Timestamp(i64::MAX).is_within_tolerance_of(now, 300));
89        assert!(!Timestamp(i64::MIN).is_within_tolerance_of(Timestamp(i64::MAX), 300));
90        // A negative tolerance is clamped to zero.
91        assert!(!Timestamp(1_700_000_001).is_within_tolerance_of(now, -5));
92        assert!(now.is_within_tolerance_of(now, -5));
93    }
94
95    #[test]
96    fn ordering_works_as_expected() {
97        assert!(Timestamp(100) < Timestamp(200));
98    }
99
100    #[test]
101    fn now_is_close_to_chrono_now() {
102        let a = Timestamp::now().0;
103        let b = chrono::Utc::now().timestamp();
104        assert!((a - b).abs() <= 5, "Timestamp::now() drift: {} vs {}", a, b);
105    }
106
107    #[test]
108    fn serializes_as_json_integer_not_string() {
109        let s = serde_json::to_string(&Timestamp(1_700_000_000)).unwrap();
110        assert_eq!(s, "1700000000");
111        let back: Timestamp = serde_json::from_str(&s).unwrap();
112        assert_eq!(back, Timestamp(1_700_000_000));
113    }
114}