use base64::Engine as _;
use ed25519_dalek::{Signature, VerifyingKey};
use sha2::{Digest, Sha256};
use std::io::Read as _;
use std::path::{Path, PathBuf};
use crate::mcp_local::cache_dir;
pub(crate) const TRUSTED_SIGNERS_ENV: &str = "GREENTIC_MCP_TRUSTED_SIGNERS";
pub(crate) const STORE_URL_ENV: &str = "GREENTIC_STORE_URL";
pub(crate) const STORE_TOKEN_ENV: &str = "GREENTIC_STORE_TOKEN";
const DESCRIBE_ENTRY: &str = "describe.json";
const WASM_ENTRY: &str = "extension.wasm";
const MAX_ARTIFACT_BYTES: u64 = 256 * 1024 * 1024;
const MAX_ZIP_ENTRY_BYTES: u64 = 512 * 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum StorePullError {
#[error("store-pull config error: {0}")]
Config(String),
#[error("store-pull network error: {0}")]
Network(String),
#[error("store-pull integrity error: {0}")]
Integrity(String),
#[error("store-pull signature error: {0}")]
Signature(String),
#[error("store-pull archive error: {0}")]
Archive(String),
#[error("store-pull io error: {0}")]
Io(String),
}
pub async fn ensure_cached(
component_ref: &str,
component_version: &str,
component_digest: &str,
) -> Result<(), StorePullError> {
let dest = cache_dir().join(format!("{component_ref}.wasm"));
if dest.exists()
&& sidecar_path(&dest).exists()
&& gtxpack_marker_matches(>xpack_marker_path(&dest), component_digest)
{
return Ok(());
}
let base = std::env::var(STORE_URL_ENV)
.map_err(|_| StorePullError::Config(format!("{STORE_URL_ENV} unset")))?;
let url = format!(
"{}/api/v1/extensions/{component_ref}/{component_version}/artifact",
base.trim_end_matches('/')
);
let archive_bytes = download_artifact(&url).await?;
let computed = hex_sha256(&archive_bytes);
if !computed.eq_ignore_ascii_case(component_digest) {
return Err(StorePullError::Integrity(format!(
"digest mismatch for {component_ref}@{component_version}: computed {computed}, expected {component_digest}"
)));
}
let (describe, wasm) = unzip_describe_and_wasm(&archive_bytes)?;
verify_describe_signature(&describe, &trusted_signers())?;
let wasm_digest = hex_sha256(&wasm);
write_atomic(&sidecar_path(&dest), wasm_digest.as_bytes())
.map_err(|e| StorePullError::Io(format!("write wasm sidecar: {e}")))?;
write_atomic(&dest, &wasm).map_err(|e| StorePullError::Io(e.to_string()))?;
write_atomic(>xpack_marker_path(&dest), component_digest.as_bytes())
.map_err(|e| StorePullError::Io(format!("write gtxpack marker: {e}")))?;
Ok(())
}
pub(crate) fn sidecar_path(wasm_dest: &std::path::Path) -> std::path::PathBuf {
let mut path = wasm_dest.to_path_buf();
let new_extension = match path.extension() {
Some(ext) => format!("{}.sha256", ext.to_string_lossy()),
None => "sha256".to_string(),
};
path.set_extension(new_extension);
path
}
fn gtxpack_marker_path(wasm_dest: &std::path::Path) -> std::path::PathBuf {
let mut path = wasm_dest.to_path_buf();
let new_extension = match path.extension() {
Some(ext) => format!("{}.gtxpack", ext.to_string_lossy()),
None => "gtxpack".to_string(),
};
path.set_extension(new_extension);
path
}
fn gtxpack_marker_matches(marker: &std::path::Path, expected_digest: &str) -> bool {
match std::fs::read_to_string(marker) {
Ok(contents) => contents.trim().eq_ignore_ascii_case(expected_digest.trim()),
Err(_) => false,
}
}
pub fn verify_describe_signature(
describe: &serde_json::Value,
trusted: &[VerifyingKey],
) -> Result<(), StorePullError> {
let signature_object = describe
.get("signature")
.ok_or_else(|| StorePullError::Signature("describe.json has no signature".into()))?;
let signature_b64 = signature_object
.get("value")
.and_then(|value| value.as_str())
.ok_or_else(|| {
StorePullError::Signature("signature.value missing or not a string".into())
})?;
let mut unsigned = describe.clone();
unsigned
.as_object_mut()
.ok_or_else(|| StorePullError::Signature("describe.json is not a JSON object".into()))?
.remove("signature");
let signed_message = serde_jcs::to_vec(&unsigned)
.map_err(|e| StorePullError::Signature(format!("re-serialize describe (JCS): {e}")))?;
let signature_bytes = base64::engine::general_purpose::STANDARD
.decode(signature_b64.trim())
.map_err(|e| StorePullError::Signature(format!("signature is not valid base64: {e}")))?;
let signature = Signature::from_slice(&signature_bytes)
.map_err(|e| StorePullError::Signature(format!("malformed Ed25519 signature: {e}")))?;
if trusted.is_empty() {
return Err(StorePullError::Signature(format!(
"no trusted signers configured ({TRUSTED_SIGNERS_ENV} empty)"
)));
}
if trusted
.iter()
.any(|key| key.verify_strict(&signed_message, &signature).is_ok())
{
Ok(())
} else {
Err(StorePullError::Signature(
"describe signature does not match any trusted signer".into(),
))
}
}
pub fn trusted_signers() -> Vec<VerifyingKey> {
let raw = match std::env::var(TRUSTED_SIGNERS_ENV) {
Ok(value) => value,
Err(_) => return Vec::new(),
};
raw.split(',')
.filter_map(|entry| parse_trusted_signer(entry.trim()))
.collect()
}
fn parse_trusted_signer(entry: &str) -> Option<VerifyingKey> {
if entry.is_empty() {
return None;
}
let key_b64 = match entry.split_once(':') {
Some((algorithm, key)) => {
if !algorithm.eq_ignore_ascii_case("ed25519") {
return None;
}
key.trim()
}
None => entry,
};
let raw = base64::engine::general_purpose::STANDARD
.decode(key_b64)
.ok()?;
let key_bytes: [u8; 32] = raw.as_slice().try_into().ok()?;
VerifyingKey::from_bytes(&key_bytes).ok()
}
async fn download_artifact(url: &str) -> Result<Vec<u8>, StorePullError> {
let mut request = reqwest::Client::new().get(url);
if let Ok(token) = std::env::var(STORE_TOKEN_ENV)
&& !token.is_empty()
{
request = request.bearer_auth(token);
}
let response = request
.send()
.await
.map_err(|e| StorePullError::Network(format!("GET {url}: {e}")))?;
let status = response.status();
if !status.is_success() {
return Err(StorePullError::Network(format!(
"GET {url} returned HTTP {status}"
)));
}
if let Some(content_length) = response.content_length()
&& content_length > MAX_ARTIFACT_BYTES
{
return Err(StorePullError::Integrity(format!(
"artifact at {url} claims Content-Length {content_length} which exceeds the {MAX_ARTIFACT_BYTES}-byte cap"
)));
}
let bytes = response
.bytes()
.await
.map_err(|e| StorePullError::Network(format!("read body from {url}: {e}")))?;
if bytes.len() as u64 > MAX_ARTIFACT_BYTES {
return Err(StorePullError::Integrity(format!(
"artifact at {url} is {} bytes which exceeds the {MAX_ARTIFACT_BYTES}-byte cap",
bytes.len()
)));
}
Ok(bytes.to_vec())
}
pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let digest = Sha256::digest(bytes);
digest.iter().fold(
String::with_capacity(digest.len() * 2),
|mut accumulator, byte| {
let _ = write!(accumulator, "{byte:02x}");
accumulator
},
)
}
fn unzip_describe_and_wasm(
archive_bytes: &[u8],
) -> Result<(serde_json::Value, Vec<u8>), StorePullError> {
let cursor = std::io::Cursor::new(archive_bytes);
let mut archive = zip::ZipArchive::new(cursor)
.map_err(|e| StorePullError::Archive(format!("open gtxpack: {e}")))?;
let describe_raw = read_zip_entry(&mut archive, DESCRIBE_ENTRY)?;
let describe: serde_json::Value = serde_json::from_slice(&describe_raw)
.map_err(|e| StorePullError::Archive(format!("parse {DESCRIBE_ENTRY}: {e}")))?;
let wasm = read_zip_entry(&mut archive, WASM_ENTRY)?;
Ok((describe, wasm))
}
fn read_zip_entry<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
name: &str,
) -> Result<Vec<u8>, StorePullError> {
let entry = archive
.by_name(name)
.map_err(|e| StorePullError::Archive(format!("{name} not in gtxpack: {e}")))?;
let prealloc = usize::try_from(entry.size().min(MAX_ZIP_ENTRY_BYTES)).unwrap_or(0);
let mut buffer = Vec::with_capacity(prealloc);
let read = std::io::Read::take(entry, MAX_ZIP_ENTRY_BYTES + 1)
.read_to_end(&mut buffer)
.map_err(|e| StorePullError::Archive(format!("read {name}: {e}")))?;
if read as u64 > MAX_ZIP_ENTRY_BYTES {
return Err(StorePullError::Archive(format!(
"{name} decompresses past the {MAX_ZIP_ENTRY_BYTES}-byte limit (possible zip bomb)"
)));
}
Ok(buffer)
}
fn write_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let temp = unique_temp_path(dest);
{
let mut file = std::fs::File::create(&temp)?;
std::io::Write::write_all(&mut file, bytes)?;
file.sync_all()?;
}
if let Err(error) = std::fs::rename(&temp, dest) {
let _ = std::fs::remove_file(&temp);
return Err(error);
}
Ok(())
}
fn unique_temp_path(dest: &Path) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nonce = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let stem = dest
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("artifact");
let parent = dest.parent().unwrap_or_else(|| Path::new("."));
parent.join(format!(".{stem}.{pid}.{nonce}.tmp"))
}
#[cfg(test)]
pub(crate) mod fixtures;
#[cfg(test)]
mod tests;