libverify_core/controls/
release_traceability.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, PromotionBatch};
3
4pub struct ReleaseTraceabilityControl;
10
11impl Control for ReleaseTraceabilityControl {
12 fn id(&self) -> ControlId {
13 builtin::id(builtin::RELEASE_TRACEABILITY)
14 }
15
16 fn description(&self) -> &'static str {
17 "Release batches must trace to governed change requests"
18 }
19
20 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
21 if evidence.promotion_batches.is_empty() {
22 return vec![ControlFinding::not_applicable(
23 self.id(),
24 "No promotion batches found",
25 )];
26 }
27
28 evidence
29 .promotion_batches
30 .iter()
31 .map(|batch| evaluate_batch(self.id(), batch))
32 .collect()
33 }
34}
35
36fn evaluate_batch(id: ControlId, batch: &PromotionBatch) -> ControlFinding {
37 let batch_subject = batch.id.clone();
38
39 match &batch.linked_change_requests {
40 EvidenceState::NotApplicable => {
41 ControlFinding::not_applicable(id, "Linked change requests not applicable")
42 }
43 EvidenceState::Missing { gaps } => ControlFinding::indeterminate(
44 id,
45 format!("{batch_subject}: linked change request evidence could not be collected"),
46 vec![batch_subject],
47 gaps.clone(),
48 ),
49 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
50 if value.is_empty() {
51 ControlFinding::violated(
52 id,
53 format!(
54 "{batch_subject}: no linked change requests found — release has no CR traceability"
55 ),
56 vec![batch_subject],
57 )
58 } else {
59 let cr_ids: Vec<String> = value.iter().map(|cr| cr.to_string()).collect();
60 ControlFinding::satisfied(
61 id,
62 format!(
63 "{batch_subject}: traces to {} change request(s)",
64 value.len()
65 ),
66 cr_ids,
67 )
68 }
69 }
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use crate::control::ControlStatus;
77 use crate::evidence::{ChangeRequestId, EvidenceGap, SourceRevision};
78
79 fn make_batch(linked_crs: EvidenceState<Vec<ChangeRequestId>>) -> PromotionBatch {
80 PromotionBatch {
81 id: "github_release:owner/repo:v0.1.0..v0.2.0".to_string(),
82 source_revisions: EvidenceState::complete(vec![SourceRevision {
83 id: "abc123".to_string(),
84 authored_by: Some("dev".to_string()),
85 committed_at: None,
86 merge: false,
87 authenticity: EvidenceState::not_applicable(),
88 }]),
89 linked_change_requests: linked_crs,
90 }
91 }
92
93 fn bundle(batches: Vec<PromotionBatch>) -> EvidenceBundle {
94 EvidenceBundle {
95 promotion_batches: batches,
96 ..Default::default()
97 }
98 }
99
100 #[test]
101 fn not_applicable_when_no_batches() {
102 let findings = ReleaseTraceabilityControl.evaluate(&EvidenceBundle::default());
103 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
104 }
105
106 #[test]
107 fn satisfied_when_crs_linked() {
108 let batch = make_batch(EvidenceState::complete(vec![
109 ChangeRequestId::new("test", "owner/repo#1"),
110 ChangeRequestId::new("test", "owner/repo#2"),
111 ]));
112 let findings = ReleaseTraceabilityControl.evaluate(&bundle(vec![batch]));
113 assert_eq!(findings[0].status, ControlStatus::Satisfied);
114 assert!(findings[0].rationale.contains("2 change request(s)"));
115 }
116
117 #[test]
118 fn violated_when_no_crs_linked() {
119 let batch = make_batch(EvidenceState::complete(vec![]));
120 let findings = ReleaseTraceabilityControl.evaluate(&bundle(vec![batch]));
121 assert_eq!(findings[0].status, ControlStatus::Violated);
122 assert!(findings[0].rationale.contains("no linked change requests"));
123 }
124
125 #[test]
126 fn indeterminate_when_evidence_missing() {
127 let batch = make_batch(EvidenceState::missing(vec![
128 EvidenceGap::CollectionFailed {
129 source: "github".to_string(),
130 subject: "commits".to_string(),
131 detail: "API error".to_string(),
132 },
133 ]));
134 let findings = ReleaseTraceabilityControl.evaluate(&bundle(vec![batch]));
135 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
136 }
137
138 #[test]
139 fn not_applicable_when_crs_not_applicable() {
140 let batch = make_batch(EvidenceState::not_applicable());
141 let findings = ReleaseTraceabilityControl.evaluate(&bundle(vec![batch]));
142 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
143 }
144}