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