enclavia-protocol 0.1.0

Shared wire types and Noise+CBOR responder helpers used by the Enclavia server, router, and SDK.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
//! The egress allowlist schema: the shared contract between everything
//! that AUTHORS a policy and the in-enclave daemon that ENFORCES it.
//!
//! The allowlist JSON (`/etc/enclavia/egress.json`, baked into the
//! measured EIF rootfs) is written by the CLI (`--egress-allow` /
//! `--egress-config`), validated by the backend on `POST /enclaves`,
//! and loaded by `enclavia-egress` at boot. All three run the SAME
//! pipeline in this module ([`AllowlistConfig::from_raw`]), so an entry
//! the CLI accepts is exactly an entry the daemon enforces; the
//! [`assemble_from_cli`] / [`validate_json`] entry points exist so
//! authoring tools never re-implement the grammar.
//!
//! Lives in `enclavia-protocol` (rather than the egress daemon crate)
//! so the CLI and backend can depend on the schema without dragging in
//! the TUN/smoltcp/vsock daemon stack. Gated behind the default-on
//! `egress-config` cargo feature (the wasm SDK build disables default
//! features and never needs it).

use std::fs;
use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf};

use ipnet::Ipv4Net;
use serde::{Deserialize, Serialize};
use tracing::warn;

/// Transport protocol for an allowlist entry.
///
/// TCP is the only one supported today. UDP is reserved at the type
/// level so the wire schema doesn't have to break when it lands, but
/// validation actively rejects UDP entries with a clear error rather
/// than silently dropping them at runtime. See
/// https://github.com/EnclaviaIO/enclavia/issues/1 for the tracking
/// issue.
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
    Tcp,
    Udp,
}

/// Raw entry as it appears in the JSON file. Host is `String` so we
/// can defer the IPv4 / IPv6 / hostname classification to load time.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RawEgressEntry {
    pub host: String,
    pub port: u16,
    pub protocol: Protocol,
}

/// Schema version currently supported. Bump in lockstep with
/// `from_raw` when the on-disk shape changes.
pub const SCHEMA_VERSION: u32 = 1;

/// Raw top-level JSON object.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RawAllowlist {
    /// Schema version. Only `SCHEMA_VERSION` is accepted today.
    pub version: u32,
    /// DNS resolvers the daemon is allowed to reach. Parsed here;
    /// enforcement belongs to the hostname-enforcement layer.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub resolvers: Vec<String>,
    /// The allow list itself.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub egress: Vec<RawEgressEntry>,
}

impl RawAllowlist {
    /// Construct an empty schema-version-1 document. Useful for the
    /// CLI/backend assembly path that builds the allowlist from flags
    /// rather than reading a JSON file off disk.
    pub fn new_v1() -> Self {
        Self {
            version: SCHEMA_VERSION,
            resolvers: Vec::new(),
            egress: Vec::new(),
        }
    }
}

/// Address-side half of an allowlist entry.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HostMatcher {
    /// Match exactly one IPv4 address.
    Literal(Ipv4Addr),
    /// Match any IPv4 address inside the CIDR block.
    Cidr(Ipv4Net),
}

impl HostMatcher {
    pub fn contains(&self, ip: Ipv4Addr) -> bool {
        match self {
            HostMatcher::Literal(a) => *a == ip,
            HostMatcher::Cidr(net) => net.contains(&ip),
        }
    }
}

/// One canonical, typed IP-shaped allowlist entry.
#[derive(Clone, Debug)]
pub struct AllowlistEntry {
    pub host: HostMatcher,
    pub port: u16,
    pub protocol: Protocol,
}

/// One canonical, typed hostname-shaped allowlist entry. Enforced via
/// a stub query to the in-enclave `unbound`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostnameEntry {
    /// Lowercased ASCII hostname. We do not preserve trailing dots; the
    /// resolver code re-adds them as part of building a `Name`.
    pub host: String,
    pub port: u16,
    pub protocol: Protocol,
}

