1use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::str::FromStr;
19
20use serde::{Deserialize, Serialize};
21use tracing::{debug, info, warn};
22use zlayer_types::package_index::PackageIndexConfig;
23use zlayer_types::ImageReference;
24
25use crate::error::{BuildError, Result};
26
27const ZLAYER_REGISTRY: &str = "ghcr.io/blackleafdigital/zlayer";
34
35#[must_use]
61pub fn rewrite_image_for_windows(image_ref: &str, ltsc: &str) -> Option<String> {
62 if image_ref.starts_with(ZLAYER_REGISTRY) {
64 return None;
65 }
66
67 let stripped = strip_registry_prefix_for_windows(image_ref);
69
70 let (name, tag) = match ImageReference::from_str(&stripped) {
73 Ok(r) => (
74 r.repository().to_string(),
75 r.tag().unwrap_or("latest").to_string(),
76 ),
77 Err(_) => (stripped.clone(), "latest".to_string()),
78 };
79 let base_name = name.rsplit('/').next().unwrap_or(&name);
80
81 if is_base_distro_for_windows(base_name) {
83 return Some(format!("{ZLAYER_REGISTRY}/base:windows-{ltsc}"));
84 }
85
86 let canonical = match base_name {
88 "golang" | "go" => "golang",
89 "node" => "node",
90 "rust" => "rust",
91 "python" | "python3" => "python",
92 "deno" => "deno",
93 "bun" => "bun",
94 _ => return None,
95 };
96
97 let version = extract_version_from_tag_for_windows(&tag);
98 Some(format!(
99 "{ZLAYER_REGISTRY}/{canonical}:{version}-windows-{ltsc}"
100 ))
101}
102
103fn is_base_distro_for_windows(name: &str) -> bool {
107 matches!(
108 name,
109 "ubuntu"
110 | "debian"
111 | "alpine"
112 | "centos"
113 | "fedora"
114 | "rockylinux"
115 | "almalinux"
116 | "archlinux"
117 | "amazonlinux"
118 | "busybox"
119 )
120}
121
122fn strip_registry_prefix_for_windows(image_ref: &str) -> String {
127 let prefixes = [
128 "docker.io/library/",
129 "docker.io/",
130 "index.docker.io/library/",
131 "index.docker.io/",
132 ];
133 for prefix in &prefixes {
134 if let Some(rest) = image_ref.strip_prefix(prefix) {
135 return rest.to_string();
136 }
137 }
138 image_ref.to_string()
139}
140
141fn extract_version_from_tag_for_windows(tag: &str) -> String {
146 if tag == "latest" {
147 return "latest".to_string();
148 }
149
150 let version_part: String = tag
152 .chars()
153 .take_while(|c| c.is_ascii_digit() || *c == '.')
154 .collect();
155
156 if version_part.is_empty() {
157 "latest".to_string()
158 } else {
159 version_part.trim_end_matches('.').to_string()
160 }
161}
162
163const REPO_SOURCES_CHOCO_BASE: &str = "https://zachhandley.github.io/RepoSources/maps/choco";
165
166const PACKAGE_MAP_CACHE_SUBDIR: &str = "package-maps-choco-v1";
170
171const PACKAGE_MAP_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
173
174const REPOSYNC_HMAC_SECRET: Option<&str> = option_env!("ZLAYER_REPOSYNC_HMAC_SECRET");
184
185const REPOSYNC_DISCOVER_BASE: &str = "https://packages.zlayer.dev/choco";
191
192const SKIP_SENTINEL: &str = "__skip__";
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct ChocoMapShard {
206 pub metadata: ChocoMapMetadata,
208 pub mappings: HashMap<String, String>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct ChocoMapMetadata {
215 pub generated_at: String,
217 pub source: String,
219 pub distro: String,
221 pub shard: String,
223 pub total_mappings: u64,
225}
226
227pub async fn resolve_chocolatey_package(
250 linux_pkg: &str,
251 source_distro: &str,
252) -> Result<Option<String>> {
253 let cache_dir = resolve_cache_dir()?;
254 resolve_chocolatey_package_with_cache(linux_pkg, source_distro, &cache_dir).await
255}
256
257pub async fn resolve_chocolatey_packages(
280 linux_pkgs: &[String],
281 source_distro: &str,
282) -> Result<Vec<(String, Option<String>, bool)>> {
283 let cache_dir = resolve_cache_dir()?;
284 let distro_cache_dir = cache_dir.join(PACKAGE_MAP_CACHE_SUBDIR).join(source_distro);
285
286 let mut shard_cache: HashMap<&'static str, HashMap<String, String>> = HashMap::new();
289 for pkg in linux_pkgs {
290 let shard = shard_key(pkg);
291 if shard_cache.contains_key(shard) {
292 continue;
293 }
294 match fetch_or_load_shard(source_distro, &distro_cache_dir, shard).await {
295 Ok(map) => {
296 shard_cache.insert(shard, map);
297 }
298 Err(e) => {
299 debug!(
300 "shard {source_distro}/{shard} unavailable during bulk resolve: {e}; \
301 packages mapping to that shard will be marked unresolved"
302 );
303 shard_cache.insert(shard, HashMap::new());
304 }
305 }
306 }
307
308 let mut out = Vec::with_capacity(linux_pkgs.len());
309 for pkg in linux_pkgs {
310 let shard = shard_key(pkg);
311 let shard_map = shard_cache.get(shard);
312 match shard_map.and_then(|m| m.get(pkg)) {
313 Some(val) if val == SKIP_SENTINEL => {
314 out.push((pkg.clone(), None, true));
315 }
316 Some(val) => {
317 out.push((pkg.clone(), Some(val.clone()), false));
318 }
319 None => {
320 out.push((pkg.clone(), None, false));
321 }
322 }
323 }
324 Ok(out)
325}
326
327#[derive(Debug, Clone, PartialEq, Eq)]
342pub enum ResolvedWindowsPackage {
343 DirectRelease {
348 name: String,
350 url: String,
352 asset_name: String,
354 },
355 RelocatableArchive {
360 name: String,
362 url: String,
364 asset_name: String,
366 },
367 ChocoFallback {
371 name: String,
373 choco_name: String,
375 },
376 Skip {
379 name: String,
381 },
382}
383
384impl ResolvedWindowsPackage {
385 #[must_use]
387 pub fn name(&self) -> &str {
388 match self {
389 Self::DirectRelease { name, .. }
390 | Self::RelocatableArchive { name, .. }
391 | Self::ChocoFallback { name, .. }
392 | Self::Skip { name } => name,
393 }
394 }
395
396 #[must_use]
400 pub fn is_relocatable(&self) -> bool {
401 matches!(
402 self,
403 Self::DirectRelease { .. } | Self::RelocatableArchive { .. }
404 )
405 }
406}
407
408#[derive(Debug, Deserialize)]
417struct WindowsDiscoveryResponse {
418 name: String,
419 #[serde(default)]
420 source: Option<String>,
421 #[serde(default)]
422 source_url: Option<String>,
423}
424
425pub async fn resolve_windows_packages(
452 linux_pkgs: &[String],
453 source_distro: &str,
454) -> Result<Vec<ResolvedWindowsPackage>> {
455 let choco = resolve_chocolatey_packages(linux_pkgs, source_distro).await?;
458 let mut choco_lookup: HashMap<&str, (Option<&str>, bool)> = HashMap::new();
459 for (linux, c, skipped) in &choco {
460 choco_lookup.insert(linux.as_str(), (c.as_deref(), *skipped));
461 }
462
463 let mut out = Vec::with_capacity(linux_pkgs.len());
464 for pkg in linux_pkgs {
465 if let Some(reloc) = discover_relocatable_artifact(pkg).await {
467 out.push(reloc);
468 continue;
469 }
470
471 match choco_lookup.get(pkg.as_str()) {
473 Some((_, true)) => out.push(ResolvedWindowsPackage::Skip { name: pkg.clone() }),
474 Some((Some(choco_name), false)) => out.push(ResolvedWindowsPackage::ChocoFallback {
475 name: pkg.clone(),
476 choco_name: (*choco_name).to_string(),
477 }),
478 Some((None, false)) | None => {
485 debug!(
486 "no relocatable artifact and no choco mapping for '{pkg}'; leaving unresolved"
487 );
488 crate::harvest::report_unfulfilled(
489 &source_distro.replacen('_', "-", 1),
490 "apt",
491 pkg,
492 );
493 }
494 }
495 }
496 Ok(out)
497}
498
499async fn discover_relocatable_artifact(pkg: &str) -> Option<ResolvedWindowsPackage> {
508 if std::env::var_os("ZLAYER_WINDOWS_DISCOVER_DISABLE").is_some_and(|v| !v.is_empty()) {
509 debug!("ZLAYER_WINDOWS_DISCOVER_DISABLE set; skipping relocatable discovery for {pkg}");
510 return None;
511 }
512 let url = format!("{REPOSYNC_DISCOVER_BASE}/{pkg}?discover=true");
513 let resp = reqwest::get(&url).await.ok()?;
514 if !resp.status().is_success() {
515 return None;
516 }
517 let text = resp.text().await.ok()?;
518 let discovery: WindowsDiscoveryResponse = serde_json::from_str(&text).ok()?;
519 let source = discovery.source.as_deref()?;
520 let src_url = discovery.source_url.clone().unwrap_or_default();
521 if src_url.is_empty() {
522 return None;
523 }
524 let asset_name = src_url.rsplit('/').next().unwrap_or(pkg).to_string();
525 let name = if discovery.name.is_empty() {
526 pkg.to_string()
527 } else {
528 discovery.name.clone()
529 };
530
531 if source.starts_with("github-release:")
532 || source.starts_with("gitlab-release:")
533 || source.starts_with("codeberg-release:")
534 || source.starts_with("forgejo-release:")
535 {
536 info!("Discovered {pkg} as a direct forge release for Windows ({source})");
537 return Some(ResolvedWindowsPackage::DirectRelease {
538 name,
539 url: src_url,
540 asset_name,
541 });
542 }
543 if source.starts_with("portable:")
544 || source.starts_with("archive:")
545 || source.starts_with("winget-portable:")
546 {
547 info!("Discovered {pkg} as a relocatable archive for Windows ({source})");
548 return Some(ResolvedWindowsPackage::RelocatableArchive {
549 name,
550 url: src_url,
551 asset_name,
552 });
553 }
554 debug!("discovery for {pkg} returned unrecognised source '{source}'; falling back to choco");
555 None
556}
557
558fn shard_key(name: &str) -> &'static str {
568 let first = name.chars().next().map(|c| c.to_ascii_lowercase());
569 match first {
570 Some(c) if c.is_ascii_lowercase() => match c {
571 'a' => "a",
572 'b' => "b",
573 'c' => "c",
574 'd' => "d",
575 'e' => "e",
576 'f' => "f",
577 'g' => "g",
578 'h' => "h",
579 'i' => "i",
580 'j' => "j",
581 'k' => "k",
582 'l' => "l",
583 'm' => "m",
584 'n' => "n",
585 'o' => "o",
586 'p' => "p",
587 'q' => "q",
588 'r' => "r",
589 's' => "s",
590 't' => "t",
591 'u' => "u",
592 'v' => "v",
593 'w' => "w",
594 'x' => "x",
595 'y' => "y",
596 'z' => "z",
597 _ => "_misc",
598 },
599 _ => "_misc",
600 }
601}
602
603#[cfg(test)]
605#[derive(Debug, PartialEq, Eq)]
606enum ShardLookup {
607 Found(String),
609 Skip,
611 Absent,
613}
614
615#[cfg(test)]
619fn resolve_in_shard(linux_pkg: &str, shard: &ChocoMapShard) -> ShardLookup {
620 match shard.mappings.get(linux_pkg) {
621 Some(v) if v == SKIP_SENTINEL => ShardLookup::Skip,
622 Some(v) => ShardLookup::Found(v.clone()),
623 None => ShardLookup::Absent,
624 }
625}
626
627fn resolve_cache_dir() -> Result<PathBuf> {
635 if let Some(dir) = std::env::var_os("ZLAYER_PACKAGE_MAP_CACHE_DIR") {
636 let p = PathBuf::from(dir);
637 if !p.as_os_str().is_empty() {
638 return Ok(p);
639 }
640 }
641 dirs::cache_dir().ok_or_else(|| {
642 BuildError::cache_error("could not determine platform cache directory (dirs::cache_dir)")
643 })
644}
645
646async fn resolve_chocolatey_package_with_cache(
650 linux_pkg: &str,
651 source_distro: &str,
652 cache_dir: &Path,
653) -> Result<Option<String>> {
654 let distro_cache_dir = cache_dir.join(PACKAGE_MAP_CACHE_SUBDIR).join(source_distro);
655 let shard = shard_key(linux_pkg);
656 let map = fetch_or_load_shard(source_distro, &distro_cache_dir, shard).await?;
657
658 match map.get(linux_pkg) {
659 Some(val) if val == SKIP_SENTINEL => {
660 debug!("chocolatey resolver skipping linux-only package: {linux_pkg}");
661 Ok(None)
662 }
663 Some(val) => Ok(Some(val.clone())),
664 None => Err(BuildError::registry_error(format!(
665 "no Chocolatey mapping for '{linux_pkg}' in {source_distro}/{shard}.json"
666 ))),
667 }
668}
669
670async fn fetch_or_load_shard(
679 distro: &str,
680 cache_dir: &Path,
681 shard: &str,
682) -> Result<HashMap<String, String>> {
683 let cache_path = cache_dir.join(format!("{shard}.json"));
684
685 if let Ok(meta) = tokio::fs::metadata(&cache_path).await {
687 if let Ok(modified) = meta.modified() {
688 let age = modified
689 .elapsed()
690 .unwrap_or(std::time::Duration::from_secs(u64::MAX));
691 if age.as_secs() < PACKAGE_MAP_CACHE_TTL_SECS {
692 if let Some(map) = read_cached_map(&cache_path).await {
693 debug!(
694 "Using cached choco package map for {distro}/{shard} ({} mappings, age {}s)",
695 map.len(),
696 age.as_secs()
697 );
698 return Ok(map);
699 }
700 }
701 }
702 }
703
704 let url = format!("{REPO_SOURCES_CHOCO_BASE}/{distro}/{shard}.json");
706 debug!("Fetching choco shard from {url}");
707 match fetch_shard(&url).await {
708 Ok(shard_file) => {
709 info!(
710 "Fetched {} choco mappings for {distro}/{shard} from RepoSources",
711 shard_file.mappings.len()
712 );
713 if let Err(e) = write_cached_shard(cache_dir, &cache_path, &shard_file).await {
714 warn!("Failed to cache choco shard {distro}/{shard}: {e}");
715 }
716 fire_reposync_hint(distro, shard);
717 Ok(shard_file.mappings)
718 }
719 Err(e) => {
720 debug!("Failed to fetch choco shard {distro}/{shard}: {e}");
721
722 if let Some(map) = read_cached_map(&cache_path).await {
724 warn!(
725 "Using stale cached choco shard for {distro}/{shard} ({} mappings)",
726 map.len()
727 );
728 return Ok(map);
729 }
730
731 Err(BuildError::registry_error(format!(
733 "failed to fetch choco shard {distro}/{shard}.json: {e}"
734 )))
735 }
736 }
737}
738
739async fn fetch_shard(url: &str) -> std::result::Result<ChocoMapShard, String> {
741 let response = reqwest::get(url)
742 .await
743 .map_err(|e| format!("HTTP request failed: {e}"))?;
744
745 if !response.status().is_success() {
746 return Err(format!("HTTP {}", response.status()));
747 }
748
749 response
750 .json::<ChocoMapShard>()
751 .await
752 .map_err(|e| format!("JSON parse failed: {e}"))
753}
754
755async fn read_cached_map(path: &Path) -> Option<HashMap<String, String>> {
757 let contents = tokio::fs::read_to_string(path).await.ok()?;
758 let shard: ChocoMapShard = serde_json::from_str(&contents).ok()?;
759 Some(shard.mappings)
760}
761
762async fn write_cached_shard(
764 map_dir: &Path,
765 cache_path: &Path,
766 shard: &ChocoMapShard,
767) -> std::result::Result<(), String> {
768 tokio::fs::create_dir_all(map_dir)
769 .await
770 .map_err(|e| format!("create dir: {e}"))?;
771 let json = serde_json::to_string_pretty(shard).map_err(|e| format!("serialize: {e}"))?;
772 tokio::fs::write(cache_path, json)
773 .await
774 .map_err(|e| format!("write: {e}"))
775}
776
777fn fire_reposync_hint(distro: &str, shard: &str) {
784 let Some(secret) = REPOSYNC_HMAC_SECRET.filter(|s| !s.is_empty()) else {
785 debug!(
786 "ZLAYER_REPOSYNC_HMAC_SECRET not baked into binary (or empty); skipping reposync cache warm for choco/{distro}/{shard}"
787 );
788 return;
789 };
790 let distro = distro.to_string();
791 let shard = shard.to_string();
792 let endpoint = PackageIndexConfig::from_env().choco_hint_url();
793 tokio::spawn(async move {
794 let payload = format!(r#"{{"scope":"choco","distro":"{distro}","shard":"{shard}"}}"#);
795 let signature = zlayer_toolchain::package_index::sign(secret, payload.as_bytes());
796 let _ = reqwest::Client::new()
797 .post(&endpoint)
798 .header("x-reposync-signature", signature)
799 .header("content-type", "application/json")
800 .body(payload)
801 .send()
802 .await;
803 });
804}
805
806#[cfg(test)]
811mod tests {
812 use super::*;
813
814 const FIXTURE_SHARD: &str = r#"{
815 "metadata": {
816 "generated_at": "2026-05-21T00:00:00Z",
817 "source": "chocolatey.org",
818 "distro": "debian-12",
819 "shard": "c",
820 "total_mappings": 2
821 },
822 "mappings": {
823 "curl": "curl",
824 "linux-headers-generic": "__skip__"
825 }
826 }"#;
827
828 #[test]
829 fn shard_key_alpha() {
830 assert_eq!(shard_key("apache2"), "a");
831 assert_eq!(shard_key("curl"), "c");
832 assert_eq!(shard_key("Zoo"), "z");
833 }
834
835 #[test]
836 fn shard_key_non_alpha() {
837 assert_eq!(shard_key("7zip"), "_misc");
838 assert_eq!(shard_key("_internal"), "_misc");
839 assert_eq!(shard_key(""), "_misc");
840 }
841
842 #[test]
843 fn parse_shard_json() {
844 let shard: ChocoMapShard =
845 serde_json::from_str(FIXTURE_SHARD).expect("fixture parses cleanly");
846 assert_eq!(shard.metadata.distro, "debian-12");
847 assert_eq!(shard.metadata.shard, "c");
848 assert_eq!(shard.metadata.total_mappings, 2);
849
850 assert_eq!(
852 resolve_in_shard("curl", &shard),
853 ShardLookup::Found("curl".to_string()),
854 );
855
856 assert_eq!(
858 resolve_in_shard("linux-headers-generic", &shard),
859 ShardLookup::Skip,
860 );
861
862 assert_eq!(
864 resolve_in_shard("not-in-shard", &shard),
865 ShardLookup::Absent,
866 );
867 }
868
869 #[tokio::test]
870 async fn cache_ttl_respected() {
871 let tmp = tempfile::tempdir().unwrap();
872 let cache_dir = tmp.path().to_path_buf();
873 let distro_dir = cache_dir.join(PACKAGE_MAP_CACHE_SUBDIR).join("debian-12");
874 tokio::fs::create_dir_all(&distro_dir).await.unwrap();
875 let shard_path = distro_dir.join("c.json");
876 tokio::fs::write(&shard_path, FIXTURE_SHARD).await.unwrap();
877
878 let fresh = resolve_chocolatey_package_with_cache("curl", "debian-12", &cache_dir)
880 .await
881 .expect("fresh cache hit should resolve");
882 assert_eq!(fresh.as_deref(), Some("curl"));
883
884 let eight_days_ago = std::time::SystemTime::now()
889 .checked_sub(std::time::Duration::from_secs(8 * 24 * 3600))
890 .unwrap();
891 let file = std::fs::File::options()
892 .write(true)
893 .open(&shard_path)
894 .unwrap();
895 file.set_modified(eight_days_ago)
896 .expect("backdate mtime via File::set_modified");
897 drop(file);
898
899 let meta = tokio::fs::metadata(&shard_path).await.unwrap();
900 let modified = meta.modified().unwrap();
901 let age = modified.elapsed().unwrap();
902 assert!(
903 age.as_secs() >= PACKAGE_MAP_CACHE_TTL_SECS,
904 "expected backdated mtime to exceed TTL ({} >= {})",
905 age.as_secs(),
906 PACKAGE_MAP_CACHE_TTL_SECS
907 );
908 }
909
910 #[test]
911 fn rewrite_image_for_windows_skips_already_rewritten() {
912 assert_eq!(
913 rewrite_image_for_windows(
914 "ghcr.io/blackleafdigital/zlayer/base:windows-ltsc2022",
915 "ltsc2022",
916 ),
917 None,
918 );
919 assert_eq!(
920 rewrite_image_for_windows(
921 "ghcr.io/blackleafdigital/zlayer/golang:1.24-windows-ltsc2025",
922 "ltsc2025",
923 ),
924 None,
925 );
926 }
927
928 #[test]
929 fn rewrite_image_for_windows_ubuntu_ltsc2022() {
930 assert_eq!(
931 rewrite_image_for_windows("ubuntu:24.04", "ltsc2022"),
932 Some("ghcr.io/blackleafdigital/zlayer/base:windows-ltsc2022".to_string()),
933 );
934 }
935
936 #[test]
937 fn rewrite_image_for_windows_ubuntu_ltsc2025() {
938 assert_eq!(
939 rewrite_image_for_windows("ubuntu:24.04", "ltsc2025"),
940 Some("ghcr.io/blackleafdigital/zlayer/base:windows-ltsc2025".to_string()),
941 );
942 }
943
944 #[test]
945 fn rewrite_image_for_windows_golang_ltsc2022() {
946 assert_eq!(
947 rewrite_image_for_windows("golang:1.24", "ltsc2022"),
948 Some("ghcr.io/blackleafdigital/zlayer/golang:1.24-windows-ltsc2022".to_string()),
949 );
950 }
951
952 #[test]
953 fn rewrite_image_for_windows_node_ltsc2025() {
954 assert_eq!(
955 rewrite_image_for_windows("node:22", "ltsc2025"),
956 Some("ghcr.io/blackleafdigital/zlayer/node:22-windows-ltsc2025".to_string()),
957 );
958 }
959
960 #[test]
961 fn rewrite_image_for_windows_unknown_returns_none() {
962 assert_eq!(rewrite_image_for_windows("nginx:1.25", "ltsc2022"), None);
963 assert_eq!(rewrite_image_for_windows("redis:7", "ltsc2025"), None);
964 }
965
966 #[test]
967 fn rewrite_image_for_windows_strips_docker_io_prefix() {
968 assert_eq!(
969 rewrite_image_for_windows("docker.io/library/ubuntu:22.04", "ltsc2022"),
970 Some("ghcr.io/blackleafdigital/zlayer/base:windows-ltsc2022".to_string()),
971 );
972 }
973
974 #[test]
975 fn rewrite_image_for_windows_python_alias() {
976 assert_eq!(
977 rewrite_image_for_windows("python3:3.12", "ltsc2022"),
978 Some("ghcr.io/blackleafdigital/zlayer/python:3.12-windows-ltsc2022".to_string()),
979 );
980 }
981
982 #[test]
983 fn rewrite_image_for_windows_no_tag_defaults_to_latest() {
984 assert_eq!(
985 rewrite_image_for_windows("bun", "ltsc2025"),
986 Some("ghcr.io/blackleafdigital/zlayer/bun:latest-windows-ltsc2025".to_string()),
987 );
988 }
989
990 #[test]
991 fn resolved_windows_package_accessors() {
992 let dr = ResolvedWindowsPackage::DirectRelease {
993 name: "jq".into(),
994 url: "https://example.com/jq.exe".into(),
995 asset_name: "jq.exe".into(),
996 };
997 assert_eq!(dr.name(), "jq");
998 assert!(dr.is_relocatable());
999
1000 let ra = ResolvedWindowsPackage::RelocatableArchive {
1001 name: "ripgrep".into(),
1002 url: "https://example.com/rg.zip".into(),
1003 asset_name: "rg.zip".into(),
1004 };
1005 assert_eq!(ra.name(), "ripgrep");
1006 assert!(ra.is_relocatable());
1007
1008 let cf = ResolvedWindowsPackage::ChocoFallback {
1009 name: "curl".into(),
1010 choco_name: "curl".into(),
1011 };
1012 assert_eq!(cf.name(), "curl");
1013 assert!(!cf.is_relocatable());
1014
1015 let sk = ResolvedWindowsPackage::Skip {
1016 name: "linux-headers-generic".into(),
1017 };
1018 assert_eq!(sk.name(), "linux-headers-generic");
1019 assert!(!sk.is_relocatable());
1020 }
1021
1022 #[tokio::test]
1023 async fn resolve_windows_packages_falls_back_to_choco_when_discovery_disabled() {
1024 let tmp = tempfile::tempdir().unwrap();
1028 let cache_dir = tmp.path().to_path_buf();
1029 std::env::set_var("ZLAYER_PACKAGE_MAP_CACHE_DIR", &cache_dir);
1030 std::env::set_var("ZLAYER_WINDOWS_DISCOVER_DISABLE", "1");
1031 let distro_dir = cache_dir.join(PACKAGE_MAP_CACHE_SUBDIR).join("debian-12");
1032 tokio::fs::create_dir_all(&distro_dir).await.unwrap();
1033 tokio::fs::write(distro_dir.join("c.json"), FIXTURE_SHARD)
1034 .await
1035 .unwrap();
1036 let l_shard = r#"{
1039 "metadata": {
1040 "generated_at": "2026-05-21T00:00:00Z",
1041 "source": "chocolatey.org",
1042 "distro": "debian-12",
1043 "shard": "l",
1044 "total_mappings": 1
1045 },
1046 "mappings": { "linux-headers-generic": "__skip__" }
1047 }"#;
1048 tokio::fs::write(distro_dir.join("l.json"), l_shard)
1049 .await
1050 .unwrap();
1051
1052 let pkgs = vec!["curl".to_string(), "linux-headers-generic".to_string()];
1053 let resolved = resolve_windows_packages(&pkgs, "debian-12")
1054 .await
1055 .expect("resolve succeeds with offline fixture");
1056
1057 std::env::remove_var("ZLAYER_PACKAGE_MAP_CACHE_DIR");
1058 std::env::remove_var("ZLAYER_WINDOWS_DISCOVER_DISABLE");
1059
1060 assert_eq!(resolved.len(), 2);
1061 assert_eq!(
1062 resolved[0],
1063 ResolvedWindowsPackage::ChocoFallback {
1064 name: "curl".into(),
1065 choco_name: "curl".into(),
1066 }
1067 );
1068 assert_eq!(
1069 resolved[1],
1070 ResolvedWindowsPackage::Skip {
1071 name: "linux-headers-generic".into(),
1072 }
1073 );
1074 }
1075
1076 #[tokio::test]
1077 #[ignore = "live network: hits zachhandley.github.io"]
1078 async fn live_resolve_curl_debian12() {
1079 let result = resolve_chocolatey_package("curl", "debian-12").await;
1080 let resolved = result.expect("live network resolve should succeed");
1081 assert!(
1082 resolved.is_some(),
1083 "curl should resolve to some chocolatey package, got None (__skip__)"
1084 );
1085 }
1086}