Skip to main content

zlayer_toolchain/
prebuilt.rs

1//! Fetch self-contained, **relocation-free** language toolchains as prebuilt
2//! vendor archives — "our apt-get" for the macOS sandbox, the prebuilt arm.
3//!
4//! # Why a prebuilt archive (vs a source build)
5//!
6//! The C-tool population ([`crate::source_build`]) has to be compiled at an
7//! absolute toolchain prefix so its baked install-names point at real paths. The
8//! language toolchains don't: vendors (go.dev, nodejs.org, the
9//! `astral-sh`/`oven-sh`/`denoland`/`ziglang`/Adoptium/`GraalVM` releases) publish
10//! macOS archives that are already position-independent and link only the macOS
11//! system libraries (`/usr/lib/...`). Dropping one into an absolute **toolchain** and
12//! pointing `PATH` / `GOROOT` / `JAVA_HOME` / `CARGO_HOME` at that toolchain yields a
13//! tool that runs unmodified under a deny-default Seatbelt profile.
14//!
15//! # What this builds
16//!
17//! A toolchain for one of go/node/rust/python/deno/bun/zig/java/graalvm: resolve the
18//! requested `formula` to a concrete vendor URL (replicating the live
19//! version-resolution that the rootfs provisioner in `zlayer-builder` performs),
20//! download it, extract it into the toolchain with the canonical per-language on-disk
21//! layout, and write a [`ToolchainManifest`] whose `path_dirs` + `env` are absolute
22//! toolchain-rooted paths. Swift is intentionally **not** handled here — on macOS it is
23//! provisioned from the host Xcode, so it falls through to the source arm.
24//!
25//! The resolvers, API structs and extraction logic are duplicated from
26//! `zlayer_builder::macos_toolchain` on purpose: `zlayer-toolchain` is a leaf
27//! crate and must not depend on `zlayer-builder` (that would re-introduce the
28//! build cycle this crate exists to break).
29
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32
33use tracing::{debug, info, warn};
34
35use crate::error::{Result, ToolchainError};
36use crate::manifest::{ToolchainManifest, ToolchainSource};
37use crate::registry::ToolchainArtifactId;
38
39/// The platform token stored in a prebuilt artifact id (prebuilts are the macOS
40/// language toolchains).
41const MACOS_OS_TOKEN: &str = "macos";
42
43// ---------------------------------------------------------------------------
44// Public API
45// ---------------------------------------------------------------------------
46
47/// A resolved prebuilt toolchain: the concrete version, the vendor download URL,
48/// and the upstream-published sha256 when one is available.
49///
50/// `sha256` is `None` for the "compute-on-download" path — vendors that publish
51/// no per-artifact digest (or exact-version code paths that construct the URL
52/// without querying the release index). [`download_verified`] then records the
53/// digest it computes over the bytes.
54///
55/// [`download_verified`]: crate::package_index::download_verified
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct PrebuiltResolution {
58    /// The concrete resolved version (e.g. `1.23.6`).
59    pub version: String,
60    /// The vendor download URL.
61    pub url: String,
62    /// The upstream-published sha256 (bare hex), when available.
63    pub sha256: Option<String>,
64}
65
66/// Resolve `formula` to a [`PrebuiltResolution`] for `vendor_arch`
67/// (`arm64` / `amd64`) without downloading — used by the lockfile resolver.
68///
69/// # Errors
70///
71/// Returns [`ToolchainError::RegistryError`] if the version cannot be resolved.
72pub(crate) async fn resolve_prebuilt(
73    formula: &str,
74    vendor_arch: &str,
75) -> Result<PrebuiltResolution> {
76    let (language, version_token) = formula_language_version(formula);
77    resolve_prebuilt_url(&language, &version_token, vendor_arch).await
78}
79
80/// Return `true` if `formula` names a language toolchain handled by the prebuilt
81/// fetcher (go/golang, node/nodejs, rust, python/python3/`python@N`, deno, bun,
82/// zig, java/openjdk/`openjdk@N`, and any `graalvm`-containing name).
83///
84/// Returns `false` for everything else — including `swift`, which on macOS is
85/// provisioned from the host Xcode by the source arm.
86#[must_use]
87#[allow(clippy::module_name_repetitions)]
88pub fn is_prebuilt_formula(formula: &str) -> bool {
89    let (language, _) = formula_language_version(formula);
90    matches!(
91        language.as_str(),
92        "go" | "node" | "rust" | "python" | "deno" | "bun" | "zig" | "java" | "graalvm"
93    )
94}
95
96/// Fetch + extract the prebuilt toolchain for `formula` into a toolchain under
97/// `cache_dir`, write its [`ToolchainManifest`], and return the toolchain path.
98///
99/// Idempotent: a `<toolchain>/.ready` marker (written LAST, after `toolchain.json`)
100/// short-circuits a populated toolchain. A cold fetch removes any partial toolchain,
101/// downloads into a scratch subdir, extracts into the toolchain root, writes the
102/// manifest, cleans scratch, then stamps `.ready`.
103///
104/// The toolchain directory is `<cache_dir>/<formula>-<resolved_version>-<arch>`, where
105/// `formula` is the **original** request string (so `python@3.12` / `node@22`
106/// keep their `@`, matching how the cache is keyed elsewhere) and
107/// `resolved_version` is the concrete version the vendor API resolved to.
108///
109/// # Errors
110///
111/// Returns [`ToolchainError::RegistryError`] on version-resolution, download or
112/// extraction failure, and propagates I/O errors via
113/// [`ToolchainError::IoError`].
114#[allow(clippy::module_name_repetitions)]
115pub async fn ensure_prebuilt(
116    formula: &str,
117    cache_dir: &Path,
118    lockfile: Option<&crate::ToolchainLockfile>,
119) -> Result<PathBuf> {
120    use crate::ToolchainLockfileExt;
121    let (language, version_token) = formula_language_version(formula);
122
123    // A lock hit pins the exact version + URL + digest (consume-only). Otherwise
124    // resolve live: `host_arch()` (arm64/amd64) is the token vendor URLs use; the
125    // toolchain dir uses `arch_token()` (arm64/x86_64).
126    let (resolved_version, url, expected_sha) =
127        if let Some(locked) = lockfile.and_then(|l| l.lookup(formula, "macos", arch_token())) {
128            (
129                locked.version.clone(),
130                locked.url.clone(),
131                Some(locked.sha256.clone()),
132            )
133        } else {
134            let resolution = resolve_prebuilt_url(&language, &version_token, host_arch()).await?;
135            (resolution.version, resolution.url, resolution.sha256)
136        };
137
138    let toolchain = cache_dir.join(format!("{formula}-{resolved_version}-{}", arch_token()));
139    let ready_marker = toolchain.join(".ready");
140
141    // Local `.ready` wins FIRST (zero network).
142    if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
143        return Ok(toolchain);
144    }
145
146    // Identity known → pull-first (a cheap win). Prebuilts are EXCLUDED from
147    // publish + relocate (they are already relocation-free vendor archives, and
148    // large — the OCI client buffers blobs in memory), so a local build only
149    // records coverage `Built` (no ref, no relocate, no publish) via
150    // `finish_built(is_prebuilt = true)`.
151    let id = ToolchainArtifactId {
152        tool: formula.to_string(),
153        version: resolved_version.clone(),
154        os: MACOS_OS_TOKEN.to_string(),
155        arch: arch_token().to_string(),
156    };
157    if crate::pull_first(&id, &toolchain).await?.is_some() {
158        return Ok(toolchain);
159    }
160
161    // Miss → fetch fresh, then record coverage (`Built`, no publish/relocate).
162    match fetch_prebuilt_fresh(
163        formula,
164        &language,
165        &resolved_version,
166        &url,
167        expected_sha.as_deref(),
168        &toolchain,
169    )
170    .await
171    {
172        Ok(()) => {
173            crate::finish_built(&id, &toolchain, None, true).await;
174            Ok(toolchain)
175        }
176        Err(e) => {
177            crate::record_failed(&id, &e).await;
178            Err(e)
179        }
180    }
181}
182
183/// Fetch + extract a prebuilt vendor archive into a freshly-cleared `toolchain`,
184/// write its manifest, and stamp `.ready`. Split out of [`ensure_prebuilt`] so
185/// the coverage wrapper stays thin and any error routes to a single
186/// `record_failed`. Prebuilts are relocation-free vendor archives, so this does
187/// NOT relocate — it finalizes directly.
188async fn fetch_prebuilt_fresh(
189    formula: &str,
190    language: &str,
191    resolved_version: &str,
192    url: &str,
193    expected_sha: Option<&str>,
194    toolchain: &Path,
195) -> Result<()> {
196    // Fresh fetch. Drop any partial toolchain from a crashed prior attempt.
197    let _ = tokio::fs::remove_dir_all(toolchain).await;
198    tokio::fs::create_dir_all(toolchain).await?;
199
200    let scratch = toolchain.join(".download");
201    tokio::fs::create_dir_all(&scratch).await?;
202
203    info!(
204        formula,
205        language,
206        version = %resolved_version,
207        url = %url,
208        "fetching prebuilt toolchain archive"
209    );
210
211    // 1. Download the vendor archive into the scratch dir, verifying against the
212    //    upstream/lockfile digest when known (else recording the computed hash).
213    let archive = scratch.join("archive");
214    let computed_sha = crate::package_index::download_verified(url, &archive, expected_sha).await?;
215
216    // 2. Extract it into the toolchain root with the canonical per-language layout.
217    extract_toolchain(language, &archive, toolchain).await?;
218
219    // 3. Rust standalone installs its binaries under `<toolchain>/bin`; create the
220    //    writable CARGO_HOME/RUSTUP_HOME dirs the manifest env points at so a
221    //    later `cargo install` lands on `PATH` (`<toolchain>/cargo/bin`).
222    if language == "rust" {
223        tokio::fs::create_dir_all(toolchain.join("cargo/bin")).await?;
224        tokio::fs::create_dir_all(toolchain.join("rustup")).await?;
225    }
226
227    // node's bundled OpenSSL 3 loads an openssl config at startup; under the
228    // Seatbelt sandbox the system default config fails ("OpenSSL configuration
229    // error") even though the binary runs fine on the host. Drop an empty config
230    // into the toolchain (covered by the toolchain-cache Seatbelt grant) and point
231    // OPENSSL_CONF at it (see toolchain_path_dirs_and_env) so node uses built-in
232    // defaults. TLS still works — node uses its own bundled root CAs.
233    if language == "node" {
234        tokio::fs::create_dir_all(toolchain.join("etc")).await?;
235        tokio::fs::write(toolchain.join("etc/openssl_sandbox.cnf"), b"").await?;
236    }
237
238    // 4. Write the manifest, clean scratch, then stamp `.ready` LAST.
239    let manifest = build_manifest(language, resolved_version, url, &computed_sha, toolchain).await;
240    manifest.write_to_toolchain(toolchain).await?;
241
242    if let Err(e) = tokio::fs::remove_dir_all(&scratch).await {
243        warn!(error = %e, "failed to clean prebuilt download scratch dir (non-fatal)");
244    }
245    tokio::fs::write(toolchain.join(".ready"), b"").await?;
246
247    Ok(())
248}
249
250// ---------------------------------------------------------------------------
251// Formula -> (language, version) mapping
252// ---------------------------------------------------------------------------
253
254/// Split a `formula` into its `(language, version_token)`.
255///
256/// Strips a `@<ver>` suffix for the version token (`python@3.12` ->
257/// `("python", "3.12")`, `node@22` -> `("node", "22")`); a bare formula resolves
258/// to `"latest"` (`go` -> `("go", "latest")`). The language is normalized via
259/// [`normalize_language`].
260fn formula_language_version(formula: &str) -> (String, String) {
261    let (name, version) = match formula.split_once('@') {
262        Some((n, v)) if !v.is_empty() => (n, v.to_string()),
263        _ => (formula, "latest".to_string()),
264    };
265    (normalize_language(name), version)
266}
267
268/// Canonicalize a toolchain alias to its language key: `golang` -> `go`,
269/// `nodejs` -> `node`, `python3` -> `python`, `openjdk` -> `java`, and any name
270/// containing `graalvm` -> `graalvm`. Unrecognized names are returned lowercased
271/// unchanged (so `swift` stays `swift` and is rejected by
272/// [`is_prebuilt_formula`]).
273fn normalize_language(name: &str) -> String {
274    let lower = name.to_ascii_lowercase();
275    if lower.contains("graalvm") {
276        return "graalvm".to_string();
277    }
278    let canonical = match lower.as_str() {
279        "go" | "golang" => "go",
280        "node" | "nodejs" => "node",
281        "rust" => "rust",
282        "python" | "python3" => "python",
283        "deno" => "deno",
284        "bun" => "bun",
285        "zig" => "zig",
286        "java" | "openjdk" => "java",
287        other => other,
288    };
289    canonical.to_string()
290}
291
292// ---------------------------------------------------------------------------
293// Toolchain layout (path_dirs + env) + manifest
294// ---------------------------------------------------------------------------
295
296/// Compute the candidate `path_dirs` and `env` for a language toolchain, all rooted at
297/// absolute toolchain paths. `path_dirs` are candidates — [`build_manifest`] filters
298/// them to those that actually exist on disk after extraction.
299///
300/// Mirrors the `ToolchainSpec::*` constructors, but with `/usr/local/<lang>`
301/// rewritten to the toolchain prefix:
302/// - go: `GOROOT=<toolchain>`, `GOFLAGS=-buildvcs=false`, `PATH += <toolchain>/bin`
303/// - rust: `CARGO_HOME=<toolchain>/cargo`, `RUSTUP_HOME=<toolchain>/rustup`,
304///   `PATH += <toolchain>/cargo/bin, <toolchain>/bin`
305/// - java: `JAVA_HOME=<toolchain>`, `PATH += <toolchain>/bin`
306/// - graalvm: `JAVA_HOME=<toolchain>`, `GRAALVM_HOME=<toolchain>`, `PATH += <toolchain>/bin`
307/// - node/python/deno/bun/zig: no env, `PATH += <toolchain>/bin`
308fn toolchain_path_dirs_and_env(
309    language: &str,
310    toolchain: &Path,
311) -> (Vec<String>, HashMap<String, String>) {
312    let toolchain_str = toolchain.display().to_string();
313    let bin = toolchain.join("bin").display().to_string();
314    let mut env = HashMap::new();
315
316    let path_dirs = match language {
317        "go" => {
318            env.insert("GOROOT".to_string(), toolchain_str);
319            env.insert("GOFLAGS".to_string(), "-buildvcs=false".to_string());
320            vec![bin]
321        }
322        "rust" => {
323            env.insert(
324                "CARGO_HOME".to_string(),
325                toolchain.join("cargo").display().to_string(),
326            );
327            env.insert(
328                "RUSTUP_HOME".to_string(),
329                toolchain.join("rustup").display().to_string(),
330            );
331            vec![toolchain.join("cargo/bin").display().to_string(), bin]
332        }
333        "java" => {
334            env.insert("JAVA_HOME".to_string(), toolchain_str);
335            vec![bin]
336        }
337        "graalvm" => {
338            env.insert("JAVA_HOME".to_string(), toolchain_str.clone());
339            env.insert("GRAALVM_HOME".to_string(), toolchain_str);
340            vec![bin]
341        }
342        "node" => {
343            // See ensure_prebuilt: point node at an empty OpenSSL config in the
344            // toolchain so its OpenSSL 3 uses built-in defaults under Seatbelt instead
345            // of failing on the system config ("OpenSSL configuration error").
346            env.insert(
347                "OPENSSL_CONF".to_string(),
348                toolchain
349                    .join("etc/openssl_sandbox.cnf")
350                    .display()
351                    .to_string(),
352            );
353            vec![bin]
354        }
355        // python, deno, bun, zig — bin only, no extra env.
356        _ => vec![bin],
357    };
358
359    (path_dirs, env)
360}
361
362/// Build the [`ToolchainManifest`] for a freshly-extracted prebuilt toolchain, keeping only
363/// the `path_dirs` that exist on disk.
364async fn build_manifest(
365    language: &str,
366    resolved_version: &str,
367    url: &str,
368    sha256: &str,
369    toolchain: &Path,
370) -> ToolchainManifest {
371    let (candidates, env) = toolchain_path_dirs_and_env(language, toolchain);
372
373    let mut path_dirs = Vec::with_capacity(candidates.len());
374    for dir in candidates {
375        if tokio::fs::try_exists(&dir).await.unwrap_or(false) {
376            path_dirs.push(dir);
377        }
378    }
379
380    ToolchainManifest {
381        tool: language.to_string(),
382        version: resolved_version.to_string(),
383        arch: arch_token().to_string(),
384        platform: "macos".to_string(),
385        path_dirs,
386        env,
387        source: ToolchainSource::Prebuilt {
388            url: url.to_string(),
389            sha256: sha256.to_string(),
390        },
391        build_deps: Vec::new(),
392        provisioned_at: chrono::Utc::now().to_rfc3339(),
393    }
394}
395
396// ---------------------------------------------------------------------------
397// Architecture tokens
398// ---------------------------------------------------------------------------
399
400/// Host architecture token used in cache keys (`arm64` / `x86_64`), matching the
401/// toolchain-dir convention in [`crate::source_build`].
402fn arch_token() -> &'static str {
403    match std::env::consts::ARCH {
404        "aarch64" => "arm64",
405        other => other,
406    }
407}
408
409/// Host architecture token used in **vendor download URLs** (`arm64` / `amd64`).
410fn host_arch() -> &'static str {
411    if cfg!(target_arch = "aarch64") {
412        "arm64"
413    } else {
414        "amd64"
415    }
416}
417
418// ---------------------------------------------------------------------------
419// Version resolution dispatch
420// ---------------------------------------------------------------------------
421
422/// Resolve a `(language, version, arch)` request to a [`PrebuiltResolution`].
423async fn resolve_prebuilt_url(
424    language: &str,
425    version: &str,
426    arch: &str,
427) -> Result<PrebuiltResolution> {
428    match language {
429        "go" => resolve_go(version, arch).await,
430        "node" => resolve_node(version, arch).await,
431        "rust" => resolve_rust(version, arch).await,
432        "python" => resolve_python(version, arch).await,
433        "deno" => resolve_deno(version, arch).await,
434        "bun" => resolve_bun(version, arch).await,
435        "zig" => resolve_zig(version, arch).await,
436        "java" => resolve_java(version, arch).await,
437        "graalvm" => resolve_graalvm(version, arch).await,
438        other => Err(ToolchainError::RegistryError {
439            message: format!(
440                "no prebuilt toolchain provisioner for '{other}'. \
441                 Supported: go, node, rust, python, deno, bun, zig, java, graalvm."
442            ),
443        }),
444    }
445}
446
447// ---------------------------------------------------------------------------
448// Go resolver
449// ---------------------------------------------------------------------------
450
451/// Resolve a Go version to a download URL (exact / partial via go.dev API / latest).
452///
453/// The go.dev downloads JSON publishes a `sha256` per file (verified live:
454/// `files[].{filename,os,arch,version,sha256,size,kind}`), so a best-effort
455/// lookup pins the archive digest.
456async fn resolve_go(version: &str, arch: &str) -> Result<PrebuiltResolution> {
457    let resolved = if version == "latest" {
458        resolve_go_version_from_api(version).await?
459    } else if version.matches('.').count() < 2 {
460        // Partial like "1.23": the go.dev API only lists the two most recent
461        // series, so fall back to "{version}.0" (Go always ships a .0 release).
462        resolve_go_version_from_api(version)
463            .await
464            .unwrap_or_else(|_| format!("{version}.0"))
465    } else {
466        version.to_string()
467    };
468
469    let filename = format!("go{resolved}.darwin-{arch}.tar.gz");
470    let url = format!("https://go.dev/dl/{filename}");
471    let sha256 = fetch_go_sha256(&filename).await;
472    Ok(PrebuiltResolution {
473        version: resolved,
474        url,
475        sha256,
476    })
477}
478
479/// A single file entry from the go.dev downloads JSON (`files[]`), carrying the
480/// published `sha256` for that archive.
481#[derive(serde::Deserialize)]
482struct GoFile {
483    #[serde(default)]
484    filename: String,
485    #[serde(default)]
486    sha256: String,
487}
488
489/// A go.dev release with its file list (superset of [`GoRelease`]).
490#[derive(serde::Deserialize)]
491struct GoReleaseFiles {
492    #[serde(default)]
493    files: Vec<GoFile>,
494}
495
496/// Best-effort: look up the published sha256 for a Go archive `filename` from
497/// the go.dev downloads JSON. `None` when the version is too old to be listed or
498/// the API is unreachable.
499async fn fetch_go_sha256(filename: &str) -> Option<String> {
500    let releases: Vec<GoReleaseFiles> = reqwest::get("https://go.dev/dl/?mode=json")
501        .await
502        .ok()?
503        .json()
504        .await
505        .ok()?;
506    for release in &releases {
507        for file in &release.files {
508            if file.filename == filename && is_hex_sha256(&file.sha256) {
509                return Some(file.sha256.to_ascii_lowercase());
510            }
511        }
512    }
513    None
514}
515
516/// Fetch the Go downloads API and resolve a version prefix to a concrete version.
517async fn resolve_go_version_from_api(version_prefix: &str) -> Result<String> {
518    let api_url = "https://go.dev/dl/?mode=json";
519    let response = reqwest::get(api_url)
520        .await
521        .map_err(|e| ToolchainError::RegistryError {
522            message: format!("Failed to fetch Go versions from {api_url}: {e}"),
523        })?;
524
525    let releases: Vec<GoRelease> =
526        response
527            .json()
528            .await
529            .map_err(|e| ToolchainError::RegistryError {
530                message: format!("Failed to parse Go versions JSON: {e}"),
531            })?;
532
533    if version_prefix == "latest" {
534        return releases
535            .first()
536            .map(|r| {
537                r.version
538                    .strip_prefix("go")
539                    .unwrap_or(&r.version)
540                    .to_string()
541            })
542            .ok_or_else(|| ToolchainError::RegistryError {
543                message: "No Go releases found".to_string(),
544            });
545    }
546
547    // Match "go1.23." (trailing dot) or exact "go1.23" to avoid "go1.23rc1".
548    let prefix_dot = format!("go{version_prefix}.");
549    let prefix_exact = format!("go{version_prefix}");
550    for release in &releases {
551        if (release.version.starts_with(&prefix_dot) || release.version == prefix_exact)
552            && release.stable
553        {
554            return Ok(release
555                .version
556                .strip_prefix("go")
557                .unwrap_or(&release.version)
558                .to_string());
559        }
560    }
561
562    for release in &releases {
563        if release.version.starts_with(&prefix_dot) || release.version == prefix_exact {
564            return Ok(release
565                .version
566                .strip_prefix("go")
567                .unwrap_or(&release.version)
568                .to_string());
569        }
570    }
571
572    Err(ToolchainError::RegistryError {
573        message: format!("No Go release found matching version '{version_prefix}'"),
574    })
575}
576
577#[derive(serde::Deserialize)]
578struct GoRelease {
579    version: String,
580    stable: bool,
581}
582
583// ---------------------------------------------------------------------------
584// Node.js resolver
585// ---------------------------------------------------------------------------
586
587/// Resolve a Node.js version to a download URL.
588///
589/// Node publishes a `SHASUMS256.txt` per release directory (verified live: lines
590/// are `<hex>  <filename>`), so a best-effort lookup pins the archive digest.
591async fn resolve_node(version: &str, arch: &str) -> Result<PrebuiltResolution> {
592    let node_arch = match arch {
593        "arm64" => "arm64",
594        _ => "x64",
595    };
596
597    // `latest`, the `lts` token, and a bare major (`24`) all carry no dot and are
598    // resolved live against the dist index; a fully-pinned `24.18.0` is used as-is.
599    let resolved = if version == "latest" || !version.contains('.') {
600        resolve_node_version_from_api(version).await?
601    } else {
602        version.to_string()
603    };
604
605    let filename = format!("node-v{resolved}-darwin-{node_arch}.tar.gz");
606    let url = format!("https://nodejs.org/dist/v{resolved}/{filename}");
607    let shasums = format!("https://nodejs.org/dist/v{resolved}/SHASUMS256.txt");
608    let sha256 = fetch_sha256_for(&shasums, &filename).await;
609    Ok(PrebuiltResolution {
610        version: resolved,
611        url,
612        sha256,
613    })
614}
615
616/// Fetch the Node.js dist index and resolve a version prefix.
617async fn resolve_node_version_from_api(version_prefix: &str) -> Result<String> {
618    let api_url = "https://nodejs.org/dist/index.json";
619    let response = reqwest::get(api_url)
620        .await
621        .map_err(|e| ToolchainError::RegistryError {
622            message: format!("Failed to fetch Node.js versions from {api_url}: {e}"),
623        })?;
624
625    let releases: Vec<NodeRelease> =
626        response
627            .json()
628            .await
629            .map_err(|e| ToolchainError::RegistryError {
630                message: format!("Failed to parse Node.js versions JSON: {e}"),
631            })?;
632
633    if version_prefix == "latest" {
634        return releases
635            .first()
636            .map(|r| {
637                r.version
638                    .strip_prefix('v')
639                    .unwrap_or(&r.version)
640                    .to_string()
641            })
642            .ok_or_else(|| ToolchainError::RegistryError {
643                message: "No Node.js releases found".to_string(),
644            });
645    }
646
647    // `node@lts` (version token "lts"): the newest Long-Term-Support line. Pick
648    // the highest-semver release whose `lts` field is a truthy codename string —
649    // derived LIVE from the dist index every run, NO hardcoded major (today that
650    // resolves to v24.x "Krypton"; it moves to v26 when v26 enters LTS).
651    if version_prefix == "lts" {
652        return select_newest_node_lts(&releases).ok_or_else(|| ToolchainError::RegistryError {
653            message: "No Node.js LTS release found in dist index".to_string(),
654        });
655    }
656
657    // Find the latest version matching the major (e.g. "20" -> "20.18.1").
658    let prefix = format!("v{version_prefix}");
659    for release in &releases {
660        if release.version.starts_with(&prefix)
661            && release
662                .version
663                .chars()
664                .nth(prefix.len())
665                .is_none_or(|c| c == '.')
666        {
667            return Ok(release
668                .version
669                .strip_prefix('v')
670                .unwrap_or(&release.version)
671                .to_string());
672        }
673    }
674
675    Err(ToolchainError::RegistryError {
676        message: format!("No Node.js release found matching version '{version_prefix}'"),
677    })
678}
679
680#[derive(serde::Deserialize)]
681struct NodeRelease {
682    version: String,
683    /// The nodejs.org dist-index `lts` field: either the JSON boolean `false`
684    /// (a "Current" line) or the LTS codename string (e.g. `"Krypton"`).
685    /// Captured as `Some(codename)` for an LTS release, `None` otherwise.
686    #[serde(default, deserialize_with = "deserialize_node_lts")]
687    lts: Option<String>,
688}
689
690/// Deserialize the dist-index `lts` field, which is `false` for a non-LTS line
691/// or a codename string for an LTS line. Maps the string case to `Some(codename)`
692/// and every other shape (`false`, `null`, missing) to `None`.
693fn deserialize_node_lts<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
694where
695    D: serde::Deserializer<'de>,
696{
697    use serde::Deserialize;
698    Ok(match serde_json::Value::deserialize(deserializer)? {
699        serde_json::Value::String(s) => Some(s),
700        _ => None,
701    })
702}
703
704/// Parse a `vX.Y.Z` Node.js version into a `(major, minor, patch)` tuple for
705/// semver-ordered comparison; unparsable components sort as `0`.
706fn parse_node_semver(version: &str) -> (u64, u64, u64) {
707    let v = version.strip_prefix('v').unwrap_or(version);
708    let mut parts = v.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
709    (
710        parts.next().unwrap_or(0),
711        parts.next().unwrap_or(0),
712        parts.next().unwrap_or(0),
713    )
714}
715
716/// From a parsed dist index, pick the newest LTS release's version (no `v`
717/// prefix): the highest-semver entry whose `lts` is a truthy codename string.
718/// Returns `None` when the index has no LTS line at all.
719fn select_newest_node_lts(releases: &[NodeRelease]) -> Option<String> {
720    releases
721        .iter()
722        .filter(|r| r.lts.is_some())
723        .max_by_key(|r| parse_node_semver(&r.version))
724        .map(|r| {
725            r.version
726                .strip_prefix('v')
727                .unwrap_or(&r.version)
728                .to_string()
729        })
730}
731
732// ---------------------------------------------------------------------------
733// Rust resolver
734// ---------------------------------------------------------------------------
735
736/// Resolve a Rust version to a download URL (exact / partial `.0` / latest channel).
737///
738/// The Rust dist server publishes a sibling `<archive>.sha256` (a `sha256sum`
739/// line), so a best-effort GET of `{url}.sha256` pins the archive digest.
740async fn resolve_rust(version: &str, arch: &str) -> Result<PrebuiltResolution> {
741    let rust_target = match arch {
742        "arm64" => "aarch64-apple-darwin",
743        _ => "x86_64-apple-darwin",
744    };
745
746    let resolved = if version == "latest" {
747        resolve_rust_latest_version().await?
748    } else if version.matches('.').count() < 2 {
749        // Rust always releases x.y.0 for each minor version.
750        format!("{version}.0")
751    } else {
752        version.to_string()
753    };
754
755    let url = format!("https://static.rust-lang.org/dist/rust-{resolved}-{rust_target}.tar.gz");
756    let sha256 = fetch_sha256_token(&format!("{url}.sha256")).await;
757    Ok(PrebuiltResolution {
758        version: resolved,
759        url,
760        sha256,
761    })
762}
763
764/// Fetch the Rust stable channel TOML and extract the current stable version.
765async fn resolve_rust_latest_version() -> Result<String> {
766    let channel_url = "https://static.rust-lang.org/dist/channel-rust-stable.toml";
767    let response = reqwest::get(channel_url)
768        .await
769        .map_err(|e| ToolchainError::RegistryError {
770            message: format!("Failed to fetch Rust stable channel from {channel_url}: {e}"),
771        })?;
772
773    let body = response
774        .text()
775        .await
776        .map_err(|e| ToolchainError::RegistryError {
777            message: format!("Failed to read Rust stable channel response: {e}"),
778        })?;
779
780    let pkg_rust_pos = body
781        .find("[pkg.rust]")
782        .ok_or_else(|| ToolchainError::RegistryError {
783            message: "Rust stable channel TOML missing [pkg.rust] section".to_string(),
784        })?;
785
786    let after_pkg = &body[pkg_rust_pos..];
787    let version_prefix = "version = \"";
788    let ver_start =
789        after_pkg
790            .find(version_prefix)
791            .ok_or_else(|| ToolchainError::RegistryError {
792                message: "No version field found in [pkg.rust] section".to_string(),
793            })?
794            + version_prefix.len();
795
796    let ver_str: String = after_pkg[ver_start..]
797        .chars()
798        .take_while(|c| c.is_ascii_digit() || *c == '.')
799        .collect();
800
801    if ver_str.is_empty() {
802        return Err(ToolchainError::RegistryError {
803            message: "Failed to parse Rust version from stable channel".to_string(),
804        });
805    }
806
807    debug!("Resolved Rust latest stable version: {ver_str}");
808    Ok(ver_str)
809}
810
811// ---------------------------------------------------------------------------
812// Python resolver
813// ---------------------------------------------------------------------------
814
815/// Resolve a Python version to a download URL via `astral-sh/python-build-standalone`.
816async fn resolve_python(version: &str, arch: &str) -> Result<PrebuiltResolution> {
817    let python_target = match arch {
818        "arm64" => "aarch64-apple-darwin",
819        _ => "x86_64-apple-darwin",
820    };
821
822    resolve_python_from_github(version, python_target).await
823}
824
825/// Fetch the `astral-sh/python-build-standalone` GitHub releases and find a
826/// matching `install_only_stripped` asset for the requested version + target.
827async fn resolve_python_from_github(
828    version_prefix: &str,
829    target: &str,
830) -> Result<PrebuiltResolution> {
831    let api_url =
832        "https://api.github.com/repos/astral-sh/python-build-standalone/releases?per_page=25";
833
834    let client = reqwest::Client::builder()
835        .user_agent("zlayer")
836        .build()
837        .map_err(|e| ToolchainError::RegistryError {
838            message: format!("Failed to build HTTP client: {e}"),
839        })?;
840
841    let response = client
842        .get(api_url)
843        .send()
844        .await
845        .map_err(|e| ToolchainError::RegistryError {
846            message: format!("Failed to fetch Python releases from GitHub: {e}"),
847        })?;
848
849    if !response.status().is_success() {
850        return Err(ToolchainError::RegistryError {
851            message: format!(
852                "GitHub API returned status {} fetching Python releases",
853                response.status()
854            ),
855        });
856    }
857
858    let releases: Vec<GitHubRelease> =
859        response
860            .json()
861            .await
862            .map_err(|e| ToolchainError::RegistryError {
863                message: format!("Failed to parse GitHub releases JSON: {e}"),
864            })?;
865
866    let target_suffix = format!("{target}-install_only_stripped.tar.gz");
867
868    if version_prefix == "latest" {
869        for release in &releases {
870            for asset in &release.assets {
871                if asset.name.starts_with("cpython-")
872                    && asset.name.ends_with(&target_suffix)
873                    && asset.name.contains("install_only")
874                {
875                    let py_version = extract_python_version_from_asset(&asset.name);
876                    if !py_version.is_empty() {
877                        debug!("Resolved Python latest to {py_version}");
878                        let sha256 =
879                            sibling_sha256(&release.assets, &format!("{}.sha256", asset.name))
880                                .await;
881                        return Ok(PrebuiltResolution {
882                            version: py_version,
883                            url: asset.browser_download_url.clone(),
884                            sha256,
885                        });
886                    }
887                }
888            }
889        }
890        return Err(ToolchainError::RegistryError {
891            message: format!(
892                "No Python release found for target '{target}' in recent GitHub releases"
893            ),
894        });
895    }
896
897    let exact_prefix = format!("cpython-{version_prefix}+");
898    let partial_prefix = format!("cpython-{version_prefix}.");
899
900    for release in &releases {
901        for asset in &release.assets {
902            if !asset.name.ends_with(&target_suffix) {
903                continue;
904            }
905            if asset.name.starts_with(&exact_prefix) || asset.name.starts_with(&partial_prefix) {
906                let py_version = extract_python_version_from_asset(&asset.name);
907                debug!("Resolved Python {version_prefix} to {py_version}");
908                let sha256 =
909                    sibling_sha256(&release.assets, &format!("{}.sha256", asset.name)).await;
910                return Ok(PrebuiltResolution {
911                    version: py_version,
912                    url: asset.browser_download_url.clone(),
913                    sha256,
914                });
915            }
916        }
917    }
918
919    Err(ToolchainError::RegistryError {
920        message: format!("No Python release found matching version '{version_prefix}'"),
921    })
922}
923
924/// Extract the Python version from an asset name like
925/// `cpython-3.12.8+20250106-aarch64-apple-darwin-install_only_stripped.tar.gz`.
926fn extract_python_version_from_asset(asset_name: &str) -> String {
927    asset_name
928        .strip_prefix("cpython-")
929        .and_then(|s| s.split('+').next())
930        .unwrap_or("")
931        .to_string()
932}
933
934#[derive(serde::Deserialize)]
935struct GitHubRelease {
936    /// Release tag (e.g. `v2.1.4`). Unused by the Python resolver (which keys on
937    /// asset names) but read by the deno/bun/graalvm resolvers.
938    tag_name: Option<String>,
939    assets: Vec<GitHubAsset>,
940}
941
942#[derive(serde::Deserialize)]
943struct GitHubAsset {
944    name: String,
945    browser_download_url: String,
946}
947
948// ---------------------------------------------------------------------------
949// Deno resolver
950// ---------------------------------------------------------------------------
951
952/// Resolve a Deno version to a download URL (exact direct / partial+latest via API).
953///
954/// The exact-version path constructs the URL directly (no digest available →
955/// compute-on-download); the API path additionally looks for a sibling
956/// `deno-<target>.zip.sha256sum` asset.
957async fn resolve_deno(version: &str, arch: &str) -> Result<PrebuiltResolution> {
958    let deno_target = match arch {
959        "arm64" => "aarch64-apple-darwin",
960        _ => "x86_64-apple-darwin",
961    };
962
963    if version == "latest" || !version.contains('.') {
964        resolve_deno_from_github(version, deno_target).await
965    } else {
966        let url = format!(
967            "https://github.com/denoland/deno/releases/download/v{version}/deno-{deno_target}.zip"
968        );
969        Ok(PrebuiltResolution {
970            version: version.to_string(),
971            url,
972            sha256: None,
973        })
974    }
975}
976
977/// Fetch the `denoland/deno` GitHub releases and find a matching release/asset.
978async fn resolve_deno_from_github(
979    version_prefix: &str,
980    target: &str,
981) -> Result<PrebuiltResolution> {
982    let api_url = "https://api.github.com/repos/denoland/deno/releases?per_page=25";
983
984    let client = reqwest::Client::builder()
985        .user_agent("zlayer")
986        .build()
987        .map_err(|e| ToolchainError::RegistryError {
988            message: format!("Failed to build HTTP client: {e}"),
989        })?;
990
991    let response = client
992        .get(api_url)
993        .send()
994        .await
995        .map_err(|e| ToolchainError::RegistryError {
996            message: format!("Failed to fetch Deno releases from GitHub: {e}"),
997        })?;
998
999    if !response.status().is_success() {
1000        return Err(ToolchainError::RegistryError {
1001            message: format!(
1002                "GitHub API returned status {} fetching Deno releases",
1003                response.status()
1004            ),
1005        });
1006    }
1007
1008    let releases: Vec<GitHubRelease> =
1009        response
1010            .json()
1011            .await
1012            .map_err(|e| ToolchainError::RegistryError {
1013                message: format!("Failed to parse GitHub releases JSON: {e}"),
1014            })?;
1015
1016    let asset_name = format!("deno-{target}.zip");
1017
1018    if version_prefix == "latest" {
1019        for release in &releases {
1020            for asset in &release.assets {
1021                if asset.name == asset_name {
1022                    let tag = release
1023                        .tag_name
1024                        .as_deref()
1025                        .unwrap_or("")
1026                        .strip_prefix('v')
1027                        .unwrap_or(release.tag_name.as_deref().unwrap_or(""));
1028                    if !tag.is_empty() {
1029                        debug!("Resolved Deno latest to {tag}");
1030                        let sha256 = deno_sibling_sha256(&release.assets, &asset_name).await;
1031                        return Ok(PrebuiltResolution {
1032                            version: tag.to_string(),
1033                            url: asset.browser_download_url.clone(),
1034                            sha256,
1035                        });
1036                    }
1037                }
1038            }
1039        }
1040        return Err(ToolchainError::RegistryError {
1041            message: format!(
1042                "No Deno release found for target '{target}' in recent GitHub releases"
1043            ),
1044        });
1045    }
1046
1047    let tag_prefix = format!("v{version_prefix}.");
1048    for release in &releases {
1049        let tag = release.tag_name.as_deref().unwrap_or("");
1050        if tag.starts_with(&tag_prefix) {
1051            for asset in &release.assets {
1052                if asset.name == asset_name {
1053                    let ver = tag.strip_prefix('v').unwrap_or(tag);
1054                    debug!("Resolved Deno {version_prefix} to {ver} (partial)");
1055                    let sha256 = deno_sibling_sha256(&release.assets, &asset_name).await;
1056                    return Ok(PrebuiltResolution {
1057                        version: ver.to_string(),
1058                        url: asset.browser_download_url.clone(),
1059                        sha256,
1060                    });
1061                }
1062            }
1063        }
1064    }
1065
1066    Err(ToolchainError::RegistryError {
1067        message: format!("No Deno release found matching version '{version_prefix}'"),
1068    })
1069}
1070
1071/// Best-effort: find Deno's sibling checksum asset for `asset_name`. Deno
1072/// publishes `<asset>.sha256sum`; some releases use `<asset>.sha256`.
1073async fn deno_sibling_sha256(assets: &[GitHubAsset], asset_name: &str) -> Option<String> {
1074    if let Some(sha) = sibling_sha256(assets, &format!("{asset_name}.sha256sum")).await {
1075        return Some(sha);
1076    }
1077    sibling_sha256(assets, &format!("{asset_name}.sha256")).await
1078}
1079
1080// ---------------------------------------------------------------------------
1081// Bun resolver
1082// ---------------------------------------------------------------------------
1083
1084/// Resolve a Bun version to a download URL (exact direct / partial+latest via API).
1085///
1086/// Bun releases ship a `SHASUMS256.txt` asset; the API path looks up the digest
1087/// for the darwin archive there. The exact-version path has no digest available.
1088async fn resolve_bun(version: &str, arch: &str) -> Result<PrebuiltResolution> {
1089    let bun_arch = match arch {
1090        "arm64" => "aarch64",
1091        _ => "x64",
1092    };
1093
1094    if version == "latest" || !version.contains('.') {
1095        resolve_bun_from_github(version, bun_arch).await
1096    } else {
1097        let url = format!(
1098            "https://github.com/oven-sh/bun/releases/download/bun-v{version}/bun-darwin-{bun_arch}.zip"
1099        );
1100        Ok(PrebuiltResolution {
1101            version: version.to_string(),
1102            url,
1103            sha256: None,
1104        })
1105    }
1106}
1107
1108/// Fetch the `oven-sh/bun` GitHub releases and find a matching release.
1109async fn resolve_bun_from_github(
1110    version_prefix: &str,
1111    bun_arch: &str,
1112) -> Result<PrebuiltResolution> {
1113    let api_url = "https://api.github.com/repos/oven-sh/bun/releases?per_page=25";
1114
1115    let client = reqwest::Client::builder()
1116        .user_agent("zlayer")
1117        .build()
1118        .map_err(|e| ToolchainError::RegistryError {
1119            message: format!("Failed to build HTTP client: {e}"),
1120        })?;
1121
1122    let response = client
1123        .get(api_url)
1124        .send()
1125        .await
1126        .map_err(|e| ToolchainError::RegistryError {
1127            message: format!("Failed to fetch Bun releases from GitHub: {e}"),
1128        })?;
1129
1130    if !response.status().is_success() {
1131        return Err(ToolchainError::RegistryError {
1132            message: format!(
1133                "GitHub API returned status {} fetching Bun releases",
1134                response.status()
1135            ),
1136        });
1137    }
1138
1139    let releases: Vec<GitHubRelease> =
1140        response
1141            .json()
1142            .await
1143            .map_err(|e| ToolchainError::RegistryError {
1144                message: format!("Failed to parse GitHub releases JSON: {e}"),
1145            })?;
1146
1147    let asset_name = format!("bun-darwin-{bun_arch}.zip");
1148
1149    if version_prefix == "latest" {
1150        for release in &releases {
1151            let tag = release.tag_name.as_deref().unwrap_or("");
1152            let ver = tag
1153                .strip_prefix("bun-v")
1154                .unwrap_or(tag.strip_prefix('v').unwrap_or(tag));
1155            if ver.is_empty() {
1156                continue;
1157            }
1158            for asset in &release.assets {
1159                if asset.name == asset_name {
1160                    debug!("Resolved Bun latest to {ver}");
1161                    let sha256 = bun_sha256(&release.assets, &asset_name).await;
1162                    return Ok(PrebuiltResolution {
1163                        version: ver.to_string(),
1164                        url: asset.browser_download_url.clone(),
1165                        sha256,
1166                    });
1167                }
1168            }
1169        }
1170        return Err(ToolchainError::RegistryError {
1171            message: format!(
1172                "No Bun release found for arch '{bun_arch}' in recent GitHub releases"
1173            ),
1174        });
1175    }
1176
1177    let tag_prefix = format!("bun-v{version_prefix}.");
1178    for release in &releases {
1179        let tag = release.tag_name.as_deref().unwrap_or("");
1180        if tag.starts_with(&tag_prefix) {
1181            for asset in &release.assets {
1182                if asset.name == asset_name {
1183                    let ver = tag.strip_prefix("bun-v").unwrap_or(tag);
1184                    debug!("Resolved Bun {version_prefix} to {ver} (partial)");
1185                    let sha256 = bun_sha256(&release.assets, &asset_name).await;
1186                    return Ok(PrebuiltResolution {
1187                        version: ver.to_string(),
1188                        url: asset.browser_download_url.clone(),
1189                        sha256,
1190                    });
1191                }
1192            }
1193        }
1194    }
1195
1196    Err(ToolchainError::RegistryError {
1197        message: format!("No Bun release found matching version '{version_prefix}'"),
1198    })
1199}
1200
1201/// Best-effort: read Bun's `SHASUMS256.txt` release asset and return the digest
1202/// for `asset_name` (falling back to a sibling `<asset>.sha256` if present).
1203async fn bun_sha256(assets: &[GitHubAsset], asset_name: &str) -> Option<String> {
1204    if let Some(shasums) = assets.iter().find(|a| a.name == "SHASUMS256.txt") {
1205        if let Some(sha) = fetch_sha256_for(&shasums.browser_download_url, asset_name).await {
1206            return Some(sha);
1207        }
1208    }
1209    sibling_sha256(assets, &format!("{asset_name}.sha256")).await
1210}
1211
1212// ---------------------------------------------------------------------------
1213// Zig resolver
1214// ---------------------------------------------------------------------------
1215
1216/// A single platform entry from the Zig download index. The index publishes the
1217/// tarball's sha256 in `shasum` alongside the `tarball` URL.
1218#[derive(serde::Deserialize)]
1219struct ZigDownloadInfo {
1220    tarball: String,
1221    #[serde(default)]
1222    shasum: String,
1223}
1224
1225/// Resolve a Zig version to a download URL via `ziglang.org/download/index.json`.
1226async fn resolve_zig(version: &str, arch: &str) -> Result<PrebuiltResolution> {
1227    let platform_key = match arch {
1228        "arm64" => "aarch64-macos",
1229        _ => "x86_64-macos",
1230    };
1231
1232    let index_url = "https://ziglang.org/download/index.json";
1233    let response = reqwest::get(index_url)
1234        .await
1235        .map_err(|e| ToolchainError::RegistryError {
1236            message: format!("Failed to fetch Zig download index from {index_url}: {e}"),
1237        })?;
1238
1239    let index: HashMap<String, serde_json::Value> =
1240        response
1241            .json()
1242            .await
1243            .map_err(|e| ToolchainError::RegistryError {
1244                message: format!("Failed to parse Zig download index JSON: {e}"),
1245            })?;
1246
1247    let resolved = if version == "latest" {
1248        let mut versions: Vec<&String> = index.keys().filter(|k| *k != "master").collect();
1249        versions.sort_by(|a, b| compare_version_strings(b, a));
1250        versions
1251            .first()
1252            .map(|v| (*v).clone())
1253            .ok_or_else(|| ToolchainError::RegistryError {
1254                message: "No stable Zig versions found in download index".to_string(),
1255            })?
1256    } else if index.contains_key(version) {
1257        version.to_string()
1258    } else {
1259        let prefix = format!("{version}.");
1260        let mut candidates: Vec<&String> = index
1261            .keys()
1262            .filter(|k| *k != "master" && k.starts_with(&prefix))
1263            .collect();
1264        candidates.sort_by(|a, b| compare_version_strings(b, a));
1265        candidates
1266            .first()
1267            .map(|v| (*v).clone())
1268            .ok_or_else(|| ToolchainError::RegistryError {
1269                message: format!("No Zig version found matching '{version}'"),
1270            })?
1271    };
1272
1273    let version_data = index
1274        .get(&resolved)
1275        .ok_or_else(|| ToolchainError::RegistryError {
1276            message: format!("Zig version '{resolved}' not found in download index"),
1277        })?;
1278
1279    let platform_data =
1280        version_data
1281            .get(platform_key)
1282            .ok_or_else(|| ToolchainError::RegistryError {
1283                message: format!(
1284                    "No Zig download found for platform '{platform_key}' in version '{resolved}'"
1285                ),
1286            })?;
1287
1288    let info: ZigDownloadInfo = serde_json::from_value(platform_data.clone()).map_err(|e| {
1289        ToolchainError::RegistryError {
1290            message: format!(
1291                "Failed to parse Zig download info for {platform_key}/{resolved}: {e}"
1292            ),
1293        }
1294    })?;
1295
1296    debug!("Resolved Zig {version} to {resolved}: {}", info.tarball);
1297    let sha256 = is_hex_sha256(&info.shasum).then(|| info.shasum.to_ascii_lowercase());
1298    Ok(PrebuiltResolution {
1299        version: resolved,
1300        url: info.tarball,
1301        sha256,
1302    })
1303}
1304
1305/// Compare two dotted version strings numerically for descending-sort selection.
1306fn compare_version_strings(a: &str, b: &str) -> std::cmp::Ordering {
1307    let a_parts: Vec<&str> = a.split('.').collect();
1308    let b_parts: Vec<&str> = b.split('.').collect();
1309    for (ap, bp) in a_parts.iter().zip(b_parts.iter()) {
1310        let ord = match (ap.parse::<u64>(), bp.parse::<u64>()) {
1311            (Ok(an), Ok(bn)) => an.cmp(&bn),
1312            _ => ap.cmp(bp),
1313        };
1314        if ord != std::cmp::Ordering::Equal {
1315            return ord;
1316        }
1317    }
1318    a_parts.len().cmp(&b_parts.len())
1319}
1320
1321// ---------------------------------------------------------------------------
1322// Java (Adoptium/Temurin) resolver
1323// ---------------------------------------------------------------------------
1324
1325/// Response from the Adoptium available releases API.
1326#[derive(serde::Deserialize)]
1327struct AdoptiumAvailableReleases {
1328    /// The most recent LTS feature version (e.g. 21).
1329    most_recent_lts: u32,
1330}
1331
1332/// Resolve a Java (Adoptium/Temurin) version to a download URL.
1333///
1334/// The Adoptium `binary/latest` endpoint is a redirect to the JDK archive and
1335/// exposes no inline digest, so the sha256 is `None` (computed on download).
1336async fn resolve_java(version: &str, arch: &str) -> Result<PrebuiltResolution> {
1337    let adoptium_arch = match arch {
1338        "arm64" => "aarch64",
1339        _ => "x64",
1340    };
1341
1342    let feature_version = if version == "latest" {
1343        resolve_java_latest_lts().await?
1344    } else {
1345        // Adoptium expects the major feature version only (e.g. "21" not "21.0.5").
1346        version.split('.').next().unwrap_or(version).to_string()
1347    };
1348
1349    let url = format!(
1350        "https://api.adoptium.net/v3/binary/latest/{feature_version}/ga/mac/{adoptium_arch}/jdk/hotspot/normal/eclipse"
1351    );
1352
1353    Ok(PrebuiltResolution {
1354        version: feature_version,
1355        url,
1356        sha256: None,
1357    })
1358}
1359
1360/// Fetch the Adoptium available releases API and return the most recent LTS.
1361async fn resolve_java_latest_lts() -> Result<String> {
1362    let api_url = "https://api.adoptium.net/v3/info/available_releases";
1363    let response = reqwest::get(api_url)
1364        .await
1365        .map_err(|e| ToolchainError::RegistryError {
1366            message: format!("Failed to fetch Adoptium available releases from {api_url}: {e}"),
1367        })?;
1368
1369    if !response.status().is_success() {
1370        return Err(ToolchainError::RegistryError {
1371            message: format!(
1372                "Adoptium API returned status {} fetching available releases",
1373                response.status()
1374            ),
1375        });
1376    }
1377
1378    let releases: AdoptiumAvailableReleases =
1379        response
1380            .json()
1381            .await
1382            .map_err(|e| ToolchainError::RegistryError {
1383                message: format!("Failed to parse Adoptium available releases JSON: {e}"),
1384            })?;
1385
1386    let version = releases.most_recent_lts.to_string();
1387    debug!("Resolved Java latest LTS to feature version {version}");
1388    Ok(version)
1389}
1390
1391// ---------------------------------------------------------------------------
1392// GraalVM CE resolver
1393// ---------------------------------------------------------------------------
1394
1395/// Resolve a `GraalVM` CE version to a download URL.
1396///
1397/// The API path additionally looks for a sibling `<archive>.sha256` asset. The
1398/// exact-version path constructs the URL directly (digest computed on download).
1399async fn resolve_graalvm(version: &str, arch: &str) -> Result<PrebuiltResolution> {
1400    let graalvm_arch = match arch {
1401        "arm64" => "aarch64",
1402        _ => "x64",
1403    };
1404
1405    if version == "latest" || !version.contains('.') {
1406        resolve_graalvm_from_github(version, graalvm_arch).await
1407    } else {
1408        let url = format!(
1409            "https://github.com/graalvm/graalvm-ce-builds/releases/download/\
1410             jdk-{version}/graalvm-community-jdk-{version}_macos-{graalvm_arch}_bin.tar.gz"
1411        );
1412        Ok(PrebuiltResolution {
1413            version: version.to_string(),
1414            url,
1415            sha256: None,
1416        })
1417    }
1418}
1419
1420/// Fetch the `graalvm/graalvm-ce-builds` GitHub releases and find a match.
1421async fn resolve_graalvm_from_github(
1422    version_prefix: &str,
1423    graalvm_arch: &str,
1424) -> Result<PrebuiltResolution> {
1425    let api_url = "https://api.github.com/repos/graalvm/graalvm-ce-builds/releases?per_page=25";
1426
1427    let client = reqwest::Client::builder()
1428        .user_agent("zlayer")
1429        .build()
1430        .map_err(|e| ToolchainError::RegistryError {
1431            message: format!("Failed to build HTTP client: {e}"),
1432        })?;
1433
1434    let response = client
1435        .get(api_url)
1436        .send()
1437        .await
1438        .map_err(|e| ToolchainError::RegistryError {
1439            message: format!("Failed to fetch GraalVM releases from GitHub: {e}"),
1440        })?;
1441
1442    if !response.status().is_success() {
1443        return Err(ToolchainError::RegistryError {
1444            message: format!(
1445                "GitHub API returned status {} fetching GraalVM releases",
1446                response.status()
1447            ),
1448        });
1449    }
1450
1451    let releases: Vec<GitHubRelease> =
1452        response
1453            .json()
1454            .await
1455            .map_err(|e| ToolchainError::RegistryError {
1456                message: format!("Failed to parse GitHub releases JSON: {e}"),
1457            })?;
1458
1459    if version_prefix == "latest" {
1460        for release in &releases {
1461            let tag = release.tag_name.as_deref().unwrap_or("");
1462            if let Some(jdk_version) = tag.strip_prefix("jdk-") {
1463                if jdk_version.is_empty() {
1464                    continue;
1465                }
1466                let filename =
1467                    format!("graalvm-community-jdk-{jdk_version}_macos-{graalvm_arch}_bin.tar.gz");
1468                let url = format!(
1469                    "https://github.com/graalvm/graalvm-ce-builds/releases/download/{tag}/{filename}"
1470                );
1471                debug!("Resolved GraalVM latest to {jdk_version}");
1472                let sha256 = sibling_sha256(&release.assets, &format!("{filename}.sha256")).await;
1473                return Ok(PrebuiltResolution {
1474                    version: jdk_version.to_string(),
1475                    url,
1476                    sha256,
1477                });
1478            }
1479        }
1480        return Err(ToolchainError::RegistryError {
1481            message: "No GraalVM CE release found in recent GitHub releases".to_string(),
1482        });
1483    }
1484
1485    let tag_prefix = format!("jdk-{version_prefix}.");
1486    for release in &releases {
1487        let tag = release.tag_name.as_deref().unwrap_or("");
1488        if tag.starts_with(&tag_prefix) {
1489            if let Some(jdk_version) = tag.strip_prefix("jdk-") {
1490                let filename =
1491                    format!("graalvm-community-jdk-{jdk_version}_macos-{graalvm_arch}_bin.tar.gz");
1492                let url = format!(
1493                    "https://github.com/graalvm/graalvm-ce-builds/releases/download/{tag}/{filename}"
1494                );
1495                debug!("Resolved GraalVM {version_prefix} to {jdk_version} (partial)");
1496                let sha256 = sibling_sha256(&release.assets, &format!("{filename}.sha256")).await;
1497                return Ok(PrebuiltResolution {
1498                    version: jdk_version.to_string(),
1499                    url,
1500                    sha256,
1501                });
1502            }
1503        }
1504    }
1505
1506    Err(ToolchainError::RegistryError {
1507        message: format!("No GraalVM CE release found matching version '{version_prefix}'"),
1508    })
1509}
1510
1511// ---------------------------------------------------------------------------
1512// Download + extraction
1513// ---------------------------------------------------------------------------
1514
1515/// `true` when `s` is a 64-char lowercase/uppercase hex sha256.
1516fn is_hex_sha256(s: &str) -> bool {
1517    s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
1518}
1519
1520/// Best-effort: GET `url` and return its first whitespace token when it is a
1521/// hex sha256. Handles both bare-digest bodies (`<hex>`) and `sha256sum`-style
1522/// bodies (`<hex>  <file>`). Returns `None` on any failure — the caller then
1523/// records the digest computed on download instead.
1524async fn fetch_sha256_token(url: &str) -> Option<String> {
1525    let text = reqwest::get(url).await.ok()?.text().await.ok()?;
1526    let token = text.split_whitespace().next()?;
1527    is_hex_sha256(token).then(|| token.to_ascii_lowercase())
1528}
1529
1530/// Best-effort: GET a `sha256sum`-format checksums file at `url` and return the
1531/// digest whose filename column equals `filename`. Returns `None` on any
1532/// failure.
1533async fn fetch_sha256_for(url: &str, filename: &str) -> Option<String> {
1534    let text = reqwest::get(url).await.ok()?.text().await.ok()?;
1535    for line in text.lines() {
1536        let mut cols = line.split_whitespace();
1537        if let (Some(hash), Some(name)) = (cols.next(), cols.next()) {
1538            // `sha256sum` writes "hash  name" or "hash *name" (binary marker).
1539            let name = name.strip_prefix('*').unwrap_or(name);
1540            if name == filename && is_hex_sha256(hash) {
1541                return Some(hash.to_ascii_lowercase());
1542            }
1543        }
1544    }
1545    None
1546}
1547
1548/// Best-effort: find a sibling checksum asset in `assets` named exactly
1549/// `sibling` (e.g. `<artifact>.sha256`), fetch it, and return the hex token.
1550async fn sibling_sha256(assets: &[GitHubAsset], sibling: &str) -> Option<String> {
1551    let asset = assets.iter().find(|a| a.name == sibling)?;
1552    fetch_sha256_token(&asset.browser_download_url).await
1553}
1554
1555/// Extract a toolchain archive into the toolchain root with the canonical per-language
1556/// layout, so that `<toolchain>/bin/<primary-binary>` ends up directly runnable.
1557///
1558/// Several arms issue an identical `tar xzf … --strip-components=1` (the
1559/// per-language branches are kept distinct for clarity and future divergence),
1560/// so `match_same_arms` is intentionally allowed here.
1561#[allow(clippy::too_many_lines, clippy::match_same_arms)]
1562async fn extract_toolchain(language: &str, archive: &Path, target_dir: &Path) -> Result<()> {
1563    let archive_str = archive.display().to_string();
1564    let target_str = target_dir.display().to_string();
1565
1566    let output = match language {
1567        "go" | "node" => {
1568            // go/ and node-v.../ both strip their single top-level dir.
1569            tokio::process::Command::new("tar")
1570                .args([
1571                    "xzf",
1572                    &archive_str,
1573                    "-C",
1574                    &target_str,
1575                    "--strip-components=1",
1576                ])
1577                .output()
1578                .await?
1579        }
1580        "rust" => {
1581            // Rust standalone ships an installer. Extract to a temp dir, then run
1582            // install.sh --prefix=<toolchain> to land rustc/cargo into <toolchain>/bin.
1583            let extract_tmp = target_dir.join("_extract");
1584            tokio::fs::create_dir_all(&extract_tmp).await?;
1585            let extract_tmp_str = extract_tmp.display().to_string();
1586
1587            let tar_out = tokio::process::Command::new("tar")
1588                .args([
1589                    "xzf",
1590                    &archive_str,
1591                    "-C",
1592                    &extract_tmp_str,
1593                    "--strip-components=1",
1594                ])
1595                .output()
1596                .await?;
1597
1598            if !tar_out.status.success() {
1599                let stderr = String::from_utf8_lossy(&tar_out.stderr);
1600                let _ = tokio::process::Command::new("chmod")
1601                    .args(["-R", "u+w"])
1602                    .arg(&extract_tmp)
1603                    .status()
1604                    .await;
1605                let _ = tokio::process::Command::new("rm")
1606                    .args(["-rf"])
1607                    .arg(&extract_tmp)
1608                    .status()
1609                    .await;
1610                return Err(ToolchainError::RegistryError {
1611                    message: format!("Failed to extract Rust tarball: {stderr}"),
1612                });
1613            }
1614
1615            let install_sh = extract_tmp.join("install.sh");
1616            let install_out = tokio::process::Command::new("sh")
1617                .arg(install_sh.display().to_string())
1618                .arg(format!("--prefix={target_str}"))
1619                .arg("--disable-ldconfig")
1620                .output()
1621                .await?;
1622
1623            let _ = tokio::process::Command::new("chmod")
1624                .args(["-R", "u+w"])
1625                .arg(&extract_tmp)
1626                .status()
1627                .await;
1628            let _ = tokio::process::Command::new("rm")
1629                .args(["-rf"])
1630                .arg(&extract_tmp)
1631                .status()
1632                .await;
1633
1634            if !install_out.status.success() {
1635                let stderr = String::from_utf8_lossy(&install_out.stderr);
1636                return Err(ToolchainError::RegistryError {
1637                    message: format!("Rust install.sh failed: {stderr}"),
1638                });
1639            }
1640
1641            return Ok(());
1642        }
1643        "deno" => {
1644            // Deno zip holds a single `deno` binary — move it into bin/.
1645            let out = tokio::process::Command::new("unzip")
1646                .args(["-o", &archive_str, "-d", &target_str])
1647                .output()
1648                .await?;
1649            if out.status.success() {
1650                let bin_dir = target_dir.join("bin");
1651                tokio::fs::create_dir_all(&bin_dir).await?;
1652                let deno_binary = target_dir.join("deno");
1653                if deno_binary.exists() {
1654                    tokio::fs::rename(&deno_binary, bin_dir.join("deno")).await?;
1655                }
1656            }
1657            out
1658        }
1659        "zig" => {
1660            // Zig .tar.xz extracts as zig-macos-{arch}-{ver}/ with `zig` + lib/
1661            // directly (no bin/). Strip the top dir, then make bin/zig -> ../zig.
1662            let out = tokio::process::Command::new("tar")
1663                .args([
1664                    "xJf",
1665                    &archive_str,
1666                    "-C",
1667                    &target_str,
1668                    "--strip-components=1",
1669                ])
1670                .output()
1671                .await?;
1672            if out.status.success() {
1673                let bin_dir = target_dir.join("bin");
1674                tokio::fs::create_dir_all(&bin_dir).await?;
1675                let zig_binary = target_dir.join("zig");
1676                if zig_binary.exists() && !bin_dir.join("zig").exists() {
1677                    // `bin/zig` -> `../zig`. `tokio::fs::symlink` is Unix-only;
1678                    // this Zig-on-macOS path never runs on Windows but must still
1679                    // COMPILE there (all non-cfg'd code is type-checked per target),
1680                    // so fall back to a copy on Windows.
1681                    #[cfg(unix)]
1682                    tokio::fs::symlink(Path::new("../zig"), &bin_dir.join("zig")).await?;
1683                    #[cfg(windows)]
1684                    {
1685                        tokio::fs::copy(&zig_binary, bin_dir.join("zig")).await?;
1686                    }
1687                }
1688            }
1689            out
1690        }
1691        "java" | "graalvm" => {
1692            // Adoptium/GraalVM macOS tarballs nest under
1693            // jdk-X/Contents/Home/... — strip 3 to reach the JDK root.
1694            tokio::process::Command::new("tar")
1695                .args([
1696                    "xzf",
1697                    &archive_str,
1698                    "-C",
1699                    &target_str,
1700                    "--strip-components=3",
1701                ])
1702                .output()
1703                .await?
1704        }
1705        "bun" => {
1706            // Bun zip extracts as bun-darwin-{arch}/bun — move into bin/.
1707            let out = tokio::process::Command::new("unzip")
1708                .args(["-o", &archive_str, "-d", &target_str])
1709                .output()
1710                .await?;
1711            if out.status.success() {
1712                let bin_dir = target_dir.join("bin");
1713                tokio::fs::create_dir_all(&bin_dir).await?;
1714                if let Ok(mut entries) = tokio::fs::read_dir(target_dir).await {
1715                    while let Ok(Some(entry)) = entries.next_entry().await {
1716                        if entry.file_name().to_string_lossy().starts_with("bun-") {
1717                            let bun_binary = entry.path().join("bun");
1718                            if bun_binary.exists() {
1719                                tokio::fs::rename(&bun_binary, bin_dir.join("bun")).await?;
1720                                let _ = tokio::process::Command::new("chmod")
1721                                    .args(["-R", "u+w"])
1722                                    .arg(entry.path())
1723                                    .status()
1724                                    .await;
1725                                let _ = tokio::process::Command::new("rm")
1726                                    .args(["-rf"])
1727                                    .arg(entry.path())
1728                                    .status()
1729                                    .await;
1730                            }
1731                        }
1732                    }
1733                }
1734            }
1735            out
1736        }
1737        // python (install_only_stripped) and any other tar.gz: strip the top dir.
1738        _ => {
1739            tokio::process::Command::new("tar")
1740                .args([
1741                    "xzf",
1742                    &archive_str,
1743                    "-C",
1744                    &target_str,
1745                    "--strip-components=1",
1746                ])
1747                .output()
1748                .await?
1749        }
1750    };
1751
1752    if !output.status.success() {
1753        let stderr = String::from_utf8_lossy(&output.stderr);
1754        return Err(ToolchainError::RegistryError {
1755            message: format!("Failed to extract {language} toolchain: {stderr}"),
1756        });
1757    }
1758
1759    Ok(())
1760}
1761
1762#[cfg(test)]
1763mod tests {
1764    use super::*;
1765
1766    #[test]
1767    fn is_prebuilt_accepts_languages_and_aliases() {
1768        for f in [
1769            "go",
1770            "golang",
1771            "node",
1772            "nodejs",
1773            "rust",
1774            "python",
1775            "python3",
1776            "python@3.12",
1777            "deno",
1778            "bun",
1779            "zig",
1780            "java",
1781            "openjdk",
1782            "openjdk@17",
1783            "node@22",
1784            "graalvm",
1785            "graalvm-ce",
1786            "graalvm-community",
1787        ] {
1788            assert!(is_prebuilt_formula(f), "{f} should be a prebuilt formula");
1789        }
1790    }
1791
1792    #[test]
1793    fn is_prebuilt_rejects_non_languages_and_swift() {
1794        for f in ["swift", "git", "jq", "cmake", "ripgrep", "openssl@3"] {
1795            assert!(
1796                !is_prebuilt_formula(f),
1797                "{f} should NOT be a prebuilt formula"
1798            );
1799        }
1800    }
1801
1802    #[test]
1803    fn formula_split_maps_language_and_version() {
1804        assert_eq!(
1805            formula_language_version("python@3.12"),
1806            ("python".to_string(), "3.12".to_string())
1807        );
1808        assert_eq!(
1809            formula_language_version("node@22"),
1810            ("node".to_string(), "22".to_string())
1811        );
1812        assert_eq!(
1813            formula_language_version("go"),
1814            ("go".to_string(), "latest".to_string())
1815        );
1816        assert_eq!(
1817            formula_language_version("golang"),
1818            ("go".to_string(), "latest".to_string())
1819        );
1820        assert_eq!(
1821            formula_language_version("openjdk@17"),
1822            ("java".to_string(), "17".to_string())
1823        );
1824        assert_eq!(
1825            formula_language_version("python3"),
1826            ("python".to_string(), "latest".to_string())
1827        );
1828        assert_eq!(
1829            formula_language_version("graalvm-ce"),
1830            ("graalvm".to_string(), "latest".to_string())
1831        );
1832        assert_eq!(
1833            formula_language_version("nodejs"),
1834            ("node".to_string(), "latest".to_string())
1835        );
1836    }
1837
1838    #[test]
1839    fn node_lts_selection_picks_highest_lts_codename() {
1840        // Shape mirrors nodejs.org/dist/index.json: Current lines carry `lts:false`,
1841        // LTS lines carry the codename string. Newest LTS here is v24.18.0.
1842        let json = r#"[
1843            {"version":"v26.0.0","lts":false},
1844            {"version":"v25.2.0","lts":false},
1845            {"version":"v24.18.0","lts":"Krypton"},
1846            {"version":"v22.20.0","lts":"Jod"},
1847            {"version":"v20.19.0","lts":"Iron"}
1848        ]"#;
1849        let releases: Vec<NodeRelease> = serde_json::from_str(json).unwrap();
1850        assert_eq!(
1851            select_newest_node_lts(&releases).as_deref(),
1852            Some("24.18.0"),
1853            "LTS resolver must pick the newest line whose lts is a codename string"
1854        );
1855    }
1856
1857    #[test]
1858    fn node_lts_selection_is_none_when_no_lts_line() {
1859        let json = r#"[{"version":"v26.0.0","lts":false},{"version":"v25.2.0","lts":false}]"#;
1860        let releases: Vec<NodeRelease> = serde_json::from_str(json).unwrap();
1861        assert_eq!(select_newest_node_lts(&releases), None);
1862    }
1863
1864    #[test]
1865    fn node_lts_token_is_a_prebuilt_node_formula() {
1866        // `node@lts` must classify as a node prebuilt formula and split to the
1867        // ("node", "lts") language/version pair the resolver branches on.
1868        assert!(is_prebuilt_formula("node@lts"));
1869        assert_eq!(
1870            formula_language_version("node@lts"),
1871            ("node".to_string(), "lts".to_string())
1872        );
1873    }
1874
1875    #[test]
1876    fn go_toolchain_layout_sets_goroot_and_bin() {
1877        let toolchain = Path::new("/cache/go-1.23.6-arm64");
1878        let (path_dirs, env) = toolchain_path_dirs_and_env("go", toolchain);
1879        assert_eq!(path_dirs, vec!["/cache/go-1.23.6-arm64/bin".to_string()]);
1880        assert_eq!(
1881            env.get("GOROOT").map(String::as_str),
1882            Some("/cache/go-1.23.6-arm64")
1883        );
1884        assert_eq!(
1885            env.get("GOFLAGS").map(String::as_str),
1886            Some("-buildvcs=false")
1887        );
1888    }
1889
1890    #[test]
1891    fn rust_toolchain_layout_sets_cargo_and_rustup_homes() {
1892        let toolchain = Path::new("/cache/rust-1.82.0-arm64");
1893        let (path_dirs, env) = toolchain_path_dirs_and_env("rust", toolchain);
1894        assert_eq!(
1895            path_dirs,
1896            vec![
1897                "/cache/rust-1.82.0-arm64/cargo/bin".to_string(),
1898                "/cache/rust-1.82.0-arm64/bin".to_string(),
1899            ]
1900        );
1901        assert_eq!(
1902            env.get("CARGO_HOME").map(String::as_str),
1903            Some("/cache/rust-1.82.0-arm64/cargo")
1904        );
1905        assert_eq!(
1906            env.get("RUSTUP_HOME").map(String::as_str),
1907            Some("/cache/rust-1.82.0-arm64/rustup")
1908        );
1909    }
1910
1911    #[test]
1912    fn graalvm_toolchain_layout_sets_both_homes() {
1913        let toolchain = Path::new("/cache/graalvm-21.0.5-arm64");
1914        let (_, env) = toolchain_path_dirs_and_env("graalvm", toolchain);
1915        assert_eq!(
1916            env.get("JAVA_HOME").map(String::as_str),
1917            Some("/cache/graalvm-21.0.5-arm64")
1918        );
1919        assert_eq!(
1920            env.get("GRAALVM_HOME").map(String::as_str),
1921            Some("/cache/graalvm-21.0.5-arm64")
1922        );
1923    }
1924
1925    #[test]
1926    fn node_toolchain_layout_sets_openssl_conf() {
1927        let toolchain = Path::new("/cache/node-22.1.0-arm64");
1928        let (path_dirs, env) = toolchain_path_dirs_and_env("node", toolchain);
1929        assert_eq!(path_dirs, vec!["/cache/node-22.1.0-arm64/bin".to_string()]);
1930        // node's bundled OpenSSL 3 fails to load the system openssl config under
1931        // the Seatbelt sandbox; point it at an empty in-toolchain config so it uses
1932        // built-in defaults (TLS still works via node's bundled root CAs).
1933        assert_eq!(
1934            env.get("OPENSSL_CONF").map(String::as_str),
1935            Some("/cache/node-22.1.0-arm64/etc/openssl_sandbox.cnf")
1936        );
1937    }
1938
1939    #[test]
1940    fn python_version_extracted_from_asset_name() {
1941        assert_eq!(
1942            extract_python_version_from_asset(
1943                "cpython-3.12.8+20250106-aarch64-apple-darwin-install_only_stripped.tar.gz"
1944            ),
1945            "3.12.8"
1946        );
1947        assert_eq!(extract_python_version_from_asset("cpython-"), "");
1948        assert_eq!(extract_python_version_from_asset("not-cpython"), "");
1949    }
1950
1951    #[test]
1952    fn version_strings_compare_numerically() {
1953        use std::cmp::Ordering;
1954        assert_eq!(
1955            compare_version_strings("0.14.0", "0.13.0"),
1956            Ordering::Greater
1957        );
1958        assert_eq!(
1959            compare_version_strings("1.0.0", "0.14.0"),
1960            Ordering::Greater
1961        );
1962        assert_eq!(
1963            compare_version_strings("0.14.1", "0.14.0"),
1964            Ordering::Greater
1965        );
1966        assert_eq!(compare_version_strings("0.14.0", "0.14.0"), Ordering::Equal);
1967    }
1968
1969    // Pure (no-network) exact-URL resolution tests — modeled on the
1970    // macos_toolchain exact-URL tests. Exact versions construct the URL directly
1971    // without any API call.
1972
1973    #[tokio::test]
1974    async fn resolve_deno_exact_url() {
1975        let r = resolve_deno("2.1.4", "arm64").await.unwrap();
1976        assert_eq!(r.version, "2.1.4");
1977        assert_eq!(
1978            r.url,
1979            "https://github.com/denoland/deno/releases/download/v2.1.4/deno-aarch64-apple-darwin.zip"
1980        );
1981        // Exact path: no digest is fetched (compute-on-download).
1982        assert!(r.sha256.is_none());
1983    }
1984
1985    #[tokio::test]
1986    async fn resolve_bun_exact_url() {
1987        let r = resolve_bun("1.2.3", "arm64").await.unwrap();
1988        assert_eq!(r.version, "1.2.3");
1989        assert_eq!(
1990            r.url,
1991            "https://github.com/oven-sh/bun/releases/download/bun-v1.2.3/bun-darwin-aarch64.zip"
1992        );
1993        assert!(r.sha256.is_none());
1994    }
1995
1996    #[tokio::test]
1997    async fn resolve_graalvm_exact_url() {
1998        let r = resolve_graalvm("21.0.5", "arm64").await.unwrap();
1999        assert_eq!(r.version, "21.0.5");
2000        assert_eq!(
2001            r.url,
2002            "https://github.com/graalvm/graalvm-ce-builds/releases/download/\
2003             jdk-21.0.5/graalvm-community-jdk-21.0.5_macos-aarch64_bin.tar.gz"
2004        );
2005        assert!(r.sha256.is_none());
2006    }
2007
2008    #[tokio::test]
2009    async fn resolve_java_exact_url_strips_to_major() {
2010        let r = resolve_java("21.0.5", "arm64").await.unwrap();
2011        assert_eq!(r.version, "21");
2012        assert_eq!(
2013            r.url,
2014            "https://api.adoptium.net/v3/binary/latest/21/ga/mac/aarch64/jdk/hotspot/normal/eclipse"
2015        );
2016        assert!(r.sha256.is_none());
2017    }
2018
2019    #[test]
2020    fn zig_download_info_parses_shasum() {
2021        let info: ZigDownloadInfo = serde_json::from_str(
2022            r#"{"tarball":"https://ziglang.org/x.tar.xz","shasum":"aa","size":"1"}"#,
2023        )
2024        .unwrap();
2025        assert_eq!(info.tarball, "https://ziglang.org/x.tar.xz");
2026        assert_eq!(info.shasum, "aa");
2027    }
2028
2029    #[test]
2030    fn hex_sha256_validation() {
2031        assert!(is_hex_sha256(
2032            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
2033        ));
2034        assert!(!is_hex_sha256("tooshort"));
2035        assert!(!is_hex_sha256(
2036            "zz4d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
2037        ));
2038    }
2039}