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}