Skip to main content

cageforge_policy/
network.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Domain, resolved-address, and Unix-socket network policy.
4//!
5//! [`crate::NetworkPolicy`] provides declarative queries and the safe
6//! [`crate::NetworkPolicy::authorize_connection`] handoff. The latter consumes
7//! a [`crate::ResolvedNetworkTarget`] and returns a non-copyable
8//! [`crate::AuthorizedSocketAddr`] so the backend can connect only to the
9//! checked address.
10
11use crate::PathSelector;
12use crate::PolicyError;
13use cageforge_path::{NativePathKey, paths_equal};
14use globset::GlobBuilder;
15use globset::GlobMatcher;
16use std::collections::{HashMap, HashSet};
17use std::hash::{Hash, Hasher};
18use std::net::{IpAddr, Ipv4Addr, SocketAddr};
19use std::path::Path;
20use std::path::PathBuf;
21
22mod model;
23
24use model::DomainMatcher;
25pub use model::{
26    AuthorizedSocketAddr, ConnectionAuthorization, DomainAccess, DomainMode, DomainRule,
27    LocalNetworkAccess, NetworkDecision, NetworkMode, NetworkPolicy, ResolvedNetworkTarget,
28    UnixSocketMode, UnixSocketRule,
29};
30
31impl NetworkDecision {
32    pub(crate) const fn is_allowed(self) -> bool {
33        matches!(self, Self::Allow)
34    }
35
36    /// Returns whether another trusted component owns enforcement.
37    pub const fn is_externally_enforced(self) -> bool {
38        matches!(self, Self::ExternallyEnforced)
39    }
40}
41
42impl AuthorizedSocketAddr {
43    /// Consumes the authorization and returns the exact checked address.
44    ///
45    /// The value is intentionally neither `Copy` nor `Clone`: an adapter
46    /// should hand it directly to its connection operation instead of keeping
47    /// a reusable authorization token.
48    ///
49    /// ```compile_fail
50    /// use cageforge_policy::AuthorizedSocketAddr;
51    ///
52    /// fn require_clone<T: Clone>() {}
53    /// require_clone::<AuthorizedSocketAddr>();
54    /// ```
55    pub const fn into_socket_addr(self) -> SocketAddr {
56        self.0
57    }
58}
59
60impl ResolvedNetworkTarget {
61    /// Creates a target from a host and the addresses resolved for it.
62    ///
63    /// An empty address list represents failed or timed-out resolution and is
64    /// retained so policy evaluation can fail closed with `Deny`.
65    pub fn new(
66        domain: impl Into<String>,
67        addresses: impl IntoIterator<Item = SocketAddr>,
68    ) -> Result<Self, PolicyError> {
69        let domain = normalize_domain(&domain.into())?;
70        let literal = parse_ip_literal(&domain);
71        let mut seen = HashSet::new();
72        let mut unique_addresses = Vec::new();
73        for address in addresses {
74            if literal.is_some_and(|literal| literal != address.ip()) {
75                return Err(PolicyError::ResolvedAddressMismatch {
76                    literal: domain,
77                    address,
78                });
79            }
80            if seen.insert(address) {
81                unique_addresses.push(address);
82            }
83        }
84        Ok(Self {
85            domain,
86            addresses: unique_addresses,
87        })
88    }
89
90    /// Returns the normalized host used for policy matching.
91    pub fn domain(&self) -> &str {
92        &self.domain
93    }
94
95    /// Returns the exact addresses captured for this resolution attempt.
96    pub fn addresses(&self) -> &[SocketAddr] {
97        &self.addresses
98    }
99
100    /// Returns whether an actual connection address belongs to this snapshot.
101    pub fn contains_address(&self, address: SocketAddr) -> bool {
102        self.addresses.contains(&address)
103    }
104}
105
106impl PartialEq for DomainRule {
107    fn eq(&self, other: &Self) -> bool {
108        self.pattern == other.pattern && self.access == other.access
109    }
110}
111
112impl Eq for DomainRule {}
113
114impl Hash for DomainRule {
115    fn hash<H: Hasher>(&self, state: &mut H) {
116        self.pattern.hash(state);
117        self.access.hash(state);
118    }
119}
120
121impl DomainRule {
122    /// Creates and validates a domain rule.
123    pub fn new(pattern: impl Into<String>, access: DomainAccess) -> Result<Self, PolicyError> {
124        let pattern = normalize_domain_pattern(&pattern.into())?;
125        let matcher = compile_domain_matcher(&pattern)?;
126        Ok(Self {
127            pattern,
128            access,
129            matcher,
130        })
131    }
132
133    /// Returns the normalized pattern.
134    pub fn pattern(&self) -> &str {
135        &self.pattern
136    }
137
138    /// Returns the access decision.
139    pub const fn access(&self) -> DomainAccess {
140        self.access
141    }
142
143    fn matches(&self, domain: &str) -> bool {
144        match &self.matcher {
145            DomainMatcher::Any => true,
146            DomainMatcher::Full(matcher) => matcher.is_match(domain),
147            DomainMatcher::Suffix {
148                labels,
149                include_apex,
150            } => {
151                let domain_label_count = domain.split('.').count();
152                if domain_label_count < labels.len()
153                    || (!include_apex && domain_label_count == labels.len())
154                {
155                    return false;
156                }
157                domain
158                    .rsplit('.')
159                    .zip(labels.iter().rev())
160                    .all(|(domain, matcher)| matcher.is_match(domain))
161            }
162        }
163    }
164}
165
166impl PartialEq for UnixSocketRule {
167    fn eq(&self, other: &Self) -> bool {
168        NativePathKey::new(&self.path) == NativePathKey::new(&other.path)
169            && self.access == other.access
170    }
171}
172
173impl Eq for UnixSocketRule {}
174
175impl Hash for UnixSocketRule {
176    fn hash<H: Hasher>(&self, state: &mut H) {
177        NativePathKey::new(&self.path).hash(state);
178        self.access.hash(state);
179    }
180}
181
182impl UnixSocketRule {
183    /// Creates an absolute Unix socket rule.
184    pub fn new(path: impl Into<PathBuf>, access: DomainAccess) -> Result<Self, PolicyError> {
185        let path = path.into();
186        PathSelector::absolute(path.clone())?;
187        Ok(Self { path, access })
188    }
189
190    /// Returns the socket path.
191    pub fn path(&self) -> &std::path::Path {
192        &self.path
193    }
194
195    /// Returns the access decision.
196    pub const fn access(&self) -> DomainAccess {
197        self.access
198    }
199}
200
201impl NetworkPolicy {
202    /// Creates a policy with command networking disabled.
203    pub const fn disabled() -> Self {
204        Self {
205            mode: NetworkMode::Disabled,
206            domain_mode: DomainMode::Disabled,
207            unix_socket_mode: UnixSocketMode::Disabled,
208            local_network_access: LocalNetworkAccess::Deny,
209            domains: Vec::new(),
210            unix_sockets: Vec::new(),
211        }
212    }
213
214    /// Creates a policy with IP networking enabled and pathname Unix sockets
215    /// disabled.
216    ///
217    /// Call [`Self::with_unix_socket_mode`] explicitly when pathname Unix
218    /// sockets should use an allowlist or be unrestricted.
219    pub const fn enabled() -> Self {
220        Self {
221            mode: NetworkMode::Enabled,
222            domain_mode: DomainMode::Enabled,
223            unix_socket_mode: UnixSocketMode::Disabled,
224            local_network_access: LocalNetworkAccess::Deny,
225            domains: Vec::new(),
226            unix_sockets: Vec::new(),
227        }
228    }
229
230    /// Creates a network policy with no local restrictions.
231    pub const fn unrestricted() -> Self {
232        Self {
233            mode: NetworkMode::Enabled,
234            domain_mode: DomainMode::Enabled,
235            unix_socket_mode: UnixSocketMode::Enabled,
236            local_network_access: LocalNetworkAccess::Allow,
237            domains: Vec::new(),
238            unix_sockets: Vec::new(),
239        }
240    }
241
242    /// Creates a policy whose network boundary is owned externally.
243    pub const fn external() -> Self {
244        Self {
245            mode: NetworkMode::External,
246            domain_mode: DomainMode::Disabled,
247            unix_socket_mode: UnixSocketMode::Disabled,
248            local_network_access: LocalNetworkAccess::Deny,
249            domains: Vec::new(),
250            unix_sockets: Vec::new(),
251        }
252    }
253
254    /// Returns the enforcement mode.
255    pub const fn mode(&self) -> NetworkMode {
256        self.mode
257    }
258
259    /// Returns the default behavior for unmatched domains.
260    pub const fn domain_mode(&self) -> DomainMode {
261        self.domain_mode
262    }
263
264    /// Returns the default behavior for unmatched Unix socket paths.
265    pub const fn unix_socket_mode(&self) -> UnixSocketMode {
266        self.unix_socket_mode
267    }
268
269    /// Returns the policy for non-public IP addresses reached through domains.
270    pub const fn local_network_access(&self) -> LocalNetworkAccess {
271        self.local_network_access
272    }
273
274    /// Sets the default behavior for unmatched domains.
275    pub const fn with_domain_mode(mut self, mode: DomainMode) -> Self {
276        self.domain_mode = mode;
277        self
278    }
279
280    /// Sets the default behavior for unmatched Unix socket paths.
281    pub const fn with_unix_socket_mode(mut self, mode: UnixSocketMode) -> Self {
282        self.unix_socket_mode = mode;
283        self
284    }
285
286    /// Sets whether resolved non-public IP addresses may be reached.
287    pub const fn with_local_network_access(mut self, access: LocalNetworkAccess) -> Self {
288        self.local_network_access = access;
289        self
290    }
291
292    /// Returns domain rules in declaration order.
293    pub fn domains(&self) -> &[DomainRule] {
294        &self.domains
295    }
296
297    /// Returns Unix socket rules in declaration order.
298    pub fn unix_sockets(&self) -> &[UnixSocketRule] {
299        &self.unix_sockets
300    }
301
302    /// Adds a domain rule.
303    pub fn with_domain(
304        mut self,
305        pattern: impl Into<String>,
306        access: DomainAccess,
307    ) -> Result<Self, PolicyError> {
308        if self.mode == NetworkMode::External {
309            return Err(PolicyError::InvalidRule {
310                message: "network rules cannot be added to an external policy".to_string(),
311            });
312        }
313        self.domains.push(DomainRule::new(pattern, access)?);
314        Ok(self)
315    }
316
317    /// Adds a Unix socket rule.
318    ///
319    /// Rules are evaluated according to [`Self::unix_socket_mode`]. In
320    /// particular, a policy whose socket mode is [`UnixSocketMode::Disabled`]
321    /// remains deny-all even when it carries rules.
322    pub fn with_unix_socket(
323        mut self,
324        path: impl Into<PathBuf>,
325        access: DomainAccess,
326    ) -> Result<Self, PolicyError> {
327        if self.mode == NetworkMode::External {
328            return Err(PolicyError::InvalidRule {
329                message: "network rules cannot be added to an external policy".to_string(),
330            });
331        }
332        self.unix_sockets.push(UnixSocketRule::new(path, access)?);
333        Ok(self)
334    }
335
336    /// Validates that an externally enforced mode has no local rules.
337    pub fn validate(&self) -> Result<(), PolicyError> {
338        if self.mode == NetworkMode::External
339            && (self.domain_mode != DomainMode::Disabled
340                || self.unix_socket_mode != UnixSocketMode::Disabled
341                || self.local_network_access != LocalNetworkAccess::Deny
342                || !self.domains.is_empty()
343                || !self.unix_sockets.is_empty())
344        {
345            return Err(PolicyError::InvalidRule {
346                message: "external network policies cannot contain local settings".to_string(),
347            });
348        }
349        Ok(())
350    }
351
352    /// Returns a policy with semantically duplicate rules collapsed using
353    /// deny precedence.
354    pub fn normalized(&self) -> Result<Self, PolicyError> {
355        self.validate()?;
356        let mut domains: Vec<DomainRule> = Vec::with_capacity(self.domains.len());
357        let mut domain_positions: HashMap<String, usize> =
358            HashMap::with_capacity(self.domains.len());
359        for rule in &self.domains {
360            if let Some(&index) = domain_positions.get(rule.pattern()) {
361                if rule.access() == DomainAccess::Deny {
362                    domains[index].access = DomainAccess::Deny;
363                }
364            } else {
365                domain_positions.insert(rule.pattern().to_owned(), domains.len());
366                domains.push(rule.clone());
367            }
368        }
369
370        let mut unix_sockets: Vec<UnixSocketRule> = Vec::with_capacity(self.unix_sockets.len());
371        let mut socket_positions: HashMap<NativePathKey, usize> =
372            HashMap::with_capacity(self.unix_sockets.len());
373        for rule in &self.unix_sockets {
374            let key = NativePathKey::new(rule.path());
375            if let Some(&index) = socket_positions.get(&key) {
376                if rule.access() == DomainAccess::Deny {
377                    unix_sockets[index].access = DomainAccess::Deny;
378                }
379            } else {
380                socket_positions.insert(key, unix_sockets.len());
381                unix_sockets.push(rule.clone());
382            }
383        }
384
385        Ok(Self {
386            mode: self.mode,
387            domain_mode: self.domain_mode,
388            unix_socket_mode: self.unix_socket_mode,
389            local_network_access: self.local_network_access,
390            domains,
391            unix_sockets,
392        })
393    }
394
395    /// Evaluates a domain against the complete network policy.
396    ///
397    /// Returns the policy result for a hostname without authorizing a socket
398    /// connection. Use a [`ResolvedNetworkTarget`] and the exact connected
399    /// address methods for connection checks.
400    pub fn decision_for_domain(&self, domain: &str) -> Result<NetworkDecision, PolicyError> {
401        let domain = normalize_domain(domain)?;
402        match self.mode {
403            NetworkMode::Disabled => Ok(NetworkDecision::Deny),
404            NetworkMode::External => Ok(NetworkDecision::ExternallyEnforced),
405            NetworkMode::Enabled => match self.domain_mode {
406                DomainMode::Disabled => Ok(NetworkDecision::Deny),
407                DomainMode::Enabled => Ok(
408                    if matches!(
409                        self.access_for_normalized_domain(&domain),
410                        Some(DomainAccess::Deny)
411                    ) {
412                        NetworkDecision::Deny
413                    } else {
414                        NetworkDecision::Allow
415                    },
416                ),
417                DomainMode::Restricted => Ok(
418                    if matches!(
419                        self.access_for_normalized_domain(&domain),
420                        Some(DomainAccess::Allow)
421                    ) {
422                        NetworkDecision::Allow
423                    } else {
424                        NetworkDecision::Deny
425                    },
426                ),
427            },
428        }
429    }
430
431    /// Evaluates a resolved target without performing DNS or network I/O.
432    ///
433    /// This is the safe policy-level entry point for a backend that has
434    /// already resolved a host. It checks every captured address and fails
435    /// closed for an empty address list.
436    fn decision_for_resolved_target(
437        &self,
438        target: &ResolvedNetworkTarget,
439    ) -> Result<NetworkDecision, PolicyError> {
440        let resolved_ips: Vec<_> = target.addresses().iter().map(SocketAddr::ip).collect();
441        self.decision_for_domain_with_resolved_ips(target.domain(), &resolved_ips)
442    }
443
444    /// Evaluates the exact address a backend is about to connect to.
445    ///
446    /// A locally allowed target is denied when the actual address was not in
447    /// the original resolution snapshot. External ownership remains external
448    /// because the other enforcement boundary owns the connection check.
449    fn decision_for_connected_address(
450        &self,
451        target: &ResolvedNetworkTarget,
452        connected: SocketAddr,
453    ) -> Result<NetworkDecision, PolicyError> {
454        let decision = self.decision_for_resolved_target(target)?;
455        if !decision.is_allowed() {
456            return Ok(decision);
457        }
458        Ok(if target.contains_address(connected) {
459            NetworkDecision::Allow
460        } else {
461            NetworkDecision::Deny
462        })
463    }
464
465    /// Authorizes the exact socket address a backend is about to connect to.
466    ///
467    /// The returned [`ConnectionAuthorization::Allowed`] value contains the
468    /// only address that passed the policy check. A backend must connect using
469    /// that address and must not resolve the hostname again.
470    pub fn authorize_connection(
471        &self,
472        target: &ResolvedNetworkTarget,
473        connected: SocketAddr,
474    ) -> Result<ConnectionAuthorization, PolicyError> {
475        Ok(
476            match self.decision_for_connected_address(target, connected)? {
477                NetworkDecision::Allow => {
478                    ConnectionAuthorization::Allowed(AuthorizedSocketAddr(connected))
479                }
480                NetworkDecision::Deny => ConnectionAuthorization::Denied,
481                NetworkDecision::ExternallyEnforced => ConnectionAuthorization::ExternallyEnforced,
482            },
483        )
484    }
485
486    /// Evaluates a domain together with addresses resolved by a future
487    /// network backend.
488    ///
489    /// This method deliberately performs no DNS lookup. For a hostname, the
490    /// backend must resolve the name, pass every result here, and pass an
491    /// empty slice when resolution fails or times out. Such an empty hostname
492    /// result is denied. Hostnames resolving to any non-public address are
493    /// denied by default to prevent DNS rebinding from bypassing the domain
494    /// policy. An IP literal does not require DNS results: an empty slice is
495    /// valid for the literal itself, while non-public literals still require
496    /// an exact literal allow or [`LocalNetworkAccess::Allow`]. Prefer
497    /// [`ResolvedNetworkTarget`] and [`Self::authorize_connection`] in backend
498    /// code for actual connections. The target must contain the exact socket
499    /// address that will be used, so a target with no addresses cannot be
500    /// connected through the authorization API.
501    pub fn decision_for_domain_with_resolved_ips(
502        &self,
503        domain: &str,
504        resolved_ips: &[IpAddr],
505    ) -> Result<NetworkDecision, PolicyError> {
506        let normalized_domain = normalize_domain(domain)?;
507        let decision = self.decision_for_domain(&normalized_domain)?;
508        if !decision.is_allowed() {
509            return Ok(decision);
510        }
511
512        if let Some(literal) = parse_ip_literal(&normalized_domain) {
513            if resolved_ips.iter().any(|ip| *ip != literal) {
514                return Ok(NetworkDecision::Deny);
515            }
516            return Ok(
517                if self.has_exact_allow(&normalized_domain)
518                    || self.local_network_access == LocalNetworkAccess::Allow
519                    || !is_non_public_ip(literal)
520                {
521                    NetworkDecision::Allow
522                } else {
523                    NetworkDecision::Deny
524                },
525            );
526        }
527
528        let explicit_localhost_allow =
529            normalized_domain == "localhost" && self.has_exact_allow(&normalized_domain);
530        if normalized_domain == "localhost"
531            && self.local_network_access == LocalNetworkAccess::Deny
532            && !explicit_localhost_allow
533        {
534            return Ok(NetworkDecision::Deny);
535        }
536        if resolved_ips.is_empty() {
537            return Ok(NetworkDecision::Deny);
538        }
539        if self.local_network_access == LocalNetworkAccess::Deny
540            && resolved_ips
541                .iter()
542                .copied()
543                .any(|ip| is_non_public_ip(ip) && !(explicit_localhost_allow && ip.is_loopback()))
544        {
545            return Ok(NetworkDecision::Deny);
546        }
547        Ok(NetworkDecision::Allow)
548    }
549
550    /// Evaluates a Unix socket path against the complete network policy.
551    ///
552    /// The path is validated even when enforcement is external so malformed
553    /// input cannot be mistaken for a successful handoff.
554    pub fn decision_for_unix_socket(&self, path: &Path) -> Result<NetworkDecision, PolicyError> {
555        PathSelector::absolute(path.to_path_buf())?;
556        if self.mode == NetworkMode::External {
557            return Ok(NetworkDecision::ExternallyEnforced);
558        }
559        if self.mode == NetworkMode::Disabled || self.unix_socket_mode == UnixSocketMode::Disabled {
560            return Ok(NetworkDecision::Deny);
561        }
562        let mut result = None;
563        for rule in &self.unix_sockets {
564            if paths_equal(path, rule.path()) {
565                result = Some(match (result, rule.access()) {
566                    (Some(DomainAccess::Deny), _) | (_, DomainAccess::Deny) => DomainAccess::Deny,
567                    _ => DomainAccess::Allow,
568                });
569            }
570        }
571        let decision = match self.unix_socket_mode {
572            UnixSocketMode::Disabled => NetworkDecision::Deny,
573            UnixSocketMode::Enabled => {
574                if matches!(result, Some(DomainAccess::Deny)) {
575                    NetworkDecision::Deny
576                } else {
577                    NetworkDecision::Allow
578                }
579            }
580            UnixSocketMode::Restricted => {
581                if matches!(result, Some(DomainAccess::Allow)) {
582                    NetworkDecision::Allow
583                } else {
584                    NetworkDecision::Deny
585                }
586            }
587        };
588        Ok(decision)
589    }
590
591    fn access_for_normalized_domain(&self, domain: &str) -> Option<DomainAccess> {
592        let mut result = None;
593        for rule in &self.domains {
594            if rule.matches(domain) {
595                result = Some(match (result, rule.access()) {
596                    (Some(DomainAccess::Deny), _) | (_, DomainAccess::Deny) => DomainAccess::Deny,
597                    _ => DomainAccess::Allow,
598                });
599            }
600        }
601        result
602    }
603
604    fn has_exact_allow(&self, domain: &str) -> bool {
605        self.domains.iter().any(|rule| {
606            rule.access() == DomainAccess::Allow
607                && !rule.pattern().contains('*')
608                && !rule.pattern().contains('?')
609                && rule.pattern() == domain
610        })
611    }
612}
613
614fn normalize_domain_pattern(raw: &str) -> Result<String, PolicyError> {
615    let raw = raw.trim();
616    let invalid_syntax = raw.is_empty()
617        || raw.contains("://")
618        || raw.contains('/')
619        || raw.contains('#')
620        || raw.chars().any(char::is_whitespace)
621        || raw.chars().any(char::is_control);
622    if invalid_syntax {
623        return Err(PolicyError::InvalidDomainPattern {
624            pattern: raw.to_string(),
625        });
626    }
627
628    let (prefix, remainder) = if let Some(remainder) = raw.strip_prefix("**.") {
629        ("**.", remainder)
630    } else if let Some(remainder) = raw.strip_prefix("*.") {
631        ("*.", remainder)
632    } else {
633        ("", raw)
634    };
635    let remainder = normalize_host(remainder).ok_or_else(|| PolicyError::InvalidDomainPattern {
636        pattern: raw.to_string(),
637    })?;
638    let pattern = if prefix.is_empty() {
639        remainder
640    } else {
641        format!("{prefix}{remainder}")
642    };
643    if valid_domain_pattern(&pattern) {
644        Ok(pattern)
645    } else {
646        Err(PolicyError::InvalidDomainPattern {
647            pattern: raw.to_string(),
648        })
649    }
650}
651
652fn normalize_domain(raw: &str) -> Result<String, PolicyError> {
653    let normalized = normalize_domain_pattern(raw)?;
654    if !valid_concrete_host(&normalized) {
655        return Err(PolicyError::InvalidDomainPattern {
656            pattern: raw.to_string(),
657        });
658    }
659    Ok(normalized)
660}
661
662fn normalize_host(host: &str) -> Option<String> {
663    let host = host.trim();
664    if host.starts_with('[') {
665        let bracketed = host.strip_prefix('[')?;
666        let end = bracketed.find(']')?;
667        let inner = &bracketed[..end];
668        let suffix = &bracketed[end + 1..];
669        if normalize_ip_literal(inner).is_some() {
670            if !suffix.is_empty() && !valid_port_suffix(suffix) {
671                return None;
672            }
673            return normalize_ip_literal(inner).map(|ip| normalize_dns_host_or_ip_literal(&ip));
674        }
675    }
676    match host.bytes().filter(|byte| *byte == b':').count() {
677        0 => Some(normalize_dns_host_or_ip_literal(host)),
678        1 => {
679            let (host, port) = host.split_once(':')?;
680            if !valid_port(port) {
681                return None;
682            }
683            Some(normalize_dns_host_or_ip_literal(host))
684        }
685        _ => normalize_ip_literal(host).map(|ip| normalize_dns_host_or_ip_literal(&ip)),
686    }
687}
688
689fn valid_port_suffix(suffix: &str) -> bool {
690    let Some(port) = suffix.strip_prefix(':') else {
691        return false;
692    };
693    valid_port(port)
694}
695
696fn valid_port(port: &str) -> bool {
697    !port.is_empty()
698        && port.bytes().all(|byte| byte.is_ascii_digit())
699        && port.parse::<u16>().is_ok()
700}
701
702fn normalize_dns_host_or_ip_literal(host: &str) -> String {
703    let host = host.to_ascii_lowercase();
704    let host = host.trim_end_matches('.');
705    if let Some(ip) = normalize_ip_literal(host) {
706        return ip;
707    }
708    host.to_string()
709}
710
711fn normalize_ip_literal(host: &str) -> Option<String> {
712    if host.parse::<IpAddr>().is_ok() {
713        return Some(host.to_string());
714    }
715    for delimiter in ["%25", "%"] {
716        if let Some((ip, scope)) = host.split_once(delimiter)
717            && ip.parse::<IpAddr>().is_ok()
718        {
719            return Some(format!("{ip}%{scope}"));
720        }
721    }
722    None
723}
724
725fn parse_ip_literal(host: &str) -> Option<IpAddr> {
726    host.split_once('%').map_or_else(
727        || host.parse().ok(),
728        |(address, _scope)| address.parse().ok(),
729    )
730}
731
732fn is_non_public_ip(ip: IpAddr) -> bool {
733    match ip {
734        IpAddr::V4(address) => {
735            let value = u32::from(address);
736            address.is_loopback()
737                || address.is_private()
738                || address.is_link_local()
739                || address.is_unspecified()
740                || address.is_multicast()
741                || address.is_broadcast()
742                || (value & 0xff00_0000) == 0
743                || (value & 0xffc0_0000) == 0x6440_0000
744                || ((value & 0xffff_ff00) == 0xc000_0000
745                    && !matches!(value, 0xc000_0009 | 0xc000_000a))
746                || (value & 0xffff_ff00) == 0xc000_0200
747                || (value & 0xffff_ff00) == 0xc058_6300
748                || (value & 0xfffe_0000) == 0xc612_0000
749                || (value & 0xffff_ff00) == 0xc633_6400
750                || (value & 0xffff_ff00) == 0xcb00_7100
751                || (value & 0xf000_0000) == 0xf000_0000
752        }
753        IpAddr::V6(address) => is_non_public_ipv6(address),
754    }
755}
756
757fn is_non_public_ipv6(address: std::net::Ipv6Addr) -> bool {
758    let segments = address.segments();
759    address.is_loopback()
760        || address.is_unspecified()
761        || address.is_multicast()
762        || address.is_unique_local()
763        || address.is_unicast_link_local()
764        || address
765            .to_ipv4()
766            .is_some_and(|address| is_non_public_ip(IpAddr::V4(address)))
767        // The globally routed NAT64 well-known prefix is safe only when its
768        // embedded IPv4 destination is itself globally reachable.
769        || well_known_nat64_ipv4(segments)
770            .is_some_and(|address| is_non_public_ip(IpAddr::V4(address)))
771        // IPv4-IPv6 translation local-use prefix.
772        || matches!(segments, [0x64, 0xff9b, 1, _, _, _, _, _])
773        // Discard-only address block.
774        || matches!(segments, [0x100, 0, 0, 0, _, _, _, _])
775        // Dummy IPv6 prefix.
776        || matches!(segments, [0x100, 0, 0, 1, _, _, _, _])
777        || is_non_public_ietf_protocol_assignment(segments)
778        // 6to4 has no globally-reachable registry designation.
779        || matches!(segments, [0x2002, _, _, _, _, _, _, _])
780        // Documentation prefixes.
781        || matches!(segments, [0x2001, 0x0db8, _, _, _, _, _, _])
782        || matches!(segments, [0x3fff, 0x0000..=0x0fff, _, _, _, _, _, _])
783        // Segment Routing SIDs.
784        || matches!(segments, [0x5f00, _, _, _, _, _, _, _])
785        // Deprecated site-local addresses can remain reachable inside a host
786        // or organization even though new routers should not forward them.
787        || (segments[0] & 0xffc0) == 0xfec0
788}
789
790fn well_known_nat64_ipv4(segments: [u16; 8]) -> Option<Ipv4Addr> {
791    match segments {
792        [0x64, 0xff9b, 0, 0, 0, 0, high, low] => {
793            let [first, second] = high.to_be_bytes();
794            let [third, fourth] = low.to_be_bytes();
795            Some(Ipv4Addr::new(first, second, third, fourth))
796        }
797        _ => None,
798    }
799}
800
801fn is_non_public_ietf_protocol_assignment(segments: [u16; 8]) -> bool {
802    if segments[0] != 0x2001 || segments[1] >= 0x0200 {
803        return false;
804    }
805    !matches!(
806        segments,
807        // PCP and TURN anycast addresses.
808        [0x2001, 0x0001, 0, 0, 0, 0, 0, 1..=3]
809            // AMT.
810            | [0x2001, 0x0003, _, _, _, _, _, _]
811            // AS112-v6.
812            | [0x2001, 0x0004, 0x0112, _, _, _, _, _]
813            // ORCHIDv2 and Drone Remote ID entity tags.
814            | [0x2001, 0x0020..=0x003f, _, _, _, _, _, _]
815    )
816}
817
818fn valid_domain_literal(pattern: &str) -> bool {
819    !pattern.is_empty()
820        && !pattern.contains('*')
821        && !pattern.contains('?')
822        && (!pattern.contains(':') || normalize_ip_literal(pattern).is_some())
823}
824
825fn valid_domain_pattern(pattern: &str) -> bool {
826    if pattern == "*" {
827        return true;
828    }
829    if pattern.contains(':') || pattern.contains('%') {
830        return valid_domain_literal(pattern);
831    }
832    let suffix = pattern
833        .strip_prefix("**.")
834        .or_else(|| pattern.strip_prefix("*."))
835        .unwrap_or(pattern);
836    !suffix.is_empty()
837        && suffix.split('.').all(|label| {
838            !label.is_empty()
839                && label.chars().all(|character| {
840                    !character.is_whitespace()
841                        && !character.is_control()
842                        && !matches!(character, ':' | '%' | '@')
843                })
844        })
845}
846
847fn valid_concrete_host(host: &str) -> bool {
848    if parse_ip_literal(host).is_some() {
849        return true;
850    }
851    host.len() <= 253
852        && host.split('.').all(|label| {
853            !label.is_empty()
854                && label.len() <= 63
855                && !label.starts_with('-')
856                && !label.ends_with('-')
857                && label
858                    .chars()
859                    .all(|character| character.is_alphanumeric() || matches!(character, '-' | '_'))
860        })
861}
862
863fn compile_domain_matcher(pattern: &str) -> Result<DomainMatcher, PolicyError> {
864    if pattern == "*" {
865        return Ok(DomainMatcher::Any);
866    }
867    if let Some(suffix) = pattern.strip_prefix("**.") {
868        return Ok(DomainMatcher::Suffix {
869            labels: compile_domain_labels(suffix, pattern)?,
870            include_apex: true,
871        });
872    }
873    if let Some(suffix) = pattern.strip_prefix("*.") {
874        return Ok(DomainMatcher::Suffix {
875            labels: compile_domain_labels(suffix, pattern)?,
876            include_apex: false,
877        });
878    }
879    let matcher = GlobBuilder::new(pattern)
880        .case_insensitive(true)
881        .build()
882        .map_err(|_| PolicyError::InvalidDomainPattern {
883            pattern: pattern.to_owned(),
884        })?
885        .compile_matcher();
886    Ok(DomainMatcher::Full(matcher))
887}
888
889fn compile_domain_labels(suffix: &str, pattern: &str) -> Result<Vec<GlobMatcher>, PolicyError> {
890    suffix
891        .split('.')
892        .map(|label| {
893            GlobBuilder::new(label)
894                .case_insensitive(true)
895                .build()
896                .map(|glob| glob.compile_matcher())
897                .map_err(|_| PolicyError::InvalidDomainPattern {
898                    pattern: pattern.to_owned(),
899                })
900        })
901        .collect()
902}