Skip to main content

allow_report/
diff_posture.rs

1use crate::{DiffFindingChange, DiffPolicyChange, DiffPostureSummary};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum DiffNetPosture {
5    Worse,
6    ReviewRequired,
7    Improved,
8    Unchanged,
9}
10
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
12pub(crate) struct DiffEvidenceDeltaSummary {
13    pub(crate) evidence_added: usize,
14    pub(crate) weak_evidence_added: usize,
15    pub(crate) broken_evidence_added: usize,
16    pub(crate) evidence_removed: usize,
17    pub(crate) evidence_removal_failures: usize,
18    pub(crate) evidence_removal_review_items: usize,
19    pub(crate) evidence_removal_improvements: usize,
20    pub(crate) link_added: usize,
21    pub(crate) weak_link_added: usize,
22    pub(crate) broken_link_added: usize,
23    pub(crate) link_removed: usize,
24    pub(crate) link_removal_failures: usize,
25    pub(crate) link_removal_review_items: usize,
26    pub(crate) link_removal_improvements: usize,
27}
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub(crate) struct DiffStructuralDeltaSummary {
31    pub(crate) scope_broadened: usize,
32    pub(crate) scope_changed: usize,
33    pub(crate) scope_narrowed: usize,
34    pub(crate) selector_changed: usize,
35    pub(crate) selector_precision_decreased: usize,
36    pub(crate) selector_precision_increased: usize,
37}
38
39impl DiffNetPosture {
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Worse => "worse",
43            Self::ReviewRequired => "review-required",
44            Self::Improved => "improved",
45            Self::Unchanged => "unchanged",
46        }
47    }
48
49    pub fn reviewer_action(self) -> &'static str {
50        match self {
51            Self::Worse => {
52                "block until failing source exception changes are fixed, narrowed, or receipted."
53            }
54            Self::ReviewRequired => "review the source exception posture change before merging.",
55            Self::Improved => "verify the cleanup was intentional and keep the narrower posture.",
56            Self::Unchanged => "no source exception posture change detected.",
57        }
58    }
59}
60
61pub(crate) fn diff_structural_delta_summary(
62    policy_changes: &[DiffPolicyChange<'_>],
63) -> DiffStructuralDeltaSummary {
64    let mut summary = DiffStructuralDeltaSummary::default();
65    for change in policy_changes {
66        match change.kind {
67            "scope_broadened" => summary.scope_broadened += 1,
68            "scope_changed" => summary.scope_changed += 1,
69            "scope_narrowed" => summary.scope_narrowed += 1,
70            "selector_changed" => summary.selector_changed += 1,
71            "selector_precision_decreased" => summary.selector_precision_decreased += 1,
72            "selector_precision_increased" => summary.selector_precision_increased += 1,
73            _ => {}
74        }
75    }
76    summary
77}
78
79pub(crate) fn diff_evidence_delta_summary(
80    policy_changes: &[DiffPolicyChange<'_>],
81) -> DiffEvidenceDeltaSummary {
82    let mut summary = DiffEvidenceDeltaSummary::default();
83    for change in policy_changes {
84        match change.kind {
85            "evidence_added" => {
86                summary.evidence_added += 1;
87                match change.severity {
88                    "review" => summary.weak_evidence_added += 1,
89                    "fail" => summary.broken_evidence_added += 1,
90                    _ => {}
91                }
92            }
93            "evidence_removed" => {
94                summary.evidence_removed += 1;
95                match change.severity {
96                    "fail" => summary.evidence_removal_failures += 1,
97                    "review" => summary.evidence_removal_review_items += 1,
98                    "improvement" => summary.evidence_removal_improvements += 1,
99                    _ => {}
100                }
101            }
102            "link_added" => {
103                summary.link_added += 1;
104                match change.severity {
105                    "review" => summary.weak_link_added += 1,
106                    "fail" => summary.broken_link_added += 1,
107                    _ => {}
108                }
109            }
110            "link_removed" => {
111                summary.link_removed += 1;
112                match change.severity {
113                    "fail" => summary.link_removal_failures += 1,
114                    "review" => summary.link_removal_review_items += 1,
115                    "improvement" => summary.link_removal_improvements += 1,
116                    _ => {}
117                }
118            }
119            _ => {}
120        }
121    }
122    summary
123}
124
125pub fn diff_posture_summary(
126    current_failures: usize,
127    finding_changes: &[DiffFindingChange<'_>],
128    policy_changes: &[DiffPolicyChange<'_>],
129) -> DiffPostureSummary {
130    DiffPostureSummary {
131        current_failures,
132        new_findings: finding_changes
133            .iter()
134            .filter(|change| change.change == "new")
135            .count(),
136        removed_findings: finding_changes
137            .iter()
138            .filter(|change| change.change == "removed")
139            .count(),
140        policy_failures: policy_changes
141            .iter()
142            .filter(|change| change.severity == "fail")
143            .count(),
144        policy_review_items: policy_changes
145            .iter()
146            .filter(|change| change.severity == "review")
147            .count(),
148        policy_improvements: policy_changes
149            .iter()
150            .filter(|change| change.severity == "improvement")
151            .count(),
152    }
153}
154
155pub fn diff_net_posture(summary: DiffPostureSummary) -> DiffNetPosture {
156    if summary.current_failures > 0 || summary.policy_failures > 0 {
157        return DiffNetPosture::Worse;
158    }
159    if summary.new_findings > 0 || summary.policy_review_items > 0 {
160        return DiffNetPosture::ReviewRequired;
161    }
162    if summary.removed_findings > 0 || summary.policy_improvements > 0 {
163        return DiffNetPosture::Improved;
164    }
165    DiffNetPosture::Unchanged
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn policy_change<'a>(severity: &'a str, kind: &'a str) -> DiffPolicyChange<'a> {
173        DiffPolicyChange {
174            severity,
175            allow_id: "allow-test",
176            kind,
177            message: "policy changed",
178            exception_identity: None,
179            selector_identity: None,
180            selector_precision: None,
181            scope: None,
182            occurrence_limit: None,
183            lifecycle: None,
184            evidence: None,
185            metadata: None,
186            requirement: None,
187            policy_status: None,
188        }
189    }
190
191    fn finding_change<'a>(change: &'a str) -> DiffFindingChange<'a> {
192        DiffFindingChange {
193            change,
194            key: "panic|unwrap|src/lib.rs",
195            kind: "panic",
196            family: Some("unwrap"),
197            path: "src/lib.rs",
198            line: Some(1),
199            column: Some(1),
200            source_package: Some("allow-report"),
201            identity: None,
202        }
203    }
204
205    fn summary(
206        current_failures: usize,
207        new_findings: usize,
208        removed_findings: usize,
209        policy_failures: usize,
210        policy_review_items: usize,
211        policy_improvements: usize,
212    ) -> DiffPostureSummary {
213        DiffPostureSummary {
214            current_failures,
215            new_findings,
216            removed_findings,
217            policy_failures,
218            policy_review_items,
219            policy_improvements,
220        }
221    }
222
223    #[test]
224    fn net_posture_strings_and_reviewer_actions_cover_all_variants() {
225        let cases = [
226            (
227                DiffNetPosture::Worse,
228                "worse",
229                "block until failing source exception changes are fixed, narrowed, or receipted.",
230            ),
231            (
232                DiffNetPosture::ReviewRequired,
233                "review-required",
234                "review the source exception posture change before merging.",
235            ),
236            (
237                DiffNetPosture::Improved,
238                "improved",
239                "verify the cleanup was intentional and keep the narrower posture.",
240            ),
241            (
242                DiffNetPosture::Unchanged,
243                "unchanged",
244                "no source exception posture change detected.",
245            ),
246        ];
247
248        for (posture, as_str, action) in cases {
249            assert_eq!(posture.as_str(), as_str);
250            assert_eq!(posture.reviewer_action(), action);
251        }
252    }
253
254    #[test]
255    fn structural_delta_summary_counts_known_kinds_and_ignores_unknowns() {
256        let changes = [
257            policy_change("fail", "scope_broadened"),
258            policy_change("review", "scope_broadened"),
259            policy_change("review", "scope_changed"),
260            policy_change("improvement", "scope_narrowed"),
261            policy_change("review", "selector_changed"),
262            policy_change("fail", "selector_precision_decreased"),
263            policy_change("improvement", "selector_precision_increased"),
264            policy_change("review", "evidence_added"),
265        ];
266
267        assert_eq!(
268            diff_structural_delta_summary(&changes),
269            DiffStructuralDeltaSummary {
270                scope_broadened: 2,
271                scope_changed: 1,
272                scope_narrowed: 1,
273                selector_changed: 1,
274                selector_precision_decreased: 1,
275                selector_precision_increased: 1,
276            }
277        );
278    }
279
280    #[test]
281    fn evidence_delta_summary_counts_severity_buckets_for_evidence_and_links() {
282        let changes = [
283            policy_change("review", "evidence_added"),
284            policy_change("fail", "evidence_added"),
285            policy_change("improvement", "evidence_added"),
286            policy_change("fail", "evidence_removed"),
287            policy_change("review", "evidence_removed"),
288            policy_change("improvement", "evidence_removed"),
289            policy_change("review", "link_added"),
290            policy_change("fail", "link_added"),
291            policy_change("improvement", "link_added"),
292            policy_change("fail", "link_removed"),
293            policy_change("review", "link_removed"),
294            policy_change("improvement", "link_removed"),
295            policy_change("review", "scope_changed"),
296        ];
297
298        assert_eq!(
299            diff_evidence_delta_summary(&changes),
300            DiffEvidenceDeltaSummary {
301                evidence_added: 3,
302                weak_evidence_added: 1,
303                broken_evidence_added: 1,
304                evidence_removed: 3,
305                evidence_removal_failures: 1,
306                evidence_removal_review_items: 1,
307                evidence_removal_improvements: 1,
308                link_added: 3,
309                weak_link_added: 1,
310                broken_link_added: 1,
311                link_removed: 3,
312                link_removal_failures: 1,
313                link_removal_review_items: 1,
314                link_removal_improvements: 1,
315            }
316        );
317    }
318
319    #[test]
320    fn posture_summary_counts_finding_and_policy_statuses() {
321        let finding_changes = [
322            finding_change("new"),
323            finding_change("new"),
324            finding_change("removed"),
325            finding_change("unchanged"),
326        ];
327        let policy_changes = [
328            policy_change("fail", "scope_broadened"),
329            policy_change("review", "selector_changed"),
330            policy_change("improvement", "scope_narrowed"),
331            policy_change("info", "metadata_changed"),
332        ];
333
334        assert_eq!(
335            diff_posture_summary(7, &finding_changes, &policy_changes),
336            DiffPostureSummary {
337                current_failures: 7,
338                new_findings: 2,
339                removed_findings: 1,
340                policy_failures: 1,
341                policy_review_items: 1,
342                policy_improvements: 1,
343            }
344        );
345    }
346
347    #[test]
348    fn net_posture_prioritizes_failures_then_review_then_improvement() {
349        let cases = [
350            (summary(1, 0, 0, 0, 0, 0), DiffNetPosture::Worse),
351            (summary(0, 0, 0, 1, 1, 1), DiffNetPosture::Worse),
352            (summary(0, 1, 1, 0, 0, 1), DiffNetPosture::ReviewRequired),
353            (summary(0, 0, 1, 0, 1, 1), DiffNetPosture::ReviewRequired),
354            (summary(0, 0, 1, 0, 0, 0), DiffNetPosture::Improved),
355            (summary(0, 0, 0, 0, 0, 1), DiffNetPosture::Improved),
356            (summary(0, 0, 0, 0, 0, 0), DiffNetPosture::Unchanged),
357        ];
358
359        for (summary, expected) in cases {
360            assert_eq!(diff_net_posture(summary), expected);
361        }
362    }
363}