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_evidence(
81 client: &GitHubClient,
82 owner: &str,
83 repo: &str,
84 base_tag: &str,
85 head_tag: &str,
86) -> Result<libverify_core::evidence::EvidenceBundle> {
87 let commits = crate::release_api::compare_refs(client, owner, repo, base_tag, head_tag)
88 .context("failed to compare refs")?;
89
90 if commits.is_empty() {
91 bail!("no commits found between {base_tag} and {head_tag}");
92 }
93
94 let shas: Vec<&str> = commits.iter().map(|c| c.sha.as_str()).collect();
95 let commit_pr_map =
96 graphql::resolve_commit_prs(client, owner, repo, &shas).unwrap_or_else(|err| {
97 eprintln!("Warning: failed to resolve commit PRs via GraphQL: {err}");
98 std::collections::HashMap::new()
99 });
100
101 let commit_prs: Vec<_> = commits
102 .iter()
103 .map(|c| adapter::GitHubCommitPullAssociation {
104 commit_sha: c.sha.clone(),
105 pull_requests: commit_pr_map.get(&c.sha).cloned().unwrap_or_default(),
106 })
107 .collect();
108
109 let release_assets = crate::release_api::get_release_assets(client, owner, repo, head_tag)
111 .unwrap_or_else(|err| {
112 eprintln!("Warning: failed to fetch release assets: {err}");
113 vec![]
114 });
115
116 let artifact_attestations =
117 crate::attestation::collect_release_attestations(owner, repo, head_tag, &release_assets);
118
119 let unique_pr_numbers: Vec<u32> = {
121 let mut seen = std::collections::HashSet::new();
122 commit_pr_map
123 .values()
124 .flatten()
125 .filter(|pr| seen.insert(pr.number))
126 .map(|pr| pr.number)
127 .collect()
128 };
129
130 let repo_full = format!("{owner}/{repo}");
131
132 let mut change_requests = Vec::new();
133 let mut all_check_runs = Vec::new();
134
135 if !unique_pr_numbers.is_empty() {
136 for (pr_number, result) in graphql::fetch_prs(client, owner, repo, &unique_pr_numbers) {
137 match result {
138 Ok(pr_data) => {
139 change_requests.push(adapter::map_pull_request_evidence(
140 &repo_full,
141 pr_number,
142 &pr_data.metadata,
143 &pr_data.files,
144 &pr_data.reviews,
145 &pr_data.commits,
146 ));
147 all_check_runs.extend(pr_data.check_runs);
148 }
149 Err(e) => {
150 eprintln!(
151 "Warning: failed to fetch PR #{pr_number} for release verification: {e:#}"
152 );
153 }
154 }
155 }
156 }
157
158 let mut bundle = adapter::build_release_bundle(
159 &repo_full,
160 base_tag,
161 head_tag,
162 &commits,
163 &commit_prs,
164 artifact_attestations,
165 );
166 bundle.change_requests = change_requests;
167 bundle.repository_posture =
168 crate::posture::collect_repository_posture(client, owner, repo, head_tag);
169
170 let check_run_evidence = adapter::map_check_runs_evidence(&all_check_runs, None);
172 bundle.check_runs = EvidenceState::complete(check_run_evidence);
173 if let Some(cr_list) = bundle.check_runs.value() {
174 let build_platforms = adapter::map_build_platform_evidence(cr_list);
175 if !build_platforms.is_empty() {
176 bundle.build_platform = EvidenceState::complete(build_platforms);
177 }
178 }
179
180 Ok(bundle)
181}
182
183pub fn collect_repo_evidence(
185 client: &GitHubClient,
186 owner: &str,
187 repo: &str,
188 reference: &str,
189) -> Result<libverify_core::evidence::EvidenceBundle> {
190 let dep_sigs = dependency::collect_repo_dependency_signatures(client, owner, repo, reference);
191
192 let dep_sigs = enrich_npm_attestations(dep_sigs);
194
195 let repository_posture =
196 crate::posture::collect_repository_posture(client, owner, repo, reference);
197
198 Ok(libverify_core::evidence::EvidenceBundle {
199 dependency_signatures: dep_sigs,
200 repository_posture,
201 check_runs: EvidenceState::not_applicable(),
202 build_platform: EvidenceState::not_applicable(),
203 artifact_attestations: EvidenceState::not_applicable(),
204 ..Default::default()
205 })
206}
207
208pub fn assess_bundle(
217 bundle: &libverify_core::evidence::EvidenceBundle,
218 policy: Option<&str>,
219 extra_controls: Vec<Box<dyn Control>>,
220) -> Result<AssessmentReport> {
221 let mut registry = ControlRegistry::builtin();
222 for c in extra_controls {
223 registry.register(c);
224 }
225 let profile = OpaProfile::from_preset_or_file(policy.unwrap_or("default"))?;
226 Ok(libverify_core::assessment::assess(
227 bundle,
228 registry.controls(),
229 &profile,
230 ))
231}
232
233pub fn assess_repo_bundle(
239 bundle: &libverify_core::evidence::EvidenceBundle,
240 policy: Option<&str>,
241 extra_controls: Vec<Box<dyn Control>>,
242) -> Result<AssessmentReport> {
243 use libverify_core::slsa::SlsaTrack;
244 let policy_str = policy.unwrap_or("default");
245 let dep_level = match policy_str {
246 "slsa-l1" => libverify_core::slsa::SlsaLevel::L1,
247 "slsa-l2" => libverify_core::slsa::SlsaLevel::L2,
248 "slsa-l3" => libverify_core::slsa::SlsaLevel::L3,
249 "slsa-l4" => libverify_core::slsa::SlsaLevel::L4,
250 _ => libverify_core::slsa::SlsaLevel::L4,
251 };
252 let dep_controls =
253 libverify_core::controls::slsa_controls_for_level(SlsaTrack::Dependencies, dep_level);
254 let mut registry = ControlRegistry::new();
255 for control in dep_controls {
256 registry.register(control);
257 }
258 for control in libverify_core::controls::posture_controls() {
259 registry.register(control);
260 }
261 for c in extra_controls {
262 registry.register(c);
263 }
264 let profile = OpaProfile::from_preset_or_file(policy_str)?;
265 Ok(libverify_core::assessment::assess(
266 bundle,
267 registry.controls(),
268 &profile,
269 ))
270}
271
272pub fn verify_pr(
278 client: &GitHubClient,
279 owner: &str,
280 repo: &str,
281 pr_number: u32,
282 policy: Option<&str>,
283 with_evidence: bool,
284 extra_controls: Vec<Box<dyn Control>>,
285) -> Result<VerificationResult> {
286 let bundle = collect_pr_evidence(client, owner, repo, pr_number)?;
287 let report = assess_bundle(&bundle, policy, extra_controls)?;
288 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
289 Ok(VerificationResult::new(report, evidence_bundle))
290}
291
292pub fn verify_pr_batch(
294 client: &GitHubClient,
295 owner: &str,
296 repo: &str,
297 pr_numbers: &[u32],
298 policy: Option<&str>,
299 with_evidence: bool,
300 extra_controls_fn: impl Fn() -> Vec<Box<dyn Control>>,
301) -> Result<BatchReport> {
302 let mut reports = Vec::new();
303 let mut skipped = Vec::new();
304 let mut total_pass = 0usize;
305 let mut total_review = 0usize;
306 let mut total_fail = 0usize;
307 let total = pr_numbers.len();
308
309 let evidence_items = collect_pr_batch_evidence(client, owner, repo, pr_numbers);
310
311 for (i, (subject_id, result)) in evidence_items.into_iter().enumerate() {
312 eprintln!("Verifying {subject_id} ({}/{})", i + 1, total);
313
314 match result.and_then(|bundle| {
315 let report = assess_bundle(&bundle, policy, extra_controls_fn())?;
316 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
317 Ok(VerificationResult::new(report, evidence_bundle))
318 }) {
319 Ok(vr) => {
320 for outcome in &vr.report.outcomes {
321 match outcome.decision {
322 GateDecision::Pass => total_pass += 1,
323 GateDecision::Review => total_review += 1,
324 GateDecision::Fail => total_fail += 1,
325 }
326 }
327 reports.push(BatchEntry {
328 subject_id,
329 result: vr,
330 });
331 }
332 Err(e) => {
333 eprintln!("Warning: skipping {subject_id}: {e:#}");
334 skipped.push(SkippedEntry {
335 subject_id,
336 reason: format!("{e:#}"),
337 });
338 }
339 }
340 }
341
342 Ok(BatchReport {
343 reports,
344 total_pass,
345 total_review,
346 total_fail,
347 skipped,
348 })
349}
350
351pub fn verify_release(
353 client: &GitHubClient,
354 owner: &str,
355 repo: &str,
356 base_tag: &str,
357 head_tag: &str,
358 policy: Option<&str>,
359 with_evidence: bool,
360 extra_controls: Vec<Box<dyn Control>>,
361) -> Result<VerificationResult> {
362 let bundle = collect_release_evidence(client, owner, repo, base_tag, head_tag)?;
363 let report = assess_bundle(&bundle, policy, extra_controls)?;
364 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
365 Ok(VerificationResult::new(report, evidence_bundle))
366}
367
368pub fn verify_repo(
370 client: &GitHubClient,
371 owner: &str,
372 repo: &str,
373 reference: &str,
374 policy: Option<&str>,
375 with_evidence: bool,
376 extra_controls: Vec<Box<dyn Control>>,
377) -> Result<VerificationResult> {
378 let bundle = collect_repo_evidence(client, owner, repo, reference)?;
379 let report = assess_repo_bundle(&bundle, policy, extra_controls)?;
380 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
381 Ok(VerificationResult::new(report, evidence_bundle))
382}
383
384pub fn exit_if_assessment_fails(result: &VerificationResult) {
385 if result
386 .report
387 .outcomes
388 .iter()
389 .any(|o| o.decision == GateDecision::Fail)
390 {
391 process::exit(1);
392 }
393}
394
395fn collect_pr_evidence_from_data(
400 client: &GitHubClient,
401 pr_data: &PrData,
402 owner: &str,
403 repo: &str,
404 pr_number: u32,
405 repository_posture: EvidenceState<libverify_core::evidence::RepositoryPosture>,
406) -> Result<libverify_core::evidence::EvidenceBundle> {
407 let repo_full = format!("{owner}/{repo}");
408 let mut bundle = adapter::build_pull_request_bundle(
409 &repo_full,
410 pr_number,
411 &pr_data.metadata,
412 &pr_data.files,
413 &pr_data.reviews,
414 &pr_data.commits,
415 );
416
417 let combined_status = if pr_data.commit_statuses.is_empty() {
418 None
419 } else {
420 Some(CombinedStatusResponse {
421 state: String::new(),
422 statuses: pr_data
423 .commit_statuses
424 .iter()
425 .map(|s| CommitStatusItem {
426 context: s.context.clone(),
427 state: s.state.clone(),
428 })
429 .collect(),
430 })
431 };
432 let evidence = adapter::map_check_runs_evidence(&pr_data.check_runs, combined_status.as_ref());
433 bundle.check_runs = EvidenceState::complete(evidence);
434
435 if let Some(cr_list) = bundle.check_runs.value() {
436 let build_platforms = adapter::map_build_platform_evidence(cr_list);
437 if !build_platforms.is_empty() {
438 bundle.build_platform = EvidenceState::complete(build_platforms);
439 }
440 }
441
442 bundle.repository_posture = repository_posture;
443
444 let changed_files: Vec<String> = pr_data.files.iter().map(|f| f.filename.clone()).collect();
446 let dep_sigs = dependency::collect_pr_dependency_signatures(
447 client,
448 owner,
449 repo,
450 &pr_data.metadata.head.sha,
451 &changed_files,
452 );
453 bundle.dependency_signatures = enrich_npm_attestations(dep_sigs);
454
455 Ok(bundle)
456}
457
458fn enrich_npm_attestations(
461 state: EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>>,
462) -> EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>> {
463 use crate::npm_attestation::NpmAttestationClient;
464 use crate::pypi_attestation::PypiAttestationClient;
465
466 fn enrich(deps: &mut [libverify_core::evidence::DependencySignatureEvidence]) {
467 let has_npm = deps
468 .iter()
469 .any(|d| d.registry.as_deref() == Some("registry.npmjs.org"));
470 let has_pypi = deps
471 .iter()
472 .any(|d| d.registry.as_deref() == Some("pypi.org"));
473
474 if has_npm && let Ok(client) = NpmAttestationClient::new() {
475 client.enrich_npm_deps(deps);
476 }
477 if has_pypi && let Ok(client) = PypiAttestationClient::new() {
478 client.enrich_pypi_deps(deps);
479 }
480 }
481
482 match state {
483 EvidenceState::Complete { mut value } => {
484 enrich(&mut value);
485 EvidenceState::Complete { value }
486 }
487 EvidenceState::Partial { mut value, gaps } => {
488 enrich(&mut value);
489 EvidenceState::Partial { value, gaps }
490 }
491 other => other,
492 }
493}