/// Parsed, validated allowlist ready to hand to the policy matcher.
#[derive(Clone, Debug, Default)]
pub struct AllowlistConfig {
    /// IP- and CIDR-bound entries, evaluated in order on every
    /// connect. Order is not significant for correctness; the matcher
    /// short-circuits on the first hit.
    pub entries: Vec<AllowlistEntry>,
    /// Resolvers declared in the JSON file. The in-enclave daemon does
    /// not talk to these resolvers directly: `unbound` does (forwarder
    /// upstream, DNS-over-TCP). The daemon mirrors `resolvers[i]:53/tcp`
    /// into `entries` at boot so `unbound`'s own egress is permitted
    /// without the operator having to spell it out.
    pub resolvers: Vec<Ipv4Addr>,
    /// Hostname-shaped allow entries. Enforced by querying the
    /// in-enclave `unbound` at connect time and checking the
    /// destination IP against the returned A records.
    pub hostnames: Vec<HostnameEntry>,
}

/// Errors surfaced while loading the allowlist from disk.
#[derive(Debug, thiserror::Error)]
pub enum AllowlistLoadError {
    #[error("I/O error reading {0}: {1}")]
    Io(PathBuf, std::io::Error),
    #[error("JSON parse error in {0}: {1}")]
    Json(PathBuf, serde_json::Error),
    #[error("unsupported allowlist schema version {0} (expected 1)")]
    UnsupportedVersion(u32),
    #[error("UDP egress is not supported yet (entry `{host}:{port}/udp`); see https://github.com/EnclaviaIO/enclavia/issues/1")]
    UdpNotSupported { host: String, port: u16 },
}

impl AllowlistConfig {
    /// Empty == deny everything. The supervisor's policy treats this
    /// the same way as a missing config file.
    pub fn empty() -> Self {
        Self::default()
    }

