use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
use crate::error::{Result, ToolchainError};
use crate::manifest::{KegManifest, KegSource};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrebuiltResolution {
pub version: String,
pub url: String,
pub sha256: Option<String>,
}
pub(crate) async fn resolve_prebuilt(
formula: &str,
vendor_arch: &str,
) -> Result<PrebuiltResolution> {
let (language, version_token) = formula_language_version(formula);
resolve_prebuilt_url(&language, &version_token, vendor_arch).await
}
#[must_use]
#[allow(clippy::module_name_repetitions)]
pub fn is_prebuilt_formula(formula: &str) -> bool {
let (language, _) = formula_language_version(formula);
matches!(
language.as_str(),
"go" | "node" | "rust" | "python" | "deno" | "bun" | "zig" | "java" | "graalvm"
)
}
#[allow(clippy::module_name_repetitions)]
pub async fn ensure_prebuilt(
formula: &str,
cache_dir: &Path,
lockfile: Option<&crate::ToolchainLockfile>,
) -> Result<PathBuf> {
use crate::ToolchainLockfileExt;
let (language, version_token) = formula_language_version(formula);
let (resolved_version, url, expected_sha) =
if let Some(locked) = lockfile.and_then(|l| l.lookup(formula, "macos", arch_token())) {
(
locked.version.clone(),
locked.url.clone(),
Some(locked.sha256.clone()),
)
} else {
let resolution = resolve_prebuilt_url(&language, &version_token, host_arch()).await?;
(resolution.version, resolution.url, resolution.sha256)
};
let keg = cache_dir.join(format!("{formula}-{resolved_version}-{}", arch_token()));
let ready_marker = keg.join(".ready");
if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
return Ok(keg);
}
let _ = tokio::fs::remove_dir_all(&keg).await;
tokio::fs::create_dir_all(&keg).await?;
let scratch = keg.join(".download");
tokio::fs::create_dir_all(&scratch).await?;
info!(
formula,
language,
version = %resolved_version,
url = %url,
"fetching prebuilt toolchain archive"
);
let archive = scratch.join("archive");
let computed_sha =
crate::package_index::download_verified(&url, &archive, expected_sha.as_deref()).await?;
extract_toolchain(&language, &archive, &keg).await?;
if language == "rust" {
tokio::fs::create_dir_all(keg.join("cargo/bin")).await?;
tokio::fs::create_dir_all(keg.join("rustup")).await?;
}
if language == "node" {
tokio::fs::create_dir_all(keg.join("etc")).await?;
tokio::fs::write(keg.join("etc/openssl_sandbox.cnf"), b"").await?;
}
let manifest = build_manifest(&language, &resolved_version, &url, &computed_sha, &keg).await;
manifest.write_to_keg(&keg).await?;
if let Err(e) = tokio::fs::remove_dir_all(&scratch).await {
warn!(error = %e, "failed to clean prebuilt download scratch dir (non-fatal)");
}
tokio::fs::write(&ready_marker, b"").await?;
Ok(keg)
}
fn formula_language_version(formula: &str) -> (String, String) {
let (name, version) = match formula.split_once('@') {
Some((n, v)) if !v.is_empty() => (n, v.to_string()),
_ => (formula, "latest".to_string()),
};
(normalize_language(name), version)
}
fn normalize_language(name: &str) -> String {
let lower = name.to_ascii_lowercase();
if lower.contains("graalvm") {
return "graalvm".to_string();
}
let canonical = match lower.as_str() {
"go" | "golang" => "go",
"node" | "nodejs" => "node",
"rust" => "rust",
"python" | "python3" => "python",
"deno" => "deno",
"bun" => "bun",
"zig" => "zig",
"java" | "openjdk" => "java",
other => other,
};
canonical.to_string()
}
fn keg_path_dirs_and_env(language: &str, keg: &Path) -> (Vec<String>, HashMap<String, String>) {
let keg_str = keg.display().to_string();
let bin = keg.join("bin").display().to_string();
let mut env = HashMap::new();
let path_dirs = match language {
"go" => {
env.insert("GOROOT".to_string(), keg_str);
env.insert("GOFLAGS".to_string(), "-buildvcs=false".to_string());
vec![bin]
}
"rust" => {
env.insert(
"CARGO_HOME".to_string(),
keg.join("cargo").display().to_string(),
);
env.insert(
"RUSTUP_HOME".to_string(),
keg.join("rustup").display().to_string(),
);
vec![keg.join("cargo/bin").display().to_string(), bin]
}
"java" => {
env.insert("JAVA_HOME".to_string(), keg_str);
vec![bin]
}
"graalvm" => {
env.insert("JAVA_HOME".to_string(), keg_str.clone());
env.insert("GRAALVM_HOME".to_string(), keg_str);
vec![bin]
}
"node" => {
env.insert(
"OPENSSL_CONF".to_string(),
keg.join("etc/openssl_sandbox.cnf").display().to_string(),
);
vec![bin]
}
_ => vec![bin],
};
(path_dirs, env)
}
async fn build_manifest(
language: &str,
resolved_version: &str,
url: &str,
sha256: &str,
keg: &Path,
) -> KegManifest {
let (candidates, env) = keg_path_dirs_and_env(language, keg);
let mut path_dirs = Vec::with_capacity(candidates.len());
for dir in candidates {
if tokio::fs::try_exists(&dir).await.unwrap_or(false) {
path_dirs.push(dir);
}
}
KegManifest {
tool: language.to_string(),
version: resolved_version.to_string(),
arch: arch_token().to_string(),
platform: "macos".to_string(),
path_dirs,
env,
source: KegSource::Prebuilt {
url: url.to_string(),
sha256: sha256.to_string(),
},
build_deps: Vec::new(),
provisioned_at: chrono::Utc::now().to_rfc3339(),
}
}
fn arch_token() -> &'static str {
match std::env::consts::ARCH {
"aarch64" => "arm64",
other => other,
}
}
fn host_arch() -> &'static str {
if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"amd64"
}
}
async fn resolve_prebuilt_url(
language: &str,
version: &str,
arch: &str,
) -> Result<PrebuiltResolution> {
match language {
"go" => resolve_go(version, arch).await,
"node" => resolve_node(version, arch).await,
"rust" => resolve_rust(version, arch).await,
"python" => resolve_python(version, arch).await,
"deno" => resolve_deno(version, arch).await,
"bun" => resolve_bun(version, arch).await,
"zig" => resolve_zig(version, arch).await,
"java" => resolve_java(version, arch).await,
"graalvm" => resolve_graalvm(version, arch).await,
other => Err(ToolchainError::RegistryError {
message: format!(
"no prebuilt toolchain provisioner for '{other}'. \
Supported: go, node, rust, python, deno, bun, zig, java, graalvm."
),
}),
}
}
async fn resolve_go(version: &str, arch: &str) -> Result<PrebuiltResolution> {
let resolved = if version == "latest" {
resolve_go_version_from_api(version).await?
} else if version.matches('.').count() < 2 {
resolve_go_version_from_api(version)
.await
.unwrap_or_else(|_| format!("{version}.0"))
} else {
version.to_string()
};
let filename = format!("go{resolved}.darwin-{arch}.tar.gz");
let url = format!("https://go.dev/dl/{filename}");
let sha256 = fetch_go_sha256(&filename).await;
Ok(PrebuiltResolution {
version: resolved,
url,
sha256,
})
}
#[derive(serde::Deserialize)]
struct GoFile {
#[serde(default)]
filename: String,
#[serde(default)]
sha256: String,
}
#[derive(serde::Deserialize)]
struct GoReleaseFiles {
#[serde(default)]
files: Vec<GoFile>,
}
async fn fetch_go_sha256(filename: &str) -> Option<String> {
let releases: Vec<GoReleaseFiles> = reqwest::get("https://go.dev/dl/?mode=json")
.await
.ok()?
.json()
.await
.ok()?;
for release in &releases {
for file in &release.files {
if file.filename == filename && is_hex_sha256(&file.sha256) {
return Some(file.sha256.to_ascii_lowercase());
}
}
}
None
}
async fn resolve_go_version_from_api(version_prefix: &str) -> Result<String> {
let api_url = "https://go.dev/dl/?mode=json";
let response = reqwest::get(api_url)
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to fetch Go versions from {api_url}: {e}"),
})?;
let releases: Vec<GoRelease> =
response
.json()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to parse Go versions JSON: {e}"),
})?;
if version_prefix == "latest" {
return releases
.first()
.map(|r| {
r.version
.strip_prefix("go")
.unwrap_or(&r.version)
.to_string()
})
.ok_or_else(|| ToolchainError::RegistryError {
message: "No Go releases found".to_string(),
});
}
let prefix_dot = format!("go{version_prefix}.");
let prefix_exact = format!("go{version_prefix}");
for release in &releases {
if (release.version.starts_with(&prefix_dot) || release.version == prefix_exact)
&& release.stable
{
return Ok(release
.version
.strip_prefix("go")
.unwrap_or(&release.version)
.to_string());
}
}
for release in &releases {
if release.version.starts_with(&prefix_dot) || release.version == prefix_exact {
return Ok(release
.version
.strip_prefix("go")
.unwrap_or(&release.version)
.to_string());
}
}
Err(ToolchainError::RegistryError {
message: format!("No Go release found matching version '{version_prefix}'"),
})
}
#[derive(serde::Deserialize)]
struct GoRelease {
version: String,
stable: bool,
}
async fn resolve_node(version: &str, arch: &str) -> Result<PrebuiltResolution> {
let node_arch = match arch {
"arm64" => "arm64",
_ => "x64",
};
let resolved = if version == "latest" || !version.contains('.') {
resolve_node_version_from_api(version).await?
} else {
version.to_string()
};
let filename = format!("node-v{resolved}-darwin-{node_arch}.tar.gz");
let url = format!("https://nodejs.org/dist/v{resolved}/{filename}");
let shasums = format!("https://nodejs.org/dist/v{resolved}/SHASUMS256.txt");
let sha256 = fetch_sha256_for(&shasums, &filename).await;
Ok(PrebuiltResolution {
version: resolved,
url,
sha256,
})
}
async fn resolve_node_version_from_api(version_prefix: &str) -> Result<String> {
let api_url = "https://nodejs.org/dist/index.json";
let response = reqwest::get(api_url)
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to fetch Node.js versions from {api_url}: {e}"),
})?;
let releases: Vec<NodeRelease> =
response
.json()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to parse Node.js versions JSON: {e}"),
})?;
if version_prefix == "latest" {
return releases
.first()
.map(|r| {
r.version
.strip_prefix('v')
.unwrap_or(&r.version)
.to_string()
})
.ok_or_else(|| ToolchainError::RegistryError {
message: "No Node.js releases found".to_string(),
});
}
if version_prefix == "lts" {
return select_newest_node_lts(&releases).ok_or_else(|| ToolchainError::RegistryError {
message: "No Node.js LTS release found in dist index".to_string(),
});
}
let prefix = format!("v{version_prefix}");
for release in &releases {
if release.version.starts_with(&prefix)
&& release
.version
.chars()
.nth(prefix.len())
.is_none_or(|c| c == '.')
{
return Ok(release
.version
.strip_prefix('v')
.unwrap_or(&release.version)
.to_string());
}
}
Err(ToolchainError::RegistryError {
message: format!("No Node.js release found matching version '{version_prefix}'"),
})
}
#[derive(serde::Deserialize)]
struct NodeRelease {
version: String,
#[serde(default, deserialize_with = "deserialize_node_lts")]
lts: Option<String>,
}
fn deserialize_node_lts<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
Ok(match serde_json::Value::deserialize(deserializer)? {
serde_json::Value::String(s) => Some(s),
_ => None,
})
}
fn parse_node_semver(version: &str) -> (u64, u64, u64) {
let v = version.strip_prefix('v').unwrap_or(version);
let mut parts = v.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
(
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
)
}
fn select_newest_node_lts(releases: &[NodeRelease]) -> Option<String> {
releases
.iter()
.filter(|r| r.lts.is_some())
.max_by_key(|r| parse_node_semver(&r.version))
.map(|r| {
r.version
.strip_prefix('v')
.unwrap_or(&r.version)
.to_string()
})
}
async fn resolve_rust(version: &str, arch: &str) -> Result<PrebuiltResolution> {
let rust_target = match arch {
"arm64" => "aarch64-apple-darwin",
_ => "x86_64-apple-darwin",
};
let resolved = if version == "latest" {
resolve_rust_latest_version().await?
} else if version.matches('.').count() < 2 {
format!("{version}.0")
} else {
version.to_string()
};
let url = format!("https://static.rust-lang.org/dist/rust-{resolved}-{rust_target}.tar.gz");
let sha256 = fetch_sha256_token(&format!("{url}.sha256")).await;
Ok(PrebuiltResolution {
version: resolved,
url,
sha256,
})
}
async fn resolve_rust_latest_version() -> Result<String> {
let channel_url = "https://static.rust-lang.org/dist/channel-rust-stable.toml";
let response = reqwest::get(channel_url)
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to fetch Rust stable channel from {channel_url}: {e}"),
})?;
let body = response
.text()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to read Rust stable channel response: {e}"),
})?;
let pkg_rust_pos = body
.find("[pkg.rust]")
.ok_or_else(|| ToolchainError::RegistryError {
message: "Rust stable channel TOML missing [pkg.rust] section".to_string(),
})?;
let after_pkg = &body[pkg_rust_pos..];
let version_prefix = "version = \"";
let ver_start =
after_pkg
.find(version_prefix)
.ok_or_else(|| ToolchainError::RegistryError {
message: "No version field found in [pkg.rust] section".to_string(),
})?
+ version_prefix.len();
let ver_str: String = after_pkg[ver_start..]
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
if ver_str.is_empty() {
return Err(ToolchainError::RegistryError {
message: "Failed to parse Rust version from stable channel".to_string(),
});
}
debug!("Resolved Rust latest stable version: {ver_str}");
Ok(ver_str)
}
async fn resolve_python(version: &str, arch: &str) -> Result<PrebuiltResolution> {
let python_target = match arch {
"arm64" => "aarch64-apple-darwin",
_ => "x86_64-apple-darwin",
};
resolve_python_from_github(version, python_target).await
}
async fn resolve_python_from_github(
version_prefix: &str,
target: &str,
) -> Result<PrebuiltResolution> {
let api_url =
"https://api.github.com/repos/astral-sh/python-build-standalone/releases?per_page=25";
let client = reqwest::Client::builder()
.user_agent("zlayer")
.build()
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to build HTTP client: {e}"),
})?;
let response = client
.get(api_url)
.send()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to fetch Python releases from GitHub: {e}"),
})?;
if !response.status().is_success() {
return Err(ToolchainError::RegistryError {
message: format!(
"GitHub API returned status {} fetching Python releases",
response.status()
),
});
}
let releases: Vec<GitHubRelease> =
response
.json()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to parse GitHub releases JSON: {e}"),
})?;
let target_suffix = format!("{target}-install_only_stripped.tar.gz");
if version_prefix == "latest" {
for release in &releases {
for asset in &release.assets {
if asset.name.starts_with("cpython-")
&& asset.name.ends_with(&target_suffix)
&& asset.name.contains("install_only")
{
let py_version = extract_python_version_from_asset(&asset.name);
if !py_version.is_empty() {
debug!("Resolved Python latest to {py_version}");
let sha256 =
sibling_sha256(&release.assets, &format!("{}.sha256", asset.name))
.await;
return Ok(PrebuiltResolution {
version: py_version,
url: asset.browser_download_url.clone(),
sha256,
});
}
}
}
}
return Err(ToolchainError::RegistryError {
message: format!(
"No Python release found for target '{target}' in recent GitHub releases"
),
});
}
let exact_prefix = format!("cpython-{version_prefix}+");
let partial_prefix = format!("cpython-{version_prefix}.");
for release in &releases {
for asset in &release.assets {
if !asset.name.ends_with(&target_suffix) {
continue;
}
if asset.name.starts_with(&exact_prefix) || asset.name.starts_with(&partial_prefix) {
let py_version = extract_python_version_from_asset(&asset.name);
debug!("Resolved Python {version_prefix} to {py_version}");
let sha256 =
sibling_sha256(&release.assets, &format!("{}.sha256", asset.name)).await;
return Ok(PrebuiltResolution {
version: py_version,
url: asset.browser_download_url.clone(),
sha256,
});
}
}
}
Err(ToolchainError::RegistryError {
message: format!("No Python release found matching version '{version_prefix}'"),
})
}
fn extract_python_version_from_asset(asset_name: &str) -> String {
asset_name
.strip_prefix("cpython-")
.and_then(|s| s.split('+').next())
.unwrap_or("")
.to_string()
}
#[derive(serde::Deserialize)]
struct GitHubRelease {
tag_name: Option<String>,
assets: Vec<GitHubAsset>,
}
#[derive(serde::Deserialize)]
struct GitHubAsset {
name: String,
browser_download_url: String,
}
async fn resolve_deno(version: &str, arch: &str) -> Result<PrebuiltResolution> {
let deno_target = match arch {
"arm64" => "aarch64-apple-darwin",
_ => "x86_64-apple-darwin",
};
if version == "latest" || !version.contains('.') {
resolve_deno_from_github(version, deno_target).await
} else {
let url = format!(
"https://github.com/denoland/deno/releases/download/v{version}/deno-{deno_target}.zip"
);
Ok(PrebuiltResolution {
version: version.to_string(),
url,
sha256: None,
})
}
}
async fn resolve_deno_from_github(
version_prefix: &str,
target: &str,
) -> Result<PrebuiltResolution> {
let api_url = "https://api.github.com/repos/denoland/deno/releases?per_page=25";
let client = reqwest::Client::builder()
.user_agent("zlayer")
.build()
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to build HTTP client: {e}"),
})?;
let response = client
.get(api_url)
.send()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to fetch Deno releases from GitHub: {e}"),
})?;
if !response.status().is_success() {
return Err(ToolchainError::RegistryError {
message: format!(
"GitHub API returned status {} fetching Deno releases",
response.status()
),
});
}
let releases: Vec<GitHubRelease> =
response
.json()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to parse GitHub releases JSON: {e}"),
})?;
let asset_name = format!("deno-{target}.zip");
if version_prefix == "latest" {
for release in &releases {
for asset in &release.assets {
if asset.name == asset_name {
let tag = release
.tag_name
.as_deref()
.unwrap_or("")
.strip_prefix('v')
.unwrap_or(release.tag_name.as_deref().unwrap_or(""));
if !tag.is_empty() {
debug!("Resolved Deno latest to {tag}");
let sha256 = deno_sibling_sha256(&release.assets, &asset_name).await;
return Ok(PrebuiltResolution {
version: tag.to_string(),
url: asset.browser_download_url.clone(),
sha256,
});
}
}
}
}
return Err(ToolchainError::RegistryError {
message: format!(
"No Deno release found for target '{target}' in recent GitHub releases"
),
});
}
let tag_prefix = format!("v{version_prefix}.");
for release in &releases {
let tag = release.tag_name.as_deref().unwrap_or("");
if tag.starts_with(&tag_prefix) {
for asset in &release.assets {
if asset.name == asset_name {
let ver = tag.strip_prefix('v').unwrap_or(tag);
debug!("Resolved Deno {version_prefix} to {ver} (partial)");
let sha256 = deno_sibling_sha256(&release.assets, &asset_name).await;
return Ok(PrebuiltResolution {
version: ver.to_string(),
url: asset.browser_download_url.clone(),
sha256,
});
}
}
}
}
Err(ToolchainError::RegistryError {
message: format!("No Deno release found matching version '{version_prefix}'"),
})
}
async fn deno_sibling_sha256(assets: &[GitHubAsset], asset_name: &str) -> Option<String> {
if let Some(sha) = sibling_sha256(assets, &format!("{asset_name}.sha256sum")).await {
return Some(sha);
}
sibling_sha256(assets, &format!("{asset_name}.sha256")).await
}
async fn resolve_bun(version: &str, arch: &str) -> Result<PrebuiltResolution> {
let bun_arch = match arch {
"arm64" => "aarch64",
_ => "x64",
};
if version == "latest" || !version.contains('.') {
resolve_bun_from_github(version, bun_arch).await
} else {
let url = format!(
"https://github.com/oven-sh/bun/releases/download/bun-v{version}/bun-darwin-{bun_arch}.zip"
);
Ok(PrebuiltResolution {
version: version.to_string(),
url,
sha256: None,
})
}
}
async fn resolve_bun_from_github(
version_prefix: &str,
bun_arch: &str,
) -> Result<PrebuiltResolution> {
let api_url = "https://api.github.com/repos/oven-sh/bun/releases?per_page=25";
let client = reqwest::Client::builder()
.user_agent("zlayer")
.build()
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to build HTTP client: {e}"),
})?;
let response = client
.get(api_url)
.send()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to fetch Bun releases from GitHub: {e}"),
})?;
if !response.status().is_success() {
return Err(ToolchainError::RegistryError {
message: format!(
"GitHub API returned status {} fetching Bun releases",
response.status()
),
});
}
let releases: Vec<GitHubRelease> =
response
.json()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to parse GitHub releases JSON: {e}"),
})?;
let asset_name = format!("bun-darwin-{bun_arch}.zip");
if version_prefix == "latest" {
for release in &releases {
let tag = release.tag_name.as_deref().unwrap_or("");
let ver = tag
.strip_prefix("bun-v")
.unwrap_or(tag.strip_prefix('v').unwrap_or(tag));
if ver.is_empty() {
continue;
}
for asset in &release.assets {
if asset.name == asset_name {
debug!("Resolved Bun latest to {ver}");
let sha256 = bun_sha256(&release.assets, &asset_name).await;
return Ok(PrebuiltResolution {
version: ver.to_string(),
url: asset.browser_download_url.clone(),
sha256,
});
}
}
}
return Err(ToolchainError::RegistryError {
message: format!(
"No Bun release found for arch '{bun_arch}' in recent GitHub releases"
),
});
}
let tag_prefix = format!("bun-v{version_prefix}.");
for release in &releases {
let tag = release.tag_name.as_deref().unwrap_or("");
if tag.starts_with(&tag_prefix) {
for asset in &release.assets {
if asset.name == asset_name {
let ver = tag.strip_prefix("bun-v").unwrap_or(tag);
debug!("Resolved Bun {version_prefix} to {ver} (partial)");
let sha256 = bun_sha256(&release.assets, &asset_name).await;
return Ok(PrebuiltResolution {
version: ver.to_string(),
url: asset.browser_download_url.clone(),
sha256,
});
}
}
}
}
Err(ToolchainError::RegistryError {
message: format!("No Bun release found matching version '{version_prefix}'"),
})
}
async fn bun_sha256(assets: &[GitHubAsset], asset_name: &str) -> Option<String> {
if let Some(shasums) = assets.iter().find(|a| a.name == "SHASUMS256.txt") {
if let Some(sha) = fetch_sha256_for(&shasums.browser_download_url, asset_name).await {
return Some(sha);
}
}
sibling_sha256(assets, &format!("{asset_name}.sha256")).await
}
#[derive(serde::Deserialize)]
struct ZigDownloadInfo {
tarball: String,
#[serde(default)]
shasum: String,
}
async fn resolve_zig(version: &str, arch: &str) -> Result<PrebuiltResolution> {
let platform_key = match arch {
"arm64" => "aarch64-macos",
_ => "x86_64-macos",
};
let index_url = "https://ziglang.org/download/index.json";
let response = reqwest::get(index_url)
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to fetch Zig download index from {index_url}: {e}"),
})?;
let index: HashMap<String, serde_json::Value> =
response
.json()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to parse Zig download index JSON: {e}"),
})?;
let resolved = if version == "latest" {
let mut versions: Vec<&String> = index.keys().filter(|k| *k != "master").collect();
versions.sort_by(|a, b| compare_version_strings(b, a));
versions
.first()
.map(|v| (*v).clone())
.ok_or_else(|| ToolchainError::RegistryError {
message: "No stable Zig versions found in download index".to_string(),
})?
} else if index.contains_key(version) {
version.to_string()
} else {
let prefix = format!("{version}.");
let mut candidates: Vec<&String> = index
.keys()
.filter(|k| *k != "master" && k.starts_with(&prefix))
.collect();
candidates.sort_by(|a, b| compare_version_strings(b, a));
candidates
.first()
.map(|v| (*v).clone())
.ok_or_else(|| ToolchainError::RegistryError {
message: format!("No Zig version found matching '{version}'"),
})?
};
let version_data = index
.get(&resolved)
.ok_or_else(|| ToolchainError::RegistryError {
message: format!("Zig version '{resolved}' not found in download index"),
})?;
let platform_data =
version_data
.get(platform_key)
.ok_or_else(|| ToolchainError::RegistryError {
message: format!(
"No Zig download found for platform '{platform_key}' in version '{resolved}'"
),
})?;
let info: ZigDownloadInfo = serde_json::from_value(platform_data.clone()).map_err(|e| {
ToolchainError::RegistryError {
message: format!(
"Failed to parse Zig download info for {platform_key}/{resolved}: {e}"
),
}
})?;
debug!("Resolved Zig {version} to {resolved}: {}", info.tarball);
let sha256 = is_hex_sha256(&info.shasum).then(|| info.shasum.to_ascii_lowercase());
Ok(PrebuiltResolution {
version: resolved,
url: info.tarball,
sha256,
})
}
fn compare_version_strings(a: &str, b: &str) -> std::cmp::Ordering {
let a_parts: Vec<&str> = a.split('.').collect();
let b_parts: Vec<&str> = b.split('.').collect();
for (ap, bp) in a_parts.iter().zip(b_parts.iter()) {
let ord = match (ap.parse::<u64>(), bp.parse::<u64>()) {
(Ok(an), Ok(bn)) => an.cmp(&bn),
_ => ap.cmp(bp),
};
if ord != std::cmp::Ordering::Equal {
return ord;
}
}
a_parts.len().cmp(&b_parts.len())
}
#[derive(serde::Deserialize)]
struct AdoptiumAvailableReleases {
most_recent_lts: u32,
}
async fn resolve_java(version: &str, arch: &str) -> Result<PrebuiltResolution> {
let adoptium_arch = match arch {
"arm64" => "aarch64",
_ => "x64",
};
let feature_version = if version == "latest" {
resolve_java_latest_lts().await?
} else {
version.split('.').next().unwrap_or(version).to_string()
};
let url = format!(
"https://api.adoptium.net/v3/binary/latest/{feature_version}/ga/mac/{adoptium_arch}/jdk/hotspot/normal/eclipse"
);
Ok(PrebuiltResolution {
version: feature_version,
url,
sha256: None,
})
}
async fn resolve_java_latest_lts() -> Result<String> {
let api_url = "https://api.adoptium.net/v3/info/available_releases";
let response = reqwest::get(api_url)
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to fetch Adoptium available releases from {api_url}: {e}"),
})?;
if !response.status().is_success() {
return Err(ToolchainError::RegistryError {
message: format!(
"Adoptium API returned status {} fetching available releases",
response.status()
),
});
}
let releases: AdoptiumAvailableReleases =
response
.json()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to parse Adoptium available releases JSON: {e}"),
})?;
let version = releases.most_recent_lts.to_string();
debug!("Resolved Java latest LTS to feature version {version}");
Ok(version)
}
async fn resolve_graalvm(version: &str, arch: &str) -> Result<PrebuiltResolution> {
let graalvm_arch = match arch {
"arm64" => "aarch64",
_ => "x64",
};
if version == "latest" || !version.contains('.') {
resolve_graalvm_from_github(version, graalvm_arch).await
} else {
let url = format!(
"https://github.com/graalvm/graalvm-ce-builds/releases/download/\
jdk-{version}/graalvm-community-jdk-{version}_macos-{graalvm_arch}_bin.tar.gz"
);
Ok(PrebuiltResolution {
version: version.to_string(),
url,
sha256: None,
})
}
}
async fn resolve_graalvm_from_github(
version_prefix: &str,
graalvm_arch: &str,
) -> Result<PrebuiltResolution> {
let api_url = "https://api.github.com/repos/graalvm/graalvm-ce-builds/releases?per_page=25";
let client = reqwest::Client::builder()
.user_agent("zlayer")
.build()
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to build HTTP client: {e}"),
})?;
let response = client
.get(api_url)
.send()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to fetch GraalVM releases from GitHub: {e}"),
})?;
if !response.status().is_success() {
return Err(ToolchainError::RegistryError {
message: format!(
"GitHub API returned status {} fetching GraalVM releases",
response.status()
),
});
}
let releases: Vec<GitHubRelease> =
response
.json()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Failed to parse GitHub releases JSON: {e}"),
})?;
if version_prefix == "latest" {
for release in &releases {
let tag = release.tag_name.as_deref().unwrap_or("");
if let Some(jdk_version) = tag.strip_prefix("jdk-") {
if jdk_version.is_empty() {
continue;
}
let filename =
format!("graalvm-community-jdk-{jdk_version}_macos-{graalvm_arch}_bin.tar.gz");
let url = format!(
"https://github.com/graalvm/graalvm-ce-builds/releases/download/{tag}/{filename}"
);
debug!("Resolved GraalVM latest to {jdk_version}");
let sha256 = sibling_sha256(&release.assets, &format!("{filename}.sha256")).await;
return Ok(PrebuiltResolution {
version: jdk_version.to_string(),
url,
sha256,
});
}
}
return Err(ToolchainError::RegistryError {
message: "No GraalVM CE release found in recent GitHub releases".to_string(),
});
}
let tag_prefix = format!("jdk-{version_prefix}.");
for release in &releases {
let tag = release.tag_name.as_deref().unwrap_or("");
if tag.starts_with(&tag_prefix) {
if let Some(jdk_version) = tag.strip_prefix("jdk-") {
let filename =
format!("graalvm-community-jdk-{jdk_version}_macos-{graalvm_arch}_bin.tar.gz");
let url = format!(
"https://github.com/graalvm/graalvm-ce-builds/releases/download/{tag}/{filename}"
);
debug!("Resolved GraalVM {version_prefix} to {jdk_version} (partial)");
let sha256 = sibling_sha256(&release.assets, &format!("{filename}.sha256")).await;
return Ok(PrebuiltResolution {
version: jdk_version.to_string(),
url,
sha256,
});
}
}
}
Err(ToolchainError::RegistryError {
message: format!("No GraalVM CE release found matching version '{version_prefix}'"),
})
}
fn is_hex_sha256(s: &str) -> bool {
s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
}
async fn fetch_sha256_token(url: &str) -> Option<String> {
let text = reqwest::get(url).await.ok()?.text().await.ok()?;
let token = text.split_whitespace().next()?;
is_hex_sha256(token).then(|| token.to_ascii_lowercase())
}
async fn fetch_sha256_for(url: &str, filename: &str) -> Option<String> {
let text = reqwest::get(url).await.ok()?.text().await.ok()?;
for line in text.lines() {
let mut cols = line.split_whitespace();
if let (Some(hash), Some(name)) = (cols.next(), cols.next()) {
let name = name.strip_prefix('*').unwrap_or(name);
if name == filename && is_hex_sha256(hash) {
return Some(hash.to_ascii_lowercase());
}
}
}
None
}
async fn sibling_sha256(assets: &[GitHubAsset], sibling: &str) -> Option<String> {
let asset = assets.iter().find(|a| a.name == sibling)?;
fetch_sha256_token(&asset.browser_download_url).await
}
#[allow(clippy::too_many_lines, clippy::match_same_arms)]
async fn extract_toolchain(language: &str, archive: &Path, target_dir: &Path) -> Result<()> {
let archive_str = archive.display().to_string();
let target_str = target_dir.display().to_string();
let output = match language {
"go" | "node" => {
tokio::process::Command::new("tar")
.args([
"xzf",
&archive_str,
"-C",
&target_str,
"--strip-components=1",
])
.output()
.await?
}
"rust" => {
let extract_tmp = target_dir.join("_extract");
tokio::fs::create_dir_all(&extract_tmp).await?;
let extract_tmp_str = extract_tmp.display().to_string();
let tar_out = tokio::process::Command::new("tar")
.args([
"xzf",
&archive_str,
"-C",
&extract_tmp_str,
"--strip-components=1",
])
.output()
.await?;
if !tar_out.status.success() {
let stderr = String::from_utf8_lossy(&tar_out.stderr);
let _ = tokio::process::Command::new("chmod")
.args(["-R", "u+w"])
.arg(&extract_tmp)
.status()
.await;
let _ = tokio::process::Command::new("rm")
.args(["-rf"])
.arg(&extract_tmp)
.status()
.await;
return Err(ToolchainError::RegistryError {
message: format!("Failed to extract Rust tarball: {stderr}"),
});
}
let install_sh = extract_tmp.join("install.sh");
let install_out = tokio::process::Command::new("sh")
.arg(install_sh.display().to_string())
.arg(format!("--prefix={target_str}"))
.arg("--disable-ldconfig")
.output()
.await?;
let _ = tokio::process::Command::new("chmod")
.args(["-R", "u+w"])
.arg(&extract_tmp)
.status()
.await;
let _ = tokio::process::Command::new("rm")
.args(["-rf"])
.arg(&extract_tmp)
.status()
.await;
if !install_out.status.success() {
let stderr = String::from_utf8_lossy(&install_out.stderr);
return Err(ToolchainError::RegistryError {
message: format!("Rust install.sh failed: {stderr}"),
});
}
return Ok(());
}
"deno" => {
let out = tokio::process::Command::new("unzip")
.args(["-o", &archive_str, "-d", &target_str])
.output()
.await?;
if out.status.success() {
let bin_dir = target_dir.join("bin");
tokio::fs::create_dir_all(&bin_dir).await?;
let deno_binary = target_dir.join("deno");
if deno_binary.exists() {
tokio::fs::rename(&deno_binary, bin_dir.join("deno")).await?;
}
}
out
}
"zig" => {
let out = tokio::process::Command::new("tar")
.args([
"xJf",
&archive_str,
"-C",
&target_str,
"--strip-components=1",
])
.output()
.await?;
if out.status.success() {
let bin_dir = target_dir.join("bin");
tokio::fs::create_dir_all(&bin_dir).await?;
let zig_binary = target_dir.join("zig");
if zig_binary.exists() && !bin_dir.join("zig").exists() {
#[cfg(unix)]
tokio::fs::symlink(Path::new("../zig"), &bin_dir.join("zig")).await?;
#[cfg(windows)]
{
tokio::fs::copy(&zig_binary, bin_dir.join("zig")).await?;
}
}
}
out
}
"java" | "graalvm" => {
tokio::process::Command::new("tar")
.args([
"xzf",
&archive_str,
"-C",
&target_str,
"--strip-components=3",
])
.output()
.await?
}
"bun" => {
let out = tokio::process::Command::new("unzip")
.args(["-o", &archive_str, "-d", &target_str])
.output()
.await?;
if out.status.success() {
let bin_dir = target_dir.join("bin");
tokio::fs::create_dir_all(&bin_dir).await?;
if let Ok(mut entries) = tokio::fs::read_dir(target_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if entry.file_name().to_string_lossy().starts_with("bun-") {
let bun_binary = entry.path().join("bun");
if bun_binary.exists() {
tokio::fs::rename(&bun_binary, bin_dir.join("bun")).await?;
let _ = tokio::process::Command::new("chmod")
.args(["-R", "u+w"])
.arg(entry.path())
.status()
.await;
let _ = tokio::process::Command::new("rm")
.args(["-rf"])
.arg(entry.path())
.status()
.await;
}
}
}
}
}
out
}
_ => {
tokio::process::Command::new("tar")
.args([
"xzf",
&archive_str,
"-C",
&target_str,
"--strip-components=1",
])
.output()
.await?
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ToolchainError::RegistryError {
message: format!("Failed to extract {language} toolchain: {stderr}"),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_prebuilt_accepts_languages_and_aliases() {
for f in [
"go",
"golang",
"node",
"nodejs",
"rust",
"python",
"python3",
"python@3.12",
"deno",
"bun",
"zig",
"java",
"openjdk",
"openjdk@17",
"node@22",
"graalvm",
"graalvm-ce",
"graalvm-community",
] {
assert!(is_prebuilt_formula(f), "{f} should be a prebuilt formula");
}
}
#[test]
fn is_prebuilt_rejects_non_languages_and_swift() {
for f in ["swift", "git", "jq", "cmake", "ripgrep", "openssl@3"] {
assert!(
!is_prebuilt_formula(f),
"{f} should NOT be a prebuilt formula"
);
}
}
#[test]
fn formula_split_maps_language_and_version() {
assert_eq!(
formula_language_version("python@3.12"),
("python".to_string(), "3.12".to_string())
);
assert_eq!(
formula_language_version("node@22"),
("node".to_string(), "22".to_string())
);
assert_eq!(
formula_language_version("go"),
("go".to_string(), "latest".to_string())
);
assert_eq!(
formula_language_version("golang"),
("go".to_string(), "latest".to_string())
);
assert_eq!(
formula_language_version("openjdk@17"),
("java".to_string(), "17".to_string())
);
assert_eq!(
formula_language_version("python3"),
("python".to_string(), "latest".to_string())
);
assert_eq!(
formula_language_version("graalvm-ce"),
("graalvm".to_string(), "latest".to_string())
);
assert_eq!(
formula_language_version("nodejs"),
("node".to_string(), "latest".to_string())
);
}
#[test]
fn node_lts_selection_picks_highest_lts_codename() {
let json = r#"[
{"version":"v26.0.0","lts":false},
{"version":"v25.2.0","lts":false},
{"version":"v24.18.0","lts":"Krypton"},
{"version":"v22.20.0","lts":"Jod"},
{"version":"v20.19.0","lts":"Iron"}
]"#;
let releases: Vec<NodeRelease> = serde_json::from_str(json).unwrap();
assert_eq!(
select_newest_node_lts(&releases).as_deref(),
Some("24.18.0"),
"LTS resolver must pick the newest line whose lts is a codename string"
);
}
#[test]
fn node_lts_selection_is_none_when_no_lts_line() {
let json = r#"[{"version":"v26.0.0","lts":false},{"version":"v25.2.0","lts":false}]"#;
let releases: Vec<NodeRelease> = serde_json::from_str(json).unwrap();
assert_eq!(select_newest_node_lts(&releases), None);
}
#[test]
fn node_lts_token_is_a_prebuilt_node_formula() {
assert!(is_prebuilt_formula("node@lts"));
assert_eq!(
formula_language_version("node@lts"),
("node".to_string(), "lts".to_string())
);
}
#[test]
fn go_keg_layout_sets_goroot_and_bin() {
let keg = Path::new("/cache/go-1.23.6-arm64");
let (path_dirs, env) = keg_path_dirs_and_env("go", keg);
assert_eq!(path_dirs, vec!["/cache/go-1.23.6-arm64/bin".to_string()]);
assert_eq!(
env.get("GOROOT").map(String::as_str),
Some("/cache/go-1.23.6-arm64")
);
assert_eq!(
env.get("GOFLAGS").map(String::as_str),
Some("-buildvcs=false")
);
}
#[test]
fn rust_keg_layout_sets_cargo_and_rustup_homes() {
let keg = Path::new("/cache/rust-1.82.0-arm64");
let (path_dirs, env) = keg_path_dirs_and_env("rust", keg);
assert_eq!(
path_dirs,
vec![
"/cache/rust-1.82.0-arm64/cargo/bin".to_string(),
"/cache/rust-1.82.0-arm64/bin".to_string(),
]
);
assert_eq!(
env.get("CARGO_HOME").map(String::as_str),
Some("/cache/rust-1.82.0-arm64/cargo")
);
assert_eq!(
env.get("RUSTUP_HOME").map(String::as_str),
Some("/cache/rust-1.82.0-arm64/rustup")
);
}
#[test]
fn graalvm_keg_layout_sets_both_homes() {
let keg = Path::new("/cache/graalvm-21.0.5-arm64");
let (_, env) = keg_path_dirs_and_env("graalvm", keg);
assert_eq!(
env.get("JAVA_HOME").map(String::as_str),
Some("/cache/graalvm-21.0.5-arm64")
);
assert_eq!(
env.get("GRAALVM_HOME").map(String::as_str),
Some("/cache/graalvm-21.0.5-arm64")
);
}
#[test]
fn node_keg_layout_sets_openssl_conf() {
let keg = Path::new("/cache/node-22.1.0-arm64");
let (path_dirs, env) = keg_path_dirs_and_env("node", keg);
assert_eq!(path_dirs, vec!["/cache/node-22.1.0-arm64/bin".to_string()]);
assert_eq!(
env.get("OPENSSL_CONF").map(String::as_str),
Some("/cache/node-22.1.0-arm64/etc/openssl_sandbox.cnf")
);
}
#[test]
fn python_version_extracted_from_asset_name() {
assert_eq!(
extract_python_version_from_asset(
"cpython-3.12.8+20250106-aarch64-apple-darwin-install_only_stripped.tar.gz"
),
"3.12.8"
);
assert_eq!(extract_python_version_from_asset("cpython-"), "");
assert_eq!(extract_python_version_from_asset("not-cpython"), "");
}
#[test]
fn version_strings_compare_numerically() {
use std::cmp::Ordering;
assert_eq!(
compare_version_strings("0.14.0", "0.13.0"),
Ordering::Greater
);
assert_eq!(
compare_version_strings("1.0.0", "0.14.0"),
Ordering::Greater
);
assert_eq!(
compare_version_strings("0.14.1", "0.14.0"),
Ordering::Greater
);
assert_eq!(compare_version_strings("0.14.0", "0.14.0"), Ordering::Equal);
}
#[tokio::test]
async fn resolve_deno_exact_url() {
let r = resolve_deno("2.1.4", "arm64").await.unwrap();
assert_eq!(r.version, "2.1.4");
assert_eq!(
r.url,
"https://github.com/denoland/deno/releases/download/v2.1.4/deno-aarch64-apple-darwin.zip"
);
assert!(r.sha256.is_none());
}
#[tokio::test]
async fn resolve_bun_exact_url() {
let r = resolve_bun("1.2.3", "arm64").await.unwrap();
assert_eq!(r.version, "1.2.3");
assert_eq!(
r.url,
"https://github.com/oven-sh/bun/releases/download/bun-v1.2.3/bun-darwin-aarch64.zip"
);
assert!(r.sha256.is_none());
}
#[tokio::test]
async fn resolve_graalvm_exact_url() {
let r = resolve_graalvm("21.0.5", "arm64").await.unwrap();
assert_eq!(r.version, "21.0.5");
assert_eq!(
r.url,
"https://github.com/graalvm/graalvm-ce-builds/releases/download/\
jdk-21.0.5/graalvm-community-jdk-21.0.5_macos-aarch64_bin.tar.gz"
);
assert!(r.sha256.is_none());
}
#[tokio::test]
async fn resolve_java_exact_url_strips_to_major() {
let r = resolve_java("21.0.5", "arm64").await.unwrap();
assert_eq!(r.version, "21");
assert_eq!(
r.url,
"https://api.adoptium.net/v3/binary/latest/21/ga/mac/aarch64/jdk/hotspot/normal/eclipse"
);
assert!(r.sha256.is_none());
}
#[test]
fn zig_download_info_parses_shasum() {
let info: ZigDownloadInfo = serde_json::from_str(
r#"{"tarball":"https://ziglang.org/x.tar.xz","shasum":"aa","size":"1"}"#,
)
.unwrap();
assert_eq!(info.tarball, "https://ziglang.org/x.tar.xz");
assert_eq!(info.shasum, "aa");
}
#[test]
fn hex_sha256_validation() {
assert!(is_hex_sha256(
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
));
assert!(!is_hex_sha256("tooshort"));
assert!(!is_hex_sha256(
"zz4d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
));
}
}