Skip to main content

zlayer_toolchain/
windows.rs

1//! Windows keg provisioning — "our apt" for the HCS sandbox.
2//!
3//! The Windows analogue of the macOS [`crate::source_build`] /
4//! [`crate::prebuilt`] paths. The HCS sandbox has no package manager, so this
5//! provisions a named tool into a self-contained, **relocatable** keg with the
6//! same [`KegManifest`] layout the macOS kegs use, and the same on-disk cache
7//! key (`<cache>/<tool>-<version>-<arch>/` + a `.ready` marker).
8//!
9//! # Strategy
10//!
11//! 1. **`git` → MinGit** ([`ensure_mingit`]). Git for Windows publishes
12//!    **MinGit**, a fully self-contained, relocatable portable zip (no
13//!    installer, no registry writes, no absolute-path baking) — exactly the
14//!    Windows equivalent of a relocation-free keg. We resolve the latest release
15//!    from the `git-for-windows/git` GitHub releases, fetch the right
16//!    architecture's `MinGit-<ver>-{64-bit,arm64}.zip`, and extract it into the
17//!    keg.
18//! 2. **Everything else → choco fallback.** Formulae with no relocatable
19//!    portable artifact are installed with `choco install` inside a throwaway
20//!    HCS compute system and captured into a keg. That path needs a live HCS
21//!    host, so it is owned by the runtime/builder layer (which has the HCS
22//!    machinery) — invoked when this module returns
23//!    [`ToolchainError::NotImplemented`] for a non-portable formula — rather
24//!    than pulled into this leaf crate (which must not depend on the HCS stack).
25//!
26//! This module is compiled on **all** hosts (it is pure HTTP + zip extraction +
27//! manifest I/O), so the keg format and MinGit resolver are unit-testable on a
28//! macOS build host even though the kegs are only *provisioned for* Windows
29//! containers.
30
31use std::path::{Path, PathBuf};
32
33use serde::Deserialize;
34
35use crate::error::{Result, ToolchainError};
36use crate::manifest::{KegManifest, KegSource};
37
38/// `git-for-windows/git` GitHub "latest release" API endpoint.
39const GIT_FOR_WINDOWS_LATEST: &str =
40    "https://api.github.com/repos/git-for-windows/git/releases/latest";
41
42/// Offline fallback: the latest known Git for Windows release at the time of
43/// writing (looked up from `git-for-windows/git` releases — NOT a stale guess).
44/// Used only when the live release API is unreachable (offline / rate-limited),
45/// so a per-machine cache can still be seeded deterministically.
46const MINGIT_FALLBACK_VERSION: &str = "2.55.0";
47/// Release tag matching [`MINGIT_FALLBACK_VERSION`].
48const MINGIT_FALLBACK_TAG: &str = "v2.55.0.windows.1";
49
50/// Architecture token used in keg cache keys + MinGit asset names
51/// (`x86_64`/`arm64`).
52#[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
61/// Split a package request into `(formula, version_token)` — mirrors the macOS
62/// `split_pkg`. The full `pkg` is always the formula/tool name.
63fn 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
70/// Provision (or reuse) a Windows keg for `pkg`, returning the keg directory.
71///
72/// Dispatches: `git` lands as a relocatable MinGit keg; every other formula
73/// returns [`ToolchainError::NotImplemented`] so the caller routes it to the
74/// HCS choco-capture path (which lives in the runtime layer, not this leaf
75/// crate).
76///
77/// # Errors
78///
79/// Returns [`ToolchainError::NotImplemented`] for a formula with no portable
80/// artifact, or propagates download/extraction errors for `git`.
81pub 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
96/// Resolve a Windows tool to `(version, url, published_sha256)` for the lockfile
97/// resolver, without downloading. Only `git` (MinGit) is portable; everything
98/// else routes to the HCS choco-capture path (not lockable here).
99///
100/// # Errors
101///
102/// Returns [`ToolchainError::NotImplemented`] for a non-portable formula.
103pub(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/// A single asset attached to a GitHub release.
115#[derive(Debug, Clone, Deserialize)]
116struct GhAsset {
117    name: String,
118    #[serde(default)]
119    browser_download_url: String,
120}
121
122/// The subset of the GitHub release JSON we consume.
123#[derive(Debug, Clone, Deserialize)]
124struct GhRelease {
125    #[serde(default)]
126    tag_name: String,
127    #[serde(default)]
128    assets: Vec<GhAsset>,
129}
130
131/// Resolve `<ver>` from a Git for Windows tag (`v2.55.0.windows.1` → `2.55.0`).
132fn 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
140/// The MinGit asset file name for `version` + `arch`.
141///
142/// Git for Windows names them `MinGit-<ver>-64-bit.zip` (x86_64) and
143/// `MinGit-<ver>-arm64.zip` (arm64). The plain (non-`busybox`) variant is the
144/// full MinGit with the real coreutils.
145fn 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
150/// Pick the right MinGit asset from a release's assets for `arch`, preferring
151/// the plain (non-`busybox`, non-`32-bit`) build. Returns the download URL.
152fn 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
159/// Construct the canonical MinGit download URL for a `(tag, version, arch)` —
160/// the offline fallback when the release API can't be queried.
161fn 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
168/// Resolve the MinGit `(version, download_url)` to fetch: query the live release
169/// API, falling back to the canonical URL for the pinned fallback version when
170/// the API is unreachable.
171async 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                // Git for Windows sometimes ships a sibling `<asset>.sha256`.
177                let sha = mingit_sibling_sha256(&rel.assets, &asset.name).await;
178                return (version, asset.browser_download_url.clone(), sha);
179            }
180            // API reachable but the expected asset name wasn't found — construct
181            // the canonical URL for the resolved tag/version.
182            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
193/// Best-effort: find a sibling `<asset>.sha256` checksum asset and return its hex
194/// digest. Returns `None` when absent or unreadable (compute-on-download).
195async 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
211/// Fetch + parse the latest `git-for-windows/git` release. GitHub requires a
212/// `User-Agent`; we parse from text so no extra reqwest feature is needed.
213async 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
237/// Provision the `git` keg from MinGit (a relocatable portable zip).
238///
239/// Idempotent via the `<keg>/.ready` marker written last. Extracts MinGit into
240/// `<cache>/git-<ver>-<arch>/` and writes a [`KegManifest`] whose `path_dirs`
241/// are the in-keg MinGit binary directories (`cmd`, `mingw64\bin`, `usr\bin`).
242///
243/// # Errors
244///
245/// Propagates download/extraction failures.
246pub async fn ensure_mingit(
247    cache_dir: &Path,
248    lockfile: Option<&crate::ToolchainLockfile>,
249) -> Result<PathBuf> {
250    let arch = windows_arch_token();
251
252    // A lock hit pins the exact version + URL + digest (consume-only).
253    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    // Fresh extract — clear any partial keg.
272    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    // Stream + verify (against a lockfile/published digest when available) into a
277    // temp zip inside the keg, recording the computed digest in the manifest.
278    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    // MinGit's `git.exe` lives at `cmd\git.exe`; the POSIX helpers + dlls live
292    // under `mingw64\bin` and `usr\bin`. All are relocatable (resolved relative
293    // to the exe), so the manifest just prepends the in-keg dirs to PATH.
294    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
318/// Extract a zip archive (in memory) into `dest`, preserving the archive's
319/// internal directory structure. Synchronous (the `zip` crate is blocking) —
320/// call under `spawn_blocking`.
321fn 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        // `enclosed_name` returns an owned `PathBuf` sanitized against path
333        // traversal (`None` = the entry name escaped the archive root); it holds
334        // no borrow on `file`, so the later `&mut file` for the copy is free.
335        let Some(rel) = file.enclosed_name() else {
336            continue; // skip unsafe (path-traversal) entries
337        };
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        // Build a tiny in-memory zip mirroring MinGit's nested layout, extract
453        // it, and assert the tree (and a file's bytes) materialize correctly.
454        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}