1use 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
43const REACHES_OUT: &[&str] = &["custom", "ipam", "reverse_dns"];
49
50const 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#[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#[derive(Debug)]
72pub struct StageReport {
73 pub label: &'static str,
74 pub evaluation: Evaluation,
75 pub skipped: Vec<String>,
81 pub answer: &'static str,
83}
84
85#[derive(Debug)]
87pub struct Explanation {
88 pub stages: Vec<StageReport>,
89 pub side_effects: Vec<String>,
91}
92
93impl Explanation {
94 #[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
104pub 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
149fn 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#[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#[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 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#[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#[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 "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 #[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 #[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 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 #[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 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 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 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 #[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 #[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 #[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 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}