sigstore_verification/
api.rs1use crate::{AttestationError, Result};
2use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
3use serde::{Deserialize, Serialize};
4
5const GITHUB_API_URL: &str = "https://api.github.com";
6const USER_AGENT_VALUE: &str = "mise-attestation/0.1.0";
7
8#[derive(Debug, Clone)]
9pub struct AttestationClient {
10 client: reqwest::Client,
11 base_url: String,
12 github_token: Option<String>,
13}
14
15#[derive(Debug, Clone, Default)]
16pub struct AttestationClientBuilder {
17 base_url: Option<String>,
18 github_token: Option<String>,
19}
20
21impl AttestationClientBuilder {
22 pub fn base_url(mut self, url: &str) -> Self {
23 self.base_url = Some(url.trim_end_matches('/').to_string());
24 self
25 }
26
27 pub fn github_token(mut self, token: &str) -> Self {
28 self.github_token = Some(token.to_string());
29 self
30 }
31
32 pub fn build(self) -> Result<AttestationClient> {
33 let mut headers = HeaderMap::new();
34 headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
35
36 let client = reqwest::Client::builder()
37 .default_headers(headers)
38 .build()?;
39
40 Ok(AttestationClient {
41 client,
42 base_url: self.base_url.unwrap_or_else(|| GITHUB_API_URL.to_string()),
43 github_token: self.github_token,
44 })
45 }
46}
47
48#[derive(Debug, Serialize)]
49pub struct FetchParams {
50 pub owner: String,
51 pub repo: Option<String>,
52 pub digest: String,
53 pub limit: usize,
54 pub predicate_type: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58pub struct AttestationsResponse {
59 pub attestations: Vec<Attestation>,
60}
61
62#[derive(Debug, Deserialize, Clone)]
63pub struct Attestation {
64 pub bundle: Option<SigstoreBundle>,
65 pub bundle_url: Option<String>,
66}
67
68#[derive(Debug, Deserialize, Clone)]
69pub struct SigstoreBundle {
70 #[serde(rename = "mediaType")]
71 pub media_type: String,
72 #[serde(rename = "dsseEnvelope")]
73 pub dsse_envelope: Option<DsseEnvelope>,
74 #[serde(rename = "verificationMaterial")]
75 pub verification_material: Option<serde_json::Value>,
76 #[serde(rename = "messageSignature")]
78 pub message_signature: Option<MessageSignature>,
79}
80
81#[derive(Debug, Deserialize, Clone)]
82pub struct MessageSignature {
83 #[serde(rename = "messageDigest")]
84 pub message_digest: MessageDigest,
85 pub signature: String,
86}
87
88#[derive(Debug, Deserialize, Clone)]
89pub struct MessageDigest {
90 pub algorithm: String,
91 pub digest: String,
92}
93
94#[derive(Debug, Deserialize, Clone)]
95pub struct DsseEnvelope {
96 pub payload: String,
97 #[serde(rename = "payloadType")]
98 pub payload_type: String,
99 pub signatures: Vec<Signature>,
100}
101
102#[derive(Debug, Deserialize, Clone)]
103pub struct Signature {
104 pub sig: String,
105 pub keyid: Option<String>,
106}
107
108impl AttestationClient {
109 pub fn new(github_token: Option<&str>) -> Result<Self> {
110 let mut builder = Self::builder();
111 if let Some(token) = github_token {
112 builder = builder.github_token(token);
113 }
114 builder.build()
115 }
116
117 pub fn builder() -> AttestationClientBuilder {
118 AttestationClientBuilder::default()
119 }
120
121 fn github_headers(&self, url: &str) -> Result<HeaderMap> {
122 let mut headers = HeaderMap::new();
123 let base_with_slash = format!("{}/", self.base_url);
124 if url.starts_with(&base_with_slash) || url == self.base_url {
125 if let Some(token) = &self.github_token {
126 headers.insert(
127 AUTHORIZATION,
128 HeaderValue::from_str(&format!("Bearer {}", token))
129 .map_err(|e| AttestationError::Api(e.to_string()))?,
130 );
131 }
132 headers.insert(
133 "x-github-api-version",
134 HeaderValue::from_static("2022-11-28"),
135 );
136 }
137 Ok(headers)
138 }
139
140 pub async fn fetch_attestations(&self, params: FetchParams) -> Result<Vec<Attestation>> {
141 let url = if let Some(repo) = ¶ms.repo {
142 format!(
143 "{}/repos/{}/attestations/{}",
144 self.base_url, repo, params.digest
145 )
146 } else {
147 format!(
148 "{}/orgs/{}/attestations/{}",
149 self.base_url, params.owner, params.digest
150 )
151 };
152
153 let mut query_params = vec![("per_page", params.limit.to_string())];
154 if let Some(predicate_type) = ¶ms.predicate_type {
155 query_params.push(("predicate_type", predicate_type.clone()));
156 }
157
158 let response = self
159 .client
160 .get(&url)
161 .headers(self.github_headers(&url)?)
162 .query(&query_params)
163 .send()
164 .await?;
165
166 if !response.status().is_success() {
167 let status = response.status();
168
169 if status == reqwest::StatusCode::NOT_FOUND {
171 return Ok(Vec::new());
172 }
173
174 let body = response
175 .text()
176 .await
177 .unwrap_or_else(|_| "Unknown error".to_string());
178 return Err(AttestationError::Api(format!(
179 "GitHub API returned {}: {}",
180 status, body
181 )));
182 }
183
184 let attestations_response: AttestationsResponse = response.json().await?;
185
186 let mut attestations = Vec::new();
188 for att in attestations_response.attestations {
189 if att.bundle.is_some() {
190 attestations.push(att);
191 } else if let Some(bundle_url) = &att.bundle_url {
192 let bundle_response = self
194 .client
195 .get(bundle_url)
196 .headers(self.github_headers(bundle_url)?)
197 .send()
198 .await?;
199 if bundle_response.status().is_success() {
200 let bundle: SigstoreBundle = if bundle_response
201 .headers()
202 .get(reqwest::header::CONTENT_TYPE)
203 .and_then(|v| v.to_str().ok())
204 == Some("application/x-snappy")
205 {
206 let bytes = bundle_response.bytes().await?;
207 let decompressed = decompress_snappy(&bytes)?;
208 serde_json::from_slice(&decompressed)?
209 } else {
210 bundle_response.json().await?
211 };
212
213 attestations.push(Attestation {
214 bundle: Some(bundle),
215 bundle_url: att.bundle_url.clone(),
216 });
217 }
218 }
219 }
220
221 Ok(attestations)
222 }
223}
224
225fn decompress_snappy(bytes: &[u8]) -> Result<Vec<u8>> {
226 let mut decoder = snap::raw::Decoder::new();
227 decoder
228 .decompress_vec(bytes)
229 .map_err(|e| AttestationError::Api(format!("Snappy decompression failed: {}", e)))
230}