use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{Result, ToolchainError};
use crate::manifest::{KegManifest, KegSource};
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_keg(
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 => Err(ToolchainError::NotImplemented(format!(
"Windows keg for '{other}' has no portable/relocatable artifact; \
provision it via the HCS choco-capture path in the runtime layer"
))),
}
}
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 => Err(ToolchainError::NotImplemented(format!(
"Windows keg for '{other}' has no portable/relocatable artifact; cannot lock it"
))),
}
}
#[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 keg = cache_dir.join(format!("git-{version}-{arch}"));
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?;
tracing::info!(url = %url, "downloading MinGit for the Windows git keg");
let zip_path = keg.join(".mingit.zip");
let computed_sha =
crate::package_index::download_verified(&url, &zip_path, expected_sha.as_deref()).await?;
let bytes = tokio::fs::read(&zip_path).await?;
let keg_clone = keg.clone();
tokio::task::spawn_blocking(move || extract_zip_to(&bytes, &keg_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| keg.join(sub).display().to_string())
.collect::<Vec<_>>();
let manifest = KegManifest {
tool: "git".to_string(),
version: version.clone(),
arch: arch.to_string(),
platform: "windows".to_string(),
path_dirs,
env: std::collections::HashMap::new(),
source: KegSource::Prebuilt {
url,
sha256: computed_sha,
},
build_deps: Vec::new(),
provisioned_at: chrono::Utc::now().to_rfc3339(),
};
manifest.write_to_keg(&keg).await?;
tokio::fs::write(&ready_marker, b"").await?;
Ok(keg)
}
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 MinGit zip: {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::*;
#[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 non_git_formula_is_not_implemented() {
let tmp = tempfile::tempdir().unwrap();
let err = ensure_windows_keg("cowsay", tmp.path(), None)
.await
.unwrap_err();
assert!(matches!(err, ToolchainError::NotImplemented(_)));
}
#[tokio::test]
async fn extract_zip_roundtrips_nested_layout() {
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();
zw.start_file("cmd/git.exe", opts).unwrap();
zw.write_all(b"MZ-fake-exe").unwrap();
zw.start_file("mingw64/bin/git.exe", opts).unwrap();
zw.write_all(b"MZ-fake-exe-2").unwrap();
zw.finish().unwrap();
}
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"
);
}
}