Skip to main content

libverify_core/controls/
source_authenticity.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{
3    AuthenticityEvidence, EvidenceBundle, EvidenceGap, EvidenceState, SourceRevision,
4};
5use crate::integrity::signature_severity;
6use crate::verdict::Severity;
7
8/// Verifies that all source revisions carry valid cryptographic signatures.
9pub struct SourceAuthenticityControl;
10
11impl Control for SourceAuthenticityControl {
12    fn id(&self) -> ControlId {
13        builtin::id(builtin::SOURCE_AUTHENTICITY)
14    }
15
16    fn description(&self) -> &'static str {
17        "All commits must carry verified signatures"
18    }
19
20    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
21        let mut findings = Vec::new();
22
23        for change in &evidence.change_requests {
24            findings.push(evaluate_revisions(
25                change.id.to_string(),
26                &change.source_revisions,
27                "change request",
28            ));
29        }
30
31        for batch in &evidence.promotion_batches {
32            findings.push(evaluate_revisions(
33                batch.id.clone(),
34                &batch.source_revisions,
35                "promotion batch",
36            ));
37        }
38
39        if findings.is_empty() {
40            findings.push(ControlFinding::not_applicable(
41                self.id(),
42                "No source revisions were supplied",
43            ));
44        }
45
46        findings
47    }
48}
49
50fn evaluate_revisions(
51    subject: String,
52    revisions_state: &EvidenceState<Vec<SourceRevision>>,
53    scope_label: &str,
54) -> ControlFinding {
55    let mut gaps = revisions_state.gaps().to_vec();
56    let revisions = match revisions_state.value() {
57        Some(revisions) => revisions,
58        None => {
59            return ControlFinding::indeterminate(
60                builtin::id(builtin::SOURCE_AUTHENTICITY),
61                format!("Source authenticity evidence is unavailable for the {scope_label}"),
62                vec![subject],
63                gaps,
64            );
65        }
66    };
67
68    let mut unsigned = Vec::new();
69    for revision in revisions {
70        match authenticity_state(&revision.authenticity) {
71            Ok(auth) => {
72                if !auth.verified {
73                    unsigned.push(revision.id.clone());
74                }
75            }
76            Err(mut revision_gaps) => {
77                gaps.append(&mut revision_gaps);
78            }
79        }
80    }
81
82    if !gaps.is_empty() {
83        return ControlFinding::indeterminate(
84            builtin::id(builtin::SOURCE_AUTHENTICITY),
85            format!("Source authenticity cannot be proven for the {scope_label}"),
86            vec![subject],
87            gaps,
88        );
89    }
90
91    match signature_severity(unsigned.len()) {
92        Severity::Pass => ControlFinding::satisfied(
93            builtin::id(builtin::SOURCE_AUTHENTICITY),
94            format!("All revisions in the {scope_label} carry authenticity evidence"),
95            vec![subject],
96        ),
97        _ => ControlFinding::violated(
98            builtin::id(builtin::SOURCE_AUTHENTICITY),
99            format!(
100                "Unsigned or unverified revisions were found in the {scope_label}: {}",
101                unsigned.join(", ")
102            ),
103            vec![subject],
104        ),
105    }
106}
107
108fn authenticity_state(
109    state: &EvidenceState<AuthenticityEvidence>,
110) -> Result<&AuthenticityEvidence, Vec<EvidenceGap>> {
111    match state {
112        EvidenceState::Complete { value } => Ok(value),
113        EvidenceState::Partial { gaps, .. } | EvidenceState::Missing { gaps } => Err(gaps.clone()),
114        EvidenceState::NotApplicable => Err(vec![EvidenceGap::Unsupported {
115            source: "control-normalization".to_string(),
116            capability: "source authenticity not collected for revision".to_string(),
117        }]),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::control::ControlStatus;
125    use crate::evidence::{ChangeRequestId, EvidenceBundle, GovernedChange, SourceRevision};
126
127    fn make_change(verified: bool) -> GovernedChange {
128        GovernedChange {
129            id: ChangeRequestId::new("test", "owner/repo#7"),
130            title: "fix: sign commits".to_string(),
131            summary: None,
132            submitted_by: Some("author".to_string()),
133            changed_assets: EvidenceState::complete(vec![]),
134            approval_decisions: EvidenceState::complete(vec![]),
135            source_revisions: EvidenceState::complete(vec![SourceRevision {
136                id: "deadbeef".to_string(),
137                authored_by: Some("author".to_string()),
138                committed_at: Some("2026-03-15T00:00:00Z".to_string()),
139                merge: false,
140                authenticity: EvidenceState::complete(AuthenticityEvidence::new(
141                    verified,
142                    Some("gpg".to_string()),
143                )),
144            }]),
145            work_item_refs: EvidenceState::complete(vec![]),
146        }
147    }
148
149    #[test]
150    fn verified_revisions_are_satisfied() {
151        let findings = SourceAuthenticityControl.evaluate(&EvidenceBundle {
152            change_requests: vec![make_change(true)],
153            promotion_batches: vec![],
154            ..Default::default()
155        });
156        assert_eq!(findings[0].status, ControlStatus::Satisfied);
157    }
158
159    #[test]
160    fn unsigned_revisions_are_violated() {
161        let findings = SourceAuthenticityControl.evaluate(&EvidenceBundle {
162            change_requests: vec![make_change(false)],
163            promotion_batches: vec![],
164            ..Default::default()
165        });
166        assert_eq!(findings[0].status, ControlStatus::Violated);
167    }
168
169    #[test]
170    fn missing_authenticity_is_indeterminate() {
171        let mut change = make_change(true);
172        change.source_revisions = EvidenceState::complete(vec![SourceRevision {
173            id: "deadbeef".to_string(),
174            authored_by: Some("author".to_string()),
175            committed_at: Some("2026-03-15T00:00:00Z".to_string()),
176            merge: false,
177            authenticity: EvidenceState::not_applicable(),
178        }]);
179
180        let findings = SourceAuthenticityControl.evaluate(&EvidenceBundle {
181            change_requests: vec![change],
182            promotion_batches: vec![],
183            ..Default::default()
184        });
185        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
186    }
187}