Skip to main content

mars_schema/
network.rs

1//! Shell network policy: the mode a profile, overlay, settings or CLI flag names, and the host
2//! grammar of its allow and deny sets.
3
4use std::fmt;
5use std::net::IpAddr;
6use std::str::FromStr;
7
8use serde::{Deserialize, Serialize};
9
10/// Shell network mode. Unset follows the sandbox, which Meridian decides, not Mars.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "lowercase")]
13pub enum NetworkPolicy {
14    Allow,
15    Deny,
16    Allowlist,
17}
18
19impl NetworkPolicy {
20    /// The values `parse` accepts, as the invalid-value errors list them.
21    pub const ALLOWED: &'static str = "allow, deny, allowlist";
22
23    pub fn parse(value: &str) -> Option<Self> {
24        match value {
25            "allow" => Some(Self::Allow),
26            "deny" => Some(Self::Deny),
27            "allowlist" => Some(Self::Allowlist),
28            _ => None,
29        }
30    }
31
32    pub fn as_str(&self) -> &'static str {
33        match self {
34            Self::Allow => "allow",
35            Self::Deny => "deny",
36            Self::Allowlist => "allowlist",
37        }
38    }
39}
40
41impl FromStr for NetworkPolicy {
42    type Err = String;
43
44    fn from_str(value: &str) -> Result<Self, Self::Err> {
45        Self::parse(value).ok_or_else(|| {
46            format!(
47                "invalid network policy `{value}`; expected one of: {}",
48                Self::ALLOWED
49            )
50        })
51    }
52}
53
54impl<'de> Deserialize<'de> for NetworkPolicy {
55    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
56    where
57        D: serde::Deserializer<'de>,
58    {
59        let value = String::deserialize(deserializer)?;
60        Self::from_str(&value).map_err(serde::de::Error::custom)
61    }
62}
63
64/// A canonical network host or Codex-compatible host wildcard.
65#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
66#[serde(transparent)]
67pub struct NetworkHost(String);
68
69impl NetworkHost {
70    pub const ALLOWED: &'static str = "exact host, IP address, *.example.com (subdomains), or **.example.com (apex and subdomains); internationalized names must use punycode; URLs, ports, paths, numeric or hex-looking DNS endings, single-label wildcards, empty strings, and bare * are not allowed";
71
72    pub fn as_str(&self) -> &str {
73        &self.0
74    }
75}
76
77impl fmt::Display for NetworkHost {
78    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79        formatter.write_str(&self.0)
80    }
81}
82
83impl FromStr for NetworkHost {
84    type Err = String;
85
86    fn from_str(raw: &str) -> Result<Self, Self::Err> {
87        if raw.is_empty() {
88            return Err(invalid_host(raw));
89        }
90        if raw.trim() != raw {
91            return Err(invalid_host(raw));
92        }
93
94        let canonical = raw.to_ascii_lowercase();
95        let canonical = canonical.strip_suffix('.').unwrap_or(&canonical);
96        if canonical.is_empty()
97            || canonical == "*"
98            || canonical.contains("//")
99            || canonical.contains('/')
100            || canonical.contains('?')
101            || canonical.contains('#')
102        {
103            return Err(invalid_host(raw));
104        }
105
106        if let Ok(ip) = canonical.parse::<IpAddr>() {
107            let ip = match ip {
108                IpAddr::V6(ipv6) => ipv6.to_ipv4_mapped().map_or(IpAddr::V6(ipv6), IpAddr::V4),
109                ipv4 => ipv4,
110            };
111            return Ok(Self(ip.to_string()));
112        }
113        if canonical.contains(':') {
114            return Err(invalid_host(raw));
115        }
116
117        let (prefix, host) = if let Some(host) = canonical.strip_prefix("**.") {
118            ("**.", host)
119        } else if let Some(host) = canonical.strip_prefix("*.") {
120            ("*.", host)
121        } else {
122            ("", canonical)
123        };
124        if host.contains('*')
125            || (!prefix.is_empty() && host.parse::<IpAddr>().is_ok())
126            || (!prefix.is_empty() && !host.contains('.'))
127            || !valid_dns_host(host)
128            || host.rsplit('.').next().is_some_and(numeric_or_hex_label)
129        {
130            return Err(invalid_host(raw));
131        }
132        Ok(Self(format!("{prefix}{host}")))
133    }
134}
135
136fn numeric_or_hex_label(label: &str) -> bool {
137    label.bytes().all(|byte| byte.is_ascii_digit())
138        || label.strip_prefix("0x").is_some_and(|digits| {
139            !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_hexdigit())
140        })
141}
142
143impl<'de> Deserialize<'de> for NetworkHost {
144    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
145    where
146        D: serde::Deserializer<'de>,
147    {
148        let value = String::deserialize(deserializer)?;
149        Self::from_str(&value).map_err(serde::de::Error::custom)
150    }
151}
152
153fn valid_dns_host(host: &str) -> bool {
154    !host.is_empty()
155        && host.len() <= 253
156        && host.split('.').all(|label| {
157            !label.is_empty()
158                && label.len() <= 63
159                && label.is_ascii()
160                && label
161                    .bytes()
162                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
163                && label
164                    .as_bytes()
165                    .first()
166                    .is_some_and(u8::is_ascii_alphanumeric)
167                && label
168                    .as_bytes()
169                    .last()
170                    .is_some_and(u8::is_ascii_alphanumeric)
171        })
172}
173
174fn invalid_host(raw: &str) -> String {
175    format!(
176        "invalid network host `{raw}`; expected {}",
177        NetworkHost::ALLOWED
178    )
179}