nixfleet_agent/
freshness.rs

1//! Defense-in-depth: refuse targets whose backing manifest's `signed_at`
2//! is older than the channel's freshness window when measured at dispatch
3//! reception time.
4//!
5//! Restored from `a9ff4f43^:crates/nixfleet-agent/src/freshness.rs` (D-028)
6//! after Phase 7g's "runtime is now the only loop" destructive cut removed
7//! the v0.1 dispatch path that called it. In v0.2 the verify-on-fetch
8//! path (`manifest_cache::fetch_or_load` → `verify_artifact`) already
9//! enforces freshness against a 3600s window at fetch time; this module
10//! provides the parametric pure-function form so callers can re-check
11//! freshness at OTHER moments in the dispatch lifecycle (the canonical
12//! example: longpoll's handle_dispatch validates the per-rollout manifest
13//! is still fresh at the moment of dispatch arrival, closing the TOCTOU
14//! between fetch and dispatch when the cache hit was near the freshness
15//! edge).
16//!
17//! The v0.2 shape is parametric over `(signed_at, freshness_window_secs,
18//! now)` rather than coupled to the deleted `EvaluatedTarget` type.
19//! Callers extract `signed_at` from a verified manifest and the window
20//! from the channel's declaration.
21
22use chrono::{DateTime, Utc};
23
24/// LOADBEARING: clock-skew slack added to the freshness window — without
25/// it, a manifest signed `window` seconds ago is rejected the instant the
26/// agent's clock is ahead of the signer's by 1s.
27pub const CLOCK_SKEW_SLACK_SECS: i64 = 60;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum FreshnessCheck {
31    Fresh,
32    /// Caller refuses to act on the target. Common response: log + drop
33    /// the dispatch; the next planner tick will re-emit (with a fresher
34    /// manifest if CI has signed one).
35    Stale {
36        signed_at: DateTime<Utc>,
37        freshness_window_secs: u32,
38        age_secs: i64,
39    },
40}
41
42/// Pure freshness check: compare `signed_at` against `now`, allowing the
43/// channel's `freshness_window_secs` plus `CLOCK_SKEW_SLACK_SECS`. Returns
44/// `Fresh` if the manifest is within the window (inclusive at the
45/// boundary), `Stale` past it.
46///
47/// Negative ages (signer's clock ahead of agent's) are always Fresh —
48/// the limit comparison is `age > limit`, so age=−30 < limit always.
49pub fn check_signed_at(
50    signed_at: DateTime<Utc>,
51    freshness_window_secs: u32,
52    now: DateTime<Utc>,
53) -> FreshnessCheck {
54    let age_secs = (now - signed_at).num_seconds();
55    let limit = freshness_window_secs as i64 + CLOCK_SKEW_SLACK_SECS;
56
57    if age_secs > limit {
58        FreshnessCheck::Stale {
59            signed_at,
60            freshness_window_secs,
61            age_secs,
62        }
63    } else {
64        FreshnessCheck::Fresh
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use chrono::TimeZone;
72
73    fn t0() -> DateTime<Utc> {
74        Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap()
75    }
76
77    #[test]
78    fn fresh_when_age_well_under_window() {
79        let signed = t0();
80        let now = signed + chrono::Duration::seconds(100);
81        assert_eq!(check_signed_at(signed, 3600, now), FreshnessCheck::Fresh);
82    }
83
84    #[test]
85    fn fresh_at_exact_window_boundary() {
86        let signed = t0();
87        let now = signed + chrono::Duration::seconds(3600);
88        assert_eq!(check_signed_at(signed, 3600, now), FreshnessCheck::Fresh);
89    }
90
91    #[test]
92    fn fresh_within_slack_past_window() {
93        let signed = t0();
94        let now = signed + chrono::Duration::seconds(3660);
95        assert_eq!(check_signed_at(signed, 3600, now), FreshnessCheck::Fresh);
96    }
97
98    #[test]
99    fn stale_just_past_slack() {
100        let signed = t0();
101        let now = signed + chrono::Duration::seconds(3661);
102        let result = check_signed_at(signed, 3600, now);
103        assert!(
104            matches!(result, FreshnessCheck::Stale { age_secs: 3661, .. }),
105            "expected Stale at 3661s past signed_at; got {result:?}",
106        );
107    }
108
109    #[test]
110    fn stale_far_past_window() {
111        let signed = t0();
112        let now = signed + chrono::Duration::seconds(86_400 * 3);
113        let result = check_signed_at(signed, 3600, now);
114        match result {
115            FreshnessCheck::Stale {
116                age_secs,
117                freshness_window_secs,
118                ..
119            } => {
120                assert_eq!(freshness_window_secs, 3600);
121                assert_eq!(age_secs, 86_400 * 3);
122            }
123            other => panic!("expected Stale, got {other:?}"),
124        }
125    }
126
127    #[test]
128    fn fresh_when_clock_skew_slightly_negative() {
129        let signed = t0();
130        let now = signed - chrono::Duration::seconds(30);
131        assert_eq!(check_signed_at(signed, 3600, now), FreshnessCheck::Fresh);
132    }
133
134    #[test]
135    fn fresh_with_zero_window_inside_slack() {
136        // Channel with freshness_window=0 still tolerates clock skew up to
137        // CLOCK_SKEW_SLACK_SECS. Pinning this edge so future tightening of
138        // the slack constant is intentional.
139        let signed = t0();
140        let now = signed + chrono::Duration::seconds(30);
141        assert_eq!(check_signed_at(signed, 0, now), FreshnessCheck::Fresh);
142    }
143
144    #[test]
145    fn stale_with_zero_window_past_slack() {
146        let signed = t0();
147        let now = signed + chrono::Duration::seconds(61);
148        let result = check_signed_at(signed, 0, now);
149        assert!(matches!(result, FreshnessCheck::Stale { .. }));
150    }
151}