Skip to main content

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}