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