use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{Result, ToolchainError};
use crate::executor::{ContainerBuildExecutor, ContainerBuildRequest, NetPolicy};
use crate::manifest::{ToolchainManifest, ToolchainSource};
use crate::recipe::{InstallPlan, InstallStep};
use crate::registry::ToolchainArtifactId;
use crate::relocate::RelocationReport;
const WINDOWS_OS_TOKEN: &str = "windows";
#[derive(Debug)]
enum WinBuildKind {
Hermetic(RelocationReport),
NetFallback(RelocationReport),
}
const GIT_FOR_WINDOWS_LATEST: &str =
"https://api.github.com/repos/git-for-windows/git/releases/latest";
const MINGIT_FALLBACK_VERSION: &str = "2.55.0";
const MINGIT_FALLBACK_TAG: &str = "v2.55.0.windows.1";
#[must_use]
pub fn windows_arch_token() -> &'static str {
if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"x86_64"
}
}
fn split_pkg(pkg: &str) -> (&str, &str) {
match pkg.split_once('@') {
Some((_, ver)) if !ver.is_empty() => (pkg, ver),
_ => (pkg, "latest"),
}
}
pub async fn ensure_windows_toolchain(
pkg: &str,
cache_dir: &Path,
lockfile: Option<&crate::ToolchainLockfile>,
) -> Result<PathBuf> {
let (formula, _version) = split_pkg(pkg);
match formula {
"git" => ensure_mingit(cache_dir, lockfile).await,
other => ensure_choco_toolchain(other, cache_dir, lockfile).await,
}
}
pub(crate) async fn resolve_locked_windows(
formula: &str,
) -> Result<(String, String, Option<String>)> {
match formula {
"git" => Ok(resolve_mingit(windows_arch_token()).await),
other => resolve_choco(other).await,
}
}
struct ChocoPkg {
tool: String,
version: String,
nupkg_url: String,
verified_sha256: String,
}
async fn resolve_choco(formula: &str) -> Result<(String, String, Option<String>)> {
let data = crate::package_index::PackageIndexClient::from_env()
.get_choco(formula)
.await?;
let version = data.version.trim();
if version.is_empty() {
return Err(ToolchainError::RegistryError {
message: format!("choco entry for {formula} carries no version"),
});
}
let url = data.url.trim();
if url.is_empty() {
return Err(ToolchainError::RegistryError {
message: format!("choco entry for {formula} carries no .nupkg URL"),
});
}
let sha256 = data
.sha256
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok((version.to_string(), url.to_string(), sha256))
}
async fn ensure_choco_toolchain(
formula: &str,
cache_dir: &Path,
lockfile: Option<&crate::ToolchainLockfile>,
) -> Result<PathBuf> {
let arch = windows_arch_token();
let (version, url, expected_sha) = match lockfile.and_then(|l| {
use crate::ToolchainLockfileExt;
l.lookup(formula, "windows", arch)
}) {
Some(locked) => (
locked.version.clone(),
locked.url.clone(),
Some(locked.sha256.clone()),
),
None => resolve_choco(formula).await?,
};
let toolchain = cache_dir.join(format!("{formula}-{version}-{arch}"));
let ready_marker = toolchain.join(".ready");
if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
return Ok(toolchain);
}
let id = ToolchainArtifactId {
tool: formula.to_string(),
version: version.clone(),
os: WINDOWS_OS_TOKEN.to_string(),
arch: arch.to_string(),
};
if crate::pull_first(&id, &toolchain).await?.is_some() {
return Ok(toolchain);
}
match install_choco_fresh(formula, &version, &url, expected_sha.as_deref(), &toolchain).await {
Ok(WinBuildKind::Hermetic(report)) => {
crate::finish_built(&id, &toolchain, Some(&report), false).await;
Ok(toolchain)
}
Ok(WinBuildKind::NetFallback(_report)) => {
crate::record_net_fallback(&id).await;
Ok(toolchain)
}
Err(e) => {
crate::record_failed(&id, &e).await;
Err(e)
}
}
}
async fn install_choco_fresh(
formula: &str,
version: &str,
url: &str,
expected_sha: Option<&str>,
toolchain: &Path,
) -> Result<WinBuildKind> {
let _ = tokio::fs::remove_dir_all(toolchain).await;
tokio::fs::create_dir_all(toolchain).await?;
tracing::info!(tool = formula, url = %url, "downloading Chocolatey .nupkg");
let nupkg_path = toolchain.join(".pkg.nupkg");
let verified = expected_sha.map(str::trim).filter(|s| !s.is_empty());
let computed = crate::package_index::download_verified(url, &nupkg_path, verified).await?;
let bytes = tokio::fs::read(&nupkg_path).await?;
let _ = tokio::fs::remove_file(&nupkg_path).await;
let recipe = crate::recipe_choco::parse_nupkg(&bytes, toolchain)?;
let pkg = ChocoPkg {
tool: formula.to_string(),
version: version.to_string(),
nupkg_url: url.to_string(),
verified_sha256: if verified.is_some() {
computed
} else {
String::new()
},
};
let executor = crate::executor::container_executor();
install_from_choco_recipe(&pkg, recipe.plan, toolchain, executor.as_deref()).await
}
async fn install_from_choco_recipe(
pkg: &ChocoPkg,
plan: InstallPlan,
toolchain: &Path,
executor: Option<&dyn ContainerBuildExecutor>,
) -> Result<WinBuildKind> {
let scratch = toolchain.join(".build");
tokio::fs::create_dir_all(&scratch).await?;
let net_fallback = if plan_is_leaf_executable(&plan) {
execute_choco_plan_leaf(&pkg.tool, &plan, toolchain).await?;
false
} else {
let Some(executor) = executor else {
return Err(ToolchainError::ExecutorUnavailable {
tool: pkg.tool.clone(),
});
};
run_choco_in_container(pkg, toolchain, executor).await?;
true
};
let _ = tokio::fs::remove_dir_all(&scratch).await;
let path_dirs = probe_windows_path_dirs(toolchain).await;
let report = finalize_windows_toolchain(
toolchain,
&pkg.tool,
&pkg.version,
path_dirs,
pkg.nupkg_url.clone(),
pkg.verified_sha256.clone(),
)
.await?;
Ok(if net_fallback {
WinBuildKind::NetFallback(report)
} else {
WinBuildKind::Hermetic(report)
})
}
fn is_weak_checksum_marker(construct: &str) -> bool {
construct.starts_with("no checksum for ") || construct.starts_with("checksumType ")
}
fn plan_is_leaf_executable(plan: &InstallPlan) -> bool {
plan.steps.iter().all(|step| match step {
InstallStep::StageResource { .. }
| InstallStep::PrefixInstall { .. }
| InstallStep::BinInstall { .. } => true,
InstallStep::Unsupported { construct } => is_weak_checksum_marker(construct),
_ => false,
})
}
async fn execute_choco_plan_leaf(tool: &str, plan: &InstallPlan, toolchain: &Path) -> Result<()> {
let resources_dir = toolchain.join(".build").join("resources");
let mut staged: HashMap<String, PathBuf> = HashMap::new();
for res in &plan.resources {
let file = res
.url
.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("resource.bin");
let dest = resources_dir.join(&res.name).join(file);
if res.sha256.trim().is_empty() {
tracing::warn!(
tool,
resource = %res.name,
url = %res.url,
"choco resource declares no sha256; downloading UNVERIFIED (weak checksum)"
);
crate::package_index::download_verified(&res.url, &dest, None).await?;
} else {
crate::package_index::download_verified(&res.url, &dest, Some(&res.sha256)).await?;
}
staged.insert(res.name.clone(), dest);
}
apply_choco_steps(plan, &staged, toolchain).await
}
async fn apply_choco_steps(
plan: &InstallPlan,
staged: &HashMap<String, PathBuf>,
toolchain: &Path,
) -> Result<()> {
let mut i = 0usize;
while i < plan.steps.len() {
match &plan.steps[i] {
InstallStep::StageResource { name, dir } => {
let src = staged
.get(name)
.ok_or_else(|| ToolchainError::RegistryError {
message: format!("choco plan stages undeclared resource '{name}'"),
})?;
let dest_dir = dir
.as_deref()
.map_or_else(|| toolchain.to_path_buf(), |d| toolchain.join(d));
let paired_extract = matches!(
plan.steps.get(i + 1),
Some(InstallStep::PrefixInstall { from, to: None }) if from == "."
);
if paired_extract {
extract_resource_zip(src, &dest_dir).await?;
i += 1; } else {
stage_web_file(name, src, &dest_dir).await?;
}
}
InstallStep::BinInstall { from, to } => {
apply_bin_install(toolchain, from, to.as_deref()).await?;
}
InstallStep::Unsupported { construct } if is_weak_checksum_marker(construct) => {
}
other => {
return Err(ToolchainError::RegistryError {
message: format!("choco plan step is not leaf-executable: {other:?}"),
});
}
}
i += 1;
}
Ok(())
}
async fn extract_resource_zip(src: &Path, dest_dir: &Path) -> Result<()> {
let bytes = tokio::fs::read(src).await?;
let dest = dest_dir.to_path_buf();
tokio::task::spawn_blocking(move || extract_zip_to(&bytes, &dest))
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("choco resource extraction task panicked: {e}"),
})?
}
async fn stage_web_file(name: &str, src: &Path, dest_dir: &Path) -> Result<()> {
let file_name = src
.extension()
.and_then(|e| e.to_str())
.map_or_else(|| name.to_string(), |ext| format!("{name}.{ext}"));
tokio::fs::create_dir_all(dest_dir).await?;
tokio::fs::copy(src, dest_dir.join(file_name)).await?;
Ok(())
}
async fn apply_bin_install(toolchain: &Path, from: &str, to: Option<&str>) -> Result<()> {
let src = toolchain.join(from);
let src_name = src
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(from)
.to_string();
let mut dest_name = to.map_or_else(|| src_name.clone(), str::to_string);
if !dest_name.contains('.') {
if let Some(ext) = Path::new(&src_name).extension().and_then(|e| e.to_str()) {
dest_name.push('.');
dest_name.push_str(ext);
}
}
let bin = toolchain.join("bin");
tokio::fs::create_dir_all(&bin).await?;
tokio::fs::copy(&src, bin.join(&dest_name))
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("Install-BinFile source '{from}' could not be staged: {e}"),
})?;
Ok(())
}
async fn run_choco_in_container(
pkg: &ChocoPkg,
toolchain: &Path,
executor: &dyn ContainerBuildExecutor,
) -> Result<()> {
let req = choco_container_request(&pkg.tool, &pkg.version, toolchain);
tracing::warn!(
tool = %pkg.tool,
version = %pkg.version,
"NET-FALLBACK: choco plan is not leaf-executable (exe/msi installer); \
running `choco install` in a network-enabled Windows container"
);
let report = executor.execute(&req).await?;
tracing::warn!(
tool = %pkg.tool,
log_tail = %report.log_tail,
"NET-FALLBACK: containerized choco install completed"
);
Ok(())
}
fn choco_container_request(tool: &str, version: &str, toolchain: &Path) -> ContainerBuildRequest {
let scratch = toolchain.join(".build");
let argv = [
"choco",
"install",
tool,
"-y",
"--no-progress",
"--version",
version,
]
.iter()
.map(ToString::to_string)
.collect();
ContainerBuildRequest {
tool: tool.to_string(),
platform: crate::ToolPlatform::Windows,
plan: InstallPlan {
steps: vec![InstallStep::System { argv }],
resources: Vec::new(),
patches: Vec::new(),
env: HashMap::new(),
deparallelize: false,
},
src_dir: scratch.clone(),
prefix: toolchain.to_path_buf(),
scratch_dir: scratch,
dep_toolchains: Vec::new(),
resources_dir: None,
env: HashMap::new(),
path_prefix: Vec::new(),
net: NetPolicy::AllowLoud,
}
}
async fn probe_windows_path_dirs(toolchain: &Path) -> Vec<String> {
let mut dirs = Vec::new();
for candidate in [
toolchain.to_path_buf(),
toolchain.join("bin"),
toolchain.join("tools"),
] {
if dir_has_windows_executable(&candidate).await {
dirs.push(candidate.display().to_string());
}
}
dirs
}
async fn dir_has_windows_executable(dir: &Path) -> bool {
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return false;
};
while let Ok(Some(entry)) = entries.next_entry().await {
if let Some(name) = entry.file_name().to_str() {
let is_executable = Path::new(name)
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| {
["exe", "bat", "cmd"]
.iter()
.any(|want| ext.eq_ignore_ascii_case(want))
});
if is_executable {
return true;
}
}
}
false
}
async fn finalize_windows_toolchain(
toolchain: &Path,
tool: &str,
version: &str,
path_dirs: Vec<String>,
url: String,
sha256: String,
) -> Result<RelocationReport> {
let report = crate::relocate::make_relocatable(toolchain, toolchain, &[]).await?;
let manifest = ToolchainManifest {
tool: tool.to_string(),
version: version.to_string(),
arch: windows_arch_token().to_string(),
platform: "windows".to_string(),
path_dirs,
env: HashMap::new(),
source: ToolchainSource::Prebuilt { url, sha256 },
build_deps: Vec::new(),
provisioned_at: chrono::Utc::now().to_rfc3339(),
};
manifest.write_to_toolchain(toolchain).await?;
tokio::fs::write(toolchain.join(".ready"), b"").await?;
Ok(report)
}
#[derive(Debug, Clone, Deserialize)]
struct GhAsset {
name: String,
#[serde(default)]
browser_download_url: String,
}
#[derive(Debug, Clone, Deserialize)]
struct GhRelease {
#[serde(default)]
tag_name: String,
#[serde(default)]
assets: Vec<GhAsset>,
}
fn mingit_version_from_tag(tag: &str) -> String {
tag.trim_start_matches('v')
.split(".windows")
.next()
.unwrap_or(tag)
.to_string()
}
fn mingit_asset_name(version: &str, arch: &str) -> String {
let suffix = if arch == "arm64" { "arm64" } else { "64-bit" };
format!("MinGit-{version}-{suffix}.zip")
}
fn pick_mingit_asset<'a>(assets: &'a [GhAsset], version: &str, arch: &str) -> Option<&'a GhAsset> {
let want = mingit_asset_name(version, arch);
assets
.iter()
.find(|a| a.name == want && !a.browser_download_url.is_empty())
}
fn mingit_download_url(tag: &str, version: &str, arch: &str) -> String {
format!(
"https://github.com/git-for-windows/git/releases/download/{tag}/{}",
mingit_asset_name(version, arch)
)
}
async fn resolve_mingit(arch: &str) -> (String, String, Option<String>) {
match fetch_latest_release().await {
Ok(rel) => {
let version = mingit_version_from_tag(&rel.tag_name);
if let Some(asset) = pick_mingit_asset(&rel.assets, &version, arch) {
let sha = mingit_sibling_sha256(&rel.assets, &asset.name).await;
return (version, asset.browser_download_url.clone(), sha);
}
let url = mingit_download_url(&rel.tag_name, &version, arch);
(version, url, None)
}
Err(_) => (
MINGIT_FALLBACK_VERSION.to_string(),
mingit_download_url(MINGIT_FALLBACK_TAG, MINGIT_FALLBACK_VERSION, arch),
None,
),
}
}
async fn mingit_sibling_sha256(assets: &[GhAsset], asset_name: &str) -> Option<String> {
let want = format!("{asset_name}.sha256");
let asset = assets
.iter()
.find(|a| a.name == want && !a.browser_download_url.is_empty())?;
let text = reqwest::get(&asset.browser_download_url)
.await
.ok()?
.text()
.await
.ok()?;
let token = text.split_whitespace().next()?;
(token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()))
.then(|| token.to_ascii_lowercase())
}
async fn fetch_latest_release() -> Result<GhRelease> {
let client = reqwest::Client::builder()
.user_agent("zlayer-toolchain")
.build()
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed to build HTTP client: {e}"),
})?;
let text = client
.get(GIT_FOR_WINDOWS_LATEST)
.send()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed to query git-for-windows releases: {e}"),
})?
.text()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed to read git-for-windows release body: {e}"),
})?;
serde_json::from_str(&text).map_err(|e| ToolchainError::RegistryError {
message: format!("failed to parse git-for-windows release JSON: {e}"),
})
}
pub async fn ensure_mingit(
cache_dir: &Path,
lockfile: Option<&crate::ToolchainLockfile>,
) -> Result<PathBuf> {
let arch = windows_arch_token();
let (version, url, expected_sha) = match lockfile.and_then(|l| {
use crate::ToolchainLockfileExt;
l.lookup("git", "windows", arch)
}) {
Some(locked) => (
locked.version.clone(),
locked.url.clone(),
Some(locked.sha256.clone()),
),
None => resolve_mingit(arch).await,
};
let toolchain = cache_dir.join(format!("git-{version}-{arch}"));
let ready_marker = toolchain.join(".ready");
if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
return Ok(toolchain);
}
let id = ToolchainArtifactId {
tool: "git".to_string(),
version: version.clone(),
os: WINDOWS_OS_TOKEN.to_string(),
arch: arch.to_string(),
};
if crate::pull_first(&id, &toolchain).await?.is_some() {
return Ok(toolchain);
}
match install_mingit_fresh(&toolchain, &version, &url, expected_sha.as_deref()).await {
Ok(report) => {
crate::finish_built(&id, &toolchain, Some(&report), false).await;
Ok(toolchain)
}
Err(e) => {
crate::record_failed(&id, &e).await;
Err(e)
}
}
}
async fn install_mingit_fresh(
toolchain: &Path,
version: &str,
url: &str,
expected_sha: Option<&str>,
) -> Result<RelocationReport> {
let _ = tokio::fs::remove_dir_all(toolchain).await;
tokio::fs::create_dir_all(toolchain).await?;
tracing::info!(url = %url, "downloading MinGit for the Windows git toolchain");
let zip_path = toolchain.join(".mingit.zip");
let computed_sha =
crate::package_index::download_verified(url, &zip_path, expected_sha).await?;
let bytes = tokio::fs::read(&zip_path).await?;
let toolchain_clone = toolchain.to_path_buf();
tokio::task::spawn_blocking(move || extract_zip_to(&bytes, &toolchain_clone))
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("MinGit extraction task panicked: {e}"),
})??;
let _ = tokio::fs::remove_file(&zip_path).await;
let path_dirs = ["cmd", "mingw64\\bin", "usr\\bin"]
.iter()
.map(|sub| toolchain.join(sub).display().to_string())
.collect::<Vec<_>>();
finalize_windows_toolchain(
toolchain,
"git",
version,
path_dirs,
url.to_string(),
computed_sha,
)
.await
}
fn extract_zip_to(bytes: &[u8], dest: &Path) -> Result<()> {
let reader = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(reader).map_err(|e| ToolchainError::RegistryError {
message: format!("failed to open zip archive: {e}"),
})?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed to read zip entry {i}: {e}"),
})?;
let Some(rel) = file.enclosed_name() else {
continue; };
let out_path = dest.join(&rel);
if file.is_dir() {
std::fs::create_dir_all(&out_path)?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out = std::fs::File::create(&out_path)?;
std::io::copy(&mut file, &mut out)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::executor::ContainerBuildReport;
use crate::{LockedTool, ToolchainLockfile, ToolchainLockfileExt};
fn build_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
use std::io::Write;
let mut buf = Vec::new();
{
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = zip::write::SimpleFileOptions::default();
for (name, content) in files {
zw.start_file(*name, opts).unwrap();
zw.write_all(content).unwrap();
}
zw.finish().unwrap();
}
buf
}
fn nuspec_xml(id: &str, version: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd">
<metadata>
<id>{id}</id>
<version>{version}</version>
<authors>test</authors>
</metadata>
</package>"#
)
}
fn build_nupkg(id: &str, version: &str, ps1: &str) -> Vec<u8> {
let nuspec = nuspec_xml(id, version);
build_zip(&[
(&format!("{id}.nuspec"), nuspec.as_bytes()),
("tools/chocolateyInstall.ps1", ps1.as_bytes()),
])
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(bytes))
}
async fn serve_bytes(bytes: Vec<u8>, name: &str) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let body = bytes.clone();
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = [0u8; 2048];
let _ = sock.read(&mut buf).await; let head = format!(
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.write_all(&body).await;
let _ = sock.shutdown().await;
});
}
});
format!("http://{addr}/{name}")
}
fn pkg(tool: &str, version: &str) -> ChocoPkg {
ChocoPkg {
tool: tool.to_string(),
version: version.to_string(),
nupkg_url: format!("https://example/{tool}.{version}.nupkg"),
verified_sha256: String::new(),
}
}
struct CapturingExecutor {
seen: std::sync::Mutex<Option<ContainerBuildRequest>>,
}
impl ContainerBuildExecutor for CapturingExecutor {
fn execute<'a>(
&'a self,
req: &'a ContainerBuildRequest,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<ContainerBuildReport>> + Send + 'a>,
> {
Box::pin(async move {
*self.seen.lock().unwrap() = Some(req.clone());
Ok(ContainerBuildReport {
log_tail: "choco install ok".to_string(),
})
})
}
}
#[test]
fn version_parsed_from_tag() {
assert_eq!(mingit_version_from_tag("v2.55.0.windows.1"), "2.55.0");
assert_eq!(mingit_version_from_tag("v2.43.2.windows.2"), "2.43.2");
assert_eq!(mingit_version_from_tag("2.40.0"), "2.40.0");
}
#[test]
fn asset_name_per_arch() {
assert_eq!(
mingit_asset_name("2.55.0", "x86_64"),
"MinGit-2.55.0-64-bit.zip"
);
assert_eq!(
mingit_asset_name("2.55.0", "arm64"),
"MinGit-2.55.0-arm64.zip"
);
}
#[test]
fn download_url_is_canonical() {
let url = mingit_download_url("v2.55.0.windows.1", "2.55.0", "x86_64");
assert_eq!(
url,
"https://github.com/git-for-windows/git/releases/download/\
v2.55.0.windows.1/MinGit-2.55.0-64-bit.zip"
);
}
#[test]
fn picks_plain_mingit_not_busybox_or_32bit() {
let assets = vec![
GhAsset {
name: "MinGit-2.55.0-32-bit.zip".to_string(),
browser_download_url: "https://x/32".to_string(),
},
GhAsset {
name: "MinGit-2.55.0-busybox-64-bit.zip".to_string(),
browser_download_url: "https://x/bb".to_string(),
},
GhAsset {
name: "MinGit-2.55.0-64-bit.zip".to_string(),
browser_download_url: "https://x/64".to_string(),
},
GhAsset {
name: "MinGit-2.55.0-arm64.zip".to_string(),
browser_download_url: "https://x/arm".to_string(),
},
];
assert_eq!(
pick_mingit_asset(&assets, "2.55.0", "x86_64")
.unwrap()
.browser_download_url,
"https://x/64"
);
assert_eq!(
pick_mingit_asset(&assets, "2.55.0", "arm64")
.unwrap()
.browser_download_url,
"https://x/arm"
);
}
#[test]
fn pick_returns_none_when_asset_missing() {
let assets = vec![GhAsset {
name: "MinGit-2.55.0-32-bit.zip".to_string(),
browser_download_url: "https://x/32".to_string(),
}];
assert!(pick_mingit_asset(&assets, "2.55.0", "x86_64").is_none());
}
#[test]
fn release_json_parses() {
let json = r#"{
"tag_name": "v2.55.0.windows.1",
"assets": [
{"name": "MinGit-2.55.0-64-bit.zip", "browser_download_url": "https://x/64"}
]
}"#;
let rel: GhRelease = serde_json::from_str(json).unwrap();
assert_eq!(mingit_version_from_tag(&rel.tag_name), "2.55.0");
assert_eq!(rel.assets.len(), 1);
}
#[tokio::test]
async fn extract_zip_roundtrips_nested_layout() {
let buf = build_zip(&[
("cmd/git.exe", b"MZ-fake-exe"),
("mingw64/bin/git.exe", b"MZ-fake-exe-2"),
]);
let tmp = tempfile::tempdir().unwrap();
extract_zip_to(&buf, tmp.path()).unwrap();
assert!(tmp.path().join("cmd/git.exe").is_file());
assert!(tmp.path().join("mingw64/bin/git.exe").is_file());
assert_eq!(
std::fs::read(tmp.path().join("cmd/git.exe")).unwrap(),
b"MZ-fake-exe"
);
}
#[tokio::test]
async fn supported_choco_zip_plan_installs_leaf_side() {
let payload = build_zip(&[("sample.exe", b"MZ-sample")]);
let sha = sha256_hex(&payload);
let url = serve_bytes(payload, "sample-1.0.0.zip").await;
let ps1 = format!(
"$toolsDir = \"$(Split-Path -Parent $MyInvocation.MyCommand.Definition)\"\n\
Install-ChocolateyZipPackage -PackageName 'sample' -Url64bit '{url}' \
-Checksum64 '{sha}' -ChecksumType64 'sha256' -UnzipLocation $toolsDir\n"
);
let tmp = tempfile::tempdir().unwrap();
let toolchain = tmp
.path()
.join(format!("sample-1.0.0-{}", windows_arch_token()));
tokio::fs::create_dir_all(&toolchain).await.unwrap();
let nupkg = build_nupkg("sample", "1.0.0", &ps1);
let recipe = crate::recipe_choco::parse_nupkg(&nupkg, &toolchain).unwrap();
assert!(recipe.plan.is_fully_supported());
assert!(plan_is_leaf_executable(&recipe.plan));
let pkg = pkg("sample", "1.0.0");
install_from_choco_recipe(&pkg, recipe.plan, &toolchain, None)
.await
.expect("leaf-executable plan must install without an executor");
assert_eq!(
tokio::fs::read(toolchain.join("sample.exe")).await.unwrap(),
b"MZ-sample"
);
assert!(toolchain.join(".ready").is_file(), ".ready stamped last");
assert!(!toolchain.join(".build").exists(), "scratch cleaned");
let manifest = ToolchainManifest::read_from_toolchain(&toolchain)
.await
.unwrap()
.expect("manifest written before .ready");
assert_eq!(manifest.tool, "sample");
assert_eq!(manifest.version, "1.0.0");
assert_eq!(manifest.arch, windows_arch_token());
assert_eq!(manifest.platform, "windows");
assert_eq!(
manifest.path_dirs,
vec![toolchain.display().to_string()],
"the prefix root holds sample.exe, so it is the probed PATH dir"
);
assert_eq!(
manifest.source,
ToolchainSource::Prebuilt {
url: pkg.nupkg_url.clone(),
sha256: String::new(),
},
"unverified nupkg records an EMPTY sha (payload was ps1-verified)"
);
}
#[tokio::test]
async fn weak_checksum_choco_plan_warns_but_installs() {
let payload = build_zip(&[("tool.exe", b"MZ-weak")]);
let url = serve_bytes(payload, "weak-1.0.0.zip").await;
let ps1 = format!(
"Install-ChocolateyZipPackage -PackageName 'weak' -Url '{url}' \
-Checksum 'd41d8cd98f00b204e9800998ecf8427e' -ChecksumType 'md5' \
-UnzipLocation \"$(Split-Path -Parent $MyInvocation.MyCommand.Definition)\"\n"
);
let tmp = tempfile::tempdir().unwrap();
let toolchain = tmp
.path()
.join(format!("weak-1.0.0-{}", windows_arch_token()));
tokio::fs::create_dir_all(&toolchain).await.unwrap();
let nupkg = build_nupkg("weak", "1.0.0", &ps1);
let recipe = crate::recipe_choco::parse_nupkg(&nupkg, &toolchain).unwrap();
assert!(
!recipe.plan.is_fully_supported(),
"weak checksum surfaces a marker"
);
assert!(
plan_is_leaf_executable(&recipe.plan),
"weak-checksum markers do NOT route to the container"
);
assert!(recipe.plan.resources[0].sha256.is_empty());
install_from_choco_recipe(&pkg("weak", "1.0.0"), recipe.plan, &toolchain, None)
.await
.expect("weak-checksum plan proceeds unverified");
assert_eq!(
tokio::fs::read(toolchain.join("tool.exe")).await.unwrap(),
b"MZ-weak"
);
assert!(toolchain.join(".ready").is_file());
}
#[tokio::test]
async fn web_file_and_bin_install_stage_into_prefix() {
let body = b"MZ-jq-fake".to_vec();
let sha = sha256_hex(&body);
let url = serve_bytes(body, "jq-windows-amd64.exe").await;
let tmp = tempfile::tempdir().unwrap();
let toolchain = tmp
.path()
.join(format!("jq-1.8.1-{}", windows_arch_token()));
tokio::fs::create_dir_all(&toolchain).await.unwrap();
let prefix = toolchain.display().to_string();
let ps1 = format!(
"Get-ChocolateyWebFile -PackageName 'jq' -FileFullPath '{prefix}\\jq.exe' \
-Url64bit '{url}' -Checksum64 '{sha}' -ChecksumType64 'sha256'\n\
Install-BinFile -Name 'jq' -Path '{prefix}/jq.exe'\n"
);
let nupkg = build_nupkg("jq", "1.8.1", &ps1);
let recipe = crate::recipe_choco::parse_nupkg(&nupkg, &toolchain).unwrap();
assert!(plan_is_leaf_executable(&recipe.plan));
install_from_choco_recipe(&pkg("jq", "1.8.1"), recipe.plan, &toolchain, None)
.await
.unwrap();
assert_eq!(
tokio::fs::read(toolchain.join("jq.exe")).await.unwrap(),
b"MZ-jq-fake",
"web file staged as <package>.exe in the prefix root"
);
assert_eq!(
tokio::fs::read(toolchain.join("bin/jq.exe")).await.unwrap(),
b"MZ-jq-fake",
"Install-BinFile copies into <prefix>/bin carrying the extension"
);
let manifest = ToolchainManifest::read_from_toolchain(&toolchain)
.await
.unwrap()
.unwrap();
assert_eq!(
manifest.path_dirs,
vec![
toolchain.display().to_string(),
toolchain.join("bin").display().to_string(),
],
"both the prefix root and bin/ carry executables"
);
}
#[tokio::test]
async fn exe_installer_without_executor_is_executor_unavailable() {
let ps1 = "$packageArgs = @{\n\
packageName = 'sample'\n\
fileType = 'exe'\n\
url = 'https://example.com/sample-setup.exe'\n\
silentArgs = '/S'\n\
}\n\
Install-ChocolateyPackage @packageArgs\n";
let tmp = tempfile::tempdir().unwrap();
let toolchain = tmp
.path()
.join(format!("sample-1.0.0-{}", windows_arch_token()));
tokio::fs::create_dir_all(&toolchain).await.unwrap();
let nupkg = build_nupkg("sample", "1.0.0", ps1);
let recipe = crate::recipe_choco::parse_nupkg(&nupkg, &toolchain).unwrap();
assert!(!plan_is_leaf_executable(&recipe.plan));
let err = install_from_choco_recipe(&pkg("sample", "1.0.0"), recipe.plan, &toolchain, None)
.await
.unwrap_err();
assert!(
matches!(err, ToolchainError::ExecutorUnavailable { ref tool } if tool == "sample"),
"{err}"
);
assert!(
!toolchain.join(".ready").exists(),
"no .ready without an install"
);
}
#[tokio::test]
async fn exe_installer_with_executor_runs_containerized_choco() {
let ps1 = "Install-ChocolateyPackage -PackageName 'sample' -FileType 'exe' \
-Url 'https://example.com/sample-setup.exe' -SilentArgs '/S'\n";
let tmp = tempfile::tempdir().unwrap();
let toolchain = tmp
.path()
.join(format!("sample-2.5.0-{}", windows_arch_token()));
tokio::fs::create_dir_all(&toolchain).await.unwrap();
let nupkg = build_nupkg("sample", "2.5.0", ps1);
let recipe = crate::recipe_choco::parse_nupkg(&nupkg, &toolchain).unwrap();
let exec = CapturingExecutor {
seen: std::sync::Mutex::new(None),
};
let pkg = pkg("sample", "2.5.0");
install_from_choco_recipe(&pkg, recipe.plan, &toolchain, Some(&exec))
.await
.expect("mock containerized choco install should succeed");
let req = exec
.seen
.lock()
.unwrap()
.take()
.expect("executor must have been invoked");
assert_eq!(req.tool, "sample");
assert_eq!(req.platform, crate::ToolPlatform::Windows);
assert_eq!(
req.net,
NetPolicy::AllowLoud,
"choco fetches its own installer — network allowed LOUDLY"
);
assert_eq!(req.prefix, toolchain);
assert_eq!(req.scratch_dir, toolchain.join(".build"));
assert_eq!(
req.plan.steps,
vec![InstallStep::System {
argv: [
"choco",
"install",
"sample",
"-y",
"--no-progress",
"--version",
"2.5.0",
]
.iter()
.map(ToString::to_string)
.collect(),
}]
);
let manifest = ToolchainManifest::read_from_toolchain(&toolchain)
.await
.unwrap()
.expect("manifest written after the container run");
assert_eq!(manifest.tool, "sample");
assert_eq!(manifest.version, "2.5.0");
assert_eq!(
manifest.source,
ToolchainSource::Prebuilt {
url: pkg.nupkg_url.clone(),
sha256: String::new(),
},
"container path records the choco pkg identity URL, empty sha"
);
assert!(toolchain.join(".ready").is_file());
}
#[tokio::test]
async fn locked_choco_pin_round_trips_through_ensure() {
let payload = build_zip(&[("pinned.exe", b"MZ-pinned")]);
let payload_sha = sha256_hex(&payload);
let res_url = serve_bytes(payload, "pinned-2.0.0.zip").await;
let ps1 = format!(
"Install-ChocolateyZipPackage -PackageName 'pinned' -Url64bit '{res_url}' \
-Checksum64 '{payload_sha}' -ChecksumType64 'sha256' \
-UnzipLocation \"$(Split-Path -Parent $MyInvocation.MyCommand.Definition)\"\n"
);
let nupkg = build_nupkg("pinned", "2.0.0", &ps1);
let nupkg_sha = sha256_hex(&nupkg);
let nupkg_url = serve_bytes(nupkg, "pinned.2.0.0.nupkg").await;
let mut lock = ToolchainLockfile::new();
lock.upsert(LockedTool {
tool: "pinned".to_string(),
platform: "windows".to_string(),
arch: windows_arch_token().to_string(),
version: "2.0.0".to_string(),
url: nupkg_url.clone(),
sha256: nupkg_sha.clone(),
resolved_at: "2026-07-06T00:00:00Z".to_string(),
});
let tmp = tempfile::tempdir().unwrap();
let toolchain = ensure_windows_toolchain("pinned", tmp.path(), Some(&lock))
.await
.expect("lock-pinned choco toolchain should provision offline");
assert_eq!(
toolchain,
tmp.path()
.join(format!("pinned-2.0.0-{}", windows_arch_token())),
"cache key uses the LOCKED version"
);
assert_eq!(
tokio::fs::read(toolchain.join("pinned.exe")).await.unwrap(),
b"MZ-pinned"
);
let manifest = ToolchainManifest::read_from_toolchain(&toolchain)
.await
.unwrap()
.unwrap();
assert_eq!(
manifest.source,
ToolchainSource::Prebuilt {
url: nupkg_url,
sha256: nupkg_sha,
},
"lock-verified nupkg records the verified digest"
);
let again = ensure_windows_toolchain("pinned", tmp.path(), Some(&lock))
.await
.unwrap();
assert_eq!(again, toolchain);
}
#[tokio::test]
async fn locked_choco_pin_rejects_digest_mismatch() {
let nupkg = build_nupkg("evil", "1.0.0", "Write-Host hi\n");
let nupkg_url = serve_bytes(nupkg, "evil.1.0.0.nupkg").await;
let mut lock = ToolchainLockfile::new();
lock.upsert(LockedTool {
tool: "evil".to_string(),
platform: "windows".to_string(),
arch: windows_arch_token().to_string(),
version: "1.0.0".to_string(),
url: nupkg_url,
sha256: "a".repeat(64),
resolved_at: "2026-07-06T00:00:00Z".to_string(),
});
let tmp = tempfile::tempdir().unwrap();
let err = ensure_windows_toolchain("evil", tmp.path(), Some(&lock))
.await
.unwrap_err();
assert!(
matches!(err, ToolchainError::DigestMismatch { .. }),
"{err}"
);
}
}