Skip to main content

zlayer_builder/
windows_image_resolver.rs

1//! Windows (Chocolatey) package resolver — counterpart to `macos_image_resolver`.
2//!
3//! On Windows containers (WCOW), Linux package manager invocations
4//! (`apt-get install`, `dnf install`, `apk add`) inside a Dockerfile cannot
5//! run natively. This module resolves Linux package names against the
6//! `RepoSources` Chocolatey shard files published at
7//! `https://zachhandley.github.io/RepoSources/maps/choco/<distro>/<shard>.json`
8//! and returns the equivalent Chocolatey package name so the builder can
9//! emit `choco install <pkg>` lines in the WCOW lowered Dockerfile.
10//!
11//! The shard layout, on-disk cache, and HMAC cache-warming hint mirror the
12//! macOS resolver in `macos_image_resolver.rs`. The Windows resolver lives in
13//! a separate cache subdirectory (`package-maps-choco-v1`) so its TTL/version
14//! is independent from the macOS resolver.
15
16use 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
27/// Registry namespace for prebuilt `ZLayer` Windows base + toolchain images.
28///
29/// Mirrors `ZLAYER_REGISTRY` in `macos_image_resolver.rs`. Used by
30/// [`rewrite_image_for_windows`] to redirect generic Docker Hub references
31/// (e.g. `golang:1.24`, `ubuntu:24.04`) to the equivalent Windows-native
32/// prebuilts under this namespace.
33const ZLAYER_REGISTRY: &str = "ghcr.io/blackleafdigital/zlayer";
34
35/// Rewrite a generic image reference to the equivalent prebuilt `ZLayer`
36/// Windows image under [`ZLAYER_REGISTRY`], parameterized by an LTSC line.
37///
38/// Counterpart to `macos_image_resolver::prebuilt_cache_ref`. Linux
39/// container images cannot run on Windows containers directly, so the
40/// builder rewrites known toolchain / base-distro references to prebuilt
41/// `nanoserver`-based images tagged with the requested LTSC line
42/// (`ltsc2022` or `ltsc2025`).
43///
44/// Returns `None` if:
45/// * The reference is already inside [`ZLAYER_REGISTRY`] (already rewritten).
46/// * The repository name doesn't map to a known base distro or toolchain.
47///
48/// # Examples
49///
50/// ```ignore
51/// assert_eq!(
52///     rewrite_image_for_windows("ubuntu:24.04", "ltsc2022"),
53///     Some("ghcr.io/blackleafdigital/zlayer/base:windows-ltsc2022".to_string()),
54/// );
55/// assert_eq!(
56///     rewrite_image_for_windows("golang:1.24", "ltsc2025"),
57///     Some("ghcr.io/blackleafdigital/zlayer/golang:1.24-windows-ltsc2025".to_string()),
58/// );
59/// ```
60#[must_use]
61pub fn rewrite_image_for_windows(image_ref: &str, ltsc: &str) -> Option<String> {
62    // Don't double-rewrite images already in our registry.
63    if image_ref.starts_with(ZLAYER_REGISTRY) {
64        return None;
65    }
66
67    // Strip the registry prefix (docker.io/library/, etc.).
68    let stripped = strip_registry_prefix_for_windows(image_ref);
69
70    // Split into name and tag using the canonical OCI parser (handles
71    // host:port, digests, and missing tags correctly).
72    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    // Base distro images → base:windows-<ltsc>
82    if is_base_distro_for_windows(base_name) {
83        return Some(format!("{ZLAYER_REGISTRY}/base:windows-{ltsc}"));
84    }
85
86    // Toolchain images → {zlayer_registry}/{canonical}:{version}-windows-<ltsc>
87    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
103/// Check whether the given base image name is a Linux distribution / base image
104/// that has a prebuilt Windows counterpart at
105/// `{ZLAYER_REGISTRY}/base:windows-<ltsc>`.
106fn 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
122/// Strip common registry prefixes from an image reference.
123///
124/// Local copy of the macOS resolver's helper so this resolver compiles on
125/// all platforms (the macOS one is `#[cfg(target_os = "macos")]`).
126fn 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
141/// Extract a version-like prefix from an image tag (e.g. `"20-slim"` → `"20"`).
142///
143/// Local copy of `macos_toolchain::extract_version_from_tag` so this
144/// resolver compiles on all platforms.
145fn extract_version_from_tag_for_windows(tag: &str) -> String {
146    if tag == "latest" {
147        return "latest".to_string();
148    }
149
150    // Try to extract a version-like prefix (digits and dots).
151    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
163/// Base URL for Chocolatey package maps on the `RepoSources` `GitHub Pages` site.
164const REPO_SOURCES_CHOCO_BASE: &str = "https://zachhandley.github.io/RepoSources/maps/choco";
165
166/// Subdirectory (under the platform cache dir) where Chocolatey shard files
167/// are stored. Distinct from the macOS Homebrew subdir so the two caches
168/// never collide.
169const PACKAGE_MAP_CACHE_SUBDIR: &str = "package-maps-choco-v1";
170
171/// How long a cached Chocolatey shard is considered fresh (7 days).
172const PACKAGE_MAP_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
173
174/// HMAC secret compiled into the binary at build time.
175///
176/// Set at `cargo build` time via `ZLAYER_REPOSYNC_HMAC_SECRET=<hex>`. If unset
177/// at build time, this evaluates to `None` and `fire_reposync_hint` silently
178/// skips the POST — useful for dev builds where the cache-warm signal isn't
179/// needed. Released binaries (built by CI with the Forgejo secret in scope)
180/// carry the value forever. The signing *algorithm* is not re-derived here —
181/// it lives once in [`zlayer_toolchain::package_index::sign`], and the hint
182/// endpoint is derived from [`PackageIndexConfig`].
183const REPOSYNC_HMAC_SECRET: Option<&str> = option_env!("ZLAYER_REPOSYNC_HMAC_SECRET");
184
185/// Base of the `RepoSourceSyncer` (soon `ZPackageIndex` — same API) discovery
186/// endpoint. `GET {base}/{name}?discover=true` returns a relocatable-artifact
187/// descriptor (forge release / portable archive) when one is known for the
188/// requested package. Mirrors the `?discover=true` path used by the macOS
189/// resolver against `/formula/{name}`.
190const REPOSYNC_DISCOVER_BASE: &str = "https://packages.zlayer.dev/choco";
191
192/// Sentinel value placed in shard mappings to indicate that a Linux package
193/// has no Chocolatey equivalent because it is Linux-only (e.g.
194/// `linux-headers-generic`, `systemd`). The builder should silently skip
195/// these.
196const SKIP_SENTINEL: &str = "__skip__";
197
198// ---------------------------------------------------------------------------
199// Shard file types
200// ---------------------------------------------------------------------------
201
202/// JSON shape of a Chocolatey package-map shard file as published by
203/// `RepoSources`.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct ChocoMapShard {
206    /// Header describing how/when the shard was generated.
207    pub metadata: ChocoMapMetadata,
208    /// Linux-package-name → Chocolatey-package-name (or `__skip__` sentinel).
209    pub mappings: HashMap<String, String>,
210}
211
212/// Metadata header inside a Chocolatey shard JSON file.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct ChocoMapMetadata {
215    /// ISO 8601 timestamp the shard was generated at.
216    pub generated_at: String,
217    /// Source description (e.g. `"chocolatey.org"`).
218    pub source: String,
219    /// Distro the shard maps from (e.g. `"debian-12"`).
220    pub distro: String,
221    /// Shard key (`a`..`z` or `_misc`).
222    pub shard: String,
223    /// Number of mappings in the shard.
224    pub total_mappings: u64,
225}
226
227// ---------------------------------------------------------------------------
228// Public API
229// ---------------------------------------------------------------------------
230
231/// Resolve a single Linux package name to its Chocolatey equivalent.
232///
233/// * `Ok(Some(choco_pkg))` — the package resolved to a Chocolatey package.
234/// * `Ok(None)` — the mapping exists but is marked `__skip__`, meaning the
235///   package is Linux-only and the builder should silently omit it.
236/// * `Err(_)` — network, cache, or parse failure, AND there's no stale cache
237///   to fall back on.
238///
239/// A "miss" — i.e. the shard fetched cleanly but the package name is absent —
240/// also returns `Err(BuildError::RegistryError { .. })` so the caller can
241/// distinguish "no Chocolatey equivalent known" from "skipped on purpose".
242///
243/// # Errors
244///
245/// Returns `Err(BuildError::RegistryError)` if the shard cannot be fetched
246/// (and no stale cache is available) or if the package is absent from the
247/// fetched shard. Returns `Err(BuildError::CacheError)` if the platform
248/// cache directory cannot be determined.
249pub 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
257/// Bulk-resolve a list of Linux packages.
258///
259/// Returns one entry per input package, in the same order, as
260/// `(linux_pkg, choco_pkg_or_none, skipped)`:
261///
262/// * `(name, Some(choco), false)` — resolved.
263/// * `(name, None, true)` — mapping says `__skip__`.
264/// * `(name, None, false)` — unresolved (no mapping and no error path
265///   would still surface that as an error per shard; here we just degrade to
266///   `None` for ergonomics so a single missing package doesn't fail the
267///   whole batch). Callers that want strict resolution should inspect the
268///   `Err` returned by [`resolve_chocolatey_package`] directly.
269///
270/// All shards needed by the batch are fetched once and reused across lookups.
271///
272/// # Errors
273///
274/// Returns `Err(BuildError::CacheError)` if the platform cache directory
275/// cannot be determined. Individual shard fetch failures are tolerated:
276/// packages mapping to a failed shard are returned with `None`/`false` and
277/// a `debug!` line; this function only returns `Err` for unrecoverable
278/// setup failures.
279pub 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    // Pre-fetch every shard the batch needs so duplicate package names within
287    // a single shard don't trigger duplicate HTTP requests.
288    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// ---------------------------------------------------------------------------
328// Relocatable-artifact resolution (parity with macOS `ResolvedPackage`)
329// ---------------------------------------------------------------------------
330
331/// A resolved Windows package, after consulting both the relocatable-artifact
332/// discovery endpoint and the Chocolatey shard map.
333///
334/// Counterpart to `macos_image_resolver::ResolvedPackage`. The Windows builder
335/// prefers *relocatable* artifacts ([`DirectRelease`](Self::DirectRelease) /
336/// [`RelocatableArchive`](Self::RelocatableArchive)) — these are downloaded and
337/// extracted directly into the container rootfs layer, so the produced image
338/// needs no Chocolatey in its base. [`ChocoFallback`](Self::ChocoFallback) is
339/// only emitted when no relocatable artifact resolves; the apt→choco
340/// translation still lowers those to `choco install`.
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub enum ResolvedWindowsPackage {
343    /// A direct download from a forge release (GitHub, GitLab, Codeberg,
344    /// Forgejo) — typically a single `.exe` or a `.zip`/`.tar.*` bundle. The
345    /// builder downloads `url`, extracts (or drops, for a bare binary) into a
346    /// controlled prefix under the rootfs, and adds the bin dir to `PATH`.
347    DirectRelease {
348        /// Original Linux package name (preserved for diagnostics/PATH dir).
349        name: String,
350        /// HTTP(S) URL of the release asset.
351        url: String,
352        /// Trailing filename of `url`, used to pick the extractor.
353        asset_name: String,
354    },
355    /// A portable/relocatable archive (zip or tarball) that extracts to a
356    /// self-contained tree. Same install machinery as
357    /// [`DirectRelease`](Self::DirectRelease); kept distinct so the discovery
358    /// source ("portable:" / "archive:") is faithfully recorded.
359    RelocatableArchive {
360        /// Original Linux package name.
361        name: String,
362        /// HTTP(S) URL of the archive.
363        url: String,
364        /// Trailing filename of `url`, used to pick the extractor.
365        asset_name: String,
366    },
367    /// No relocatable artifact resolved, but the shard map has a Chocolatey
368    /// package name. The apt→choco translation keeps emitting
369    /// `choco install -y <choco_name>` for these.
370    ChocoFallback {
371        /// Original Linux package name.
372        name: String,
373        /// Chocolatey package id to install.
374        choco_name: String,
375    },
376    /// Linux-only package (`__skip__` sentinel) with no Windows equivalent —
377    /// the builder silently omits it.
378    Skip {
379        /// Original Linux package name.
380        name: String,
381    },
382}
383
384impl ResolvedWindowsPackage {
385    /// The original Linux package name this resolution is for.
386    #[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    /// `true` when this package resolved to a relocatable artifact that the
397    /// builder installs into the rootfs layer (so it must be elided from the
398    /// `choco install` line).
399    #[must_use]
400    pub fn is_relocatable(&self) -> bool {
401        matches!(
402            self,
403            Self::DirectRelease { .. } | Self::RelocatableArchive { .. }
404        )
405    }
406}
407
408/// Response from the `RepoSourceSyncer` / `ZPackageIndex` discovery endpoint.
409///
410/// Mirrors `macos_image_resolver::DiscoveryResponse`. The `source` tag
411/// disambiguates the artifact kind:
412/// * `github-release:` / `gitlab-release:` / `codeberg-release:` /
413///   `forgejo-release:` → [`ResolvedWindowsPackage::DirectRelease`]
414/// * `portable:` / `archive:` / `winget-portable:` →
415///   [`ResolvedWindowsPackage::RelocatableArchive`]
416#[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
425/// Resolve a batch of Linux package names to [`ResolvedWindowsPackage`]s.
426///
427/// For each package, resolution order is:
428/// 1. **Relocatable artifact** — `GET {discover}/{pkg}?discover=true`. A
429///    forge-release source yields [`ResolvedWindowsPackage::DirectRelease`]; a
430///    portable/archive source yields
431///    [`ResolvedWindowsPackage::RelocatableArchive`]. This is preferred so the
432///    produced image needs no Chocolatey.
433/// 2. **Chocolatey fallback** — the existing shard map. A real choco name
434///    yields [`ResolvedWindowsPackage::ChocoFallback`]; the `__skip__`
435///    sentinel yields [`ResolvedWindowsPackage::Skip`].
436/// 3. **Unresolved** — neither source knows the package: surfaced as
437///    `Err(BuildError::ChocoResolutionFailed)` by the *caller* (the translator
438///    decides whether an unresolved package is fatal). Here it is simply
439///    omitted from the result vector so callers can detect the gap by name.
440///
441/// All shards needed by the batch are fetched once via
442/// [`resolve_chocolatey_packages`] and reused. Discovery is a per-package
443/// `GET`; failures (offline, 404) degrade silently to the choco fallback.
444///
445/// # Errors
446///
447/// Returns `Err(BuildError::CacheError)` if the platform cache directory
448/// cannot be determined (the only unrecoverable setup failure). Per-package
449/// discovery/network failures degrade to the Chocolatey fallback and never
450/// abort the batch.
451pub async fn resolve_windows_packages(
452    linux_pkgs: &[String],
453    source_distro: &str,
454) -> Result<Vec<ResolvedWindowsPackage>> {
455    // Pull the choco/skip mapping for the whole batch up front (one shard
456    // fetch per letter) so the fallback path is cheap.
457    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        // 1. Prefer a relocatable artifact.
466        if let Some(reloc) = discover_relocatable_artifact(pkg).await {
467            out.push(reloc);
468            continue;
469        }
470
471        // 2. Chocolatey fallback (or skip).
472        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            // 3. Unresolved — omit; the caller surfaces the diagnostic. This is
479            //    a genuine MISS (no relocatable artifact AND no choco mapping),
480            //    so fire a cheap fire-and-forget "unfulfilled request" to
481            //    ZPackageIndex. The choco resolver maps a Debian source package
482            //    to Windows, so the source manager is `apt`; distro normalized
483            //    (`_` -> `-`, no-op when already `debian-12`).
484            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
499/// Ask the discovery endpoint whether `pkg` has a relocatable Windows
500/// artifact. Returns `None` on any failure (offline, 404, unrecognised
501/// source) so the caller can fall back to Chocolatey.
502///
503/// Setting `ZLAYER_WINDOWS_DISCOVER_DISABLE=1` short-circuits the live `GET`
504/// and always returns `None` — used by the hermetic translator unit tests
505/// (which seed offline shard fixtures and must not touch the network) and by
506/// operators who want a pure-Chocolatey lowering with no discovery round-trip.
507async 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
558// ---------------------------------------------------------------------------
559// Internals
560// ---------------------------------------------------------------------------
561
562/// Compute the shard key for a Linux package name.
563///
564/// Mirrors `macos_image_resolver::shard_key`: the lowercase first ASCII
565/// letter (`a`..`z`) if alphabetic, otherwise `_misc`. Empty strings and
566/// non-ASCII inputs also map to `_misc`.
567fn 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/// Outcome of looking up a single package inside an already-parsed shard.
604#[cfg(test)]
605#[derive(Debug, PartialEq, Eq)]
606enum ShardLookup {
607    /// Mapping resolved to a Chocolatey package name.
608    Found(String),
609    /// Mapping is marked `__skip__` (Linux-only).
610    Skip,
611    /// Package name is absent from this shard.
612    Absent,
613}
614
615/// Inspect a parsed shard for a given package name. Only used by unit tests
616/// today; production code goes through [`fetch_or_load_shard`] +
617/// [`resolve_chocolatey_package_with_cache`] which combine I/O and lookup.
618#[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
627/// Returns the platform cache directory used for storing Chocolatey shards.
628///
629/// `ZLAYER_PACKAGE_MAP_CACHE_DIR`, when set to a non-empty path, overrides the
630/// platform default on **every** OS. This is both an operator knob (relocate
631/// the package-map cache) and what the test suite uses for a hermetic cache —
632/// `dirs::cache_dir()` ignores `XDG_CACHE_HOME` on macOS and Windows, so an
633/// env override is the only portable way to redirect it.
634fn 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
646/// Single-package resolve with an explicit cache root. Used by
647/// [`resolve_chocolatey_package`] and by unit tests that need a controlled
648/// cache dir.
649async 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
670/// Fetch one shard for `<distro>/<shard>.json`, with on-disk cache + stale
671/// fallback.
672///
673/// Order of precedence:
674/// 1. Fresh local cache (mtime within TTL) — read and return.
675/// 2. Network fetch — write to cache, fire HMAC POST hint, return.
676/// 3. Stale cache fallback — read and return with a `warn!`.
677/// 4. Truly nothing available — propagate the HTTP error as `RegistryError`.
678async 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    // 1. Fresh local cache.
686    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    // 2. Network fetch.
705    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            // 3. Stale cache fallback.
723            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            // 4. Nothing available.
732            Err(BuildError::registry_error(format!(
733                "failed to fetch choco shard {distro}/{shard}.json: {e}"
734            )))
735        }
736    }
737}
738
739/// Fetch one shard JSON document from `url`.
740async 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
755/// Read a cached shard from disk and return just the mappings.
756async 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
762/// Write a `ChocoMapShard` to disk, creating the directory if needed.
763async 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
777/// Fire-and-forget cache-warm hint POST to `RepoSourceSyncer`. No-op when
778/// `ZLAYER_REPOSYNC_HMAC_SECRET` was unset at build time.
779///
780/// Reuses the single workspace HMAC signer
781/// ([`zlayer_toolchain::package_index::sign`]) and derives the endpoint from
782/// [`PackageIndexConfig`] (`{base}/choco-hint`) rather than re-deriving either.
783fn 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// ---------------------------------------------------------------------------
807// Tests
808// ---------------------------------------------------------------------------
809
810#[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        // Normal mapping.
851        assert_eq!(
852            resolve_in_shard("curl", &shard),
853            ShardLookup::Found("curl".to_string()),
854        );
855
856        // __skip__ sentinel.
857        assert_eq!(
858            resolve_in_shard("linux-headers-generic", &shard),
859            ShardLookup::Skip,
860        );
861
862        // Absent from shard.
863        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        // Sanity check: with a fresh mtime, resolver reads from cache (no network).
879        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        // Rewind mtime to 8 days ago — the loader must treat it as expired
885        // and fall through to the network-fetch path. We then verify that
886        // the same TTL predicate the production code uses (mtime elapsed >=
887        // PACKAGE_MAP_CACHE_TTL_SECS) reports the file as stale.
888        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        // With discovery disabled and an offline shard fixture, every
1025        // package must resolve through the Chocolatey fallback path —
1026        // no network for either discovery or shard fetch.
1027        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        // `linux-headers-generic` shards to `l`, so it needs its own shard
1037        // file carrying the `__skip__` sentinel.
1038        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}