guard_core_engine/ip_gate.rs
1//! The global IP gate: `whitelist`, `blacklist`, and `exempt_ips`.
2//!
3//! This is the Rust family's minimal port of the reference engine's global IP
4//! stage (guard-core `check_ip_access`, frozen by the ecosystem
5//! `exempt_ips` contract). One call,
6//! [`IpGateConfig::evaluate`], decides a request IP:
7//!
8//! ```text
9//! if a whitelist is configured and the IP is not on it: deny (not in whitelist)
10//! otherwise, if the IP is on the blacklist: deny (blacklisted)
11//! allowed: is_whitelisted = matched the whitelist
12//! is_exempt = matched exempt_ips
13//! ```
14//!
15//! ## The `exempt_ips` contract
16//!
17//! `exempt_ips` is the skip-list for known-friendly automation (monitoring
18//! probes, VPN egress, a partner's server). A match sets the same skip state
19//! a whitelist match sets ([`IpGateDecision::is_exempt`]), but it carries no
20//! deny path of its own: exemption can never open the whitelist gate, and an
21//! exempt IP on the blacklist is still denied. `whitelist` keeps its
22//! allowlist semantics untouched: when it is non-empty, every unlisted IP is
23//! denied exactly as before, exemption included.
24//!
25//! Matching semantics are identical for all three lists: an entry is a bare
26//! IP or a CIDR range (host bits are cleared at parse time, so `1.2.3.4/24`
27//! and `1.2.3.0/24` are the same network), an IPv4-mapped request address
28//! (`::ffff:203.0.113.7`) matches its IPv4 canonical form, and families never
29//! cross (an IPv4 entry never matches an unrelated IPv6 address).
30//!
31//! Invalid entries are config errors: [`IpGateConfig::new`] fails closed, so
32//! an adapter that builds its gate at startup never serves with a broken
33//! list.
34//!
35//! # Example
36//!
37//! ```
38//! use std::net::IpAddr;
39//! use std::str::FromStr;
40//!
41//! use guard_core_engine::ip_gate::{IpGateConfig, IpGateVerdict};
42//!
43//! let gate = IpGateConfig::new(
44//! [] as [&str; 0],
45//! ["203.0.113.9"],
46//! ["198.51.100.7", "198.51.100.16/28"],
47//! )
48//! .expect("valid lists");
49//!
50//! // An exempt IP passes with the skip flag set.
51//! let exempt = gate.evaluate(IpAddr::from_str("198.51.100.7").unwrap());
52//! assert!(matches!(exempt, IpGateVerdict::Allowed(decision) if decision.is_exempt));
53//!
54//! // A CIDR entry matches the whole range, the flag carried along.
55//! let in_range = gate.evaluate(IpAddr::from_str("198.51.100.20").unwrap());
56//! assert!(matches!(in_range, IpGateVerdict::Allowed(decision) if decision.is_exempt));
57//!
58//! // The blacklist wins over exemption: no deny path is added, but none is
59//! // removed either.
60//! assert_eq!(
61//! gate.evaluate(IpAddr::from_str("203.0.113.9").unwrap()),
62//! IpGateVerdict::Denied(guard_core_engine::ip_gate::IpGateDenial::Blacklisted)
63//! );
64//!
65//! // Everyone else passes unimpeded: an empty whitelist denies nobody.
66//! let other = gate.evaluate(IpAddr::from_str("192.0.2.1").unwrap());
67//! assert!(matches!(other, IpGateVerdict::Allowed(decision) if !decision.is_exempt));
68//! ```
69
70use std::fmt;
71use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
72use std::str::FromStr;
73
74/// The skip state a request carries after it passed the global IP gate.
75///
76/// This is the family-local equivalent of the reference engine's
77/// `state.is_whitelisted` / `state.is_exempt` pair. The stateful stages the
78/// engine ships (the rate limiter and the auto-ban violation counter, see
79/// [`crate::rate_limit`] and [`crate::ip_ban`]) skip for
80/// `is_whitelisted || is_exempt` exactly what the reference skips for a
81/// whitelist match, and never skip penetration detection. A stage that lands
82/// later (user-agent filter, cloud-provider blocker) must follow the same
83/// rule.
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
85pub struct IpGateDecision {
86 /// A non-empty `whitelist` matched the request IP.
87 pub is_whitelisted: bool,
88 /// `exempt_ips` matched the request IP. Per the contract, an IP on both
89 /// lists is simply a whitelist match with no observable difference, so
90 /// this flag adds nothing a whitelist match does not already carry.
91 pub is_exempt: bool,
92}
93
94/// Why the global IP gate denied a request.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum IpGateDenial {
97 /// The IP is on the `blacklist` (exemption does not win).
98 Blacklisted,
99 /// A non-empty `whitelist` matched neither the IP nor any exemption (a
100 /// match would be a plain whitelist pass; exemption never opens the gate).
101 NotInWhitelist,
102}
103
104impl IpGateDenial {
105 /// The denial reason, in the ecosystem's reason vocabulary (same strings
106 /// the Go engine port emits).
107 #[must_use]
108 pub const fn reason(self) -> &'static str {
109 match self {
110 Self::Blacklisted => "IP is blacklisted",
111 Self::NotInWhitelist => "IP not in whitelist",
112 }
113 }
114}
115
116/// The outcome of the global IP gate for one request IP.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum IpGateVerdict {
119 /// The IP passed the gate; the decision carries the skip state.
120 Allowed(IpGateDecision),
121 /// The IP was denied; exemption never applies to a denial.
122 Denied(IpGateDenial),
123}
124
125/// One parsed list entry: a bare IP or a CIDR range.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127enum Entry {
128 Exact(IpAddr),
129 Network(IpNet),
130}
131
132/// A CIDR range with the host bits cleared at parse time.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub(crate) struct IpNet {
135 pub(crate) addr: IpAddr,
136 pub(crate) prefix: u8,
137}
138
139impl IpNet {
140 /// Parse an `addr/prefix` entry, clearing the host bits.
141 ///
142 /// The network keeps the family its literal was written in: Python and Go
143 /// both treat `::ffff:0:0/96` as an IPv6 network, so the request side is
144 /// what gets canonicalized (see [`entry_matches`]), never the entry.
145 pub(crate) fn parse(text: &str) -> Option<Self> {
146 let (addr_part, prefix_part) = text.split_once('/')?;
147 let addr = IpAddr::from_str(addr_part).ok()?;
148 let prefix = u8::from_str(prefix_part).ok()?;
149 let bits = family_bits(addr);
150 if prefix > bits {
151 return None;
152 }
153 Some(Self {
154 addr: masked(addr, prefix),
155 prefix,
156 })
157 }
158
159 /// Family-preserving membership: an IPv4 network never contains an IPv6
160 /// address and vice versa.
161 pub(crate) fn contains(&self, addr: IpAddr) -> bool {
162 match (self.addr, addr) {
163 (IpAddr::V4(net), IpAddr::V4(ip)) => {
164 u32::from(net) == (u32::from(ip) & v4_mask(self.prefix))
165 }
166 (IpAddr::V6(net), IpAddr::V6(ip)) => {
167 u128::from(net) == (u128::from(ip) & v6_mask(self.prefix))
168 }
169 _ => false,
170 }
171 }
172}
173
174/// Parse one list entry into its exact or network form.
175fn parse_entry(text: &str) -> Option<Entry> {
176 if text.contains('/') {
177 return Some(Entry::Network(IpNet::parse(text)?));
178 }
179 Some(Entry::Exact(canonical(IpAddr::from_str(text).ok()?)))
180}
181
182/// Parse one list entry into a network: a CIDR range as written (host bits
183/// cleared) or a bare IP as a single-host network of its own family. The
184/// overlap checks of the dynamic ban store (loopback and trusted-proxy
185/// refusal) compare request addresses against networks built from this.
186pub(crate) fn parse_network_entry(text: &str) -> Option<IpNet> {
187 match parse_entry(text)? {
188 Entry::Exact(addr) => Some(IpNet {
189 addr,
190 prefix: family_bits(addr),
191 }),
192 Entry::Network(net) => Some(net),
193 }
194}
195
196/// Undo a `::ffff:a.b.c.d` mapping: the canonical form exact entries, request
197/// addresses, and every stateful store key (rate windows, ban entries) are
198/// compared in, so a v4-mapped request counts toward the same bucket as its
199/// IPv4 form.
200pub(crate) const fn canonical(addr: IpAddr) -> IpAddr {
201 match addr {
202 IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
203 Some(v4) => IpAddr::V4(v4),
204 None => IpAddr::V6(v6),
205 },
206 IpAddr::V4(v4) => IpAddr::V4(v4),
207 }
208}
209
210impl core::fmt::Display for IpNet {
211 /// The family's canonical textual form: the masked address, `/`, the
212 /// prefix (the reference `str(ip_network(...))` / Go `Prefix.String()`;
213 /// IPv6 comes out compressed).
214 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
215 write!(f, "{}/{}", self.addr, self.prefix)
216 }
217}
218
219/// The address family's bit width.
220const fn family_bits(addr: IpAddr) -> u8 {
221 match addr {
222 IpAddr::V4(_) => 32,
223 IpAddr::V6(_) => 128,
224 }
225}
226
227/// Clear the host bits of `addr` below `prefix`.
228fn masked(addr: IpAddr, prefix: u8) -> IpAddr {
229 match addr {
230 IpAddr::V4(v4) => IpAddr::V4(Ipv4Addr::from(u32::from(v4) & v4_mask(prefix))),
231 IpAddr::V6(v6) => IpAddr::V6(Ipv6Addr::from(u128::from(v6) & v6_mask(prefix))),
232 }
233}
234
235/// The IPv4 network mask for a prefix length (0 for `/0`).
236const fn v4_mask(prefix: u8) -> u32 {
237 if prefix == 0 {
238 0
239 } else {
240 u32::MAX << (32 - prefix)
241 }
242}
243
244/// The IPv6 network mask for a prefix length (0 for `/0`).
245const fn v6_mask(prefix: u8) -> u128 {
246 if prefix == 0 {
247 0
248 } else {
249 u128::MAX << (128 - prefix)
250 }
251}
252
253/// Whether one entry matches a request address.
254///
255/// The CIDR arms mirror the Go engine port exactly: the request address is
256/// tried in canonical (IPv4-unmapped) form first, then raw, so a v4-mapped
257/// request matches an IPv4 network while a raw v6 request still matches a v6
258/// network such as `::ffff:0:0/96`. Exact entries compare canonical forms on
259/// both sides.
260fn entry_matches(entry: &Entry, addr: IpAddr) -> bool {
261 match entry {
262 Entry::Exact(expected) => *expected == canonical(addr),
263 Entry::Network(net) => net.contains(canonical(addr)) || net.contains(addr),
264 }
265}
266
267/// Whether any entry of `entries` matches `addr`; an empty list matches
268/// nothing.
269fn list_matches(entries: &[Entry], addr: IpAddr) -> bool {
270 entries.iter().any(|entry| entry_matches(entry, addr))
271}
272
273/// An invalid list entry: the config error [`IpGateConfig::new`] fails with.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct IpGateError {
276 /// The list the rejected entry came from (`whitelist`, `blacklist`, or
277 /// `exempt_ips`).
278 pub list: &'static str,
279 /// The rejected entry.
280 pub entry: String,
281}
282
283impl fmt::Display for IpGateError {
284 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285 write!(
286 f,
287 "invalid {} entry '{}': expected an IP address or CIDR range",
288 self.list, self.entry
289 )
290 }
291}
292
293impl std::error::Error for IpGateError {}
294
295/// The global IP gate: the parsed `whitelist`, `blacklist`, and `exempt_ips`
296/// lists.
297///
298/// Build it once at startup with [`IpGateConfig::new`] (which fails closed on
299/// an invalid entry) and evaluate request IPs with
300/// [`IpGateConfig::evaluate`]. The default value is three empty lists: the
301/// gate is inert, denying nobody and exempting nobody.
302#[derive(Debug, Clone, Default, PartialEq, Eq)]
303pub struct IpGateConfig {
304 whitelist: Vec<Entry>,
305 blacklist: Vec<Entry>,
306 exempt_ips: Vec<Entry>,
307}
308
309impl IpGateConfig {
310 /// Parse and validate the three lists, failing closed on the first
311 /// invalid entry.
312 ///
313 /// Entry semantics match the reference whitelist matcher: a bare IP or a
314 /// `addr/prefix` CIDR range, IPv4-mapped forms included, host bits
315 /// cleared. The empty iterator is valid for any list.
316 ///
317 /// # Errors
318 ///
319 /// Returns an [`IpGateError`] naming the list and the first entry that is
320 /// neither a valid IP nor a valid CIDR range.
321 pub fn new<W, B, E>(whitelist: W, blacklist: B, exempt_ips: E) -> Result<Self, IpGateError>
322 where
323 W: IntoIterator,
324 W::Item: AsRef<str>,
325 B: IntoIterator,
326 B::Item: AsRef<str>,
327 E: IntoIterator,
328 E::Item: AsRef<str>,
329 {
330 Ok(Self {
331 whitelist: parse_list("whitelist", whitelist)?,
332 blacklist: parse_list("blacklist", blacklist)?,
333 exempt_ips: parse_list("exempt_ips", exempt_ips)?,
334 })
335 }
336
337 /// Evaluate a request IP against the gate.
338 ///
339 /// The ordering is the reference contract's: with a non-empty whitelist
340 /// the whitelist gate decides (an unlisted, non-exempt IP is denied; an
341 /// exempt IP does **not** pass the gate), otherwise the blacklist decides.
342 /// The exemption flag is only set on an allowed verdict: it never adds a
343 /// deny path and never removes one.
344 #[must_use]
345 pub fn evaluate(&self, ip: IpAddr) -> IpGateVerdict {
346 if !self.whitelist.is_empty() {
347 if !list_matches(&self.whitelist, ip) {
348 return IpGateVerdict::Denied(IpGateDenial::NotInWhitelist);
349 }
350 return IpGateVerdict::Allowed(IpGateDecision {
351 is_whitelisted: true,
352 is_exempt: list_matches(&self.exempt_ips, ip),
353 });
354 }
355 if !self.blacklist.is_empty() && list_matches(&self.blacklist, ip) {
356 return IpGateVerdict::Denied(IpGateDenial::Blacklisted);
357 }
358 IpGateVerdict::Allowed(IpGateDecision {
359 is_whitelisted: false,
360 is_exempt: list_matches(&self.exempt_ips, ip),
361 })
362 }
363}
364
365/// The outcome of the route-level IP gate for one request IP
366/// (`check_route_ip_access`'s tri-state, the country arms aside).
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum RouteIpVerdict {
369 /// The route IP lists leave the request alone: neither list matched in a
370 /// denying way and no restrictive whitelist claims the route. The global
371 /// gate decides as usual.
372 Unrestricted,
373 /// A configured route whitelist matched the IP (or nothing denied): the
374 /// route stage passes.
375 Allowed,
376 /// A route blacklist match, or a miss under a configured route
377 /// whitelist: the route stage denies.
378 Denied,
379}
380
381/// The route-level IP gate: a route decorator's `ip_whitelist` and
382/// `ip_blacklist` (`RouteConfig.ip_whitelist` / `.ip_blacklist`).
383///
384/// This is the Rust family's port of the reference engine's route IP stage
385/// (`guard_core/core/checks/helpers.py::check_route_ip_access`, the IP-list
386/// arms; the Go and PHP family ports run the same order). One call,
387/// [`RouteIpGate::evaluate`], decides a request IP against one route:
388///
389/// ```text
390/// whitelist configured, match: pass the route stage (the whitelist verdict
391/// wins, even over a blacklist match)
392/// whitelist configured, miss: deny (the whitelist takes over the route
393/// verdict; the blacklist is never consulted)
394/// blacklist match: deny
395/// neither list configured: unrestricted (the global gate decides)
396/// ```
397///
398/// The precedence is the reference's `check_route_ip_access`: the whitelist
399/// is asked first and its answer settles the IP verdict (`ip_result`); the
400/// blacklist is only consulted when the whitelist gives no verdict (it is
401/// not configured), so a whitelisted IP that is also blacklisted still
402/// passes the route stage.
403///
404/// Matching semantics are the global gate's ([`IpGateConfig`]): a bare IP or
405/// a CIDR range per entry, IPv4-mapped request addresses canonicalized,
406/// families never crossing. Invalid entries are config errors:
407/// [`RouteIpGate::new`] fails closed.
408///
409/// The global lists are still enforced afterwards by the caller, so a route
410/// whitelist match never relaxes the global gate; it only clears the route
411/// verdict.
412///
413/// # Example
414///
415/// ```
416/// use std::net::IpAddr;
417/// use std::str::FromStr;
418///
419/// use guard_core_engine::ip_gate::{RouteIpGate, RouteIpVerdict};
420///
421/// let gate = RouteIpGate::new(["192.0.2.40"], ["203.0.113.9"]).expect("valid lists");
422///
423/// // A whitelist member passes the route stage, even a blacklisted one:
424/// // the whitelist verdict wins over the blacklist.
425/// assert_eq!(
426/// gate.evaluate(IpAddr::from_str("192.0.2.40").unwrap()),
427/// RouteIpVerdict::Allowed
428/// );
429/// // Everyone else on the route is denied: the whitelist takes over.
430/// assert_eq!(
431/// gate.evaluate(IpAddr::from_str("192.0.2.11").unwrap()),
432/// RouteIpVerdict::Denied
433/// );
434/// // A blacklisted IP outside the whitelist is denied.
435/// assert_eq!(
436/// gate.evaluate(IpAddr::from_str("203.0.113.9").unwrap()),
437/// RouteIpVerdict::Denied
438/// );
439///
440/// // No lists: the route leaves the request to the global gate.
441/// let open = RouteIpGate::new([] as [&str; 0], [] as [&str; 0]).expect("valid lists");
442/// assert_eq!(
443/// open.evaluate(IpAddr::from_str("192.0.2.11").unwrap()),
444/// RouteIpVerdict::Unrestricted
445/// );
446/// ```
447#[derive(Debug, Clone, Default, PartialEq, Eq)]
448pub struct RouteIpGate {
449 whitelist: Vec<Entry>,
450 blacklist: Vec<Entry>,
451}
452
453impl RouteIpGate {
454 /// Parse the route's two lists, failing closed on the first invalid
455 /// entry (the list names in the error are `ip_whitelist` and
456 /// `ip_blacklist`).
457 ///
458 /// # Errors
459 ///
460 /// Returns an [`IpGateError`] naming the list and the first entry that is
461 /// neither a valid IP nor a valid CIDR range.
462 pub fn new<W, B>(whitelist: W, blacklist: B) -> Result<Self, IpGateError>
463 where
464 W: IntoIterator,
465 W::Item: AsRef<str>,
466 B: IntoIterator,
467 B::Item: AsRef<str>,
468 {
469 Ok(Self {
470 whitelist: parse_list("ip_whitelist", whitelist)?,
471 blacklist: parse_list("ip_blacklist", blacklist)?,
472 })
473 }
474
475 /// Whether a route whitelist is configured (non-empty). The reference
476 /// `_route_overrides_ip_lists`: a configured route whitelist also
477 /// overrides the global IP lists for the request, so the caller skips
478 /// the global gate's deny paths.
479 #[must_use]
480 pub const fn whitelist_configured(&self) -> bool {
481 !self.whitelist.is_empty()
482 }
483
484 /// Evaluate a request IP against the route's lists: a configured
485 /// whitelist takes over the route verdict first (a match passes, a miss
486 /// denies, exactly the reference's `ip_result` order), then the
487 /// blacklist denies, and no list leaves the request unrestricted.
488 #[must_use]
489 pub fn evaluate(&self, ip: IpAddr) -> RouteIpVerdict {
490 if !self.whitelist.is_empty() {
491 return if list_matches(&self.whitelist, ip) {
492 RouteIpVerdict::Allowed
493 } else {
494 RouteIpVerdict::Denied
495 };
496 }
497 if !self.blacklist.is_empty() && list_matches(&self.blacklist, ip) {
498 return RouteIpVerdict::Denied;
499 }
500 RouteIpVerdict::Unrestricted
501 }
502}
503
504/// Parse one list, reporting the first invalid entry with the list's name.
505fn parse_list<I>(list: &'static str, entries: I) -> Result<Vec<Entry>, IpGateError>
506where
507 I: IntoIterator,
508 I::Item: AsRef<str>,
509{
510 let mut parsed = Vec::new();
511 for entry in entries {
512 let entry = entry.as_ref();
513 match parse_entry(entry) {
514 Some(parsed_entry) => parsed.push(parsed_entry),
515 None => {
516 return Err(IpGateError {
517 list,
518 entry: entry.to_owned(),
519 });
520 }
521 }
522 }
523 Ok(parsed)
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 /// The empty list, typed so the `new` calls stay inferable.
531 const NIL: [&str; 0] = [];
532
533 fn ip(text: &str) -> IpAddr {
534 IpAddr::from_str(text).expect("test address")
535 }
536
537 #[test]
538 fn parse_entry_forms() {
539 assert_eq!(
540 parse_entry("203.0.113.7"),
541 Some(Entry::Exact(ip("203.0.113.7")))
542 );
543 assert_eq!(
544 parse_entry("198.51.100.16/28"),
545 Some(Entry::Network(IpNet {
546 addr: ip("198.51.100.16"),
547 prefix: 28
548 }))
549 );
550 // Host bits are cleared at parse time.
551 assert_eq!(
552 parse_entry("198.51.100.20/28"),
553 parse_entry("198.51.100.16/28")
554 );
555 // IPv4-mapped exact entries canonicalize to IPv4.
556 assert_eq!(
557 parse_entry("::ffff:203.0.113.7"),
558 Some(Entry::Exact(ip("203.0.113.7")))
559 );
560 // A mapped CIDR entry keeps its IPv6 family, as in Python and Go.
561 assert_eq!(
562 parse_entry("::ffff:0:0/96"),
563 Some(Entry::Network(IpNet {
564 addr: ip("::ffff:0:0"),
565 prefix: 96
566 }))
567 );
568 }
569
570 #[test]
571 fn parse_entry_rejects_junk() {
572 for junk in [
573 "not-an-ip",
574 "",
575 "203.0.113.0/33",
576 "203.0.113.7/",
577 "/24",
578 "::1/129",
579 ] {
580 assert_eq!(parse_entry(junk), None, "{junk} must not parse");
581 }
582 // The network parser demands the `addr/prefix` form outright.
583 assert_eq!(IpNet::parse("203.0.113.7"), None);
584 }
585
586 #[test]
587 fn family_never_crosses() {
588 let net = IpNet::parse("203.0.113.0/24").expect("v4 network");
589 assert!(net.contains(ip("203.0.113.7")));
590 // An unrelated v6 address is never inside a v4 network. The v4-mapped
591 // v6 form matches through the mapped arm of `entry_matches`, never
592 // through a family crossing here.
593 assert!(!net.contains(ip("::1")));
594 let net = IpNet::parse("2001:db8::/32").expect("v6 network");
595 assert!(net.contains(ip("2001:db8::1")));
596 assert!(!net.contains(ip("203.0.113.7")));
597 }
598
599 #[test]
600 fn zero_prefix_networks_match_everything_in_family() {
601 let net = IpNet::parse("0.0.0.0/0").expect("v4 /0");
602 assert!(net.contains(ip("203.0.113.7")));
603 assert!(!net.contains(ip("2001:db8::1")));
604 let net = IpNet::parse("::/0").expect("v6 /0");
605 assert!(net.contains(ip("2001:db8::1")));
606 assert!(!net.contains(ip("203.0.113.7")));
607 }
608
609 #[test]
610 fn new_reports_the_list_and_entry_of_the_first_reject() {
611 let error = RouteIpGate::new(["203.0.113.0/33"], NIL).unwrap_err();
612 assert_eq!(error.list, "ip_whitelist");
613 assert_eq!(error.entry, "203.0.113.0/33");
614
615 let error = RouteIpGate::new(NIL, ["junk"]).unwrap_err();
616 assert_eq!(error.list, "ip_blacklist");
617 assert_eq!(error.entry, "junk");
618
619 let error = IpGateConfig::new(["203.0.113.7"], ["ok-but-not-an-ip"], NIL).unwrap_err();
620 assert_eq!(error.list, "blacklist");
621 assert_eq!(error.entry, "ok-but-not-an-ip");
622 assert_eq!(
623 error.to_string(),
624 "invalid blacklist entry 'ok-but-not-an-ip': expected an IP address or CIDR range"
625 );
626
627 let error = IpGateConfig::new(NIL, NIL, ["203.0.113.0/33"]).unwrap_err();
628 assert_eq!(error.list, "exempt_ips");
629
630 let error = IpGateConfig::new(["::1/129"], NIL, NIL).unwrap_err();
631 assert_eq!(error.list, "whitelist");
632 }
633
634 #[test]
635 fn new_accepts_empty_lists_and_the_default_is_inert() {
636 let gate = IpGateConfig::new(NIL, NIL, NIL).expect("empty lists");
637 for address in ["203.0.113.7", "2001:db8::1", "::ffff:203.0.113.7"] {
638 assert_eq!(
639 gate.evaluate(ip(address)),
640 IpGateVerdict::Allowed(IpGateDecision::default())
641 );
642 }
643 assert_eq!(
644 IpGateConfig::default().evaluate(ip("192.0.2.1")),
645 IpGateVerdict::Allowed(IpGateDecision::default())
646 );
647 }
648
649 #[test]
650 fn blacklist_denies_and_exemption_does_not_win() {
651 let gate =
652 IpGateConfig::new(NIL, ["203.0.113.9", "198.51.100.0/24"], ["203.0.113.9"]).unwrap();
653 assert_eq!(
654 gate.evaluate(ip("203.0.113.9")),
655 IpGateVerdict::Denied(IpGateDenial::Blacklisted)
656 );
657 assert_eq!(
658 gate.evaluate(ip("198.51.100.77")),
659 IpGateVerdict::Denied(IpGateDenial::Blacklisted)
660 );
661 assert_eq!(
662 gate.evaluate(ip("203.0.113.9")).unwrap_denied().reason(),
663 "IP is blacklisted"
664 );
665 // A blacklisted range does not leak onto its neighbors.
666 assert_eq!(
667 gate.evaluate(ip("198.51.200.1")),
668 IpGateVerdict::Allowed(IpGateDecision::default())
669 );
670 }
671
672 #[test]
673 fn exemption_never_opens_the_whitelist_gate() {
674 let gate = IpGateConfig::new(["192.0.2.1"], NIL, ["203.0.113.7"]).unwrap();
675 assert_eq!(
676 gate.evaluate(ip("203.0.113.7")),
677 IpGateVerdict::Denied(IpGateDenial::NotInWhitelist)
678 );
679 assert_eq!(
680 gate.evaluate(ip("203.0.113.7")).unwrap_denied().reason(),
681 "IP not in whitelist"
682 );
683 assert!(matches!(
684 gate.evaluate(ip("192.0.2.1")),
685 IpGateVerdict::Allowed(IpGateDecision {
686 is_whitelisted: true,
687 is_exempt: false
688 })
689 ));
690 }
691
692 #[test]
693 fn an_ip_on_both_lists_is_simply_a_whitelist_match() {
694 let gate = IpGateConfig::new(["203.0.113.7"], NIL, ["203.0.113.7"]).unwrap();
695 assert_eq!(
696 gate.evaluate(ip("203.0.113.7")),
697 IpGateVerdict::Allowed(IpGateDecision {
698 is_whitelisted: true,
699 is_exempt: true
700 })
701 );
702 }
703
704 #[test]
705 fn exempt_exact_and_cidr_matches_set_the_flag_without_deny_paths() {
706 let gate = IpGateConfig::new(NIL, NIL, ["198.51.100.7", "198.51.100.16/28"]).unwrap();
707 for address in ["198.51.100.7", "198.51.100.20", "::ffff:198.51.100.7"] {
708 assert_eq!(
709 gate.evaluate(ip(address)),
710 IpGateVerdict::Allowed(IpGateDecision {
711 is_whitelisted: false,
712 is_exempt: true
713 }),
714 "{address} must be exempt"
715 );
716 }
717 assert_eq!(
718 gate.evaluate(ip("198.51.100.15")),
719 IpGateVerdict::Allowed(IpGateDecision::default()),
720 "the address just below the CIDR range is not exempt"
721 );
722 }
723
724 #[test]
725 fn ipv4_mapped_parity_across_all_list_forms() {
726 // v4-mapped request against an exact v4 entry.
727 let gate = IpGateConfig::new(["::ffff:203.0.113.7"], NIL, NIL).unwrap();
728 assert_eq!(
729 gate.evaluate(ip("203.0.113.7")),
730 IpGateVerdict::Allowed(IpGateDecision {
731 is_whitelisted: true,
732 is_exempt: false
733 })
734 );
735 // v4-mapped request against a v4 CIDR entry.
736 let gate = IpGateConfig::new(["203.0.113.0/24"], NIL, NIL).unwrap();
737 assert_eq!(
738 gate.evaluate(ip("::ffff:203.0.113.99")),
739 IpGateVerdict::Allowed(IpGateDecision {
740 is_whitelisted: true,
741 is_exempt: false
742 })
743 );
744 // A v6-mapped CIDR entry still matches the raw v6 form.
745 let gate = IpGateConfig::new(["::ffff:0:0/96"], NIL, NIL).unwrap();
746 assert_eq!(
747 gate.evaluate(ip("::ffff:203.0.113.99")),
748 IpGateVerdict::Allowed(IpGateDecision {
749 is_whitelisted: true,
750 is_exempt: false
751 })
752 );
753 assert_eq!(
754 gate.evaluate(ip("2001:db8::1")),
755 IpGateVerdict::Denied(IpGateDenial::NotInWhitelist)
756 );
757 }
758
759 #[test]
760 fn exempt_flag_is_only_set_after_the_deny_checks_pass() {
761 let gate = IpGateConfig::new(NIL, ["198.51.100.7"], ["198.51.100.7"]).unwrap();
762 assert_eq!(
763 gate.evaluate(ip("198.51.100.7")),
764 IpGateVerdict::Denied(IpGateDenial::Blacklisted)
765 );
766 }
767
768 impl IpGateVerdict {
769 /// The denial of a `Denied` verdict, for assertions.
770 fn unwrap_denied(self) -> IpGateDenial {
771 match self {
772 Self::Allowed(_) => panic!("expected a denial"),
773 Self::Denied(denial) => denial,
774 }
775 }
776 }
777
778 #[test]
779 fn unwrap_denied_extracts_the_denial_reason() {
780 let gate = IpGateConfig::new(Vec::<String>::new(), ["198.51.100.7"], Vec::<String>::new())
781 .unwrap();
782 let denial = gate.evaluate(ip("198.51.100.7")).unwrap_denied();
783 assert_eq!(denial, IpGateDenial::Blacklisted);
784 }
785
786 #[test]
787 fn route_gate_whitelist_takes_over_the_route_verdict() {
788 // The corpus shape (pipeline_ip_control::route_ip_whitelist_denies_other_ip).
789 let gate = RouteIpGate::new(["192.0.2.40"], NIL).unwrap();
790 assert!(gate.whitelist_configured());
791 assert_eq!(
792 gate.evaluate(ip("192.0.2.40")),
793 RouteIpVerdict::Allowed,
794 "a whitelist member passes the route stage"
795 );
796 assert_eq!(
797 gate.evaluate(ip("192.0.2.11")),
798 RouteIpVerdict::Denied,
799 "a miss under a configured whitelist denies"
800 );
801 }
802
803 #[test]
804 fn route_gate_whitelist_verdict_wins_over_the_blacklist() {
805 // The reference order (`check_route_ip_access`): the whitelist is
806 // asked first and its answer settles the IP verdict; the blacklist
807 // is only consulted when the whitelist is not configured.
808 let gate = RouteIpGate::new(["192.0.2.40"], ["203.0.113.9", "198.51.100.0/24"]).unwrap();
809 assert_eq!(
810 gate.evaluate(ip("203.0.113.9")),
811 RouteIpVerdict::Denied,
812 "a blacklist match outside the whitelist denies"
813 );
814 assert_eq!(
815 gate.evaluate(ip("198.51.100.7")),
816 RouteIpVerdict::Denied,
817 "a blacklist-range miss under the whitelist denies"
818 );
819 assert_eq!(
820 gate.evaluate(ip("192.0.2.40")),
821 RouteIpVerdict::Allowed,
822 "the whitelist passes its members"
823 );
824 assert_eq!(
825 gate.evaluate(ip("192.0.2.11")),
826 RouteIpVerdict::Denied,
827 "the whitelist denies its misses"
828 );
829 }
830
831 #[test]
832 fn route_gate_whitelisted_blacklisted_ip_passes() {
833 // An IP on both lists passes: the whitelist verdict wins, exactly
834 // the reference's `ip_result = _check_ip_whitelist(...)` settling
835 // before the blacklist is ever consulted.
836 let gate = RouteIpGate::new(["192.0.2.40"], ["192.0.2.40"]).unwrap();
837 assert_eq!(
838 gate.evaluate(ip("192.0.2.40")),
839 RouteIpVerdict::Allowed,
840 "a whitelisted IP that is also blacklisted passes the route stage"
841 );
842 }
843
844 #[test]
845 fn route_gate_blacklist_only_denies_its_matches() {
846 let gate = RouteIpGate::new(NIL, ["203.0.113.9"]).unwrap();
847 assert!(!gate.whitelist_configured());
848 assert_eq!(gate.evaluate(ip("203.0.113.9")), RouteIpVerdict::Denied);
849 assert_eq!(
850 gate.evaluate(ip("192.0.2.11")),
851 RouteIpVerdict::Unrestricted,
852 "a non-member is left to the global gate"
853 );
854 }
855
856 #[test]
857 fn route_gate_empty_is_unrestricted() {
858 let gate = RouteIpGate::new(NIL, NIL).unwrap();
859 assert!(!gate.whitelist_configured());
860 assert_eq!(
861 gate.evaluate(ip("192.0.2.11")),
862 RouteIpVerdict::Unrestricted
863 );
864 }
865
866 #[test]
867 fn route_gate_entries_match_like_the_global_gate() {
868 // CIDR entries and IPv4-mapped request addresses go through the
869 // same matcher as the global lists.
870 let gate = RouteIpGate::new(["198.51.100.16/28"], NIL).unwrap();
871 assert_eq!(gate.evaluate(ip("198.51.100.20")), RouteIpVerdict::Allowed);
872 assert_eq!(gate.evaluate(ip("198.51.100.32")), RouteIpVerdict::Denied);
873
874 let gate = RouteIpGate::new(["203.0.113.7"], NIL).unwrap();
875 assert_eq!(
876 gate.evaluate(IpAddr::from_str("::ffff:203.0.113.7").unwrap()),
877 RouteIpVerdict::Allowed,
878 "a v4-mapped request matches its IPv4 entry"
879 );
880 }
881
882 #[test]
883 #[should_panic(expected = "expected a denial")]
884 fn unwrap_denied_panics_on_an_allowed_verdict() {
885 // the helper's contract: it is only called on denied verdicts
886 let gate = IpGateConfig::new(["198.51.100.7"], NIL, NIL).unwrap();
887 let _ = gate.evaluate(ip("198.51.100.7")).unwrap_denied();
888 }
889}