use crate::{AttestationError, Result};
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
use serde::{Deserialize, Serialize};
const GITHUB_API_URL: &str = "https://api.github.com";
const USER_AGENT_VALUE: &str = "mise-attestation/0.1.0";
#[derive(Debug, Clone)]
pub struct AttestationClient {
client: reqwest::Client,
base_url: String,
github_token: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AttestationClientBuilder {
base_url: Option<String>,
github_token: Option<String>,
}
impl AttestationClientBuilder {
pub fn base_url(mut self, url: &str) -> Self {
self.base_url = Some(url.trim_end_matches('/').to_string());
self
}
pub fn github_token(mut self, token: &str) -> Self {
self.github_token = Some(token.to_string());
self
}
pub fn build(self) -> Result<AttestationClient> {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
let client = reqwest::Client::builder()
.default_headers(headers)
.build()?;
Ok(AttestationClient {
client,
base_url: self.base_url.unwrap_or_else(|| GITHUB_API_URL.to_string()),
github_token: self.github_token,
})
}
}
#[derive(Debug, Serialize)]
pub struct FetchParams {
pub owner: String,
pub repo: Option<String>,
pub digest: String,
pub limit: usize,
pub predicate_type: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AttestationsResponse {
pub attestations: Vec<Attestation>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Attestation {
pub bundle: Option<SigstoreBundle>,
pub bundle_url: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct SigstoreBundle {
#[serde(rename = "mediaType")]
pub media_type: String,
#[serde(rename = "dsseEnvelope")]
pub dsse_envelope: Option<DsseEnvelope>,
#[serde(rename = "verificationMaterial")]
pub verification_material: Option<serde_json::Value>,
#[serde(rename = "messageSignature")]
pub message_signature: Option<MessageSignature>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct MessageSignature {
#[serde(rename = "messageDigest")]
pub message_digest: MessageDigest,
pub signature: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct MessageDigest {
pub algorithm: String,
pub digest: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DsseEnvelope {
pub payload: String,
#[serde(rename = "payloadType")]
pub payload_type: String,
pub signatures: Vec<Signature>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Signature {
pub sig: String,
pub keyid: Option<String>,
}
impl AttestationClient {
pub fn new(github_token: Option<&str>) -> Result<Self> {
let mut builder = Self::builder();
if let Some(token) = github_token {
builder = builder.github_token(token);
}
builder.build()
}
pub fn builder() -> AttestationClientBuilder {
AttestationClientBuilder::default()
}
fn github_headers(&self, url: &str) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
let base_with_slash = format!("{}/", self.base_url);
if url.starts_with(&base_with_slash) || url == self.base_url {
if let Some(token) = &self.github_token {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", token))
.map_err(|e| AttestationError::Api(e.to_string()))?,
);
}
headers.insert(
"x-github-api-version",
HeaderValue::from_static("2022-11-28"),
);
}
Ok(headers)
}
pub async fn fetch_attestations(&self, params: FetchParams) -> Result<Vec<Attestation>> {
let url = if let Some(repo) = ¶ms.repo {
format!(
"{}/repos/{}/attestations/{}",
self.base_url, repo, params.digest
)
} else {
format!(
"{}/orgs/{}/attestations/{}",
self.base_url, params.owner, params.digest
)
};
let mut query_params = vec![("per_page", params.limit.to_string())];
if let Some(predicate_type) = ¶ms.predicate_type {
query_params.push(("predicate_type", predicate_type.clone()));
}
let response = self
.client
.get(&url)
.headers(self.github_headers(&url)?)
.query(&query_params)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Ok(Vec::new());
}
let body = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(AttestationError::Api(format!(
"GitHub API returned {}: {}",
status, body
)));
}
let attestations_response: AttestationsResponse = response.json().await?;
let mut attestations = Vec::new();
for att in attestations_response.attestations {
if att.bundle.is_some() {
attestations.push(att);
} else if let Some(bundle_url) = &att.bundle_url {
let bundle_response = self
.client
.get(bundle_url)
.headers(self.github_headers(bundle_url)?)
.send()
.await?;
if bundle_response.status().is_success() {
let bundle: SigstoreBundle = if bundle_response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
== Some("application/x-snappy")
{
let bytes = bundle_response.bytes().await?;
let decompressed = decompress_snappy(&bytes)?;
serde_json::from_slice(&decompressed)?
} else {
bundle_response.json().await?
};
attestations.push(Attestation {
bundle: Some(bundle),
bundle_url: att.bundle_url.clone(),
});
}
}
}
Ok(attestations)
}
}
fn decompress_snappy(bytes: &[u8]) -> Result<Vec<u8>> {
let mut decoder = snap::raw::Decoder::new();
decoder
.decompress_vec(bytes)
.map_err(|e| AttestationError::Api(format!("Snappy decompression failed: {}", e)))
}