    /// Load + parse the allowlist from `path`. Missing file is not an
    /// error: it returns [`Self::empty`] (deny-all) so the daemon can
    /// boot before the operator has dropped a policy in.
    pub fn load_or_empty(path: &Path) -> Result<Self, AllowlistLoadError> {
        let bytes = match fs::read(path) {
            Ok(b) => b,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                warn!(path = %path.display(), "Allowlist file missing, defaulting to deny-all");
                return Ok(Self::empty());
            }
            Err(e) => return Err(AllowlistLoadError::Io(path.to_path_buf(), e)),
        };
        if bytes.iter().all(|b| b.is_ascii_whitespace()) {
            warn!(path = %path.display(), "Allowlist file is empty, defaulting to deny-all");
            return Ok(Self::empty());
        }
        Self::from_bytes(&bytes).map_err(|e| match e {
            AllowlistLoadError::Io(_, ioe) => AllowlistLoadError::Io(path.to_path_buf(), ioe),
            AllowlistLoadError::Json(_, je) => AllowlistLoadError::Json(path.to_path_buf(), je),
            other => other,
        })
    }

    /// Parse from raw JSON bytes. Public so unit tests can exercise the
    /// parser without touching the filesystem.
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, AllowlistLoadError> {
        let raw: RawAllowlist = serde_json::from_slice(bytes)
            .map_err(|e| AllowlistLoadError::Json(PathBuf::new(), e))?;
        Self::from_raw(raw)
    }

    /// Convert the JSON-shaped struct into the typed allowlist.
    ///
    /// Classification per entry:
    ///   - parses as `a.b.c.d/n`     -> `HostMatcher::Cidr`
    ///   - parses as `a.b.c.d`       -> `HostMatcher::Literal`
    ///   - parses as IPv6 literal    -> logged + dropped (always-deny)
    ///   - anything else (hostname)  -> `HostnameEntry`, enforced via the
    ///     in-enclave resolver
    ///
    /// Duplicates are not deduped; the matcher's short-circuit means
    /// they cost a little memory but cannot cause a logic bug.
    pub fn from_raw(raw: RawAllowlist) -> Result<Self, AllowlistLoadError> {
        if raw.version != 1 {
            return Err(AllowlistLoadError::UnsupportedVersion(raw.version));
        }

        let mut entries = Vec::new();
        let mut hostnames = Vec::new();
        for raw_entry in raw.egress {
            // UDP entries used to be accepted by the schema and silently
            // ignored at runtime (the daemon is TCP-only). That's footgun-y;
            // reject upfront with a clear error pointing at the tracking
            // issue. When the daemon learns UDP this check goes away.
            if matches!(raw_entry.protocol, Protocol::Udp) {
                return Err(AllowlistLoadError::UdpNotSupported {
                    host: raw_entry.host.trim().to_string(),
                    port: raw_entry.port,
                });
            }
            let host = raw_entry.host.trim().to_string();
            if let Ok(net) = host.parse::<Ipv4Net>() {
                entries.push(AllowlistEntry {
                    host: HostMatcher::Cidr(net),
                    port: raw_entry.port,
                    protocol: raw_entry.protocol,
                });
                continue;
            }
            match host.parse::<IpAddr>() {
                Ok(IpAddr::V4(v4)) => entries.push(AllowlistEntry {
                    host: HostMatcher::Literal(v4),
                    port: raw_entry.port,
                    protocol: raw_entry.protocol,
                }),
                Ok(IpAddr::V6(v6)) => {
                    warn!(
                        host = %v6,
                        port = raw_entry.port,
                        protocol = ?raw_entry.protocol,
                        "Ignoring IPv6 allowlist entry: IPv6 egress is always denied",
                    );
                }
                Err(_) => {
                    hostnames.push(HostnameEntry {
                        host: host.to_ascii_lowercase(),
                        port: raw_entry.port,
                        protocol: raw_entry.protocol,
                    });
                }
            }
        }

        let mut resolvers = Vec::new();
        for r in raw.resolvers {
            match r.trim().parse::<IpAddr>() {
                Ok(IpAddr::V4(v4)) => resolvers.push(v4),
                Ok(IpAddr::V6(v6)) => {
                    warn!(resolver = %v6, "Ignoring IPv6 resolver: IPv6 egress is always denied");
                }
                Err(_) => {
                    warn!(resolver = %r, "Ignoring non-IPv4 resolver entry");
                }
            }
        }

        Ok(Self {
            entries,
            resolvers,
            hostnames,
        })
    }

    /// True iff there is at least one TCP IP/CIDR entry that matches
    /// `(ip, port)`. Hostname entries are NOT consulted here; they are
    /// evaluated separately by the policy (which needs an async
    /// resolver call).
    pub fn allows_tcp(&self, ip: Ipv4Addr, port: u16) -> bool {
        self.entries.iter().any(|e| {
            matches!(e.protocol, Protocol::Tcp) && e.port == port && e.host.contains(ip)
        })
    }

    /// Iterator over hostname TCP entries whose port matches `port`.
    /// The policy calls this when an IP/CIDR miss happens and needs to
    /// know which hostnames are worth resolving for the current connect.
    pub fn tcp_hostnames_for_port(&self, port: u16) -> impl Iterator<Item = &HostnameEntry> {
        self.hostnames
            .iter()
            .filter(move |h| matches!(h.protocol, Protocol::Tcp) && h.port == port)
    }

    /// Append a fresh IP literal entry. Used at boot to auto-inject the
    /// resolvers from the JSON file (`resolvers[i]:53/tcp`) so the
    /// in-enclave `unbound` can reach them through the egress path.
    pub fn push_entry(&mut self, entry: AllowlistEntry) {
        self.entries.push(entry);
    }
}

