use std::path::Path;
use hmac::{Hmac, Mac};
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
use tracing::debug;
use crate::error::{Result, ToolchainError};
use zlayer_types::package_index::{ChocoData, FormulaData, PackageIndexConfig};
const REPOSYNC_HMAC_SECRET: Option<&str> = option_env!("ZLAYER_REPOSYNC_HMAC_SECRET");
const REPOSYNC_SIGNATURE_HEADER: &str = "x-reposync-signature";
pub struct PackageIndexClient {
config: PackageIndexConfig,
http: reqwest::Client,
}
impl PackageIndexClient {
#[must_use]
pub fn new(config: PackageIndexConfig) -> Self {
let http = reqwest::Client::builder()
.user_agent("zlayer-toolchain")
.build()
.unwrap_or_default();
Self { config, http }
}
#[must_use]
pub fn from_env() -> Self {
Self::new(PackageIndexConfig::from_env())
}
#[must_use]
pub fn base_url(&self) -> &str {
self.config.base_url.trim_end_matches('/')
}
pub async fn get_formula(&self, name: &str) -> Result<FormulaData> {
let primary = format!("{}/formula/{name}", self.base_url());
match self.try_get_json::<FormulaData>(&primary).await {
Ok(Some(data)) => return Ok(data),
Ok(None) => {
self.hint_formula_refresh(name).await;
if let Ok(Some(data)) = self.try_get_json::<FormulaData>(&primary).await {
return Ok(data);
}
}
Err(e) => debug!(name, error = %e, "package index unreachable; trying brew upstream"),
}
let upstream = format!("https://formulae.brew.sh/api/formula/{name}.json");
match self.try_get_json::<FormulaData>(&upstream).await {
Ok(Some(data)) => Ok(data),
Ok(None) => Err(ToolchainError::RegistryError {
message: format!("formula {name} not found in package index or brew upstream"),
}),
Err(e) => Err(e),
}
}
pub async fn get_choco(&self, name: &str) -> Result<ChocoData> {
let url = format!("{}/choco/{name}", self.base_url());
match self.try_get_json::<ChocoData>(&url).await {
Ok(Some(data)) => Ok(data),
Ok(None) => Err(ToolchainError::RegistryError {
message: format!("choco package {name} not found in package index"),
}),
Err(e) => Err(e),
}
}
async fn try_get_json<T: DeserializeOwned>(&self, url: &str) -> Result<Option<T>> {
let resp = self
.http
.get(url)
.send()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed to GET {url}: {e}"),
})?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
return Err(ToolchainError::RegistryError {
message: format!("GET {url} returned status {}", resp.status()),
});
}
let bytes = resp
.bytes()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed to read body from {url}: {e}"),
})?;
let data = serde_json::from_slice(&bytes).map_err(|e| ToolchainError::RegistryError {
message: format!("failed to parse JSON from {url}: {e}"),
})?;
Ok(Some(data))
}
async fn hint_formula_refresh(&self, name: &str) {
let Some(secret) = REPOSYNC_HMAC_SECRET.filter(|s| !s.is_empty()) else {
debug!(
name,
"no reposync HMAC secret compiled in; skipping refresh hint"
);
return;
};
let url = format!("{}/hint", self.base_url());
let body = format!(r#"{{"kind":"formula","name":"{name}"}}"#);
let signature = sign(secret, body.as_bytes());
match self
.http
.post(&url)
.header(REPOSYNC_SIGNATURE_HEADER, signature)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body)
.send()
.await
{
Ok(resp) => debug!(name, status = %resp.status(), "sent formula refresh hint"),
Err(e) => debug!(name, error = %e, "refresh hint failed (non-fatal)"),
}
}
}
#[must_use]
pub fn sign(secret: &str, body: &[u8]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.expect("HMAC accepts a key of any length");
mac.update(body);
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
pub async fn download_verified(url: &str, dest: &Path, expected: Option<&str>) -> Result<String> {
let client = reqwest::Client::builder()
.user_agent("zlayer-toolchain")
.build()
.unwrap_or_default();
let mut resp = client
.get(url)
.send()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed to download {url}: {e}"),
})?;
if !resp.status().is_success() {
return Err(ToolchainError::RegistryError {
message: format!("download failed with status {}: {url}", resp.status()),
});
}
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let mut hasher = Sha256::new();
let mut file = tokio::fs::File::create(dest).await?;
while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed while streaming {url}: {e}"),
})?
{
hasher.update(&chunk);
file.write_all(&chunk).await?;
}
file.flush().await?;
let actual = hex::encode(hasher.finalize());
if let Some(expected) = expected {
let expected = expected.trim();
let expected = expected.strip_prefix("sha256:").unwrap_or(expected);
if !expected.is_empty() && !expected.eq_ignore_ascii_case(&actual) {
let _ = tokio::fs::remove_file(dest).await;
let tool = dest
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("artifact")
.to_string();
return Err(ToolchainError::DigestMismatch {
tool,
expected: expected.to_ascii_lowercase(),
actual,
});
}
}
Ok(actual)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_is_hex_prefixed_and_64_chars() {
let sig = sign("secret", b"body");
assert!(sig.starts_with("sha256="));
let hex = &sig[7..];
assert_eq!(hex.len(), 64);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn sign_matches_known_vector() {
let sig = sign("key", b"The quick brown fox jumps over the lazy dog");
assert_eq!(
sig,
"sha256=f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
);
}
#[test]
fn client_trims_trailing_slash() {
let client = PackageIndexClient::new(zlayer_types::package_index::PackageIndexConfig::new(
"https://example.dev/",
));
assert_eq!(client.base_url(), "https://example.dev");
}
}