#![cfg(target_os = "windows")]
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
use std::str::FromStr;
use zlayer_types::ImageReference;
use crate::error::{BuildError, Result};
#[derive(Debug, Clone)]
pub struct ToolchainSpec {
pub language: String,
pub version: String,
pub install_dir: String,
pub path_dirs: Vec<String>,
pub env: HashMap<String, String>,
}
impl ToolchainSpec {
#[must_use]
pub fn go(version: &str) -> Self {
let mut env = HashMap::new();
env.insert("GOROOT".to_string(), "C:\\toolchains\\go".to_string());
env.insert("GOFLAGS".to_string(), "-buildvcs=false".to_string());
Self {
language: "go".to_string(),
version: version.to_string(),
install_dir: "C:\\toolchains\\go".to_string(),
path_dirs: vec!["C:\\toolchains\\go\\bin".to_string()],
env,
}
}
#[must_use]
pub fn node(version: &str) -> Self {
Self {
language: "node".to_string(),
version: version.to_string(),
install_dir: "C:\\toolchains\\node".to_string(),
path_dirs: vec!["C:\\toolchains\\node".to_string()],
env: HashMap::new(),
}
}
#[must_use]
pub fn rust(version: &str) -> Self {
let mut env = HashMap::new();
env.insert(
"CARGO_HOME".to_string(),
"C:\\toolchains\\cargo".to_string(),
);
env.insert(
"RUSTUP_HOME".to_string(),
"C:\\toolchains\\rustup".to_string(),
);
Self {
language: "rust".to_string(),
version: version.to_string(),
install_dir: "C:\\toolchains\\rust".to_string(),
path_dirs: vec!["C:\\toolchains\\cargo\\bin".to_string()],
env,
}
}
#[must_use]
pub fn python(version: &str) -> Self {
Self {
language: "python".to_string(),
version: version.to_string(),
install_dir: "C:\\toolchains\\python".to_string(),
path_dirs: vec![
"C:\\toolchains\\python".to_string(),
"C:\\toolchains\\python\\Scripts".to_string(),
],
env: HashMap::new(),
}
}
#[must_use]
pub fn deno(version: &str) -> Self {
Self {
language: "deno".to_string(),
version: version.to_string(),
install_dir: "C:\\toolchains\\deno".to_string(),
path_dirs: vec!["C:\\toolchains\\deno".to_string()],
env: HashMap::new(),
}
}
#[must_use]
pub fn bun(version: &str) -> Self {
Self {
language: "bun".to_string(),
version: version.to_string(),
install_dir: "C:\\toolchains\\bun".to_string(),
path_dirs: vec!["C:\\toolchains\\bun".to_string()],
env: HashMap::new(),
}
}
}
#[must_use]
pub fn detect_toolchain(image_ref: &str) -> Option<ToolchainSpec> {
let (name, tag) = match ImageReference::from_str(image_ref) {
Ok(r) => (
r.repository().to_string(),
r.tag().unwrap_or("latest").to_string(),
),
Err(_) => {
(image_ref.to_string(), "latest".to_string())
}
};
let base_name = name.rsplit('/').next().unwrap_or(&name);
let version = extract_version_from_tag(&tag);
match base_name {
"golang" | "go" => Some(ToolchainSpec::go(&version)),
"node" => Some(ToolchainSpec::node(&version)),
"rust" => Some(ToolchainSpec::rust(&version)),
"python" | "python3" => Some(ToolchainSpec::python(&version)),
"deno" => Some(ToolchainSpec::deno(&version)),
"bun" => Some(ToolchainSpec::bun(&version)),
_ => None,
}
}
pub(crate) fn extract_version_from_tag(tag: &str) -> String {
if tag == "latest" {
return "latest".to_string();
}
let version_part: String = tag
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
if version_part.is_empty() {
"latest".to_string()
} else {
version_part.trim_end_matches('.').to_string()
}
}
#[must_use]
pub fn host_arch() -> &'static str {
if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"x86_64"
}
}
pub async fn provision_toolchain(
spec: &ToolchainSpec,
rootfs_dir: &Path,
cache_dir: &Path,
tmp_dir: &Path,
) -> Result<()> {
let arch = host_arch();
let cache_key = format!("{}-{}-{}", spec.language, spec.version, arch);
let cached_toolchain = cache_dir.join(&cache_key);
if cached_toolchain.exists() {
info!(
"Linking cached {} {} toolchain from {}",
spec.language,
spec.version,
cached_toolchain.display()
);
link_toolchain_into_rootfs(&cached_toolchain, rootfs_dir, spec).await?;
return Ok(());
}
let (resolved_version, url) = resolve_download_url(spec, arch).await?;
info!(
"Downloading {} {} (resolved: {}) from {}",
spec.language, spec.version, resolved_version, url
);
if spec.language == "rust" {
tokio::fs::create_dir_all(&cached_toolchain).await?;
provision_rust_via_rustup(&resolved_version, &url, &cached_toolchain, tmp_dir).await?;
link_toolchain_into_rootfs(&cached_toolchain, rootfs_dir, spec).await?;
info!(
"Provisioned {} {} into rootfs",
spec.language, resolved_version
);
return Ok(());
}
let ext = if spec.language == "python" {
"tar.gz"
} else {
"zip"
};
let download_path = tmp_dir.join(format!("toolchain-{cache_key}.{ext}"));
download_file(&url, &download_path).await?;
tokio::fs::create_dir_all(&cached_toolchain).await?;
extract_toolchain(&spec.language, &download_path, &cached_toolchain).await?;
link_toolchain_into_rootfs(&cached_toolchain, rootfs_dir, spec).await?;
let _ = tokio::fs::remove_file(&download_path).await;
info!(
"Provisioned {} {} into rootfs",
spec.language, resolved_version
);
Ok(())
}
async fn resolve_download_url(spec: &ToolchainSpec, arch: &str) -> Result<(String, String)> {
match spec.language.as_str() {
"go" => resolve_go(&spec.version, arch).await,
"node" => resolve_node(&spec.version, arch).await,
"rust" => resolve_rust(&spec.version, arch).await,
"python" => resolve_python(&spec.version, arch).await,
"deno" => resolve_deno(&spec.version, arch).await,
"bun" => resolve_bun(&spec.version, arch).await,
other => Err(BuildError::RegistryError {
message: format!(
"No Windows toolchain provisioner for '{other}'. \
Supported: go, node, rust, python, deno, bun. \
Use a pre-built zlayer/ base image or specify a toolchain URL."
),
}),
}
}
async fn resolve_go(version: &str, _arch: &str) -> Result<(String, String)> {
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 url = format!("https://go.dev/dl/go{resolved}.windows-amd64.zip");
Ok((resolved, url))
}
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| BuildError::RegistryError {
message: format!("Failed to fetch Go versions from {api_url}: {e}"),
})?;
let releases: Vec<GoRelease> =
response
.json()
.await
.map_err(|e| BuildError::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(|| BuildError::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(BuildError::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<(String, String)> {
let resolved = if version == "latest" || !version.contains('.') {
resolve_node_version_from_api(version).await?
} else {
version.to_string()
};
let url = format!("https://nodejs.org/dist/v{resolved}/node-v{resolved}-win-x64.zip");
Ok((resolved, url))
}
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| BuildError::RegistryError {
message: format!("Failed to fetch Node.js versions from {api_url}: {e}"),
})?;
let releases: Vec<NodeRelease> =
response
.json()
.await
.map_err(|e| BuildError::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(|| BuildError::RegistryError {
message: "No Node.js releases found".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(BuildError::RegistryError {
message: format!("No Node.js release found matching version '{version_prefix}'"),
})
}
#[derive(serde::Deserialize)]
struct NodeRelease {
version: String,
}
async fn resolve_rust(version: &str, _arch: &str) -> Result<(String, String)> {
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 = "https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe"
.to_string();
Ok((resolved, url))
}
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| BuildError::RegistryError {
message: format!("Failed to fetch Rust stable channel from {channel_url}: {e}"),
})?;
let body = response
.text()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to read Rust stable channel response: {e}"),
})?;
let pkg_rust_pos = body
.find("[pkg.rust]")
.ok_or_else(|| BuildError::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(|| BuildError::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(BuildError::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 provision_rust_via_rustup(
version: &str,
url: &str,
cached: &Path,
tmp_dir: &Path,
) -> Result<()> {
let init_path = tmp_dir.join("rustup-init.exe");
download_file(url, &init_path).await?;
let cargo_home = cached.join("cargo");
let rustup_home = cached.join("rustup");
tokio::fs::create_dir_all(&cargo_home).await?;
tokio::fs::create_dir_all(&rustup_home).await?;
let output = tokio::process::Command::new(&init_path)
.args([
"-y",
"--default-toolchain",
version,
"--profile",
"minimal",
"--no-modify-path",
])
.env("CARGO_HOME", &cargo_home)
.env("RUSTUP_HOME", &rustup_home)
.output()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to run rustup-init.exe: {e}"),
})?;
let _ = tokio::fs::remove_file(&init_path).await;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BuildError::RegistryError {
message: format!("rustup-init.exe failed installing {version}: {stderr}"),
});
}
Ok(())
}
async fn resolve_python(version: &str, _arch: &str) -> Result<(String, String)> {
let python_target = "x86_64-pc-windows-msvc";
resolve_python_from_github(version, python_target).await
}
async fn resolve_python_from_github(
version_prefix: &str,
target: &str,
) -> Result<(String, String)> {
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| BuildError::RegistryError {
message: format!("Failed to build HTTP client: {e}"),
})?;
let response = client
.get(api_url)
.send()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to fetch Python releases from GitHub: {e}"),
})?;
if !response.status().is_success() {
return Err(BuildError::RegistryError {
message: format!(
"GitHub API returned status {} fetching Python releases",
response.status()
),
});
}
let releases: Vec<GitHubRelease> =
response
.json()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to parse GitHub releases JSON: {e}"),
})?;
let target_suffix = format!("{target}-install_only.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) {
let py_version = extract_python_version_from_asset(&asset.name);
if !py_version.is_empty() {
debug!("Resolved Python latest to {py_version}");
return Ok((py_version, asset.browser_download_url.clone()));
}
}
}
}
return Err(BuildError::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) {
let py_version = extract_python_version_from_asset(&asset.name);
debug!("Resolved Python {version_prefix} to {py_version} (exact)");
return Ok((py_version, asset.browser_download_url.clone()));
}
if asset.name.starts_with(&partial_prefix) {
let py_version = extract_python_version_from_asset(&asset.name);
debug!("Resolved Python {version_prefix} to {py_version} (partial)");
return Ok((py_version, asset.browser_download_url.clone()));
}
}
}
Err(BuildError::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<(String, String)> {
let deno_target = "x86_64-pc-windows-msvc";
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((version.to_string(), url))
}
}
async fn resolve_deno_from_github(version_prefix: &str, target: &str) -> Result<(String, String)> {
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| BuildError::RegistryError {
message: format!("Failed to build HTTP client: {e}"),
})?;
let response = client
.get(api_url)
.send()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to fetch Deno releases from GitHub: {e}"),
})?;
if !response.status().is_success() {
return Err(BuildError::RegistryError {
message: format!(
"GitHub API returned status {} fetching Deno releases",
response.status()
),
});
}
let releases: Vec<GitHubRelease> =
response
.json()
.await
.map_err(|e| BuildError::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}");
return Ok((tag.to_string(), asset.browser_download_url.clone()));
}
}
}
}
return Err(BuildError::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)");
return Ok((ver.to_string(), asset.browser_download_url.clone()));
}
}
}
}
Err(BuildError::RegistryError {
message: format!("No Deno release found matching version '{version_prefix}'"),
})
}
async fn resolve_bun(version: &str, _arch: &str) -> Result<(String, String)> {
let bun_target = "windows-x64";
if version == "latest" || !version.contains('.') {
resolve_bun_from_github(version, bun_target).await
} else {
let url = format!(
"https://github.com/oven-sh/bun/releases/download/bun-v{version}/bun-{bun_target}.zip"
);
Ok((version.to_string(), url))
}
}
async fn resolve_bun_from_github(
version_prefix: &str,
bun_target: &str,
) -> Result<(String, String)> {
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| BuildError::RegistryError {
message: format!("Failed to build HTTP client: {e}"),
})?;
let response = client
.get(api_url)
.send()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to fetch Bun releases from GitHub: {e}"),
})?;
if !response.status().is_success() {
return Err(BuildError::RegistryError {
message: format!(
"GitHub API returned status {} fetching Bun releases",
response.status()
),
});
}
let releases: Vec<GitHubRelease> =
response
.json()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to parse GitHub releases JSON: {e}"),
})?;
let asset_name = format!("bun-{bun_target}.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}");
return Ok((ver.to_string(), asset.browser_download_url.clone()));
}
}
}
return Err(BuildError::RegistryError {
message: format!(
"No Bun release found for target '{bun_target}' 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)");
return Ok((ver.to_string(), asset.browser_download_url.clone()));
}
}
}
}
Err(BuildError::RegistryError {
message: format!("No Bun release found matching version '{version_prefix}'"),
})
}
async fn download_file(url: &str, dest: &Path) -> Result<()> {
let response = reqwest::get(url)
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to download {url}: {e}"),
})?;
if !response.status().is_success() {
return Err(BuildError::RegistryError {
message: format!("Download failed with status {}: {url}", response.status()),
});
}
let bytes = response
.bytes()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to read response body from {url}: {e}"),
})?;
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(dest, &bytes).await?;
Ok(())
}
async fn extract_toolchain(language: &str, archive: &Path, target_dir: &Path) -> Result<()> {
let archive = archive.to_path_buf();
let target_dir = target_dir.to_path_buf();
let language = language.to_string();
tokio::task::spawn_blocking(move || {
extract_toolchain_blocking(&language, &archive, &target_dir)
})
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Toolchain extraction task panicked: {e}"),
})?
}
fn extract_toolchain_blocking(language: &str, archive: &Path, target_dir: &Path) -> Result<()> {
match language {
"python" => extract_tar_gz(archive, target_dir, 1),
"go" | "node" | "bun" => extract_zip(archive, target_dir, 1),
"deno" => extract_zip(archive, target_dir, 0),
other => Err(BuildError::RegistryError {
message: format!("No extraction strategy for toolchain '{other}'"),
}),
}
}
fn extract_zip(archive: &Path, target_dir: &Path, strip_components: usize) -> Result<()> {
let file = std::fs::File::open(archive).map_err(|e| {
BuildError::IoError(std::io::Error::new(
e.kind(),
format!("failed to open zip archive {}: {e}", archive.display()),
))
})?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| {
BuildError::IoError(std::io::Error::other(format!(
"failed to read zip archive: {e}"
)))
})?;
std::fs::create_dir_all(target_dir)?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| {
BuildError::IoError(std::io::Error::other(format!(
"failed to read zip entry {i}: {e}"
)))
})?;
let Some(enclosed) = entry.enclosed_name() else {
debug!("Skipping potentially unsafe zip entry");
continue;
};
let Some(stripped) = strip_leading_components(&enclosed, strip_components) else {
continue;
};
let out_path = target_dir.join(stripped);
if entry.is_dir() {
std::fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out_file = std::fs::File::create(&out_path).map_err(|e| {
BuildError::IoError(std::io::Error::new(
e.kind(),
format!("failed to create file {}: {e}", out_path.display()),
))
})?;
let mut buf = Vec::new();
entry.read_to_end(&mut buf).map_err(|e| {
BuildError::IoError(std::io::Error::new(
e.kind(),
format!("failed to read zip entry: {e}"),
))
})?;
std::io::Write::write_all(&mut out_file, &buf)?;
}
}
Ok(())
}
fn extract_tar_gz(archive: &Path, target_dir: &Path, strip_components: usize) -> Result<()> {
let file = std::fs::File::open(archive).map_err(|e| {
BuildError::IoError(std::io::Error::new(
e.kind(),
format!("failed to open tarball {}: {e}", archive.display()),
))
})?;
std::fs::create_dir_all(target_dir)?;
let decoder = flate2::read::GzDecoder::new(file);
let mut tar = tar::Archive::new(decoder);
let entries = tar.entries().map_err(|e| {
BuildError::IoError(std::io::Error::other(format!(
"failed to read tar entries: {e}"
)))
})?;
for entry in entries {
let mut entry = entry.map_err(|e| {
BuildError::IoError(std::io::Error::other(format!(
"failed to read tar entry: {e}"
)))
})?;
let path = entry
.path()
.map_err(|e| {
BuildError::IoError(std::io::Error::other(format!(
"failed to read tar entry path: {e}"
)))
})?
.into_owned();
let Some(stripped) = strip_leading_components(&path, strip_components) else {
continue;
};
let out_path = target_dir.join(stripped);
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
entry.unpack(&out_path).map_err(|e| {
BuildError::IoError(std::io::Error::new(
e.kind(),
format!("failed to unpack tar entry to {}: {e}", out_path.display()),
))
})?;
}
Ok(())
}
fn strip_leading_components(path: &Path, n: usize) -> Option<PathBuf> {
if n == 0 {
return Some(path.to_path_buf());
}
let mut comps = path.components();
for _ in 0..n {
comps.next()?;
}
let rest: PathBuf = comps.as_path().to_path_buf();
if rest.as_os_str().is_empty() {
None
} else {
Some(rest)
}
}
async fn link_toolchain_into_rootfs(
cached: &Path,
rootfs_dir: &Path,
spec: &ToolchainSpec,
) -> Result<()> {
let rel = strip_windows_drive(&spec.install_dir);
let install_target = rootfs_dir.join(rel);
if let Some(parent) = install_target.parent() {
tokio::fs::create_dir_all(parent).await?;
}
if install_target.exists() {
let _ = tokio::fs::remove_dir_all(&install_target).await;
}
let junction_ok = tokio::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(install_target.as_os_str())
.arg(cached.as_os_str())
.output()
.await
.is_ok_and(|o| o.status.success());
if junction_ok {
debug!(
"Junctioned toolchain {} → {}",
install_target.display(),
cached.display()
);
return Ok(());
}
debug!(
"Junction unavailable; copying toolchain {} → {}",
cached.display(),
install_target.display()
);
copy_dir_recursive(cached, &install_target).await
}
fn strip_windows_drive(path: &str) -> String {
let trimmed = path.trim_start_matches(|c: char| c.is_ascii_alphabetic());
let trimmed = trimmed.strip_prefix(':').unwrap_or(trimmed);
trimmed.trim_start_matches(['\\', '/']).replace('\\', "/")
}
async fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
let src = src.to_path_buf();
let dst = dst.to_path_buf();
tokio::task::spawn_blocking(move || copy_dir_recursive_blocking(&src, &dst))
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Toolchain copy task panicked: {e}"),
})?
}
fn copy_dir_recursive_blocking(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let from = entry.path();
let to = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive_blocking(&from, &to)?;
} else {
std::fs::copy(&from, &to)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_golang() {
let spec = detect_toolchain("golang:1.23-alpine").unwrap();
assert_eq!(spec.language, "go");
assert_eq!(spec.version, "1.23");
assert_eq!(spec.install_dir, "C:\\toolchains\\go");
assert_eq!(
spec.env.get("GOROOT"),
Some(&"C:\\toolchains\\go".to_string())
);
}
#[test]
fn test_detect_golang_exact() {
let spec = detect_toolchain("golang:1.23.6").unwrap();
assert_eq!(spec.language, "go");
assert_eq!(spec.version, "1.23.6");
}
#[test]
fn test_detect_node() {
let spec = detect_toolchain("node:20-slim").unwrap();
assert_eq!(spec.language, "node");
assert_eq!(spec.version, "20");
assert!(spec.path_dirs.contains(&"C:\\toolchains\\node".to_string()));
}
#[test]
fn test_detect_python() {
let spec = detect_toolchain("python:3.12-bookworm").unwrap();
assert_eq!(spec.language, "python");
assert_eq!(spec.version, "3.12");
assert!(spec
.path_dirs
.contains(&"C:\\toolchains\\python\\Scripts".to_string()));
}
#[test]
fn test_detect_rust() {
let spec = detect_toolchain("rust:1.82-alpine").unwrap();
assert_eq!(spec.language, "rust");
assert_eq!(spec.version, "1.82");
assert_eq!(
spec.env.get("CARGO_HOME"),
Some(&"C:\\toolchains\\cargo".to_string())
);
assert_eq!(
spec.env.get("RUSTUP_HOME"),
Some(&"C:\\toolchains\\rustup".to_string())
);
assert!(spec
.path_dirs
.contains(&"C:\\toolchains\\cargo\\bin".to_string()));
}
#[test]
fn test_detect_alpine_no_toolchain() {
assert!(detect_toolchain("alpine:latest").is_none());
}
#[test]
fn test_detect_swift_omitted_on_windows() {
assert!(detect_toolchain("swift:6.1").is_none());
}
#[test]
fn test_detect_java_omitted_on_windows() {
assert!(detect_toolchain("eclipse-temurin:21-jdk").is_none());
}
#[test]
fn test_detect_unknown_no_toolchain() {
assert!(detect_toolchain("myapp:v2").is_none());
}
#[test]
fn test_detect_docker_hub_qualified() {
let spec = detect_toolchain("docker.io/library/golang:1.23-alpine").unwrap();
assert_eq!(spec.language, "go");
assert_eq!(spec.version, "1.23");
}
#[test]
fn test_extract_version_from_tag_with_suffix() {
assert_eq!(extract_version_from_tag("1.23-alpine"), "1.23");
assert_eq!(extract_version_from_tag("20-slim"), "20");
assert_eq!(extract_version_from_tag("3.12.1-bookworm"), "3.12.1");
}
#[test]
fn test_extract_version_from_tag_latest() {
assert_eq!(extract_version_from_tag("latest"), "latest");
assert_eq!(extract_version_from_tag("alpine"), "latest");
}
#[test]
fn test_host_arch_is_x86_64_on_amd64() {
if cfg!(target_arch = "x86_64") {
assert_eq!(host_arch(), "x86_64");
}
}
#[test]
fn test_extract_python_version_from_asset_name() {
assert_eq!(
extract_python_version_from_asset(
"cpython-3.12.8+20250106-x86_64-pc-windows-msvc-install_only.tar.gz"
),
"3.12.8"
);
assert_eq!(
extract_python_version_from_asset(
"cpython-3.11.11+20250106-x86_64-pc-windows-msvc-install_only.tar.gz"
),
"3.11.11"
);
}
#[test]
fn test_extract_python_version_from_asset_edge_cases() {
assert_eq!(extract_python_version_from_asset("not-a-cpython-asset"), "");
assert_eq!(extract_python_version_from_asset("cpython-"), "");
}
#[tokio::test]
async fn test_resolve_go_exact_url() {
let (version, url) = resolve_go("1.23.6", "x86_64").await.unwrap();
assert_eq!(version, "1.23.6");
assert_eq!(url, "https://go.dev/dl/go1.23.6.windows-amd64.zip");
}
#[tokio::test]
async fn test_resolve_node_exact_url() {
let (version, url) = resolve_node("20.18.1", "x86_64").await.unwrap();
assert_eq!(version, "20.18.1");
assert_eq!(
url,
"https://nodejs.org/dist/v20.18.1/node-v20.18.1-win-x64.zip"
);
}
#[tokio::test]
async fn test_resolve_rust_partial_url() {
let (version, url) = resolve_rust("1.82", "x86_64").await.unwrap();
assert_eq!(version, "1.82.0");
assert_eq!(
url,
"https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe"
);
}
#[tokio::test]
async fn test_resolve_deno_exact_url() {
let (version, url) = resolve_deno("2.1.4", "x86_64").await.unwrap();
assert_eq!(version, "2.1.4");
assert_eq!(
url,
"https://github.com/denoland/deno/releases/download/v2.1.4/deno-x86_64-pc-windows-msvc.zip"
);
}
#[tokio::test]
async fn test_resolve_bun_exact_url() {
let (version, url) = resolve_bun("1.2.3", "x86_64").await.unwrap();
assert_eq!(version, "1.2.3");
assert_eq!(
url,
"https://github.com/oven-sh/bun/releases/download/bun-v1.2.3/bun-windows-x64.zip"
);
}
#[test]
fn test_strip_windows_drive() {
assert_eq!(strip_windows_drive("C:\\toolchains\\go"), "toolchains/go");
assert_eq!(
strip_windows_drive("C:\\toolchains\\python\\Scripts"),
"toolchains/python/Scripts"
);
}
#[test]
fn test_strip_leading_components() {
let p = Path::new("go/bin/go.exe");
assert_eq!(
strip_leading_components(p, 1),
Some(PathBuf::from("bin/go.exe"))
);
assert_eq!(strip_leading_components(p, 0), Some(PathBuf::from(p)));
assert_eq!(strip_leading_components(Path::new("go"), 1), None);
}
}