libverify_core/controls/
branch_history_integrity.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, GovernedChange};
3use crate::integrity::branch_history_severity;
4use crate::verdict::Severity;
5
6pub 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}