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