zlayer_toolchain/package_index.rs
1//! HTTP client for the ZLayer package index (`packages.zlayer.dev`) plus the
2//! shared streaming-download-with-integrity primitive.
3//!
4//! `packages.zlayer.dev` is a Cloudflare worker in front of the Komodo
5//! `ZPackageIndex` container. Public reads:
6//! - `GET /formula/:name` → a Homebrew formula JSON ([`FormulaData`]) carrying
7//! `versions.stable`, `urls.stable.{url,checksum}` and per-platform
8//! `bottle.stable.files.<tag>.{url,sha256}`.
9//! - `GET /choco/:name` → a Chocolatey `OData` entry ([`ChocoData`]).
10//!
11//! The index serves both routes wrapped in a `{"name": ..., "data": {...}}`
12//! envelope (the direct upstreams serve the bare payload), so every JSON parse
13//! here goes through [`parse_maybe_enveloped`], which tolerates both shapes.
14//!
15//! Writes / hints / `/admin/*` require an HMAC signature
16//! (`x-reposync-signature: sha256=<hex>` over the request body), keyed by the
17//! `ZLAYER_REPOSYNC_HMAC_SECRET` build-time secret. [`sign`] is the single DRY
18//! home for that signature — the builder's harvest / windows-image resolvers
19//! (a later commit) reuse it instead of re-deriving HMAC each.
20//!
21//! Reads degrade gracefully: the index base URL first, then (on miss/failure)
22//! the direct upstream (`formulae.brew.sh` for brew). On a `/formula/:name` miss
23//! the client fires a best-effort HMAC refresh hint and retries once before
24//! falling back.
25
26use std::path::Path;
27
28use hmac::{Hmac, Mac};
29use serde::de::DeserializeOwned;
30use sha2::{Digest, Sha256};
31use tokio::io::AsyncWriteExt;
32use tracing::debug;
33
34use crate::error::{Result, ToolchainError};
35use zlayer_types::package_index::{ChocoData, FormulaData, PackageIndexConfig};
36
37/// Build-time reposync HMAC secret (used to sign refresh hints / write calls).
38/// Absent in most builds — hints are then simply skipped (best-effort).
39const REPOSYNC_HMAC_SECRET: Option<&str> = option_env!("ZLAYER_REPOSYNC_HMAC_SECRET");
40
41/// The header carrying the reposync HMAC signature.
42const REPOSYNC_SIGNATURE_HEADER: &str = "x-reposync-signature";
43
44/// A typed client over the ZLayer package index.
45///
46/// Cheap to construct and clone-free per call; the inner `reqwest::Client`
47/// follows redirects by default (the index answers `/formula/:name` with a 302
48/// to the cached artifact host for some routes).
49pub struct PackageIndexClient {
50 config: PackageIndexConfig,
51 http: reqwest::Client,
52}
53
54impl PackageIndexClient {
55 /// Build a client from an explicit [`PackageIndexConfig`]. Infallible: a
56 /// `reqwest::Client` build error falls back to `reqwest::Client::default`.
57 #[must_use]
58 pub fn new(config: PackageIndexConfig) -> Self {
59 let http = reqwest::Client::builder()
60 .user_agent("zlayer-toolchain")
61 .build()
62 .unwrap_or_default();
63 Self { config, http }
64 }
65
66 /// Build a client honoring `ZLAYER_PACKAGE_INDEX_URL` (default
67 /// `https://packages.zlayer.dev`).
68 #[must_use]
69 pub fn from_env() -> Self {
70 Self::new(PackageIndexConfig::from_env())
71 }
72
73 /// The configured base URL (no trailing slash).
74 #[must_use]
75 pub fn base_url(&self) -> &str {
76 self.config.base_url.trim_end_matches('/')
77 }
78
79 /// Fetch and parse a formula from `{base_url}/formula/{name}`.
80 ///
81 /// Fallback chain: the index first; on an explicit miss (404) fire a
82 /// best-effort HMAC refresh hint and retry once; on any remaining
83 /// miss/failure fall back to the direct `formulae.brew.sh` upstream.
84 ///
85 /// # Errors
86 ///
87 /// Returns [`ToolchainError::RegistryError`] only when neither the index nor
88 /// the upstream can produce a parseable formula.
89 pub async fn get_formula(&self, name: &str) -> Result<FormulaData> {
90 let primary = format!("{}/formula/{name}", self.base_url());
91
92 match self.try_get_json::<FormulaData>(&primary).await {
93 Ok(Some(data)) if !formula_is_hollow(&data) => return Ok(data),
94 Ok(Some(_)) => {
95 // Parsed "successfully" but carries neither a stable version nor
96 // a stable URL — the hollow signature of a mis-shaped payload
97 // (every `FormulaData` field is serde-defaulted, so junk JSON
98 // parses into a husk). Treat it like a parse failure so the
99 // brew.sh fallback below actually fires instead of the caller
100 // dying later with "formula X has no stable version".
101 debug!(
102 name,
103 "package index returned a hollow formula (no stable version/url); trying brew upstream"
104 );
105 }
106 Ok(None) => {
107 // Explicit index miss: nudge the index to sync, then retry once.
108 self.hint_formula_refresh(name).await;
109 if let Ok(Some(data)) = self.try_get_json::<FormulaData>(&primary).await {
110 if !formula_is_hollow(&data) {
111 return Ok(data);
112 }
113 debug!(
114 name,
115 "package index retry returned a hollow formula; trying brew upstream"
116 );
117 }
118 }
119 Err(e) => debug!(name, error = %e, "package index unreachable; trying brew upstream"),
120 }
121
122 let upstream = format!("https://formulae.brew.sh/api/formula/{name}.json");
123 match self.try_get_json::<FormulaData>(&upstream).await {
124 Ok(Some(data)) => Ok(data),
125 Ok(None) => Err(ToolchainError::RegistryError {
126 message: format!("formula {name} not found in package index or brew upstream"),
127 }),
128 Err(e) => Err(e),
129 }
130 }
131
132 /// Fetch and parse a Chocolatey `OData` entry from `{base_url}/choco/{name}`.
133 ///
134 /// # Errors
135 ///
136 /// Returns [`ToolchainError::RegistryError`] on a miss or transport/parse
137 /// failure.
138 pub async fn get_choco(&self, name: &str) -> Result<ChocoData> {
139 let url = format!("{}/choco/{name}", self.base_url());
140 match self.try_get_json::<ChocoData>(&url).await {
141 Ok(Some(data)) if !choco_is_hollow(&data) => Ok(data),
142 // Hollow husk (see `choco_is_hollow`): a mis-shaped payload parsed
143 // into an all-defaulted `ChocoData`. There is no direct upstream
144 // fallback for choco, so fail loudly instead of returning a husk
145 // the caller would reject later with a misleading error.
146 Ok(Some(_)) => Err(ToolchainError::RegistryError {
147 message: format!(
148 "choco package {name}: index payload parsed hollow (no version) — mis-shaped response"
149 ),
150 }),
151 Ok(None) => Err(ToolchainError::RegistryError {
152 message: format!("choco package {name} not found in package index"),
153 }),
154 Err(e) => Err(e),
155 }
156 }
157
158 /// GET `url` and deserialize the body as `T`, tolerating both the index's
159 /// `{name, data}` envelope and the bare payload (see
160 /// [`parse_maybe_enveloped`]). Returns `Ok(None)` for a 404 (an explicit
161 /// not-found), `Err` for any other non-success status or a transport /
162 /// parse failure.
163 async fn try_get_json<T: DeserializeOwned>(&self, url: &str) -> Result<Option<T>> {
164 let resp = self
165 .http
166 .get(url)
167 .send()
168 .await
169 .map_err(|e| ToolchainError::RegistryError {
170 message: format!("failed to GET {url}: {e}"),
171 })?;
172 if resp.status() == reqwest::StatusCode::NOT_FOUND {
173 return Ok(None);
174 }
175 if !resp.status().is_success() {
176 return Err(ToolchainError::RegistryError {
177 message: format!("GET {url} returned status {}", resp.status()),
178 });
179 }
180 let bytes = resp
181 .bytes()
182 .await
183 .map_err(|e| ToolchainError::RegistryError {
184 message: format!("failed to read body from {url}: {e}"),
185 })?;
186 let data = parse_maybe_enveloped(&bytes).map_err(|e| ToolchainError::RegistryError {
187 message: format!("failed to parse JSON from {url}: {e}"),
188 })?;
189 Ok(Some(data))
190 }
191
192 /// Fire a best-effort, HMAC-signed refresh hint for a formula miss. Never
193 /// fatal: a missing secret, a transport error, or a non-2xx response is
194 /// logged and swallowed so a read never fails because a hint failed.
195 async fn hint_formula_refresh(&self, name: &str) {
196 let Some(secret) = REPOSYNC_HMAC_SECRET.filter(|s| !s.is_empty()) else {
197 debug!(
198 name,
199 "no reposync HMAC secret compiled in; skipping refresh hint"
200 );
201 return;
202 };
203 let url = format!("{}/hint", self.base_url());
204 let body = format!(r#"{{"kind":"formula","name":"{name}"}}"#);
205 let signature = sign(secret, body.as_bytes());
206 match self
207 .http
208 .post(&url)
209 .header(REPOSYNC_SIGNATURE_HEADER, signature)
210 .header(reqwest::header::CONTENT_TYPE, "application/json")
211 .body(body)
212 .send()
213 .await
214 {
215 Ok(resp) => debug!(name, status = %resp.status(), "sent formula refresh hint"),
216 Err(e) => debug!(name, error = %e, "refresh hint failed (non-fatal)"),
217 }
218 }
219}
220
221/// The `{"name": ..., "data": {...}}` envelope `packages.zlayer.dev` wraps
222/// around `/formula/:name` and `/choco/:name` payloads. Only `data` matters;
223/// the outer `name` is ignored.
224#[derive(serde::Deserialize)]
225struct Enveloped<T> {
226 data: T,
227}
228
229/// Parse a body that is either the index's `{name, data: T}` envelope or a
230/// bare `T`, returning the inner `T`.
231///
232/// ORDER MATTERS — envelope first. A bare `T` body FAILS the [`Enveloped`]
233/// parse (missing the `data` field), so falling through to the bare parse is
234/// safe. The reverse order would be a bug: [`FormulaData`] / [`ChocoData`] are
235/// fully serde-defaulted and don't deny unknown fields, so parsing the
236/// ENVELOPE as bare `T` "succeeds" into a hollow all-defaults husk — exactly
237/// the production failure this helper fixes. On double failure the bare
238/// parse's error is returned (the more useful one for a bare-shaped body).
239fn parse_maybe_enveloped<T: DeserializeOwned>(bytes: &[u8]) -> serde_json::Result<T> {
240 match serde_json::from_slice::<Enveloped<T>>(bytes) {
241 Ok(envelope) => Ok(envelope.data),
242 Err(_) => serde_json::from_slice::<T>(bytes),
243 }
244}
245
246/// Whether a parsed [`FormulaData`] is a hollow husk: neither a stable version
247/// nor a stable source URL. Every real formula the provisioning pipeline can
248/// consume carries at least one of these; the all-defaults result of parsing a
249/// mis-shaped payload carries neither. Callers treat hollow as a parse
250/// failure so the brew.sh fallback fires instead of the caller dying later
251/// with "formula X has no stable version" (`source_build::resolve_source_spec`).
252fn formula_is_hollow(data: &FormulaData) -> bool {
253 data.stable_version().is_none() && data.stable_url().is_none()
254}
255
256/// Whether a parsed [`ChocoData`] is a hollow husk: an empty `version`. Every
257/// real choco entry carries `Version`; `url` is NOT part of the signature
258/// because the live index payload legitimately omits it (the `.nupkg` URL
259/// check lives in the consumer, `windows::resolve_choco`).
260fn choco_is_hollow(data: &ChocoData) -> bool {
261 data.version.trim().is_empty()
262}
263
264/// Compute the reposync HMAC signature for a request `body`:
265/// `sha256=<hex>` = `HMAC-SHA256(secret, body)`.
266///
267/// This is the single DRY home for the reposync signature; the builder's
268/// harvest / windows-image resolvers reuse it rather than re-deriving HMAC.
269///
270/// # Panics
271///
272/// Never in practice: HMAC-SHA256 accepts a key of any length, so keying is
273/// infallible.
274#[must_use]
275pub fn sign(secret: &str, body: &[u8]) -> String {
276 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
277 .expect("HMAC accepts a key of any length");
278 mac.update(body);
279 format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
280}
281
282/// Stream `url` into `dest` while hashing with SHA-256.
283///
284/// When `expected` is `Some`, the computed digest is compared (case-insensitively,
285/// with any `sha256:` prefix stripped) against it; on mismatch the partial file
286/// is deleted and [`ToolchainError::DigestMismatch`] is returned. The lowercase
287/// hex of the computed digest is always returned on success, so callers that had
288/// no expected digest still learn (and can record) the real hash — the
289/// "compute-on-download" path for artifacts whose upstream publishes no digest.
290///
291/// # Errors
292///
293/// Returns [`ToolchainError::RegistryError`] on a transport error or non-success
294/// status, [`ToolchainError::DigestMismatch`] on a digest mismatch, and
295/// [`ToolchainError::IoError`] on a filesystem error.
296pub async fn download_verified(url: &str, dest: &Path, expected: Option<&str>) -> Result<String> {
297 let client = reqwest::Client::builder()
298 .user_agent("zlayer-toolchain")
299 .build()
300 .unwrap_or_default();
301 let mut resp = client
302 .get(url)
303 .send()
304 .await
305 .map_err(|e| ToolchainError::RegistryError {
306 message: format!("failed to download {url}: {e}"),
307 })?;
308 if !resp.status().is_success() {
309 return Err(ToolchainError::RegistryError {
310 message: format!("download failed with status {}: {url}", resp.status()),
311 });
312 }
313
314 if let Some(parent) = dest.parent() {
315 tokio::fs::create_dir_all(parent).await?;
316 }
317
318 let mut hasher = Sha256::new();
319 let mut file = tokio::fs::File::create(dest).await?;
320 while let Some(chunk) = resp
321 .chunk()
322 .await
323 .map_err(|e| ToolchainError::RegistryError {
324 message: format!("failed while streaming {url}: {e}"),
325 })?
326 {
327 hasher.update(&chunk);
328 file.write_all(&chunk).await?;
329 }
330 file.flush().await?;
331
332 let actual = hex::encode(hasher.finalize());
333
334 if let Some(expected) = expected {
335 let expected = expected.trim();
336 let expected = expected.strip_prefix("sha256:").unwrap_or(expected);
337 if !expected.is_empty() && !expected.eq_ignore_ascii_case(&actual) {
338 // Delete the partial/mismatched artifact so a retry starts clean.
339 let _ = tokio::fs::remove_file(dest).await;
340 let tool = dest
341 .file_name()
342 .and_then(|n| n.to_str())
343 .unwrap_or("artifact")
344 .to_string();
345 return Err(ToolchainError::DigestMismatch {
346 tool,
347 expected: expected.to_ascii_lowercase(),
348 actual,
349 });
350 }
351 }
352
353 Ok(actual)
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn sign_is_hex_prefixed_and_64_chars() {
362 let sig = sign("secret", b"body");
363 assert!(sig.starts_with("sha256="));
364 let hex = &sig[7..];
365 assert_eq!(hex.len(), 64);
366 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
367 }
368
369 #[test]
370 fn sign_matches_known_vector() {
371 // HMAC-SHA256(key="key", msg="The quick brown fox jumps over the lazy dog")
372 // — the canonical RFC-style test vector.
373 let sig = sign("key", b"The quick brown fox jumps over the lazy dog");
374 assert_eq!(
375 sig,
376 "sha256=f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
377 );
378 }
379
380 #[test]
381 fn client_trims_trailing_slash() {
382 let client = PackageIndexClient::new(zlayer_types::package_index::PackageIndexConfig::new(
383 "https://example.dev/",
384 ));
385 assert_eq!(client.base_url(), "https://example.dev");
386 }
387
388 /// A bare formula body (the `formulae.brew.sh` shape).
389 const BARE_FORMULA: &str = r#"{
390 "versions": {"stable": "1.8.2"},
391 "urls": {"stable": {"url": "https://example/jq-1.8.2.tar.gz", "checksum": "sha256:abc"}}
392 }"#;
393
394 /// The same formula wrapped in the index's `{name, data}` envelope (the
395 /// live `packages.zlayer.dev/formula/jq` shape).
396 fn enveloped_formula() -> String {
397 format!(r#"{{"name":"jq","data":{BARE_FORMULA}}}"#)
398 }
399
400 #[test]
401 fn formula_envelope_unwraps_to_inner_data() {
402 let f: FormulaData = parse_maybe_enveloped(enveloped_formula().as_bytes()).unwrap();
403 assert_eq!(f.stable_version(), Some("1.8.2"));
404 assert_eq!(f.stable_url(), Some("https://example/jq-1.8.2.tar.gz"));
405 assert!(!formula_is_hollow(&f));
406 }
407
408 #[test]
409 fn formula_bare_body_still_parses() {
410 let f: FormulaData = parse_maybe_enveloped(BARE_FORMULA.as_bytes()).unwrap();
411 assert_eq!(f.stable_version(), Some("1.8.2"));
412 assert_eq!(f.stable_url(), Some("https://example/jq-1.8.2.tar.gz"));
413 assert!(!formula_is_hollow(&f));
414 }
415
416 /// Regression for the production bug: parsing the ENVELOPE as bare
417 /// `FormulaData` "succeeds" into a hollow husk (all fields serde-defaulted,
418 /// unknown fields ignored). The hollow guard must flag it so `get_formula`
419 /// routes to the brew.sh fallback instead of returning the husk.
420 #[test]
421 fn envelope_parsed_as_bare_formula_is_hollow_and_guarded() {
422 let husk: FormulaData = serde_json::from_str(&enveloped_formula()).unwrap();
423 assert_eq!(husk.stable_version(), None, "husk parse must stay hollow");
424 assert_eq!(husk.stable_url(), None);
425 assert!(formula_is_hollow(&husk));
426 }
427
428 /// The live `packages.zlayer.dev/choco/:name` shape: an `OData` entry (with
429 /// `Version` but no url alias) inside the `{name, data}` envelope.
430 const ENVELOPED_CHOCO: &str =
431 r#"{"name":"jq","data":{"Id":"jq","Version":"1.8.1","Title":"jq"}}"#;
432
433 #[test]
434 fn choco_envelope_unwraps_to_inner_data() {
435 let c: ChocoData = parse_maybe_enveloped(ENVELOPED_CHOCO.as_bytes()).unwrap();
436 assert_eq!(c.version, "1.8.1");
437 assert!(!choco_is_hollow(&c));
438 }
439
440 #[test]
441 fn choco_bare_body_still_parses() {
442 let c: ChocoData =
443 parse_maybe_enveloped(br#"{"Version":"1.2.3","Url":"https://x/pkg.nupkg"}"#).unwrap();
444 assert_eq!(c.version, "1.2.3");
445 assert_eq!(c.url, "https://x/pkg.nupkg");
446 assert!(!choco_is_hollow(&c));
447 }
448
449 /// Regression mirror of the formula case: the envelope parsed as bare
450 /// `ChocoData` "succeeds" hollow; the guard must flag it so `get_choco`
451 /// errors loudly instead of returning a versionless husk.
452 #[test]
453 fn envelope_parsed_as_bare_choco_is_hollow_and_guarded() {
454 let husk: ChocoData = serde_json::from_str(ENVELOPED_CHOCO).unwrap();
455 assert!(husk.version.is_empty(), "husk parse must stay hollow");
456 assert!(choco_is_hollow(&husk));
457 }
458
459 #[test]
460 fn parse_error_reported_for_garbage_body() {
461 assert!(parse_maybe_enveloped::<FormulaData>(b"not json").is_err());
462 }
463}