1use std::process;
2
3use anyhow::{Context, Result, bail};
4
5use libverify_core::assessment::{
6 AssessmentReport, BatchEntry, BatchReport, SkippedEntry, VerificationResult,
7};
8use libverify_core::control::Control;
9use libverify_core::evidence::EvidenceState;
10use libverify_core::profile::GateDecision;
11use libverify_core::registry::ControlRegistry;
12use libverify_policy::OpaProfile;
13
14use crate::adapter;
15use crate::client::GitHubClient;
16use crate::dependency;
17use crate::graphql::{self, PrData};
18use crate::types::{CombinedStatusResponse, CommitStatusItem};
19
20pub fn collect_pr_evidence(
29 client: &GitHubClient,
30 owner: &str,
31 repo: &str,
32 pr_number: u32,
33) -> Result<libverify_core::evidence::EvidenceBundle> {
34 let pr_data =
35 graphql::fetch_pr(client, owner, repo, pr_number).context("failed to fetch PR data")?;
36 let posture =
37 crate::posture::collect_repository_posture(client, owner, repo, &pr_data.metadata.head.sha);
38 collect_pr_evidence_from_data(client, &pr_data, owner, repo, pr_number, posture)
39}
40
41pub fn collect_pr_batch_evidence(
46 client: &GitHubClient,
47 owner: &str,
48 repo: &str,
49 pr_numbers: &[u32],
50) -> Vec<(String, Result<libverify_core::evidence::EvidenceBundle>)> {
51 let all_data = graphql::fetch_prs(client, owner, repo, pr_numbers);
52
53 let head_sha = all_data
55 .iter()
56 .find_map(|(_, r)| r.as_ref().ok().map(|d| d.metadata.head.sha.as_str()))
57 .unwrap_or("HEAD");
58 let posture = crate::posture::collect_repository_posture(client, owner, repo, head_sha);
59
60 all_data
61 .into_iter()
62 .map(|(pr_number, result)| {
63 let subject_id = format!("#{pr_number}");
64 let bundle = result.and_then(|pr_data| {
65 collect_pr_evidence_from_data(
66 client,
67 &pr_data,
68 owner,
69 repo,
70 pr_number,
71 posture.clone(),
72 )
73 });
74 (subject_id, bundle)
75 })
76 .collect()
77}
78
79pub fn collect_release_pr_evidence(
87 client: &GitHubClient,
88 owner: &str,
89 repo: &str,
90 base_tag: &str,
91 head_tag: &str,
92) -> Result<libverify_core::evidence::EvidenceBundle> {
93 let commits = crate::release_api::compare_refs(client, owner, repo, base_tag, head_tag)
94 .context("failed to compare refs")?;
95
96 if commits.is_empty() {
97 bail!("no commits found between {base_tag} and {head_tag}");
98 }
99
100 let shas: Vec<&str> = commits.iter().map(|c| c.sha.as_str()).collect();
101 let commit_pr_map =
102 graphql::resolve_commit_prs(client, owner, repo, &shas).unwrap_or_else(|err| {
103 eprintln!("Warning: failed to resolve commit PRs via GraphQL: {err}");
104 std::collections::HashMap::new()
105 });
106
107 let commit_prs: Vec<_> = commits
108 .iter()
109 .map(|c| adapter::GitHubCommitPullAssociation {
110 commit_sha: c.sha.clone(),
111 pull_requests: commit_pr_map.get(&c.sha).cloned().unwrap_or_default(),
112 })
113 .collect();
114
115 let unique_pr_numbers: Vec<u32> = {
116 let mut seen = std::collections::HashSet::new();
117 commit_pr_map
118 .values()
119 .flatten()
120 .filter(|pr| seen.insert(pr.number))
121 .map(|pr| pr.number)
122 .collect()
123 };
124
125 let repo_full = format!("{owner}/{repo}");
126 let mut change_requests = Vec::new();
127 let mut all_check_runs = Vec::new();
128
129 if !unique_pr_numbers.is_empty() {
130 for (pr_number, result) in graphql::fetch_prs(client, owner, repo, &unique_pr_numbers) {
131 match result {
132 Ok(pr_data) => {
133 change_requests.push(adapter::map_pull_request_evidence(
134 &repo_full,
135 pr_number,
136 &pr_data.metadata,
137 &pr_data.files,
138 &pr_data.reviews,
139 &pr_data.commits,
140 ));
141 all_check_runs.extend(pr_data.check_runs);
142 }
143 Err(e) => {
144 eprintln!(
145 "Warning: failed to fetch PR #{pr_number} for release verification: {e:#}"
146 );
147 }
148 }
149 }
150 }
151
152 let mut bundle = adapter::build_release_bundle(
153 &repo_full,
154 base_tag,
155 head_tag,
156 &commits,
157 &commit_prs,
158 EvidenceState::default(),
159 );
160 bundle.change_requests = change_requests;
161
162 let check_run_evidence = adapter::map_check_runs_evidence(&all_check_runs, None);
163 bundle.check_runs = EvidenceState::complete(check_run_evidence);
164 if let Some(cr_list) = bundle.check_runs.value() {
165 let build_platforms = adapter::map_build_platform_evidence(cr_list);
166 if !build_platforms.is_empty() {
167 bundle.build_platform = EvidenceState::complete(build_platforms);
168 }
169 }
170
171 Ok(bundle)
172}
173
174pub fn collect_release_repo_evidence(
179 client: &GitHubClient,
180 owner: &str,
181 repo: &str,
182 head_tag: &str,
183 bundle: &mut libverify_core::evidence::EvidenceBundle,
184) {
185 bundle.repository_posture =
186 crate::posture::collect_repository_posture(client, owner, repo, head_tag);
187}
188
189pub fn collect_release_attestation_evidence(
195 client: &GitHubClient,
196 owner: &str,
197 repo: &str,
198 head_tag: &str,
199 bundle: &mut libverify_core::evidence::EvidenceBundle,
200) {
201 let release_assets = crate::release_api::get_release_assets(client, owner, repo, head_tag)
202 .unwrap_or_else(|err| {
203 eprintln!("Warning: failed to fetch release assets: {err}");
204 vec![]
205 });
206
207 bundle.artifact_attestations =
208 crate::attestation::collect_release_attestations(owner, repo, head_tag, &release_assets, client);
209}
210
211pub fn collect_release_evidence(
216 client: &GitHubClient,
217 owner: &str,
218 repo: &str,
219 base_tag: &str,
220 head_tag: &str,
221) -> Result<libverify_core::evidence::EvidenceBundle> {
222 let mut bundle = collect_release_pr_evidence(client, owner, repo, base_tag, head_tag)?;
223 collect_release_repo_evidence(client, owner, repo, head_tag, &mut bundle);
224 collect_release_attestation_evidence(client, owner, repo, head_tag, &mut bundle);
225 Ok(bundle)
226}
227
228pub fn collect_repo_evidence(
230 client: &GitHubClient,
231 owner: &str,
232 repo: &str,
233 reference: &str,
234) -> Result<libverify_core::evidence::EvidenceBundle> {
235 let dep_sigs = dependency::collect_repo_dependency_signatures(client, owner, repo, reference);
236
237 let dep_sigs = enrich_npm_attestations(dep_sigs);
239
240 let repository_posture =
241 crate::posture::collect_repository_posture(client, owner, repo, reference);
242
243 Ok(libverify_core::evidence::EvidenceBundle {
244 dependency_signatures: dep_sigs,
245 repository_posture,
246 check_runs: EvidenceState::not_applicable(),
247 build_platform: EvidenceState::not_applicable(),
248 artifact_attestations: EvidenceState::not_applicable(),
249 ..Default::default()
250 })
251}
252
253pub fn assess_bundle(
262 bundle: &libverify_core::evidence::EvidenceBundle,
263 policy: Option<&str>,
264 extra_controls: Vec<Box<dyn Control>>,
265) -> Result<AssessmentReport> {
266 let mut registry = ControlRegistry::builtin();
267 for c in extra_controls {
268 registry.register(c);
269 }
270 let profile = OpaProfile::from_preset_or_file(policy.unwrap_or("default"))?;
271 Ok(libverify_core::assessment::assess(
272 bundle,
273 registry.controls(),
274 &profile,
275 ))
276}
277
278pub fn assess_repo_bundle(
284 bundle: &libverify_core::evidence::EvidenceBundle,
285 policy: Option<&str>,
286 extra_controls: Vec<Box<dyn Control>>,
287) -> Result<AssessmentReport> {
288 use libverify_core::slsa::SlsaTrack;
289 let policy_str = policy.unwrap_or("default");
290 let dep_level = match policy_str {
291 "slsa-l1" => libverify_core::slsa::SlsaLevel::L1,
292 "slsa-l2" => libverify_core::slsa::SlsaLevel::L2,
293 "slsa-l3" => libverify_core::slsa::SlsaLevel::L3,
294 "slsa-l4" => libverify_core::slsa::SlsaLevel::L4,
295 _ => libverify_core::slsa::SlsaLevel::L4,
296 };
297 let dep_controls =
298 libverify_core::controls::slsa_controls_for_level(SlsaTrack::Dependencies, dep_level);
299 let mut registry = ControlRegistry::new();
300 for control in dep_controls {
301 registry.register(control);
302 }
303 for control in libverify_core::controls::posture_controls() {
304 registry.register(control);
305 }
306 for c in extra_controls {
307 registry.register(c);
308 }
309 let profile = OpaProfile::from_preset_or_file(policy_str)?;
310 Ok(libverify_core::assessment::assess(
311 bundle,
312 registry.controls(),
313 &profile,
314 ))
315}
316
317pub fn verify_pr(
323 client: &GitHubClient,
324 owner: &str,
325 repo: &str,
326 pr_number: u32,
327 policy: Option<&str>,
328 with_evidence: bool,
329 extra_controls: Vec<Box<dyn Control>>,
330) -> Result<VerificationResult> {
331 let bundle = collect_pr_evidence(client, owner, repo, pr_number)?;
332 let report = assess_bundle(&bundle, policy, extra_controls)?;
333 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
334 Ok(VerificationResult::new(report, evidence_bundle))
335}
336
337pub fn verify_pr_batch(
339 client: &GitHubClient,
340 owner: &str,
341 repo: &str,
342 pr_numbers: &[u32],
343 policy: Option<&str>,
344 with_evidence: bool,
345 extra_controls_fn: impl Fn() -> Vec<Box<dyn Control>>,
346) -> Result<BatchReport> {
347 let mut reports = Vec::new();
348 let mut skipped = Vec::new();
349 let mut total_pass = 0usize;
350 let mut total_review = 0usize;
351 let mut total_fail = 0usize;
352 let total = pr_numbers.len();
353
354 let evidence_items = collect_pr_batch_evidence(client, owner, repo, pr_numbers);
355
356 for (i, (subject_id, result)) in evidence_items.into_iter().enumerate() {
357 eprintln!("Verifying {subject_id} ({}/{})", i + 1, total);
358
359 match result.and_then(|bundle| {
360 let report = assess_bundle(&bundle, policy, extra_controls_fn())?;
361 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
362 Ok(VerificationResult::new(report, evidence_bundle))
363 }) {
364 Ok(vr) => {
365 for outcome in &vr.report.outcomes {
366 match outcome.decision {
367 GateDecision::Pass => total_pass += 1,
368 GateDecision::Review => total_review += 1,
369 GateDecision::Fail => total_fail += 1,
370 }
371 }
372 reports.push(BatchEntry {
373 subject_id,
374 result: vr,
375 });
376 }
377 Err(e) => {
378 eprintln!("Warning: skipping {subject_id}: {e:#}");
379 skipped.push(SkippedEntry {
380 subject_id,
381 reason: format!("{e:#}"),
382 });
383 }
384 }
385 }
386
387 Ok(BatchReport {
388 reports,
389 total_pass,
390 total_review,
391 total_fail,
392 skipped,
393 })
394}
395
396#[allow(clippy::too_many_arguments)]
398pub fn verify_release(
399 client: &GitHubClient,
400 owner: &str,
401 repo: &str,
402 base_tag: &str,
403 head_tag: &str,
404 policy: Option<&str>,
405 with_evidence: bool,
406 extra_controls: Vec<Box<dyn Control>>,
407) -> Result<VerificationResult> {
408 let bundle = collect_release_evidence(client, owner, repo, base_tag, head_tag)?;
409 let report = assess_bundle(&bundle, policy, extra_controls)?;
410 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
411 Ok(VerificationResult::new(report, evidence_bundle))
412}
413
414pub fn verify_repo(
416 client: &GitHubClient,
417 owner: &str,
418 repo: &str,
419 reference: &str,
420 policy: Option<&str>,
421 with_evidence: bool,
422 extra_controls: Vec<Box<dyn Control>>,
423) -> Result<VerificationResult> {
424 let bundle = collect_repo_evidence(client, owner, repo, reference)?;
425 let report = assess_repo_bundle(&bundle, policy, extra_controls)?;
426 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
427 Ok(VerificationResult::new(report, evidence_bundle))
428}
429
430pub fn exit_if_assessment_fails(result: &VerificationResult) {
431 if result
432 .report
433 .outcomes
434 .iter()
435 .any(|o| o.decision == GateDecision::Fail)
436 {
437 process::exit(1);
438 }
439}
440
441fn collect_pr_evidence_from_data(
446 client: &GitHubClient,
447 pr_data: &PrData,
448 owner: &str,
449 repo: &str,
450 pr_number: u32,
451 repository_posture: EvidenceState<libverify_core::evidence::RepositoryPosture>,
452) -> Result<libverify_core::evidence::EvidenceBundle> {
453 let repo_full = format!("{owner}/{repo}");
454 let mut bundle = adapter::build_pull_request_bundle(
455 &repo_full,
456 pr_number,
457 &pr_data.metadata,
458 &pr_data.files,
459 &pr_data.reviews,
460 &pr_data.commits,
461 );
462
463 let combined_status = if pr_data.commit_statuses.is_empty() {
464 None
465 } else {
466 Some(CombinedStatusResponse {
467 state: String::new(),
468 statuses: pr_data
469 .commit_statuses
470 .iter()
471 .map(|s| CommitStatusItem {
472 context: s.context.clone(),
473 state: s.state.clone(),
474 })
475 .collect(),
476 })
477 };
478 let evidence = adapter::map_check_runs_evidence(&pr_data.check_runs, combined_status.as_ref());
479 bundle.check_runs = EvidenceState::complete(evidence);
480
481 if let Some(cr_list) = bundle.check_runs.value() {
482 let build_platforms = adapter::map_build_platform_evidence(cr_list);
483 if !build_platforms.is_empty() {
484 bundle.build_platform = EvidenceState::complete(build_platforms);
485 }
486 }
487
488 bundle.repository_posture = repository_posture;
489
490 let changed_files: Vec<String> = pr_data.files.iter().map(|f| f.filename.clone()).collect();
492 let dep_sigs = dependency::collect_pr_dependency_signatures(
493 client,
494 owner,
495 repo,
496 &pr_data.metadata.head.sha,
497 &changed_files,
498 );
499 bundle.dependency_signatures = enrich_npm_attestations(dep_sigs);
500
501 Ok(bundle)
502}
503
504fn enrich_npm_attestations(
507 state: EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>>,
508) -> EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>> {
509 use crate::npm_attestation::NpmAttestationClient;
510 use crate::pypi_attestation::PypiAttestationClient;
511
512 fn enrich(deps: &mut [libverify_core::evidence::DependencySignatureEvidence]) {
513 let has_npm = deps
514 .iter()
515 .any(|d| d.registry.as_deref() == Some("registry.npmjs.org"));
516 let has_pypi = deps
517 .iter()
518 .any(|d| d.registry.as_deref() == Some("pypi.org"));
519
520 if has_npm && let Ok(client) = NpmAttestationClient::new() {
521 client.enrich_npm_deps(deps);
522 }
523 if has_pypi && let Ok(client) = PypiAttestationClient::new() {
524 client.enrich_pypi_deps(deps);
525 }
526 }
527
528 match state {
529 EvidenceState::Complete { mut value } => {
530 enrich(&mut value);
531 EvidenceState::Complete { value }
532 }
533 EvidenceState::Partial { mut value, gaps } => {
534 enrich(&mut value);
535 EvidenceState::Partial { value, gaps }
536 }
537 other => other,
538 }
539}