/// Errors surfaced while parsing CLI / backend flag input.
///
/// These wrap [`AllowlistLoadError`] for the "assembled-then-validated"
/// path: callers build a [`RawAllowlist`] from flags, then run it
/// through the same `from_raw` pipeline the on-disk loader uses so the
/// CLI, the backend, and the in-enclave daemon all agree on what is
/// well-formed.
#[derive(Debug, thiserror::Error)]
pub enum AllowlistFlagError {
    #[error("egress allow spec must be HOST:PORT[/PROTO] (got `{0}`)")]
    BadEntryShape(String),
    #[error("egress allow spec `{0}` is missing the :PORT segment")]
    MissingPort(String),
    #[error("egress allow spec `{0}` has an invalid port: {1}")]
    InvalidPort(String, std::num::ParseIntError),
    #[error("egress allow spec `{0}` has port 0 (must be 1..=65535)")]
    PortZero(String),
    #[error("egress allow spec `{0}` has an unsupported protocol `{1}` (expected tcp or udp)")]
    UnsupportedProtocol(String, String),
    #[error("egress allow spec `{0}` has an empty host")]
    EmptyHost(String),
    #[error("egress allow spec `{0}` uses an IPv6 host; IPv6 egress is always denied")]
    IpV6Host(String),
    #[error("egress allow spec `{spec}` has invalid hostname `{host}`: {reason}")]
    InvalidHostname { spec: String, host: String, reason: &'static str },
    #[error("resolver spec `{0}` must be an IPv4 address")]
    InvalidResolver(String),
    #[error("invalid allowlist: {0}")]
    Validation(#[from] AllowlistLoadError),
}

/// Parse one CLI / backend-flag entry like `HOST:PORT[/PROTO]` into a
/// canonical [`RawEgressEntry`]. Used by `--egress-allow HOST:PORT[/PROTO]`
/// on the CLI and by the backend's POST /enclaves validator so both gate
/// on the same grammar.
///
/// Forms accepted:
///   - `1.2.3.4:443`
///   - `10.0.0.0/8:443`
///   - `api.example.com:443`
///   - any of the above with an explicit `/tcp` or `/udp` suffix
///
/// Defaults to `tcp` when the protocol suffix is omitted (tcp is the
/// only thing actually enforced today; udp entries parse but don't
/// fire).
pub fn parse_cli_entry(spec: &str) -> Result<RawEgressEntry, AllowlistFlagError> {
    let trimmed = spec.trim();
    if trimmed.is_empty() {
        return Err(AllowlistFlagError::BadEntryShape(spec.to_string()));
    }

    // Strip the optional `/tcp` / `/udp` suffix from the right so the
    // CIDR slash (e.g. `10.0.0.0/8`) doesn't get confused with the
    // protocol slash. The suffix is only recognised when it appears
    // *after* the port — a spec like `10.0.0.0/8:443` has a slash but
    // no proto tail, so we leave the whole string alone and let the
    // host:port split below pick up `10.0.0.0/8` as the host.
    let (head, protocol) = match trimmed.rsplit_once('/') {
        Some((head, tail)) if !tail.contains(':') => {
            match tail.to_ascii_lowercase().as_str() {
                "tcp" => (head, Protocol::Tcp),
                "udp" => (head, Protocol::Udp),
                _ => {
                    return Err(AllowlistFlagError::UnsupportedProtocol(
                        spec.to_string(),
                        tail.to_string(),
                    ));
                }
            }
        }
        _ => (trimmed, Protocol::Tcp),
    };

    // Now split the host:port on the *last* `:` so CIDR slashes in HOST
    // (which always sit left of the colon) are preserved verbatim.
    let (host, port_str) = head
        .rsplit_once(':')
        .ok_or_else(|| AllowlistFlagError::MissingPort(spec.to_string()))?;

    let host = host.trim();
    if host.is_empty() {
        return Err(AllowlistFlagError::EmptyHost(spec.to_string()));
    }

    let port: u16 = port_str
        .trim()
        .parse()
        .map_err(|e| AllowlistFlagError::InvalidPort(spec.to_string(), e))?;
    if port == 0 {
        return Err(AllowlistFlagError::PortZero(spec.to_string()));
    }

    // Up-front rejection of obviously-bad hosts. The `from_raw` pipeline
    // would otherwise silently demote them to hostname entries that
    // never resolve, so we'd lose the early error.
    if host.parse::<Ipv4Net>().is_err() && host.parse::<Ipv4Addr>().is_err() {
        // Not an IPv4 literal/CIDR — has to be a hostname. v6 lands here.
        if let Ok(IpAddr::V6(_)) = host.parse::<IpAddr>() {
            return Err(AllowlistFlagError::IpV6Host(spec.to_string()));
        }
        if let Err(reason) = validate_hostname(host) {
            return Err(AllowlistFlagError::InvalidHostname {
                spec: spec.to_string(),
                host: host.to_string(),
                reason,
            });
        }
    }

    Ok(RawEgressEntry {
        host: host.to_string(),
        port,
        protocol,
    })
}

/// Validate a resolver spec from `--egress-resolver`. IPv4 only —
/// `unbound` upstream resolvers must be IPv4 literals because IPv6
/// egress is denied across the whole policy.
pub fn parse_cli_resolver(spec: &str) -> Result<String, AllowlistFlagError> {
    let trimmed = spec.trim();
    match trimmed.parse::<IpAddr>() {
        Ok(IpAddr::V4(_)) => Ok(trimmed.to_string()),
        _ => Err(AllowlistFlagError::InvalidResolver(spec.to_string())),
    }
}

/// Assemble a canonical [`RawAllowlist`] from CLI-style inputs and
/// validate it by running through the same `from_raw` pipeline the
/// in-enclave daemon uses. Returns the typed structure on success;
/// callers serialise it back to JSON with `serde_json::to_*`.
///
/// `allow_specs` are `HOST:PORT[/PROTO]` strings. `resolver_specs` are
/// IPv4 literals. Either list may be empty; an empty allowlist is
/// deny-all on the daemon side, which is the same behaviour as a
/// missing file.
pub fn assemble_from_cli(
    allow_specs: &[&str],
    resolver_specs: &[&str],
) -> Result<RawAllowlist, AllowlistFlagError> {
    let mut raw = RawAllowlist::new_v1();
    for s in allow_specs {
        raw.egress.push(parse_cli_entry(s)?);
    }
    for r in resolver_specs {
        raw.resolvers.push(parse_cli_resolver(r)?);
    }
    // Run the typed-validation pipeline so CLI/backend/daemon agree on
    // what is well-formed. We discard the typed config here — the
    // caller wants the canonical JSON-shaped struct — but a failure
    // here means the daemon would reject the same input at boot.
    AllowlistConfig::from_raw(raw.clone()).map_err(AllowlistFlagError::Validation)?;
    Ok(raw)
}

/// Validate a JSON-shaped allowlist supplied by the frontend / API
/// without a CLI parse step. Backend uses this on POST /enclaves.
///
/// Accepts either a raw `serde_json::Value` (frontend POSTs a JSON
/// object) or anything that deserialises into [`RawAllowlist`]. On
/// success returns the canonical struct so the caller can re-serialise
/// it for storage; on failure returns the same `AllowlistFlagError`
/// the CLI path uses, so error messages stay consistent.
pub fn validate_json(value: &serde_json::Value) -> Result<RawAllowlist, AllowlistFlagError> {
    let raw: RawAllowlist = serde_json::from_value(value.clone()).map_err(|e| {
        AllowlistFlagError::Validation(AllowlistLoadError::Json(PathBuf::new(), e))
    })?;
    AllowlistConfig::from_raw(raw.clone()).map_err(AllowlistFlagError::Validation)?;
    Ok(raw)
}

/// Minimum-effort hostname validation: RFC 1035-ish syntax check that
/// catches obvious garbage (empty labels, leading/trailing dots,
/// invalid chars) without trying to be a full IDNA validator. We don't
/// resolve here — the in-enclave daemon's DNS query is the real test —
/// but rejecting `foo!bar` at the CLI is more useful than waiting for
/// the resolver to silently never match.
fn validate_hostname(host: &str) -> Result<(), &'static str> {
    if host.is_empty() {
        return Err("empty hostname");
    }
    if host.len() > 253 {
        return Err("hostname exceeds 253 characters");
    }
    if host.starts_with('.') || host.ends_with('.') {
        return Err("hostname must not start or end with a dot");
    }
    for label in host.split('.') {
        if label.is_empty() {
            return Err("hostname contains an empty label (consecutive dots)");
        }
        if label.len() > 63 {
            return Err("hostname label exceeds 63 characters");
        }
        if label.starts_with('-') || label.ends_with('-') {
            return Err("hostname label must not start or end with a hyphen");
        }
        if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
            return Err("hostname label has invalid characters (allowed: a-z, 0-9, '-')");
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_literal_and_cidr_entries() {
        let raw = br#"{
            "version": 1,
            "resolvers": ["1.1.1.1"],
            "egress": [
                {"host": "10.0.0.0/8", "port": 443, "protocol": "tcp"},
                {"host": "1.2.3.4",   "port": 80,  "protocol": "tcp"}
            ]
        }"#;
        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
        assert_eq!(cfg.entries.len(), 2);
        assert_eq!(cfg.resolvers, vec![Ipv4Addr::new(1, 1, 1, 1)]);
        assert!(matches!(cfg.entries[0].host, HostMatcher::Cidr(_)));
        assert!(matches!(cfg.entries[1].host, HostMatcher::Literal(_)));
    }

    #[test]
    fn hostnames_are_recognized_as_first_class() {
        let raw = br#"{
            "version": 1,
            "egress": [
                {"host": "api.openai.com", "port": 443, "protocol": "tcp"}
            ]
        }"#;
        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
        assert!(cfg.entries.is_empty());
        assert_eq!(cfg.hostnames.len(), 1);
        assert_eq!(cfg.hostnames[0].host, "api.openai.com");
        assert_eq!(cfg.hostnames[0].port, 443);
        assert!(matches!(cfg.hostnames[0].protocol, Protocol::Tcp));
    }

