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 mut attestations: Vec<ArtifactAttestation> = Vec::new();
190 let mut gaps: Vec<EvidenceGap> = Vec::new();
191
192 for asset in assets {
193 let asset_path = tmp_dir.path().join(&asset.name);
194
195 match download_asset(owner, repo, tag, &asset.name, &asset_path) {
196 Ok(()) => {}
197 Err(e) => {
198 gaps.push(EvidenceGap::CollectionFailed {
199 source: "gh-release-download".to_string(),
200 subject: asset.name.clone(),
201 detail: format!("failed to download asset: {e}"),
202 });
203 attestations.push(ArtifactAttestation {
204 subject: asset.name.clone(),
205 subject_digest: None,
206 predicate_type: String::new(),
207 signer_workflow: None,
208 source_repo: None,
209 verification: VerificationOutcome::Failed {
210 detail: format!("download failed: {e}"),
211 },
212 });
213 continue;
214 }
215 }
216
217 let digest = sha256_file(&asset_path).ok();
218
219 let path_str = asset_path.to_string_lossy().to_string();
220 match verify_artifact(&path_str, None, Some(&repo_full)) {
221 Ok(results) if !results.is_empty() => {
222 attestations.extend(to_artifact_attestations(&asset.name, &results, digest));
223 }
224 Ok(_) => {
225 attestations.push(ArtifactAttestation {
226 subject: asset.name.clone(),
227 subject_digest: digest,
228 predicate_type: String::new(),
229 signer_workflow: None,
230 source_repo: None,
231 verification: VerificationOutcome::AttestationAbsent {
232 detail: "no attestation found".to_string(),
233 },
234 });
235 }
236 Err(e) => {
237 let detail = format!("{e}");
238 let outcome = classify_verification_error(&detail);
239 attestations.push(ArtifactAttestation {
240 subject: asset.name.clone(),
241 subject_digest: digest,
242 predicate_type: String::new(),
243 signer_workflow: None,
244 source_repo: None,
245 verification: outcome,
246 });
247 }
248 }
249 }
250
251 if gaps.is_empty() {
252 EvidenceState::complete(attestations)
253 } else {
254 EvidenceState::partial(attestations, gaps)
255 }
256}
257
258fn download_asset(
260 owner: &str,
261 repo: &str,
262 tag: &str,
263 asset_name: &str,
264 dest: &std::path::Path,
265) -> Result<()> {
266 let repo_full = format!("{owner}/{repo}");
267 let output = Command::new("gh")
268 .args([
269 "release",
270 "download",
271 tag,
272 "--repo",
273 &repo_full,
274 "--pattern",
275 asset_name,
276 "--dir",
277 &dest.parent().unwrap().to_string_lossy(),
278 "--clobber",
279 ])
280 .output()?;
281
282 if !output.status.success() {
283 let stderr = String::from_utf8_lossy(&output.stderr);
284 anyhow::bail!("{stderr}");
285 }
286
287 if !dest.exists() {
288 anyhow::bail!("asset file not found after download");
289 }
290
291 Ok(())
292}
293
294fn classify_verification_error(detail: &str) -> VerificationOutcome {
296 let lower = detail.to_lowercase();
297 if lower.contains("no attestation") || lower.contains("not found") {
298 VerificationOutcome::AttestationAbsent {
299 detail: detail.to_string(),
300 }
301 } else if lower.contains("signature") || lower.contains("cosign") {
302 VerificationOutcome::SignatureInvalid {
303 detail: detail.to_string(),
304 }
305 } else if lower.contains("transparency") || lower.contains("rekor") || lower.contains("tlog") {
306 VerificationOutcome::TransparencyLogMissing {
307 detail: detail.to_string(),
308 }
309 } else if lower.contains("signer") || lower.contains("identity") || lower.contains("issuer") {
310 VerificationOutcome::SignerMismatch {
311 detail: detail.to_string(),
312 }
313 } else {
314 VerificationOutcome::Failed {
315 detail: detail.to_string(),
316 }
317 }
318}
319
320fn gh_cli_available() -> bool {
322 Command::new("gh")
323 .arg("--version")
324 .output()
325 .map(|o| o.status.success())
326 .unwrap_or(false)
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn empty_assets_returns_not_applicable() {
335 let result = collect_release_attestations("owner", "repo", "v1.0.0", &[]);
336 assert!(matches!(result, EvidenceState::NotApplicable));
337 }
338}