Skip to main content

cellos_supervisor/
nft_counters.rs

1//! Pure-data parsing + classification of `nft list ruleset --json` output for
2//! FC-38 Phase 1 per-flow `network_flow_decision` events.
3//!
4//! Honest scope: this module operates on the structured JSON nftables prints
5//! when invoked with `--json`. It does **not** invoke `nft` itself, does no
6//! I/O, and does not attempt real-time per-packet observation. Phase 2
7//! (future) is eBPF / nflog real-time per-flow events; this Phase 1 layer
8//! emits "which rule fired with what counter delta" attribution after the
9//! workload exits, scraped via `nsenter ... nft list ruleset --json` from the
10//! supervisor's hot path.
11//!
12//! The functions in this module are deliberately pure so they can be unit
13//! tested on any platform — including macOS dev hosts where `nft(8)` is not
14//! available — by feeding synthesized JSON shaped like the real binary
15//! produces. The callers that DO invoke `nft` live behind
16//! `#[cfg(target_os = "linux")]` in `supervisor.rs`.
17//!
18//! Two responsibilities:
19//!
20//! 1. [`parse_nft_list_ruleset_json`] turns the JSON document into a flat
21//!    [`Vec<NftCounterRow>`]. One row per rule that nft printed, carrying
22//!    its `family` / `table` / `chain` / `handle`, the `packets` / `bytes`
23//!    counter from the rule's expression list when present, and a
24//!    text representation of the rule's expression that the classifier can
25//!    work on.
26//!
27//! 2. [`classify_rule_repr`] takes the synthesized text representation and
28//!    decides which of the four FC-38 reason-codes Phase 1 attributes
29//!    today, plus the destination address / port / protocol when the rule
30//!    expresses them.
31//!
32//! See `docs/trust-plane-runtime.md` §Per-flow enforcement events (FC-38)
33//! for the operator-side documentation.
34//!
35//! `parse_nft_list_ruleset_json` (and its private helpers) is only consumed
36//! by the `#[cfg(target_os = "linux")]` `scan_nft_counters_in_ns` path in
37//! `supervisor.rs`. On non-Linux dev builds the binary's hot path doesn't
38//! reach the parser, so we suppress the `dead_code` lint at module level
39//! rather than chase the symbol through cfg gates — the parser is still
40//! exercised on every platform via `cargo test -p cellos-supervisor --lib
41//! nft_counters`.
42
43#![allow(dead_code)]
44
45use serde::{Deserialize, Serialize};
46
47/// One counter row distilled from `nft list ruleset --json` output.
48///
49/// `rule_repr` is a synthesized text shape (e.g. `"ip daddr 10.0.0.1 tcp dport 443 accept"`)
50/// rebuilt from the JSON expression tree so the classifier can run pure
51/// string matching against the same shape `generate_nft_ruleset` emits in
52/// `supervisor.rs`. We do NOT try to round-trip the original text — the
53/// repr is a normalized debug form purely for classification + audit
54/// surfaces.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct NftCounterRow {
57    /// nft family, e.g. `"inet"`, `"ip"`, `"ip6"`.
58    pub family: String,
59    /// Table name, e.g. `"cellos_<safe_id>"`.
60    pub table: String,
61    /// Chain name, e.g. `"output"`.
62    pub chain: String,
63    /// nft handle for the rule (stable within a single ruleset version).
64    pub handle: u64,
65    /// Packet counter from the rule's `counter` expression, or `0` when
66    /// no counter was present. Phase 1 emits per-rule events whether the
67    /// counter is present or zero — operators want to know "this deny rule
68    /// applied, no traffic hit it" as much as "this deny rule fired N times".
69    pub packet_count: u64,
70    /// Byte counter from the rule's `counter` expression, or `0`.
71    pub byte_count: u64,
72    /// Synthesized text shape for the classifier (see struct doc).
73    pub rule_repr: String,
74}
75
76/// Errors returned by [`parse_nft_list_ruleset_json`].
77#[derive(Debug, thiserror::Error)]
78pub enum NftCountersError {
79    /// The input wasn't valid JSON.
80    #[error("nft counter JSON parse failure: {0}")]
81    Json(#[from] serde_json::Error),
82    /// The JSON parsed but didn't contain the expected `nftables` array shape.
83    #[error("nft counter JSON missing top-level `nftables` array")]
84    MissingNftablesArray,
85}
86
87/// Parse the JSON document produced by `nft list ruleset --json` into a flat
88/// list of counter rows.
89///
90/// nft's JSON shape is `{"nftables": [ {<obj>}, {<obj>}, ... ]}` where each
91/// object is one of: `{"metainfo": ...}`, `{"table": ...}`, `{"chain": ...}`,
92/// or `{"rule": {...}}`. Phase 1 cares only about the `rule` entries.
93///
94/// Each `rule` entry carries:
95/// - `family`, `table`, `chain`, `handle`
96/// - `expr`: a list of expression objects describing the rule's match +
97///   verdict, e.g. `{"match": {"left": ..., "right": ..., "op": "=="}},
98///   {"counter": {"packets": N, "bytes": N}}, {"accept": null}`.
99///
100/// We build the synthesized `rule_repr` by walking `expr` and emitting a
101/// minimal text form sufficient for [`classify_rule_repr`]. Counter
102/// expressions are surfaced into [`NftCounterRow::packet_count`] /
103/// [`NftCounterRow::byte_count`].
104///
105/// Unknown / unsupported expression kinds are skipped silently so we degrade
106/// gracefully across nftables minor versions; a rule whose entire `expr` is
107/// unrecognized still surfaces as a row with an empty `rule_repr` (the
108/// classifier returns a default-deny-style attribution for that case).
109pub fn parse_nft_list_ruleset_json(raw: &str) -> Result<Vec<NftCounterRow>, NftCountersError> {
110    let value: serde_json::Value = serde_json::from_str(raw)?;
111    let arr = value
112        .get("nftables")
113        .and_then(|v| v.as_array())
114        .ok_or(NftCountersError::MissingNftablesArray)?;
115
116    let mut rows = Vec::new();
117    for item in arr {
118        let Some(rule) = item.get("rule").and_then(|v| v.as_object()) else {
119            continue;
120        };
121        let family = rule
122            .get("family")
123            .and_then(|v| v.as_str())
124            .unwrap_or_default()
125            .to_string();
126        let table = rule
127            .get("table")
128            .and_then(|v| v.as_str())
129            .unwrap_or_default()
130            .to_string();
131        let chain = rule
132            .get("chain")
133            .and_then(|v| v.as_str())
134            .unwrap_or_default()
135            .to_string();
136        let handle = rule.get("handle").and_then(|v| v.as_u64()).unwrap_or(0);
137        let expr = rule.get("expr").and_then(|v| v.as_array());
138
139        let mut packet_count: u64 = 0;
140        let mut byte_count: u64 = 0;
141        let mut repr_parts: Vec<String> = Vec::new();
142        if let Some(exprs) = expr {
143            for e in exprs {
144                emit_expr(e, &mut repr_parts, &mut packet_count, &mut byte_count);
145            }
146        }
147        let rule_repr = repr_parts.join(" ");
148        rows.push(NftCounterRow {
149            family,
150            table,
151            chain,
152            handle,
153            packet_count,
154            byte_count,
155            rule_repr,
156        });
157    }
158    Ok(rows)
159}
160
161/// Walk a single expression object and append its rendered form (if any)
162/// to `repr_parts`. Counter expressions are extracted into the running
163/// counters rather than rendered to text.
164fn emit_expr(
165    expr: &serde_json::Value,
166    repr_parts: &mut Vec<String>,
167    packet_count: &mut u64,
168    byte_count: &mut u64,
169) {
170    let Some(obj) = expr.as_object() else {
171        return;
172    };
173    for (kind, body) in obj {
174        match kind.as_str() {
175            "counter" => {
176                if let Some(c) = body.as_object() {
177                    if let Some(p) = c.get("packets").and_then(|v| v.as_u64()) {
178                        // Multiple counter expressions on a single rule are
179                        // unusual but tolerated — sum across them so a rule
180                        // with split packet/byte counters surfaces the
181                        // largest observed value rather than the last one.
182                        *packet_count = (*packet_count).max(p);
183                    }
184                    if let Some(b) = c.get("bytes").and_then(|v| v.as_u64()) {
185                        *byte_count = (*byte_count).max(b);
186                    }
187                }
188            }
189            "match" => {
190                if let Some(m) = body.as_object() {
191                    let left = m.get("left");
192                    let right = m.get("right");
193                    let op = m.get("op").and_then(|v| v.as_str()).unwrap_or("==");
194                    if op == "==" {
195                        if let Some(rendered) = render_match(left, right) {
196                            repr_parts.push(rendered);
197                        }
198                    }
199                }
200            }
201            "accept" => repr_parts.push("accept".to_string()),
202            "drop" => repr_parts.push("drop".to_string()),
203            "policy" => {
204                if let Some(s) = body.as_str() {
205                    repr_parts.push(format!("policy {s}"));
206                }
207            }
208            // ignore other expression kinds (log, jump, return, ...)
209            _ => {}
210        }
211    }
212}
213
214/// Render an nft `match` expression to the text shape `classify_rule_repr`
215/// recognizes. Supports the limited family used by `generate_nft_ruleset`:
216/// `ip daddr <ip>`, `ip6 daddr <ip>`, and `<proto> dport <n>`.
217fn render_match(
218    left: Option<&serde_json::Value>,
219    right: Option<&serde_json::Value>,
220) -> Option<String> {
221    let left = left?.as_object()?;
222    let payload = left.get("payload")?.as_object()?;
223    let proto = payload.get("protocol").and_then(|v| v.as_str())?;
224    let field = payload.get("field").and_then(|v| v.as_str())?;
225    let right_value = right?;
226    let rendered_right = match right_value {
227        serde_json::Value::String(s) => s.clone(),
228        serde_json::Value::Number(n) => n.to_string(),
229        _ => return None,
230    };
231    match (proto, field) {
232        ("ip", "daddr") => Some(format!("ip daddr {rendered_right}")),
233        ("ip6", "daddr") => Some(format!("ip6 daddr {rendered_right}")),
234        ("udp", "dport") => Some(format!("udp dport {rendered_right}")),
235        ("tcp", "dport") => Some(format!("tcp dport {rendered_right}")),
236        _ => None,
237    }
238}
239
240/// Result of classifying a single rule's text repr.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct ClassifiedRule {
243    /// `allow` for `accept`, `deny` for `drop` (or default-drop policy).
244    pub decision: NftDecision,
245    /// FC-38 Phase 1 reason code; one of the four documented classes or
246    /// the catch-all `nft_default_drop` for unrecognized drop rules.
247    pub reason_code: &'static str,
248    /// Optional reference back to the rule's declaration site in the spec.
249    pub nft_rule_ref: Option<String>,
250    /// Optional destination IP (when the rule has `ip/ip6 daddr <x>`).
251    pub dst_addr: Option<String>,
252    /// Optional destination port (when the rule has `<proto> dport <n>`).
253    pub dst_port: Option<u16>,
254    /// Optional transport protocol (`udp` / `tcp`).
255    pub protocol: Option<String>,
256}
257
258/// Allow / deny outcome a classifier returns. Mirrors
259/// [`cellos_core::NetworkFlowDecisionOutcome`] but lives in this module to
260/// keep the parser independent of the public types crate's serde contract.
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum NftDecision {
263    Allow,
264    Deny,
265}
266
267/// Classify a single nft rule's text shape into a Phase 1 reason code.
268///
269/// The recognized shapes match the output of `generate_nft_ruleset` in
270/// `supervisor.rs`:
271///
272/// | shape                                                   | decision | reason_code                  |
273/// |---------------------------------------------------------|----------|------------------------------|
274/// | `policy drop`                                           | deny     | `nft_default_drop`           |
275/// | `udp dport 53 drop` or `tcp dport 53 drop`              | deny     | `nft_workload_dns_block`     |
276/// | `udp dport 853 drop` (SEC-22 Phase 3d DoQ block)        | deny     | `nft_doq_blocked`            |
277/// | `udp dport 443 drop` (SEC-22 Phase 3d HTTP/3 block)     | deny     | `nft_http3_blocked`          |
278/// | `ip daddr <ip> udp dport 53 accept` (resolver allow)    | allow    | `nft_resolver_allowlist_match`|
279/// | `ip daddr <ip> tcp dport 53 accept` (resolver allow)    | allow    | `nft_resolver_allowlist_match`|
280/// | `ip daddr <ip> udp dport 853 accept` (Phase 3d allow)   | allow    | `nft_resolver_allowlist_match`|
281/// | `ip daddr <ip> udp dport 443 accept` (Phase 3d allow)   | allow    | `nft_resolver_allowlist_match`|
282/// | `ip[6] daddr <ip> <proto> dport <port> accept` (egress) | allow    | `nft_egress_rule_match`      |
283/// | (anything else with `accept`)                           | allow    | `nft_egress_rule_match`      |
284/// | (anything else with `drop`)                             | deny     | `nft_default_drop`           |
285/// | (empty / unrecognized)                                  | deny     | `nft_default_drop`           |
286///
287/// `nft_resolver_allowlist_match` distinguishes a port-53 / port-853 / port-443
288/// accept (a declared `dnsAuthority.resolvers[]` carve-out under the SEC-22
289/// backstop) from an ordinary egress accept (`nft_egress_rule_match`). Both
290/// are `decision: allow`; the reason code lets operators tell at a glance
291/// whether traffic is hitting the resolver allowlist or a generic
292/// `authority.egressRules[]` entry. Workload-DNS denials still surface as
293/// `nft_workload_dns_block` from the udp/tcp 53 drop rules.
294///
295/// SEC-22 Phase 3d adds two new deny reason codes — `nft_doq_blocked` for
296/// the `udp dport 853 drop` rule and `nft_http3_blocked` for the
297/// `udp dport 443 drop` rule. These fire only when the operator has set the
298/// corresponding `blockUdpDoq` / `blockUdpHttp3` opt-in. The
299/// resolver-allowlist match is also extended to recognise the parallel
300/// accept-leg shape on UDP/853 and UDP/443 (same `ip daddr <x> udp dport <p>
301/// accept` shape, just with a different dport).
302///
303/// `nft_rule_ref` is populated when the rule's shape unambiguously points at
304/// a spec source. For the policy-default-drop and the workload-dns-block
305/// rules, we emit the synthetic refs `default-drop` and `dnsAuthority.workloadDnsBlock`
306/// respectively — they don't index into a list. For Phase 3d, the synthetic
307/// refs are `dnsAuthority.blockUdpDoq` and `dnsAuthority.blockUdpHttp3`. For
308/// ordinary egress rules we cannot reverse the index without the full
309/// ruleset context, so the caller (supervisor) is responsible for setting
310/// the spec-relative ref; this function leaves it as `None` for those rows.
311pub fn classify_rule_repr(rule_repr: &str) -> ClassifiedRule {
312    let trimmed = rule_repr.trim();
313
314    // Default-drop policy line ("policy drop"): lone keyword.
315    if trimmed == "policy drop" {
316        return ClassifiedRule {
317            decision: NftDecision::Deny,
318            reason_code: "nft_default_drop",
319            nft_rule_ref: Some("default-drop".to_string()),
320            dst_addr: None,
321            dst_port: None,
322            protocol: None,
323        };
324    }
325
326    let has_accept = trimmed.split_whitespace().any(|t| t == "accept");
327    let has_drop = trimmed.split_whitespace().any(|t| t == "drop");
328
329    // SEC-22 workload-dns-block: bare `udp/tcp dport 53 drop` with no daddr.
330    let dst_addr = extract_daddr(trimmed);
331    let (proto, dport) = extract_dport(trimmed);
332
333    if has_drop && dst_addr.is_none() && dport == Some(53) {
334        let proto_string = proto.map(|p| p.to_string());
335        return ClassifiedRule {
336            decision: NftDecision::Deny,
337            reason_code: "nft_workload_dns_block",
338            nft_rule_ref: Some("dnsAuthority.workloadDnsBlock".to_string()),
339            dst_addr: None,
340            dst_port: Some(53),
341            protocol: proto_string,
342        };
343    }
344
345    // SEC-22 Phase 3d — bare `udp dport 853 drop` with no daddr (DoQ block).
346    if has_drop && dst_addr.is_none() && dport == Some(853) && proto == Some("udp") {
347        return ClassifiedRule {
348            decision: NftDecision::Deny,
349            reason_code: "nft_doq_blocked",
350            nft_rule_ref: Some("dnsAuthority.blockUdpDoq".to_string()),
351            dst_addr: None,
352            dst_port: Some(853),
353            protocol: Some("udp".to_string()),
354        };
355    }
356
357    // SEC-22 Phase 3d — bare `udp dport 443 drop` with no daddr (HTTP/3 block).
358    if has_drop && dst_addr.is_none() && dport == Some(443) && proto == Some("udp") {
359        return ClassifiedRule {
360            decision: NftDecision::Deny,
361            reason_code: "nft_http3_blocked",
362            nft_rule_ref: Some("dnsAuthority.blockUdpHttp3".to_string()),
363            dst_addr: None,
364            dst_port: Some(443),
365            protocol: Some("udp".to_string()),
366        };
367    }
368
369    if has_accept {
370        // Resolver allowlist rule: `ip[6] daddr <ip> <proto> dport <p> accept`
371        // where p is 53 (legacy SEC-22), or — under SEC-22 Phase 3d — also
372        // 853 (DoQ) / 443 (HTTP/3) for UDP transport. The reason code
373        // reflects "this allow exists because a SEC-22 resolver carve-out
374        // is active" — see fn doc.
375        let is_phase3d_udp_resolver_port =
376            proto == Some("udp") && (dport == Some(853) || dport == Some(443));
377        if dport == Some(53) || is_phase3d_udp_resolver_port {
378            let proto_string = proto.map(|p| p.to_string());
379            return ClassifiedRule {
380                decision: NftDecision::Allow,
381                reason_code: "nft_resolver_allowlist_match",
382                nft_rule_ref: None,
383                dst_addr,
384                dst_port: dport,
385                protocol: proto_string,
386            };
387        }
388        let proto_string = proto.map(|p| p.to_string());
389        return ClassifiedRule {
390            decision: NftDecision::Allow,
391            reason_code: "nft_egress_rule_match",
392            nft_rule_ref: None,
393            dst_addr,
394            dst_port: dport,
395            protocol: proto_string,
396        };
397    }
398
399    if has_drop {
400        let proto_string = proto.map(|p| p.to_string());
401        return ClassifiedRule {
402            decision: NftDecision::Deny,
403            reason_code: "nft_default_drop",
404            nft_rule_ref: None,
405            dst_addr,
406            dst_port: dport,
407            protocol: proto_string,
408        };
409    }
410
411    // Empty or unrecognized — treat as deny default. Honest fallback so a
412    // rule we don't understand surfaces as an event the operator can see
413    // rather than getting silently dropped.
414    ClassifiedRule {
415        decision: NftDecision::Deny,
416        reason_code: "nft_default_drop",
417        nft_rule_ref: None,
418        dst_addr: None,
419        dst_port: None,
420        protocol: None,
421    }
422}
423
424/// Extract the destination IP from a rule repr like
425/// `"ip daddr 10.0.0.1 tcp dport 443 accept"`. Supports both `ip` and `ip6`
426/// daddrs.
427fn extract_daddr(repr: &str) -> Option<String> {
428    let tokens: Vec<&str> = repr.split_whitespace().collect();
429    for win in tokens.windows(3) {
430        if (win[0] == "ip" || win[0] == "ip6") && win[1] == "daddr" {
431            return Some(win[2].to_string());
432        }
433    }
434    None
435}
436
437/// Extract `(protocol, port)` from a rule repr containing
438/// `"<proto> dport <n>"`. Returns `(None, None)` when absent.
439fn extract_dport(repr: &str) -> (Option<&'static str>, Option<u16>) {
440    let tokens: Vec<&str> = repr.split_whitespace().collect();
441    for win in tokens.windows(3) {
442        let proto = match win[0] {
443            "udp" => Some("udp"),
444            "tcp" => Some("tcp"),
445            _ => None,
446        };
447        if proto.is_some() && win[1] == "dport" {
448            if let Ok(p) = win[2].parse::<u16>() {
449                return (proto, Some(p));
450            }
451        }
452    }
453    (None, None)
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn classify_recognizes_default_drop_policy() {
462        let c = classify_rule_repr("policy drop");
463        assert_eq!(c.decision, NftDecision::Deny);
464        assert_eq!(c.reason_code, "nft_default_drop");
465        assert_eq!(c.nft_rule_ref.as_deref(), Some("default-drop"));
466        assert!(c.dst_addr.is_none());
467        assert!(c.dst_port.is_none());
468        assert!(c.protocol.is_none());
469    }
470
471    #[test]
472    fn classify_recognizes_workload_dns_block_udp() {
473        let c = classify_rule_repr("udp dport 53 drop");
474        assert_eq!(c.decision, NftDecision::Deny);
475        assert_eq!(c.reason_code, "nft_workload_dns_block");
476        assert_eq!(
477            c.nft_rule_ref.as_deref(),
478            Some("dnsAuthority.workloadDnsBlock")
479        );
480        assert_eq!(c.dst_port, Some(53));
481        assert_eq!(c.protocol.as_deref(), Some("udp"));
482        assert!(c.dst_addr.is_none());
483    }
484
485    #[test]
486    fn classify_recognizes_workload_dns_block_tcp() {
487        let c = classify_rule_repr("tcp dport 53 drop");
488        assert_eq!(c.decision, NftDecision::Deny);
489        assert_eq!(c.reason_code, "nft_workload_dns_block");
490        assert_eq!(c.dst_port, Some(53));
491        assert_eq!(c.protocol.as_deref(), Some("tcp"));
492    }
493
494    #[test]
495    fn classify_recognizes_resolver_allowlist_udp() {
496        let c = classify_rule_repr("ip daddr 1.1.1.1 udp dport 53 accept");
497        assert_eq!(c.decision, NftDecision::Allow);
498        assert_eq!(c.reason_code, "nft_resolver_allowlist_match");
499        assert_eq!(c.dst_addr.as_deref(), Some("1.1.1.1"));
500        assert_eq!(c.dst_port, Some(53));
501        assert_eq!(c.protocol.as_deref(), Some("udp"));
502    }
503
504    #[test]
505    fn classify_recognizes_resolver_allowlist_tcp() {
506        let c = classify_rule_repr("ip daddr 1.1.1.1 tcp dport 53 accept");
507        assert_eq!(c.decision, NftDecision::Allow);
508        assert_eq!(c.reason_code, "nft_resolver_allowlist_match");
509        assert_eq!(c.dst_port, Some(53));
510        assert_eq!(c.protocol.as_deref(), Some("tcp"));
511    }
512
513    #[test]
514    fn classify_recognizes_egress_rule_accept_v4() {
515        let c = classify_rule_repr("ip daddr 10.0.0.1 tcp dport 443 accept");
516        assert_eq!(c.decision, NftDecision::Allow);
517        assert_eq!(c.reason_code, "nft_egress_rule_match");
518        assert_eq!(c.dst_addr.as_deref(), Some("10.0.0.1"));
519        assert_eq!(c.dst_port, Some(443));
520        assert_eq!(c.protocol.as_deref(), Some("tcp"));
521    }
522
523    #[test]
524    fn classify_recognizes_egress_rule_accept_v6() {
525        let c = classify_rule_repr("ip6 daddr 2001:db8::1 tcp dport 443 accept");
526        assert_eq!(c.decision, NftDecision::Allow);
527        assert_eq!(c.reason_code, "nft_egress_rule_match");
528        assert_eq!(c.dst_addr.as_deref(), Some("2001:db8::1"));
529        assert_eq!(c.dst_port, Some(443));
530        assert_eq!(c.protocol.as_deref(), Some("tcp"));
531    }
532
533    // ── SEC-22 Phase 3d classifier tests ────────────────────────────────
534    //
535    // Pin the new reason codes (`nft_doq_blocked`, `nft_http3_blocked`) and
536    // the resolver-allowlist match extension to UDP/853 + UDP/443. These
537    // mirror the existing port-53 classifier tests above.
538
539    #[test]
540    fn classify_recognises_nft_doq_blocked() {
541        let c = classify_rule_repr("udp dport 853 drop");
542        assert_eq!(c.decision, NftDecision::Deny);
543        assert_eq!(c.reason_code, "nft_doq_blocked");
544        assert_eq!(c.nft_rule_ref.as_deref(), Some("dnsAuthority.blockUdpDoq"));
545        assert_eq!(c.dst_port, Some(853));
546        assert_eq!(c.protocol.as_deref(), Some("udp"));
547        assert!(c.dst_addr.is_none());
548    }
549
550    #[test]
551    fn classify_recognises_nft_http3_blocked() {
552        let c = classify_rule_repr("udp dport 443 drop");
553        assert_eq!(c.decision, NftDecision::Deny);
554        assert_eq!(c.reason_code, "nft_http3_blocked");
555        assert_eq!(
556            c.nft_rule_ref.as_deref(),
557            Some("dnsAuthority.blockUdpHttp3")
558        );
559        assert_eq!(c.dst_port, Some(443));
560        assert_eq!(c.protocol.as_deref(), Some("udp"));
561        assert!(c.dst_addr.is_none());
562    }
563
564    #[test]
565    fn classify_recognises_resolver_allowlist_match_for_udp_443() {
566        let c = classify_rule_repr("ip daddr 1.1.1.1 udp dport 443 accept");
567        assert_eq!(c.decision, NftDecision::Allow);
568        assert_eq!(c.reason_code, "nft_resolver_allowlist_match");
569        assert_eq!(c.dst_addr.as_deref(), Some("1.1.1.1"));
570        assert_eq!(c.dst_port, Some(443));
571        assert_eq!(c.protocol.as_deref(), Some("udp"));
572    }
573
574    #[test]
575    fn classify_recognises_resolver_allowlist_match_for_udp_853() {
576        let c = classify_rule_repr("ip daddr 1.1.1.1 udp dport 853 accept");
577        assert_eq!(c.decision, NftDecision::Allow);
578        assert_eq!(c.reason_code, "nft_resolver_allowlist_match");
579        assert_eq!(c.dst_addr.as_deref(), Some("1.1.1.1"));
580        assert_eq!(c.dst_port, Some(853));
581        assert_eq!(c.protocol.as_deref(), Some("udp"));
582    }
583
584    #[test]
585    fn classify_does_not_misroute_tcp_443_to_http3_blocked() {
586        // TCP/443 drop is NOT an HTTP/3 block — falls through to the
587        // generic default-drop classifier so we don't accidentally label
588        // ordinary TCP 443 traffic as Phase-3d HTTP/3.
589        let c = classify_rule_repr("tcp dport 443 drop");
590        assert_eq!(c.decision, NftDecision::Deny);
591        assert_eq!(c.reason_code, "nft_default_drop");
592    }
593
594    #[test]
595    fn classify_egress_rule_match_still_recognised_for_non_resolver_udp_dport() {
596        // A spec-declared egress on UDP/4242 (not a resolver port) must
597        // still classify as `nft_egress_rule_match`, NOT as a resolver
598        // allowlist match.
599        let c = classify_rule_repr("ip daddr 10.0.0.7 udp dport 4242 accept");
600        assert_eq!(c.decision, NftDecision::Allow);
601        assert_eq!(c.reason_code, "nft_egress_rule_match");
602        assert_eq!(c.dst_port, Some(4242));
603    }
604
605    #[test]
606    fn classify_handles_malformed_input_gracefully() {
607        let c = classify_rule_repr("");
608        assert_eq!(c.decision, NftDecision::Deny);
609        assert_eq!(c.reason_code, "nft_default_drop");
610        assert!(c.dst_addr.is_none());
611        assert!(c.dst_port.is_none());
612    }
613
614    #[test]
615    fn classify_unrecognized_drop_falls_back_to_default_drop() {
616        let c = classify_rule_repr("ct state invalid drop");
617        assert_eq!(c.decision, NftDecision::Deny);
618        assert_eq!(c.reason_code, "nft_default_drop");
619    }
620
621    #[test]
622    fn extract_dport_ignores_wrong_proto() {
623        let (p, port) = extract_dport("icmp echo-request accept");
624        assert!(p.is_none());
625        assert!(port.is_none());
626    }
627
628    #[test]
629    fn parse_extracts_one_egress_accept_row() {
630        // Synthesized snippet shaped like real `nft list ruleset --json`
631        // output for a single egress accept rule with a counter.
632        let json = r#"{
633          "nftables": [
634            {"metainfo": {"version": "1.0.6"}},
635            {"table": {"family": "inet", "name": "cellos_test"}},
636            {"chain": {"family": "inet", "table": "cellos_test", "name": "output"}},
637            {"rule": {
638              "family": "inet",
639              "table": "cellos_test",
640              "chain": "output",
641              "handle": 7,
642              "expr": [
643                {"match": {
644                  "left": {"payload": {"protocol": "ip", "field": "daddr"}},
645                  "right": "10.0.0.1",
646                  "op": "=="
647                }},
648                {"match": {
649                  "left": {"payload": {"protocol": "tcp", "field": "dport"}},
650                  "right": 443,
651                  "op": "=="
652                }},
653                {"counter": {"packets": 5, "bytes": 320}},
654                {"accept": null}
655              ]
656            }}
657          ]
658        }"#;
659        let rows = parse_nft_list_ruleset_json(json).expect("parse ok");
660        assert_eq!(rows.len(), 1);
661        let row = &rows[0];
662        assert_eq!(row.family, "inet");
663        assert_eq!(row.table, "cellos_test");
664        assert_eq!(row.chain, "output");
665        assert_eq!(row.handle, 7);
666        assert_eq!(row.packet_count, 5);
667        assert_eq!(row.byte_count, 320);
668        assert_eq!(row.rule_repr, "ip daddr 10.0.0.1 tcp dport 443 accept");
669
670        let classified = classify_rule_repr(&row.rule_repr);
671        assert_eq!(classified.decision, NftDecision::Allow);
672        assert_eq!(classified.reason_code, "nft_egress_rule_match");
673        assert_eq!(classified.dst_addr.as_deref(), Some("10.0.0.1"));
674        assert_eq!(classified.dst_port, Some(443));
675    }
676
677    #[test]
678    fn parse_extracts_workload_dns_block_drop_row() {
679        let json = r#"{
680          "nftables": [
681            {"rule": {
682              "family": "inet",
683              "table": "cellos_test",
684              "chain": "output",
685              "handle": 4,
686              "expr": [
687                {"match": {
688                  "left": {"payload": {"protocol": "udp", "field": "dport"}},
689                  "right": 53,
690                  "op": "=="
691                }},
692                {"counter": {"packets": 2, "bytes": 80}},
693                {"drop": null}
694              ]
695            }}
696          ]
697        }"#;
698        let rows = parse_nft_list_ruleset_json(json).expect("parse ok");
699        assert_eq!(rows.len(), 1);
700        assert_eq!(rows[0].packet_count, 2);
701        assert_eq!(rows[0].rule_repr, "udp dport 53 drop");
702        let c = classify_rule_repr(&rows[0].rule_repr);
703        assert_eq!(c.reason_code, "nft_workload_dns_block");
704        assert_eq!(c.decision, NftDecision::Deny);
705    }
706
707    #[test]
708    fn parse_returns_empty_for_empty_nftables() {
709        let json = r#"{"nftables": []}"#;
710        let rows = parse_nft_list_ruleset_json(json).expect("parse ok");
711        assert!(rows.is_empty());
712    }
713
714    #[test]
715    fn parse_errors_when_nftables_array_missing() {
716        let json = r#"{"not_nftables": []}"#;
717        let err = parse_nft_list_ruleset_json(json).expect_err("must error");
718        assert!(matches!(err, NftCountersError::MissingNftablesArray));
719    }
720
721    #[test]
722    fn parse_errors_on_malformed_json() {
723        let err = parse_nft_list_ruleset_json("not json").expect_err("must error");
724        assert!(matches!(err, NftCountersError::Json(_)));
725    }
726}