1use 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
56const WINDOWS_OS_TOKEN: &str = "windows";
58
59#[derive(Debug)]
63enum WinBuildKind {
64 Hermetic(RelocationReport),
67 NetFallback(RelocationReport),
70}
71
72const GIT_FOR_WINDOWS_LATEST: &str =
74 "https://api.github.com/repos/git-for-windows/git/releases/latest";
75
76const MINGIT_FALLBACK_VERSION: &str = "2.55.0";
81const MINGIT_FALLBACK_TAG: &str = "v2.55.0.windows.1";
83
84#[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
95fn 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
104pub 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
129pub(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
147struct ChocoPkg {
154 tool: String,
156 version: String,
158 nupkg_url: String,
160 verified_sha256: String,
164}
165
166async 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
196async 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 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 if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
226 return Ok(toolchain);
227 }
228
229 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 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
257async 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 let _ = tokio::fs::remove_dir_all(toolchain).await;
270 tokio::fs::create_dir_all(toolchain).await?;
271
272 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
298async 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 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
348fn is_weak_checksum_marker(construct: &str) -> bool {
354 construct.starts_with("no checksum for ") || construct.starts_with("checksumType ")
355}
356
357fn 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
372async 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 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
404async 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; } 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 }
450 other => {
451 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
463async 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
474async 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
488async 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
515async 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
542fn 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 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
582async 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
599async 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
622async 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#[derive(Debug, Clone, Deserialize)]
664struct GhAsset {
665 name: String,
666 #[serde(default)]
667 browser_download_url: String,
668}
669
670#[derive(Debug, Clone, Deserialize)]
672struct GhRelease {
673 #[serde(default)]
674 tag_name: String,
675 #[serde(default)]
676 assets: Vec<GhAsset>,
677}
678
679fn 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
688fn 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
698fn 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
707fn 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
716async 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 let sha = mingit_sibling_sha256(&rel.assets, &asset.name).await;
726 return (version, asset.browser_download_url.clone(), sha);
727 }
728 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
741async 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
759async 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
785pub async fn ensure_mingit(
795 cache_dir: &Path,
796 lockfile: Option<&crate::ToolchainLockfile>,
797) -> Result<PathBuf> {
798 let arch = windows_arch_token();
799
800 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 if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
817 return Ok(toolchain);
818 }
819
820 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 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
845async fn install_mingit_fresh(
850 toolchain: &Path,
851 version: &str,
852 url: &str,
853 expected_sha: Option<&str>,
854) -> Result<RelocationReport> {
855 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 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 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
894fn 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 let Some(rel) = file.enclosed_name() else {
912 continue; };
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 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 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 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 fn sha256_hex(bytes: &[u8]) -> String {
978 use sha2::{Digest, Sha256};
979 hex::encode(Sha256::digest(bytes))
980 }
981
982 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; 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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 let again = ensure_windows_toolchain("pinned", tmp.path(), Some(&lock))
1472 .await
1473 .unwrap();
1474 assert_eq!(again, toolchain);
1475 }
1476
1477 #[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}