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 verify_pr(
21 client: &GitHubClient,
22 owner: &str,
23 repo: &str,
24 pr_number: u32,
25 policy: Option<&str>,
26 with_evidence: bool,
27) -> Result<VerificationResult> {
28 let pr_data =
29 graphql::fetch_pr(client, owner, repo, pr_number).context("failed to fetch PR data")?;
30 assess_from_pr_data(
31 client,
32 &pr_data,
33 owner,
34 repo,
35 pr_number,
36 policy,
37 with_evidence,
38 )
39}
40
41fn assess_from_pr_data(
42 client: &GitHubClient,
43 pr_data: &PrData,
44 owner: &str,
45 repo: &str,
46 pr_number: u32,
47 policy: Option<&str>,
48 with_evidence: bool,
49) -> Result<VerificationResult> {
50 let repo_full = format!("{owner}/{repo}");
51 let mut bundle = adapter::build_pull_request_bundle(
52 &repo_full,
53 pr_number,
54 &pr_data.metadata,
55 &pr_data.files,
56 &pr_data.reviews,
57 &pr_data.commits,
58 );
59
60 let combined_status = if pr_data.commit_statuses.is_empty() {
61 None
62 } else {
63 Some(CombinedStatusResponse {
64 state: String::new(),
65 statuses: pr_data
66 .commit_statuses
67 .iter()
68 .map(|s| CommitStatusItem {
69 context: s.context.clone(),
70 state: s.state.clone(),
71 })
72 .collect(),
73 })
74 };
75 let evidence = adapter::map_check_runs_evidence(&pr_data.check_runs, combined_status.as_ref());
76 bundle.check_runs = EvidenceState::complete(evidence);
77
78 if let Some(cr_list) = bundle.check_runs.value() {
79 let build_platforms = adapter::map_build_platform_evidence(cr_list);
80 if !build_platforms.is_empty() {
81 bundle.build_platform = EvidenceState::complete(build_platforms);
82 }
83 }
84
85 let changed_files: Vec<String> = pr_data.files.iter().map(|f| f.filename.clone()).collect();
87 let dep_sigs = dependency::collect_pr_dependency_signatures(
88 client,
89 owner,
90 repo,
91 &pr_data.metadata.head.sha,
92 &changed_files,
93 );
94 bundle.dependency_signatures = enrich_npm_attestations(dep_sigs);
95
96 let report = assess_bundle(&bundle, policy)?;
97 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
98 Ok(VerificationResult::new(report, evidence_bundle))
99}
100
101pub fn verify_pr_batch(
103 client: &GitHubClient,
104 owner: &str,
105 repo: &str,
106 pr_numbers: &[u32],
107 policy: Option<&str>,
108 with_evidence: bool,
109) -> Result<BatchReport> {
110 let mut reports = Vec::new();
111 let mut skipped = Vec::new();
112 let mut total_pass = 0usize;
113 let mut total_review = 0usize;
114 let mut total_fail = 0usize;
115 let total = pr_numbers.len();
116
117 let all_data = graphql::fetch_prs(client, owner, repo, pr_numbers);
118
119 for (i, (pr_number, result)) in all_data.into_iter().enumerate() {
120 eprintln!("Verifying PR #{pr_number} ({}/{})", i + 1, total);
121
122 match result.and_then(|pr_data| {
123 assess_from_pr_data(
124 client,
125 &pr_data,
126 owner,
127 repo,
128 pr_number,
129 policy,
130 with_evidence,
131 )
132 }) {
133 Ok(vr) => {
134 for outcome in &vr.report.outcomes {
135 match outcome.decision {
136 GateDecision::Pass => total_pass += 1,
137 GateDecision::Review => total_review += 1,
138 GateDecision::Fail => total_fail += 1,
139 }
140 }
141 reports.push(BatchEntry {
142 subject_id: format!("#{pr_number}"),
143 result: vr,
144 });
145 }
146 Err(e) => {
147 eprintln!("Warning: skipping PR #{pr_number}: {e:#}");
148 skipped.push(SkippedEntry {
149 subject_id: format!("#{pr_number}"),
150 reason: format!("{e:#}"),
151 });
152 }
153 }
154 }
155
156 Ok(BatchReport {
157 reports,
158 total_pass,
159 total_review,
160 total_fail,
161 skipped,
162 })
163}
164
165pub fn verify_release(
170 client: &GitHubClient,
171 owner: &str,
172 repo: &str,
173 base_tag: &str,
174 head_tag: &str,
175 policy: Option<&str>,
176 with_evidence: bool,
177) -> Result<VerificationResult> {
178 let commits = crate::release_api::compare_refs(client, owner, repo, base_tag, head_tag)
179 .context("failed to compare refs")?;
180
181 if commits.is_empty() {
182 bail!("no commits found between {base_tag} and {head_tag}");
183 }
184
185 let shas: Vec<&str> = commits.iter().map(|c| c.sha.as_str()).collect();
186 let commit_pr_map =
187 graphql::resolve_commit_prs(client, owner, repo, &shas).unwrap_or_else(|err| {
188 eprintln!("Warning: failed to resolve commit PRs via GraphQL: {err}");
189 std::collections::HashMap::new()
190 });
191
192 let commit_prs: Vec<_> = commits
193 .iter()
194 .map(|c| adapter::GitHubCommitPullAssociation {
195 commit_sha: c.sha.clone(),
196 pull_requests: commit_pr_map.get(&c.sha).cloned().unwrap_or_default(),
197 })
198 .collect();
199
200 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 let artifact_attestations =
208 crate::attestation::collect_release_attestations(owner, repo, head_tag, &release_assets);
209
210 let repo_full = format!("{owner}/{repo}");
211 let mut bundle = adapter::build_release_bundle(
212 &repo_full,
213 base_tag,
214 head_tag,
215 &commits,
216 &commit_prs,
217 artifact_attestations,
218 );
219 bundle.check_runs = EvidenceState::not_applicable();
221
222 let report = assess_bundle(&bundle, policy)?;
223 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
224 Ok(VerificationResult::new(report, evidence_bundle))
225}
226
227pub fn verify_repo(
235 client: &GitHubClient,
236 owner: &str,
237 repo: &str,
238 reference: &str,
239 policy: Option<&str>,
240 with_evidence: bool,
241) -> Result<VerificationResult> {
242 let dep_sigs = dependency::collect_repo_dependency_signatures(client, owner, repo, reference);
243
244 let dep_sigs = enrich_npm_attestations(dep_sigs);
246
247 let bundle = libverify_core::evidence::EvidenceBundle {
248 dependency_signatures: dep_sigs,
249 check_runs: EvidenceState::not_applicable(),
250 build_platform: EvidenceState::not_applicable(),
251 artifact_attestations: EvidenceState::not_applicable(),
252 ..Default::default()
253 };
254
255 use libverify_core::slsa::SlsaTrack;
257 let policy_str = policy.unwrap_or("default");
258 let dep_level = match policy_str {
259 "slsa-l1" => libverify_core::slsa::SlsaLevel::L1,
260 "slsa-l2" => libverify_core::slsa::SlsaLevel::L2,
261 "slsa-l3" => libverify_core::slsa::SlsaLevel::L3,
262 "slsa-l4" => libverify_core::slsa::SlsaLevel::L4,
263 _ => libverify_core::slsa::SlsaLevel::L4, };
265 let dep_controls =
266 libverify_core::controls::slsa_controls_for_level(SlsaTrack::Dependencies, dep_level);
267 let mut registry = ControlRegistry::new();
268 for control in dep_controls {
269 registry.register(control);
270 }
271 let profile = OpaProfile::from_preset_or_file(policy_str)?;
272 let report = libverify_core::assessment::assess(&bundle, registry.controls(), &profile);
273 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
274 Ok(VerificationResult::new(report, evidence_bundle))
275}
276
277pub fn assess_bundle(
278 bundle: &libverify_core::evidence::EvidenceBundle,
279 policy: Option<&str>,
280) -> Result<AssessmentReport> {
281 let registry = ControlRegistry::builtin();
282 let profile = OpaProfile::from_preset_or_file(policy.unwrap_or("default"))?;
283 Ok(libverify_core::assessment::assess(
284 bundle,
285 registry.controls(),
286 &profile,
287 ))
288}
289
290pub fn exit_if_assessment_fails(result: &VerificationResult) {
291 if result
292 .report
293 .outcomes
294 .iter()
295 .any(|o| o.decision == GateDecision::Fail)
296 {
297 process::exit(1);
298 }
299}
300
301fn enrich_npm_attestations(
304 state: EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>>,
305) -> EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>> {
306 use crate::npm_attestation::NpmAttestationClient;
307 use crate::pypi_attestation::PypiAttestationClient;
308
309 fn enrich(deps: &mut [libverify_core::evidence::DependencySignatureEvidence]) {
310 let has_npm = deps
311 .iter()
312 .any(|d| d.registry.as_deref() == Some("registry.npmjs.org"));
313 let has_pypi = deps
314 .iter()
315 .any(|d| d.registry.as_deref() == Some("pypi.org"));
316
317 if has_npm && let Ok(client) = NpmAttestationClient::new() {
318 client.enrich_npm_deps(deps);
319 }
320 if has_pypi && let Ok(client) = PypiAttestationClient::new() {
321 client.enrich_pypi_deps(deps);
322 }
323 }
324
325 match state {
326 EvidenceState::Complete { mut value } => {
327 enrich(&mut value);
328 EvidenceState::Complete { value }
329 }
330 EvidenceState::Partial { mut value, gaps } => {
331 enrich(&mut value);
332 EvidenceState::Partial { value, gaps }
333 }
334 other => other,
335 }
336}