#![allow(clippy::module_name_repetitions)]
use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use zlayer_registry::pack::{pack_dir_tar_zstd, DEFAULT_ZSTD_LEVEL};
use zlayer_registry::{
ArtifactLayer, BlobCache, ImagePuller, LayerUnpacker, OciImageManifest, RegistryAuth,
RegistryError,
};
use crate::error::{Result, ToolchainError};
use crate::manifest::{ToolchainManifest, ToolchainSource};
use crate::relocate::{relocate_pulled, RelocationReport};
pub const DEFAULT_TOOLCHAIN_REGISTRY: &str = "ghcr.io/blackleafdigital/zlayer/toolchains";
pub const ARTIFACT_TYPE: &str = "application/vnd.zlayer.toolchain.v1";
pub const CONFIG_MEDIA_TYPE: &str = "application/vnd.zlayer.toolchain.config.v1+json";
const LAYER_TITLE: &str = "toolchain.tar.zst";
const ANN_TOOL: &str = "com.zlayer.toolchain.tool";
const ANN_VERSION: &str = "com.zlayer.toolchain.version";
const ANN_OS: &str = "com.zlayer.toolchain.os";
const ANN_ARCH: &str = "com.zlayer.toolchain.arch";
const ANN_PREFIX: &str = "com.zlayer.toolchain.prefix";
const ANN_RELOCATABLE: &str = "com.zlayer.toolchain.relocatable";
#[must_use]
pub fn registry_repo() -> String {
std::env::var("ZLAYER_TOOLCHAIN_REGISTRY")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| DEFAULT_TOOLCHAIN_REGISTRY.to_string())
}
#[must_use]
pub fn artifact_tag(tool: &str, version: &str, os: &str, arch: &str) -> String {
format!(
"{}-{}-{}-{}",
sanitize_tag_component(tool),
sanitize_tag_component(version),
sanitize_tag_component(os),
sanitize_tag_component(arch),
)
}
#[must_use]
pub fn latest_tag(tool: &str, os: &str, arch: &str) -> String {
format!(
"{}-latest-{}-{}",
sanitize_tag_component(tool),
sanitize_tag_component(os),
sanitize_tag_component(arch),
)
}
#[must_use]
pub fn sanitize_tag_component(s: &str) -> String {
s.chars()
.map(|c| {
let c = c.to_ascii_lowercase();
match c {
'a'..='z' | '0'..='9' | '.' | '_' | '-' => c,
_ => '-',
}
})
.collect()
}
#[derive(Debug, Clone)]
pub struct ToolchainArtifactId {
pub tool: String,
pub version: String,
pub os: String,
pub arch: String,
}
impl ToolchainArtifactId {
#[must_use]
pub fn artifact_tag(&self) -> String {
artifact_tag(&self.tool, &self.version, &self.os, &self.arch)
}
#[must_use]
pub fn latest_tag(&self) -> String {
latest_tag(&self.tool, &self.os, &self.arch)
}
}
#[must_use]
pub fn registry_auth_from_env(registry_host: &str) -> RegistryAuth {
if let Ok(token) = std::env::var("GHCR_TOKEN") {
if !token.is_empty() {
return RegistryAuth::Basic("_token".to_string(), token);
}
}
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
if !token.is_empty() {
return RegistryAuth::Basic("_token".to_string(), token);
}
}
if let Some(auth) = docker_config_auth(registry_host) {
return auth;
}
RegistryAuth::Anonymous
}
fn docker_config_auth(registry_host: &str) -> Option<RegistryAuth> {
if registry_host.is_empty() {
return None;
}
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
let path = Path::new(&home).join(".docker").join("config.json");
let contents = std::fs::read_to_string(path).ok()?;
let config: serde_json::Value = serde_json::from_str(&contents).ok()?;
let auth_b64 = config
.get("auths")?
.get(registry_host)?
.get("auth")?
.as_str()?;
let decoded = base64_decode(auth_b64.trim())?;
let decoded = String::from_utf8(decoded).ok()?;
let (user, pass) = decoded.split_once(':')?;
Some(RegistryAuth::Basic(user.to_string(), pass.to_string()))
}
fn base64_decode(s: &str) -> Option<Vec<u8>> {
fn sextet(c: u8) -> Option<u32> {
match c {
b'A'..=b'Z' => Some(u32::from(c - b'A')),
b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let mut out = Vec::new();
let mut acc = 0u32;
let mut bits = 0u32;
for &c in s.as_bytes() {
if matches!(c, b'=' | b'\n' | b'\r' | b' ' | b'\t') {
continue;
}
acc = (acc << 6) | sextet(c)?;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(u8::try_from((acc >> bits) & 0xff).ok()?);
}
}
Some(out)
}
fn registry_host(reference: &str) -> &str {
match reference.split_once('/') {
Some((head, _)) if head.contains('.') || head.contains(':') || head == "localhost" => head,
_ => "",
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolchainArtifactConfig {
pub format: u32,
pub tool: String,
pub version: String,
pub os: String,
pub arch: String,
pub built_prefix: String,
pub relocatable: bool,
pub manifest: ToolchainManifest,
}
#[derive(Debug, Clone)]
pub struct PublishedToolchain {
pub reference: String,
pub digest: String,
}
pub async fn publish_toolchain(
dir: &Path,
manifest: &ToolchainManifest,
report: &RelocationReport,
built_prefix: &Path,
id: &ToolchainArtifactId,
) -> Result<PublishedToolchain> {
let repo = registry_repo();
let reference = format!("{repo}:{}", id.artifact_tag());
let latest_ref = format!("{repo}:{}", id.latest_tag());
let relocatable = report.is_fully_relocatable();
let (config_blob, layer, annotations) =
build_artifact(dir, manifest, built_prefix, id, relocatable, report).await?;
let auth = registry_auth_from_env(registry_host(&repo));
let cache = BlobCache::new().map_err(|e| ToolchainError::RegistryError {
message: format!("failed to init blob cache for toolchain publish: {e}"),
})?;
let puller = ImagePuller::new(cache);
let layers = std::slice::from_ref(&layer);
info!(
reference = %reference,
relocatable,
"publishing toolchain artifact"
);
let push = puller
.push_artifact(
&reference,
ARTIFACT_TYPE,
CONFIG_MEDIA_TYPE,
&config_blob,
layers,
annotations.clone(),
&auth,
)
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("push toolchain artifact {reference}: {e}"),
})?;
puller
.push_artifact(
&latest_ref,
ARTIFACT_TYPE,
CONFIG_MEDIA_TYPE,
&config_blob,
layers,
annotations,
&auth,
)
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("repoint latest alias {latest_ref}: {e}"),
})?;
info!(
reference = %reference,
digest = %push.manifest_digest,
"toolchain artifact published"
);
Ok(PublishedToolchain {
reference,
digest: push.manifest_digest,
})
}
async fn build_artifact(
dir: &Path,
manifest: &ToolchainManifest,
built_prefix: &Path,
id: &ToolchainArtifactId,
relocatable: bool,
report: &RelocationReport,
) -> Result<(Vec<u8>, ArtifactLayer, BTreeMap<String, String>)> {
let built_prefix = built_prefix.to_string_lossy().into_owned();
let config = ToolchainArtifactConfig {
format: 1,
tool: id.tool.clone(),
version: id.version.clone(),
os: id.os.clone(),
arch: id.arch.clone(),
built_prefix: built_prefix.clone(),
relocatable,
manifest: manifest.clone(),
};
let config_blob = serde_json::to_vec(&config).map_err(|e| ToolchainError::RegistryError {
message: format!("serialize toolchain artifact config for {}: {e}", id.tool),
})?;
let (layer_bytes, layer_media) = if report.text_files_with_prefix.is_empty() {
let root = dir.to_path_buf();
tokio::task::spawn_blocking(move || {
pack_dir_tar_zstd(&root, &[".build", ".ready"], DEFAULT_ZSTD_LEVEL)
})
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("toolchain layer packing task failed: {e}"),
})??
} else {
let scratch = tempfile::tempdir().map_err(|e| ToolchainError::RegistryError {
message: format!("create publish scratch dir for {}: {e}", id.tool),
})?;
let copy_root = scratch.path().join("tree");
copy_tree(dir, ©_root).await?;
crate::relocate::apply_text_placeholders(
©_root,
Path::new(built_prefix.as_str()),
dir,
&report.text_files_with_prefix,
)
.await?;
let root = copy_root.clone();
tokio::task::spawn_blocking(move || {
pack_dir_tar_zstd(&root, &[".build", ".ready"], DEFAULT_ZSTD_LEVEL)
})
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("toolchain layer packing task failed: {e}"),
})??
};
let mut annotations = BTreeMap::new();
annotations.insert(ANN_TOOL.to_string(), id.tool.clone());
annotations.insert(ANN_VERSION.to_string(), id.version.clone());
annotations.insert(ANN_OS.to_string(), id.os.clone());
annotations.insert(ANN_ARCH.to_string(), id.arch.clone());
annotations.insert(ANN_PREFIX.to_string(), built_prefix);
annotations.insert(
ANN_RELOCATABLE.to_string(),
if relocatable { "true" } else { "false" }.to_string(),
);
let layer = ArtifactLayer {
data: layer_bytes,
media_type: layer_media,
title: Some(LAYER_TITLE.to_string()),
};
Ok((config_blob, layer, annotations))
}
async fn copy_tree(src: &Path, dest: &Path) -> Result<()> {
let mut stack = vec![(src.to_path_buf(), dest.to_path_buf())];
while let Some((from, to)) = stack.pop() {
let meta = tokio::fs::symlink_metadata(&from).await?;
let ft = meta.file_type();
if ft.is_symlink() {
let target = tokio::fs::read_link(&from).await?;
#[cfg(unix)]
tokio::fs::symlink(&target, &to).await?;
#[cfg(windows)]
{
let _ = tokio::fs::symlink_file(&target, &to).await;
}
} else if ft.is_dir() {
tokio::fs::create_dir_all(&to).await?;
let mut rd = tokio::fs::read_dir(&from).await?;
while let Some(entry) = rd.next_entry().await? {
let name = entry.file_name();
stack.push((from.join(&name), to.join(&name)));
}
} else {
if let Some(parent) = to.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::copy(&from, &to).await?;
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct PulledToolchain {
pub reference: String,
pub digest: String,
}
pub async fn try_pull_toolchain(
id: &ToolchainArtifactId,
dest_dir: &Path,
) -> Result<Option<PulledToolchain>> {
let repo = registry_repo();
let reference = format!("{repo}:{}", id.artifact_tag());
let cache = BlobCache::new().map_err(|e| ToolchainError::RegistryError {
message: format!("failed to init blob cache for toolchain pull: {e}"),
})?;
let puller = ImagePuller::new(cache);
pull_with(&puller, &reference, dest_dir).await
}
async fn pull_with(
puller: &ImagePuller,
reference: &str,
dest_dir: &Path,
) -> Result<Option<PulledToolchain>> {
let Some((manifest, digest, auth)) = resolve_manifest(puller, reference).await? else {
info!(reference = %reference, "toolchain artifact not published; will build");
return Ok(None);
};
let config = pull_config(puller, reference, &manifest, &auth).await?;
let mut layers: Vec<(Vec<u8>, String)> = Vec::with_capacity(manifest.layers.len());
for descriptor in &manifest.layers {
let data = puller
.pull_blob(reference, &descriptor.digest, &auth)
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!(
"pull toolchain layer {} for {reference}: {e}",
descriptor.digest
),
})?;
layers.push((data, descriptor.media_type.clone()));
}
tokio::fs::create_dir_all(dest_dir).await?;
let local_prefix = dest_dir.to_string_lossy().into_owned();
if !config.relocatable && local_prefix.len() > config.built_prefix.len() {
warn!(
reference = %reference,
recorded_prefix = %config.built_prefix,
local_prefix = %local_prefix,
"pulled toolchain is not relocatable to this (longer) local prefix; will build"
);
let _ = tokio::fs::remove_dir_all(dest_dir).await;
return Ok(None);
}
let mut unpacker = LayerUnpacker::new(dest_dir.to_path_buf());
unpacker
.unpack_layers(&layers)
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("unpack toolchain layers into {}: {e}", dest_dir.display()),
})?;
relocate_pulled(dest_dir, &config.built_prefix, dest_dir, config.relocatable).await?;
let mut rehomed = config.manifest;
rehome_manifest(&mut rehomed, &config.built_prefix, &local_prefix);
rehomed.source = ToolchainSource::Registry {
reference: reference.to_string(),
digest: digest.clone(),
};
rehomed.write_to_toolchain(dest_dir).await?;
info!(reference = %reference, digest = %digest, dest = %dest_dir.display(), "toolchain artifact pulled");
Ok(Some(PulledToolchain {
reference: reference.to_string(),
digest,
}))
}
async fn resolve_manifest(
puller: &ImagePuller,
reference: &str,
) -> Result<Option<(OciImageManifest, String, RegistryAuth)>> {
match puller
.pull_manifest(reference, &RegistryAuth::Anonymous)
.await
{
Ok((manifest, digest)) => Ok(Some((manifest, digest, RegistryAuth::Anonymous))),
Err(ref e) if err_is_not_found(e) => Ok(None),
Err(ref e) if err_is_auth(e) => {
let auth = registry_auth_from_env(registry_host(reference));
match puller.pull_manifest(reference, &auth).await {
Ok((manifest, digest)) => Ok(Some((manifest, digest, auth))),
Err(ref e2) if err_is_not_found(e2) || err_is_auth(e2) => Ok(None),
Err(e2) => Err(ToolchainError::RegistryError {
message: format!("pull toolchain manifest {reference} (authed): {e2}"),
}),
}
}
Err(e) => Err(ToolchainError::RegistryError {
message: format!("pull toolchain manifest {reference}: {e}"),
}),
}
}
async fn pull_config(
puller: &ImagePuller,
reference: &str,
manifest: &OciImageManifest,
auth: &RegistryAuth,
) -> Result<ToolchainArtifactConfig> {
let bytes = puller
.pull_blob(reference, &manifest.config.digest, auth)
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("pull toolchain config blob for {reference}: {e}"),
})?;
serde_json::from_slice(&bytes).map_err(|e| ToolchainError::RegistryError {
message: format!("parse toolchain config blob for {reference}: {e}"),
})
}
fn rehome_manifest(manifest: &mut ToolchainManifest, recorded: &str, local: &str) {
for dir in &mut manifest.path_dirs {
if dir.contains(recorded) {
*dir = dir.replace(recorded, local);
}
}
for value in manifest.env.values_mut() {
if value.contains(recorded) {
*value = value.replace(recorded, local);
}
}
}
fn err_is_not_found(err: &RegistryError) -> bool {
matches!(err, RegistryError::NotFound { .. }) || msg_is_not_found(&err.to_string())
}
fn msg_is_not_found(msg: &str) -> bool {
let m = msg.to_ascii_lowercase();
m.contains("not found")
|| m.contains("notfound")
|| m.contains("not_found")
|| m.contains("manifest unknown")
|| m.contains("manifestunknown")
|| m.contains("manifest_unknown")
|| m.contains("name unknown")
|| m.contains("nameunknown")
|| m.contains("name_unknown")
|| m.contains("blob unknown")
|| m.contains("blobunknown")
|| m.contains("blob_unknown")
|| m.contains("code: 404")
|| m.contains("404")
}
fn err_is_auth(err: &RegistryError) -> bool {
matches!(err, RegistryError::AuthFailed { .. }) || msg_is_auth(&err.to_string())
}
fn msg_is_auth(msg: &str) -> bool {
let m = msg.to_ascii_lowercase();
m.contains("authentication fail")
|| m.contains("not authorized")
|| m.contains("unauthorized")
|| m.contains("code: 401")
|| m.contains("401")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::relocate::TEXT_PLACEHOLDER;
use std::collections::HashMap;
use std::sync::Arc;
use zlayer_registry::{BlobCache, BlobCacheBackend, LocalRegistry};
#[test]
fn sanitize_lowercases_and_replaces_specials() {
assert_eq!(sanitize_tag_component("openssl@3"), "openssl-3");
assert_eq!(sanitize_tag_component("GIT"), "git");
assert_eq!(sanitize_tag_component("2.55.0"), "2.55.0");
assert_eq!(
sanitize_tag_component("weird/name space!"),
"weird-name-space-"
);
assert_eq!(sanitize_tag_component("a_b-c.d"), "a_b-c.d");
}
#[test]
fn artifact_and_latest_tags_are_structured_and_sanitized() {
assert_eq!(
artifact_tag("openssl@3", "3", "macos", "arm64"),
"openssl-3-3-macos-arm64"
);
assert_eq!(
artifact_tag("GIT", "2.55.0", "macOS", "ARM64"),
"git-2.55.0-macos-arm64"
);
assert_eq!(latest_tag("jq", "macos", "arm64"), "jq-latest-macos-arm64");
let id = ToolchainArtifactId {
tool: "jq".to_string(),
version: "1.8.1".to_string(),
os: "macos".to_string(),
arch: "arm64".to_string(),
};
assert_eq!(id.artifact_tag(), "jq-1.8.1-macos-arm64");
assert_eq!(id.latest_tag(), "jq-latest-macos-arm64");
assert_ne!(id.artifact_tag(), id.latest_tag());
}
#[test]
fn registry_repo_defaults_and_reads_env() {
assert_eq!(
DEFAULT_TOOLCHAIN_REGISTRY,
"ghcr.io/blackleafdigital/zlayer/toolchains"
);
assert!(registry_repo().contains("toolchains"));
}
#[test]
fn not_found_classifier_matches_oci_not_found_shapes() {
assert!(msg_is_not_found("Image manifest not found: foo"));
assert!(msg_is_not_found(
"Registry error: url x, envelope: MANIFEST_UNKNOWN"
));
assert!(msg_is_not_found(
"Server error: url x, code: 404, message: nope"
));
assert!(msg_is_not_found("NAME_UNKNOWN"));
assert!(!msg_is_not_found("connection refused"));
assert!(!msg_is_not_found("dns error: failed to lookup host"));
assert!(!msg_is_not_found("operation timed out"));
assert!(err_is_not_found(&RegistryError::NotFound {
registry: "local".to_string(),
image: "x".to_string(),
}));
}
#[test]
fn auth_classifier_matches_oci_auth_shapes() {
assert!(msg_is_auth("Authentication failure: bad creds"));
assert!(msg_is_auth("Not authorized: url https://ghcr.io/token"));
assert!(msg_is_auth(
"Server error: url x, code: 401, message: denied"
));
assert!(!msg_is_auth("Image manifest not found: foo"));
assert!(err_is_auth(&RegistryError::AuthFailed {
registry: "ghcr.io".to_string(),
reason: "denied".to_string(),
}));
}
#[test]
fn rehome_manifest_rewrites_prefix_paths() {
let mut env = HashMap::new();
env.insert("JQ_LIB".to_string(), "/built/tc/lib".to_string());
env.insert("UNRELATED".to_string(), "keepme".to_string());
let mut m = ToolchainManifest {
tool: "jq".to_string(),
version: "1.8.1".to_string(),
arch: "arm64".to_string(),
platform: "macos".to_string(),
path_dirs: vec!["/built/tc/bin".to_string()],
env,
source: ToolchainSource::SourceBuild {
url: String::new(),
sha256: String::new(),
},
build_deps: vec![],
provisioned_at: String::new(),
};
rehome_manifest(&mut m, "/built/tc", "/local/dest");
assert_eq!(m.path_dirs, vec!["/local/dest/bin".to_string()]);
assert_eq!(m.env.get("JQ_LIB").unwrap(), "/local/dest/lib");
assert_eq!(m.env.get("UNRELATED").unwrap(), "keepme");
}
#[test]
fn base64_decode_round_trips_user_pass() {
let decoded = base64_decode("X3Rva2VuOmdocF9hYmMxMjM=").unwrap();
assert_eq!(String::from_utf8(decoded).unwrap(), "_token:ghp_abc123");
assert!(base64_decode("not base64 %%%").is_none());
}
#[cfg(unix)]
async fn build_fixture_toolchain(src: &Path) {
tokio::fs::create_dir_all(src.join("bin")).await.unwrap();
tokio::fs::create_dir_all(src.join("lib")).await.unwrap();
tokio::fs::create_dir_all(src.join("share")).await.unwrap();
tokio::fs::write(src.join("bin/jq"), b"#!/bin/sh\nexit 0\n")
.await
.unwrap();
tokio::fs::write(
src.join("share/jq.pc"),
format!("prefix={TEXT_PLACEHOLDER}\nlibdir={TEXT_PLACEHOLDER}/lib\n"),
)
.await
.unwrap();
std::os::unix::fs::symlink("../bin/jq", src.join("lib/jq-link")).unwrap();
tokio::fs::create_dir_all(src.join(".build")).await.unwrap();
tokio::fs::write(src.join(".build/stamp"), b"x")
.await
.unwrap();
tokio::fs::write(src.join(".ready"), b"").await.unwrap();
}
async fn seed_artifact(
registry: &LocalRegistry,
repo: &str,
tag: &str,
layer_bytes: &[u8],
layer_media: &str,
config: &ToolchainArtifactConfig,
) -> String {
let config_blob = serde_json::to_vec(config).unwrap();
let config_digest = registry.put_blob(&config_blob).await.unwrap();
let layer_digest = registry.put_blob(layer_bytes).await.unwrap();
let manifest_json = format!(
concat!(
r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","#,
r#""artifactType":"{artifact_type}","#,
r#""config":{{"mediaType":"{config_media}","digest":"{config_digest}","size":{config_size}}},"#,
r#""layers":[{{"mediaType":"{layer_media}","digest":"{layer_digest}","size":{layer_size}}}],"#,
r#""annotations":{{"{ann_tool}":"{tool}","{ann_reloc}":"{reloc}"}}}}"#
),
artifact_type = ARTIFACT_TYPE,
config_media = CONFIG_MEDIA_TYPE,
config_digest = config_digest,
config_size = config_blob.len(),
layer_media = layer_media,
layer_digest = layer_digest,
layer_size = layer_bytes.len(),
ann_tool = ANN_TOOL,
tool = config.tool,
ann_reloc = ANN_RELOCATABLE,
reloc = config.relocatable,
);
registry
.put_manifest(repo, tag, manifest_json.as_bytes())
.await
.unwrap()
}
fn local_puller(registry: &Arc<LocalRegistry>) -> ImagePuller {
let cache: Arc<Box<dyn BlobCacheBackend>> = Arc::new(Box::new(BlobCache::new().unwrap()));
ImagePuller::with_cache(cache).with_local_registry(registry.clone())
}
fn fixture_config(built_prefix: &str, relocatable: bool) -> ToolchainArtifactConfig {
let mut env = HashMap::new();
env.insert("JQ_LIB".to_string(), format!("{built_prefix}/lib"));
ToolchainArtifactConfig {
format: 1,
tool: "jq".to_string(),
version: "1.8.1".to_string(),
os: "macos".to_string(),
arch: "arm64".to_string(),
built_prefix: built_prefix.to_string(),
relocatable,
manifest: ToolchainManifest {
tool: "jq".to_string(),
version: "1.8.1".to_string(),
arch: "arm64".to_string(),
platform: "macos".to_string(),
path_dirs: vec![format!("{built_prefix}/bin")],
env,
source: ToolchainSource::SourceBuild {
url: String::new(),
sha256: String::new(),
},
build_deps: vec![],
provisioned_at: "2026-07-06T00:00:00Z".to_string(),
},
}
}
#[cfg(unix)]
#[tokio::test]
async fn publish_placeholders_a_copy_not_the_live_tree() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("jq-1.8.1-macos-arm64");
tokio::fs::create_dir_all(src.join("bin")).await.unwrap();
tokio::fs::create_dir_all(src.join("share")).await.unwrap();
tokio::fs::write(src.join("bin/jq"), b"#!/bin/sh\nexit 0\n")
.await
.unwrap();
let real = src.to_string_lossy().into_owned();
let pc = src.join("share/jq.pc");
tokio::fs::write(&pc, format!("prefix={real}\nlibdir={real}/lib\n"))
.await
.unwrap();
let report = crate::relocate::make_relocatable(&src, &src, &[])
.await
.unwrap();
assert_eq!(report.text_files_with_prefix, vec![pc.clone()]);
assert!(
tokio::fs::read_to_string(&pc)
.await
.unwrap()
.contains(&real),
"make_relocatable must leave the live .pc real-pathed"
);
let config = fixture_config(&real, report.is_fully_relocatable());
let id = ToolchainArtifactId {
tool: "jq".to_string(),
version: "1.8.1".to_string(),
os: "macos".to_string(),
arch: "arm64".to_string(),
};
let (_cfg, layer, _ann) = build_artifact(
&src,
&config.manifest,
&src,
&id,
report.is_fully_relocatable(),
&report,
)
.await
.unwrap();
assert!(
tokio::fs::read_to_string(&pc)
.await
.unwrap()
.contains(&real),
"publish must not mutate the live toolchain tree"
);
let registry = Arc::new(LocalRegistry::new(tmp.path().join("reg")).await.unwrap());
let repo = registry_repo();
let tag = id.artifact_tag();
seed_artifact(
®istry,
&repo,
&tag,
&layer.data,
&layer.media_type,
&config,
)
.await;
let dest = tmp.path().join("dest");
pull_with(&local_puller(®istry), &format!("{repo}:{tag}"), &dest)
.await
.unwrap()
.expect("pull");
let dest_pc = tokio::fs::read_to_string(dest.join("share/jq.pc"))
.await
.unwrap();
assert!(
!dest_pc.contains(TEXT_PLACEHOLDER),
"pull left a placeholder: {dest_pc}"
);
assert!(
dest_pc.contains(&dest.to_string_lossy().into_owned()),
"pulled .pc must be rehomed to the dest prefix; got: {dest_pc}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn pull_round_trip_from_local_registry_rehomes_and_stamps_source() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
build_fixture_toolchain(&src).await;
let built_prefix = "/opt/zlayer/toolchains/jq-1.8.1-macos-arm64";
let (layer_bytes, layer_media) =
pack_dir_tar_zstd(&src, &[".build", ".ready"], DEFAULT_ZSTD_LEVEL).unwrap();
let config = fixture_config(built_prefix, true);
let registry = Arc::new(LocalRegistry::new(tmp.path().join("reg")).await.unwrap());
let repo = registry_repo();
let tag = artifact_tag("jq", "1.8.1", "macos", "arm64");
let man_digest =
seed_artifact(®istry, &repo, &tag, &layer_bytes, &layer_media, &config).await;
let puller = local_puller(®istry);
let reference = format!("{repo}:{tag}");
let dest = tmp.path().join("dest");
let got = pull_with(&puller, &reference, &dest)
.await
.unwrap()
.expect("published toolchain should pull");
assert_eq!(got.reference, reference);
assert_eq!(got.digest, man_digest);
assert!(dest.join("bin/jq").is_file());
assert!(std::fs::symlink_metadata(dest.join("lib/jq-link"))
.unwrap()
.is_symlink());
assert!(!dest.join(".build").exists());
assert!(!dest.join(".ready").exists());
let pc = tokio::fs::read_to_string(dest.join("share/jq.pc"))
.await
.unwrap();
assert!(!pc.contains(TEXT_PLACEHOLDER), "placeholder survived: {pc}");
assert!(
pc.contains(&dest.to_string_lossy().into_owned()),
"not re-homed: {pc}"
);
let m = ToolchainManifest::read_from_toolchain(&dest)
.await
.unwrap()
.unwrap();
match &m.source {
ToolchainSource::Registry {
reference: r,
digest: d,
} => {
assert_eq!(r, &reference);
assert_eq!(d, &man_digest);
}
other => panic!("expected Registry source, got {other:?}"),
}
assert_eq!(m.path_dirs, vec![dest.join("bin").display().to_string()]);
assert_eq!(
m.env.get("JQ_LIB").unwrap(),
&dest.join("lib").display().to_string()
);
assert!(!dest.join(".ready").exists());
}
#[tokio::test]
async fn pull_miss_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let registry = Arc::new(LocalRegistry::new(tmp.path().join("reg")).await.unwrap());
let puller = local_puller(®istry);
let dest = tmp.path().join("dest");
let res = pull_with(&puller, "zlayertesttoolchains:jq-9.9.9-macos-arm64", &dest)
.await
.unwrap();
assert!(res.is_none(), "a never-published tag must pull to None");
}
#[cfg(unix)]
#[tokio::test]
async fn latest_alias_resolves_to_same_digest_as_immutable_tag() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
build_fixture_toolchain(&src).await;
let (layer_bytes, layer_media) =
pack_dir_tar_zstd(&src, &[".build", ".ready"], DEFAULT_ZSTD_LEVEL).unwrap();
let config = fixture_config("/opt/zlayer/toolchains/jq-1.8.1-macos-arm64", true);
let registry = Arc::new(LocalRegistry::new(tmp.path().join("reg")).await.unwrap());
let repo = registry_repo();
let tag = artifact_tag("jq", "1.8.1", "macos", "arm64");
let latest = latest_tag("jq", "macos", "arm64");
let tag_digest =
seed_artifact(®istry, &repo, &tag, &layer_bytes, &layer_media, &config).await;
let latest_digest = seed_artifact(
®istry,
&repo,
&latest,
&layer_bytes,
&layer_media,
&config,
)
.await;
assert_eq!(
tag_digest, latest_digest,
"same content ⇒ same manifest digest"
);
let puller = local_puller(®istry);
let (_m1, d1) = puller
.pull_manifest(&format!("{repo}:{tag}"), &RegistryAuth::Anonymous)
.await
.unwrap();
let (_m2, d2) = puller
.pull_manifest(&format!("{repo}:{latest}"), &RegistryAuth::Anonymous)
.await
.unwrap();
assert_eq!(
d1, d2,
"latest alias must resolve to the immutable tag's digest"
);
}
#[cfg(unix)]
#[tokio::test]
async fn unrelocatable_longer_prefix_cleans_dest_and_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
build_fixture_toolchain(&src).await;
let (layer_bytes, layer_media) =
pack_dir_tar_zstd(&src, &[".build", ".ready"], DEFAULT_ZSTD_LEVEL).unwrap();
let config = fixture_config("/x", false);
let registry = Arc::new(LocalRegistry::new(tmp.path().join("reg")).await.unwrap());
let repo = registry_repo();
let tag = artifact_tag("jq", "1.8.1", "macos", "arm64");
seed_artifact(®istry, &repo, &tag, &layer_bytes, &layer_media, &config).await;
let puller = local_puller(®istry);
let dest = tmp.path().join("dest-much-longer-than-slash-x");
let res = pull_with(&puller, &format!("{repo}:{tag}"), &dest)
.await
.unwrap();
assert!(res.is_none(), "unrelocatable-to-longer-prefix must decline");
assert!(!dest.exists(), "partial dest must be cleaned");
}
#[cfg(unix)]
#[tokio::test]
#[ignore = "live: pushes to a real OCI registry (ZLAYER_TOOLCHAIN_REGISTRY + auth)"]
async fn live_publish_then_pull_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
build_fixture_toolchain(&src).await;
let built_prefix = src.canonicalize().unwrap();
let id = ToolchainArtifactId {
tool: "zlayer-selftest".to_string(),
version: "0.0.1".to_string(),
os: "macos".to_string(),
arch: "arm64".to_string(),
};
let manifest = fixture_config(&built_prefix.to_string_lossy(), true).manifest;
let report = RelocationReport::default();
let published = publish_toolchain(&src, &manifest, &report, &built_prefix, &id)
.await
.expect("publish should succeed against the configured registry");
assert!(published.reference.ends_with(&id.artifact_tag()));
let dest = tmp.path().join("dest");
let pulled = try_pull_toolchain(&id, &dest)
.await
.expect("pull should not error")
.expect("the just-published toolchain should pull");
assert_eq!(pulled.digest, published.digest);
assert!(dest.join("bin/jq").is_file());
}
}