Skip to main content

libverify_core/controls/
merge_commit_policy.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, GovernedChange};
3
4/// Verifies that source revisions follow a linear history policy (no merge commits).
5///
6/// Maps to SOC2 CC8.1: change management process integrity.
7/// Merge commits in a change request indicate non-linear history (e.g. merging the base
8/// branch into the feature branch), which can obscure the audit trail and
9/// make it harder to review individual changes.
10pub struct MergeCommitPolicyControl;
11
12impl Control for MergeCommitPolicyControl {
13    fn id(&self) -> ControlId {
14        builtin::id(builtin::MERGE_COMMIT_POLICY)
15    }
16
17    fn description(&self) -> &'static str {
18        "Source revisions must follow linear history (no merge commits)"
19    }
20
21    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
22        if evidence.change_requests.is_empty() {
23            return vec![ControlFinding::not_applicable(
24                self.id(),
25                "No change requests found",
26            )];
27        }
28
29        evidence
30            .change_requests
31            .iter()
32            .map(|cr| evaluate_change(self.id(), cr))
33            .collect()
34    }
35}
36
37fn evaluate_change(id: ControlId, cr: &GovernedChange) -> ControlFinding {
38    let cr_subject = cr.id.to_string();
39
40    let revisions = match &cr.source_revisions {
41        EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
42        EvidenceState::Missing { gaps } => {
43            return ControlFinding::indeterminate(
44                id,
45                format!("{cr_subject}: source revision evidence could not be collected"),
46                vec![cr_subject],
47                gaps.clone(),
48            );
49        }
50        EvidenceState::NotApplicable => {
51            return ControlFinding::not_applicable(id, "Source revisions not applicable");
52        }
53    };
54
55    if revisions.is_empty() {
56        return ControlFinding::not_applicable(
57            id,
58            format!("{cr_subject}: no source revisions to evaluate"),
59        );
60    }
61
62    let merge_commits: Vec<&str> = revisions
63        .iter()
64        .filter(|r| r.merge)
65        .map(|r| r.id.as_str())
66        .collect();
67
68    if merge_commits.is_empty() {
69        ControlFinding::satisfied(
70            id,
71            format!(
72                "{cr_subject}: all {} revision(s) follow linear history",
73                revisions.len()
74            ),
75            vec![cr_subject],
76        )
77    } else {
78        ControlFinding::violated(
79            id,
80            format!(
81                "{cr_subject}: {} merge commit(s) found: {}",
82                merge_commits.len(),
83                merge_commits.join(", ")
84            ),
85            merge_commits.into_iter().map(String::from).collect(),
86        )
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::control::ControlStatus;
94    use crate::evidence::{ChangeRequestId, SourceRevision};
95
96    fn revision(id: &str, merge: bool) -> SourceRevision {
97        SourceRevision {
98            id: id.to_string(),
99            authored_by: Some("dev".to_string()),
100            committed_at: None,
101            merge,
102            authenticity: EvidenceState::not_applicable(),
103        }
104    }
105
106    fn make_change(revisions: EvidenceState<Vec<SourceRevision>>) -> GovernedChange {
107        GovernedChange {
108            id: ChangeRequestId::new("test", "owner/repo#1"),
109            title: "test".to_string(),
110            summary: None,
111            submitted_by: None,
112            changed_assets: EvidenceState::not_applicable(),
113            approval_decisions: EvidenceState::not_applicable(),
114            source_revisions: revisions,
115            work_item_refs: EvidenceState::not_applicable(),
116        }
117    }
118
119    fn bundle(changes: Vec<GovernedChange>) -> EvidenceBundle {
120        EvidenceBundle {
121            change_requests: changes,
122            ..Default::default()
123        }
124    }
125
126    #[test]
127    fn not_applicable_when_no_changes() {
128        let findings = MergeCommitPolicyControl.evaluate(&EvidenceBundle::default());
129        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
130    }
131
132    #[test]
133    fn satisfied_when_all_linear() {
134        let cr = make_change(EvidenceState::complete(vec![
135            revision("abc", false),
136            revision("def", false),
137        ]));
138        let findings = MergeCommitPolicyControl.evaluate(&bundle(vec![cr]));
139        assert_eq!(findings[0].status, ControlStatus::Satisfied);
140    }
141
142    #[test]
143    fn violated_when_merge_commit_present() {
144        let cr = make_change(EvidenceState::complete(vec![
145            revision("abc", false),
146            revision("merge123", true),
147        ]));
148        let findings = MergeCommitPolicyControl.evaluate(&bundle(vec![cr]));
149        assert_eq!(findings[0].status, ControlStatus::Violated);
150        assert!(findings[0].rationale.contains("merge123"));
151    }
152
153    #[test]
154    fn not_applicable_when_no_revisions() {
155        let cr = make_change(EvidenceState::complete(vec![]));
156        let findings = MergeCommitPolicyControl.evaluate(&bundle(vec![cr]));
157        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
158    }
159
160    #[test]
161    fn indeterminate_when_revisions_missing() {
162        let cr = make_change(EvidenceState::missing(vec![
163            crate::evidence::EvidenceGap::CollectionFailed {
164                source: "github".to_string(),
165                subject: "commits".to_string(),
166                detail: "API error".to_string(),
167            },
168        ]));
169        let findings = MergeCommitPolicyControl.evaluate(&bundle(vec![cr]));
170        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
171    }
172}