use miette::{IntoDiagnostic, WrapErr};
use reqwest::header;
use reqwest_middleware::ClientWithMiddleware;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sigstore_sign::{Attestation, SigningContext, oidc::IdentityToken};
use std::path::Path;
#[derive(Debug, Serialize, Deserialize)]
pub struct CondaV1Predicate {
#[serde(rename = "targetChannel", skip_serializing_if = "Option::is_none")]
pub target_channel: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttestationResponse {
pub id: u64,
}
#[derive(Debug, Clone)]
pub struct AttestationConfig {
pub repo_owner: Option<String>,
pub repo_name: Option<String>,
pub github_token: Option<String>,
}
pub async fn create_attestation(
package_path: &Path,
channel_url: &str,
config: &AttestationConfig,
client: &ClientWithMiddleware,
) -> miette::Result<String> {
let identity_token = IdentityToken::detect_ambient()
.await
.into_diagnostic()
.with_context(|| "Failed to detect OIDC identity token")?
.ok_or_else(|| miette::miette!("No OIDC identity token found in environment. Attestation signing is supported in GitHub Actions, GitLab CI, and other OIDC-enabled CI environments."))?;
let digest = rattler_digest::compute_file_digest::<rattler_digest::Sha256>(package_path)
.into_diagnostic()?;
let digest_hex = format!("{digest:x}");
let filename = package_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| miette::miette!("Invalid package file name"))?;
let predicate = CondaV1Predicate {
target_channel: Some(channel_url.to_string()),
};
let sha256_hash = sigstore_sign::types::Sha256Hash::from_hex(&digest_hex)
.map_err(|e| miette::miette!("Invalid SHA256 hash: {}", e))?;
let attestation = Attestation::new(
"https://schemas.conda.org/attestations-publish-1.schema.json",
serde_json::to_value(&predicate).into_diagnostic()?,
)
.add_subject(filename, sha256_hash);
tracing::info!("Signing attestation with Sigstore...");
let context = SigningContext::production();
let signer = context.signer(identity_token);
let bundle = signer
.sign_attestation(attestation)
.await
.map_err(|e| miette::miette!("Failed to sign attestation with Sigstore: {}", e))?;
let bundle_json = bundle
.to_json_pretty()
.map_err(|e| miette::miette!("Failed to serialize bundle: {}", e))?;
tracing::info!("Successfully created Sigstore attestation");
if let (Some(token), Some(owner), Some(repo)) =
(&config.github_token, &config.repo_owner, &config.repo_name)
{
let attestation_id =
store_attestation_to_github(&bundle_json, token, owner, repo, client).await?;
tracing::info!("Attestation stored to GitHub with ID: {}", attestation_id);
}
Ok(bundle_json)
}
async fn store_attestation_to_github(
bundle_json: &str,
github_token: &str,
owner: &str,
repo: &str,
client: &ClientWithMiddleware,
) -> miette::Result<String> {
let url = format!("https://api.github.com/repos/{owner}/{repo}/attestations");
let bundle: serde_json::Value = serde_json::from_str(bundle_json)
.into_diagnostic()
.map_err(|e| miette::miette!("Invalid bundle JSON: {}", e))?;
let request_body = json!({
"bundle": bundle,
});
tracing::debug!("Storing attestation to GitHub at {}", url);
let response = client
.post(&url)
.bearer_auth(github_token)
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.json(&request_body)
.send()
.await
.into_diagnostic()?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.into_diagnostic()?;
let error_detail = match status.as_u16() {
401 => "Authentication failed. Check your GitHub token.",
403 => {
"Permission denied. Ensure token has 'attestations:write' and repository allows attestations."
}
404 => {
"Repository not found or attestations API unavailable. Ensure you're on a supported GitHub plan."
}
422 => "Invalid attestation bundle format.",
_ => "Unknown error storing attestation.",
};
return Err(miette::miette!(
"{}\nStatus: {}\nResponse: {}",
error_detail,
status,
body
));
}
let body = response.text().await.into_diagnostic()?;
let response_data: AttestationResponse = serde_json::from_str(&body)
.map_err(|e| miette::miette!("Failed to parse GitHub response: {}\nBody: {}", e, body))?;
tracing::info!(
"Successfully stored attestation with ID: {}",
response_data.id
);
Ok(response_data.id.to_string())
}