zlayer_toolchain/
package_index.rs1use std::path::Path;
23
24use hmac::{Hmac, Mac};
25use serde::de::DeserializeOwned;
26use sha2::{Digest, Sha256};
27use tokio::io::AsyncWriteExt;
28use tracing::debug;
29
30use crate::error::{Result, ToolchainError};
31use zlayer_types::package_index::{ChocoData, FormulaData, PackageIndexConfig};
32
33const REPOSYNC_HMAC_SECRET: Option<&str> = option_env!("ZLAYER_REPOSYNC_HMAC_SECRET");
36
37const REPOSYNC_SIGNATURE_HEADER: &str = "x-reposync-signature";
39
40pub struct PackageIndexClient {
46 config: PackageIndexConfig,
47 http: reqwest::Client,
48}
49
50impl PackageIndexClient {
51 #[must_use]
54 pub fn new(config: PackageIndexConfig) -> Self {
55 let http = reqwest::Client::builder()
56 .user_agent("zlayer-toolchain")
57 .build()
58 .unwrap_or_default();
59 Self { config, http }
60 }
61
62 #[must_use]
65 pub fn from_env() -> Self {
66 Self::new(PackageIndexConfig::from_env())
67 }
68
69 #[must_use]
71 pub fn base_url(&self) -> &str {
72 self.config.base_url.trim_end_matches('/')
73 }
74
75 pub async fn get_formula(&self, name: &str) -> Result<FormulaData> {
86 let primary = format!("{}/formula/{name}", self.base_url());
87
88 match self.try_get_json::<FormulaData>(&primary).await {
89 Ok(Some(data)) => return Ok(data),
90 Ok(None) => {
91 self.hint_formula_refresh(name).await;
93 if let Ok(Some(data)) = self.try_get_json::<FormulaData>(&primary).await {
94 return Ok(data);
95 }
96 }
97 Err(e) => debug!(name, error = %e, "package index unreachable; trying brew upstream"),
98 }
99
100 let upstream = format!("https://formulae.brew.sh/api/formula/{name}.json");
101 match self.try_get_json::<FormulaData>(&upstream).await {
102 Ok(Some(data)) => Ok(data),
103 Ok(None) => Err(ToolchainError::RegistryError {
104 message: format!("formula {name} not found in package index or brew upstream"),
105 }),
106 Err(e) => Err(e),
107 }
108 }
109
110 pub async fn get_choco(&self, name: &str) -> Result<ChocoData> {
117 let url = format!("{}/choco/{name}", self.base_url());
118 match self.try_get_json::<ChocoData>(&url).await {
119 Ok(Some(data)) => Ok(data),
120 Ok(None) => Err(ToolchainError::RegistryError {
121 message: format!("choco package {name} not found in package index"),
122 }),
123 Err(e) => Err(e),
124 }
125 }
126
127 async fn try_get_json<T: DeserializeOwned>(&self, url: &str) -> Result<Option<T>> {
131 let resp = self
132 .http
133 .get(url)
134 .send()
135 .await
136 .map_err(|e| ToolchainError::RegistryError {
137 message: format!("failed to GET {url}: {e}"),
138 })?;
139 if resp.status() == reqwest::StatusCode::NOT_FOUND {
140 return Ok(None);
141 }
142 if !resp.status().is_success() {
143 return Err(ToolchainError::RegistryError {
144 message: format!("GET {url} returned status {}", resp.status()),
145 });
146 }
147 let bytes = resp
148 .bytes()
149 .await
150 .map_err(|e| ToolchainError::RegistryError {
151 message: format!("failed to read body from {url}: {e}"),
152 })?;
153 let data = serde_json::from_slice(&bytes).map_err(|e| ToolchainError::RegistryError {
154 message: format!("failed to parse JSON from {url}: {e}"),
155 })?;
156 Ok(Some(data))
157 }
158
159 async fn hint_formula_refresh(&self, name: &str) {
163 let Some(secret) = REPOSYNC_HMAC_SECRET.filter(|s| !s.is_empty()) else {
164 debug!(
165 name,
166 "no reposync HMAC secret compiled in; skipping refresh hint"
167 );
168 return;
169 };
170 let url = format!("{}/hint", self.base_url());
171 let body = format!(r#"{{"kind":"formula","name":"{name}"}}"#);
172 let signature = sign(secret, body.as_bytes());
173 match self
174 .http
175 .post(&url)
176 .header(REPOSYNC_SIGNATURE_HEADER, signature)
177 .header(reqwest::header::CONTENT_TYPE, "application/json")
178 .body(body)
179 .send()
180 .await
181 {
182 Ok(resp) => debug!(name, status = %resp.status(), "sent formula refresh hint"),
183 Err(e) => debug!(name, error = %e, "refresh hint failed (non-fatal)"),
184 }
185 }
186}
187
188#[must_use]
199pub fn sign(secret: &str, body: &[u8]) -> String {
200 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
201 .expect("HMAC accepts a key of any length");
202 mac.update(body);
203 format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
204}
205
206pub async fn download_verified(url: &str, dest: &Path, expected: Option<&str>) -> Result<String> {
221 let client = reqwest::Client::builder()
222 .user_agent("zlayer-toolchain")
223 .build()
224 .unwrap_or_default();
225 let mut resp = client
226 .get(url)
227 .send()
228 .await
229 .map_err(|e| ToolchainError::RegistryError {
230 message: format!("failed to download {url}: {e}"),
231 })?;
232 if !resp.status().is_success() {
233 return Err(ToolchainError::RegistryError {
234 message: format!("download failed with status {}: {url}", resp.status()),
235 });
236 }
237
238 if let Some(parent) = dest.parent() {
239 tokio::fs::create_dir_all(parent).await?;
240 }
241
242 let mut hasher = Sha256::new();
243 let mut file = tokio::fs::File::create(dest).await?;
244 while let Some(chunk) = resp
245 .chunk()
246 .await
247 .map_err(|e| ToolchainError::RegistryError {
248 message: format!("failed while streaming {url}: {e}"),
249 })?
250 {
251 hasher.update(&chunk);
252 file.write_all(&chunk).await?;
253 }
254 file.flush().await?;
255
256 let actual = hex::encode(hasher.finalize());
257
258 if let Some(expected) = expected {
259 let expected = expected.trim();
260 let expected = expected.strip_prefix("sha256:").unwrap_or(expected);
261 if !expected.is_empty() && !expected.eq_ignore_ascii_case(&actual) {
262 let _ = tokio::fs::remove_file(dest).await;
264 let tool = dest
265 .file_name()
266 .and_then(|n| n.to_str())
267 .unwrap_or("artifact")
268 .to_string();
269 return Err(ToolchainError::DigestMismatch {
270 tool,
271 expected: expected.to_ascii_lowercase(),
272 actual,
273 });
274 }
275 }
276
277 Ok(actual)
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn sign_is_hex_prefixed_and_64_chars() {
286 let sig = sign("secret", b"body");
287 assert!(sig.starts_with("sha256="));
288 let hex = &sig[7..];
289 assert_eq!(hex.len(), 64);
290 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
291 }
292
293 #[test]
294 fn sign_matches_known_vector() {
295 let sig = sign("key", b"The quick brown fox jumps over the lazy dog");
298 assert_eq!(
299 sig,
300 "sha256=f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
301 );
302 }
303
304 #[test]
305 fn client_trims_trailing_slash() {
306 let client = PackageIndexClient::new(zlayer_types::package_index::PackageIndexConfig::new(
307 "https://example.dev/",
308 ));
309 assert_eq!(client.base_url(), "https://example.dev");
310 }
311}