Skip to main content

acme_proxy/filter/
explain.rs

1//! Rendering a policy, and what it would do to one hypothetical request.
2//!
3//! Everything `acme-proxy filter show` and `acme-proxy filter explain` print
4//! lives here; [`crate::cli::filter`] marshals arguments and does nothing else.
5//! That split is the one the rest of the admin surface uses, and it is what
6//! lets the web panel serve `show` — `GET /api/profiles/{name}/filter` and
7//! `/ui/profiles/{name}/filter` render [`policy_json`], the same document
8//! `filter show --json` prints, without moving any logic. The warning below is
9//! about `explain`, which has no web surface and is not getting one.
10//!
11//! ## `explain` really runs the policy
12//!
13//! It executes the operator's `custom` scripts and issues real IPAM and DNS
14//! requests, exactly as a request would, because a stubbed answer would be
15//! worse than nothing the first time it disagreed with production. It touches
16//! no database and creates nothing. [`Explanation::side_effects`] names the
17//! checks that reached outside the process, so the output says which parts of
18//! it were not free.
19//!
20//! That is also why this is a host-only command. The inputs — a client address
21//! and a list of names — are chosen by the caller, so behind an admin session
22//! it would be script execution and outbound requests driven from one stolen
23//! cookie, on a listener that deliberately carries no filter chain and no
24//! admission control.
25//!
26//! [`render_policy`] and [`policy_json`] are the other half of that argument
27//! rather than an exception to it: they call four accessors on an
28//! already-built [`FilterPolicy`], invoke no [`Check`](super::policy::Check),
29//! and are not even `async`. Nothing a caller can type reaches outside this
30//! process, which is what makes `show` proposable behind a session where
31//! `explain` is not.
32
33use std::fmt::Write as _;
34use std::net::IpAddr;
35
36use serde_json::{Value, json};
37
38use super::policy::{Evaluation, FilterPolicy, Outcome, Stage, Verdict};
39use super::{ConnectionContext, EabIdentity, IdentifierContext, IdentifierStage};
40use crate::cli::style::Palette;
41use crate::sqlite::order::Identifier;
42
43/// Which check kinds reach outside this process when they run.
44///
45/// Used only to warn, so an over-broad answer is the safe direction: a check
46/// listed here that happens not to have made a request this time is a harmless
47/// caution, where an omission would be a lie.
48const REACHES_OUT: &[&str] = &["custom", "ipam", "reverse_dns"];
49
50/// What both renderings say about a policy with no rules.
51///
52/// One `const` rather than a sentence per renderer: `render_policy` paints it
53/// and [`policy_json`] carries it bare, and a warning this load-bearing said
54/// two slightly different ways in two front ends is how one of them stops
55/// being read.
56const INACTIVE_WARNING: &str = "no rules configured: this endpoint filters \
57     nothing, and any client that can reach it may request a certificate for \
58     any name";
59
60/// The request `explain` is asked about.
61#[derive(Debug, Clone, Default)]
62pub struct Subject {
63    pub client_ip: Option<IpAddr>,
64    pub account_id: String,
65    pub identifiers: Vec<Identifier>,
66    pub path: String,
67    pub eab: Option<EabIdentity>,
68}
69
70/// One stage's evaluation, plus the checks it never reached.
71#[derive(Debug)]
72pub struct StageReport {
73    pub label: &'static str,
74    pub evaluation: Evaluation,
75    /// Checks some applicable rule names that were never evaluated, because a
76    /// decisive operand short-circuited past them. Reporting these is half the
77    /// diagnostic value: "why did my `ipam` check not run" is otherwise
78    /// invisible, since a skipped check and a passing one look identical in
79    /// the outcome.
80    pub skipped: Vec<String>,
81    /// The HTTP answer this stage would produce.
82    pub answer: &'static str,
83}
84
85/// What the whole request would get.
86#[derive(Debug)]
87pub struct Explanation {
88    pub stages: Vec<StageReport>,
89    /// Names of the checks that reached outside the process while explaining.
90    pub side_effects: Vec<String>,
91}
92
93impl Explanation {
94    /// Whether every stage allowed. Both must, which is the thing operators
95    /// most often misread, so the rendering states it explicitly.
96    #[must_use]
97    pub fn allowed(&self) -> bool {
98        self.stages
99            .iter()
100            .all(|stage| matches!(stage.evaluation.outcome, Outcome::Allow))
101    }
102}
103
104/// Evaluates the policy at the connection stage and both identifier
105/// sub-stages, keeping every trace.
106pub async fn explain(policy: &FilterPolicy, subject: &Subject) -> Explanation {
107    let mut stages = Vec::new();
108
109    let connection = policy
110        .evaluate_connection(&ConnectionContext {
111            client_ip: subject.client_ip,
112            method: &axum::http::Method::POST,
113            path: &subject.path,
114        })
115        .await;
116    stages.push(report("connection", connection, policy, Stage::Connection));
117
118    for (label, sub_stage) in [
119        ("newOrder", IdentifierStage::NewOrder),
120        ("CSR", IdentifierStage::Csr),
121    ] {
122        let evaluation = policy
123            .evaluate_identifiers(&IdentifierContext {
124                client_ip: subject.client_ip,
125                account_id: &subject.account_id,
126                stage: sub_stage,
127                identifiers: &subject.identifiers,
128                eab: subject.eab.clone(),
129            })
130            .await;
131        stages.push(report(label, evaluation, policy, Stage::Identifiers));
132    }
133
134    let mut side_effects: Vec<String> = stages
135        .iter()
136        .flat_map(|stage| stage.evaluation.checks.iter())
137        .filter(|outcome| REACHES_OUT.contains(&outcome.kind))
138        .map(|outcome| outcome.name.clone())
139        .collect();
140    side_effects.sort_unstable();
141    side_effects.dedup();
142
143    Explanation {
144        stages,
145        side_effects,
146    }
147}
148
149/// Works out which checks the evaluation never reached, and the HTTP answer.
150fn report(
151    label: &'static str,
152    evaluation: Evaluation,
153    policy: &FilterPolicy,
154    stage: Stage,
155) -> StageReport {
156    let evaluated: Vec<&str> = evaluation
157        .checks
158        .iter()
159        .map(|outcome| outcome.name.as_str())
160        .collect();
161
162    let mut skipped: Vec<String> = policy
163        .rules()
164        .iter()
165        .filter(|rule| rule.stages.contains(stage))
166        .flat_map(|rule| rule.when.check_names())
167        .filter(|name| !evaluated.contains(name))
168        .map(std::string::ToString::to_string)
169        .collect();
170    skipped.sort_unstable();
171    skipped.dedup();
172
173    let answer = match (&evaluation.outcome, label) {
174        (Outcome::Allow, _) => "allowed",
175        (Outcome::Deny(_), "connection") => "403 access_denied",
176        (Outcome::Deny(_), "newOrder") => "403 rejectedIdentifier",
177        (Outcome::Deny(_), _) => "400 badCSR",
178        (Outcome::Undecided(_), _) => "500 serverInternal",
179    };
180
181    StageReport {
182        label,
183        evaluation,
184        skipped,
185        answer,
186    }
187}
188
189/// The resolved policy, as `filter show` prints it.
190///
191/// Conditions are re-printed through [`Condition`](super::expr::Condition)'s
192/// `Display`, which makes every grouping explicit — so an operator who wrote
193/// `a or b and c` sees `a or (b and c)` and has their answer about precedence.
194#[must_use]
195pub fn render_policy(profile: &str, policy: &FilterPolicy, palette: Palette) -> String {
196    let mut out = String::new();
197    let _ = writeln!(out, "profile: {profile}");
198
199    if !policy.is_active() {
200        let _ = writeln!(out, "\n{}", palette.warn(INACTIVE_WARNING));
201        return out;
202    }
203
204    let _ = writeln!(
205        out,
206        "default: {} (when a rule was applicable and none matched)",
207        palette.status(policy.default_effect().as_str())
208    );
209
210    let _ = writeln!(out, "\nchecks");
211    for check in policy.checks() {
212        let _ = writeln!(
213            out,
214            "  {:<20} {:<12} {}",
215            check.name, check.kind, check.stages
216        );
217    }
218
219    let _ = writeln!(out, "\nrules (first match wins)");
220    for rule in policy.rules() {
221        let mode = match rule.mode {
222            super::Mode::Enforce => String::new(),
223            super::Mode::Warn => palette.warn("  [warn: matches but does not decide]"),
224        };
225        let _ = writeln!(
226            out,
227            "  {:<20} {} -> {}{}",
228            rule.name,
229            rule.when,
230            palette.status(rule.then.as_str()),
231            mode
232        );
233        let _ = writeln!(out, "  {:<20}   evaluated at: {}", "", rule.stages);
234    }
235
236    out
237}
238
239/// The human rendering of [`explain`].
240#[must_use]
241pub fn render_explanation(
242    profile: &str,
243    subject: &Subject,
244    explanation: &Explanation,
245    palette: Palette,
246) -> String {
247    let mut out = String::new();
248    let _ = writeln!(out, "profile: {profile}");
249    let _ = writeln!(
250        out,
251        "client:  {}",
252        subject
253            .client_ip
254            .map_or_else(|| "(none)".to_string(), |ip| ip.to_string())
255    );
256    let _ = writeln!(out, "path:    {}", subject.path);
257    if subject.identifiers.is_empty() {
258        let _ = writeln!(out, "names:   (none)");
259    } else {
260        let names: Vec<&str> = subject
261            .identifiers
262            .iter()
263            .map(|identifier| identifier.value.as_str())
264            .collect();
265        let _ = writeln!(out, "names:   {}", names.join(", "));
266    }
267
268    for stage in &explanation.stages {
269        let _ = writeln!(out, "\n{} stage", stage.label);
270
271        if stage.evaluation.checks.is_empty() && stage.skipped.is_empty() {
272            let _ = writeln!(out, "  no rule applies here, so it allows");
273        }
274
275        for outcome in &stage.evaluation.checks {
276            let (verdict, reason) = match &outcome.verdict {
277                Verdict::Pass => (palette.ok("pass"), String::new()),
278                Verdict::Fail(detail) => (palette.bad("fail"), format!("  {detail}")),
279                Verdict::Undecided(detail) => (palette.unknown("unknown"), format!("  {detail}")),
280            };
281            let _ = writeln!(
282                out,
283                "  {:<20} {:<12} {}{}",
284                outcome.name, outcome.kind, verdict, reason
285            );
286        }
287        for name in &stage.skipped {
288            let _ = writeln!(
289                out,
290                "  {name:<20} {:<12} skipped (an earlier operand already decided)",
291                ""
292            );
293        }
294
295        for warned in &stage.evaluation.warned {
296            let _ = writeln!(
297                out,
298                "  rule {} matched in warn mode and would have {}",
299                warned.name,
300                warned.then.as_str()
301            );
302        }
303
304        match (&stage.evaluation.matched, &stage.evaluation.outcome) {
305            (Some(rule), _) => {
306                let _ = writeln!(out, "  rule {rule} matched");
307            }
308            (None, Outcome::Allow) if stage.evaluation.checks.is_empty() => {}
309            (None, _) => {
310                let _ = writeln!(out, "  no rule matched, so the default applies");
311            }
312        }
313
314        // Painted off the outcome rather than off `answer`, which is an HTTP
315        // status line (`403 rejectedIdentifier`) and not a status word — and
316        // the Kleene third value has to read apart from a refusal here, since
317        // telling those two apart is the whole reason `explain` exists.
318        let (answer, detail) = match &stage.evaluation.outcome {
319            Outcome::Allow => (palette.ok(stage.answer), String::new()),
320            Outcome::Deny(detail) => (palette.bad(stage.answer), format!("  {detail}")),
321            Outcome::Undecided(detail) => (palette.unknown(stage.answer), format!("  {detail}")),
322        };
323        let _ = writeln!(out, "  -> {answer}{detail}");
324    }
325
326    let _ = writeln!(
327        out,
328        "\nresult: {}",
329        if explanation.allowed() {
330            palette.ok("allowed (every stage must allow, and every stage did)")
331        } else {
332            palette.bad("refused (a request is served only when every stage allows)")
333        }
334    );
335
336    if !explanation.side_effects.is_empty() {
337        let _ = writeln!(
338            out,
339            "\n{}",
340            palette.warn(&format!(
341                "note: these checks really ran, reaching outside this process exactly as a \
342                 request would: {}",
343                explanation.side_effects.join(", ")
344            ))
345        );
346    }
347
348    out
349}
350
351/// The `--json` rendering of [`explain`].
352#[must_use]
353pub fn explanation_json(profile: &str, subject: &Subject, explanation: &Explanation) -> Value {
354    let stages: Vec<Value> = explanation
355        .stages
356        .iter()
357        .map(|stage| {
358            let checks: Vec<Value> = stage
359                .evaluation
360                .checks
361                .iter()
362                .map(|outcome| {
363                    let (verdict, detail) = match &outcome.verdict {
364                        Verdict::Pass => ("pass", None),
365                        Verdict::Fail(detail) => ("fail", Some(detail.clone())),
366                        Verdict::Undecided(detail) => ("unknown", Some(detail.clone())),
367                    };
368                    json!({
369                        "name": outcome.name,
370                        "type": outcome.kind,
371                        "verdict": verdict,
372                        "detail": detail,
373                    })
374                })
375                .collect();
376
377            json!({
378                "stage": stage.label,
379                "checks": checks,
380                "skipped": stage.skipped,
381                "matchedRule": stage.evaluation.matched,
382                "warned": stage.evaluation.warned.iter().map(|warned| json!({
383                    "rule": warned.name,
384                    "wouldHave": warned.then.as_str(),
385                })).collect::<Vec<_>>(),
386                "answer": stage.answer,
387            })
388        })
389        .collect();
390
391    json!({
392        "profile": profile,
393        "request": {
394            "clientIp": subject.client_ip.map(|ip| ip.to_string()),
395            "path": subject.path,
396            "identifiers": subject.identifiers,
397            "accountId": subject.account_id,
398        },
399        "stages": stages,
400        "allowed": explanation.allowed(),
401        "sideEffects": explanation.side_effects,
402    })
403}
404
405/// The resolved policy as a JSON document.
406///
407/// The one shape three consumers share: `filter show --json`,
408/// `GET /api/profiles/{name}/filter`, and the context the `/ui` page's
409/// template walks. Member for member it carries what [`render_policy`] prints
410/// and nothing more — an operator reading the panel and an operator reading
411/// the terminal are looking at the same policy, and neither front end may grow
412/// a field the other cannot show.
413///
414/// **Reaches nothing outside this process.** Four accessors on an
415/// already-built [`FilterPolicy`]; no [`Check`](super::policy::Check) is ever
416/// invoked, which is why this is not `async` and why, unlike [`explain`], it
417/// is safe behind an admin session.
418///
419/// Two orderings are load-bearing and neither is stated in the document:
420/// `rules` is evaluation order, because the first match decides, and `checks`
421/// is name-sorted, because a check has no order of its own.
422///
423/// `defaultEffect` is `null` when the policy is inactive, and the `warning` is
424/// there instead. That is not a formality: `filter.default` is consulted only
425/// where some rule was applicable, so with no rules at all the configured
426/// value is not a fact about this endpoint's behaviour — which is the same
427/// thing [`render_policy`] says by returning before it prints one.
428#[must_use]
429pub fn policy_json(profile: &str, policy: &FilterPolicy) -> Value {
430    if !policy.is_active() {
431        return json!({
432            "profile": profile,
433            "active": false,
434            "defaultEffect": Value::Null,
435            "warning": INACTIVE_WARNING,
436            "checks": [],
437            "rules": [],
438        });
439    }
440
441    let checks: Vec<Value> = policy
442        .checks()
443        .iter()
444        .map(|check| {
445            json!({
446                "name": check.name,
447                "type": check.kind,
448                "stages": check.stages.to_string(),
449            })
450        })
451        .collect();
452
453    let rules: Vec<Value> = policy
454        .rules()
455        .iter()
456        .map(|rule| {
457            json!({
458                "name": rule.name,
459                // `Condition`'s `Display`: the re-parenthesized expression,
460                // which is the member this whole surface exists for.
461                "when": rule.when.to_string(),
462                "then": rule.then.as_str(),
463                "mode": rule.mode.as_str(),
464                "stages": rule.stages.to_string(),
465            })
466        })
467        .collect();
468
469    json!({
470        "profile": profile,
471        "active": true,
472        "defaultEffect": policy.default_effect().as_str(),
473        "warning": Value::Null,
474        "checks": checks,
475        "rules": rules,
476    })
477}
478
479#[cfg(test)]
480mod tests {
481    use std::sync::Arc;
482
483    use super::*;
484    use crate::filter::expr::Condition;
485    use crate::filter::policy::{Check, Effect, Mode, Rule};
486    use crate::filter::{ProxyPolicy, ip_allow};
487    use crate::testutil::dns_identifiers;
488
489    fn net(allow: &[&str]) -> Arc<dyn Check> {
490        Arc::new(
491            ip_allow::AllowedFromIpAddress::from_settings(
492                "net",
493                &ip_allow::Settings {
494                    allow: allow.iter().map(std::string::ToString::to_string).collect(),
495                    deny: Vec::new(),
496                },
497            )
498            .unwrap(),
499        )
500    }
501
502    fn names(allow: &[&str]) -> Arc<dyn Check> {
503        Arc::new(
504            crate::filter::identifiers::IdentifierList::from_settings(
505                "names",
506                &crate::filter::identifiers::Settings {
507                    allow: allow.iter().map(std::string::ToString::to_string).collect(),
508                    ..crate::filter::identifiers::Settings::default()
509                },
510            )
511            .unwrap(),
512        )
513    }
514
515    fn rule(name: &str, when: &str, then: Effect, mode: Mode) -> Rule {
516        Rule {
517            name: name.to_string(),
518            when: Condition::parse(when).unwrap(),
519            then,
520            message: None,
521            mode,
522        }
523    }
524
525    fn subject(ip: &str, names: &[&str]) -> Subject {
526        Subject {
527            client_ip: Some(ip.parse().unwrap()),
528            account_id: "explain".to_string(),
529            identifiers: dns_identifiers(names),
530            path: "/newOrder".to_string(),
531            eab: None,
532        }
533    }
534
535    fn policy() -> FilterPolicy {
536        FilterPolicy::new(
537            vec![
538                ("net".to_string(), net(&["10.0.0.0/8"])),
539                ("names".to_string(), names(&["*.example.com"])),
540            ],
541            vec![
542                rule("mgmt", "net", Effect::Allow, Mode::Enforce),
543                rule("corp", "names", Effect::Allow, Mode::Enforce),
544            ],
545            Effect::Deny,
546            ProxyPolicy::default(),
547        )
548    }
549
550    #[tokio::test]
551    async fn a_permitted_request_reports_every_stage_allowing() {
552        let policy = policy();
553        let subject = subject("10.0.0.5", &["web.example.com"]);
554        let explanation = explain(&policy, &subject).await;
555
556        assert!(explanation.allowed());
557        assert_eq!(explanation.stages.len(), 3);
558        let labels: Vec<&str> = explanation.stages.iter().map(|s| s.label).collect();
559        assert_eq!(labels, vec!["connection", "newOrder", "CSR"]);
560
561        let rendered = render_explanation("default", &subject, &explanation, Palette::plain());
562        assert!(rendered.contains("rule mgmt matched"), "{rendered}");
563        assert!(rendered.contains("-> allowed"), "{rendered}");
564        assert!(rendered.contains("every stage must allow"), "{rendered}");
565    }
566
567    /// The mapping an operator most wants: which HTTP answer each stage
568    /// produces, and that they are not the same one.
569    #[tokio::test]
570    async fn each_stage_names_its_own_http_answer() {
571        let policy = policy();
572        let subject = subject("203.0.113.9", &["web.evil.net"]);
573        let explanation = explain(&policy, &subject).await;
574
575        assert!(!explanation.allowed());
576        let answers: Vec<&str> = explanation.stages.iter().map(|s| s.answer).collect();
577        assert_eq!(
578            answers,
579            vec!["403 access_denied", "403 rejectedIdentifier", "400 badCSR"]
580        );
581
582        let rendered = render_explanation("default", &subject, &explanation, Palette::plain());
583        assert!(rendered.contains("no rule matched"), "{rendered}");
584        assert!(rendered.contains("refused"), "{rendered}");
585    }
586
587    /// A skipped check and a passing one look identical in the outcome, so
588    /// reporting the skip is the only way the output can answer "why did my
589    /// inventory check not run".
590    #[tokio::test]
591    async fn a_short_circuited_check_is_reported_as_skipped() {
592        let policy = FilterPolicy::new(
593            vec![
594                ("net".to_string(), net(&["10.0.0.0/8"])),
595                ("names".to_string(), names(&["*.example.com"])),
596            ],
597            // `net` passes, so the `or` never reaches `names`.
598            vec![rule("either", "net or names", Effect::Allow, Mode::Enforce)],
599            Effect::Deny,
600            ProxyPolicy::default(),
601        );
602
603        let subject = subject("10.0.0.5", &["web.evil.net"]);
604        let explanation = explain(&policy, &subject).await;
605
606        let identifiers = &explanation.stages[1];
607        assert_eq!(identifiers.skipped, vec!["names".to_string()]);
608        let rendered = render_explanation("default", &subject, &explanation, Palette::plain());
609        assert!(
610            rendered.contains("skipped (an earlier operand"),
611            "{rendered}"
612        );
613    }
614
615    #[tokio::test]
616    async fn a_warn_rule_is_rendered_as_what_it_would_have_done() {
617        let policy = FilterPolicy::new(
618            vec![("net".to_string(), net(&["10.0.0.0/8"]))],
619            vec![rule("would-deny", "net", Effect::Deny, Mode::Warn)],
620            Effect::Allow,
621            ProxyPolicy::default(),
622        );
623
624        let subject = subject("10.0.0.5", &[]);
625        let explanation = explain(&policy, &subject).await;
626        assert!(explanation.allowed());
627
628        let rendered = render_explanation("default", &subject, &explanation, Palette::plain());
629        assert!(
630            rendered.contains("matched in warn mode and would have deny"),
631            "{rendered}"
632        );
633    }
634
635    #[tokio::test]
636    async fn a_stage_with_no_rules_says_so_rather_than_looking_empty() {
637        let policy = FilterPolicy::new(
638            vec![("names".to_string(), names(&["*.example.com"]))],
639            vec![rule("corp", "names", Effect::Allow, Mode::Enforce)],
640            Effect::Deny,
641            ProxyPolicy::default(),
642        );
643
644        let subject = subject("10.0.0.5", &["web.example.com"]);
645        let rendered = render_explanation(
646            "default",
647            &subject,
648            &explain(&policy, &subject).await,
649            Palette::plain(),
650        );
651        assert!(rendered.contains("no rule applies here"), "{rendered}");
652    }
653
654    #[tokio::test]
655    async fn checks_that_reach_outside_the_process_are_named() {
656        let dir = crate::testutil::TempDir::new("filter-explain");
657        let script = crate::testutil::write_script(&dir, "hook.sh", "#!/bin/sh\nexit 0\n");
658        let hook: Arc<dyn Check> = Arc::new(
659            crate::filter::custom::CustomScriptFilter::from_settings(
660                "hook",
661                &crate::filter::custom::Settings {
662                    script_path: script.to_string_lossy().into_owned(),
663                    ..crate::filter::custom::Settings::default()
664                },
665            )
666            .unwrap(),
667        );
668
669        let policy = FilterPolicy::new(
670            vec![("hook".to_string(), hook)],
671            vec![rule("scripted", "hook", Effect::Allow, Mode::Enforce)],
672            Effect::Deny,
673            ProxyPolicy::default(),
674        );
675
676        let subject = subject("10.0.0.5", &["web.example.com"]);
677        let explanation = explain(&policy, &subject).await;
678        assert_eq!(explanation.side_effects, vec!["hook".to_string()]);
679
680        let rendered = render_explanation("default", &subject, &explanation, Palette::plain());
681        assert!(rendered.contains("these checks really ran"), "{rendered}");
682    }
683
684    #[tokio::test]
685    async fn the_json_shape_carries_every_stage_and_its_verdicts() {
686        let policy = policy();
687        let subject = subject("203.0.113.9", &["web.evil.net"]);
688        let explanation = explain(&policy, &subject).await;
689        let value = explanation_json("default", &subject, &explanation);
690
691        assert_eq!(value["profile"], "default");
692        assert_eq!(value["allowed"], false);
693        assert_eq!(value["request"]["clientIp"], "203.0.113.9");
694        let stages = value["stages"].as_array().unwrap();
695        assert_eq!(stages.len(), 3);
696        assert_eq!(stages[0]["stage"], "connection");
697        assert_eq!(stages[0]["answer"], "403 access_denied");
698        assert_eq!(stages[0]["checks"][0]["verdict"], "fail");
699        assert!(
700            stages[0]["checks"][0]["detail"]
701                .as_str()
702                .unwrap()
703                .contains("not allowed")
704        );
705    }
706
707    /// `show` re-prints conditions with grouping made explicit, which is the
708    /// whole reason it exists rather than an operator re-reading their file.
709    #[test]
710    fn show_renders_the_policy_with_explicit_grouping() {
711        let policy = FilterPolicy::new(
712            vec![
713                ("net".to_string(), net(&["10.0.0.0/8"])),
714                ("names".to_string(), names(&["*.example.com"])),
715            ],
716            vec![rule(
717                "mixed",
718                "names or net and names",
719                Effect::Allow,
720                Mode::Enforce,
721            )],
722            Effect::Deny,
723            ProxyPolicy::default(),
724        );
725
726        let rendered = render_policy("default", &policy, Palette::plain());
727        assert!(rendered.contains("names or (net and names)"), "{rendered}");
728        assert!(rendered.contains("net"), "{rendered}");
729        assert!(rendered.contains("allowed_ip"), "{rendered}");
730        assert!(rendered.contains("default: deny"), "{rendered}");
731        assert!(rendered.contains("identifiers only"), "{rendered}");
732    }
733
734    #[test]
735    fn show_says_plainly_when_nothing_is_configured() {
736        let rendered = render_policy("default", &FilterPolicy::default(), Palette::plain());
737        assert!(rendered.contains("filters nothing"), "{rendered}");
738    }
739
740    #[test]
741    fn show_marks_a_warn_rule() {
742        let policy = FilterPolicy::new(
743            vec![("net".to_string(), net(&["10.0.0.0/8"]))],
744            vec![rule("dry", "net", Effect::Deny, Mode::Warn)],
745            Effect::Deny,
746            ProxyPolicy::default(),
747        );
748        assert!(render_policy("default", &policy, Palette::plain()).contains("does not decide"));
749    }
750
751    /// The fixture both `show` renderings are asserted over.
752    ///
753    /// Its condition is written **unparenthesized** on purpose: what comes
754    /// back out is `names or (net and names)`, and that is the whole reason
755    /// either rendering exists.
756    fn two_rule_policy() -> FilterPolicy {
757        FilterPolicy::new(
758            vec![
759                ("net".to_string(), net(&["10.0.0.0/8"])),
760                ("names".to_string(), names(&["*.example.com"])),
761            ],
762            vec![
763                rule(
764                    "mixed",
765                    "names or net and names",
766                    Effect::Allow,
767                    Mode::Enforce,
768                ),
769                rule("dry", "net", Effect::Deny, Mode::Warn),
770            ],
771            Effect::Deny,
772            ProxyPolicy::default(),
773        )
774    }
775
776    #[test]
777    fn the_policy_json_carries_every_check_and_every_rule() {
778        let value = policy_json("default", &two_rule_policy());
779
780        assert_eq!(value["profile"], "default");
781        assert_eq!(value["active"], true);
782        assert_eq!(value["defaultEffect"], "deny");
783        assert!(value["warning"].is_null());
784
785        // Name-sorted, as `FilterPolicy::checks` walks a `BTreeMap`.
786        let checks = value["checks"].as_array().unwrap();
787        assert_eq!(checks.len(), 2);
788        assert_eq!(checks[0]["name"], "names");
789        assert_eq!(checks[0]["type"], "identifiers");
790        assert_eq!(checks[0]["stages"], "identifiers only");
791        assert_eq!(checks[1]["name"], "net");
792        assert_eq!(checks[1]["type"], "allowed_ip");
793        assert_eq!(checks[1]["stages"], "connection and identifiers");
794
795        // Evaluation order, because the first match decides.
796        let rules = value["rules"].as_array().unwrap();
797        assert_eq!(rules.len(), 2);
798        assert_eq!(rules[0]["name"], "mixed");
799        assert_eq!(rules[0]["when"], "names or (net and names)");
800        assert_eq!(rules[0]["then"], "allow");
801        assert_eq!(rules[0]["mode"], "enforce");
802        assert_eq!(rules[0]["stages"], "identifiers only");
803        assert_eq!(rules[1]["mode"], "warn");
804    }
805
806    /// A policy with no rules is a state, not an error — and `filter.default`
807    /// is not a fact about it, since no stage ever has an applicable rule.
808    #[test]
809    fn an_inactive_policy_says_so_and_carries_no_default() {
810        let value = policy_json("default", &FilterPolicy::default());
811
812        assert_eq!(value["active"], false);
813        assert!(value["defaultEffect"].is_null());
814        assert!(
815            value["warning"]
816                .as_str()
817                .unwrap()
818                .contains("filters nothing")
819        );
820        assert!(value["checks"].as_array().unwrap().is_empty());
821        assert!(value["rules"].as_array().unwrap().is_empty());
822    }
823
824    /// `message` is the operator's own words for a refusal, and `show` does
825    /// not print it. Its absence here is a decision, not an oversight: the
826    /// panel and the terminal describe a policy identically or one of them is
827    /// lying, and adding it is an addition to *both*.
828    #[test]
829    fn the_operator_message_is_not_in_the_json() {
830        let policy = FilterPolicy::new(
831            vec![("net".to_string(), net(&["10.0.0.0/8"]))],
832            vec![Rule {
833                name: "spoken".to_string(),
834                when: Condition::parse("net").unwrap(),
835                then: Effect::Deny,
836                message: Some("ask the network team".to_string()),
837                mode: Mode::Enforce,
838            }],
839            Effect::Deny,
840            ProxyPolicy::default(),
841        );
842
843        let value = policy_json("default", &policy);
844        assert!(value["rules"][0].get("message").is_none());
845        assert!(!value.to_string().contains("network team"));
846    }
847
848    /// The text and the JSON are two walks over one [`FilterPolicy`] rather
849    /// than one built on the other, so this is what stops them drifting: every
850    /// word the document carries has to appear in what `filter show` prints.
851    #[test]
852    fn the_two_renderings_agree_on_the_vocabulary() {
853        let policy = two_rule_policy();
854        let value = policy_json("default", &policy);
855        let rendered = render_policy("default", &policy, Palette::plain());
856
857        assert!(rendered.contains(value["defaultEffect"].as_str().unwrap()));
858        for check in value["checks"].as_array().unwrap() {
859            for member in ["name", "type", "stages"] {
860                let word = check[member].as_str().unwrap();
861                assert!(rendered.contains(word), "`{word}` missing from {rendered}");
862            }
863        }
864        for rule in value["rules"].as_array().unwrap() {
865            for member in ["name", "when", "then", "stages"] {
866                let word = rule[member].as_str().unwrap();
867                assert!(rendered.contains(word), "`{word}` missing from {rendered}");
868            }
869        }
870
871        // And the sentence an inactive policy is described by, byte for byte:
872        // one `const`, painted on one side and carried bare on the other.
873        let inactive = FilterPolicy::default();
874        assert!(
875            render_policy("default", &inactive, Palette::plain()).contains(
876                policy_json("default", &inactive)["warning"]
877                    .as_str()
878                    .unwrap()
879            )
880        );
881    }
882}