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