1use std::process;
2
3use anyhow::{Context, Result, bail};
4
5use libverify_core::adapter::PlatformAdapter;
6use libverify_core::assessment::{
7 AssessmentReport, BatchEntry, BatchReport, SkippedEntry, VerificationResult,
8};
9use libverify_core::control::Control;
10use libverify_core::evidence::EvidenceState;
11use libverify_core::profile::GateDecision;
12use libverify_core::registry::ControlRegistry;
13use libverify_policy::OpaProfile;
14
15use crate::adapter;
16use crate::client::GitHubClient;
17use crate::dependency;
18use crate::graphql::{self, PrData};
19use crate::types::{CombinedStatusResponse, CommitStatusItem};
20
21pub struct GitHubAdapter<'a> {
31 client: &'a GitHubClient,
32}
33
34impl<'a> GitHubAdapter<'a> {
35 pub fn new(client: &'a GitHubClient) -> Self {
36 Self { client }
37 }
38
39 pub fn client(&self) -> &GitHubClient {
42 self.client
43 }
44}
45
46impl PlatformAdapter for GitHubAdapter<'_> {
47 fn collect_pr_evidence(
48 &self,
49 owner: &str,
50 repo: &str,
51 pr_number: u32,
52 ) -> libverify_core::adapter::AdapterResult<libverify_core::evidence::EvidenceBundle> {
53 collect_pr_evidence(self.client, owner, repo, pr_number).map_err(Into::into)
54 }
55
56 fn collect_pr_batch_evidence(
57 &self,
58 owner: &str,
59 repo: &str,
60 pr_numbers: &[u32],
61 ) -> Vec<(
62 String,
63 libverify_core::adapter::AdapterResult<libverify_core::evidence::EvidenceBundle>,
64 )> {
65 collect_pr_batch_evidence(self.client, owner, repo, pr_numbers)
66 .into_iter()
67 .map(|(id, r)| (id, r.map_err(Into::into)))
68 .collect()
69 }
70
71 fn collect_release_evidence(
72 &self,
73 owner: &str,
74 repo: &str,
75 base_tag: &str,
76 head_tag: &str,
77 ) -> libverify_core::adapter::AdapterResult<libverify_core::evidence::EvidenceBundle> {
78 collect_release_evidence(self.client, owner, repo, base_tag, head_tag).map_err(Into::into)
79 }
80
81 fn collect_repo_evidence(
82 &self,
83 owner: &str,
84 repo: &str,
85 reference: &str,
86 ) -> libverify_core::adapter::AdapterResult<libverify_core::evidence::EvidenceBundle> {
87 collect_repo_evidence(self.client, owner, repo, reference).map_err(Into::into)
88 }
89}
90
91pub fn collect_pr_evidence(
100 client: &GitHubClient,
101 owner: &str,
102 repo: &str,
103 pr_number: u32,
104) -> Result<libverify_core::evidence::EvidenceBundle> {
105 let pr_data =
106 graphql::fetch_pr(client, owner, repo, pr_number).context("failed to fetch PR data")?;
107 let posture =
108 crate::posture::collect_repository_posture(client, owner, repo, &pr_data.metadata.head.sha);
109 collect_pr_evidence_from_data(client, &pr_data, owner, repo, pr_number, posture)
110}
111
112pub fn collect_pr_batch_evidence(
117 client: &GitHubClient,
118 owner: &str,
119 repo: &str,
120 pr_numbers: &[u32],
121) -> Vec<(String, Result<libverify_core::evidence::EvidenceBundle>)> {
122 let all_data = graphql::fetch_prs(client, owner, repo, pr_numbers);
123
124 let head_sha = all_data
126 .iter()
127 .find_map(|(_, r)| r.as_ref().ok().map(|d| d.metadata.head.sha.as_str()))
128 .unwrap_or("HEAD");
129 let posture = crate::posture::collect_repository_posture(client, owner, repo, head_sha);
130
131 all_data
132 .into_iter()
133 .map(|(pr_number, result)| {
134 let subject_id = format!("#{pr_number}");
135 let bundle = result.and_then(|pr_data| {
136 collect_pr_evidence_from_data(
137 client,
138 &pr_data,
139 owner,
140 repo,
141 pr_number,
142 posture.clone(),
143 )
144 });
145 (subject_id, bundle)
146 })
147 .collect()
148}
149
150pub fn collect_release_pr_evidence(
158 client: &GitHubClient,
159 owner: &str,
160 repo: &str,
161 base_tag: &str,
162 head_tag: &str,
163) -> Result<libverify_core::evidence::EvidenceBundle> {
164 let commits = crate::release_api::compare_refs(client, owner, repo, base_tag, head_tag)
165 .context("failed to compare refs")?;
166
167 if commits.is_empty() {
168 bail!("no commits found between {base_tag} and {head_tag}");
169 }
170
171 let shas: Vec<&str> = commits.iter().map(|c| c.sha.as_str()).collect();
172 let commit_pr_map =
173 graphql::resolve_commit_prs(client, owner, repo, &shas).unwrap_or_else(|err| {
174 eprintln!("Warning: failed to resolve commit PRs via GraphQL: {err}");
175 std::collections::HashMap::new()
176 });
177
178 let commit_prs: Vec<_> = commits
179 .iter()
180 .map(|c| adapter::GitHubCommitPullAssociation {
181 commit_sha: c.sha.clone(),
182 pull_requests: commit_pr_map.get(&c.sha).cloned().unwrap_or_default(),
183 })
184 .collect();
185
186 let unique_pr_numbers: Vec<u32> = {
187 let mut seen = std::collections::HashSet::new();
188 commit_pr_map
189 .values()
190 .flatten()
191 .filter(|pr| seen.insert(pr.number))
192 .map(|pr| pr.number)
193 .collect()
194 };
195
196 let repo_full = format!("{owner}/{repo}");
197 let mut change_requests = Vec::new();
198 let mut all_check_runs = Vec::new();
199
200 if !unique_pr_numbers.is_empty() {
201 for (pr_number, result) in graphql::fetch_prs(client, owner, repo, &unique_pr_numbers) {
202 match result {
203 Ok(pr_data) => {
204 change_requests.push(adapter::map_pull_request_evidence(
205 &repo_full,
206 pr_number,
207 &pr_data.metadata,
208 &pr_data.files,
209 &pr_data.reviews,
210 &pr_data.commits,
211 ));
212 all_check_runs.extend(pr_data.check_runs);
213 }
214 Err(e) => {
215 eprintln!(
216 "Warning: failed to fetch PR #{pr_number} for release verification: {e:#}"
217 );
218 }
219 }
220 }
221 }
222
223 let mut bundle = adapter::build_release_bundle(
224 &repo_full,
225 base_tag,
226 head_tag,
227 &commits,
228 &commit_prs,
229 EvidenceState::default(),
230 );
231 bundle.change_requests = change_requests;
232
233 let check_run_evidence = adapter::map_check_runs_evidence(&all_check_runs, None);
234 bundle.check_runs = EvidenceState::complete(check_run_evidence);
235 if let Some(cr_list) = bundle.check_runs.value() {
236 let build_platforms = adapter::map_build_platform_evidence(cr_list);
237 if !build_platforms.is_empty() {
238 bundle.build_platform = EvidenceState::complete(build_platforms);
239 }
240 }
241
242 Ok(bundle)
243}
244
245pub fn collect_release_repo_evidence(
250 client: &GitHubClient,
251 owner: &str,
252 repo: &str,
253 head_tag: &str,
254 bundle: &mut libverify_core::evidence::EvidenceBundle,
255) {
256 bundle.repository_posture =
257 crate::posture::collect_repository_posture(client, owner, repo, head_tag);
258}
259
260pub fn collect_release_attestation_evidence(
266 client: &GitHubClient,
267 owner: &str,
268 repo: &str,
269 head_tag: &str,
270 bundle: &mut libverify_core::evidence::EvidenceBundle,
271) {
272 let release_assets = crate::release_api::get_release_assets(client, owner, repo, head_tag)
273 .unwrap_or_else(|err| {
274 eprintln!("Warning: failed to fetch release assets: {err}");
275 vec![]
276 });
277
278 bundle.artifact_attestations = crate::attestation::collect_release_attestations(
279 owner,
280 repo,
281 head_tag,
282 &release_assets,
283 client,
284 );
285}
286
287pub fn collect_release_evidence(
292 client: &GitHubClient,
293 owner: &str,
294 repo: &str,
295 base_tag: &str,
296 head_tag: &str,
297) -> Result<libverify_core::evidence::EvidenceBundle> {
298 let mut bundle = collect_release_pr_evidence(client, owner, repo, base_tag, head_tag)?;
299 collect_release_repo_evidence(client, owner, repo, head_tag, &mut bundle);
300 collect_release_attestation_evidence(client, owner, repo, head_tag, &mut bundle);
301 Ok(bundle)
302}
303
304pub fn collect_repo_evidence(
306 client: &GitHubClient,
307 owner: &str,
308 repo: &str,
309 reference: &str,
310) -> Result<libverify_core::evidence::EvidenceBundle> {
311 let dep_sigs = dependency::collect_repo_dependency_signatures(client, owner, repo, reference);
312
313 let dep_sigs = enrich_npm_attestations(dep_sigs);
315
316 let repository_posture =
317 crate::posture::collect_repository_posture(client, owner, repo, reference);
318
319 Ok(libverify_core::evidence::EvidenceBundle {
320 dependency_signatures: dep_sigs,
321 repository_posture,
322 check_runs: EvidenceState::not_applicable(),
323 build_platform: EvidenceState::not_applicable(),
324 artifact_attestations: EvidenceState::not_applicable(),
325 ..Default::default()
326 })
327}
328
329pub fn assess_bundle(
338 bundle: &libverify_core::evidence::EvidenceBundle,
339 policy: Option<&str>,
340 extra_controls: Vec<Box<dyn Control>>,
341) -> Result<AssessmentReport> {
342 let mut registry = ControlRegistry::builtin();
343 for c in extra_controls {
344 registry.register(c);
345 }
346 let profile = OpaProfile::from_preset_or_file(policy.unwrap_or("default"))?;
347 Ok(libverify_core::assessment::assess(
348 bundle,
349 registry.controls(),
350 &profile,
351 ))
352}
353
354pub fn assess_repo_bundle(
360 bundle: &libverify_core::evidence::EvidenceBundle,
361 policy: Option<&str>,
362 extra_controls: Vec<Box<dyn Control>>,
363) -> Result<AssessmentReport> {
364 use libverify_core::slsa::SlsaTrack;
365 let policy_str = policy.unwrap_or("default");
366 let dep_level = match policy_str {
367 "slsa-l1" => libverify_core::slsa::SlsaLevel::L1,
368 "slsa-l2" => libverify_core::slsa::SlsaLevel::L2,
369 "slsa-l3" => libverify_core::slsa::SlsaLevel::L3,
370 "slsa-l4" => libverify_core::slsa::SlsaLevel::L4,
371 _ => libverify_core::slsa::SlsaLevel::L4,
372 };
373 let dep_controls =
374 libverify_core::controls::slsa_controls_for_level(SlsaTrack::Dependencies, dep_level);
375 let mut registry = ControlRegistry::new();
376 for control in dep_controls {
377 registry.register(control);
378 }
379 for control in libverify_core::controls::posture_controls() {
380 registry.register(control);
381 }
382 for c in extra_controls {
383 registry.register(c);
384 }
385 let profile = OpaProfile::from_preset_or_file(policy_str)?;
386 Ok(libverify_core::assessment::assess(
387 bundle,
388 registry.controls(),
389 &profile,
390 ))
391}
392
393pub fn verify_pr(
399 client: &GitHubClient,
400 owner: &str,
401 repo: &str,
402 pr_number: u32,
403 policy: Option<&str>,
404 with_evidence: bool,
405 extra_controls: Vec<Box<dyn Control>>,
406) -> Result<VerificationResult> {
407 let bundle = collect_pr_evidence(client, owner, repo, pr_number)?;
408 let report = assess_bundle(&bundle, policy, extra_controls)?;
409 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
410 Ok(VerificationResult::new(report, evidence_bundle))
411}
412
413pub fn verify_pr_batch(
415 client: &GitHubClient,
416 owner: &str,
417 repo: &str,
418 pr_numbers: &[u32],
419 policy: Option<&str>,
420 with_evidence: bool,
421 extra_controls_fn: impl Fn() -> Vec<Box<dyn Control>>,
422) -> Result<BatchReport> {
423 let mut reports = Vec::new();
424 let mut skipped = Vec::new();
425 let mut total_pass = 0usize;
426 let mut total_review = 0usize;
427 let mut total_fail = 0usize;
428 let total = pr_numbers.len();
429
430 let evidence_items = collect_pr_batch_evidence(client, owner, repo, pr_numbers);
431
432 for (i, (subject_id, result)) in evidence_items.into_iter().enumerate() {
433 eprintln!("Verifying {subject_id} ({}/{})", i + 1, total);
434
435 match result.and_then(|bundle| {
436 let report = assess_bundle(&bundle, policy, extra_controls_fn())?;
437 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
438 Ok(VerificationResult::new(report, evidence_bundle))
439 }) {
440 Ok(vr) => {
441 for outcome in &vr.report.outcomes {
442 match outcome.decision {
443 GateDecision::Pass => total_pass += 1,
444 GateDecision::Review => total_review += 1,
445 GateDecision::Fail => total_fail += 1,
446 }
447 }
448 reports.push(BatchEntry {
449 subject_id,
450 result: vr,
451 });
452 }
453 Err(e) => {
454 eprintln!("Warning: skipping {subject_id}: {e:#}");
455 skipped.push(SkippedEntry {
456 subject_id,
457 reason: format!("{e:#}"),
458 });
459 }
460 }
461 }
462
463 Ok(BatchReport {
464 reports,
465 total_pass,
466 total_review,
467 total_fail,
468 skipped,
469 })
470}
471
472#[allow(clippy::too_many_arguments)]
474pub fn verify_release(
475 client: &GitHubClient,
476 owner: &str,
477 repo: &str,
478 base_tag: &str,
479 head_tag: &str,
480 policy: Option<&str>,
481 with_evidence: bool,
482 extra_controls: Vec<Box<dyn Control>>,
483) -> Result<VerificationResult> {
484 let bundle = collect_release_evidence(client, owner, repo, base_tag, head_tag)?;
485 let report = assess_bundle(&bundle, policy, extra_controls)?;
486 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
487 Ok(VerificationResult::new(report, evidence_bundle))
488}
489
490pub fn verify_repo(
492 client: &GitHubClient,
493 owner: &str,
494 repo: &str,
495 reference: &str,
496 policy: Option<&str>,
497 with_evidence: bool,
498 extra_controls: Vec<Box<dyn Control>>,
499) -> Result<VerificationResult> {
500 let bundle = collect_repo_evidence(client, owner, repo, reference)?;
501 let report = assess_repo_bundle(&bundle, policy, extra_controls)?;
502 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
503 Ok(VerificationResult::new(report, evidence_bundle))
504}
505
506pub fn exit_if_assessment_fails(result: &VerificationResult) {
507 if result
508 .report
509 .outcomes
510 .iter()
511 .any(|o| o.decision == GateDecision::Fail)
512 {
513 process::exit(1);
514 }
515}
516
517fn collect_pr_evidence_from_data(
522 client: &GitHubClient,
523 pr_data: &PrData,
524 owner: &str,
525 repo: &str,
526 pr_number: u32,
527 repository_posture: EvidenceState<libverify_core::evidence::RepositoryPosture>,
528) -> Result<libverify_core::evidence::EvidenceBundle> {
529 let repo_full = format!("{owner}/{repo}");
530 let mut bundle = adapter::build_pull_request_bundle(
531 &repo_full,
532 pr_number,
533 &pr_data.metadata,
534 &pr_data.files,
535 &pr_data.reviews,
536 &pr_data.commits,
537 );
538
539 let combined_status = if pr_data.commit_statuses.is_empty() {
540 None
541 } else {
542 Some(CombinedStatusResponse {
543 state: String::new(),
544 statuses: pr_data
545 .commit_statuses
546 .iter()
547 .map(|s| CommitStatusItem {
548 context: s.context.clone(),
549 state: s.state.clone(),
550 })
551 .collect(),
552 })
553 };
554 let evidence = adapter::map_check_runs_evidence(&pr_data.check_runs, combined_status.as_ref());
555 bundle.check_runs = EvidenceState::complete(evidence);
556
557 if let Some(cr_list) = bundle.check_runs.value() {
558 let build_platforms = adapter::map_build_platform_evidence(cr_list);
559 if !build_platforms.is_empty() {
560 bundle.build_platform = EvidenceState::complete(build_platforms);
561 }
562 }
563
564 bundle.repository_posture = repository_posture;
565
566 let changed_files: Vec<String> = pr_data.files.iter().map(|f| f.filename.clone()).collect();
568 let dep_sigs = dependency::collect_pr_dependency_signatures(
569 client,
570 owner,
571 repo,
572 &pr_data.metadata.head.sha,
573 &changed_files,
574 );
575 bundle.dependency_signatures = enrich_npm_attestations(dep_sigs);
576
577 Ok(bundle)
578}
579
580fn enrich_npm_attestations(
583 state: EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>>,
584) -> EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>> {
585 use crate::npm_attestation::NpmAttestationClient;
586 use crate::pypi_attestation::PypiAttestationClient;
587
588 fn enrich(deps: &mut [libverify_core::evidence::DependencySignatureEvidence]) {
589 let has_npm = deps
590 .iter()
591 .any(|d| d.registry.as_deref() == Some("registry.npmjs.org"));
592 let has_pypi = deps
593 .iter()
594 .any(|d| d.registry.as_deref() == Some("pypi.org"));
595
596 if has_npm && let Ok(client) = NpmAttestationClient::new() {
597 client.enrich_npm_deps(deps);
598 }
599 if has_pypi && let Ok(client) = PypiAttestationClient::new() {
600 client.enrich_pypi_deps(deps);
601 }
602 }
603
604 match state {
605 EvidenceState::Complete { mut value } => {
606 enrich(&mut value);
607 EvidenceState::Complete { value }
608 }
609 EvidenceState::Partial { mut value, gaps } => {
610 enrich(&mut value);
611 EvidenceState::Partial { value, gaps }
612 }
613 other => other,
614 }
615}