Skip to main content

zlayer_types/
package_index.rs

1//! Wire types for the ZLayer package index (`packages.zlayer.dev`).
2//!
3//! These are **pure serde** shapes: they carry no HTTP client and do no I/O. The
4//! index client (which fetches `/formula/:name` and `/choco/:name`, follows
5//! redirects, and fires HMAC-signed refresh hints) lives in `zlayer-toolchain`
6//! so that this crate stays dependency-light (no `reqwest`, no `tokio`).
7//!
8//! [`FormulaData`] deserializes the subset of a Homebrew formula JSON that the
9//! provisioning + integrity pipeline consumes: `versions.stable`,
10//! `urls.stable.{url,checksum}` (the source-tarball sha256, sometimes
11//! `sha256:`-prefixed), and `bottle.stable.files.<tag>.{url,sha256}` (the
12//! per-platform prebuilt bottle digests), plus the dependency graph the macOS
13//! source builder walks. Every field is permissive (`#[serde(default)]`) so a
14//! partial or evolving index payload never fails to parse.
15
16use std::collections::HashMap;
17
18use serde::{Deserialize, Serialize};
19
20/// Default base URL for the ZLayer package index.
21pub const DEFAULT_PACKAGE_INDEX_URL: &str = "https://packages.zlayer.dev";
22
23/// Environment variable overriding the package-index base URL.
24pub const PACKAGE_INDEX_URL_ENV: &str = "ZLAYER_PACKAGE_INDEX_URL";
25
26/// Configuration for the package-index client.
27///
28/// Pure data — the HTTP client that consumes it lives in `zlayer-toolchain`.
29/// Construct with [`PackageIndexConfig::from_env`] to honor
30/// [`PACKAGE_INDEX_URL_ENV`], falling back to [`DEFAULT_PACKAGE_INDEX_URL`].
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct PackageIndexConfig {
33    /// Base URL of the index (no trailing slash), e.g. `https://packages.zlayer.dev`.
34    pub base_url: String,
35}
36
37impl Default for PackageIndexConfig {
38    fn default() -> Self {
39        Self {
40            base_url: DEFAULT_PACKAGE_INDEX_URL.to_string(),
41        }
42    }
43}
44
45impl PackageIndexConfig {
46    /// Build a config from an explicit base URL (trailing slashes trimmed).
47    #[must_use]
48    pub fn new(base_url: impl Into<String>) -> Self {
49        let base_url = base_url.into().trim_end_matches('/').to_string();
50        let base_url = if base_url.is_empty() {
51            DEFAULT_PACKAGE_INDEX_URL.to_string()
52        } else {
53            base_url
54        };
55        Self { base_url }
56    }
57
58    /// Build a config from [`PACKAGE_INDEX_URL_ENV`], defaulting to
59    /// [`DEFAULT_PACKAGE_INDEX_URL`] when the variable is unset or empty.
60    #[must_use]
61    pub fn from_env() -> Self {
62        match std::env::var(PACKAGE_INDEX_URL_ENV) {
63            Ok(v) if !v.trim().is_empty() => Self::new(v.trim()),
64            _ => Self::default(),
65        }
66    }
67
68    /// The base URL with any trailing slash trimmed.
69    ///
70    /// [`PackageIndexConfig::new`] already normalizes this, but
71    /// [`Default`]/deserialized instances may retain one, so the URL helpers
72    /// trim defensively.
73    #[must_use]
74    fn base(&self) -> &str {
75        self.base_url.trim_end_matches('/')
76    }
77
78    /// URL of the Linux "unfulfilled request" endpoint (`{base_url}/linux/request`).
79    ///
80    /// The builder's cross-search harvest POSTs here (HMAC-signed) when a Linux
81    /// package name maps to no Homebrew/Chocolatey equivalent, so the index can
82    /// backfill the mapping for a future native build.
83    #[must_use]
84    pub fn linux_request_url(&self) -> String {
85        format!("{}/linux/request", self.base())
86    }
87
88    /// URL of the Chocolatey cache-warm hint endpoint (`{base_url}/choco-hint`).
89    ///
90    /// The Windows image resolver POSTs here (HMAC-signed) to nudge the index to
91    /// keep a distro/shard's Chocolatey mappings warm.
92    #[must_use]
93    pub fn choco_hint_url(&self) -> String {
94        format!("{}/choco-hint", self.base())
95    }
96}
97
98/// A parsed Homebrew formula (the subset served verbatim by `/formula/:name`).
99///
100/// The version is never hardcoded — a formula bump is picked up automatically.
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct FormulaData {
103    /// Released versions (we use `stable`).
104    #[serde(default)]
105    pub versions: FormulaVersions,
106    /// Source/bottle URLs (we use `stable.{url,checksum}`).
107    #[serde(default)]
108    pub urls: FormulaUrls,
109    /// Prebuilt bottle coordinates (per-platform `{url,sha256}`).
110    #[serde(default)]
111    pub bottle: FormulaBottle,
112    /// Runtime dependencies (other formulae the tool links / runs against).
113    #[serde(default)]
114    pub dependencies: Vec<String>,
115    /// Build-only dependencies (autoconf, pkgconf, cmake, ...).
116    #[serde(default)]
117    pub build_dependencies: Vec<String>,
118    /// Dependencies satisfied by macOS itself (curl, expat, zlib, ...). Each
119    /// entry is either a bare name or a `{name: [conditions]}` object.
120    #[serde(default)]
121    pub uses_from_macos: Vec<UsesFromMacos>,
122    /// Path of the formula's Ruby definition within homebrew-core
123    /// (e.g. `Formula/g/git.rb`).
124    #[serde(default)]
125    pub ruby_source_path: Option<String>,
126}
127
128/// Versions block (`versions.stable`).
129#[derive(Debug, Clone, Default, Serialize, Deserialize)]
130pub struct FormulaVersions {
131    /// Stable release version string (e.g. `2.55.0`).
132    #[serde(default)]
133    pub stable: Option<String>,
134}
135
136/// URLs block (`urls.stable`).
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138pub struct FormulaUrls {
139    /// Stable source-tarball coordinates.
140    #[serde(default)]
141    pub stable: Option<FormulaUrlStable>,
142}
143
144/// The stable source URL entry (`urls.stable`).
145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146pub struct FormulaUrlStable {
147    /// The source tarball URL.
148    #[serde(default)]
149    pub url: String,
150    /// The source tarball sha256 (sometimes `sha256:`-prefixed upstream).
151    #[serde(default)]
152    pub checksum: String,
153}
154
155/// Bottle block (`bottle.stable`).
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct FormulaBottle {
158    /// Stable bottle coordinates.
159    #[serde(default)]
160    pub stable: Option<FormulaBottleStable>,
161}
162
163/// The stable bottle entry, keyed by platform tag (`arm64_sonoma`, ...).
164#[derive(Debug, Clone, Default, Serialize, Deserialize)]
165pub struct FormulaBottleStable {
166    /// Per-platform bottle files (`bottle.stable.files.<tag>`).
167    #[serde(default)]
168    pub files: HashMap<String, FormulaBottleFile>,
169}
170
171/// A single per-platform bottle file (`{url, sha256}`).
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173pub struct FormulaBottleFile {
174    /// The bottle download URL (a GHCR blob).
175    #[serde(default)]
176    pub url: String,
177    /// The bottle sha256 (bare hex).
178    #[serde(default)]
179    pub sha256: String,
180}
181
182/// An entry in `uses_from_macos`: either a bare formula name, or a
183/// `{name: [conditions]}` object (e.g. `{"llvm": ["build"]}`).
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(untagged)]
186pub enum UsesFromMacos {
187    /// A bare dependency name provided by macOS.
188    Name(String),
189    /// A conditional dependency: the single key is the formula name.
190    Conditional(HashMap<String, serde_json::Value>),
191}
192
193impl UsesFromMacos {
194    /// The dependency name, regardless of the entry shape.
195    #[must_use]
196    pub fn name(&self) -> Option<&str> {
197        match self {
198            Self::Name(name) => Some(name.as_str()),
199            Self::Conditional(map) => map.keys().next().map(String::as_str),
200        }
201    }
202}
203
204impl FormulaData {
205    /// The stable version, if present and non-empty.
206    #[must_use]
207    pub fn stable_version(&self) -> Option<&str> {
208        self.versions.stable.as_deref().filter(|v| !v.is_empty())
209    }
210
211    /// The stable source tarball URL, if present and non-empty.
212    #[must_use]
213    pub fn stable_url(&self) -> Option<&str> {
214        self.urls
215            .stable
216            .as_ref()
217            .map(|u| u.url.as_str())
218            .filter(|u| !u.is_empty())
219    }
220
221    /// The stable source tarball sha256 (bare hex, `sha256:` prefix stripped),
222    /// if present and non-empty.
223    #[must_use]
224    pub fn stable_checksum(&self) -> Option<String> {
225        self.urls
226            .stable
227            .as_ref()
228            .map(|u| u.checksum.trim())
229            .filter(|c| !c.is_empty())
230            .map(|c| c.strip_prefix("sha256:").unwrap_or(c).to_string())
231    }
232
233    /// The prebuilt bottle file for a platform `tag` (e.g. `arm64_sonoma`).
234    #[must_use]
235    pub fn bottle_file(&self, tag: &str) -> Option<&FormulaBottleFile> {
236        self.bottle.stable.as_ref().and_then(|b| b.files.get(tag))
237    }
238
239    /// The set of dependency names macOS itself provides (so a source build
240    /// need not resolve them as kegs).
241    #[must_use]
242    pub fn macos_provided(&self) -> Vec<String> {
243        self.uses_from_macos
244            .iter()
245            .filter_map(|u| u.name().map(String::from))
246            .collect()
247    }
248}
249
250/// A parsed Chocolatey `OData` package entry (the subset served by `/choco/:name`).
251///
252/// Choco publishes a `.nupkg` URL and a `Version`, but **no reliable sha256**
253/// (the hash is computed on download), so [`ChocoData::sha256`] is `Option` and
254/// typically `None`.
255#[derive(Debug, Clone, Default, Serialize, Deserialize)]
256pub struct ChocoData {
257    /// The package version (`Version` in `OData`).
258    #[serde(default, alias = "Version")]
259    pub version: String,
260    /// The `.nupkg` download URL.
261    #[serde(default, alias = "Url", alias = "url", alias = "nupkg_url")]
262    pub url: String,
263    /// The package sha256, when the index happens to carry one (usually absent).
264    #[serde(default, alias = "Sha256")]
265    pub sha256: Option<String>,
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    const GIT_JSON: &str = r#"{
273        "versions": {"stable": "2.55.0"},
274        "urls": {"stable": {"url": "https://example/git-2.55.0.tar.xz", "checksum": "sha256:abc123"}},
275        "bottle": {"stable": {"files": {
276            "arm64_sonoma": {"url": "https://ghcr.io/git", "sha256": "deadbeef"}
277        }}},
278        "dependencies": ["pcre2", "gettext"],
279        "build_dependencies": ["gettext", "pkgconf"],
280        "uses_from_macos": ["curl", "expat", {"llvm": ["build"]}],
281        "ruby_source_path": "Formula/g/git.rb"
282    }"#;
283
284    #[test]
285    fn parses_all_fields_including_checksum_and_bottle() {
286        let f: FormulaData = serde_json::from_str(GIT_JSON).unwrap();
287        assert_eq!(f.stable_version(), Some("2.55.0"));
288        assert_eq!(f.stable_url(), Some("https://example/git-2.55.0.tar.xz"));
289        // `sha256:` prefix is stripped.
290        assert_eq!(f.stable_checksum().as_deref(), Some("abc123"));
291        assert_eq!(f.dependencies, vec!["pcre2", "gettext"]);
292        assert_eq!(f.build_dependencies, vec!["gettext", "pkgconf"]);
293        assert_eq!(f.ruby_source_path.as_deref(), Some("Formula/g/git.rb"));
294        assert_eq!(f.macos_provided(), vec!["curl", "expat", "llvm"]);
295        let bottle = f.bottle_file("arm64_sonoma").unwrap();
296        assert_eq!(bottle.url, "https://ghcr.io/git");
297        assert_eq!(bottle.sha256, "deadbeef");
298        assert!(f.bottle_file("missing").is_none());
299    }
300
301    #[test]
302    fn missing_fields_default_cleanly() {
303        let f: FormulaData = serde_json::from_str("{}").unwrap();
304        assert_eq!(f.stable_version(), None);
305        assert_eq!(f.stable_url(), None);
306        assert_eq!(f.stable_checksum(), None);
307        assert!(f.dependencies.is_empty());
308        assert!(f.macos_provided().is_empty());
309        assert!(f.ruby_source_path.is_none());
310    }
311
312    #[test]
313    fn empty_version_url_and_checksum_are_absent() {
314        let f: FormulaData = serde_json::from_str(
315            r#"{"versions":{"stable":""},"urls":{"stable":{"url":"","checksum":""}}}"#,
316        )
317        .unwrap();
318        assert_eq!(f.stable_version(), None);
319        assert_eq!(f.stable_url(), None);
320        assert_eq!(f.stable_checksum(), None);
321    }
322
323    #[test]
324    fn checksum_without_prefix_passes_through() {
325        let f: FormulaData =
326            serde_json::from_str(r#"{"urls":{"stable":{"url":"u","checksum":"beef"}}}"#).unwrap();
327        assert_eq!(f.stable_checksum().as_deref(), Some("beef"));
328    }
329
330    #[test]
331    fn config_from_env_defaults() {
332        // Explicit constructor trims trailing slashes.
333        assert_eq!(
334            PackageIndexConfig::new("https://x.dev/").base_url,
335            "https://x.dev"
336        );
337        assert_eq!(
338            PackageIndexConfig::default().base_url,
339            DEFAULT_PACKAGE_INDEX_URL
340        );
341    }
342
343    #[test]
344    fn derived_endpoint_urls() {
345        let cfg = PackageIndexConfig::new("https://packages.example.dev/");
346        assert_eq!(
347            cfg.linux_request_url(),
348            "https://packages.example.dev/linux/request"
349        );
350        assert_eq!(
351            cfg.choco_hint_url(),
352            "https://packages.example.dev/choco-hint"
353        );
354        // Default base yields the production endpoints.
355        assert_eq!(
356            PackageIndexConfig::default().linux_request_url(),
357            format!("{DEFAULT_PACKAGE_INDEX_URL}/linux/request")
358        );
359    }
360
361    #[test]
362    fn choco_parses_odata_casing() {
363        let c: ChocoData =
364            serde_json::from_str(r#"{"Version":"1.2.3","Url":"https://x/pkg.nupkg"}"#).unwrap();
365        assert_eq!(c.version, "1.2.3");
366        assert_eq!(c.url, "https://x/pkg.nupkg");
367        assert!(c.sha256.is_none());
368    }
369}