    #[test]
    fn hostnames_are_lowercased() {
        let raw = br#"{
            "version": 1,
            "egress": [
                {"host": "API.Openai.COM", "port": 443, "protocol": "tcp"}
            ]
        }"#;
        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
        assert_eq!(cfg.hostnames[0].host, "api.openai.com");
    }

    #[test]
    fn ipv6_literal_entries_are_dropped() {
        let raw = br#"{
            "version": 1,
            "egress": [
                {"host": "::1", "port": 443, "protocol": "tcp"}
            ]
        }"#;
        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
        assert!(cfg.entries.is_empty());
        assert!(cfg.hostnames.is_empty());
    }

    #[test]
    fn hostnames_for_port_filters_by_port() {
        let raw = br#"{
            "version": 1,
            "egress": [
                {"host": "a.example", "port": 443, "protocol": "tcp"},
                {"host": "b.example", "port": 80,  "protocol": "tcp"}
            ]
        }"#;
        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
        let on_443: Vec<_> =
            cfg.tcp_hostnames_for_port(443).map(|h| h.host.as_str()).collect();
        assert_eq!(on_443, vec!["a.example"]);
    }

    #[test]
    fn udp_entry_in_json_is_rejected() {
        let raw = br#"{
            "version": 1,
            "egress": [
                {"host": "1.1.1.1", "port": 53, "protocol": "udp"}
            ]
        }"#;
        let err = AllowlistConfig::from_bytes(raw).expect_err("must reject UDP");
        assert!(matches!(
            err,
            AllowlistLoadError::UdpNotSupported { ref host, port: 53 } if host == "1.1.1.1"
        ));
    }

    #[test]
    fn udp_entry_in_hostname_form_is_rejected() {
        let raw = br#"{
            "version": 1,
            "egress": [
                {"host": "example.com", "port": 53, "protocol": "udp"}
            ]
        }"#;
        let err = AllowlistConfig::from_bytes(raw).expect_err("must reject UDP");
        assert!(matches!(err, AllowlistLoadError::UdpNotSupported { .. }));
    }

    #[test]
    fn udp_via_assemble_from_cli_is_rejected() {
        let err = assemble_from_cli(&["1.1.1.1:53/udp"], &[]).expect_err("must reject UDP");
        assert!(matches!(
            err,
            AllowlistFlagError::Validation(AllowlistLoadError::UdpNotSupported { .. })
        ));
    }

    #[test]
    fn malformed_json_returns_error() {
        let raw = br#"{ "version": 1, "egress": [ ] "#;
        let err = AllowlistConfig::from_bytes(raw).expect_err("must fail");
        assert!(matches!(err, AllowlistLoadError::Json(_, _)));
    }

    #[test]
    fn unsupported_version_rejected() {
        let raw = br#"{ "version": 2, "egress": [] }"#;
        let err = AllowlistConfig::from_bytes(raw).expect_err("must fail");
        assert!(matches!(err, AllowlistLoadError::UnsupportedVersion(2)));
    }

    #[test]
    fn missing_file_returns_empty() {
        let path = std::path::PathBuf::from("/tmp/this-path-does-not-exist-egress-XYZ.json");
        let cfg = AllowlistConfig::load_or_empty(&path).expect("missing file should not error");
        assert!(cfg.entries.is_empty());
    }

    #[test]
    fn empty_file_returns_empty() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("egress.json");
        std::fs::write(&p, "\n\n  \n").unwrap();
        let cfg = AllowlistConfig::load_or_empty(&p).expect("load");
        assert!(cfg.entries.is_empty());
    }

    #[test]
    fn duplicate_entries_do_not_panic() {
        let raw = br#"{
            "version": 1,
            "egress": [
                {"host": "1.2.3.4", "port": 80, "protocol": "tcp"},
                {"host": "1.2.3.4", "port": 80, "protocol": "tcp"}
            ]
        }"#;
        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
        assert_eq!(cfg.entries.len(), 2);
        assert!(cfg.allows_tcp(Ipv4Addr::new(1, 2, 3, 4), 80));
    }

    #[test]
    fn cli_entry_parses_literal_default_tcp() {
        let e = parse_cli_entry("1.2.3.4:443").unwrap();
        assert_eq!(e.host, "1.2.3.4");
        assert_eq!(e.port, 443);
        assert_eq!(e.protocol, Protocol::Tcp);
    }

    #[test]
    fn cli_entry_parses_explicit_tcp() {
        let e = parse_cli_entry("1.2.3.4:443/tcp").unwrap();
        assert_eq!(e.protocol, Protocol::Tcp);
    }

    #[test]
    fn cli_entry_parses_explicit_udp() {
        let e = parse_cli_entry("1.2.3.4:53/udp").unwrap();
        assert_eq!(e.protocol, Protocol::Udp);
    }

    #[test]
    fn cli_entry_parses_cidr() {
        let e = parse_cli_entry("10.0.0.0/8:443").unwrap();
        assert_eq!(e.host, "10.0.0.0/8");
        assert_eq!(e.port, 443);
    }

    #[test]
    fn cli_entry_parses_cidr_with_proto() {
        let e = parse_cli_entry("10.0.0.0/8:443/udp").unwrap();
        assert_eq!(e.host, "10.0.0.0/8");
        assert_eq!(e.protocol, Protocol::Udp);
    }

    #[test]
    fn cli_entry_parses_hostname() {
        let e = parse_cli_entry("api.example.com:443").unwrap();
        assert_eq!(e.host, "api.example.com");
        assert_eq!(e.port, 443);
    }

    #[test]
    fn cli_entry_rejects_missing_port() {
        assert!(matches!(
            parse_cli_entry("api.example.com"),
            Err(AllowlistFlagError::MissingPort(_))
        ));
    }

    #[test]
    fn cli_entry_rejects_bad_port() {
        assert!(matches!(
            parse_cli_entry("api.example.com:abc"),
            Err(AllowlistFlagError::InvalidPort(_, _))
        ));
    }

    #[test]
    fn cli_entry_rejects_zero_port() {
        assert!(matches!(
            parse_cli_entry("1.2.3.4:0"),
            Err(AllowlistFlagError::PortZero(_))
        ));
    }

    #[test]
    fn cli_entry_rejects_unknown_protocol() {
        assert!(matches!(
            parse_cli_entry("1.2.3.4:443/sctp"),
            Err(AllowlistFlagError::UnsupportedProtocol(_, _))
        ));
    }

    #[test]
    fn cli_entry_rejects_bad_hostname() {
        assert!(matches!(
            parse_cli_entry("foo!bar.com:443"),
            Err(AllowlistFlagError::InvalidHostname { .. })
        ));
    }

    #[test]
    fn cli_entry_rejects_ipv6() {
        assert!(matches!(
            parse_cli_entry("::1:443"),
            Err(AllowlistFlagError::IpV6Host(_)) | Err(AllowlistFlagError::InvalidHostname { .. })
        ));
    }

    #[test]
    fn cli_resolver_accepts_ipv4() {
        assert_eq!(parse_cli_resolver("1.1.1.1").unwrap(), "1.1.1.1");
    }

    #[test]
    fn cli_resolver_rejects_hostname() {
        assert!(matches!(
            parse_cli_resolver("dns.example.com"),
            Err(AllowlistFlagError::InvalidResolver(_))
        ));
    }

    #[test]
    fn assemble_round_trips_through_from_raw() {
        let raw = assemble_from_cli(
            &["10.0.0.0/8:443", "api.example.com:443/tcp", "1.2.3.4:80"],
            &["1.1.1.1"],
        )
        .unwrap();
        assert_eq!(raw.version, SCHEMA_VERSION);
        assert_eq!(raw.egress.len(), 3);
        assert_eq!(raw.resolvers, vec!["1.1.1.1".to_string()]);

        // The typed config should accept this without complaint.
        let cfg = AllowlistConfig::from_raw(raw).unwrap();
        assert_eq!(cfg.entries.len(), 2); // CIDR + literal
        assert_eq!(cfg.hostnames.len(), 1);
        assert_eq!(cfg.resolvers.len(), 1);
    }

    #[test]
    fn validate_json_accepts_well_formed_doc() {
        let v: serde_json::Value = serde_json::from_str(
            r#"{"version": 1, "resolvers": ["1.1.1.1"], "egress": [{"host":"api.example.com","port":443,"protocol":"tcp"}]}"#,
        )
        .unwrap();
        let raw = validate_json(&v).unwrap();
        assert_eq!(raw.egress.len(), 1);
        assert_eq!(raw.resolvers.len(), 1);
    }

    #[test]
    fn validate_json_rejects_unknown_version() {
        let v: serde_json::Value =
            serde_json::from_str(r#"{"version": 2, "egress": []}"#).unwrap();
        assert!(matches!(
            validate_json(&v),
            Err(AllowlistFlagError::Validation(AllowlistLoadError::UnsupportedVersion(2)))
        ));
    }

    #[test]
    fn quad_zero_slash_zero_matches_everything() {
        let raw = br#"{
            "version": 1,
            "egress": [
                {"host": "0.0.0.0/0", "port": 19444, "protocol": "tcp"}
            ]
        }"#;
        let cfg = AllowlistConfig::from_bytes(raw).expect("parse");
        assert!(cfg.allows_tcp(Ipv4Addr::new(1, 2, 3, 4), 19444));
        assert!(cfg.allows_tcp(Ipv4Addr::new(192, 168, 1, 1), 19444));
        assert!(!cfg.allows_tcp(Ipv4Addr::new(1, 2, 3, 4), 19445));
    }
}