Skip to main content

guard_core_engine/
emergency_mode.rs

1//! The emergency-mode gate: the reference pipeline's second check.
2//!
3//! This is the Rust family's port of
4//! `guard_core/core/checks/implementations/emergency_mode.py`. One call,
5//! [`decide`], decides a request IP against the emergency whitelist while
6//! `config.emergency_mode` is active:
7//!
8//! ```text
9//! whitelisted IP:   pass (the reference logs an INFO line only)
10//! everyone else:    Denied (the reference's 503
11//!                   "Service temporarily unavailable")
12//! missing/unparseable IP: Denied (the reference's ValueError arm counts it
13//!                         as non-whitelisted)
14//! ```
15//!
16//! The switch itself stays on the stage wiring: the reference check is a
17//! no-op unless `config.emergency_mode` is on (it is still constructed when
18//! `enable_dynamic_rules` is on, watching for the switch), so the stage
19//! holds the live switch and the engine holds the whitelist verdict.
20//!
21//! Entries match like every IP list in the family (a bare IP or a CIDR
22//! range, IPv4-mapped canonicalization, families never crossing), the
23//! reference `_ip_in_list`; [`EmergencyModeConfig::new`] fails closed on an
24//! invalid entry.
25//!
26//! # Example
27//!
28//! ```
29//! use guard_core_engine::emergency_mode::{decide, EmergencyModeConfig, EmergencyVerdict};
30//!
31//! let config = EmergencyModeConfig::new(["192.0.2.40", "198.51.100.0/24"]).expect("valid");
32//! assert_eq!(decide(Some("192.0.2.40"), &config), EmergencyVerdict::Allowed);
33//! assert_eq!(decide(Some("192.0.2.41"), &config), EmergencyVerdict::Denied);
34//! assert_eq!(decide(Some("198.51.100.7"), &config), EmergencyVerdict::Allowed);
35//! // No client IP at all counts as non-whitelisted.
36//! assert_eq!(decide(None, &config), EmergencyVerdict::Denied);
37//! ```
38
39use std::net::IpAddr;
40
41use crate::ip_gate::{IpGateError, IpNet, canonical, parse_network_entry};
42
43/// The check's stable name (`check_name`).
44pub const EMERGENCY_MODE_CHECK_NAME: &str = "emergency_mode";
45
46/// The block status the reference answers (`create_error_response(503,
47/// default_message="Service temporarily unavailable")`).
48pub const EMERGENCY_BLOCK_STATUS: u16 = 503;
49
50/// The block body the reference answers with.
51pub const EMERGENCY_BLOCK_BODY: &str = "Service temporarily unavailable";
52
53/// The emergency whitelist (`SecurityConfig.emergency_whitelist`).
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
55pub struct EmergencyModeConfig {
56    whitelist: Vec<IpNet>,
57}
58
59impl EmergencyModeConfig {
60    /// Parse the whitelist, failing closed on an invalid entry.
61    ///
62    /// # Errors
63    ///
64    /// [`IpGateError`] naming the first entry that is neither a valid IP
65    /// nor a valid CIDR range.
66    pub fn new<I>(entries: I) -> Result<Self, IpGateError>
67    where
68        I: IntoIterator,
69        I::Item: AsRef<str>,
70    {
71        let mut whitelist = Vec::new();
72        for entry in entries {
73            let entry = entry.as_ref();
74            match parse_network_entry(entry) {
75                Some(network) => whitelist.push(network),
76                None => {
77                    return Err(IpGateError {
78                        list: "emergency_whitelist",
79                        entry: entry.to_owned(),
80                    });
81                }
82            }
83        }
84        Ok(Self { whitelist })
85    }
86
87    /// How many entries the whitelist holds (the reference event's
88    /// `emergency_whitelist_count` metadata).
89    #[must_use]
90    pub const fn len(&self) -> usize {
91        self.whitelist.len()
92    }
93
94    /// Whether the whitelist is empty.
95    #[must_use]
96    pub const fn is_empty(&self) -> bool {
97        self.whitelist.is_empty()
98    }
99}
100
101/// What [`decide`] decided.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum EmergencyVerdict {
104    /// A whitelisted client: the reference logs INFO and lets it pass.
105    Allowed,
106    /// Everyone else while the mode is active: the reference's 503 shape.
107    Denied,
108}
109
110/// The reference check body: a missing or unparseable IP is never
111/// whitelisted (`ip_address(None)` / `ValueError` -> `client_ip_addr =
112/// None`), a whitelisted one passes, everyone else is denied.
113#[must_use]
114pub fn decide(client_ip: Option<&str>, config: &EmergencyModeConfig) -> EmergencyVerdict {
115    if let Some(raw) = client_ip
116        && let Ok(addr) = raw.parse::<IpAddr>()
117        && config
118            .whitelist
119            .iter()
120            .any(|network| network.contains(canonical(addr)) || network.contains(addr))
121    {
122        return EmergencyVerdict::Allowed;
123    }
124    EmergencyVerdict::Denied
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn whitelist_members_pass_everyone_else_is_denied() {
133        let config = EmergencyModeConfig::new(["192.0.2.40"]).unwrap();
134        assert_eq!(
135            decide(Some("192.0.2.40"), &config),
136            EmergencyVerdict::Allowed
137        );
138        assert_eq!(
139            decide(Some("192.0.2.41"), &config),
140            EmergencyVerdict::Denied
141        );
142    }
143
144    #[test]
145    fn missing_or_unparseable_ip_is_denied() {
146        let config = EmergencyModeConfig::new(["192.0.2.40"]).unwrap();
147        assert_eq!(decide(None, &config), EmergencyVerdict::Denied);
148        assert_eq!(decide(Some("not-an-ip"), &config), EmergencyVerdict::Denied);
149        assert_eq!(decide(Some(""), &config), EmergencyVerdict::Denied);
150    }
151
152    #[test]
153    fn cidr_entries_and_families_match_like_the_global_lists() {
154        let config = EmergencyModeConfig::new(["198.51.100.0/24", "2001:db8:ffff::/48"]).unwrap();
155        assert_eq!(
156            decide(Some("198.51.100.7"), &config),
157            EmergencyVerdict::Allowed
158        );
159        assert_eq!(
160            decide(Some("::ffff:198.51.100.7"), &config),
161            EmergencyVerdict::Allowed,
162            "a v4-mapped request matches its IPv4 entry"
163        );
164        assert_eq!(
165            decide(Some("2001:db8:ffff::9"), &config),
166            EmergencyVerdict::Allowed
167        );
168        assert_eq!(
169            decide(Some("198.51.101.7"), &config),
170            EmergencyVerdict::Denied,
171            "families and ranges never stretch"
172        );
173    }
174
175    #[test]
176    fn an_empty_whitelist_denies_everything() {
177        let config = EmergencyModeConfig::new(Vec::<String>::new()).unwrap();
178        assert!(config.is_empty());
179        assert_eq!(config.len(), 0);
180        assert_eq!(
181            decide(Some("192.0.2.40"), &config),
182            EmergencyVerdict::Denied
183        );
184    }
185
186    #[test]
187    fn config_fails_closed_on_a_bad_entry() {
188        let error = EmergencyModeConfig::new(["junk"]).unwrap_err();
189        assert_eq!(error.list, "emergency_whitelist");
190        assert_eq!(error.entry, "junk");
191    }
192}