use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
use zlayer_types::ImageReference;
use crate::error::{BuildError, Result};
const ZLAYER_REGISTRY: &str = "ghcr.io/blackleafdigital/zlayer";
#[must_use]
pub fn rewrite_image_for_windows(image_ref: &str, ltsc: &str) -> Option<String> {
if image_ref.starts_with(ZLAYER_REGISTRY) {
return None;
}
let stripped = strip_registry_prefix_for_windows(image_ref);
let (name, tag) = match ImageReference::from_str(&stripped) {
Ok(r) => (
r.repository().to_string(),
r.tag().unwrap_or("latest").to_string(),
),
Err(_) => (stripped.clone(), "latest".to_string()),
};
let base_name = name.rsplit('/').next().unwrap_or(&name);
if is_base_distro_for_windows(base_name) {
return Some(format!("{ZLAYER_REGISTRY}/base:windows-{ltsc}"));
}
let canonical = match base_name {
"golang" | "go" => "golang",
"node" => "node",
"rust" => "rust",
"python" | "python3" => "python",
"deno" => "deno",
"bun" => "bun",
_ => return None,
};
let version = extract_version_from_tag_for_windows(&tag);
Some(format!(
"{ZLAYER_REGISTRY}/{canonical}:{version}-windows-{ltsc}"
))
}
fn is_base_distro_for_windows(name: &str) -> bool {
matches!(
name,
"ubuntu"
| "debian"
| "alpine"
| "centos"
| "fedora"
| "rockylinux"
| "almalinux"
| "archlinux"
| "amazonlinux"
| "busybox"
)
}
fn strip_registry_prefix_for_windows(image_ref: &str) -> String {
let prefixes = [
"docker.io/library/",
"docker.io/",
"index.docker.io/library/",
"index.docker.io/",
];
for prefix in &prefixes {
if let Some(rest) = image_ref.strip_prefix(prefix) {
return rest.to_string();
}
}
image_ref.to_string()
}
fn extract_version_from_tag_for_windows(tag: &str) -> String {
if tag == "latest" {
return "latest".to_string();
}
let version_part: String = tag
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
if version_part.is_empty() {
"latest".to_string()
} else {
version_part.trim_end_matches('.').to_string()
}
}
const REPO_SOURCES_CHOCO_BASE: &str = "https://zachhandley.github.io/RepoSources/maps/choco";
const PACKAGE_MAP_CACHE_SUBDIR: &str = "package-maps-choco-v1";
const PACKAGE_MAP_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
const REPOSYNC_HMAC_SECRET: Option<&str> = option_env!("ZLAYER_REPOSYNC_HMAC_SECRET");
const REPOSYNC_HINT_ENDPOINT: &str = "https://reposync.blackleafdigital.com/choco-hint";
const SKIP_SENTINEL: &str = "__skip__";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChocoMapShard {
pub metadata: ChocoMapMetadata,
pub mappings: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChocoMapMetadata {
pub generated_at: String,
pub source: String,
pub distro: String,
pub shard: String,
pub total_mappings: u64,
}
pub async fn resolve_chocolatey_package(
linux_pkg: &str,
source_distro: &str,
) -> Result<Option<String>> {
let cache_dir = resolve_cache_dir()?;
resolve_chocolatey_package_with_cache(linux_pkg, source_distro, &cache_dir).await
}
pub async fn resolve_chocolatey_packages(
linux_pkgs: &[String],
source_distro: &str,
) -> Result<Vec<(String, Option<String>, bool)>> {
let cache_dir = resolve_cache_dir()?;
let distro_cache_dir = cache_dir.join(PACKAGE_MAP_CACHE_SUBDIR).join(source_distro);
let mut shard_cache: HashMap<&'static str, HashMap<String, String>> = HashMap::new();
for pkg in linux_pkgs {
let shard = shard_key(pkg);
if shard_cache.contains_key(shard) {
continue;
}
match fetch_or_load_shard(source_distro, &distro_cache_dir, shard).await {
Ok(map) => {
shard_cache.insert(shard, map);
}
Err(e) => {
debug!(
"shard {source_distro}/{shard} unavailable during bulk resolve: {e}; \
packages mapping to that shard will be marked unresolved"
);
shard_cache.insert(shard, HashMap::new());
}
}
}
let mut out = Vec::with_capacity(linux_pkgs.len());
for pkg in linux_pkgs {
let shard = shard_key(pkg);
let shard_map = shard_cache.get(shard);
match shard_map.and_then(|m| m.get(pkg)) {
Some(val) if val == SKIP_SENTINEL => {
out.push((pkg.clone(), None, true));
}
Some(val) => {
out.push((pkg.clone(), Some(val.clone()), false));
}
None => {
out.push((pkg.clone(), None, false));
}
}
}
Ok(out)
}
fn shard_key(name: &str) -> &'static str {
let first = name.chars().next().map(|c| c.to_ascii_lowercase());
match first {
Some(c) if c.is_ascii_lowercase() => match c {
'a' => "a",
'b' => "b",
'c' => "c",
'd' => "d",
'e' => "e",
'f' => "f",
'g' => "g",
'h' => "h",
'i' => "i",
'j' => "j",
'k' => "k",
'l' => "l",
'm' => "m",
'n' => "n",
'o' => "o",
'p' => "p",
'q' => "q",
'r' => "r",
's' => "s",
't' => "t",
'u' => "u",
'v' => "v",
'w' => "w",
'x' => "x",
'y' => "y",
'z' => "z",
_ => "_misc",
},
_ => "_misc",
}
}
#[cfg(test)]
#[derive(Debug, PartialEq, Eq)]
enum ShardLookup {
Found(String),
Skip,
Absent,
}
#[cfg(test)]
fn resolve_in_shard(linux_pkg: &str, shard: &ChocoMapShard) -> ShardLookup {
match shard.mappings.get(linux_pkg) {
Some(v) if v == SKIP_SENTINEL => ShardLookup::Skip,
Some(v) => ShardLookup::Found(v.clone()),
None => ShardLookup::Absent,
}
}
fn resolve_cache_dir() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os("ZLAYER_PACKAGE_MAP_CACHE_DIR") {
let p = PathBuf::from(dir);
if !p.as_os_str().is_empty() {
return Ok(p);
}
}
dirs::cache_dir().ok_or_else(|| {
BuildError::cache_error("could not determine platform cache directory (dirs::cache_dir)")
})
}
async fn resolve_chocolatey_package_with_cache(
linux_pkg: &str,
source_distro: &str,
cache_dir: &Path,
) -> Result<Option<String>> {
let distro_cache_dir = cache_dir.join(PACKAGE_MAP_CACHE_SUBDIR).join(source_distro);
let shard = shard_key(linux_pkg);
let map = fetch_or_load_shard(source_distro, &distro_cache_dir, shard).await?;
match map.get(linux_pkg) {
Some(val) if val == SKIP_SENTINEL => {
debug!("chocolatey resolver skipping linux-only package: {linux_pkg}");
Ok(None)
}
Some(val) => Ok(Some(val.clone())),
None => Err(BuildError::registry_error(format!(
"no Chocolatey mapping for '{linux_pkg}' in {source_distro}/{shard}.json"
))),
}
}
async fn fetch_or_load_shard(
distro: &str,
cache_dir: &Path,
shard: &str,
) -> Result<HashMap<String, String>> {
let cache_path = cache_dir.join(format!("{shard}.json"));
if let Ok(meta) = tokio::fs::metadata(&cache_path).await {
if let Ok(modified) = meta.modified() {
let age = modified
.elapsed()
.unwrap_or(std::time::Duration::from_secs(u64::MAX));
if age.as_secs() < PACKAGE_MAP_CACHE_TTL_SECS {
if let Some(map) = read_cached_map(&cache_path).await {
debug!(
"Using cached choco package map for {distro}/{shard} ({} mappings, age {}s)",
map.len(),
age.as_secs()
);
return Ok(map);
}
}
}
}
let url = format!("{REPO_SOURCES_CHOCO_BASE}/{distro}/{shard}.json");
debug!("Fetching choco shard from {url}");
match fetch_shard(&url).await {
Ok(shard_file) => {
info!(
"Fetched {} choco mappings for {distro}/{shard} from RepoSources",
shard_file.mappings.len()
);
if let Err(e) = write_cached_shard(cache_dir, &cache_path, &shard_file).await {
warn!("Failed to cache choco shard {distro}/{shard}: {e}");
}
fire_reposync_hint(distro, shard);
Ok(shard_file.mappings)
}
Err(e) => {
debug!("Failed to fetch choco shard {distro}/{shard}: {e}");
if let Some(map) = read_cached_map(&cache_path).await {
warn!(
"Using stale cached choco shard for {distro}/{shard} ({} mappings)",
map.len()
);
return Ok(map);
}
Err(BuildError::registry_error(format!(
"failed to fetch choco shard {distro}/{shard}.json: {e}"
)))
}
}
}
async fn fetch_shard(url: &str) -> std::result::Result<ChocoMapShard, String> {
let response = reqwest::get(url)
.await
.map_err(|e| format!("HTTP request failed: {e}"))?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()));
}
response
.json::<ChocoMapShard>()
.await
.map_err(|e| format!("JSON parse failed: {e}"))
}
async fn read_cached_map(path: &Path) -> Option<HashMap<String, String>> {
let contents = tokio::fs::read_to_string(path).await.ok()?;
let shard: ChocoMapShard = serde_json::from_str(&contents).ok()?;
Some(shard.mappings)
}
async fn write_cached_shard(
map_dir: &Path,
cache_path: &Path,
shard: &ChocoMapShard,
) -> std::result::Result<(), String> {
tokio::fs::create_dir_all(map_dir)
.await
.map_err(|e| format!("create dir: {e}"))?;
let json = serde_json::to_string_pretty(shard).map_err(|e| format!("serialize: {e}"))?;
tokio::fs::write(cache_path, json)
.await
.map_err(|e| format!("write: {e}"))
}
fn compute_reposync_signature(secret: &str, body: &[u8]) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac =
Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
mac.update(body);
let bytes = mac.finalize().into_bytes();
format!("sha256={}", hex::encode(bytes))
}
fn fire_reposync_hint(distro: &str, shard: &str) {
let Some(secret) = REPOSYNC_HMAC_SECRET.filter(|s| !s.is_empty()) else {
debug!(
"ZLAYER_REPOSYNC_HMAC_SECRET not baked into binary (or empty); skipping reposync cache warm for choco/{distro}/{shard}"
);
return;
};
let distro = distro.to_string();
let shard = shard.to_string();
tokio::spawn(async move {
let payload = format!(r#"{{"scope":"choco","distro":"{distro}","shard":"{shard}"}}"#);
let signature = compute_reposync_signature(secret, payload.as_bytes());
let _ = reqwest::Client::new()
.post(REPOSYNC_HINT_ENDPOINT)
.header("x-reposync-signature", signature)
.header("content-type", "application/json")
.body(payload)
.send()
.await;
});
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE_SHARD: &str = r#"{
"metadata": {
"generated_at": "2026-05-21T00:00:00Z",
"source": "chocolatey.org",
"distro": "debian-12",
"shard": "c",
"total_mappings": 2
},
"mappings": {
"curl": "curl",
"linux-headers-generic": "__skip__"
}
}"#;
#[test]
fn shard_key_alpha() {
assert_eq!(shard_key("apache2"), "a");
assert_eq!(shard_key("curl"), "c");
assert_eq!(shard_key("Zoo"), "z");
}
#[test]
fn shard_key_non_alpha() {
assert_eq!(shard_key("7zip"), "_misc");
assert_eq!(shard_key("_internal"), "_misc");
assert_eq!(shard_key(""), "_misc");
}
#[test]
fn parse_shard_json() {
let shard: ChocoMapShard =
serde_json::from_str(FIXTURE_SHARD).expect("fixture parses cleanly");
assert_eq!(shard.metadata.distro, "debian-12");
assert_eq!(shard.metadata.shard, "c");
assert_eq!(shard.metadata.total_mappings, 2);
assert_eq!(
resolve_in_shard("curl", &shard),
ShardLookup::Found("curl".to_string()),
);
assert_eq!(
resolve_in_shard("linux-headers-generic", &shard),
ShardLookup::Skip,
);
assert_eq!(
resolve_in_shard("not-in-shard", &shard),
ShardLookup::Absent,
);
}
#[tokio::test]
async fn cache_ttl_respected() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().to_path_buf();
let distro_dir = cache_dir.join(PACKAGE_MAP_CACHE_SUBDIR).join("debian-12");
tokio::fs::create_dir_all(&distro_dir).await.unwrap();
let shard_path = distro_dir.join("c.json");
tokio::fs::write(&shard_path, FIXTURE_SHARD).await.unwrap();
let fresh = resolve_chocolatey_package_with_cache("curl", "debian-12", &cache_dir)
.await
.expect("fresh cache hit should resolve");
assert_eq!(fresh.as_deref(), Some("curl"));
let eight_days_ago = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(8 * 24 * 3600))
.unwrap();
let file = std::fs::File::options()
.write(true)
.open(&shard_path)
.unwrap();
file.set_modified(eight_days_ago)
.expect("backdate mtime via File::set_modified");
drop(file);
let meta = tokio::fs::metadata(&shard_path).await.unwrap();
let modified = meta.modified().unwrap();
let age = modified.elapsed().unwrap();
assert!(
age.as_secs() >= PACKAGE_MAP_CACHE_TTL_SECS,
"expected backdated mtime to exceed TTL ({} >= {})",
age.as_secs(),
PACKAGE_MAP_CACHE_TTL_SECS
);
}
#[test]
fn rewrite_image_for_windows_skips_already_rewritten() {
assert_eq!(
rewrite_image_for_windows(
"ghcr.io/blackleafdigital/zlayer/base:windows-ltsc2022",
"ltsc2022",
),
None,
);
assert_eq!(
rewrite_image_for_windows(
"ghcr.io/blackleafdigital/zlayer/golang:1.24-windows-ltsc2025",
"ltsc2025",
),
None,
);
}
#[test]
fn rewrite_image_for_windows_ubuntu_ltsc2022() {
assert_eq!(
rewrite_image_for_windows("ubuntu:24.04", "ltsc2022"),
Some("ghcr.io/blackleafdigital/zlayer/base:windows-ltsc2022".to_string()),
);
}
#[test]
fn rewrite_image_for_windows_ubuntu_ltsc2025() {
assert_eq!(
rewrite_image_for_windows("ubuntu:24.04", "ltsc2025"),
Some("ghcr.io/blackleafdigital/zlayer/base:windows-ltsc2025".to_string()),
);
}
#[test]
fn rewrite_image_for_windows_golang_ltsc2022() {
assert_eq!(
rewrite_image_for_windows("golang:1.24", "ltsc2022"),
Some("ghcr.io/blackleafdigital/zlayer/golang:1.24-windows-ltsc2022".to_string()),
);
}
#[test]
fn rewrite_image_for_windows_node_ltsc2025() {
assert_eq!(
rewrite_image_for_windows("node:22", "ltsc2025"),
Some("ghcr.io/blackleafdigital/zlayer/node:22-windows-ltsc2025".to_string()),
);
}
#[test]
fn rewrite_image_for_windows_unknown_returns_none() {
assert_eq!(rewrite_image_for_windows("nginx:1.25", "ltsc2022"), None);
assert_eq!(rewrite_image_for_windows("redis:7", "ltsc2025"), None);
}
#[test]
fn rewrite_image_for_windows_strips_docker_io_prefix() {
assert_eq!(
rewrite_image_for_windows("docker.io/library/ubuntu:22.04", "ltsc2022"),
Some("ghcr.io/blackleafdigital/zlayer/base:windows-ltsc2022".to_string()),
);
}
#[test]
fn rewrite_image_for_windows_python_alias() {
assert_eq!(
rewrite_image_for_windows("python3:3.12", "ltsc2022"),
Some("ghcr.io/blackleafdigital/zlayer/python:3.12-windows-ltsc2022".to_string()),
);
}
#[test]
fn rewrite_image_for_windows_no_tag_defaults_to_latest() {
assert_eq!(
rewrite_image_for_windows("bun", "ltsc2025"),
Some("ghcr.io/blackleafdigital/zlayer/bun:latest-windows-ltsc2025".to_string()),
);
}
#[tokio::test]
#[ignore = "live network: hits zachhandley.github.io"]
async fn live_resolve_curl_debian12() {
let result = resolve_chocolatey_package("curl", "debian-12").await;
let resolved = result.expect("live network resolve should succeed");
assert!(
resolved.is_some(),
"curl should resolve to some chocolatey package, got None (__skip__)"
);
}
}