1use std::path::{Path, PathBuf};
32
33use serde::Deserialize;
34
35use crate::error::{Result, ToolchainError};
36use crate::manifest::{KegManifest, KegSource};
37
38const GIT_FOR_WINDOWS_LATEST: &str =
40 "https://api.github.com/repos/git-for-windows/git/releases/latest";
41
42const MINGIT_FALLBACK_VERSION: &str = "2.55.0";
47const MINGIT_FALLBACK_TAG: &str = "v2.55.0.windows.1";
49
50#[must_use]
53pub fn windows_arch_token() -> &'static str {
54 if cfg!(target_arch = "aarch64") {
55 "arm64"
56 } else {
57 "x86_64"
58 }
59}
60
61fn split_pkg(pkg: &str) -> (&str, &str) {
64 match pkg.split_once('@') {
65 Some((_, ver)) if !ver.is_empty() => (pkg, ver),
66 _ => (pkg, "latest"),
67 }
68}
69
70pub async fn ensure_windows_keg(
82 pkg: &str,
83 cache_dir: &Path,
84 lockfile: Option<&crate::ToolchainLockfile>,
85) -> Result<PathBuf> {
86 let (formula, _version) = split_pkg(pkg);
87 match formula {
88 "git" => ensure_mingit(cache_dir, lockfile).await,
89 other => Err(ToolchainError::NotImplemented(format!(
90 "Windows keg for '{other}' has no portable/relocatable artifact; \
91 provision it via the HCS choco-capture path in the runtime layer"
92 ))),
93 }
94}
95
96pub(crate) async fn resolve_locked_windows(
104 formula: &str,
105) -> Result<(String, String, Option<String>)> {
106 match formula {
107 "git" => Ok(resolve_mingit(windows_arch_token()).await),
108 other => Err(ToolchainError::NotImplemented(format!(
109 "Windows keg for '{other}' has no portable/relocatable artifact; cannot lock it"
110 ))),
111 }
112}
113
114#[derive(Debug, Clone, Deserialize)]
116struct GhAsset {
117 name: String,
118 #[serde(default)]
119 browser_download_url: String,
120}
121
122#[derive(Debug, Clone, Deserialize)]
124struct GhRelease {
125 #[serde(default)]
126 tag_name: String,
127 #[serde(default)]
128 assets: Vec<GhAsset>,
129}
130
131fn mingit_version_from_tag(tag: &str) -> String {
133 tag.trim_start_matches('v')
134 .split(".windows")
135 .next()
136 .unwrap_or(tag)
137 .to_string()
138}
139
140fn mingit_asset_name(version: &str, arch: &str) -> String {
146 let suffix = if arch == "arm64" { "arm64" } else { "64-bit" };
147 format!("MinGit-{version}-{suffix}.zip")
148}
149
150fn pick_mingit_asset<'a>(assets: &'a [GhAsset], version: &str, arch: &str) -> Option<&'a GhAsset> {
153 let want = mingit_asset_name(version, arch);
154 assets
155 .iter()
156 .find(|a| a.name == want && !a.browser_download_url.is_empty())
157}
158
159fn mingit_download_url(tag: &str, version: &str, arch: &str) -> String {
162 format!(
163 "https://github.com/git-for-windows/git/releases/download/{tag}/{}",
164 mingit_asset_name(version, arch)
165 )
166}
167
168async fn resolve_mingit(arch: &str) -> (String, String, Option<String>) {
172 match fetch_latest_release().await {
173 Ok(rel) => {
174 let version = mingit_version_from_tag(&rel.tag_name);
175 if let Some(asset) = pick_mingit_asset(&rel.assets, &version, arch) {
176 let sha = mingit_sibling_sha256(&rel.assets, &asset.name).await;
178 return (version, asset.browser_download_url.clone(), sha);
179 }
180 let url = mingit_download_url(&rel.tag_name, &version, arch);
183 (version, url, None)
184 }
185 Err(_) => (
186 MINGIT_FALLBACK_VERSION.to_string(),
187 mingit_download_url(MINGIT_FALLBACK_TAG, MINGIT_FALLBACK_VERSION, arch),
188 None,
189 ),
190 }
191}
192
193async fn mingit_sibling_sha256(assets: &[GhAsset], asset_name: &str) -> Option<String> {
196 let want = format!("{asset_name}.sha256");
197 let asset = assets
198 .iter()
199 .find(|a| a.name == want && !a.browser_download_url.is_empty())?;
200 let text = reqwest::get(&asset.browser_download_url)
201 .await
202 .ok()?
203 .text()
204 .await
205 .ok()?;
206 let token = text.split_whitespace().next()?;
207 (token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()))
208 .then(|| token.to_ascii_lowercase())
209}
210
211async fn fetch_latest_release() -> Result<GhRelease> {
214 let client = reqwest::Client::builder()
215 .user_agent("zlayer-toolchain")
216 .build()
217 .map_err(|e| ToolchainError::RegistryError {
218 message: format!("failed to build HTTP client: {e}"),
219 })?;
220 let text = client
221 .get(GIT_FOR_WINDOWS_LATEST)
222 .send()
223 .await
224 .map_err(|e| ToolchainError::RegistryError {
225 message: format!("failed to query git-for-windows releases: {e}"),
226 })?
227 .text()
228 .await
229 .map_err(|e| ToolchainError::RegistryError {
230 message: format!("failed to read git-for-windows release body: {e}"),
231 })?;
232 serde_json::from_str(&text).map_err(|e| ToolchainError::RegistryError {
233 message: format!("failed to parse git-for-windows release JSON: {e}"),
234 })
235}
236
237pub async fn ensure_mingit(
247 cache_dir: &Path,
248 lockfile: Option<&crate::ToolchainLockfile>,
249) -> Result<PathBuf> {
250 let arch = windows_arch_token();
251
252 let (version, url, expected_sha) = match lockfile.and_then(|l| {
254 use crate::ToolchainLockfileExt;
255 l.lookup("git", "windows", arch)
256 }) {
257 Some(locked) => (
258 locked.version.clone(),
259 locked.url.clone(),
260 Some(locked.sha256.clone()),
261 ),
262 None => resolve_mingit(arch).await,
263 };
264
265 let keg = cache_dir.join(format!("git-{version}-{arch}"));
266 let ready_marker = keg.join(".ready");
267 if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
268 return Ok(keg);
269 }
270
271 let _ = tokio::fs::remove_dir_all(&keg).await;
273 tokio::fs::create_dir_all(&keg).await?;
274
275 tracing::info!(url = %url, "downloading MinGit for the Windows git keg");
276 let zip_path = keg.join(".mingit.zip");
279 let computed_sha =
280 crate::package_index::download_verified(&url, &zip_path, expected_sha.as_deref()).await?;
281 let bytes = tokio::fs::read(&zip_path).await?;
282
283 let keg_clone = keg.clone();
284 tokio::task::spawn_blocking(move || extract_zip_to(&bytes, &keg_clone))
285 .await
286 .map_err(|e| ToolchainError::RegistryError {
287 message: format!("MinGit extraction task panicked: {e}"),
288 })??;
289 let _ = tokio::fs::remove_file(&zip_path).await;
290
291 let path_dirs = ["cmd", "mingw64\\bin", "usr\\bin"]
295 .iter()
296 .map(|sub| keg.join(sub).display().to_string())
297 .collect::<Vec<_>>();
298
299 let manifest = KegManifest {
300 tool: "git".to_string(),
301 version: version.clone(),
302 arch: arch.to_string(),
303 platform: "windows".to_string(),
304 path_dirs,
305 env: std::collections::HashMap::new(),
306 source: KegSource::Prebuilt {
307 url,
308 sha256: computed_sha,
309 },
310 build_deps: Vec::new(),
311 provisioned_at: chrono::Utc::now().to_rfc3339(),
312 };
313 manifest.write_to_keg(&keg).await?;
314 tokio::fs::write(&ready_marker, b"").await?;
315 Ok(keg)
316}
317
318fn extract_zip_to(bytes: &[u8], dest: &Path) -> Result<()> {
322 let reader = std::io::Cursor::new(bytes);
323 let mut archive = zip::ZipArchive::new(reader).map_err(|e| ToolchainError::RegistryError {
324 message: format!("failed to open MinGit zip: {e}"),
325 })?;
326 for i in 0..archive.len() {
327 let mut file = archive
328 .by_index(i)
329 .map_err(|e| ToolchainError::RegistryError {
330 message: format!("failed to read zip entry {i}: {e}"),
331 })?;
332 let Some(rel) = file.enclosed_name() else {
336 continue; };
338 let out_path = dest.join(&rel);
339 if file.is_dir() {
340 std::fs::create_dir_all(&out_path)?;
341 continue;
342 }
343 if let Some(parent) = out_path.parent() {
344 std::fs::create_dir_all(parent)?;
345 }
346 let mut out = std::fs::File::create(&out_path)?;
347 std::io::copy(&mut file, &mut out)?;
348 }
349 Ok(())
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn version_parsed_from_tag() {
358 assert_eq!(mingit_version_from_tag("v2.55.0.windows.1"), "2.55.0");
359 assert_eq!(mingit_version_from_tag("v2.43.2.windows.2"), "2.43.2");
360 assert_eq!(mingit_version_from_tag("2.40.0"), "2.40.0");
361 }
362
363 #[test]
364 fn asset_name_per_arch() {
365 assert_eq!(
366 mingit_asset_name("2.55.0", "x86_64"),
367 "MinGit-2.55.0-64-bit.zip"
368 );
369 assert_eq!(
370 mingit_asset_name("2.55.0", "arm64"),
371 "MinGit-2.55.0-arm64.zip"
372 );
373 }
374
375 #[test]
376 fn download_url_is_canonical() {
377 let url = mingit_download_url("v2.55.0.windows.1", "2.55.0", "x86_64");
378 assert_eq!(
379 url,
380 "https://github.com/git-for-windows/git/releases/download/\
381 v2.55.0.windows.1/MinGit-2.55.0-64-bit.zip"
382 );
383 }
384
385 #[test]
386 fn picks_plain_mingit_not_busybox_or_32bit() {
387 let assets = vec![
388 GhAsset {
389 name: "MinGit-2.55.0-32-bit.zip".to_string(),
390 browser_download_url: "https://x/32".to_string(),
391 },
392 GhAsset {
393 name: "MinGit-2.55.0-busybox-64-bit.zip".to_string(),
394 browser_download_url: "https://x/bb".to_string(),
395 },
396 GhAsset {
397 name: "MinGit-2.55.0-64-bit.zip".to_string(),
398 browser_download_url: "https://x/64".to_string(),
399 },
400 GhAsset {
401 name: "MinGit-2.55.0-arm64.zip".to_string(),
402 browser_download_url: "https://x/arm".to_string(),
403 },
404 ];
405 assert_eq!(
406 pick_mingit_asset(&assets, "2.55.0", "x86_64")
407 .unwrap()
408 .browser_download_url,
409 "https://x/64"
410 );
411 assert_eq!(
412 pick_mingit_asset(&assets, "2.55.0", "arm64")
413 .unwrap()
414 .browser_download_url,
415 "https://x/arm"
416 );
417 }
418
419 #[test]
420 fn pick_returns_none_when_asset_missing() {
421 let assets = vec![GhAsset {
422 name: "MinGit-2.55.0-32-bit.zip".to_string(),
423 browser_download_url: "https://x/32".to_string(),
424 }];
425 assert!(pick_mingit_asset(&assets, "2.55.0", "x86_64").is_none());
426 }
427
428 #[test]
429 fn release_json_parses() {
430 let json = r#"{
431 "tag_name": "v2.55.0.windows.1",
432 "assets": [
433 {"name": "MinGit-2.55.0-64-bit.zip", "browser_download_url": "https://x/64"}
434 ]
435 }"#;
436 let rel: GhRelease = serde_json::from_str(json).unwrap();
437 assert_eq!(mingit_version_from_tag(&rel.tag_name), "2.55.0");
438 assert_eq!(rel.assets.len(), 1);
439 }
440
441 #[tokio::test]
442 async fn non_git_formula_is_not_implemented() {
443 let tmp = tempfile::tempdir().unwrap();
444 let err = ensure_windows_keg("cowsay", tmp.path(), None)
445 .await
446 .unwrap_err();
447 assert!(matches!(err, ToolchainError::NotImplemented(_)));
448 }
449
450 #[tokio::test]
451 async fn extract_zip_roundtrips_nested_layout() {
452 use std::io::Write;
455 let mut buf = Vec::new();
456 {
457 let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
458 let opts = zip::write::SimpleFileOptions::default();
459 zw.start_file("cmd/git.exe", opts).unwrap();
460 zw.write_all(b"MZ-fake-exe").unwrap();
461 zw.start_file("mingw64/bin/git.exe", opts).unwrap();
462 zw.write_all(b"MZ-fake-exe-2").unwrap();
463 zw.finish().unwrap();
464 }
465 let tmp = tempfile::tempdir().unwrap();
466 extract_zip_to(&buf, tmp.path()).unwrap();
467 assert!(tmp.path().join("cmd/git.exe").is_file());
468 assert!(tmp.path().join("mingw64/bin/git.exe").is_file());
469 assert_eq!(
470 std::fs::read(tmp.path().join("cmd/git.exe")).unwrap(),
471 b"MZ-fake-exe"
472 );
473 }
474}