Skip to main content

libverify_core/controls/
branch_history_integrity.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, GovernedChange};
3use crate::integrity::branch_history_severity;
4use crate::verdict::Severity;
5
6/// Source L2: Verifies that branch history is continuous and linear
7/// by checking actual commit history for merge commits (evidence of non-linear history).
8///
9/// Instead of checking branch protection API settings (which require admin permissions),
10/// this control examines the factual commit history collected from the change request.
11/// See ADR-0002 for rationale.
12pub struct BranchHistoryIntegrityControl;
13
14impl Control for BranchHistoryIntegrityControl {
15    fn id(&self) -> ControlId {
16        builtin::id(builtin::BRANCH_HISTORY_INTEGRITY)
17    }
18
19    fn description(&self) -> &'static str {
20        "Branch history must be continuous and protected from force-push"
21    }
22
23    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
24        let id = self.id();
25
26        if evidence.change_requests.is_empty() {
27            return vec![ControlFinding::not_applicable(
28                id,
29                "No governed changes were supplied",
30            )];
31        }
32
33        evidence
34            .change_requests
35            .iter()
36            .map(|cr| evaluate_change(id.clone(), cr))
37            .collect()
38    }
39}
40
41fn evaluate_change(id: ControlId, change: &GovernedChange) -> ControlFinding {
42    let subject = change.id.to_string();
43
44    let revisions = match &change.source_revisions {
45        EvidenceState::NotApplicable => {
46            return ControlFinding::not_applicable(
47                id,
48                "Source revision evidence does not apply to this context",
49            );
50        }
51        EvidenceState::Missing { gaps } => {
52            return ControlFinding::indeterminate(
53                id,
54                "Source revision evidence could not be collected",
55                vec![subject],
56                gaps.clone(),
57            );
58        }
59        EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
60    };
61
62    if revisions.is_empty() {
63        return ControlFinding::indeterminate(
64            id,
65            "No source revisions found in the change request",
66            vec![subject],
67            vec![],
68        );
69    }
70
71    let merge_commits: Vec<&str> = revisions
72        .iter()
73        .filter(|r| r.merge)
74        .map(|r| r.id.as_str())
75        .collect();
76
77    match branch_history_severity(merge_commits.len()) {
78        Severity::Pass => ControlFinding::satisfied(
79            id,
80            format!(
81                "All {} commit(s) form a linear history (no merge commits)",
82                revisions.len()
83            ),
84            vec![subject],
85        ),
86        _ => ControlFinding::violated(
87            id,
88            format!(
89                "{} merge commit(s) found, indicating non-linear history: {}",
90                merge_commits.len(),
91                merge_commits.join(", ")
92            ),
93            vec![subject],
94        ),
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::control::ControlStatus;
102    use crate::evidence::{ChangeRequestId, EvidenceGap, SourceRevision};
103
104    fn make_revision(sha: &str, merge: bool) -> SourceRevision {
105        SourceRevision {
106            id: sha.to_string(),
107            authored_by: Some("author".to_string()),
108            committed_at: Some("2026-03-15T00:00:00Z".to_string()),
109            merge,
110            authenticity: EvidenceState::not_applicable(),
111        }
112    }
113
114    fn make_change(revisions: EvidenceState<Vec<SourceRevision>>) -> GovernedChange {
115        GovernedChange {
116            id: ChangeRequestId::new("test", "owner/repo#1"),
117            title: "feat: test".to_string(),
118            summary: None,
119            submitted_by: Some("author".to_string()),
120            changed_assets: EvidenceState::complete(vec![]),
121            approval_decisions: EvidenceState::complete(vec![]),
122            source_revisions: revisions,
123            work_item_refs: EvidenceState::complete(vec![]),
124        }
125    }
126
127    fn make_bundle(change: GovernedChange) -> EvidenceBundle {
128        EvidenceBundle {
129            change_requests: vec![change],
130            ..Default::default()
131        }
132    }
133
134    #[test]
135    fn not_applicable_when_no_changes() {
136        let evidence = EvidenceBundle::default();
137        let findings = BranchHistoryIntegrityControl.evaluate(&evidence);
138        assert_eq!(findings.len(), 1);
139        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
140        assert_eq!(
141            findings[0].control_id,
142            builtin::id(builtin::BRANCH_HISTORY_INTEGRITY)
143        );
144    }
145
146    #[test]
147    fn not_applicable_when_revisions_not_applicable() {
148        let bundle = make_bundle(make_change(EvidenceState::not_applicable()));
149        let findings = BranchHistoryIntegrityControl.evaluate(&bundle);
150        assert_eq!(findings.len(), 1);
151        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
152    }
153
154    #[test]
155    fn indeterminate_when_revisions_missing() {
156        let bundle = make_bundle(make_change(EvidenceState::missing(vec![
157            EvidenceGap::CollectionFailed {
158                source: "github".to_string(),
159                subject: "commits".to_string(),
160                detail: "API returned 403".to_string(),
161            },
162        ])));
163        let findings = BranchHistoryIntegrityControl.evaluate(&bundle);
164        assert_eq!(findings.len(), 1);
165        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
166        assert_eq!(findings[0].evidence_gaps.len(), 1);
167    }
168
169    #[test]
170    fn indeterminate_when_revisions_empty() {
171        let bundle = make_bundle(make_change(EvidenceState::complete(vec![])));
172        let findings = BranchHistoryIntegrityControl.evaluate(&bundle);
173        assert_eq!(findings.len(), 1);
174        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
175    }
176
177    #[test]
178    fn satisfied_when_all_commits_linear() {
179        let bundle = make_bundle(make_change(EvidenceState::complete(vec![
180            make_revision("abc123", false),
181            make_revision("def456", false),
182        ])));
183        let findings = BranchHistoryIntegrityControl.evaluate(&bundle);
184        assert_eq!(findings.len(), 1);
185        assert_eq!(findings[0].status, ControlStatus::Satisfied);
186        assert!(findings[0].rationale.contains("linear history"));
187    }
188
189    #[test]
190    fn violated_when_merge_commits_present() {
191        let bundle = make_bundle(make_change(EvidenceState::complete(vec![
192            make_revision("abc123", false),
193            make_revision("merge1", true),
194        ])));
195        let findings = BranchHistoryIntegrityControl.evaluate(&bundle);
196        assert_eq!(findings.len(), 1);
197        assert_eq!(findings[0].status, ControlStatus::Violated);
198        assert!(findings[0].rationale.contains("merge commit"));
199        assert!(findings[0].rationale.contains("merge1"));
200    }
201
202    #[test]
203    fn correct_control_id() {
204        assert_eq!(
205            BranchHistoryIntegrityControl.id(),
206            builtin::id(builtin::BRANCH_HISTORY_INTEGRITY)
207        );
208    }
209}