agentguard_core/
ttl.rs

1//! Time-related primitives: [`Clock`] trait, [`Timestamp`] type, and Duration parsing.
2
3use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
4
5/// Error from [`parse_duration`].
6#[derive(Debug, thiserror::Error)]
7#[error("invalid duration: {0}")]
8pub struct TtlParseError(String);
9
10/// Unix-epoch seconds. Newtype to avoid mixing with other integers.
11pub type Timestamp = i64;
12
13/// A pluggable time source.
14///
15/// Used by the authorizer, cache, and delegation verifier so that tests can
16/// use a [`MockClock`] to drive deterministic TTL behavior.
17///
18/// # Future
19/// This trait is currently held as `Arc<dyn Clock>` in the cache and
20/// policy watcher (cheap but not free). Switching the call sites to
21/// `impl Clock` generics would let `SystemClock` (zero-sized) live
22/// on the stack and avoid the `Arc`/`dyn` indirection entirely.
23/// Tracked for v0.3.
24pub trait Clock: Send + Sync {
25    /// Monotonic instant (for measuring elapsed time).
26    fn now(&self) -> Instant;
27
28    /// Wall-clock unix seconds (for absolute-time checks like `exp`).
29    fn unix_now(&self) -> Timestamp;
30}
31
32/// Production clock backed by the OS.
33#[derive(Debug, Clone, Copy, Default)]
34pub struct SystemClock;
35
36impl Clock for SystemClock {
37    fn now(&self) -> Instant {
38        Instant::now()
39    }
40
41    fn unix_now(&self) -> Timestamp {
42        SystemTime::now()
43            .duration_since(UNIX_EPOCH)
44            .map(|d| d.as_secs() as Timestamp)
45            .unwrap_or(0)
46    }
47}
48
49/// Test clock — advanceable by the test.
50#[derive(Debug)]
51pub struct MockClock {
52    inner: parking_lot::Mutex<Instant>,
53    unix_inner: parking_lot::Mutex<Timestamp>,
54}
55
56impl MockClock {
57    pub fn new() -> Self {
58        Self {
59            inner: parking_lot::Mutex::new(Instant::now()),
60            unix_inner: parking_lot::Mutex::new(0),
61        }
62    }
63
64    /// Set the wall-clock time to the given unix seconds.
65    pub fn set_unix(&self, t: Timestamp) {
66        *self.unix_inner.lock() = t;
67    }
68
69    /// Advance the wall clock by `d`. Also advances the monotonic clock so
70    /// `Duration`-based TTLs work in tests.
71    pub fn advance_unix(&self, d: Duration) {
72        *self.unix_inner.lock() += d.as_secs() as Timestamp;
73        let now = *self.inner.lock();
74        *self.inner.lock() = now.checked_add(d).unwrap_or(now);
75    }
76}
77
78impl Default for MockClock {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl Clock for MockClock {
85    fn now(&self) -> Instant {
86        *self.inner.lock()
87    }
88
89    fn unix_now(&self) -> Timestamp {
90        *self.unix_inner.lock()
91    }
92}
93
94/// Parse a humantime string into a `Duration`.
95///
96/// Accepts forms like `30s`, `5m`, `2h`, `1d`, or just a number of seconds.
97/// Returns an error if the string is malformed.
98pub fn parse_duration(s: &str) -> std::result::Result<Duration, TtlParseError> {
99    use std::str::FromStr;
100    humantime::Duration::from_str(s)
101        .map(Into::into)
102        .map_err(|e| TtlParseError(e.to_string()))
103}
104
105/// Format a `Duration` as a humantime string (e.g. `5m`, `2h`).
106pub fn format_duration(d: Duration) -> String {
107    humantime::format_duration(d).to_string()
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn system_clock_returns_sane_unix_now() {
116        let c = SystemClock;
117        let t = c.unix_now();
118        // After Jan 2020, unix seconds should be > 1.5e9.
119        assert!(t > 1_500_000_000, "unix_now returned {}", t);
120    }
121
122    #[test]
123    fn mock_clock_advances() {
124        let c = MockClock::new();
125        c.set_unix(100);
126        assert_eq!(c.unix_now(), 100);
127        c.advance_unix(Duration::from_secs(60));
128        assert_eq!(c.unix_now(), 160);
129    }
130
131    #[test]
132    fn parse_and_format_roundtrip() {
133        let cases = ["30s", "5m", "2h", "1d"];
134        for s in cases {
135            let d = parse_duration(s).unwrap();
136            assert!(format_duration(d).contains(|c: char| c.is_ascii_digit()));
137        }
138    }
139}