use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
use zlayer_types::toolchain_lock::ToolchainLockfile;
use zlayer_types::ImageReference;
use crate::error::{BuildError, Result};
use crate::macos_toolchain::{
ensure_base_rootfs, extract_version_from_tag, provision_toolchain, ToolchainSpec,
};
use crate::sandbox_builder::SandboxImageConfig;
pub async fn install_formula_into_rootfs(
formula: &str,
rootfs_dir: &Path,
cache_dir: &Path,
lockfile: Option<&ToolchainLockfile>,
) -> Result<()> {
let handle = zlayer_toolchain::ensure_toolchain(
formula,
zlayer_toolchain::ToolPlatform::MacOS,
cache_dir,
lockfile,
)
.await?;
materialize_keg_into_rootfs(&handle.install_dir, rootfs_dir).await
}
async fn materialize_keg_into_rootfs(keg: &Path, rootfs_dir: &Path) -> Result<()> {
let dest_prefix = rootfs_dir.join("usr/local");
tokio::fs::create_dir_all(&dest_prefix).await?;
for sub in ["bin", "sbin", "lib", "libexec", "include", "share"] {
let src = keg.join(sub);
if !tokio::fs::try_exists(&src).await.unwrap_or(false) {
continue;
}
let dest = dest_prefix.join(sub);
tokio::fs::create_dir_all(&dest).await?;
let status = tokio::process::Command::new("cp")
.arg("-R")
.arg(format!("{}/.", src.display()))
.arg(&dest)
.status()
.await?;
if !status.success() {
return Err(BuildError::IoError(std::io::Error::other(format!(
"failed to copy keg subtree {} into rootfs at {}",
src.display(),
dest.display()
))));
}
}
Ok(())
}
const ZLAYER_REGISTRY: &str = "ghcr.io/blackleafdigital/zlayer";
const REPO_SOURCES_BASE: &str = "https://zachhandley.github.io/RepoSources/maps";
const PACKAGE_MAP_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
const PACKAGE_MAP_CACHE_SUBDIR: &str = "package-maps-v3";
#[derive(Debug, Deserialize, Serialize)]
struct PackageMapFile {
metadata: PackageMapMetadata,
mappings: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct PackageMapMetadata {
generated_at: String,
source: String,
distro: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
shard: Option<String>,
total_mappings: usize,
}
#[derive(Debug, Clone)]
pub enum MacBaseKind {
Toolchain(ToolchainSpec),
BaseDistro,
}
#[must_use]
pub fn classify_macos_base(image_ref: &str) -> Option<MacBaseKind> {
if image_ref.starts_with(ZLAYER_REGISTRY) {
return None;
}
if let Some(spec) = crate::macos_toolchain::detect_toolchain(image_ref) {
return Some(MacBaseKind::Toolchain(spec));
}
let stripped = strip_registry_prefix(image_ref);
let name = match ImageReference::from_str(&stripped) {
Ok(r) => r.repository().to_string(),
Err(_) => stripped.clone(),
};
let base_name = name.rsplit('/').next().unwrap_or(&name);
if is_base_distro(base_name) {
return Some(MacBaseKind::BaseDistro);
}
None
}
#[must_use]
pub fn prebuilt_cache_ref(image_ref: &str) -> Option<String> {
if image_ref.starts_with(ZLAYER_REGISTRY) {
return None;
}
let stripped = strip_registry_prefix(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(base_name) {
return Some(format!("{ZLAYER_REGISTRY}/base:latest"));
}
let canonical = match base_name {
"golang" | "go" => "golang",
"node" => "node",
"rust" => "rust",
"python" | "python3" => "python",
"deno" => "deno",
"bun" => "bun",
"swift" => "swift",
"zig" => "zig",
"eclipse-temurin" | "amazoncorretto" | "openjdk" => "java",
name if name.contains("graalvm") => "graalvm",
_ => return None,
};
let version = extract_version_from_tag(&tag);
Some(format!("{ZLAYER_REGISTRY}/{canonical}:{version}"))
}
fn is_base_distro(name: &str) -> bool {
matches!(
name,
"ubuntu"
| "debian"
| "alpine"
| "centos"
| "fedora"
| "rockylinux"
| "almalinux"
| "archlinux"
| "amazonlinux"
| "busybox"
)
}
fn strip_registry_prefix(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()
}
pub fn resolve_ghcr_auth() -> zlayer_registry::RegistryAuth {
use zlayer_registry::RegistryAuth;
if let Ok(token) = std::env::var("GHCR_TOKEN") {
if !token.is_empty() {
debug!("Using GHCR_TOKEN for registry auth");
return RegistryAuth::Basic("_token".to_string(), token);
}
}
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
if !token.is_empty() {
debug!("Using GITHUB_TOKEN for registry auth");
return RegistryAuth::Basic("_token".to_string(), token);
}
}
if let Some(creds) = read_docker_config_ghcr_auth() {
debug!("Using Docker config credentials for GHCR");
return creds;
}
debug!("No GHCR credentials found, using anonymous auth");
RegistryAuth::Anonymous
}
fn read_docker_config_ghcr_auth() -> Option<zlayer_registry::RegistryAuth> {
use base64::prelude::*;
use zlayer_registry::RegistryAuth;
let home = dirs::home_dir()?;
let config_path = home.join(".docker").join("config.json");
let contents = std::fs::read_to_string(&config_path).ok()?;
let config: serde_json::Value = serde_json::from_str(&contents).ok()?;
let auth_b64 = config.get("auths")?.get("ghcr.io")?.get("auth")?.as_str()?;
let decoded = BASE64_STANDARD.decode(auth_b64).ok()?;
let decoded_str = String::from_utf8(decoded).ok()?;
let (user, pass) = decoded_str.split_once(':')?;
Some(RegistryAuth::Basic(user.to_string(), pass.to_string()))
}
#[cfg(feature = "cache")]
pub async fn try_pull_zlayer_image(
image_ref: &str,
image_dir: &Path,
rootfs_dir: &Path,
) -> Result<bool> {
use zlayer_registry::{BlobCache, ImagePuller, LayerUnpacker};
info!("Attempting to pull ZLayer image: {}", image_ref);
let cache = match BlobCache::new() {
Ok(c) => c,
Err(e) => {
warn!("Failed to create blob cache for GHCR pull: {e}");
return Ok(false);
}
};
let puller = ImagePuller::new(cache);
let auth = resolve_ghcr_auth();
tokio::fs::create_dir_all(rootfs_dir).await?;
let layer_stage = {
let mut s = rootfs_dir.as_os_str().to_owned();
s.push(".layers.tmp");
std::path::PathBuf::from(s)
};
let _ = tokio::fs::remove_dir_all(&layer_stage).await;
let layers = match puller
.pull_image_to_files(image_ref, &auth, &layer_stage)
.await
{
Ok(l) => l,
Err(e) => {
warn!("Failed to pull ZLayer image {image_ref}: {e}");
let _ = tokio::fs::remove_dir_all(&layer_stage).await;
return Ok(false);
}
};
info!(
"Pulled {} layers for {}, extracting to rootfs",
layers.len(),
image_ref
);
let mut unpacker = LayerUnpacker::new(rootfs_dir.to_path_buf());
let unpack_result = unpacker.unpack_layers_from_files(&layers).await;
let _ = tokio::fs::remove_dir_all(&layer_stage).await;
if let Err(e) = unpack_result {
warn!("Failed to unpack layers for {image_ref}: {e}");
return Ok(false);
}
match puller.pull_image_config(image_ref, &auth).await {
Ok(ic) => {
if let Ok(json) = serde_json::to_string_pretty(&ic) {
let _ = tokio::fs::write(image_dir.join("image_config.json"), json).await;
}
}
Err(e) => debug!("Could not pull image config for {image_ref}: {e}"),
}
info!(
"Successfully pulled and extracted ZLayer image: {}",
image_ref
);
Ok(true)
}
pub async fn build_toolchain_as_image(
spec: &ToolchainSpec,
image_ref: &str,
data_dir: &Path,
) -> Result<PathBuf> {
let image_name = sanitize_image_name(image_ref);
let image_dir = data_dir.join("images").join(&image_name);
let rootfs_dir = image_dir.join("rootfs");
tokio::fs::create_dir_all(&rootfs_dir).await?;
ensure_base_rootfs(&rootfs_dir).await?;
let cache_dir = data_dir.join("toolchain-cache");
let tmp_dir = data_dir.join("tmp");
tokio::fs::create_dir_all(&cache_dir).await?;
tokio::fs::create_dir_all(&tmp_dir).await?;
provision_toolchain(spec, &rootfs_dir, &cache_dir, &tmp_dir).await?;
let mut config = toolchain_spec_to_config(spec);
{
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(format!("{spec:?}").as_bytes());
config.source_hash = Some(format!("{:x}", hasher.finalize()));
}
let config_json =
serde_json::to_string_pretty(&config).map_err(|e| BuildError::CacheError {
message: format!("failed to serialise image config: {e}"),
})?;
tokio::fs::write(image_dir.join("config.json"), config_json).await?;
info!(
"Built toolchain image for {} v{} at {}",
spec.language,
spec.version,
image_dir.display()
);
Ok(image_dir)
}
pub async fn build_base_image(image_ref: &str, data_dir: &Path) -> Result<PathBuf> {
let image_name = sanitize_image_name(image_ref);
let image_dir = data_dir.join("images").join(&image_name);
let rootfs_dir = image_dir.join("rootfs");
tokio::fs::create_dir_all(&rootfs_dir).await?;
ensure_base_rootfs(&rootfs_dir).await?;
let source_hash = {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(format!("base_image:{image_ref}").as_bytes());
format!("{:x}", hasher.finalize())
};
let config = SandboxImageConfig {
env: vec![
"PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin".to_string(),
"HOME=/root".to_string(),
],
working_dir: "/".to_string(),
entrypoint: None,
cmd: Some(vec!["/bin/sh".to_string()]),
exposed_ports: HashMap::new(),
labels: HashMap::new(),
user: None,
volumes: Vec::new(),
stop_signal: None,
shell: None,
healthcheck: None,
source_hash: Some(source_hash),
};
let config_json =
serde_json::to_string_pretty(&config).map_err(|e| BuildError::CacheError {
message: format!("failed to serialise image config: {e}"),
})?;
tokio::fs::write(image_dir.join("config.json"), config_json).await?;
info!("Built base image at {}", image_dir.display());
Ok(image_dir)
}
#[must_use]
pub fn toolchain_spec_to_config(spec: &ToolchainSpec) -> SandboxImageConfig {
let mut env: Vec<String> = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
let mut path_parts: Vec<&str> = spec.path_dirs.iter().map(String::as_str).collect();
path_parts.extend(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]);
let path_value = path_parts.join(":");
env.push(format!("PATH={path_value}"));
env.push("HOME=/root".to_string());
SandboxImageConfig {
env,
working_dir: "/".to_string(),
entrypoint: None,
cmd: Some(vec!["/bin/sh".to_string()]),
exposed_ports: HashMap::new(),
labels: HashMap::new(),
user: None,
volumes: Vec::new(),
stop_signal: None,
shell: None,
healthcheck: None,
source_hash: None,
}
}
pub async fn map_linux_packages(
packages: &[&str],
distro: &str,
cache_dir: &Path,
) -> Vec<(String, String, bool)> {
let map = load_or_fetch_package_map(distro, packages, cache_dir).await;
packages
.iter()
.map(|&pkg| {
let (brew, skipped) = resolve_single_package(pkg, distro, &map);
(pkg.to_string(), brew, skipped)
})
.collect()
}
fn resolve_single_package(
pkg: &str,
distro: &str,
map: &HashMap<String, String>,
) -> (String, bool) {
if is_linux_only_package(pkg) {
return (pkg.to_string(), true);
}
if let Some(brew) = map.get(pkg) {
return (brew.clone(), false);
}
if let Some(brew) = try_name_transforms(pkg, map) {
return (brew, false);
}
let manager = if distro.starts_with("alpine") {
"apk"
} else {
"apt"
};
crate::harvest::report_unfulfilled(&distro.replacen('_', "-", 1), manager, pkg);
map_single_package_hardcoded(pkg)
}
pub(crate) fn is_macos_irrelevant_package(pkg: &str) -> bool {
matches!(
pkg,
"ca-certificates"
| "apt-transport-https"
| "software-properties-common"
| "procps"
| "gnupg"
| "gnupg2"
)
}
pub(crate) fn is_linux_toolchain_package(pkg: &str) -> bool {
matches!(
pkg,
"build-essential"
| "gcc"
| "g++"
| "make"
| "musl-dev"
| "musl-tools"
| "musl"
| "libc-dev"
| "libc6-dev"
| "linux-headers"
| "linux-headers-generic"
)
}
fn is_linux_only_package(pkg: &str) -> bool {
is_macos_irrelevant_package(pkg) || is_linux_toolchain_package(pkg)
}
fn try_name_transforms(pkg: &str, map: &HashMap<String, String>) -> Option<String> {
if let Some(base) = pkg.strip_suffix("-dev") {
if let Some(brew) = map.get(base) {
return Some(brew.clone());
}
}
if let Some(rest) = pkg.strip_prefix("lib") {
if let Some(brew) = map.get(rest) {
return Some(brew.clone());
}
if let Some(base) = rest.strip_suffix("-dev") {
if let Some(brew) = map.get(base) {
return Some(brew.clone());
}
}
}
let without_digits = pkg.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.');
if without_digits != pkg && !without_digits.is_empty() {
if let Some(brew) = map.get(without_digits) {
return Some(brew.clone());
}
let without_g = without_digits.trim_end_matches('g');
if without_g != without_digits && !without_g.is_empty() {
if let Some(brew) = map.get(without_g) {
return Some(brew.clone());
}
}
}
None
}
fn map_single_package_hardcoded(pkg: &str) -> (String, bool) {
(pkg.to_string(), false)
}
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",
}
}
async fn load_or_fetch_package_map(
distro: &str,
packages: &[&str],
cache_dir: &Path,
) -> HashMap<String, String> {
let cache_root = cache_dir.join(PACKAGE_MAP_CACHE_SUBDIR);
let common_cache_dir = cache_root.join("common");
let distro_cache_dir = cache_root.join(distro);
let mut shards: HashSet<&'static str> = HashSet::new();
for pkg in packages {
shards.insert(shard_key(pkg));
}
let mut common_merged: HashMap<String, String> = HashMap::new();
let mut distro_merged: HashMap<String, String> = HashMap::new();
for shard in shards {
if let Some(map) = fetch_or_load_shard("common", &common_cache_dir, shard).await {
common_merged.extend(map);
}
if let Some(map) = fetch_or_load_shard(distro, &distro_cache_dir, shard).await {
distro_merged.extend(map);
}
}
let mut merged = common_merged;
merged.extend(distro_merged);
merged
}
async fn fetch_or_load_shard(
label: &str,
cache_dir: &Path,
shard: &str,
) -> Option<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::ZERO);
if age.as_secs() < PACKAGE_MAP_CACHE_TTL_SECS {
if let Some(map) = read_cached_map(&cache_path).await {
debug!(
"Using cached package map for {label}/{shard} ({} mappings, age {}s)",
map.len(),
age.as_secs()
);
return Some(map);
}
}
}
}
let url = format!("{REPO_SOURCES_BASE}/{label}/{shard}.json");
debug!("Fetching package map shard from {url}");
match fetch_package_map(&url).await {
Ok(map_file) => {
info!(
"Fetched {} package mappings for {label}/{shard} from RepoSources",
map_file.mappings.len()
);
if let Err(e) = write_cached_map(cache_dir, &cache_path, &map_file).await {
warn!("Failed to cache package map for {label}/{shard}: {e}");
}
Some(map_file.mappings)
}
Err(e) => {
debug!("Failed to fetch package map for {label}/{shard}: {e}");
if let Some(map) = read_cached_map(&cache_path).await {
info!(
"Using stale cached package map for {label}/{shard} ({} mappings)",
map.len()
);
return Some(map);
}
None
}
}
}
async fn fetch_package_map(url: &str) -> std::result::Result<PackageMapFile, 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::<PackageMapFile>()
.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 map_file: PackageMapFile = serde_json::from_str(&contents).ok()?;
Some(map_file.mappings)
}
async fn write_cached_map(
map_dir: &Path,
cache_path: &Path,
map_file: &PackageMapFile,
) -> 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(map_file).map_err(|e| format!("serialize: {e}"))?;
tokio::fs::write(cache_path, json)
.await
.map_err(|e| format!("write: {e}"))
}
fn sanitize_image_name(image: &str) -> String {
image.replace(['/', ':', '@'], "_")
}
#[cfg(test)]
mod tests {
use super::*;
use zlayer_paths::ZLayerDirs;
#[test]
fn test_rewrite_golang() {
let result = prebuilt_cache_ref("golang:1.23");
assert_eq!(result, Some(format!("{ZLAYER_REGISTRY}/golang:1.23")));
}
#[test]
fn test_rewrite_golang_alpine() {
let result = prebuilt_cache_ref("golang:1.23-alpine");
assert_eq!(result, Some(format!("{ZLAYER_REGISTRY}/golang:1.23")));
}
#[test]
fn test_rewrite_ubuntu_to_base() {
let result = prebuilt_cache_ref("ubuntu:22.04");
assert_eq!(result, Some(format!("{ZLAYER_REGISTRY}/base:latest")));
}
#[test]
fn test_rewrite_alpine_to_base() {
let result = prebuilt_cache_ref("alpine:3.19");
assert_eq!(result, Some(format!("{ZLAYER_REGISTRY}/base:latest")));
}
#[test]
fn test_rewrite_node_latest() {
let result = prebuilt_cache_ref("node:latest");
assert_eq!(result, Some(format!("{ZLAYER_REGISTRY}/node:latest")));
}
#[test]
fn test_rewrite_node_slim() {
let result = prebuilt_cache_ref("node:20-slim");
assert_eq!(result, Some(format!("{ZLAYER_REGISTRY}/node:20")));
}
#[test]
fn test_rewrite_python_bookworm() {
let result = prebuilt_cache_ref("python:3.12-bookworm");
assert_eq!(result, Some(format!("{ZLAYER_REGISTRY}/python:3.12")));
}
#[test]
fn test_rewrite_qualified_golang() {
let result = prebuilt_cache_ref("docker.io/library/golang:1.22");
assert_eq!(result, Some(format!("{ZLAYER_REGISTRY}/golang:1.22")));
}
#[test]
fn test_no_rewrite_custom_image() {
let result = prebuilt_cache_ref("myregistry.io/myteam/myapp:v1.0");
assert_eq!(result, None);
}
#[test]
fn test_no_rewrite_already_zlayer() {
let result = prebuilt_cache_ref("ghcr.io/blackleafdigital/zlayer/golang:1.23");
assert_eq!(result, None);
}
#[test]
fn test_toolchain_spec_to_config_go() {
let spec = ToolchainSpec::go("1.23");
let config = toolchain_spec_to_config(&spec);
assert!(config.env.iter().any(|e| e.starts_with("GOROOT=")));
assert!(config.env.iter().any(|e| e.starts_with("PATH=")));
let path_entry = config.env.iter().find(|e| e.starts_with("PATH=")).unwrap();
assert!(path_entry.contains("/usr/local/go/bin"));
assert_eq!(config.working_dir, "/");
}
#[test]
fn test_toolchain_spec_to_config_java() {
let spec = ToolchainSpec::java("21");
let config = toolchain_spec_to_config(&spec);
assert!(config.env.iter().any(|e| e.starts_with("JAVA_HOME=")));
let path_entry = config.env.iter().find(|e| e.starts_with("PATH=")).unwrap();
assert!(path_entry.contains("/usr/local/java/bin"));
}
#[test]
fn test_resolve_common_packages_hardcoded() {
let empty_map = HashMap::new();
for pkg in &["curl", "git", "wget", "jq"] {
let (name, skipped) = resolve_single_package(pkg, "debian_12", &empty_map);
assert!(!skipped, "{pkg} should not be skipped");
assert_eq!(name, *pkg);
}
}
#[test]
fn test_resolve_skip_linux_only() {
let empty_map = HashMap::new();
for pkg in &["build-essential", "ca-certificates", "musl-dev", "libc-dev"] {
let (_name, skipped) = resolve_single_package(pkg, "debian_12", &empty_map);
assert!(skipped, "{pkg} should be skipped");
}
}
#[test]
fn test_resolve_passthrough_unknown() {
let empty_map = HashMap::new();
let (name, skipped) =
resolve_single_package("some-obscure-package", "debian_12", &empty_map);
assert_eq!(name, "some-obscure-package");
assert!(!skipped);
}
#[test]
fn test_resolve_with_remote_map() {
let mut map = HashMap::new();
map.insert("libfoo-dev".to_string(), "foo".to_string());
map.insert("custom-pkg".to_string(), "custom-brew".to_string());
let (name, skipped) = resolve_single_package("custom-pkg", "debian_12", &map);
assert_eq!(name, "custom-brew");
assert!(!skipped);
let (name, skipped) = resolve_single_package("libfoo-dev", "debian_12", &map);
assert_eq!(name, "foo");
assert!(!skipped);
}
#[test]
fn test_resolve_name_transforms() {
let mut map = HashMap::new();
map.insert("ssl".to_string(), "openssl".to_string());
map.insert("yaml".to_string(), "libyaml".to_string());
let (name, _) = resolve_single_package("libssl", "debian_12", &map);
assert_eq!(name, "openssl");
let (name, _) = resolve_single_package("libyaml-dev", "debian_12", &map);
assert_eq!(name, "libyaml");
}
#[tokio::test]
#[ignore = "live network test; runs against published RepoSources"]
async fn test_map_linux_packages_live_reposources() {
let tmp = ZLayerDirs::system_default()
.tmp()
.join("zlayer-test-pkg-map-live");
let _ = tokio::process::Command::new("chmod")
.args(["-R", "u+w"])
.arg(&tmp)
.status()
.await;
let _ = tokio::process::Command::new("rm")
.args(["-rf"])
.arg(&tmp)
.status()
.await;
let result =
map_linux_packages(&["curl", "libssl-dev", "musl-dev"], "debian_12", &tmp).await;
assert_eq!(result.len(), 3);
assert_eq!(result[0].0, "curl");
assert_eq!(result[0].1, "curl");
assert!(!result[0].2);
assert_eq!(result[1].0, "libssl-dev");
assert_eq!(result[1].1, "openssl@3");
assert!(!result[1].2);
assert_eq!(result[2].0, "musl-dev");
assert!(result[2].2);
let _ = tokio::process::Command::new("chmod")
.args(["-R", "u+w"])
.arg(&tmp)
.status()
.await;
let _ = tokio::process::Command::new("rm")
.args(["-rf"])
.arg(&tmp)
.status()
.await;
}
async fn write_shard(
label_dir: &Path,
label: &str,
shard: &str,
mappings: HashMap<String, String>,
) {
tokio::fs::create_dir_all(label_dir)
.await
.expect("create label dir");
let fixture = PackageMapFile {
metadata: PackageMapMetadata {
generated_at: "2026-04-29T00:00:00Z".to_string(),
source: "test-fixture".to_string(),
distro: label.to_string(),
shard: Some(shard.to_string()),
total_mappings: mappings.len(),
},
mappings,
};
let json = serde_json::to_string_pretty(&fixture).expect("serialize fixture");
tokio::fs::write(label_dir.join(format!("{shard}.json")), json)
.await
.expect("write shard fixture");
}
#[tokio::test]
async fn test_map_linux_packages_with_seeded_cache() {
let tmp = ZLayerDirs::system_default()
.scratch_dir("test-map-linux-packages-with-seeded-cache-")
.expect("create tmpdir");
let cache_dir = tmp.path().to_path_buf();
let cache_root = cache_dir.join(PACKAGE_MAP_CACHE_SUBDIR);
let distro_dir = cache_root.join("debian_12");
let mut c_mappings = HashMap::new();
c_mappings.insert("curl".to_string(), "curl".to_string());
write_shard(&distro_dir, "debian_12", "c", c_mappings).await;
let mut l_mappings = HashMap::new();
l_mappings.insert("libssl-dev".to_string(), "openssl@3".to_string());
write_shard(&distro_dir, "debian_12", "l", l_mappings).await;
let mut n_mappings = HashMap::new();
n_mappings.insert("nodejs".to_string(), "node@24".to_string());
write_shard(&distro_dir, "debian_12", "n", n_mappings).await;
let result = map_linux_packages(
&["curl", "libssl-dev", "musl-dev", "nodejs"],
"debian_12",
&cache_dir,
)
.await;
assert_eq!(result.len(), 4);
assert_eq!(result[0].0, "curl");
assert_eq!(result[0].1, "curl");
assert!(!result[0].2);
assert_eq!(result[1].0, "libssl-dev");
assert_eq!(result[1].1, "openssl@3");
assert!(!result[1].2);
assert_eq!(result[2].0, "musl-dev");
assert!(result[2].2);
assert_eq!(result[3].0, "nodejs");
assert_eq!(result[3].1, "node@24");
assert!(!result[3].2);
}
#[tokio::test]
async fn test_map_linux_packages_falls_back_to_common() {
let tmp = ZLayerDirs::system_default()
.scratch_dir("test-map-linux-packages-falls-back-to-common-")
.expect("create tmpdir");
let cache_dir = tmp.path().to_path_buf();
let cache_root = cache_dir.join(PACKAGE_MAP_CACHE_SUBDIR);
let distro_dir = cache_root.join("centos_8");
let common_dir = cache_root.join("common");
let mut distro_o = HashMap::new();
distro_o.insert("openssl-devel".to_string(), "openssl@3".to_string());
write_shard(&distro_dir, "centos_8", "o", distro_o).await;
let mut common_l = HashMap::new();
common_l.insert("libssl-dev".to_string(), "openssl@3".to_string());
write_shard(&common_dir, "common", "l", common_l).await;
write_shard(&common_dir, "common", "o", HashMap::new()).await;
write_shard(&distro_dir, "centos_8", "l", HashMap::new()).await;
let result =
map_linux_packages(&["openssl-devel", "libssl-dev"], "centos_8", &cache_dir).await;
assert_eq!(result.len(), 2);
assert_eq!(result[0].0, "openssl-devel");
assert_eq!(result[0].1, "openssl@3");
assert!(!result[0].2);
assert_eq!(result[1].0, "libssl-dev");
assert_eq!(result[1].1, "openssl@3");
assert!(!result[1].2);
}
#[tokio::test]
async fn test_map_linux_packages_distro_overrides_common() {
let tmp = ZLayerDirs::system_default()
.scratch_dir("test-map-linux-packages-distro-overrides-common-")
.expect("create tmpdir");
let cache_dir = tmp.path().to_path_buf();
let cache_root = cache_dir.join(PACKAGE_MAP_CACHE_SUBDIR);
let distro_dir = cache_root.join("alpine_3_20");
let common_dir = cache_root.join("common");
let mut distro_p = HashMap::new();
distro_p.insert("python3".to_string(), "python@3.13".to_string());
write_shard(&distro_dir, "alpine_3_20", "p", distro_p).await;
let mut common_p = HashMap::new();
common_p.insert("python3".to_string(), "python@3.14".to_string());
write_shard(&common_dir, "common", "p", common_p).await;
let result = map_linux_packages(&["python3"], "alpine_3_20", &cache_dir).await;
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "python3");
assert_eq!(result[0].1, "python@3.13");
assert!(!result[0].2);
}
#[tokio::test]
async fn test_future_mtime_shard_is_fresh_not_refetched() {
let tmp = ZLayerDirs::system_default()
.scratch_dir("test-future-mtime-shard-")
.expect("create tmpdir");
let cache_dir = tmp.path().to_path_buf();
let cache_root = cache_dir.join(PACKAGE_MAP_CACHE_SUBDIR);
let distro_dir = cache_root.join("centos_8");
let common_dir = cache_root.join("common");
let mut distro_o = HashMap::new();
distro_o.insert("openssl-devel".to_string(), "openssl@3".to_string());
write_shard(&distro_dir, "centos_8", "o", distro_o).await;
write_shard(&common_dir, "common", "o", HashMap::new()).await;
let future = std::time::SystemTime::now() + std::time::Duration::from_secs(3600);
for p in [distro_dir.join("o.json"), common_dir.join("o.json")] {
let f = std::fs::File::options()
.write(true)
.open(&p)
.expect("open shard for mtime bump");
f.set_modified(future).expect("set future mtime");
}
let result = map_linux_packages(&["openssl-devel"], "centos_8", &cache_dir).await;
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "openssl-devel");
assert_eq!(result[0].1, "openssl@3");
assert!(!result[0].2);
}
}