Skip to main content

clash_policy/
diff.rs

1//! Tree diff utility for policy changes.
2//!
3//! Compiles before/after policies, renders with `format_tree()`, and produces
4//! a unified diff suitable for terminal display (with optional colors).
5
6use similar::{ChangeTag, TextDiff};
7
8use console::Style;
9
10use crate::format::format_tree_with_options;
11use crate::match_tree::CompiledPolicy;
12
13/// Produce a unified diff between two compiled policies, rendered as tree strings.
14///
15/// Returns `None` if the two policies produce identical tree output.
16pub fn tree_diff(before: &CompiledPolicy, after: &CompiledPolicy) -> Option<String> {
17    let before_lines = format_tree_with_options(before, false);
18    let after_lines = format_tree_with_options(after, false);
19
20    let before_text = before_lines.join("\n");
21    let after_text = after_lines.join("\n");
22
23    if before_text == after_text {
24        return None;
25    }
26
27    let diff = TextDiff::from_lines(&before_text, &after_text);
28    let mut output = String::new();
29
30    for change in diff.iter_all_changes() {
31        let line = change.value().trim_end_matches('\n');
32        match change.tag() {
33            ChangeTag::Delete => {
34                output.push_str(&Style::new().red().apply_to(format!("- {line}")).to_string());
35                output.push('\n');
36            }
37            ChangeTag::Insert => {
38                output.push_str(
39                    &Style::new()
40                        .green()
41                        .apply_to(format!("+ {line}"))
42                        .to_string(),
43                );
44                output.push('\n');
45            }
46            ChangeTag::Equal => {
47                output.push_str(&format!("  {line}"));
48                output.push('\n');
49            }
50        }
51    }
52
53    Some(output)
54}
55
56/// Produce a plain (uncolored) unified diff between two compiled policies.
57///
58/// Useful for testing and non-TTY environments.
59pub fn tree_diff_plain(before: &CompiledPolicy, after: &CompiledPolicy) -> Option<String> {
60    let before_lines = format_tree_with_options(before, false);
61    let after_lines = format_tree_with_options(after, false);
62
63    let before_text = before_lines.join("\n");
64    let after_text = after_lines.join("\n");
65
66    if before_text == after_text {
67        return None;
68    }
69
70    let diff = TextDiff::from_lines(&before_text, &after_text);
71    let mut output = String::new();
72
73    for change in diff.iter_all_changes() {
74        let line = change.value().trim_end_matches('\n');
75        match change.tag() {
76            ChangeTag::Delete => {
77                output.push_str(&format!("- {line}\n"));
78            }
79            ChangeTag::Insert => {
80                output.push_str(&format!("+ {line}\n"));
81            }
82            ChangeTag::Equal => {
83                output.push_str(&format!("  {line}\n"));
84            }
85        }
86    }
87
88    Some(output)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::match_tree::*;
95    use std::collections::HashMap;
96
97    fn empty_policy() -> CompiledPolicy {
98        CompiledPolicy {
99            sandboxes: HashMap::new(),
100            tree: vec![],
101            default_effect: crate::Effect::Deny,
102            default_sandbox: None,
103            on_sandbox_violation: Default::default(),
104            harness_defaults: None,
105        }
106    }
107
108    fn policy_with_rule(bin: &str, decision: Decision) -> CompiledPolicy {
109        let node = Node::Condition {
110            observe: Observable::ToolName,
111            pattern: Pattern::Literal(Value::Literal("Bash".into())),
112            children: vec![Node::Condition {
113                observe: Observable::PositionalArg(0),
114                pattern: Pattern::Literal(Value::Literal(bin.into())),
115                children: vec![Node::Decision(decision)],
116                doc: None,
117                source: None,
118                terminal: false,
119            }],
120            doc: None,
121            source: None,
122            terminal: false,
123        };
124        CompiledPolicy {
125            tree: vec![node],
126            ..empty_policy()
127        }
128    }
129
130    #[test]
131    fn identical_policies_produce_no_diff() {
132        let p = policy_with_rule("git", Decision::Allow(None));
133        assert!(tree_diff_plain(&p, &p).is_none());
134    }
135
136    #[test]
137    fn added_rule_shows_in_diff() {
138        let before = empty_policy();
139        let after = policy_with_rule("git", Decision::Allow(None));
140        let diff = tree_diff_plain(&before, &after).unwrap();
141        assert!(diff.contains("+ "), "expected additions in diff:\n{diff}");
142        assert!(diff.contains("git"), "expected 'git' in diff:\n{diff}");
143    }
144
145    #[test]
146    fn removed_rule_shows_in_diff() {
147        let before = policy_with_rule("git", Decision::Allow(None));
148        let after = empty_policy();
149        let diff = tree_diff_plain(&before, &after).unwrap();
150        assert!(diff.contains("- "), "expected deletions in diff:\n{diff}");
151    }
152
153    #[test]
154    fn changed_decision_shows_in_diff() {
155        let before = policy_with_rule("git", Decision::Allow(None));
156        let after = policy_with_rule("git", Decision::Deny);
157        let diff = tree_diff_plain(&before, &after).unwrap();
158        assert!(diff.contains("- "), "expected deletions:\n{diff}");
159        assert!(diff.contains("+ "), "expected additions:\n{diff}");
160    }
161}