Skip to main content

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
210/// The address family's bit width.
211const fn family_bits(addr: IpAddr) -> u8 {
212    match addr {
213        IpAddr::V4(_) => 32,
214        IpAddr::V6(_) => 128,
215    }
216}
217
218/// Clear the host bits of `addr` below `prefix`.
219fn masked(addr: IpAddr, prefix: u8) -> IpAddr {
220    match addr {
221        IpAddr::V4(v4) => IpAddr::V4(Ipv4Addr::from(u32::from(v4) & v4_mask(prefix))),
222        IpAddr::V6(v6) => IpAddr::V6(Ipv6Addr::from(u128::from(v6) & v6_mask(prefix))),
223    }
224}
225
226/// The IPv4 network mask for a prefix length (0 for `/0`).
227const fn v4_mask(prefix: u8) -> u32 {
228    if prefix == 0 {
229        0
230    } else {
231        u32::MAX << (32 - prefix)
232    }
233}
234
235/// The IPv6 network mask for a prefix length (0 for `/0`).
236const fn v6_mask(prefix: u8) -> u128 {
237    if prefix == 0 {
238        0
239    } else {
240        u128::MAX << (128 - prefix)
241    }
242}
243
244/// Whether one entry matches a request address.
245///
246/// The CIDR arms mirror the Go engine port exactly: the request address is
247/// tried in canonical (IPv4-unmapped) form first, then raw, so a v4-mapped
248/// request matches an IPv4 network while a raw v6 request still matches a v6
249/// network such as `::ffff:0:0/96`. Exact entries compare canonical forms on
250/// both sides.
251fn entry_matches(entry: &Entry, addr: IpAddr) -> bool {
252    match entry {
253        Entry::Exact(expected) => *expected == canonical(addr),
254        Entry::Network(net) => net.contains(canonical(addr)) || net.contains(addr),
255    }
256}
257
258/// Whether any entry of `entries` matches `addr`; an empty list matches
259/// nothing.
260fn list_matches(entries: &[Entry], addr: IpAddr) -> bool {
261    entries.iter().any(|entry| entry_matches(entry, addr))
262}
263
264/// An invalid list entry: the config error [`IpGateConfig::new`] fails with.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct IpGateError {
267    /// The list the rejected entry came from (`whitelist`, `blacklist`, or
268    /// `exempt_ips`).
269    pub list: &'static str,
270    /// The rejected entry.
271    pub entry: String,
272}
273
274impl fmt::Display for IpGateError {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        write!(
277            f,
278            "invalid {} entry '{}': expected an IP address or CIDR range",
279            self.list, self.entry
280        )
281    }
282}
283
284impl std::error::Error for IpGateError {}
285
286/// The global IP gate: the parsed `whitelist`, `blacklist`, and `exempt_ips`
287/// lists.
288///
289/// Build it once at startup with [`IpGateConfig::new`] (which fails closed on
290/// an invalid entry) and evaluate request IPs with
291/// [`IpGateConfig::evaluate`]. The default value is three empty lists: the
292/// gate is inert, denying nobody and exempting nobody.
293#[derive(Debug, Clone, Default, PartialEq, Eq)]
294pub struct IpGateConfig {
295    whitelist: Vec<Entry>,
296    blacklist: Vec<Entry>,
297    exempt_ips: Vec<Entry>,
298}
299
300impl IpGateConfig {
301    /// Parse and validate the three lists, failing closed on the first
302    /// invalid entry.
303    ///
304    /// Entry semantics match the reference whitelist matcher: a bare IP or a
305    /// `addr/prefix` CIDR range, IPv4-mapped forms included, host bits
306    /// cleared. The empty iterator is valid for any list.
307    ///
308    /// # Errors
309    ///
310    /// Returns an [`IpGateError`] naming the list and the first entry that is
311    /// neither a valid IP nor a valid CIDR range.
312    pub fn new<W, B, E>(whitelist: W, blacklist: B, exempt_ips: E) -> Result<Self, IpGateError>
313    where
314        W: IntoIterator,
315        W::Item: AsRef<str>,
316        B: IntoIterator,
317        B::Item: AsRef<str>,
318        E: IntoIterator,
319        E::Item: AsRef<str>,
320    {
321        Ok(Self {
322            whitelist: parse_list("whitelist", whitelist)?,
323            blacklist: parse_list("blacklist", blacklist)?,
324            exempt_ips: parse_list("exempt_ips", exempt_ips)?,
325        })
326    }
327
328    /// Evaluate a request IP against the gate.
329    ///
330    /// The ordering is the reference contract's: with a non-empty whitelist
331    /// the whitelist gate decides (an unlisted, non-exempt IP is denied; an
332    /// exempt IP does **not** pass the gate), otherwise the blacklist decides.
333    /// The exemption flag is only set on an allowed verdict: it never adds a
334    /// deny path and never removes one.
335    #[must_use]
336    pub fn evaluate(&self, ip: IpAddr) -> IpGateVerdict {
337        if !self.whitelist.is_empty() {
338            if !list_matches(&self.whitelist, ip) {
339                return IpGateVerdict::Denied(IpGateDenial::NotInWhitelist);
340            }
341            return IpGateVerdict::Allowed(IpGateDecision {
342                is_whitelisted: true,
343                is_exempt: list_matches(&self.exempt_ips, ip),
344            });
345        }
346        if !self.blacklist.is_empty() && list_matches(&self.blacklist, ip) {
347            return IpGateVerdict::Denied(IpGateDenial::Blacklisted);
348        }
349        IpGateVerdict::Allowed(IpGateDecision {
350            is_whitelisted: false,
351            is_exempt: list_matches(&self.exempt_ips, ip),
352        })
353    }
354}
355
356/// The outcome of the route-level IP gate for one request IP
357/// (`check_route_ip_access`'s tri-state, the country arms aside).
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359pub enum RouteIpVerdict {
360    /// The route IP lists leave the request alone: neither list matched in a
361    /// denying way and no restrictive whitelist claims the route. The global
362    /// gate decides as usual.
363    Unrestricted,
364    /// A configured route whitelist matched the IP (or nothing denied): the
365    /// route stage passes.
366    Allowed,
367    /// A route blacklist match, or a miss under a configured route
368    /// whitelist: the route stage denies.
369    Denied,
370}
371
372/// The route-level IP gate: a route decorator's `ip_whitelist` and
373/// `ip_blacklist` (`RouteConfig.ip_whitelist` / `.ip_blacklist`).
374///
375/// This is the Rust family's port of the reference engine's route IP stage
376/// (`guard_core/core/checks/helpers.py::check_route_ip_access`, the IP-list
377/// arms; the Go and PHP family ports run the same order). One call,
378/// [`RouteIpGate::evaluate`], decides a request IP against one route:
379///
380/// ```text
381/// blacklist match:            deny (a blacklisted IP is denied even when a
382///                             whitelist would take it)
383/// whitelist configured, miss: deny (the whitelist takes over the route verdict)
384/// whitelist match:            pass the route stage
385/// neither list configured:    unrestricted (the global gate decides)
386/// ```
387///
388/// Matching semantics are the global gate's ([`IpGateConfig`]): a bare IP or
389/// a CIDR range per entry, IPv4-mapped request addresses canonicalized,
390/// families never crossing. Invalid entries are config errors:
391/// [`RouteIpGate::new`] fails closed.
392///
393/// The global lists are still enforced afterwards by the caller, so a route
394/// whitelist match never relaxes the global gate; it only clears the route
395/// verdict.
396///
397/// # Example
398///
399/// ```
400/// use std::net::IpAddr;
401/// use std::str::FromStr;
402///
403/// use guard_core_engine::ip_gate::{RouteIpGate, RouteIpVerdict};
404///
405/// let gate = RouteIpGate::new(["192.0.2.40"], ["203.0.113.9"]).expect("valid lists");
406///
407/// // A whitelist member passes the route stage.
408/// assert_eq!(
409///     gate.evaluate(IpAddr::from_str("192.0.2.40").unwrap()),
410///     RouteIpVerdict::Allowed
411/// );
412/// // Everyone else on the route is denied: the whitelist takes over.
413/// assert_eq!(
414///     gate.evaluate(IpAddr::from_str("192.0.2.11").unwrap()),
415///     RouteIpVerdict::Denied
416/// );
417/// // A blacklisted IP is denied even when a whitelist entry would take it.
418/// assert_eq!(
419///     gate.evaluate(IpAddr::from_str("203.0.113.9").unwrap()),
420///     RouteIpVerdict::Denied
421/// );
422///
423/// // No lists: the route leaves the request to the global gate.
424/// let open = RouteIpGate::new([] as [&str; 0], [] as [&str; 0]).expect("valid lists");
425/// assert_eq!(
426///     open.evaluate(IpAddr::from_str("192.0.2.11").unwrap()),
427///     RouteIpVerdict::Unrestricted
428/// );
429/// ```
430#[derive(Debug, Clone, Default, PartialEq, Eq)]
431pub struct RouteIpGate {
432    whitelist: Vec<Entry>,
433    blacklist: Vec<Entry>,
434}
435
436impl RouteIpGate {
437    /// Parse the route's two lists, failing closed on the first invalid
438    /// entry (the list names in the error are `ip_whitelist` and
439    /// `ip_blacklist`).
440    ///
441    /// # Errors
442    ///
443    /// Returns an [`IpGateError`] naming the list and the first entry that is
444    /// neither a valid IP nor a valid CIDR range.
445    pub fn new<W, B>(whitelist: W, blacklist: B) -> Result<Self, IpGateError>
446    where
447        W: IntoIterator,
448        W::Item: AsRef<str>,
449        B: IntoIterator,
450        B::Item: AsRef<str>,
451    {
452        Ok(Self {
453            whitelist: parse_list("ip_whitelist", whitelist)?,
454            blacklist: parse_list("ip_blacklist", blacklist)?,
455        })
456    }
457
458    /// Whether a route whitelist is configured (non-empty). The reference
459    /// `_route_overrides_ip_lists`: a configured route whitelist also
460    /// overrides the global IP lists for the request, so the caller skips
461    /// the global gate's deny paths.
462    #[must_use]
463    pub const fn whitelist_configured(&self) -> bool {
464        !self.whitelist.is_empty()
465    }
466
467    /// Evaluate a request IP against the route's lists: the blacklist
468    /// denies first, then a configured whitelist takes over the route
469    /// verdict (a miss denies, a match passes), and no list leaves the
470    /// request unrestricted.
471    #[must_use]
472    pub fn evaluate(&self, ip: IpAddr) -> RouteIpVerdict {
473        if !self.blacklist.is_empty() && list_matches(&self.blacklist, ip) {
474            return RouteIpVerdict::Denied;
475        }
476        if !self.whitelist.is_empty() {
477            return if list_matches(&self.whitelist, ip) {
478                RouteIpVerdict::Allowed
479            } else {
480                RouteIpVerdict::Denied
481            };
482        }
483        RouteIpVerdict::Unrestricted
484    }
485}
486
487/// Parse one list, reporting the first invalid entry with the list's name.
488fn parse_list<I>(list: &'static str, entries: I) -> Result<Vec<Entry>, IpGateError>
489where
490    I: IntoIterator,
491    I::Item: AsRef<str>,
492{
493    let mut parsed = Vec::new();
494    for entry in entries {
495        let entry = entry.as_ref();
496        match parse_entry(entry) {
497            Some(parsed_entry) => parsed.push(parsed_entry),
498            None => {
499                return Err(IpGateError {
500                    list,
501                    entry: entry.to_owned(),
502                });
503            }
504        }
505    }
506    Ok(parsed)
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    /// The empty list, typed so the `new` calls stay inferable.
514    const NIL: [&str; 0] = [];
515
516    fn ip(text: &str) -> IpAddr {
517        IpAddr::from_str(text).expect("test address")
518    }
519
520    #[test]
521    fn parse_entry_forms() {
522        assert_eq!(
523            parse_entry("203.0.113.7"),
524            Some(Entry::Exact(ip("203.0.113.7")))
525        );
526        assert_eq!(
527            parse_entry("198.51.100.16/28"),
528            Some(Entry::Network(IpNet {
529                addr: ip("198.51.100.16"),
530                prefix: 28
531            }))
532        );
533        // Host bits are cleared at parse time.
534        assert_eq!(
535            parse_entry("198.51.100.20/28"),
536            parse_entry("198.51.100.16/28")
537        );
538        // IPv4-mapped exact entries canonicalize to IPv4.
539        assert_eq!(
540            parse_entry("::ffff:203.0.113.7"),
541            Some(Entry::Exact(ip("203.0.113.7")))
542        );
543        // A mapped CIDR entry keeps its IPv6 family, as in Python and Go.
544        assert_eq!(
545            parse_entry("::ffff:0:0/96"),
546            Some(Entry::Network(IpNet {
547                addr: ip("::ffff:0:0"),
548                prefix: 96
549            }))
550        );
551    }
552
553    #[test]
554    fn parse_entry_rejects_junk() {
555        for junk in [
556            "not-an-ip",
557            "",
558            "203.0.113.0/33",
559            "203.0.113.7/",
560            "/24",
561            "::1/129",
562        ] {
563            assert_eq!(parse_entry(junk), None, "{junk} must not parse");
564        }
565    }
566
567    #[test]
568    fn family_never_crosses() {
569        let net = IpNet::parse("203.0.113.0/24").expect("v4 network");
570        assert!(net.contains(ip("203.0.113.7")));
571        // An unrelated v6 address is never inside a v4 network. The v4-mapped
572        // v6 form matches through the mapped arm of `entry_matches`, never
573        // through a family crossing here.
574        assert!(!net.contains(ip("::1")));
575        let net = IpNet::parse("2001:db8::/32").expect("v6 network");
576        assert!(net.contains(ip("2001:db8::1")));
577        assert!(!net.contains(ip("203.0.113.7")));
578    }
579
580    #[test]
581    fn zero_prefix_networks_match_everything_in_family() {
582        let net = IpNet::parse("0.0.0.0/0").expect("v4 /0");
583        assert!(net.contains(ip("203.0.113.7")));
584        assert!(!net.contains(ip("2001:db8::1")));
585        let net = IpNet::parse("::/0").expect("v6 /0");
586        assert!(net.contains(ip("2001:db8::1")));
587        assert!(!net.contains(ip("203.0.113.7")));
588    }
589
590    #[test]
591    fn new_reports_the_list_and_entry_of_the_first_reject() {
592        let error = RouteIpGate::new(["203.0.113.0/33"], NIL).unwrap_err();
593        assert_eq!(error.list, "ip_whitelist");
594        assert_eq!(error.entry, "203.0.113.0/33");
595
596        let error = RouteIpGate::new(NIL, ["junk"]).unwrap_err();
597        assert_eq!(error.list, "ip_blacklist");
598        assert_eq!(error.entry, "junk");
599
600        let error = IpGateConfig::new(["203.0.113.7"], ["ok-but-not-an-ip"], NIL).unwrap_err();
601        assert_eq!(error.list, "blacklist");
602        assert_eq!(error.entry, "ok-but-not-an-ip");
603        assert_eq!(
604            error.to_string(),
605            "invalid blacklist entry 'ok-but-not-an-ip': expected an IP address or CIDR range"
606        );
607
608        let error = IpGateConfig::new(NIL, NIL, ["203.0.113.0/33"]).unwrap_err();
609        assert_eq!(error.list, "exempt_ips");
610
611        let error = IpGateConfig::new(["::1/129"], NIL, NIL).unwrap_err();
612        assert_eq!(error.list, "whitelist");
613    }
614
615    #[test]
616    fn new_accepts_empty_lists_and_the_default_is_inert() {
617        let gate = IpGateConfig::new(NIL, NIL, NIL).expect("empty lists");
618        for address in ["203.0.113.7", "2001:db8::1", "::ffff:203.0.113.7"] {
619            assert_eq!(
620                gate.evaluate(ip(address)),
621                IpGateVerdict::Allowed(IpGateDecision::default())
622            );
623        }
624        assert_eq!(
625            IpGateConfig::default().evaluate(ip("192.0.2.1")),
626            IpGateVerdict::Allowed(IpGateDecision::default())
627        );
628    }
629
630    #[test]
631    fn blacklist_denies_and_exemption_does_not_win() {
632        let gate =
633            IpGateConfig::new(NIL, ["203.0.113.9", "198.51.100.0/24"], ["203.0.113.9"]).unwrap();
634        assert_eq!(
635            gate.evaluate(ip("203.0.113.9")),
636            IpGateVerdict::Denied(IpGateDenial::Blacklisted)
637        );
638        assert_eq!(
639            gate.evaluate(ip("198.51.100.77")),
640            IpGateVerdict::Denied(IpGateDenial::Blacklisted)
641        );
642        assert_eq!(
643            gate.evaluate(ip("203.0.113.9")).unwrap_denied().reason(),
644            "IP is blacklisted"
645        );
646        // A blacklisted range does not leak onto its neighbors.
647        assert!(matches!(
648            gate.evaluate(ip("198.51.200.1")),
649            IpGateVerdict::Allowed(_)
650        ));
651    }
652
653    #[test]
654    fn exemption_never_opens_the_whitelist_gate() {
655        let gate = IpGateConfig::new(["192.0.2.1"], NIL, ["203.0.113.7"]).unwrap();
656        assert_eq!(
657            gate.evaluate(ip("203.0.113.7")),
658            IpGateVerdict::Denied(IpGateDenial::NotInWhitelist)
659        );
660        assert_eq!(
661            gate.evaluate(ip("203.0.113.7")).unwrap_denied().reason(),
662            "IP not in whitelist"
663        );
664        assert!(matches!(
665            gate.evaluate(ip("192.0.2.1")),
666            IpGateVerdict::Allowed(IpGateDecision {
667                is_whitelisted: true,
668                is_exempt: false
669            })
670        ));
671    }
672
673    #[test]
674    fn an_ip_on_both_lists_is_simply_a_whitelist_match() {
675        let gate = IpGateConfig::new(["203.0.113.7"], NIL, ["203.0.113.7"]).unwrap();
676        assert_eq!(
677            gate.evaluate(ip("203.0.113.7")),
678            IpGateVerdict::Allowed(IpGateDecision {
679                is_whitelisted: true,
680                is_exempt: true
681            })
682        );
683    }
684
685    #[test]
686    fn exempt_exact_and_cidr_matches_set_the_flag_without_deny_paths() {
687        let gate = IpGateConfig::new(NIL, NIL, ["198.51.100.7", "198.51.100.16/28"]).unwrap();
688        for address in ["198.51.100.7", "198.51.100.20", "::ffff:198.51.100.7"] {
689            assert_eq!(
690                gate.evaluate(ip(address)),
691                IpGateVerdict::Allowed(IpGateDecision {
692                    is_whitelisted: false,
693                    is_exempt: true
694                }),
695                "{address} must be exempt"
696            );
697        }
698        assert_eq!(
699            gate.evaluate(ip("198.51.100.15")),
700            IpGateVerdict::Allowed(IpGateDecision::default()),
701            "the address just below the CIDR range is not exempt"
702        );
703    }
704
705    #[test]
706    fn ipv4_mapped_parity_across_all_list_forms() {
707        // v4-mapped request against an exact v4 entry.
708        let gate = IpGateConfig::new(["::ffff:203.0.113.7"], NIL, NIL).unwrap();
709        assert!(matches!(
710            gate.evaluate(ip("203.0.113.7")),
711            IpGateVerdict::Allowed(IpGateDecision {
712                is_whitelisted: true,
713                ..
714            })
715        ));
716        // v4-mapped request against a v4 CIDR entry.
717        let gate = IpGateConfig::new(["203.0.113.0/24"], NIL, NIL).unwrap();
718        assert!(matches!(
719            gate.evaluate(ip("::ffff:203.0.113.99")),
720            IpGateVerdict::Allowed(IpGateDecision {
721                is_whitelisted: true,
722                ..
723            })
724        ));
725        // A v6-mapped CIDR entry still matches the raw v6 form.
726        let gate = IpGateConfig::new(["::ffff:0:0/96"], NIL, NIL).unwrap();
727        assert!(matches!(
728            gate.evaluate(ip("::ffff:203.0.113.99")),
729            IpGateVerdict::Allowed(IpGateDecision {
730                is_whitelisted: true,
731                ..
732            })
733        ));
734        assert!(!matches!(
735            gate.evaluate(ip("2001:db8::1")),
736            IpGateVerdict::Allowed(IpGateDecision {
737                is_whitelisted: true,
738                ..
739            })
740        ));
741    }
742
743    #[test]
744    fn exempt_flag_is_only_set_after_the_deny_checks_pass() {
745        let gate = IpGateConfig::new(NIL, ["198.51.100.7"], ["198.51.100.7"]).unwrap();
746        match gate.evaluate(ip("198.51.100.7")) {
747            IpGateVerdict::Denied(denial) => assert_eq!(denial, IpGateDenial::Blacklisted),
748            IpGateVerdict::Allowed(_) => panic!("a blacklisted exempt IP must be denied"),
749        }
750    }
751
752    impl IpGateVerdict {
753        /// The denial of a `Denied` verdict, for assertions.
754        fn unwrap_denied(self) -> IpGateDenial {
755            match self {
756                Self::Allowed(_) => panic!("expected a denial"),
757                Self::Denied(denial) => denial,
758            }
759        }
760    }
761
762    #[test]
763    fn route_gate_whitelist_takes_over_the_route_verdict() {
764        // The corpus shape (pipeline_ip_control::route_ip_whitelist_denies_other_ip).
765        let gate = RouteIpGate::new(["192.0.2.40"], NIL).unwrap();
766        assert!(gate.whitelist_configured());
767        assert_eq!(
768            gate.evaluate(ip("192.0.2.40")),
769            RouteIpVerdict::Allowed,
770            "a whitelist member passes the route stage"
771        );
772        assert_eq!(
773            gate.evaluate(ip("192.0.2.11")),
774            RouteIpVerdict::Denied,
775            "a miss under a configured whitelist denies"
776        );
777    }
778
779    #[test]
780    fn route_gate_blacklist_denies_first() {
781        let gate = RouteIpGate::new(["192.0.2.40"], ["203.0.113.9", "198.51.100.0/24"]).unwrap();
782        assert_eq!(
783            gate.evaluate(ip("203.0.113.9")),
784            RouteIpVerdict::Denied,
785            "a blacklist match denies"
786        );
787        // A blacklisted IP is denied even when the whitelist would take it.
788        assert_eq!(
789            gate.evaluate(ip("198.51.100.7")),
790            RouteIpVerdict::Denied,
791            "blacklist beats the whitelist"
792        );
793        assert_eq!(
794            gate.evaluate(ip("192.0.2.40")),
795            RouteIpVerdict::Allowed,
796            "the whitelist still passes its members"
797        );
798        assert_eq!(
799            gate.evaluate(ip("192.0.2.11")),
800            RouteIpVerdict::Denied,
801            "the whitelist still denies its misses"
802        );
803    }
804
805    #[test]
806    fn route_gate_blacklist_only_denies_its_matches() {
807        let gate = RouteIpGate::new(NIL, ["203.0.113.9"]).unwrap();
808        assert!(!gate.whitelist_configured());
809        assert_eq!(gate.evaluate(ip("203.0.113.9")), RouteIpVerdict::Denied);
810        assert_eq!(
811            gate.evaluate(ip("192.0.2.11")),
812            RouteIpVerdict::Unrestricted,
813            "a non-member is left to the global gate"
814        );
815    }
816
817    #[test]
818    fn route_gate_empty_is_unrestricted() {
819        let gate = RouteIpGate::new(NIL, NIL).unwrap();
820        assert!(!gate.whitelist_configured());
821        assert_eq!(
822            gate.evaluate(ip("192.0.2.11")),
823            RouteIpVerdict::Unrestricted
824        );
825    }
826
827    #[test]
828    fn route_gate_entries_match_like_the_global_gate() {
829        // CIDR entries and IPv4-mapped request addresses go through the
830        // same matcher as the global lists.
831        let gate = RouteIpGate::new(["198.51.100.16/28"], NIL).unwrap();
832        assert_eq!(gate.evaluate(ip("198.51.100.20")), RouteIpVerdict::Allowed);
833        assert_eq!(gate.evaluate(ip("198.51.100.32")), RouteIpVerdict::Denied);
834
835        let gate = RouteIpGate::new(["203.0.113.7"], NIL).unwrap();
836        assert_eq!(
837            gate.evaluate(IpAddr::from_str("::ffff:203.0.113.7").unwrap()),
838            RouteIpVerdict::Allowed,
839            "a v4-mapped request matches its IPv4 entry"
840        );
841    }
842}