Skip to main content

guard_core_engine/
time_window.rs

1//! The route time-window gate: the reference pipeline's tenth check.
2//!
3//! This is the Rust family's port of
4//! `guard_core/core/checks/implementations/time_window.py`. A route's
5//! `time_restrictions` dict (`start`, `end`, optional `timezone`, all
6//! `"HH:MM"` / IANA strings) admits requests only inside its window:
7//!
8//! ```text
9//! current local HH:MM in the zone (invalid zone -> UTC):
10//!   start > end:   allowed when now >= start OR now <= end
11//!                  (the window wraps midnight)
12//!   otherwise:     allowed when start <= now <= end
13//! missing start/end, or any evaluation error:  allowed
14//!                                              (the reference's fail-open
15//!                                              except arm, captured as
16//!                                              normative by spec 03)
17//! ```
18//!
19//! The comparisons are plain `"HH:MM"` string comparisons, exactly the
20//! reference's `strftime("%H:MM")` ordering (lexicographic == chronological
21//! on the zero-padded form). Zone resolution uses the IANA database
22//! (`ZoneInfo` in the reference); a name the database does not know falls
23//! back to UTC, exactly the reference's `except` arm around `ZoneInfo`.
24//!
25//! # Example
26//!
27//! ```
28//! use guard_core_engine::time_window::{hhmm_in_zone, is_within, TimeWindow};
29//! use chrono::TimeZone;
30//!
31//! let window = TimeWindow { start: Some("09:00".into()), end: Some("17:00".into()), timezone: None };
32//! assert!(is_within(&window, "09:00"));
33//! assert!(is_within(&window, "12:30"));
34//! assert!(!is_within(&window, "17:01"));
35//!
36//! // A window past midnight wraps.
37//! let night = TimeWindow { start: Some("22:00".into()), end: Some("06:00".into()), timezone: None };
38//! assert!(is_within(&night, "23:30"));
39//! assert!(is_within(&night, "05:59"));
40//! assert!(!is_within(&night, "12:00"));
41//!
42//! // The zone resolution: 12:00 UTC is 07:00 in New York (winter).
43//! let utc = chrono::Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap();
44//! assert_eq!(hhmm_in_zone(utc, Some("America/New_York")), "07:00");
45//! // An unknown zone falls back to UTC.
46//! assert_eq!(hhmm_in_zone(utc, Some("Mars/Olympus")), "12:00");
47//! ```
48
49/// The check's stable name (`check_name`).
50pub const TIME_WINDOW_CHECK_NAME: &str = "time_window";
51
52/// The block status the reference answers (`create_error_response(403,
53/// default_message="Access not allowed at this time")`).
54pub const TIME_WINDOW_BLOCK_STATUS: u16 = 403;
55
56/// The block body the reference answers with.
57pub const TIME_WINDOW_BLOCK_BODY: &str = "Access not allowed at this time";
58
59/// One route's `time_restrictions`: the window bounds and the IANA zone.
60///
61/// `None` bounds mirror the reference's missing dict keys: the evaluation
62/// raises into its `except` arm and the request is allowed.
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct TimeWindow {
65    /// The window's `"HH:MM"` opening bound (`start`).
66    pub start: Option<String>,
67    /// The window's `"HH:MM"` closing bound (`end`).
68    pub end: Option<String>,
69    /// The IANA zone name (`timezone`); `None` or an unknown name is UTC.
70    pub timezone: Option<String>,
71}
72
73/// The wall-clock `"HH:MM"` of `now_utc` observed in `timezone`.
74///
75/// The reference's `datetime.now(ZoneInfo(timezone)).strftime("%H:%M")`.
76/// An unknown or empty zone falls back to UTC (the reference's `except`
77/// around `ZoneInfo`).
78#[must_use]
79pub fn hhmm_in_zone(now_utc: chrono::DateTime<chrono::Utc>, timezone: Option<&str>) -> String {
80    let zone: chrono_tz::Tz = timezone
81        .and_then(|name| name.parse().ok())
82        .unwrap_or(chrono_tz::UTC);
83    now_utc.with_timezone(&zone).format("%H:%M").to_string()
84}
85
86/// The window decision against a current `"HH:MM"`.
87///
88/// Midnight-wrapping when `start > end`, the plain span otherwise, and
89/// **allowed** whenever the bounds are missing (the reference's fail-open
90/// `except` arm).
91#[must_use]
92pub fn is_within(window: &TimeWindow, current_hhmm: &str) -> bool {
93    let (Some(start), Some(end)) = (window.start.as_deref(), window.end.as_deref()) else {
94        return true;
95    };
96    if start > end {
97        current_hhmm >= start || current_hhmm <= end
98    } else {
99        start <= current_hhmm && current_hhmm <= end
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use chrono::TimeZone;
107
108    fn at(hour: u32, minute: u32) -> TimeWindow {
109        TimeWindow {
110            start: Some(format!("{hour:02}:{minute:02}")),
111            ..TimeWindow::default()
112        }
113    }
114
115    #[test]
116    fn plain_windows_admit_only_the_span() {
117        let window = TimeWindow {
118            start: Some("09:00".into()),
119            end: Some("17:00".into()),
120            timezone: None,
121        };
122        assert!(!is_within(&window, "08:59"));
123        assert!(is_within(&window, "09:00"));
124        assert!(is_within(&window, "17:00"));
125        assert!(!is_within(&window, "17:01"));
126        assert!(!is_within(&window, "23:59"));
127        assert!(!is_within(&window, "00:00"));
128    }
129
130    #[test]
131    fn midnight_wrapping_windows_admit_both_sides() {
132        let window = TimeWindow {
133            start: Some("22:00".into()),
134            end: Some("06:00".into()),
135            timezone: None,
136        };
137        assert!(is_within(&window, "22:00"));
138        assert!(is_within(&window, "23:59"));
139        assert!(is_within(&window, "00:00"));
140        assert!(is_within(&window, "06:00"));
141        assert!(!is_within(&window, "06:01"));
142        assert!(!is_within(&window, "21:59"));
143        assert!(!is_within(&window, "12:00"));
144    }
145
146    #[test]
147    fn missing_bounds_fail_open_like_the_reference() {
148        // The reference's KeyError arm: any evaluation error allows.
149        let empty = TimeWindow::default();
150        assert!(is_within(&empty, "12:00"));
151        let no_start = TimeWindow {
152            end: Some("06:00".into()),
153            ..TimeWindow::default()
154        };
155        assert!(is_within(&no_start, "12:00"));
156        let no_end = at(9, 0);
157        assert!(is_within(&no_end, "12:00"));
158    }
159
160    #[test]
161    fn zones_shift_the_wall_clock_and_unknown_zones_fall_back() {
162        let utc = chrono::Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap();
163        assert_eq!(hhmm_in_zone(utc, None), "12:00");
164        assert_eq!(hhmm_in_zone(utc, Some("UTC")), "12:00");
165        assert_eq!(
166            hhmm_in_zone(utc, Some("America/New_York")),
167            "07:00",
168            "EST in January"
169        );
170        assert_eq!(hhmm_in_zone(utc, Some("Asia/Tokyo")), "21:00");
171        assert_eq!(
172            hhmm_in_zone(utc, Some("Not/AZone")),
173            "12:00",
174            "an unknown zone falls back to UTC, the reference's except arm"
175        );
176
177        // The summer side of the DST boundary.
178        let summer = chrono::Utc.with_ymd_and_hms(2026, 7, 15, 12, 0, 0).unwrap();
179        assert_eq!(
180            hhmm_in_zone(summer, Some("America/New_York")),
181            "08:00",
182            "EDT in July"
183        );
184    }
185
186    #[test]
187    fn the_reference_window_example_over_a_real_zone() {
188        // 02:00 in Tokyo is outside a 09:00-17:00 Tokyo window, but
189        // 10:00 is inside; the decision uses the zone's wall clock.
190        let window = TimeWindow {
191            start: Some("09:00".into()),
192            end: Some("17:00".into()),
193            timezone: Some("Asia/Tokyo".into()),
194        };
195        let utc = chrono::Utc.with_ymd_and_hms(2026, 1, 15, 0, 0, 0).unwrap();
196        assert_eq!(hhmm_in_zone(utc, Some("Asia/Tokyo")), "09:00");
197        assert!(is_within(&window, &hhmm_in_zone(utc, Some("Asia/Tokyo"))));
198    }
199}