1use anyhow::{Context, Result, bail};
2use serde::Deserialize;
3use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use std::process::Command;
6
7use libverify_core::evidence::{
8 ArtifactAttestation, EvidenceGap, EvidenceState, VerificationOutcome,
9};
10
11use crate::types::ReleaseAsset;
12
13#[derive(Debug, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct GhAttestationOutput {
19 pub verification_result: GhVerificationResult,
20}
21
22#[derive(Debug, Deserialize)]
23pub struct GhVerificationResult {
24 pub statement: Statement,
25 pub signature: Option<SignatureInfo>,
26}
27
28#[derive(Debug, Deserialize)]
29pub struct Statement {
30 #[serde(rename = "predicateType")]
31 pub predicate_type: String,
32 #[serde(default)]
34 pub subject: Vec<StatementSubject>,
35}
36
37#[derive(Debug, Deserialize)]
39pub struct StatementSubject {
40 pub name: String,
41 #[serde(default)]
43 pub digest: HashMap<String, String>,
44}
45
46#[derive(Debug, Deserialize)]
47pub struct SignatureInfo {
48 pub certificate: Option<CertificateInfo>,
49}
50
51#[derive(Debug, Deserialize)]
52pub struct CertificateInfo {
53 #[serde(rename = "sourceRepositoryURI")]
54 pub source_repository_uri: Option<String>,
55 #[serde(rename = "buildSignerURI")]
56 pub build_signer_uri: Option<String>,
57}
58
59pub fn verify_artifact(
63 artifact: &str,
64 owner: Option<&str>,
65 repo: Option<&str>,
66) -> Result<Vec<GhAttestationOutput>> {
67 let mut cmd = Command::new("gh");
68 cmd.args(["attestation", "verify", artifact, "--format", "json"]);
69
70 if let Some(r) = repo {
71 cmd.args(["--repo", r]);
72 } else if let Some(o) = owner {
73 cmd.args(["--owner", o]);
74 } else {
75 bail!("either --owner or --repo is required for attestation verification");
76 }
77
78 let output = cmd
79 .output()
80 .context("failed to execute `gh attestation verify`")?;
81
82 if !output.status.success() {
83 let stderr = String::from_utf8_lossy(&output.stderr);
84 bail!("gh attestation verify failed: {stderr}");
85 }
86
87 let stdout = String::from_utf8(output.stdout).context("invalid UTF-8 in gh output")?;
88 let results: Vec<GhAttestationOutput> =
89 serde_json::from_str(&stdout).context("failed to parse gh attestation verify output")?;
90
91 Ok(results)
92}
93
94pub fn to_artifact_attestations(
100 artifact: &str,
101 results: &[GhAttestationOutput],
102 subject_digest: Option<String>,
103) -> Vec<ArtifactAttestation> {
104 results
105 .iter()
106 .map(|r| {
107 let cert = r
108 .verification_result
109 .signature
110 .as_ref()
111 .and_then(|s| s.certificate.as_ref());
112
113 let claimed_digest = r
114 .verification_result
115 .statement
116 .subject
117 .iter()
118 .find(|s| s.name == artifact)
119 .and_then(|s| s.digest.get("sha256"))
120 .map(|hex| format!("sha256:{hex}"));
121
122 let verification = match (&subject_digest, &claimed_digest) {
123 (Some(local), Some(claimed)) if local != claimed => {
124 VerificationOutcome::SignatureInvalid {
125 detail: format!("digest mismatch: local={local}, attestation={claimed}"),
126 }
127 }
128 _ => VerificationOutcome::Verified,
129 };
130
131 ArtifactAttestation {
132 subject: artifact.to_string(),
133 subject_digest: subject_digest.clone(),
134 predicate_type: r.verification_result.statement.predicate_type.clone(),
135 signer_workflow: cert.and_then(|c| c.build_signer_uri.clone()),
136 source_repo: cert.and_then(|c| c.source_repository_uri.clone()),
137 verification,
138 }
139 })
140 .collect()
141}
142
143fn sha256_file(path: &std::path::Path) -> Result<String> {
147 let bytes = std::fs::read(path)?;
148 let hash = Sha256::digest(&bytes);
149 Ok(format!("sha256:{hash:x}"))
150}
151
152pub fn collect_release_attestations(
158 owner: &str,
159 repo: &str,
160 tag: &str,
161 assets: &[ReleaseAsset],
162) -> EvidenceState<Vec<ArtifactAttestation>> {
163 if assets.is_empty() {
164 return EvidenceState::not_applicable();
165 }
166
167 let repo_full = format!("{owner}/{repo}");
168
169 if !gh_cli_available() {
171 return EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
172 source: "gh-attestation".to_string(),
173 subject: "release-assets".to_string(),
174 detail: "`gh` CLI is not available".to_string(),
175 }]);
176 }
177
178 let tmp_dir = match tempfile::tempdir() {
179 Ok(d) => d,
180 Err(e) => {
181 return EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
182 source: "gh-attestation".to_string(),
183 subject: "release-assets".to_string(),
184 detail: format!("failed to create temporary directory: {e}"),
185 }]);
186 }
187 };
188
189 let tmp_path = tmp_dir.path();
192 let per_asset_results: Vec<(Vec<ArtifactAttestation>, Vec<EvidenceGap>)> =
193 std::thread::scope(|s| {
194 let handles: Vec<_> = assets
195 .iter()
196 .map(|asset| {
197 s.spawn(|| {
198 let mut atts = Vec::new();
199 let mut g = Vec::new();
200 let asset_path = tmp_path.join(&asset.name);
201
202 match download_asset(owner, repo, tag, &asset.name, &asset_path) {
203 Ok(()) => {}
204 Err(e) => {
205 g.push(EvidenceGap::CollectionFailed {
206 source: "gh-release-download".to_string(),
207 subject: asset.name.clone(),
208 detail: format!("failed to download asset: {e}"),
209 });
210 atts.push(ArtifactAttestation {
211 subject: asset.name.clone(),
212 subject_digest: None,
213 predicate_type: String::new(),
214 signer_workflow: None,
215 source_repo: None,
216 verification: VerificationOutcome::Failed {
217 detail: format!("download failed: {e}"),
218 },
219 });
220 return (atts, g);
221 }
222 }
223
224 let digest = sha256_file(&asset_path).ok();
225 let path_str = asset_path.to_string_lossy().to_string();
226 match verify_artifact(&path_str, None, Some(&repo_full)) {
227 Ok(results) if !results.is_empty() => {
228 atts.extend(to_artifact_attestations(
229 &asset.name, &results, digest,
230 ));
231 }
232 Ok(_) => {
233 atts.push(ArtifactAttestation {
234 subject: asset.name.clone(),
235 subject_digest: digest,
236 predicate_type: String::new(),
237 signer_workflow: None,
238 source_repo: None,
239 verification: VerificationOutcome::AttestationAbsent {
240 detail: "no attestation found".to_string(),
241 },
242 });
243 }
244 Err(e) => {
245 let detail = format!("{e}");
246 let outcome = classify_verification_error(&detail);
247 atts.push(ArtifactAttestation {
248 subject: asset.name.clone(),
249 subject_digest: digest,
250 predicate_type: String::new(),
251 signer_workflow: None,
252 source_repo: None,
253 verification: outcome,
254 });
255 }
256 }
257 (atts, g)
258 })
259 })
260 .collect();
261 handles.into_iter().map(|h| h.join().unwrap()).collect()
262 });
263
264 let mut attestations: Vec<ArtifactAttestation> = Vec::new();
265 let mut gaps: Vec<EvidenceGap> = Vec::new();
266 for (atts, g) in per_asset_results {
267 attestations.extend(atts);
268 gaps.extend(g);
269 }
270
271 if gaps.is_empty() {
272 EvidenceState::complete(attestations)
273 } else {
274 EvidenceState::partial(attestations, gaps)
275 }
276}
277
278fn download_asset(
280 owner: &str,
281 repo: &str,
282 tag: &str,
283 asset_name: &str,
284 dest: &std::path::Path,
285) -> Result<()> {
286 let repo_full = format!("{owner}/{repo}");
287 let output = Command::new("gh")
288 .args([
289 "release",
290 "download",
291 tag,
292 "--repo",
293 &repo_full,
294 "--pattern",
295 asset_name,
296 "--dir",
297 &dest.parent().unwrap().to_string_lossy(),
298 "--clobber",
299 ])
300 .output()?;
301
302 if !output.status.success() {
303 let stderr = String::from_utf8_lossy(&output.stderr);
304 anyhow::bail!("{stderr}");
305 }
306
307 if !dest.exists() {
308 anyhow::bail!("asset file not found after download");
309 }
310
311 Ok(())
312}
313
314fn classify_verification_error(detail: &str) -> VerificationOutcome {
316 let lower = detail.to_lowercase();
317 if lower.contains("no attestation") || lower.contains("not found") {
318 VerificationOutcome::AttestationAbsent {
319 detail: detail.to_string(),
320 }
321 } else if lower.contains("signature") || lower.contains("cosign") {
322 VerificationOutcome::SignatureInvalid {
323 detail: detail.to_string(),
324 }
325 } else if lower.contains("transparency") || lower.contains("rekor") || lower.contains("tlog") {
326 VerificationOutcome::TransparencyLogMissing {
327 detail: detail.to_string(),
328 }
329 } else if lower.contains("signer") || lower.contains("identity") || lower.contains("issuer") {
330 VerificationOutcome::SignerMismatch {
331 detail: detail.to_string(),
332 }
333 } else {
334 VerificationOutcome::Failed {
335 detail: detail.to_string(),
336 }
337 }
338}
339
340fn gh_cli_available() -> bool {
342 Command::new("gh")
343 .arg("--version")
344 .output()
345 .map(|o| o.status.success())
346 .unwrap_or(false)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn empty_assets_returns_not_applicable() {
355 let result = collect_release_attestations("owner", "repo", "v1.0.0", &[]);
356 assert!(matches!(result, EvidenceState::NotApplicable));
357 }
358}