Skip to main content

enclavia_protocol/
egress_config.rs

1//! The egress allowlist schema: the shared contract between everything
2//! that AUTHORS a policy and the in-enclave daemon that ENFORCES it.
3//!
4//! The allowlist JSON (`/etc/enclavia/egress.json`, baked into the
5//! measured EIF rootfs) is written by the CLI (`--egress-allow` /
6//! `--egress-config`), validated by the backend on `POST /enclaves`,
7//! and loaded by `enclavia-egress` at boot. All three run the SAME
8//! pipeline in this module ([`AllowlistConfig::from_raw`]), so an entry
9//! the CLI accepts is exactly an entry the daemon enforces; the
10//! [`assemble_from_cli`] / [`validate_json`] entry points exist so
11//! authoring tools never re-implement the grammar.
12//!
13//! Lives in `enclavia-protocol` (rather than the egress daemon crate)
14//! so the CLI and backend can depend on the schema without dragging in
15//! the TUN/smoltcp/vsock daemon stack. Gated behind the default-on
16//! `egress-config` cargo feature (the wasm SDK build disables default
17//! features and never needs it).
18
19use std::fs;
20use std::net::{IpAddr, Ipv4Addr};
21use std::path::{Path, PathBuf};
22
23use ipnet::Ipv4Net;
24use serde::{Deserialize, Serialize};
25use tracing::warn;
26
27/// Transport protocol for an allowlist entry.
28///
29/// TCP is the only one supported today. UDP is reserved at the type
30/// level so the wire schema doesn't have to break when it lands, but
31/// validation actively rejects UDP entries with a clear error rather
32/// than silently dropping them at runtime. See
33/// https://github.com/EnclaviaIO/enclavia/issues/1 for the tracking
34/// issue.
35#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
36#[serde(rename_all = "lowercase")]
37pub enum Protocol {
38    Tcp,
39    Udp,
40}
41
42/// Raw entry as it appears in the JSON file. Host is `String` so we
43/// can defer the IPv4 / IPv6 / hostname classification to load time.
44#[derive(Clone, Debug, Deserialize, Serialize)]
45pub struct RawEgressEntry {
46    pub host: String,
47    pub port: u16,
48    pub protocol: Protocol,
49}
50
51/// Schema version currently supported. Bump in lockstep with
52/// `from_raw` when the on-disk shape changes.
53pub const SCHEMA_VERSION: u32 = 1;
54
55/// Raw top-level JSON object.
56#[derive(Clone, Debug, Deserialize, Serialize)]
57pub struct RawAllowlist {
58    /// Schema version. Only `SCHEMA_VERSION` is accepted today.
59    pub version: u32,
60    /// DNS resolvers the daemon is allowed to reach. Parsed here;
61    /// enforcement belongs to the hostname-enforcement layer.
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub resolvers: Vec<String>,
64    /// The allow list itself.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub egress: Vec<RawEgressEntry>,
67}
68
69impl RawAllowlist {
70    /// Construct an empty schema-version-1 document. Useful for the
71    /// CLI/backend assembly path that builds the allowlist from flags
72    /// rather than reading a JSON file off disk.
73    pub fn new_v1() -> Self {
74        Self {
75            version: SCHEMA_VERSION,
76            resolvers: Vec::new(),
77            egress: Vec::new(),
78        }
79    }
80}
81
82/// Address-side half of an allowlist entry.
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub enum HostMatcher {
85    /// Match exactly one IPv4 address.
86    Literal(Ipv4Addr),
87    /// Match any IPv4 address inside the CIDR block.
88    Cidr(Ipv4Net),
89}
90
91impl HostMatcher {
92    pub fn contains(&self, ip: Ipv4Addr) -> bool {
93        match self {
94            HostMatcher::Literal(a) => *a == ip,
95            HostMatcher::Cidr(net) => net.contains(&ip),
96        }
97    }
98}
99
100/// One canonical, typed IP-shaped allowlist entry.
101#[derive(Clone, Debug)]
102pub struct AllowlistEntry {
103    pub host: HostMatcher,
104    pub port: u16,
105    pub protocol: Protocol,
106}
107
108/// One canonical, typed hostname-shaped allowlist entry. Enforced via
109/// a stub query to the in-enclave `unbound`.
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct HostnameEntry {
112    /// Lowercased ASCII hostname. We do not preserve trailing dots; the
113    /// resolver code re-adds them as part of building a `Name`.
114    pub host: String,
115    pub port: u16,
116    pub protocol: Protocol,
117}
118
119/// Parsed, validated allowlist ready to hand to the policy matcher.
120#[derive(Clone, Debug, Default)]
121pub struct AllowlistConfig {
122    /// IP- and CIDR-bound entries, evaluated in order on every
123    /// connect. Order is not significant for correctness; the matcher
124    /// short-circuits on the first hit.
125    pub entries: Vec<AllowlistEntry>,
126    /// Resolvers declared in the JSON file. The in-enclave daemon does
127    /// not talk to these resolvers directly: `unbound` does (forwarder
128    /// upstream, DNS-over-TCP). The daemon mirrors `resolvers[i]:53/tcp`
129    /// into `entries` at boot so `unbound`'s own egress is permitted
130    /// without the operator having to spell it out.
131    pub resolvers: Vec<Ipv4Addr>,
132    /// Hostname-shaped allow entries. Enforced by querying the
133    /// in-enclave `unbound` at connect time and checking the
134    /// destination IP against the returned A records.
135    pub hostnames: Vec<HostnameEntry>,
136}
137
138/// Errors surfaced while loading the allowlist from disk.
139#[derive(Debug, thiserror::Error)]
140pub enum AllowlistLoadError {
141    #[error("I/O error reading {0}: {1}")]
142    Io(PathBuf, std::io::Error),
143    #[error("JSON parse error in {0}: {1}")]
144    Json(PathBuf, serde_json::Error),
145    #[error("unsupported allowlist schema version {0} (expected 1)")]
146    UnsupportedVersion(u32),
147    #[error("UDP egress is not supported yet (entry `{host}:{port}/udp`); see https://github.com/EnclaviaIO/enclavia/issues/1")]
148    UdpNotSupported { host: String, port: u16 },
149}
150
151impl AllowlistConfig {
152    /// Empty == deny everything. The supervisor's policy treats this
153    /// the same way as a missing config file.
154    pub fn empty() -> Self {
155        Self::default()
156    }
157
158    /// Load + parse the allowlist from `path`. Missing file is not an
159    /// error: it returns [`Self::empty`] (deny-all) so the daemon can
160    /// boot before the operator has dropped a policy in.
161    pub fn load_or_empty(path: &Path) -> Result<Self, AllowlistLoadError> {
162        let bytes = match fs::read(path) {
163            Ok(b) => b,
164            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
165                warn!(path = %path.display(), "Allowlist file missing, defaulting to deny-all");
166                return Ok(Self::empty());
167            }
168            Err(e) => return Err(AllowlistLoadError::Io(path.to_path_buf(), e)),
169        };
170        if bytes.iter().all(|b| b.is_ascii_whitespace()) {
171            warn!(path = %path.display(), "Allowlist file is empty, defaulting to deny-all");
172            return Ok(Self::empty());
173        }
174        Self::from_bytes(&bytes).map_err(|e| match e {
175            AllowlistLoadError::Io(_, ioe) => AllowlistLoadError::Io(path.to_path_buf(), ioe),
176            AllowlistLoadError::Json(_, je) => AllowlistLoadError::Json(path.to_path_buf(), je),
177            other => other,
178        })
179    }
180
181    /// Parse from raw JSON bytes. Public so unit tests can exercise the
182    /// parser without touching the filesystem.
183    pub fn from_bytes(bytes: &[u8]) -> Result<Self, AllowlistLoadError> {
184        let raw: RawAllowlist = serde_json::from_slice(bytes)
185            .map_err(|e| AllowlistLoadError::Json(PathBuf::new(), e))?;
186        Self::from_raw(raw)
187    }
188
189    /// Convert the JSON-shaped struct into the typed allowlist.
190    ///
191    /// Classification per entry:
192    ///   - parses as `a.b.c.d/n`     -> `HostMatcher::Cidr`
193    ///   - parses as `a.b.c.d`       -> `HostMatcher::Literal`
194    ///   - parses as IPv6 literal    -> logged + dropped (always-deny)
195    ///   - anything else (hostname)  -> `HostnameEntry`, enforced via the
196    ///     in-enclave resolver
197    ///
198    /// Duplicates are not deduped; the matcher's short-circuit means
199    /// they cost a little memory but cannot cause a logic bug.
200    pub fn from_raw(raw: RawAllowlist) -> Result<Self, AllowlistLoadError> {
201        if raw.version != 1 {
202            return Err(AllowlistLoadError::UnsupportedVersion(raw.version));
203        }
204
205        let mut entries = Vec::new();
206        let mut hostnames = Vec::new();
207        for raw_entry in raw.egress {
208            // UDP entries used to be accepted by the schema and silently
209            // ignored at runtime (the daemon is TCP-only). That's footgun-y;
210            // reject upfront with a clear error pointing at the tracking
211            // issue. When the daemon learns UDP this check goes away.
212            if matches!(raw_entry.protocol, Protocol::Udp) {
213                return Err(AllowlistLoadError::UdpNotSupported {
214                    host: raw_entry.host.trim().to_string(),
215                    port: raw_entry.port,
216                });
217            }
218            let host = raw_entry.host.trim().to_string();
219            if let Ok(net) = host.parse::<Ipv4Net>() {
220                entries.push(AllowlistEntry {
221                    host: HostMatcher::Cidr(net),
222                    port: raw_entry.port,
223                    protocol: raw_entry.protocol,
224                });
225                continue;
226            }
227            match host.parse::<IpAddr>() {
228                Ok(IpAddr::V4(v4)) => entries.push(AllowlistEntry {
229                    host: HostMatcher::Literal(v4),
230                    port: raw_entry.port,
231                    protocol: raw_entry.protocol,
232                }),
233                Ok(IpAddr::V6(v6)) => {
234                    warn!(
235                        host = %v6,
236                        port = raw_entry.port,
237                        protocol = ?raw_entry.protocol,
238                        "Ignoring IPv6 allowlist entry: IPv6 egress is always denied",
239                    );
240                }
241                Err(_) => {
242                    hostnames.push(HostnameEntry {
243                        host: host.to_ascii_lowercase(),
244                        port: raw_entry.port,
245                        protocol: raw_entry.protocol,
246                    });
247                }
248            }
249        }
250
251        let mut resolvers = Vec::new();
252        for r in raw.resolvers {
253            match r.trim().parse::<IpAddr>() {
254                Ok(IpAddr::V4(v4)) => resolvers.push(v4),
255                Ok(IpAddr::V6(v6)) => {
256                    warn!(resolver = %v6, "Ignoring IPv6 resolver: IPv6 egress is always denied");
257                }
258                Err(_) => {
259                    warn!(resolver = %r, "Ignoring non-IPv4 resolver entry");
260                }
261            }
262        }
263
264        Ok(Self {
265            entries,
266            resolvers,
267            hostnames,
268        })
269    }
270
271    /// True iff there is at least one TCP IP/CIDR entry that matches
272    /// `(ip, port)`. Hostname entries are NOT consulted here; they are
273    /// evaluated separately by the policy (which needs an async
274    /// resolver call).
275    pub fn allows_tcp(&self, ip: Ipv4Addr, port: u16) -> bool {
276        self.entries.iter().any(|e| {
277            matches!(e.protocol, Protocol::Tcp) && e.port == port && e.host.contains(ip)
278        })
279    }
280
281    /// Iterator over hostname TCP entries whose port matches `port`.
282    /// The policy calls this when an IP/CIDR miss happens and needs to
283    /// know which hostnames are worth resolving for the current connect.
284    pub fn tcp_hostnames_for_port(&self, port: u16) -> impl Iterator<Item = &HostnameEntry> {
285        self.hostnames
286            .iter()
287            .filter(move |h| matches!(h.protocol, Protocol::Tcp) && h.port == port)
288    }
289
290    /// Append a fresh IP literal entry. Used at boot to auto-inject the
291    /// resolvers from the JSON file (`resolvers[i]:53/tcp`) so the
292    /// in-enclave `unbound` can reach them through the egress path.
293    pub fn push_entry(&mut self, entry: AllowlistEntry) {
294        self.entries.push(entry);
295    }
296}
297
298/// Errors surfaced while parsing CLI / backend flag input.
299///
300/// These wrap [`AllowlistLoadError`] for the "assembled-then-validated"
301/// path: callers build a [`RawAllowlist`] from flags, then run it
302/// through the same `from_raw` pipeline the on-disk loader uses so the
303/// CLI, the backend, and the in-enclave daemon all agree on what is
304/// well-formed.
305#[derive(Debug, thiserror::Error)]
306pub enum AllowlistFlagError {
307    #[error("egress allow spec must be HOST:PORT[/PROTO] (got `{0}`)")]
308    BadEntryShape(String),
309    #[error("egress allow spec `{0}` is missing the :PORT segment")]
310    MissingPort(String),
311    #[error("egress allow spec `{0}` has an invalid port: {1}")]
312    InvalidPort(String, std::num::ParseIntError),
313    #[error("egress allow spec `{0}` has port 0 (must be 1..=65535)")]
314    PortZero(String),
315    #[error("egress allow spec `{0}` has an unsupported protocol `{1}` (expected tcp or udp)")]
316    UnsupportedProtocol(String, String),
317    #[error("egress allow spec `{0}` has an empty host")]
318    EmptyHost(String),
319    #[error("egress allow spec `{0}` uses an IPv6 host; IPv6 egress is always denied")]
320    IpV6Host(String),
321    #[error("egress allow spec `{spec}` has invalid hostname `{host}`: {reason}")]
322    InvalidHostname { spec: String, host: String, reason: &'static str },
323    #[error("resolver spec `{0}` must be an IPv4 address")]
324    InvalidResolver(String),
325    #[error("invalid allowlist: {0}")]
326    Validation(#[from] AllowlistLoadError),
327}
328
329/// Parse one CLI / backend-flag entry like `HOST:PORT[/PROTO]` into a
330/// canonical [`RawEgressEntry`]. Used by `--egress-allow HOST:PORT[/PROTO]`
331/// on the CLI and by the backend's POST /enclaves validator so both gate
332/// on the same grammar.
333///
334/// Forms accepted:
335///   - `1.2.3.4:443`
336///   - `10.0.0.0/8:443`
337///   - `api.example.com:443`
338///   - any of the above with an explicit `/tcp` or `/udp` suffix
339///
340/// Defaults to `tcp` when the protocol suffix is omitted (tcp is the
341/// only thing actually enforced today; udp entries parse but don't
342/// fire).
343pub fn parse_cli_entry(spec: &str) -> Result<RawEgressEntry, AllowlistFlagError> {
344    let trimmed = spec.trim();
345    if trimmed.is_empty() {
346        return Err(AllowlistFlagError::BadEntryShape(spec.to_string()));
347    }
348
349    // Strip the optional `/tcp` / `/udp` suffix from the right so the
350    // CIDR slash (e.g. `10.0.0.0/8`) doesn't get confused with the
351    // protocol slash. The suffix is only recognised when it appears
352    // *after* the port — a spec like `10.0.0.0/8:443` has a slash but
353    // no proto tail, so we leave the whole string alone and let the
354    // host:port split below pick up `10.0.0.0/8` as the host.
355    let (head, protocol) = match trimmed.rsplit_once('/') {
356        Some((head, tail)) if !tail.contains(':') => {
357            match tail.to_ascii_lowercase().as_str() {
358                "tcp" => (head, Protocol::Tcp),
359                "udp" => (head, Protocol::Udp),
360                _ => {
361                    return Err(AllowlistFlagError::UnsupportedProtocol(
362                        spec.to_string(),
363                        tail.to_string(),
364                    ));
365                }
366            }
367        }
368        _ => (trimmed, Protocol::Tcp),
369    };
370
371    // Now split the host:port on the *last* `:` so CIDR slashes in HOST
372    // (which always sit left of the colon) are preserved verbatim.
373    let (host, port_str) = head
374        .rsplit_once(':')
375        .ok_or_else(|| AllowlistFlagError::MissingPort(spec.to_string()))?;
376
377    let host = host.trim();
378    if host.is_empty() {
379        return Err(AllowlistFlagError::EmptyHost(spec.to_string()));
380    }
381
382    let port: u16 = port_str
383        .trim()
384        .parse()
385        .map_err(|e| AllowlistFlagError::InvalidPort(spec.to_string(), e))?;
386    if port == 0 {
387        return Err(AllowlistFlagError::PortZero(spec.to_string()));
388    }
389
390    // Up-front rejection of obviously-bad hosts. The `from_raw` pipeline
391    // would otherwise silently demote them to hostname entries that
392    // never resolve, so we'd lose the early error.
393    if host.parse::<Ipv4Net>().is_err() && host.parse::<Ipv4Addr>().is_err() {
394        // Not an IPv4 literal/CIDR — has to be a hostname. v6 lands here.
395        if let Ok(IpAddr::V6(_)) = host.parse::<IpAddr>() {
396            return Err(AllowlistFlagError::IpV6Host(spec.to_string()));
397        }
398        if let Err(reason) = validate_hostname(host) {
399            return Err(AllowlistFlagError::InvalidHostname {
400                spec: spec.to_string(),
401                host: host.to_string(),
402                reason,
403            });
404        }
405    }
406
407    Ok(RawEgressEntry {
408        host: host.to_string(),
409        port,
410        protocol,
411    })
412}
413
414/// Validate a resolver spec from `--egress-resolver`. IPv4 only —
415/// `unbound` upstream resolvers must be IPv4 literals because IPv6
416/// egress is denied across the whole policy.
417pub fn parse_cli_resolver(spec: &str) -> Result<String, AllowlistFlagError> {
418    let trimmed = spec.trim();
419    match trimmed.parse::<IpAddr>() {
420        Ok(IpAddr::V4(_)) => Ok(trimmed.to_string()),
421        _ => Err(AllowlistFlagError::InvalidResolver(spec.to_string())),
422    }
423}
424
425/// Assemble a canonical [`RawAllowlist`] from CLI-style inputs and
426/// validate it by running through the same `from_raw` pipeline the
427/// in-enclave daemon uses. Returns the typed structure on success;
428/// callers serialise it back to JSON with `serde_json::to_*`.
429///
430/// `allow_specs` are `HOST:PORT[/PROTO]` strings. `resolver_specs` are
431/// IPv4 literals. Either list may be empty; an empty allowlist is
432/// deny-all on the daemon side, which is the same behaviour as a
433/// missing file.
434pub fn assemble_from_cli(
435    allow_specs: &[&str],
436    resolver_specs: &[&str],
437) -> Result<RawAllowlist, AllowlistFlagError> {
438    let mut raw = RawAllowlist::new_v1();
439    for s in allow_specs {
440        raw.egress.push(parse_cli_entry(s)?);
441    }
442    for r in resolver_specs {
443        raw.resolvers.push(parse_cli_resolver(r)?);
444    }
445    // Run the typed-validation pipeline so CLI/backend/daemon agree on
446    // what is well-formed. We discard the typed config here — the
447    // caller wants the canonical JSON-shaped struct — but a failure
448    // here means the daemon would reject the same input at boot.
449    AllowlistConfig::from_raw(raw.clone()).map_err(AllowlistFlagError::Validation)?;
450    Ok(raw)
451}
452
453/// Validate a JSON-shaped allowlist supplied by the frontend / API
454/// without a CLI parse step. Backend uses this on POST /enclaves.
455///
456/// Accepts either a raw `serde_json::Value` (frontend POSTs a JSON
457/// object) or anything that deserialises into [`RawAllowlist`]. On
458/// success returns the canonical struct so the caller can re-serialise
459/// it for storage; on failure returns the same `AllowlistFlagError`
460/// the CLI path uses, so error messages stay consistent.
461pub fn validate_json(value: &serde_json::Value) -> Result<RawAllowlist, AllowlistFlagError> {
462    let raw: RawAllowlist = serde_json::from_value(value.clone()).map_err(|e| {
463        AllowlistFlagError::Validation(AllowlistLoadError::Json(PathBuf::new(), e))
464    })?;
465    AllowlistConfig::from_raw(raw.clone()).map_err(AllowlistFlagError::Validation)?;
466    Ok(raw)
467}
468
469/// Minimum-effort hostname validation: RFC 1035-ish syntax check that
470/// catches obvious garbage (empty labels, leading/trailing dots,
471/// invalid chars) without trying to be a full IDNA validator. We don't
472/// resolve here — the in-enclave daemon's DNS query is the real test —
473/// but rejecting `foo!bar` at the CLI is more useful than waiting for
474/// the resolver to silently never match.
475fn validate_hostname(host: &str) -> Result<(), &'static str> {
476    if host.is_empty() {
477        return Err("empty hostname");
478    }
479    if host.len() > 253 {
480        return Err("hostname exceeds 253 characters");
481    }
482    if host.starts_with('.') || host.ends_with('.') {
483        return Err("hostname must not start or end with a dot");
484    }
485    for label in host.split('.') {
486        if label.is_empty() {
487            return Err("hostname contains an empty label (consecutive dots)");
488        }
489        if label.len() > 63 {
490            return Err("hostname label exceeds 63 characters");
491        }
492        if label.starts_with('-') || label.ends_with('-') {
493            return Err("hostname label must not start or end with a hyphen");
494        }
495        if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
496            return Err("hostname label has invalid characters (allowed: a-z, 0-9, '-')");
497        }
498    }
499    Ok(())
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[test]
507    fn parses_literal_and_cidr_entries() {
508        let raw = br#"{
509            "version": 1,
510            "resolvers": ["1.1.1.1"],
511            "egress": [
512                {"host": "10.0.0.0/8", "port": 443, "protocol": "tcp"},
513                {"host": "1.2.3.4",   "port": 80,  "protocol": "tcp"}
514            ]
515        }"#;
516        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
517        assert_eq!(cfg.entries.len(), 2);
518        assert_eq!(cfg.resolvers, vec![Ipv4Addr::new(1, 1, 1, 1)]);
519        assert!(matches!(cfg.entries[0].host, HostMatcher::Cidr(_)));
520        assert!(matches!(cfg.entries[1].host, HostMatcher::Literal(_)));
521    }
522
523    #[test]
524    fn hostnames_are_recognized_as_first_class() {
525        let raw = br#"{
526            "version": 1,
527            "egress": [
528                {"host": "api.openai.com", "port": 443, "protocol": "tcp"}
529            ]
530        }"#;
531        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
532        assert!(cfg.entries.is_empty());
533        assert_eq!(cfg.hostnames.len(), 1);
534        assert_eq!(cfg.hostnames[0].host, "api.openai.com");
535        assert_eq!(cfg.hostnames[0].port, 443);
536        assert!(matches!(cfg.hostnames[0].protocol, Protocol::Tcp));
537    }
538
539    #[test]
540    fn hostnames_are_lowercased() {
541        let raw = br#"{
542            "version": 1,
543            "egress": [
544                {"host": "API.Openai.COM", "port": 443, "protocol": "tcp"}
545            ]
546        }"#;
547        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
548        assert_eq!(cfg.hostnames[0].host, "api.openai.com");
549    }
550
551    #[test]
552    fn ipv6_literal_entries_are_dropped() {
553        let raw = br#"{
554            "version": 1,
555            "egress": [
556                {"host": "::1", "port": 443, "protocol": "tcp"}
557            ]
558        }"#;
559        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
560        assert!(cfg.entries.is_empty());
561        assert!(cfg.hostnames.is_empty());
562    }
563
564    #[test]
565    fn hostnames_for_port_filters_by_port() {
566        let raw = br#"{
567            "version": 1,
568            "egress": [
569                {"host": "a.example", "port": 443, "protocol": "tcp"},
570                {"host": "b.example", "port": 80,  "protocol": "tcp"}
571            ]
572        }"#;
573        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
574        let on_443: Vec<_> =
575            cfg.tcp_hostnames_for_port(443).map(|h| h.host.as_str()).collect();
576        assert_eq!(on_443, vec!["a.example"]);
577    }
578
579    #[test]
580    fn udp_entry_in_json_is_rejected() {
581        let raw = br#"{
582            "version": 1,
583            "egress": [
584                {"host": "1.1.1.1", "port": 53, "protocol": "udp"}
585            ]
586        }"#;
587        let err = AllowlistConfig::from_bytes(raw).expect_err("must reject UDP");
588        assert!(matches!(
589            err,
590            AllowlistLoadError::UdpNotSupported { ref host, port: 53 } if host == "1.1.1.1"
591        ));
592    }
593
594    #[test]
595    fn udp_entry_in_hostname_form_is_rejected() {
596        let raw = br#"{
597            "version": 1,
598            "egress": [
599                {"host": "example.com", "port": 53, "protocol": "udp"}
600            ]
601        }"#;
602        let err = AllowlistConfig::from_bytes(raw).expect_err("must reject UDP");
603        assert!(matches!(err, AllowlistLoadError::UdpNotSupported { .. }));
604    }
605
606    #[test]
607    fn udp_via_assemble_from_cli_is_rejected() {
608        let err = assemble_from_cli(&["1.1.1.1:53/udp"], &[]).expect_err("must reject UDP");
609        assert!(matches!(
610            err,
611            AllowlistFlagError::Validation(AllowlistLoadError::UdpNotSupported { .. })
612        ));
613    }
614
615    #[test]
616    fn malformed_json_returns_error() {
617        let raw = br#"{ "version": 1, "egress": [ ] "#;
618        let err = AllowlistConfig::from_bytes(raw).expect_err("must fail");
619        assert!(matches!(err, AllowlistLoadError::Json(_, _)));
620    }
621
622    #[test]
623    fn unsupported_version_rejected() {
624        let raw = br#"{ "version": 2, "egress": [] }"#;
625        let err = AllowlistConfig::from_bytes(raw).expect_err("must fail");
626        assert!(matches!(err, AllowlistLoadError::UnsupportedVersion(2)));
627    }
628
629    #[test]
630    fn missing_file_returns_empty() {
631        let path = std::path::PathBuf::from("/tmp/this-path-does-not-exist-egress-XYZ.json");
632        let cfg = AllowlistConfig::load_or_empty(&path).expect("missing file should not error");
633        assert!(cfg.entries.is_empty());
634    }
635
636    #[test]
637    fn empty_file_returns_empty() {
638        let dir = tempfile::tempdir().unwrap();
639        let p = dir.path().join("egress.json");
640        std::fs::write(&p, "\n\n  \n").unwrap();
641        let cfg = AllowlistConfig::load_or_empty(&p).expect("load");
642        assert!(cfg.entries.is_empty());
643    }
644
645    #[test]
646    fn duplicate_entries_do_not_panic() {
647        let raw = br#"{
648            "version": 1,
649            "egress": [
650                {"host": "1.2.3.4", "port": 80, "protocol": "tcp"},
651                {"host": "1.2.3.4", "port": 80, "protocol": "tcp"}
652            ]
653        }"#;
654        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
655        assert_eq!(cfg.entries.len(), 2);
656        assert!(cfg.allows_tcp(Ipv4Addr::new(1, 2, 3, 4), 80));
657    }
658
659    #[test]
660    fn cli_entry_parses_literal_default_tcp() {
661        let e = parse_cli_entry("1.2.3.4:443").unwrap();
662        assert_eq!(e.host, "1.2.3.4");
663        assert_eq!(e.port, 443);
664        assert_eq!(e.protocol, Protocol::Tcp);
665    }
666
667    #[test]
668    fn cli_entry_parses_explicit_tcp() {
669        let e = parse_cli_entry("1.2.3.4:443/tcp").unwrap();
670        assert_eq!(e.protocol, Protocol::Tcp);
671    }
672
673    #[test]
674    fn cli_entry_parses_explicit_udp() {
675        let e = parse_cli_entry("1.2.3.4:53/udp").unwrap();
676        assert_eq!(e.protocol, Protocol::Udp);
677    }
678
679    #[test]
680    fn cli_entry_parses_cidr() {
681        let e = parse_cli_entry("10.0.0.0/8:443").unwrap();
682        assert_eq!(e.host, "10.0.0.0/8");
683        assert_eq!(e.port, 443);
684    }
685
686    #[test]
687    fn cli_entry_parses_cidr_with_proto() {
688        let e = parse_cli_entry("10.0.0.0/8:443/udp").unwrap();
689        assert_eq!(e.host, "10.0.0.0/8");
690        assert_eq!(e.protocol, Protocol::Udp);
691    }
692
693    #[test]
694    fn cli_entry_parses_hostname() {
695        let e = parse_cli_entry("api.example.com:443").unwrap();
696        assert_eq!(e.host, "api.example.com");
697        assert_eq!(e.port, 443);
698    }
699
700    #[test]
701    fn cli_entry_rejects_missing_port() {
702        assert!(matches!(
703            parse_cli_entry("api.example.com"),
704            Err(AllowlistFlagError::MissingPort(_))
705        ));
706    }
707
708    #[test]
709    fn cli_entry_rejects_bad_port() {
710        assert!(matches!(
711            parse_cli_entry("api.example.com:abc"),
712            Err(AllowlistFlagError::InvalidPort(_, _))
713        ));
714    }
715
716    #[test]
717    fn cli_entry_rejects_zero_port() {
718        assert!(matches!(
719            parse_cli_entry("1.2.3.4:0"),
720            Err(AllowlistFlagError::PortZero(_))
721        ));
722    }
723
724    #[test]
725    fn cli_entry_rejects_unknown_protocol() {
726        assert!(matches!(
727            parse_cli_entry("1.2.3.4:443/sctp"),
728            Err(AllowlistFlagError::UnsupportedProtocol(_, _))
729        ));
730    }
731
732    #[test]
733    fn cli_entry_rejects_bad_hostname() {
734        assert!(matches!(
735            parse_cli_entry("foo!bar.com:443"),
736            Err(AllowlistFlagError::InvalidHostname { .. })
737        ));
738    }
739
740    #[test]
741    fn cli_entry_rejects_ipv6() {
742        assert!(matches!(
743            parse_cli_entry("::1:443"),
744            Err(AllowlistFlagError::IpV6Host(_)) | Err(AllowlistFlagError::InvalidHostname { .. })
745        ));
746    }
747
748    #[test]
749    fn cli_resolver_accepts_ipv4() {
750        assert_eq!(parse_cli_resolver("1.1.1.1").unwrap(), "1.1.1.1");
751    }
752
753    #[test]
754    fn cli_resolver_rejects_hostname() {
755        assert!(matches!(
756            parse_cli_resolver("dns.example.com"),
757            Err(AllowlistFlagError::InvalidResolver(_))
758        ));
759    }
760
761    #[test]
762    fn assemble_round_trips_through_from_raw() {
763        let raw = assemble_from_cli(
764            &["10.0.0.0/8:443", "api.example.com:443/tcp", "1.2.3.4:80"],
765            &["1.1.1.1"],
766        )
767        .unwrap();
768        assert_eq!(raw.version, SCHEMA_VERSION);
769        assert_eq!(raw.egress.len(), 3);
770        assert_eq!(raw.resolvers, vec!["1.1.1.1".to_string()]);
771
772        // The typed config should accept this without complaint.
773        let cfg = AllowlistConfig::from_raw(raw).unwrap();
774        assert_eq!(cfg.entries.len(), 2); // CIDR + literal
775        assert_eq!(cfg.hostnames.len(), 1);
776        assert_eq!(cfg.resolvers.len(), 1);
777    }
778
779    #[test]
780    fn validate_json_accepts_well_formed_doc() {
781        let v: serde_json::Value = serde_json::from_str(
782            r#"{"version": 1, "resolvers": ["1.1.1.1"], "egress": [{"host":"api.example.com","port":443,"protocol":"tcp"}]}"#,
783        )
784        .unwrap();
785        let raw = validate_json(&v).unwrap();
786        assert_eq!(raw.egress.len(), 1);
787        assert_eq!(raw.resolvers.len(), 1);
788    }
789
790    #[test]
791    fn validate_json_rejects_unknown_version() {
792        let v: serde_json::Value =
793            serde_json::from_str(r#"{"version": 2, "egress": []}"#).unwrap();
794        assert!(matches!(
795            validate_json(&v),
796            Err(AllowlistFlagError::Validation(AllowlistLoadError::UnsupportedVersion(2)))
797        ));
798    }
799
800    #[test]
801    fn quad_zero_slash_zero_matches_everything() {
802        let raw = br#"{
803            "version": 1,
804            "egress": [
805                {"host": "0.0.0.0/0", "port": 19444, "protocol": "tcp"}
806            ]
807        }"#;
808        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
809        assert!(cfg.allows_tcp(Ipv4Addr::new(1, 2, 3, 4), 19444));
810        assert!(cfg.allows_tcp(Ipv4Addr::new(192, 168, 1, 1), 19444));
811        assert!(!cfg.allows_tcp(Ipv4Addr::new(1, 2, 3, 4), 19445));
812    }
813}