1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, RegistryProvenanceCapability};
3
4pub struct DependencyCompletenessControl;
17
18impl Control for DependencyCompletenessControl {
19 fn id(&self) -> ControlId {
20 builtin::id(builtin::DEPENDENCY_COMPLETENESS)
21 }
22
23 fn description(&self) -> &'static str {
24 "All dependencies (direct and transitive) must be fully provenance-verified"
25 }
26
27 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
28 let id = self.id();
29
30 match &evidence.dependency_signatures {
31 EvidenceState::NotApplicable => {
32 vec![ControlFinding::not_applicable(
33 id,
34 "Dependency evidence is not applicable",
35 )]
36 }
37 EvidenceState::Missing { gaps } => {
38 vec![ControlFinding::indeterminate(
39 id,
40 "Dependency evidence could not be collected",
41 Vec::new(),
42 gaps.clone(),
43 )]
44 }
45 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
46 if value.is_empty() {
47 return vec![ControlFinding::not_applicable(
48 id,
49 "No dependencies were present",
50 )];
51 }
52
53 let in_scope: Vec<_> = value
55 .iter()
56 .filter(|d| {
57 d.registry_provenance_capability()
58 >= RegistryProvenanceCapability::FullTrustChain
59 })
60 .collect();
61
62 let skipped = value.len() - in_scope.len();
63
64 if in_scope.is_empty() {
65 return vec![ControlFinding::not_applicable(
66 id,
67 format!(
68 "No dependencies from registries with full trust chain support \
69 ({skipped} dependenc(ies) from other registries skipped)",
70 ),
71 )];
72 }
73
74 let total = in_scope.len();
75 let direct_count = in_scope.iter().filter(|d| d.is_direct).count();
76 let transitive_count = total - direct_count;
77
78 let subjects: Vec<String> = in_scope
79 .iter()
80 .map(|d| {
81 let kind = if d.is_direct { "direct" } else { "transitive" };
82 format!("{}@{} ({})", d.name, d.version, kind)
83 })
84 .collect();
85
86 let lacking: Vec<String> = in_scope
88 .iter()
89 .filter(|d| {
90 !d.verification.is_cryptographically_signed()
91 || d.signer_identity.is_none()
92 || d.transparency_log_uri.is_none()
93 })
94 .map(|d| {
95 let kind = if d.is_direct { "direct" } else { "transitive" };
96 let mut reasons = Vec::new();
97 if !d.verification.is_cryptographically_signed() {
98 reasons.push("no signature");
99 }
100 if d.signer_identity.is_none() {
101 reasons.push("no signer_identity");
102 }
103 if d.transparency_log_uri.is_none() {
104 reasons.push("no transparency_log");
105 }
106 format!("{}@{} [{kind}] ({})", d.name, d.version, reasons.join(", "))
107 })
108 .collect();
109
110 let gaps = match &evidence.dependency_signatures {
111 EvidenceState::Partial { gaps, .. } => gaps.as_slice(),
112 _ => &[],
113 };
114
115 if !gaps.is_empty() {
117 let mut finding = ControlFinding::violated(
118 id,
119 format!(
120 "Cannot guarantee completeness: {} evidence gap(s) — \
121 transitive dependencies may be missing from evaluation",
122 gaps.len()
123 ),
124 subjects,
125 );
126 finding.evidence_gaps = gaps.to_vec();
127 return vec![finding];
128 }
129
130 let skip_note = if skipped > 0 {
131 format!(" [{skipped} non-L3 registr(ies) skipped]")
132 } else {
133 String::new()
134 };
135
136 if lacking.is_empty() {
137 vec![ControlFinding::satisfied(
138 id,
139 format!(
140 "All {total} dependenc(ies) ({direct_count} direct, \
141 {transitive_count} transitive) fully verified with provenance{skip_note}",
142 ),
143 subjects,
144 )]
145 } else {
146 vec![ControlFinding::violated(
147 id,
148 format!(
149 "{}/{total} dependenc(ies) lack full provenance: {}{skip_note}",
150 lacking.len(),
151 lacking.join("; ")
152 ),
153 subjects,
154 )]
155 }
156 }
157 }
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::control::ControlStatus;
165 use crate::evidence::{DependencySignatureEvidence, EvidenceGap, VerificationOutcome};
166
167 fn npm_dep_l3(name: &str, is_direct: bool) -> DependencySignatureEvidence {
168 DependencySignatureEvidence {
169 name: name.to_string(),
170 version: "1.0.0".to_string(),
171 registry: Some("registry.npmjs.org".to_string()),
172 verification: VerificationOutcome::Verified,
173 signature_mechanism: Some("sigstore".to_string()),
174 signer_identity: Some("https://github.com/login/oauth".to_string()),
175 source_repo: Some("owner/repo".to_string()),
176 source_commit: Some("abc123".to_string()),
177 pinned_digest: None,
178 actual_digest: None,
179 transparency_log_uri: Some(
180 "https://rekor.sigstore.dev/api/v1/log/entries/abc".to_string(),
181 ),
182 is_direct,
183 }
184 }
185
186 fn npm_dep_checksum(name: &str, is_direct: bool) -> DependencySignatureEvidence {
187 DependencySignatureEvidence {
188 name: name.to_string(),
189 version: "1.0.0".to_string(),
190 registry: Some("registry.npmjs.org".to_string()),
191 verification: VerificationOutcome::ChecksumMatch,
192 signature_mechanism: Some("checksum".to_string()),
193 signer_identity: None,
194 source_repo: None,
195 source_commit: None,
196 pinned_digest: Some("sha512-abc".to_string()),
197 actual_digest: None,
198 transparency_log_uri: None,
199 is_direct,
200 }
201 }
202
203 fn cargo_dep(name: &str) -> DependencySignatureEvidence {
204 DependencySignatureEvidence {
205 name: name.to_string(),
206 version: "1.0.0".to_string(),
207 registry: Some("crates.io".to_string()),
208 verification: VerificationOutcome::ChecksumMatch,
209 signature_mechanism: Some("checksum".to_string()),
210 signer_identity: None,
211 source_repo: None,
212 source_commit: None,
213 pinned_digest: Some("sha256:abc".to_string()),
214 actual_digest: None,
215 transparency_log_uri: None,
216 is_direct: true,
217 }
218 }
219
220 fn bundle(deps: Vec<DependencySignatureEvidence>) -> EvidenceBundle {
221 EvidenceBundle {
222 dependency_signatures: EvidenceState::complete(deps),
223 ..Default::default()
224 }
225 }
226
227 #[test]
228 fn satisfied_when_all_npm_deps_fully_verified() {
229 let evidence = bundle(vec![
230 npm_dep_l3("react", true),
231 npm_dep_l3("react-dom", false),
232 npm_dep_l3("express", true),
233 npm_dep_l3("body-parser", false),
234 ]);
235 let findings = DependencyCompletenessControl.evaluate(&evidence);
236 assert_eq!(findings[0].status, ControlStatus::Satisfied);
237 assert!(findings[0].rationale.contains("2 direct"));
238 assert!(findings[0].rationale.contains("2 transitive"));
239 }
240
241 #[test]
242 fn violated_when_npm_transitive_dep_lacks_provenance() {
243 let evidence = bundle(vec![
244 npm_dep_l3("react", true),
245 npm_dep_checksum("scheduler", false),
246 ]);
247 let findings = DependencyCompletenessControl.evaluate(&evidence);
248 assert_eq!(findings[0].status, ControlStatus::Violated);
249 assert!(
250 findings[0]
251 .rationale
252 .contains("scheduler@1.0.0 [transitive]")
253 );
254 }
255
256 #[test]
257 fn violated_when_npm_direct_dep_lacks_provenance() {
258 let evidence = bundle(vec![
259 npm_dep_checksum("lodash", true),
260 npm_dep_l3("express", false),
261 ]);
262 let findings = DependencyCompletenessControl.evaluate(&evidence);
263 assert_eq!(findings[0].status, ControlStatus::Violated);
264 assert!(findings[0].rationale.contains("lodash@1.0.0 [direct]"));
265 }
266
267 #[test]
268 fn not_applicable_when_only_cargo_deps() {
269 let findings = DependencyCompletenessControl.evaluate(&bundle(vec![cargo_dep("serde")]));
270 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
271 }
272
273 #[test]
274 fn mixed_registries_only_evaluates_npm() {
275 let evidence = bundle(vec![cargo_dep("serde"), npm_dep_l3("react", true)]);
276 let findings = DependencyCompletenessControl.evaluate(&evidence);
277 assert_eq!(findings[0].status, ControlStatus::Satisfied);
278 assert!(findings[0].rationale.contains("skipped"));
279 }
280
281 #[test]
282 fn violated_when_partial_evidence_has_gaps() {
283 let evidence = EvidenceBundle {
284 dependency_signatures: EvidenceState::partial(
285 vec![npm_dep_l3("react", true)],
286 vec![EvidenceGap::Truncated {
287 source: "github-tree-api".to_string(),
288 subject: "repository-tree".to_string(),
289 }],
290 ),
291 ..Default::default()
292 };
293 let findings = DependencyCompletenessControl.evaluate(&evidence);
294 assert_eq!(findings[0].status, ControlStatus::Violated);
295 assert!(
296 findings[0]
297 .rationale
298 .contains("Cannot guarantee completeness")
299 );
300 }
301
302 #[test]
303 fn not_applicable_when_empty() {
304 let findings = DependencyCompletenessControl.evaluate(&bundle(vec![]));
305 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
306 }
307
308 #[test]
309 fn indeterminate_when_evidence_missing() {
310 let evidence = EvidenceBundle {
311 dependency_signatures: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
312 source: "registry".to_string(),
313 subject: "deps".to_string(),
314 detail: "timeout".to_string(),
315 }]),
316 ..Default::default()
317 };
318 let findings = DependencyCompletenessControl.evaluate(&evidence);
319 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
320 assert_eq!(findings[0].evidence_gaps.len(), 1);
321 }
322}