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)) if !formula_is_hollow(&data) => return Ok(data),
Ok(Some(_)) => {
debug!(
name,
"package index returned a hollow formula (no stable version/url); trying brew upstream"
);
}
Ok(None) => {
self.hint_formula_refresh(name).await;
if let Ok(Some(data)) = self.try_get_json::<FormulaData>(&primary).await {
if !formula_is_hollow(&data) {
return Ok(data);
}
debug!(
name,
"package index retry returned a hollow formula; trying brew upstream"
);
}
}
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)) if !choco_is_hollow(&data) => Ok(data),
Ok(Some(_)) => Err(ToolchainError::RegistryError {
message: format!(
"choco package {name}: index payload parsed hollow (no version) — mis-shaped response"
),
}),
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 = parse_maybe_enveloped(&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)"),
}
}
}
#[derive(serde::Deserialize)]
struct Enveloped<T> {
data: T,
}
fn parse_maybe_enveloped<T: DeserializeOwned>(bytes: &[u8]) -> serde_json::Result<T> {
match serde_json::from_slice::<Enveloped<T>>(bytes) {
Ok(envelope) => Ok(envelope.data),
Err(_) => serde_json::from_slice::<T>(bytes),
}
}
fn formula_is_hollow(data: &FormulaData) -> bool {
data.stable_version().is_none() && data.stable_url().is_none()
}
fn choco_is_hollow(data: &ChocoData) -> bool {
data.version.trim().is_empty()
}
#[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");
}
const BARE_FORMULA: &str = r#"{
"versions": {"stable": "1.8.2"},
"urls": {"stable": {"url": "https://example/jq-1.8.2.tar.gz", "checksum": "sha256:abc"}}
}"#;
fn enveloped_formula() -> String {
format!(r#"{{"name":"jq","data":{BARE_FORMULA}}}"#)
}
#[test]
fn formula_envelope_unwraps_to_inner_data() {
let f: FormulaData = parse_maybe_enveloped(enveloped_formula().as_bytes()).unwrap();
assert_eq!(f.stable_version(), Some("1.8.2"));
assert_eq!(f.stable_url(), Some("https://example/jq-1.8.2.tar.gz"));
assert!(!formula_is_hollow(&f));
}
#[test]
fn formula_bare_body_still_parses() {
let f: FormulaData = parse_maybe_enveloped(BARE_FORMULA.as_bytes()).unwrap();
assert_eq!(f.stable_version(), Some("1.8.2"));
assert_eq!(f.stable_url(), Some("https://example/jq-1.8.2.tar.gz"));
assert!(!formula_is_hollow(&f));
}
#[test]
fn envelope_parsed_as_bare_formula_is_hollow_and_guarded() {
let husk: FormulaData = serde_json::from_str(&enveloped_formula()).unwrap();
assert_eq!(husk.stable_version(), None, "husk parse must stay hollow");
assert_eq!(husk.stable_url(), None);
assert!(formula_is_hollow(&husk));
}
const ENVELOPED_CHOCO: &str =
r#"{"name":"jq","data":{"Id":"jq","Version":"1.8.1","Title":"jq"}}"#;
#[test]
fn choco_envelope_unwraps_to_inner_data() {
let c: ChocoData = parse_maybe_enveloped(ENVELOPED_CHOCO.as_bytes()).unwrap();
assert_eq!(c.version, "1.8.1");
assert!(!choco_is_hollow(&c));
}
#[test]
fn choco_bare_body_still_parses() {
let c: ChocoData =
parse_maybe_enveloped(br#"{"Version":"1.2.3","Url":"https://x/pkg.nupkg"}"#).unwrap();
assert_eq!(c.version, "1.2.3");
assert_eq!(c.url, "https://x/pkg.nupkg");
assert!(!choco_is_hollow(&c));
}
#[test]
fn envelope_parsed_as_bare_choco_is_hollow_and_guarded() {
let husk: ChocoData = serde_json::from_str(ENVELOPED_CHOCO).unwrap();
assert!(husk.version.is_empty(), "husk parse must stay hollow");
assert!(choco_is_hollow(&husk));
}
#[test]
fn parse_error_reported_for_garbage_body() {
assert!(parse_maybe_enveloped::<FormulaData>(b"not json").is_err());
}
}