Skip to main content

zlayer_toolchain/
windows.rs

1//! Windows toolchain 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** toolchain with the
6//! same [`ToolchainManifest`] layout the macOS toolchains 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 toolchain. 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//!    toolchain.
18//! 2. **Everything else → Chocolatey.** The package is resolved through the
19//!    ZLayer package index (`/choco/:name` → `.nupkg` URL + version), the
20//!    `.nupkg` is downloaded (digest-verified when the index/lockfile carries
21//!    one) and its `tools/chocolateyInstall.ps1` is parsed into an
22//!    [`InstallPlan`] by [`crate::recipe_choco`]:
23//!    - **Portable zip / web-file packages** — plans made only of
24//!      [`InstallStep::StageResource`] / [`InstallStep::PrefixInstall`] /
25//!      [`InstallStep::BinInstall`] — execute **leaf-side**: each resource is
26//!      fetched (sha256-verified from the ps1's embedded checksums; an empty
27//!      checksum — the weak-checksum case [`crate::recipe_choco`] documents —
28//!      downloads unverified with a loud warning) and extracted/staged into
29//!      the toolchain prefix. This is fetch + unzip, no arbitrary code — the
30//!      same trust class as MinGit.
31//!    - **exe/msi installer packages** (any other plan shape) run
32//!      `choco install` inside a Windows container through the registered
33//!      [`crate::executor::ContainerBuildExecutor`] with the network allowed
34//!      loudly (grep `NET-FALLBACK`). When no executor is registered this
35//!      fails with [`ToolchainError::ExecutorUnavailable`] — never a host
36//!      subprocess, and never a wrong binary.
37//!
38//! This module is compiled on **all** hosts (it is pure HTTP + zip extraction +
39//! manifest I/O; the executor call is platform-tagged *data*, not `cfg`'d code),
40//! so the toolchain format, the MinGit resolver, and the choco chain are
41//! unit-testable on a macOS build host even though the toolchains are only
42//! *provisioned for* Windows containers.
43
44use std::collections::HashMap;
45use std::path::{Path, PathBuf};
46
47use serde::Deserialize;
48
49use crate::error::{Result, ToolchainError};
50use crate::executor::{ContainerBuildExecutor, ContainerBuildRequest, NetPolicy};
51use crate::manifest::{ToolchainManifest, ToolchainSource};
52use crate::recipe::{InstallPlan, InstallStep};
53use crate::registry::ToolchainArtifactId;
54use crate::relocate::RelocationReport;
55
56/// The platform token stored in a Windows artifact id / coverage record.
57const WINDOWS_OS_TOKEN: &str = "windows";
58
59/// Which install path produced a Windows toolchain, for coverage + publish
60/// routing. Both carry the finalize [`RelocationReport`] (finalization always
61/// relocates), but only the hermetic path is published.
62#[derive(Debug)]
63enum WinBuildKind {
64    /// A leaf-executable choco plan or the MinGit extract: hermetic, publishable
65    /// → coverage `Built`.
66    Hermetic(RelocationReport),
67    /// The loud full-network containerized `choco install` (`AllowLoud`) fallback
68    /// → coverage `NetFallback` (not published).
69    NetFallback(RelocationReport),
70}
71
72/// `git-for-windows/git` GitHub "latest release" API endpoint.
73const GIT_FOR_WINDOWS_LATEST: &str =
74    "https://api.github.com/repos/git-for-windows/git/releases/latest";
75
76/// Offline fallback: the latest known Git for Windows release at the time of
77/// writing (looked up from `git-for-windows/git` releases — NOT a stale guess).
78/// Used only when the live release API is unreachable (offline / rate-limited),
79/// so a per-machine cache can still be seeded deterministically.
80const MINGIT_FALLBACK_VERSION: &str = "2.55.0";
81/// Release tag matching [`MINGIT_FALLBACK_VERSION`].
82const MINGIT_FALLBACK_TAG: &str = "v2.55.0.windows.1";
83
84/// Architecture token used in toolchain cache keys + MinGit asset names
85/// (`x86_64`/`arm64`).
86#[must_use]
87pub fn windows_arch_token() -> &'static str {
88    if cfg!(target_arch = "aarch64") {
89        "arm64"
90    } else {
91        "x86_64"
92    }
93}
94
95/// Split a package request into `(formula, version_token)` — mirrors the macOS
96/// `split_pkg`. The full `pkg` is always the formula/tool name.
97fn split_pkg(pkg: &str) -> (&str, &str) {
98    match pkg.split_once('@') {
99        Some((_, ver)) if !ver.is_empty() => (pkg, ver),
100        _ => (pkg, "latest"),
101    }
102}
103
104/// Provision (or reuse) a Windows toolchain for `pkg`, returning the toolchain directory.
105///
106/// Dispatches: `git` lands as a relocatable MinGit toolchain; every other formula
107/// resolves through the Chocolatey chain ([`ensure_choco_toolchain`]): portable
108/// zip/web-file packages are fetched + extracted leaf-side, exe/msi installer
109/// packages run `choco install` in a network-enabled Windows container via the
110/// registered [`crate::executor::ContainerBuildExecutor`].
111///
112/// # Errors
113///
114/// Returns [`ToolchainError::ExecutorUnavailable`] for an installer-style
115/// package when no container executor is registered, and propagates
116/// resolution / download / extraction errors.
117pub async fn ensure_windows_toolchain(
118    pkg: &str,
119    cache_dir: &Path,
120    lockfile: Option<&crate::ToolchainLockfile>,
121) -> Result<PathBuf> {
122    let (formula, _version) = split_pkg(pkg);
123    match formula {
124        "git" => ensure_mingit(cache_dir, lockfile).await,
125        other => ensure_choco_toolchain(other, cache_dir, lockfile).await,
126    }
127}
128
129/// Resolve a Windows tool to `(version, url, published_sha256)` for the lockfile
130/// resolver, without downloading. `git` resolves through the MinGit release
131/// API; everything else resolves to its `.nupkg` coordinates through the
132/// package index's Chocolatey route (the lock then pins the nupkg URL +
133/// version, and the digest is computed by the caller's verifying download).
134///
135/// # Errors
136///
137/// Propagates package-index resolution failures for non-`git` formulae.
138pub(crate) async fn resolve_locked_windows(
139    formula: &str,
140) -> Result<(String, String, Option<String>)> {
141    match formula {
142        "git" => Ok(resolve_mingit(windows_arch_token()).await),
143        other => resolve_choco(other).await,
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Chocolatey chain (every non-`git` tool)
149// ---------------------------------------------------------------------------
150
151/// Identity + provenance of a resolved Chocolatey package, threaded from
152/// resolution through install into the manifest.
153struct ChocoPkg {
154    /// The tool / choco package id (e.g. `terraform`).
155    tool: String,
156    /// Resolved (or lock-pinned) package version — also the cache-key version.
157    version: String,
158    /// The `.nupkg` download URL (recorded as the manifest source URL).
159    nupkg_url: String,
160    /// The digest actually verified for the `.nupkg`, or empty when no
161    /// index/lockfile digest existed (verification then comes from the ps1's
162    /// embedded per-resource checksums on the leaf path).
163    verified_sha256: String,
164}
165
166/// Resolve a non-`git` Windows tool through the package index's Chocolatey
167/// route to `(version, nupkg_url, published_sha256)`.
168///
169/// [`ChocoData`](zlayer_types::package_index::ChocoData) carries `version`,
170/// the `.nupkg` `url`, and an `Option<sha256>` that is usually `None` (choco
171/// publishes no reliable package digest — the ps1's embedded checksums verify
172/// the actual payloads instead).
173async fn resolve_choco(formula: &str) -> Result<(String, String, Option<String>)> {
174    let data = crate::package_index::PackageIndexClient::from_env()
175        .get_choco(formula)
176        .await?;
177    let version = data.version.trim();
178    if version.is_empty() {
179        return Err(ToolchainError::RegistryError {
180            message: format!("choco entry for {formula} carries no version"),
181        });
182    }
183    let url = data.url.trim();
184    if url.is_empty() {
185        return Err(ToolchainError::RegistryError {
186            message: format!("choco entry for {formula} carries no .nupkg URL"),
187        });
188    }
189    let sha256 = data
190        .sha256
191        .map(|s| s.trim().to_string())
192        .filter(|s| !s.is_empty());
193    Ok((version.to_string(), url.to_string(), sha256))
194}
195
196/// Provision (or reuse) a Chocolatey-backed toolchain for `formula`.
197///
198/// Idempotent via the `<toolchain>/.ready` marker (written LAST, after the
199/// manifest — mirroring [`ensure_mingit`]). A lockfile hit pins the exact
200/// version + nupkg URL + digest (consume-only, exactly like MinGit's locked
201/// path); a miss live-resolves through the package index.
202async fn ensure_choco_toolchain(
203    formula: &str,
204    cache_dir: &Path,
205    lockfile: Option<&crate::ToolchainLockfile>,
206) -> Result<PathBuf> {
207    let arch = windows_arch_token();
208
209    // A lock hit pins the exact version + nupkg URL + digest (consume-only).
210    let (version, url, expected_sha) = match lockfile.and_then(|l| {
211        use crate::ToolchainLockfileExt;
212        l.lookup(formula, "windows", arch)
213    }) {
214        Some(locked) => (
215            locked.version.clone(),
216            locked.url.clone(),
217            Some(locked.sha256.clone()),
218        ),
219        None => resolve_choco(formula).await?,
220    };
221
222    let toolchain = cache_dir.join(format!("{formula}-{version}-{arch}"));
223    let ready_marker = toolchain.join(".ready");
224    // Local `.ready` wins FIRST (zero network).
225    if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
226        return Ok(toolchain);
227    }
228
229    // Identity known (version resolved) → pull-first, best-effort.
230    let id = ToolchainArtifactId {
231        tool: formula.to_string(),
232        version: version.clone(),
233        os: WINDOWS_OS_TOKEN.to_string(),
234        arch: arch.to_string(),
235    };
236    if crate::pull_first(&id, &toolchain).await?.is_some() {
237        return Ok(toolchain);
238    }
239
240    // Miss → install fresh, then record coverage + (hermetic) publish.
241    match install_choco_fresh(formula, &version, &url, expected_sha.as_deref(), &toolchain).await {
242        Ok(WinBuildKind::Hermetic(report)) => {
243            crate::finish_built(&id, &toolchain, Some(&report), false).await;
244            Ok(toolchain)
245        }
246        Ok(WinBuildKind::NetFallback(_report)) => {
247            crate::record_net_fallback(&id).await;
248            Ok(toolchain)
249        }
250        Err(e) => {
251            crate::record_failed(&id, &e).await;
252            Err(e)
253        }
254    }
255}
256
257/// Download the `.nupkg`, parse it, and install it into a freshly-cleared
258/// `toolchain`, returning which path won ([`WinBuildKind`]). Split out of
259/// [`ensure_choco_toolchain`] so the coverage/publish wrapper stays thin and any
260/// error routes to a single `record_failed`.
261async fn install_choco_fresh(
262    formula: &str,
263    version: &str,
264    url: &str,
265    expected_sha: Option<&str>,
266    toolchain: &Path,
267) -> Result<WinBuildKind> {
268    // Fresh install — clear any partial toolchain.
269    let _ = tokio::fs::remove_dir_all(toolchain).await;
270    tokio::fs::create_dir_all(toolchain).await?;
271
272    // Download the .nupkg (digest-verified when the index/lock pinned one;
273    // otherwise the digest is computed only — real verification then comes
274    // from the ps1's embedded per-resource checksums on the leaf path).
275    tracing::info!(tool = formula, url = %url, "downloading Chocolatey .nupkg");
276    let nupkg_path = toolchain.join(".pkg.nupkg");
277    let verified = expected_sha.map(str::trim).filter(|s| !s.is_empty());
278    let computed = crate::package_index::download_verified(url, &nupkg_path, verified).await?;
279    let bytes = tokio::fs::read(&nupkg_path).await?;
280    let _ = tokio::fs::remove_file(&nupkg_path).await;
281
282    let recipe = crate::recipe_choco::parse_nupkg(&bytes, toolchain)?;
283    let pkg = ChocoPkg {
284        tool: formula.to_string(),
285        version: version.to_string(),
286        nupkg_url: url.to_string(),
287        verified_sha256: if verified.is_some() {
288            computed
289        } else {
290            String::new()
291        },
292    };
293
294    let executor = crate::executor::container_executor();
295    install_from_choco_recipe(&pkg, recipe.plan, toolchain, executor.as_deref()).await
296}
297
298/// Install a parsed choco plan into `toolchain` and finalize it (relocate +
299/// manifest + `.ready`): leaf-side fetch + extract for a leaf-executable plan,
300/// otherwise containerized `choco install` through `executor`. Returns whether
301/// the hermetic leaf path or the loud-network container path won (so the caller
302/// publishes only the former), carrying the finalize [`RelocationReport`].
303///
304/// The executor is a parameter (rather than resolved here) so tests can drive
305/// the decision with a mock without touching the process-global slot.
306async fn install_from_choco_recipe(
307    pkg: &ChocoPkg,
308    plan: InstallPlan,
309    toolchain: &Path,
310    executor: Option<&dyn ContainerBuildExecutor>,
311) -> Result<WinBuildKind> {
312    let scratch = toolchain.join(".build");
313    tokio::fs::create_dir_all(&scratch).await?;
314
315    let net_fallback = if plan_is_leaf_executable(&plan) {
316        execute_choco_plan_leaf(&pkg.tool, &plan, toolchain).await?;
317        false
318    } else {
319        let Some(executor) = executor else {
320            return Err(ToolchainError::ExecutorUnavailable {
321                tool: pkg.tool.clone(),
322            });
323        };
324        run_choco_in_container(pkg, toolchain, executor).await?;
325        true
326    };
327
328    // Finalize exactly like MinGit: scratch cleared, relocate, manifest written,
329    // then `.ready` stamped LAST.
330    let _ = tokio::fs::remove_dir_all(&scratch).await;
331    let path_dirs = probe_windows_path_dirs(toolchain).await;
332    let report = finalize_windows_toolchain(
333        toolchain,
334        &pkg.tool,
335        &pkg.version,
336        path_dirs,
337        pkg.nupkg_url.clone(),
338        pkg.verified_sha256.clone(),
339    )
340    .await?;
341    Ok(if net_fallback {
342        WinBuildKind::NetFallback(report)
343    } else {
344        WinBuildKind::Hermetic(report)
345    })
346}
347
348/// `true` when an [`InstallStep::Unsupported`] construct is one of
349/// [`crate::recipe_choco`]'s weak-checksum markers (`no checksum for <url>` /
350/// `checksumType <t>` / `checksumType unknown for <url>`) rather than a real
351/// inexecutable construct. Weak-checksum plans still execute leaf-side — the
352/// download just proceeds unverified, loudly.
353fn is_weak_checksum_marker(construct: &str) -> bool {
354    construct.starts_with("no checksum for ") || construct.starts_with("checksumType ")
355}
356
357/// `true` when every step of the plan is leaf-executable fetch + unzip
358/// ([`InstallStep::StageResource`] / [`InstallStep::PrefixInstall`] /
359/// [`InstallStep::BinInstall`], plus informational weak-checksum markers).
360/// Anything else (exe/msi installers, opaque PowerShell) routes to the
361/// containerized choco path — bias to the container, never a wrong binary.
362fn plan_is_leaf_executable(plan: &InstallPlan) -> bool {
363    plan.steps.iter().all(|step| match step {
364        InstallStep::StageResource { .. }
365        | InstallStep::PrefixInstall { .. }
366        | InstallStep::BinInstall { .. } => true,
367        InstallStep::Unsupported { construct } => is_weak_checksum_marker(construct),
368        _ => false,
369    })
370}
371
372/// Execute a leaf-executable choco plan: download every resource
373/// (sha256-verified when the ps1 embedded a digest; unverified-with-warning
374/// for the weak-checksum case), then apply the staged steps into the prefix.
375async fn execute_choco_plan_leaf(tool: &str, plan: &InstallPlan, toolchain: &Path) -> Result<()> {
376    let resources_dir = toolchain.join(".build").join("resources");
377    let mut staged: HashMap<String, PathBuf> = HashMap::new();
378    for res in &plan.resources {
379        let file = res
380            .url
381            .rsplit('/')
382            .next()
383            .filter(|s| !s.is_empty())
384            .unwrap_or("resource.bin");
385        let dest = resources_dir.join(&res.name).join(file);
386        if res.sha256.trim().is_empty() {
387            // The weak-checksum case recipe_choco documents: the ps1 carried no
388            // usable sha256, so this download is UNVERIFIED. Proceed, loudly.
389            tracing::warn!(
390                tool,
391                resource = %res.name,
392                url = %res.url,
393                "choco resource declares no sha256; downloading UNVERIFIED (weak checksum)"
394            );
395            crate::package_index::download_verified(&res.url, &dest, None).await?;
396        } else {
397            crate::package_index::download_verified(&res.url, &dest, Some(&res.sha256)).await?;
398        }
399        staged.insert(res.name.clone(), dest);
400    }
401    apply_choco_steps(plan, &staged, toolchain).await
402}
403
404/// Apply the steps of a leaf-executable choco plan against the prefix,
405/// mirroring how [`crate::recipe_choco`] models the choco cmdlets:
406///
407/// - `Install-ChocolateyZipPackage` parses to a [`InstallStep::StageResource`]
408///   plus an immediate `PrefixInstall { from: ".", to: None }` — the pair
409///   means "extract the downloaded zip into the tools dir (our prefix)".
410/// - `Get-ChocolateyWebFile` parses to a lone `StageResource` — the raw file
411///   lands in the tools dir named after the package (the parser does not
412///   retain `FileFullPath`, and the dominant idiom is
413///   `Join-Path $toolsDir "$packageName.exe"`).
414/// - `Install-BinFile` parses to a [`InstallStep::BinInstall`] — copy the
415///   prefix-relative source into `<prefix>/bin`, optionally renamed.
416async fn apply_choco_steps(
417    plan: &InstallPlan,
418    staged: &HashMap<String, PathBuf>,
419    toolchain: &Path,
420) -> Result<()> {
421    let mut i = 0usize;
422    while i < plan.steps.len() {
423        match &plan.steps[i] {
424            InstallStep::StageResource { name, dir } => {
425                let src = staged
426                    .get(name)
427                    .ok_or_else(|| ToolchainError::RegistryError {
428                        message: format!("choco plan stages undeclared resource '{name}'"),
429                    })?;
430                let dest_dir = dir
431                    .as_deref()
432                    .map_or_else(|| toolchain.to_path_buf(), |d| toolchain.join(d));
433                let paired_extract = matches!(
434                    plan.steps.get(i + 1),
435                    Some(InstallStep::PrefixInstall { from, to: None }) if from == "."
436                );
437                if paired_extract {
438                    extract_resource_zip(src, &dest_dir).await?;
439                    i += 1; // the PrefixInstall is consumed by the extraction
440                } else {
441                    stage_web_file(name, src, &dest_dir).await?;
442                }
443            }
444            InstallStep::BinInstall { from, to } => {
445                apply_bin_install(toolchain, from, to.as_deref()).await?;
446            }
447            InstallStep::Unsupported { construct } if is_weak_checksum_marker(construct) => {
448                // Informational only — the unverified download already warned.
449            }
450            other => {
451                // Unreachable behind `plan_is_leaf_executable`, but never
452                // guess: a step this executor does not model is an error.
453                return Err(ToolchainError::RegistryError {
454                    message: format!("choco plan step is not leaf-executable: {other:?}"),
455                });
456            }
457        }
458        i += 1;
459    }
460    Ok(())
461}
462
463/// Extract a staged zip resource into `dest_dir` (the prefix or a subdir).
464async fn extract_resource_zip(src: &Path, dest_dir: &Path) -> Result<()> {
465    let bytes = tokio::fs::read(src).await?;
466    let dest = dest_dir.to_path_buf();
467    tokio::task::spawn_blocking(move || extract_zip_to(&bytes, &dest))
468        .await
469        .map_err(|e| ToolchainError::RegistryError {
470            message: format!("choco resource extraction task panicked: {e}"),
471        })?
472}
473
474/// Land a raw (non-archive) web-file resource in `dest_dir` as
475/// `<resource-name>.<source-extension>` — e.g. resource `jq` downloaded from
476/// `.../jq-windows-amd64.exe` lands as `jq.exe`, matching choco's
477/// `$toolsDir\$packageName.exe` idiom (never the mangled URL basename).
478async fn stage_web_file(name: &str, src: &Path, dest_dir: &Path) -> Result<()> {
479    let file_name = src
480        .extension()
481        .and_then(|e| e.to_str())
482        .map_or_else(|| name.to_string(), |ext| format!("{name}.{ext}"));
483    tokio::fs::create_dir_all(dest_dir).await?;
484    tokio::fs::copy(src, dest_dir.join(file_name)).await?;
485    Ok(())
486}
487
488/// `Install-BinFile` semantics: copy the prefix-relative `from` into
489/// `<prefix>/bin/<to-or-basename>`. A rename without an extension carries the
490/// source's extension so the landed binary stays runnable (`jq` → `jq.exe`).
491async fn apply_bin_install(toolchain: &Path, from: &str, to: Option<&str>) -> Result<()> {
492    let src = toolchain.join(from);
493    let src_name = src
494        .file_name()
495        .and_then(|n| n.to_str())
496        .unwrap_or(from)
497        .to_string();
498    let mut dest_name = to.map_or_else(|| src_name.clone(), str::to_string);
499    if !dest_name.contains('.') {
500        if let Some(ext) = Path::new(&src_name).extension().and_then(|e| e.to_str()) {
501            dest_name.push('.');
502            dest_name.push_str(ext);
503        }
504    }
505    let bin = toolchain.join("bin");
506    tokio::fs::create_dir_all(&bin).await?;
507    tokio::fs::copy(&src, bin.join(&dest_name))
508        .await
509        .map_err(|e| ToolchainError::RegistryError {
510            message: format!("Install-BinFile source '{from}' could not be staged: {e}"),
511        })?;
512    Ok(())
513}
514
515/// Run `choco install <tool>` inside a network-enabled Windows container.
516///
517/// `choco` exists inside the runtime's choco-capable Windows base container —
518/// this request depends on that image; the runtime executor (the agent's HCS
519/// implementation) owns materializing it, and capture semantics (installed
520/// payload → `prefix`) are runtime-side too.
521async fn run_choco_in_container(
522    pkg: &ChocoPkg,
523    toolchain: &Path,
524    executor: &dyn ContainerBuildExecutor,
525) -> Result<()> {
526    let req = choco_container_request(&pkg.tool, &pkg.version, toolchain);
527    tracing::warn!(
528        tool = %pkg.tool,
529        version = %pkg.version,
530        "NET-FALLBACK: choco plan is not leaf-executable (exe/msi installer); \
531         running `choco install` in a network-enabled Windows container"
532    );
533    let report = executor.execute(&req).await?;
534    tracing::warn!(
535        tool = %pkg.tool,
536        log_tail = %report.log_tail,
537        "NET-FALLBACK: containerized choco install completed"
538    );
539    Ok(())
540}
541
542/// Assemble the containerized-choco [`ContainerBuildRequest`]: a single
543/// `choco install` [`InstallStep::System`] step, Windows platform, network
544/// allowed loudly (choco fetches its own installers), scratch at
545/// `<prefix>/.build`.
546fn choco_container_request(tool: &str, version: &str, toolchain: &Path) -> ContainerBuildRequest {
547    let scratch = toolchain.join(".build");
548    let argv = [
549        "choco",
550        "install",
551        tool,
552        "-y",
553        "--no-progress",
554        "--version",
555        version,
556    ]
557    .iter()
558    .map(ToString::to_string)
559    .collect();
560    ContainerBuildRequest {
561        tool: tool.to_string(),
562        platform: crate::ToolPlatform::Windows,
563        plan: InstallPlan {
564            steps: vec![InstallStep::System { argv }],
565            resources: Vec::new(),
566            patches: Vec::new(),
567            env: HashMap::new(),
568            deparallelize: false,
569        },
570        // No source tree for a choco install — the step runs in scratch.
571        src_dir: scratch.clone(),
572        prefix: toolchain.to_path_buf(),
573        scratch_dir: scratch,
574        dep_toolchains: Vec::new(),
575        resources_dir: None,
576        env: HashMap::new(),
577        path_prefix: Vec::new(),
578        net: NetPolicy::AllowLoud,
579    }
580}
581
582/// Probe the provisioned layout for `PATH` directories: the toolchain root,
583/// `bin/`, and `tools/` qualify when they directly contain a Windows
584/// executable (`.exe` / `.bat` / `.cmd`).
585async fn probe_windows_path_dirs(toolchain: &Path) -> Vec<String> {
586    let mut dirs = Vec::new();
587    for candidate in [
588        toolchain.to_path_buf(),
589        toolchain.join("bin"),
590        toolchain.join("tools"),
591    ] {
592        if dir_has_windows_executable(&candidate).await {
593            dirs.push(candidate.display().to_string());
594        }
595    }
596    dirs
597}
598
599/// `true` when `dir` directly contains a `.exe` / `.bat` / `.cmd` entry.
600async fn dir_has_windows_executable(dir: &Path) -> bool {
601    let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
602        return false;
603    };
604    while let Ok(Some(entry)) = entries.next_entry().await {
605        if let Some(name) = entry.file_name().to_str() {
606            let is_executable = Path::new(name)
607                .extension()
608                .and_then(|e| e.to_str())
609                .is_some_and(|ext| {
610                    ["exe", "bat", "cmd"]
611                        .iter()
612                        .any(|want| ext.eq_ignore_ascii_case(want))
613                });
614            if is_executable {
615                return true;
616            }
617        }
618    }
619    false
620}
621
622/// Finish a populated Windows toolchain the single canonical way (shared by
623/// the MinGit and choco paths):
624///
625/// 1. [`make_relocatable`](crate::relocate::make_relocatable) the tree in place
626///    (built prefix = the toolchain dir; no build deps). Windows toolchains hold
627///    PE binaries, never Mach-O, so this invokes NO host tools — it only
628///    residue-scans + text-placeholders — and returns the [`RelocationReport`]
629///    the publish step consumes.
630/// 2. write the [`ToolchainManifest`] (`platform: "windows"`, `source: Prebuilt`)
631///    AFTER relocation (so its absolute `path_dirs` are not placeholdered), then
632///    stamp `.ready` LAST.
633async fn finalize_windows_toolchain(
634    toolchain: &Path,
635    tool: &str,
636    version: &str,
637    path_dirs: Vec<String>,
638    url: String,
639    sha256: String,
640) -> Result<RelocationReport> {
641    let report = crate::relocate::make_relocatable(toolchain, toolchain, &[]).await?;
642    let manifest = ToolchainManifest {
643        tool: tool.to_string(),
644        version: version.to_string(),
645        arch: windows_arch_token().to_string(),
646        platform: "windows".to_string(),
647        path_dirs,
648        env: HashMap::new(),
649        source: ToolchainSource::Prebuilt { url, sha256 },
650        build_deps: Vec::new(),
651        provisioned_at: chrono::Utc::now().to_rfc3339(),
652    };
653    manifest.write_to_toolchain(toolchain).await?;
654    tokio::fs::write(toolchain.join(".ready"), b"").await?;
655    Ok(report)
656}
657
658// ---------------------------------------------------------------------------
659// MinGit (the `git` fast path)
660// ---------------------------------------------------------------------------
661
662/// A single asset attached to a GitHub release.
663#[derive(Debug, Clone, Deserialize)]
664struct GhAsset {
665    name: String,
666    #[serde(default)]
667    browser_download_url: String,
668}
669
670/// The subset of the GitHub release JSON we consume.
671#[derive(Debug, Clone, Deserialize)]
672struct GhRelease {
673    #[serde(default)]
674    tag_name: String,
675    #[serde(default)]
676    assets: Vec<GhAsset>,
677}
678
679/// Resolve `<ver>` from a Git for Windows tag (`v2.55.0.windows.1` → `2.55.0`).
680fn mingit_version_from_tag(tag: &str) -> String {
681    tag.trim_start_matches('v')
682        .split(".windows")
683        .next()
684        .unwrap_or(tag)
685        .to_string()
686}
687
688/// The MinGit asset file name for `version` + `arch`.
689///
690/// Git for Windows names them `MinGit-<ver>-64-bit.zip` (x86_64) and
691/// `MinGit-<ver>-arm64.zip` (arm64). The plain (non-`busybox`) variant is the
692/// full MinGit with the real coreutils.
693fn mingit_asset_name(version: &str, arch: &str) -> String {
694    let suffix = if arch == "arm64" { "arm64" } else { "64-bit" };
695    format!("MinGit-{version}-{suffix}.zip")
696}
697
698/// Pick the right MinGit asset from a release's assets for `arch`, preferring
699/// the plain (non-`busybox`, non-`32-bit`) build. Returns the download URL.
700fn pick_mingit_asset<'a>(assets: &'a [GhAsset], version: &str, arch: &str) -> Option<&'a GhAsset> {
701    let want = mingit_asset_name(version, arch);
702    assets
703        .iter()
704        .find(|a| a.name == want && !a.browser_download_url.is_empty())
705}
706
707/// Construct the canonical MinGit download URL for a `(tag, version, arch)` —
708/// the offline fallback when the release API can't be queried.
709fn mingit_download_url(tag: &str, version: &str, arch: &str) -> String {
710    format!(
711        "https://github.com/git-for-windows/git/releases/download/{tag}/{}",
712        mingit_asset_name(version, arch)
713    )
714}
715
716/// Resolve the MinGit `(version, download_url)` to fetch: query the live release
717/// API, falling back to the canonical URL for the pinned fallback version when
718/// the API is unreachable.
719async fn resolve_mingit(arch: &str) -> (String, String, Option<String>) {
720    match fetch_latest_release().await {
721        Ok(rel) => {
722            let version = mingit_version_from_tag(&rel.tag_name);
723            if let Some(asset) = pick_mingit_asset(&rel.assets, &version, arch) {
724                // Git for Windows sometimes ships a sibling `<asset>.sha256`.
725                let sha = mingit_sibling_sha256(&rel.assets, &asset.name).await;
726                return (version, asset.browser_download_url.clone(), sha);
727            }
728            // API reachable but the expected asset name wasn't found — construct
729            // the canonical URL for the resolved tag/version.
730            let url = mingit_download_url(&rel.tag_name, &version, arch);
731            (version, url, None)
732        }
733        Err(_) => (
734            MINGIT_FALLBACK_VERSION.to_string(),
735            mingit_download_url(MINGIT_FALLBACK_TAG, MINGIT_FALLBACK_VERSION, arch),
736            None,
737        ),
738    }
739}
740
741/// Best-effort: find a sibling `<asset>.sha256` checksum asset and return its hex
742/// digest. Returns `None` when absent or unreadable (compute-on-download).
743async fn mingit_sibling_sha256(assets: &[GhAsset], asset_name: &str) -> Option<String> {
744    let want = format!("{asset_name}.sha256");
745    let asset = assets
746        .iter()
747        .find(|a| a.name == want && !a.browser_download_url.is_empty())?;
748    let text = reqwest::get(&asset.browser_download_url)
749        .await
750        .ok()?
751        .text()
752        .await
753        .ok()?;
754    let token = text.split_whitespace().next()?;
755    (token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()))
756        .then(|| token.to_ascii_lowercase())
757}
758
759/// Fetch + parse the latest `git-for-windows/git` release. GitHub requires a
760/// `User-Agent`; we parse from text so no extra reqwest feature is needed.
761async fn fetch_latest_release() -> Result<GhRelease> {
762    let client = reqwest::Client::builder()
763        .user_agent("zlayer-toolchain")
764        .build()
765        .map_err(|e| ToolchainError::RegistryError {
766            message: format!("failed to build HTTP client: {e}"),
767        })?;
768    let text = client
769        .get(GIT_FOR_WINDOWS_LATEST)
770        .send()
771        .await
772        .map_err(|e| ToolchainError::RegistryError {
773            message: format!("failed to query git-for-windows releases: {e}"),
774        })?
775        .text()
776        .await
777        .map_err(|e| ToolchainError::RegistryError {
778            message: format!("failed to read git-for-windows release body: {e}"),
779        })?;
780    serde_json::from_str(&text).map_err(|e| ToolchainError::RegistryError {
781        message: format!("failed to parse git-for-windows release JSON: {e}"),
782    })
783}
784
785/// Provision the `git` toolchain from MinGit (a relocatable portable zip).
786///
787/// Idempotent via the `<toolchain>/.ready` marker written last. Extracts MinGit into
788/// `<cache>/git-<ver>-<arch>/` and writes a [`ToolchainManifest`] whose `path_dirs`
789/// are the in-toolchain MinGit binary directories (`cmd`, `mingw64\bin`, `usr\bin`).
790///
791/// # Errors
792///
793/// Propagates download/extraction failures.
794pub async fn ensure_mingit(
795    cache_dir: &Path,
796    lockfile: Option<&crate::ToolchainLockfile>,
797) -> Result<PathBuf> {
798    let arch = windows_arch_token();
799
800    // A lock hit pins the exact version + URL + digest (consume-only).
801    let (version, url, expected_sha) = match lockfile.and_then(|l| {
802        use crate::ToolchainLockfileExt;
803        l.lookup("git", "windows", arch)
804    }) {
805        Some(locked) => (
806            locked.version.clone(),
807            locked.url.clone(),
808            Some(locked.sha256.clone()),
809        ),
810        None => resolve_mingit(arch).await,
811    };
812
813    let toolchain = cache_dir.join(format!("git-{version}-{arch}"));
814    let ready_marker = toolchain.join(".ready");
815    // Local `.ready` wins FIRST (zero network).
816    if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
817        return Ok(toolchain);
818    }
819
820    // Identity known (version resolved) → pull-first, best-effort.
821    let id = ToolchainArtifactId {
822        tool: "git".to_string(),
823        version: version.clone(),
824        os: WINDOWS_OS_TOKEN.to_string(),
825        arch: arch.to_string(),
826    };
827    if crate::pull_first(&id, &toolchain).await?.is_some() {
828        return Ok(toolchain);
829    }
830
831    // Miss → extract fresh, then record coverage + publish (MinGit is always a
832    // hermetic leaf extract — no net fallback).
833    match install_mingit_fresh(&toolchain, &version, &url, expected_sha.as_deref()).await {
834        Ok(report) => {
835            crate::finish_built(&id, &toolchain, Some(&report), false).await;
836            Ok(toolchain)
837        }
838        Err(e) => {
839            crate::record_failed(&id, &e).await;
840            Err(e)
841        }
842    }
843}
844
845/// Download + extract MinGit into a freshly-cleared `toolchain` and finalize it
846/// (relocate + manifest + `.ready`), returning the [`RelocationReport`]. Split
847/// out of [`ensure_mingit`] so the coverage/publish wrapper stays thin and any
848/// error routes to a single `record_failed`.
849async fn install_mingit_fresh(
850    toolchain: &Path,
851    version: &str,
852    url: &str,
853    expected_sha: Option<&str>,
854) -> Result<RelocationReport> {
855    // Fresh extract — clear any partial toolchain.
856    let _ = tokio::fs::remove_dir_all(toolchain).await;
857    tokio::fs::create_dir_all(toolchain).await?;
858
859    tracing::info!(url = %url, "downloading MinGit for the Windows git toolchain");
860    // Stream + verify (against a lockfile/published digest when available) into a
861    // temp zip inside the toolchain, recording the computed digest in the manifest.
862    let zip_path = toolchain.join(".mingit.zip");
863    let computed_sha =
864        crate::package_index::download_verified(url, &zip_path, expected_sha).await?;
865    let bytes = tokio::fs::read(&zip_path).await?;
866
867    let toolchain_clone = toolchain.to_path_buf();
868    tokio::task::spawn_blocking(move || extract_zip_to(&bytes, &toolchain_clone))
869        .await
870        .map_err(|e| ToolchainError::RegistryError {
871            message: format!("MinGit extraction task panicked: {e}"),
872        })??;
873    let _ = tokio::fs::remove_file(&zip_path).await;
874
875    // MinGit's `git.exe` lives at `cmd\git.exe`; the POSIX helpers + dlls live
876    // under `mingw64\bin` and `usr\bin`. All are relocatable (resolved relative
877    // to the exe), so the manifest just prepends the in-toolchain dirs to PATH.
878    let path_dirs = ["cmd", "mingw64\\bin", "usr\\bin"]
879        .iter()
880        .map(|sub| toolchain.join(sub).display().to_string())
881        .collect::<Vec<_>>();
882
883    finalize_windows_toolchain(
884        toolchain,
885        "git",
886        version,
887        path_dirs,
888        url.to_string(),
889        computed_sha,
890    )
891    .await
892}
893
894/// Extract a zip archive (in memory) into `dest`, preserving the archive's
895/// internal directory structure. Synchronous (the `zip` crate is blocking) —
896/// call under `spawn_blocking`. Shared by the MinGit and choco leaf paths.
897fn extract_zip_to(bytes: &[u8], dest: &Path) -> Result<()> {
898    let reader = std::io::Cursor::new(bytes);
899    let mut archive = zip::ZipArchive::new(reader).map_err(|e| ToolchainError::RegistryError {
900        message: format!("failed to open zip archive: {e}"),
901    })?;
902    for i in 0..archive.len() {
903        let mut file = archive
904            .by_index(i)
905            .map_err(|e| ToolchainError::RegistryError {
906                message: format!("failed to read zip entry {i}: {e}"),
907            })?;
908        // `enclosed_name` returns an owned `PathBuf` sanitized against path
909        // traversal (`None` = the entry name escaped the archive root); it holds
910        // no borrow on `file`, so the later `&mut file` for the copy is free.
911        let Some(rel) = file.enclosed_name() else {
912            continue; // skip unsafe (path-traversal) entries
913        };
914        let out_path = dest.join(&rel);
915        if file.is_dir() {
916            std::fs::create_dir_all(&out_path)?;
917            continue;
918        }
919        if let Some(parent) = out_path.parent() {
920            std::fs::create_dir_all(parent)?;
921        }
922        let mut out = std::fs::File::create(&out_path)?;
923        std::io::copy(&mut file, &mut out)?;
924    }
925    Ok(())
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931    use crate::executor::ContainerBuildReport;
932    use crate::{LockedTool, ToolchainLockfile, ToolchainLockfileExt};
933
934    // -- shared fixture helpers ------------------------------------------------
935
936    /// Build a tiny in-memory zip from `(name, bytes)` entries.
937    fn build_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
938        use std::io::Write;
939        let mut buf = Vec::new();
940        {
941            let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
942            let opts = zip::write::SimpleFileOptions::default();
943            for (name, content) in files {
944                zw.start_file(*name, opts).unwrap();
945                zw.write_all(content).unwrap();
946            }
947            zw.finish().unwrap();
948        }
949        buf
950    }
951
952    /// Minimal-but-realistic nuspec XML for `id`/`version` (mirrors the
953    /// `recipe_choco` test fixture).
954    fn nuspec_xml(id: &str, version: &str) -> String {
955        format!(
956            r#"<?xml version="1.0" encoding="utf-8"?>
957<package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd">
958  <metadata>
959    <id>{id}</id>
960    <version>{version}</version>
961    <authors>test</authors>
962  </metadata>
963</package>"#
964        )
965    }
966
967    /// Build an in-memory `.nupkg` (nuspec + install ps1) for `id`/`version`.
968    fn build_nupkg(id: &str, version: &str, ps1: &str) -> Vec<u8> {
969        let nuspec = nuspec_xml(id, version);
970        build_zip(&[
971            (&format!("{id}.nuspec"), nuspec.as_bytes()),
972            ("tools/chocolateyInstall.ps1", ps1.as_bytes()),
973        ])
974    }
975
976    /// sha256 (bare lowercase hex) of `bytes`.
977    fn sha256_hex(bytes: &[u8]) -> String {
978        use sha2::{Digest, Sha256};
979        hex::encode(Sha256::digest(bytes))
980    }
981
982    /// Serve `bytes` over a loopback HTTP listener (every request gets the
983    /// same 200 body). Returns the URL of `/{name}`. The accept loop lives on
984    /// a detached task for the test's lifetime.
985    async fn serve_bytes(bytes: Vec<u8>, name: &str) -> String {
986        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
987        let addr = listener.local_addr().unwrap();
988        tokio::spawn(async move {
989            loop {
990                let Ok((mut sock, _)) = listener.accept().await else {
991                    break;
992                };
993                let body = bytes.clone();
994                tokio::spawn(async move {
995                    use tokio::io::{AsyncReadExt, AsyncWriteExt};
996                    let mut buf = [0u8; 2048];
997                    let _ = sock.read(&mut buf).await; // consume the request head
998                    let head = format!(
999                        "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
1000                        body.len()
1001                    );
1002                    let _ = sock.write_all(head.as_bytes()).await;
1003                    let _ = sock.write_all(&body).await;
1004                    let _ = sock.shutdown().await;
1005                });
1006            }
1007        });
1008        format!("http://{addr}/{name}")
1009    }
1010
1011    /// A [`ChocoPkg`] identity for fixture installs.
1012    fn pkg(tool: &str, version: &str) -> ChocoPkg {
1013        ChocoPkg {
1014            tool: tool.to_string(),
1015            version: version.to_string(),
1016            nupkg_url: format!("https://example/{tool}.{version}.nupkg"),
1017            verified_sha256: String::new(),
1018        }
1019    }
1020
1021    /// Local mock executor passed DIRECTLY as `&dyn` (never through the
1022    /// process-global slot — that slot belongs to `executor.rs`'s single
1023    /// sequential test). Captures the request it served.
1024    struct CapturingExecutor {
1025        seen: std::sync::Mutex<Option<ContainerBuildRequest>>,
1026    }
1027
1028    impl ContainerBuildExecutor for CapturingExecutor {
1029        fn execute<'a>(
1030            &'a self,
1031            req: &'a ContainerBuildRequest,
1032        ) -> std::pin::Pin<
1033            Box<dyn std::future::Future<Output = Result<ContainerBuildReport>> + Send + 'a>,
1034        > {
1035            Box::pin(async move {
1036                *self.seen.lock().unwrap() = Some(req.clone());
1037                Ok(ContainerBuildReport {
1038                    log_tail: "choco install ok".to_string(),
1039                })
1040            })
1041        }
1042    }
1043
1044    // -- MinGit resolver (unchanged behavior) -----------------------------------
1045
1046    #[test]
1047    fn version_parsed_from_tag() {
1048        assert_eq!(mingit_version_from_tag("v2.55.0.windows.1"), "2.55.0");
1049        assert_eq!(mingit_version_from_tag("v2.43.2.windows.2"), "2.43.2");
1050        assert_eq!(mingit_version_from_tag("2.40.0"), "2.40.0");
1051    }
1052
1053    #[test]
1054    fn asset_name_per_arch() {
1055        assert_eq!(
1056            mingit_asset_name("2.55.0", "x86_64"),
1057            "MinGit-2.55.0-64-bit.zip"
1058        );
1059        assert_eq!(
1060            mingit_asset_name("2.55.0", "arm64"),
1061            "MinGit-2.55.0-arm64.zip"
1062        );
1063    }
1064
1065    #[test]
1066    fn download_url_is_canonical() {
1067        let url = mingit_download_url("v2.55.0.windows.1", "2.55.0", "x86_64");
1068        assert_eq!(
1069            url,
1070            "https://github.com/git-for-windows/git/releases/download/\
1071             v2.55.0.windows.1/MinGit-2.55.0-64-bit.zip"
1072        );
1073    }
1074
1075    #[test]
1076    fn picks_plain_mingit_not_busybox_or_32bit() {
1077        let assets = vec![
1078            GhAsset {
1079                name: "MinGit-2.55.0-32-bit.zip".to_string(),
1080                browser_download_url: "https://x/32".to_string(),
1081            },
1082            GhAsset {
1083                name: "MinGit-2.55.0-busybox-64-bit.zip".to_string(),
1084                browser_download_url: "https://x/bb".to_string(),
1085            },
1086            GhAsset {
1087                name: "MinGit-2.55.0-64-bit.zip".to_string(),
1088                browser_download_url: "https://x/64".to_string(),
1089            },
1090            GhAsset {
1091                name: "MinGit-2.55.0-arm64.zip".to_string(),
1092                browser_download_url: "https://x/arm".to_string(),
1093            },
1094        ];
1095        assert_eq!(
1096            pick_mingit_asset(&assets, "2.55.0", "x86_64")
1097                .unwrap()
1098                .browser_download_url,
1099            "https://x/64"
1100        );
1101        assert_eq!(
1102            pick_mingit_asset(&assets, "2.55.0", "arm64")
1103                .unwrap()
1104                .browser_download_url,
1105            "https://x/arm"
1106        );
1107    }
1108
1109    #[test]
1110    fn pick_returns_none_when_asset_missing() {
1111        let assets = vec![GhAsset {
1112            name: "MinGit-2.55.0-32-bit.zip".to_string(),
1113            browser_download_url: "https://x/32".to_string(),
1114        }];
1115        assert!(pick_mingit_asset(&assets, "2.55.0", "x86_64").is_none());
1116    }
1117
1118    #[test]
1119    fn release_json_parses() {
1120        let json = r#"{
1121            "tag_name": "v2.55.0.windows.1",
1122            "assets": [
1123                {"name": "MinGit-2.55.0-64-bit.zip", "browser_download_url": "https://x/64"}
1124            ]
1125        }"#;
1126        let rel: GhRelease = serde_json::from_str(json).unwrap();
1127        assert_eq!(mingit_version_from_tag(&rel.tag_name), "2.55.0");
1128        assert_eq!(rel.assets.len(), 1);
1129    }
1130
1131    #[tokio::test]
1132    async fn extract_zip_roundtrips_nested_layout() {
1133        // Build a tiny in-memory zip mirroring MinGit's nested layout, extract
1134        // it, and assert the tree (and a file's bytes) materialize correctly.
1135        let buf = build_zip(&[
1136            ("cmd/git.exe", b"MZ-fake-exe"),
1137            ("mingw64/bin/git.exe", b"MZ-fake-exe-2"),
1138        ]);
1139        let tmp = tempfile::tempdir().unwrap();
1140        extract_zip_to(&buf, tmp.path()).unwrap();
1141        assert!(tmp.path().join("cmd/git.exe").is_file());
1142        assert!(tmp.path().join("mingw64/bin/git.exe").is_file());
1143        assert_eq!(
1144            std::fs::read(tmp.path().join("cmd/git.exe")).unwrap(),
1145            b"MZ-fake-exe"
1146        );
1147    }
1148
1149    // -- choco chain: leaf execution ---------------------------------------------
1150
1151    /// A fully-supported zip-package plan (the terraform-style splat idiom)
1152    /// executes LEAF-SIDE: resource verified + extracted into the prefix,
1153    /// manifest written in the MinGit shape, scratch cleaned, `.ready` LAST.
1154    #[tokio::test]
1155    async fn supported_choco_zip_plan_installs_leaf_side() {
1156        let payload = build_zip(&[("sample.exe", b"MZ-sample")]);
1157        let sha = sha256_hex(&payload);
1158        let url = serve_bytes(payload, "sample-1.0.0.zip").await;
1159        let ps1 = format!(
1160            "$toolsDir = \"$(Split-Path -Parent $MyInvocation.MyCommand.Definition)\"\n\
1161             Install-ChocolateyZipPackage -PackageName 'sample' -Url64bit '{url}' \
1162             -Checksum64 '{sha}' -ChecksumType64 'sha256' -UnzipLocation $toolsDir\n"
1163        );
1164        let tmp = tempfile::tempdir().unwrap();
1165        let toolchain = tmp
1166            .path()
1167            .join(format!("sample-1.0.0-{}", windows_arch_token()));
1168        tokio::fs::create_dir_all(&toolchain).await.unwrap();
1169
1170        let nupkg = build_nupkg("sample", "1.0.0", &ps1);
1171        let recipe = crate::recipe_choco::parse_nupkg(&nupkg, &toolchain).unwrap();
1172        assert!(recipe.plan.is_fully_supported());
1173        assert!(plan_is_leaf_executable(&recipe.plan));
1174
1175        let pkg = pkg("sample", "1.0.0");
1176        install_from_choco_recipe(&pkg, recipe.plan, &toolchain, None)
1177            .await
1178            .expect("leaf-executable plan must install without an executor");
1179
1180        assert_eq!(
1181            tokio::fs::read(toolchain.join("sample.exe")).await.unwrap(),
1182            b"MZ-sample"
1183        );
1184        assert!(toolchain.join(".ready").is_file(), ".ready stamped last");
1185        assert!(!toolchain.join(".build").exists(), "scratch cleaned");
1186
1187        let manifest = ToolchainManifest::read_from_toolchain(&toolchain)
1188            .await
1189            .unwrap()
1190            .expect("manifest written before .ready");
1191        assert_eq!(manifest.tool, "sample");
1192        assert_eq!(manifest.version, "1.0.0");
1193        assert_eq!(manifest.arch, windows_arch_token());
1194        assert_eq!(manifest.platform, "windows");
1195        assert_eq!(
1196            manifest.path_dirs,
1197            vec![toolchain.display().to_string()],
1198            "the prefix root holds sample.exe, so it is the probed PATH dir"
1199        );
1200        assert_eq!(
1201            manifest.source,
1202            ToolchainSource::Prebuilt {
1203                url: pkg.nupkg_url.clone(),
1204                sha256: String::new(),
1205            },
1206            "unverified nupkg records an EMPTY sha (payload was ps1-verified)"
1207        );
1208    }
1209
1210    /// The weak-checksum case (md5 checksumType → empty sha256 + marker):
1211    /// still leaf-executable — the download proceeds UNVERIFIED (with a warn)
1212    /// and the install lands.
1213    #[tokio::test]
1214    async fn weak_checksum_choco_plan_warns_but_installs() {
1215        let payload = build_zip(&[("tool.exe", b"MZ-weak")]);
1216        let url = serve_bytes(payload, "weak-1.0.0.zip").await;
1217        let ps1 = format!(
1218            "Install-ChocolateyZipPackage -PackageName 'weak' -Url '{url}' \
1219             -Checksum 'd41d8cd98f00b204e9800998ecf8427e' -ChecksumType 'md5' \
1220             -UnzipLocation \"$(Split-Path -Parent $MyInvocation.MyCommand.Definition)\"\n"
1221        );
1222        let tmp = tempfile::tempdir().unwrap();
1223        let toolchain = tmp
1224            .path()
1225            .join(format!("weak-1.0.0-{}", windows_arch_token()));
1226        tokio::fs::create_dir_all(&toolchain).await.unwrap();
1227
1228        let nupkg = build_nupkg("weak", "1.0.0", &ps1);
1229        let recipe = crate::recipe_choco::parse_nupkg(&nupkg, &toolchain).unwrap();
1230        assert!(
1231            !recipe.plan.is_fully_supported(),
1232            "weak checksum surfaces a marker"
1233        );
1234        assert!(
1235            plan_is_leaf_executable(&recipe.plan),
1236            "weak-checksum markers do NOT route to the container"
1237        );
1238        assert!(recipe.plan.resources[0].sha256.is_empty());
1239
1240        install_from_choco_recipe(&pkg("weak", "1.0.0"), recipe.plan, &toolchain, None)
1241            .await
1242            .expect("weak-checksum plan proceeds unverified");
1243        assert_eq!(
1244            tokio::fs::read(toolchain.join("tool.exe")).await.unwrap(),
1245            b"MZ-weak"
1246        );
1247        assert!(toolchain.join(".ready").is_file());
1248    }
1249
1250    /// A lone web-file stage lands the raw file in the prefix named after the
1251    /// package (never the mangled URL basename), and `Install-BinFile` copies
1252    /// it into `<prefix>/bin` carrying the extension.
1253    #[tokio::test]
1254    async fn web_file_and_bin_install_stage_into_prefix() {
1255        let body = b"MZ-jq-fake".to_vec();
1256        let sha = sha256_hex(&body);
1257        let url = serve_bytes(body, "jq-windows-amd64.exe").await;
1258        let tmp = tempfile::tempdir().unwrap();
1259        let toolchain = tmp
1260            .path()
1261            .join(format!("jq-1.8.1-{}", windows_arch_token()));
1262        tokio::fs::create_dir_all(&toolchain).await.unwrap();
1263        let prefix = toolchain.display().to_string();
1264        let ps1 = format!(
1265            "Get-ChocolateyWebFile -PackageName 'jq' -FileFullPath '{prefix}\\jq.exe' \
1266             -Url64bit '{url}' -Checksum64 '{sha}' -ChecksumType64 'sha256'\n\
1267             Install-BinFile -Name 'jq' -Path '{prefix}/jq.exe'\n"
1268        );
1269
1270        let nupkg = build_nupkg("jq", "1.8.1", &ps1);
1271        let recipe = crate::recipe_choco::parse_nupkg(&nupkg, &toolchain).unwrap();
1272        assert!(plan_is_leaf_executable(&recipe.plan));
1273
1274        install_from_choco_recipe(&pkg("jq", "1.8.1"), recipe.plan, &toolchain, None)
1275            .await
1276            .unwrap();
1277
1278        assert_eq!(
1279            tokio::fs::read(toolchain.join("jq.exe")).await.unwrap(),
1280            b"MZ-jq-fake",
1281            "web file staged as <package>.exe in the prefix root"
1282        );
1283        assert_eq!(
1284            tokio::fs::read(toolchain.join("bin/jq.exe")).await.unwrap(),
1285            b"MZ-jq-fake",
1286            "Install-BinFile copies into <prefix>/bin carrying the extension"
1287        );
1288        let manifest = ToolchainManifest::read_from_toolchain(&toolchain)
1289            .await
1290            .unwrap()
1291            .unwrap();
1292        assert_eq!(
1293            manifest.path_dirs,
1294            vec![
1295                toolchain.display().to_string(),
1296                toolchain.join("bin").display().to_string(),
1297            ],
1298            "both the prefix root and bin/ carry executables"
1299        );
1300    }
1301
1302    // -- choco chain: container fallback ------------------------------------------
1303
1304    /// An exe-installer plan (not leaf-executable) WITHOUT a registered
1305    /// executor fails with `ExecutorUnavailable` — no network, no `.ready`.
1306    #[tokio::test]
1307    async fn exe_installer_without_executor_is_executor_unavailable() {
1308        let ps1 = "$packageArgs = @{\n\
1309                     packageName = 'sample'\n\
1310                     fileType    = 'exe'\n\
1311                     url         = 'https://example.com/sample-setup.exe'\n\
1312                     silentArgs  = '/S'\n\
1313                   }\n\
1314                   Install-ChocolateyPackage @packageArgs\n";
1315        let tmp = tempfile::tempdir().unwrap();
1316        let toolchain = tmp
1317            .path()
1318            .join(format!("sample-1.0.0-{}", windows_arch_token()));
1319        tokio::fs::create_dir_all(&toolchain).await.unwrap();
1320
1321        let nupkg = build_nupkg("sample", "1.0.0", ps1);
1322        let recipe = crate::recipe_choco::parse_nupkg(&nupkg, &toolchain).unwrap();
1323        assert!(!plan_is_leaf_executable(&recipe.plan));
1324
1325        let err = install_from_choco_recipe(&pkg("sample", "1.0.0"), recipe.plan, &toolchain, None)
1326            .await
1327            .unwrap_err();
1328        assert!(
1329            matches!(err, ToolchainError::ExecutorUnavailable { ref tool } if tool == "sample"),
1330            "{err}"
1331        );
1332        assert!(
1333            !toolchain.join(".ready").exists(),
1334            "no .ready without an install"
1335        );
1336    }
1337
1338    /// With a (mock) executor, the exe-installer plan becomes ONE containerized
1339    /// `choco install` request: Windows platform, `AllowLoud` network, the choco
1340    /// argv, prefix/scratch wired — and finalization writes the manifest with
1341    /// the nupkg identity URL + empty sha, then `.ready`.
1342    #[tokio::test]
1343    async fn exe_installer_with_executor_runs_containerized_choco() {
1344        let ps1 = "Install-ChocolateyPackage -PackageName 'sample' -FileType 'exe' \
1345                   -Url 'https://example.com/sample-setup.exe' -SilentArgs '/S'\n";
1346        let tmp = tempfile::tempdir().unwrap();
1347        let toolchain = tmp
1348            .path()
1349            .join(format!("sample-2.5.0-{}", windows_arch_token()));
1350        tokio::fs::create_dir_all(&toolchain).await.unwrap();
1351
1352        let nupkg = build_nupkg("sample", "2.5.0", ps1);
1353        let recipe = crate::recipe_choco::parse_nupkg(&nupkg, &toolchain).unwrap();
1354        let exec = CapturingExecutor {
1355            seen: std::sync::Mutex::new(None),
1356        };
1357        let pkg = pkg("sample", "2.5.0");
1358        install_from_choco_recipe(&pkg, recipe.plan, &toolchain, Some(&exec))
1359            .await
1360            .expect("mock containerized choco install should succeed");
1361
1362        let req = exec
1363            .seen
1364            .lock()
1365            .unwrap()
1366            .take()
1367            .expect("executor must have been invoked");
1368        assert_eq!(req.tool, "sample");
1369        assert_eq!(req.platform, crate::ToolPlatform::Windows);
1370        assert_eq!(
1371            req.net,
1372            NetPolicy::AllowLoud,
1373            "choco fetches its own installer — network allowed LOUDLY"
1374        );
1375        assert_eq!(req.prefix, toolchain);
1376        assert_eq!(req.scratch_dir, toolchain.join(".build"));
1377        assert_eq!(
1378            req.plan.steps,
1379            vec![InstallStep::System {
1380                argv: [
1381                    "choco",
1382                    "install",
1383                    "sample",
1384                    "-y",
1385                    "--no-progress",
1386                    "--version",
1387                    "2.5.0",
1388                ]
1389                .iter()
1390                .map(ToString::to_string)
1391                .collect(),
1392            }]
1393        );
1394
1395        let manifest = ToolchainManifest::read_from_toolchain(&toolchain)
1396            .await
1397            .unwrap()
1398            .expect("manifest written after the container run");
1399        assert_eq!(manifest.tool, "sample");
1400        assert_eq!(manifest.version, "2.5.0");
1401        assert_eq!(
1402            manifest.source,
1403            ToolchainSource::Prebuilt {
1404                url: pkg.nupkg_url.clone(),
1405                sha256: String::new(),
1406            },
1407            "container path records the choco pkg identity URL, empty sha"
1408        );
1409        assert!(toolchain.join(".ready").is_file());
1410    }
1411
1412    // -- choco chain: lockfile pin -------------------------------------------------
1413
1414    /// The lockfile pin round-trips through the FULL `ensure_windows_toolchain`
1415    /// chain offline: the pinned `(version, nupkg url, sha256)` is consumed
1416    /// (no live index resolve), the nupkg download is digest-verified, the
1417    /// leaf plan installs, and a second call short-circuits on `.ready`.
1418    #[tokio::test]
1419    async fn locked_choco_pin_round_trips_through_ensure() {
1420        let payload = build_zip(&[("pinned.exe", b"MZ-pinned")]);
1421        let payload_sha = sha256_hex(&payload);
1422        let res_url = serve_bytes(payload, "pinned-2.0.0.zip").await;
1423        let ps1 = format!(
1424            "Install-ChocolateyZipPackage -PackageName 'pinned' -Url64bit '{res_url}' \
1425             -Checksum64 '{payload_sha}' -ChecksumType64 'sha256' \
1426             -UnzipLocation \"$(Split-Path -Parent $MyInvocation.MyCommand.Definition)\"\n"
1427        );
1428        let nupkg = build_nupkg("pinned", "2.0.0", &ps1);
1429        let nupkg_sha = sha256_hex(&nupkg);
1430        let nupkg_url = serve_bytes(nupkg, "pinned.2.0.0.nupkg").await;
1431
1432        let mut lock = ToolchainLockfile::new();
1433        lock.upsert(LockedTool {
1434            tool: "pinned".to_string(),
1435            platform: "windows".to_string(),
1436            arch: windows_arch_token().to_string(),
1437            version: "2.0.0".to_string(),
1438            url: nupkg_url.clone(),
1439            sha256: nupkg_sha.clone(),
1440            resolved_at: "2026-07-06T00:00:00Z".to_string(),
1441        });
1442
1443        let tmp = tempfile::tempdir().unwrap();
1444        let toolchain = ensure_windows_toolchain("pinned", tmp.path(), Some(&lock))
1445            .await
1446            .expect("lock-pinned choco toolchain should provision offline");
1447        assert_eq!(
1448            toolchain,
1449            tmp.path()
1450                .join(format!("pinned-2.0.0-{}", windows_arch_token())),
1451            "cache key uses the LOCKED version"
1452        );
1453        assert_eq!(
1454            tokio::fs::read(toolchain.join("pinned.exe")).await.unwrap(),
1455            b"MZ-pinned"
1456        );
1457        let manifest = ToolchainManifest::read_from_toolchain(&toolchain)
1458            .await
1459            .unwrap()
1460            .unwrap();
1461        assert_eq!(
1462            manifest.source,
1463            ToolchainSource::Prebuilt {
1464                url: nupkg_url,
1465                sha256: nupkg_sha,
1466            },
1467            "lock-verified nupkg records the verified digest"
1468        );
1469
1470        // Idempotent: the second call short-circuits on `.ready`.
1471        let again = ensure_windows_toolchain("pinned", tmp.path(), Some(&lock))
1472            .await
1473            .unwrap();
1474        assert_eq!(again, toolchain);
1475    }
1476
1477    /// A lock-pinned nupkg whose bytes do NOT match the pinned digest is
1478    /// rejected (`DigestMismatch`) — the pin is consume-only and enforced.
1479    #[tokio::test]
1480    async fn locked_choco_pin_rejects_digest_mismatch() {
1481        let nupkg = build_nupkg("evil", "1.0.0", "Write-Host hi\n");
1482        let nupkg_url = serve_bytes(nupkg, "evil.1.0.0.nupkg").await;
1483
1484        let mut lock = ToolchainLockfile::new();
1485        lock.upsert(LockedTool {
1486            tool: "evil".to_string(),
1487            platform: "windows".to_string(),
1488            arch: windows_arch_token().to_string(),
1489            version: "1.0.0".to_string(),
1490            url: nupkg_url,
1491            sha256: "a".repeat(64),
1492            resolved_at: "2026-07-06T00:00:00Z".to_string(),
1493        });
1494
1495        let tmp = tempfile::tempdir().unwrap();
1496        let err = ensure_windows_toolchain("evil", tmp.path(), Some(&lock))
1497            .await
1498            .unwrap_err();
1499        assert!(
1500            matches!(err, ToolchainError::DigestMismatch { .. }),
1501            "{err}"
1502        );
1503    }
1504}