Skip to main content

zlayer_toolchain/
registry.rs

1//! Toolchain-artifact OCI registry publish/pull.
2//!
3//! A provisioned, [relocatable](crate::relocate) toolchain is published to a
4//! **single** OCI repository under a **structured tag** and pulled back down by
5//! any consumer that lacks it locally. This is the network tier of the
6//! toolchain cache: the source-build / prebuilt paths land a toolchain at an
7//! absolute prefix, [`crate::relocate::make_relocatable`] proves (or fails to
8//! prove) it prefix-independent, and this module ships the result so the next
9//! machine skips the compile.
10//!
11//! # Naming scheme
12//!
13//! - Repository: env `ZLAYER_TOOLCHAIN_REGISTRY`, default
14//!   [`DEFAULT_TOOLCHAIN_REGISTRY`] (`ghcr.io/blackleafdigital/zlayer/toolchains`).
15//! - Immutable tag: `{tool}-{version}-{os}-{arch}` ([`artifact_tag`]).
16//! - Movable alias: `{tool}-latest-{os}-{arch}` ([`latest_tag`]), repointed to
17//!   the freshly-published manifest on every publish.
18//!
19//! Tag components are [sanitized](sanitize_tag_component) (lowercase, `@`→`-`,
20//! anything outside `[a-z0-9._-]`→`-`, so `openssl@3` → `openssl-3`) because OCI
21//! tags are a restricted grammar. The *exact* identity is never lost — it is
22//! preserved verbatim in manifest annotations (`com.zlayer.toolchain.{tool,
23//! version,os,arch,prefix,relocatable}`) and inside the config blob.
24//!
25//! # Artifact shape
26//!
27//! - `artifactType` = [`ARTIFACT_TYPE`] (`application/vnd.zlayer.toolchain.v1`).
28//! - Config blob (`application/vnd.zlayer.toolchain.config.v1+json`) = a
29//!   [`ToolchainArtifactConfig`] JSON carrying the identity, the built prefix,
30//!   the relocatability flag, and the embedded [`ToolchainManifest`].
31//! - ONE layer = the toolchain tree packed by
32//!   [`zlayer_registry::pack::pack_dir_tar_zstd`] (excluding the `.build` /
33//!   `.ready` stamps) as `tar+zstd`.
34
35// `registry_repo` / `registry_auth_from_env` are the spec-mandated public API
36// names; they repeat this module's name (`registry`) by design, so the pedantic
37// naming lint is not actionable here.
38#![allow(clippy::module_name_repetitions)]
39
40use std::collections::BTreeMap;
41use std::path::Path;
42
43use serde::{Deserialize, Serialize};
44use tracing::{info, warn};
45
46use zlayer_registry::pack::{pack_dir_tar_zstd, DEFAULT_ZSTD_LEVEL};
47use zlayer_registry::{
48    ArtifactLayer, BlobCache, ImagePuller, LayerUnpacker, OciImageManifest, RegistryAuth,
49    RegistryError,
50};
51
52use crate::error::{Result, ToolchainError};
53use crate::manifest::{ToolchainManifest, ToolchainSource};
54use crate::relocate::{relocate_pulled, RelocationReport};
55
56/// Default toolchain-artifact repository when `ZLAYER_TOOLCHAIN_REGISTRY` is
57/// unset. A single repo holds every tool; the structured tag disambiguates.
58pub const DEFAULT_TOOLCHAIN_REGISTRY: &str = "ghcr.io/blackleafdigital/zlayer/toolchains";
59
60/// OCI `artifactType` for a published toolchain manifest.
61pub const ARTIFACT_TYPE: &str = "application/vnd.zlayer.toolchain.v1";
62
63/// Media type of the toolchain-artifact config blob (a
64/// [`ToolchainArtifactConfig`] JSON document).
65pub const CONFIG_MEDIA_TYPE: &str = "application/vnd.zlayer.toolchain.config.v1+json";
66
67/// `org.opencontainers.image.title` hint stored on the single toolchain layer.
68const LAYER_TITLE: &str = "toolchain.tar.zst";
69
70// Manifest annotation keys carrying the EXACT (un-sanitized) identity.
71const ANN_TOOL: &str = "com.zlayer.toolchain.tool";
72const ANN_VERSION: &str = "com.zlayer.toolchain.version";
73const ANN_OS: &str = "com.zlayer.toolchain.os";
74const ANN_ARCH: &str = "com.zlayer.toolchain.arch";
75const ANN_PREFIX: &str = "com.zlayer.toolchain.prefix";
76const ANN_RELOCATABLE: &str = "com.zlayer.toolchain.relocatable";
77
78/// The toolchain-artifact repository: `ZLAYER_TOOLCHAIN_REGISTRY` or, when unset
79/// or blank, [`DEFAULT_TOOLCHAIN_REGISTRY`].
80#[must_use]
81pub fn registry_repo() -> String {
82    std::env::var("ZLAYER_TOOLCHAIN_REGISTRY")
83        .ok()
84        .filter(|s| !s.trim().is_empty())
85        .unwrap_or_else(|| DEFAULT_TOOLCHAIN_REGISTRY.to_string())
86}
87
88/// The immutable artifact tag `{tool}-{version}-{os}-{arch}` (each component
89/// [sanitized](sanitize_tag_component)).
90#[must_use]
91pub fn artifact_tag(tool: &str, version: &str, os: &str, arch: &str) -> String {
92    format!(
93        "{}-{}-{}-{}",
94        sanitize_tag_component(tool),
95        sanitize_tag_component(version),
96        sanitize_tag_component(os),
97        sanitize_tag_component(arch),
98    )
99}
100
101/// The movable alias tag `{tool}-latest-{os}-{arch}` (each component
102/// [sanitized](sanitize_tag_component)) repointed on every publish.
103#[must_use]
104pub fn latest_tag(tool: &str, os: &str, arch: &str) -> String {
105    format!(
106        "{}-latest-{}-{}",
107        sanitize_tag_component(tool),
108        sanitize_tag_component(os),
109        sanitize_tag_component(arch),
110    )
111}
112
113/// Lowercase a tag component and replace every character outside `[a-z0-9._-]`
114/// (in particular `@`) with `-`, so an arbitrary tool/version/os/arch token
115/// becomes a valid OCI tag segment (`openssl@3` → `openssl-3`, `2.55.0` stays).
116#[must_use]
117pub fn sanitize_tag_component(s: &str) -> String {
118    s.chars()
119        .map(|c| {
120            let c = c.to_ascii_lowercase();
121            match c {
122                'a'..='z' | '0'..='9' | '.' | '_' | '-' => c,
123                _ => '-',
124            }
125        })
126        .collect()
127}
128
129/// Identity coordinates of a toolchain artifact: the four tokens that form its
130/// immutable tag and annotations.
131///
132/// Bundling them avoids a wide `publish_toolchain(tool, version, os, arch, …)`
133/// argument list and keeps the tool identity as one value through the pipeline.
134#[derive(Debug, Clone)]
135pub struct ToolchainArtifactId {
136    /// Brew/tool name (e.g. `jq`, `openssl@3`) — stored verbatim in annotations.
137    pub tool: String,
138    /// Resolved version (e.g. `1.8.1`).
139    pub version: String,
140    /// Platform token (`macos` / `windows`).
141    pub os: String,
142    /// Architecture token (`arm64` / `x86_64`).
143    pub arch: String,
144}
145
146impl ToolchainArtifactId {
147    /// The immutable tag for this identity — see [`artifact_tag`].
148    #[must_use]
149    pub fn artifact_tag(&self) -> String {
150        artifact_tag(&self.tool, &self.version, &self.os, &self.arch)
151    }
152
153    /// The movable alias tag for this identity — see [`latest_tag`].
154    #[must_use]
155    pub fn latest_tag(&self) -> String {
156        latest_tag(&self.tool, &self.os, &self.arch)
157    }
158}
159
160/// GHCR-style auth resolution for `registry_host`, mirroring
161/// `zlayer_builder::macos_image_resolver::resolve_ghcr_auth`
162/// (`crates/zlayer-builder/src/macos_image_resolver.rs:304`) — replicated
163/// rather than imported because this leaf crate must not depend on the builder.
164///
165/// Order: `GHCR_TOKEN` → `GITHUB_TOKEN` → `~/.docker/config.json`
166/// (`auths.<registry_host>.auth`, base64 `user:pass`) → [`RegistryAuth::Anonymous`].
167#[must_use]
168pub fn registry_auth_from_env(registry_host: &str) -> RegistryAuth {
169    if let Ok(token) = std::env::var("GHCR_TOKEN") {
170        if !token.is_empty() {
171            return RegistryAuth::Basic("_token".to_string(), token);
172        }
173    }
174    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
175        if !token.is_empty() {
176            return RegistryAuth::Basic("_token".to_string(), token);
177        }
178    }
179    if let Some(auth) = docker_config_auth(registry_host) {
180        return auth;
181    }
182    RegistryAuth::Anonymous
183}
184
185/// Read `auths.<registry_host>.auth` (base64 `user:pass`) from
186/// `~/.docker/config.json`, if present.
187fn docker_config_auth(registry_host: &str) -> Option<RegistryAuth> {
188    if registry_host.is_empty() {
189        return None;
190    }
191    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
192    let path = Path::new(&home).join(".docker").join("config.json");
193    let contents = std::fs::read_to_string(path).ok()?;
194    let config: serde_json::Value = serde_json::from_str(&contents).ok()?;
195    let auth_b64 = config
196        .get("auths")?
197        .get(registry_host)?
198        .get("auth")?
199        .as_str()?;
200    let decoded = base64_decode(auth_b64.trim())?;
201    let decoded = String::from_utf8(decoded).ok()?;
202    let (user, pass) = decoded.split_once(':')?;
203    Some(RegistryAuth::Basic(user.to_string(), pass.to_string()))
204}
205
206/// Minimal standard-alphabet base64 decoder (no external dep). Skips padding /
207/// whitespace; returns `None` on any invalid character.
208fn base64_decode(s: &str) -> Option<Vec<u8>> {
209    fn sextet(c: u8) -> Option<u32> {
210        match c {
211            b'A'..=b'Z' => Some(u32::from(c - b'A')),
212            b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
213            b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
214            b'+' => Some(62),
215            b'/' => Some(63),
216            _ => None,
217        }
218    }
219    let mut out = Vec::new();
220    let mut acc = 0u32;
221    let mut bits = 0u32;
222    for &c in s.as_bytes() {
223        if matches!(c, b'=' | b'\n' | b'\r' | b' ' | b'\t') {
224            continue;
225        }
226        acc = (acc << 6) | sextet(c)?;
227        bits += 6;
228        if bits >= 8 {
229            bits -= 8;
230            out.push(u8::try_from((acc >> bits) & 0xff).ok()?);
231        }
232    }
233    Some(out)
234}
235
236/// Registry host of a repository / reference string: the segment before the
237/// first `/` when it looks like a host (`.`, `:`, or `localhost`), else `""`
238/// (an unqualified name has no host).
239fn registry_host(reference: &str) -> &str {
240    match reference.split_once('/') {
241        Some((head, _)) if head.contains('.') || head.contains(':') || head == "localhost" => head,
242        _ => "",
243    }
244}
245
246/// The config-blob document (`application/vnd.zlayer.toolchain.config.v1+json`):
247/// the toolchain identity plus the embedded [`ToolchainManifest`] the consumer
248/// re-homes on pull.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ToolchainArtifactConfig {
251    /// Schema version (currently `1`).
252    pub format: u32,
253    /// Tool name (verbatim, e.g. `openssl@3`).
254    pub tool: String,
255    /// Resolved version.
256    pub version: String,
257    /// Platform token.
258    pub os: String,
259    /// Architecture token.
260    pub arch: String,
261    /// The absolute prefix the toolchain was built at (its recorded prefix —
262    /// what [`relocate_pulled`] rewrites away from).
263    pub built_prefix: String,
264    /// Whether the publisher proved the artifact fully prefix-independent. When
265    /// `false`, the consumer must null-pad patch binaries and can only install
266    /// at a prefix no longer than `built_prefix`.
267    pub relocatable: bool,
268    /// The toolchain manifest, with `path_dirs` / `env` rooted at
269    /// `built_prefix`. Re-homed to the local dir on pull.
270    pub manifest: ToolchainManifest,
271}
272
273/// Outcome of a successful [`publish_toolchain`].
274#[derive(Debug, Clone)]
275pub struct PublishedToolchain {
276    /// The immutable pull reference (`{repo}:{artifact_tag}`).
277    pub reference: String,
278    /// The pushed manifest digest (`sha256:...`).
279    pub digest: String,
280}
281
282/// Publish a relocatable toolchain at `dir` (built at `built_prefix`) to the
283/// toolchain registry under both its immutable tag and its `latest` alias.
284///
285/// The layer is [`dir`](pack_dir_tar_zstd) minus the `.build` / `.ready`
286/// stamps; the config blob is a [`ToolchainArtifactConfig`] carrying `manifest`;
287/// the manifest annotations carry the exact identity + relocatability. An
288/// artifact the publisher could NOT prove relocatable
289/// (`!report.is_fully_relocatable()`) is STILL published, with
290/// `relocatable="false"` and the built prefix recorded so the pull side can
291/// null-pad patch (or decline).
292///
293/// The `latest` alias is repointed by a second [`push_artifact`] of byte-identical
294/// content: OCI has no server-side re-tag primitive in `oci-client`, and identical
295/// bytes yield the identical manifest digest, so the alias resolves to the SAME
296/// manifest as the immutable tag. The blobs already exist from the first push, so
297/// the registry mounts them and only the manifest + tag are written.
298///
299/// [`push_artifact`]: zlayer_registry::ImagePuller::push_artifact
300///
301/// # Errors
302///
303/// Returns [`ToolchainError::RegistryError`] on a packing, auth, or push failure
304/// (an auth failure / anonymous-denied push is surfaced, never swallowed).
305pub async fn publish_toolchain(
306    dir: &Path,
307    manifest: &ToolchainManifest,
308    report: &RelocationReport,
309    built_prefix: &Path,
310    id: &ToolchainArtifactId,
311) -> Result<PublishedToolchain> {
312    let repo = registry_repo();
313    let reference = format!("{repo}:{}", id.artifact_tag());
314    let latest_ref = format!("{repo}:{}", id.latest_tag());
315    let relocatable = report.is_fully_relocatable();
316
317    let (config_blob, layer, annotations) =
318        build_artifact(dir, manifest, built_prefix, id, relocatable, report).await?;
319
320    let auth = registry_auth_from_env(registry_host(&repo));
321    let cache = BlobCache::new().map_err(|e| ToolchainError::RegistryError {
322        message: format!("failed to init blob cache for toolchain publish: {e}"),
323    })?;
324    let puller = ImagePuller::new(cache);
325
326    let layers = std::slice::from_ref(&layer);
327    info!(
328        reference = %reference,
329        relocatable,
330        "publishing toolchain artifact"
331    );
332    let push = puller
333        .push_artifact(
334            &reference,
335            ARTIFACT_TYPE,
336            CONFIG_MEDIA_TYPE,
337            &config_blob,
338            layers,
339            annotations.clone(),
340            &auth,
341        )
342        .await
343        .map_err(|e| ToolchainError::RegistryError {
344            message: format!("push toolchain artifact {reference}: {e}"),
345        })?;
346
347    // Repoint the `latest` alias to the SAME content (identical bytes → identical
348    // digest → same manifest). See the fn docs for why this is a second push.
349    puller
350        .push_artifact(
351            &latest_ref,
352            ARTIFACT_TYPE,
353            CONFIG_MEDIA_TYPE,
354            &config_blob,
355            layers,
356            annotations,
357            &auth,
358        )
359        .await
360        .map_err(|e| ToolchainError::RegistryError {
361            message: format!("repoint latest alias {latest_ref}: {e}"),
362        })?;
363
364    info!(
365        reference = %reference,
366        digest = %push.manifest_digest,
367        "toolchain artifact published"
368    );
369    Ok(PublishedToolchain {
370        reference,
371        digest: push.manifest_digest,
372    })
373}
374
375/// Build the `(config_blob, layer, annotations)` triple for a publish: pack the
376/// toolchain tree (excluding `.build` / `.ready`) and serialize the config.
377///
378/// The LIVE toolchain tree is never mutated here: text files that carry the
379/// built prefix (`report.text_files_with_prefix`) must keep their real absolute
380/// paths on disk so same-machine dependent builds resolve `.pc`/cmake/scripts.
381/// So the tree is copied to a scratch dir, the placeholders are applied to the
382/// COPY, and the copy is what gets packed. Mach-O `@loader_path` rewrites are
383/// already in the live tree (done by `make_relocatable`), so the copy inherits
384/// them; only the text files differ (real in the live tree, placeholdered in
385/// the artifact).
386async fn build_artifact(
387    dir: &Path,
388    manifest: &ToolchainManifest,
389    built_prefix: &Path,
390    id: &ToolchainArtifactId,
391    relocatable: bool,
392    report: &RelocationReport,
393) -> Result<(Vec<u8>, ArtifactLayer, BTreeMap<String, String>)> {
394    let built_prefix = built_prefix.to_string_lossy().into_owned();
395    let config = ToolchainArtifactConfig {
396        format: 1,
397        tool: id.tool.clone(),
398        version: id.version.clone(),
399        os: id.os.clone(),
400        arch: id.arch.clone(),
401        built_prefix: built_prefix.clone(),
402        relocatable,
403        manifest: manifest.clone(),
404    };
405    let config_blob = serde_json::to_vec(&config).map_err(|e| ToolchainError::RegistryError {
406        message: format!("serialize toolchain artifact config for {}: {e}", id.tool),
407    })?;
408
409    // Pack from a scratch COPY with text placeholders applied, so the live tree
410    // keeps real absolute paths. When no text file carries the prefix, pack the
411    // live tree directly (nothing to placeholder → no copy needed).
412    let (layer_bytes, layer_media) = if report.text_files_with_prefix.is_empty() {
413        let root = dir.to_path_buf();
414        tokio::task::spawn_blocking(move || {
415            pack_dir_tar_zstd(&root, &[".build", ".ready"], DEFAULT_ZSTD_LEVEL)
416        })
417        .await
418        .map_err(|e| ToolchainError::RegistryError {
419            message: format!("toolchain layer packing task failed: {e}"),
420        })??
421    } else {
422        let scratch = tempfile::tempdir().map_err(|e| ToolchainError::RegistryError {
423            message: format!("create publish scratch dir for {}: {e}", id.tool),
424        })?;
425        let copy_root = scratch.path().join("tree");
426        copy_tree(dir, &copy_root).await?;
427        crate::relocate::apply_text_placeholders(
428            &copy_root,
429            Path::new(built_prefix.as_str()),
430            dir,
431            &report.text_files_with_prefix,
432        )
433        .await?;
434        let root = copy_root.clone();
435        tokio::task::spawn_blocking(move || {
436            pack_dir_tar_zstd(&root, &[".build", ".ready"], DEFAULT_ZSTD_LEVEL)
437        })
438        .await
439        .map_err(|e| ToolchainError::RegistryError {
440            message: format!("toolchain layer packing task failed: {e}"),
441        })??
442        // `scratch` drops here, removing the copy.
443    };
444
445    let mut annotations = BTreeMap::new();
446    annotations.insert(ANN_TOOL.to_string(), id.tool.clone());
447    annotations.insert(ANN_VERSION.to_string(), id.version.clone());
448    annotations.insert(ANN_OS.to_string(), id.os.clone());
449    annotations.insert(ANN_ARCH.to_string(), id.arch.clone());
450    annotations.insert(ANN_PREFIX.to_string(), built_prefix);
451    annotations.insert(
452        ANN_RELOCATABLE.to_string(),
453        if relocatable { "true" } else { "false" }.to_string(),
454    );
455
456    let layer = ArtifactLayer {
457        data: layer_bytes,
458        media_type: layer_media,
459        title: Some(LAYER_TITLE.to_string()),
460    };
461    Ok((config_blob, layer, annotations))
462}
463
464/// Recursively copy `src` into `dest` (created), preserving directories, file
465/// contents+permissions, and symlinks AS symlinks (toolchain trees carry
466/// `opt/` symlinks that must survive into the packed layer). Used only to make
467/// a publish scratch copy — never touches the live toolchain tree.
468async fn copy_tree(src: &Path, dest: &Path) -> Result<()> {
469    let mut stack = vec![(src.to_path_buf(), dest.to_path_buf())];
470    while let Some((from, to)) = stack.pop() {
471        let meta = tokio::fs::symlink_metadata(&from).await?;
472        let ft = meta.file_type();
473        if ft.is_symlink() {
474            let target = tokio::fs::read_link(&from).await?;
475            #[cfg(unix)]
476            tokio::fs::symlink(&target, &to).await?;
477            #[cfg(windows)]
478            {
479                // Best-effort: file symlink (dir symlinks are rare in toolchains).
480                let _ = tokio::fs::symlink_file(&target, &to).await;
481            }
482        } else if ft.is_dir() {
483            tokio::fs::create_dir_all(&to).await?;
484            let mut rd = tokio::fs::read_dir(&from).await?;
485            while let Some(entry) = rd.next_entry().await? {
486                let name = entry.file_name();
487                stack.push((from.join(&name), to.join(&name)));
488            }
489        } else {
490            if let Some(parent) = to.parent() {
491                tokio::fs::create_dir_all(parent).await?;
492            }
493            tokio::fs::copy(&from, &to).await?;
494        }
495    }
496    Ok(())
497}
498
499/// Outcome of a successful [`try_pull_toolchain`].
500#[derive(Debug, Clone)]
501pub struct PulledToolchain {
502    /// The immutable reference the toolchain was pulled from.
503    pub reference: String,
504    /// The manifest digest (`sha256:...`) the pull was pinned to.
505    pub digest: String,
506}
507
508/// Try to pull `id`'s toolchain artifact into `dest_dir`.
509///
510/// Resolves `{repo}:{artifact_tag}` (anonymous first, retrying with
511/// [`registry_auth_from_env`] on an auth error). A missing tag (404 /
512/// manifest-unknown) is **`Ok(None)`** — the caller builds instead; a real
513/// transport error is **`Err`**. On a hit the layer is unpacked into `dest_dir`
514/// and [`relocate_pulled`] re-homes it from the recorded prefix to `dest_dir`.
515/// A non-relocatable artifact whose local prefix would be LONGER than the
516/// recorded one cannot be null-pad patched: `dest_dir` is cleared and
517/// **`Ok(None)`** returned (the caller falls back to building).
518///
519/// The embedded manifest is re-homed (absolute built-prefix paths in
520/// `path_dirs` / `env` rewritten to `dest_dir`), stamped
521/// [`ToolchainSource::Registry`], and written as `dest_dir/toolchain.json`. The
522/// `.ready` marker is deliberately NOT written — the caller's finalize owns it.
523///
524/// # Errors
525///
526/// Returns [`ToolchainError::RegistryError`] on a transport / unpack failure and
527/// propagates relocation / I/O errors.
528pub async fn try_pull_toolchain(
529    id: &ToolchainArtifactId,
530    dest_dir: &Path,
531) -> Result<Option<PulledToolchain>> {
532    let repo = registry_repo();
533    let reference = format!("{repo}:{}", id.artifact_tag());
534    let cache = BlobCache::new().map_err(|e| ToolchainError::RegistryError {
535        message: format!("failed to init blob cache for toolchain pull: {e}"),
536    })?;
537    let puller = ImagePuller::new(cache);
538    pull_with(&puller, &reference, dest_dir).await
539}
540
541/// Pull-and-rehome against an already-constructed [`ImagePuller`] (so tests can
542/// wire a [`LocalRegistry`](zlayer_registry::LocalRegistry) in-process).
543async fn pull_with(
544    puller: &ImagePuller,
545    reference: &str,
546    dest_dir: &Path,
547) -> Result<Option<PulledToolchain>> {
548    let Some((manifest, digest, auth)) = resolve_manifest(puller, reference).await? else {
549        info!(reference = %reference, "toolchain artifact not published; will build");
550        return Ok(None);
551    };
552
553    let config = pull_config(puller, reference, &manifest, &auth).await?;
554
555    // Pull every layer (the toolchain is one layer, but resolve generically).
556    let mut layers: Vec<(Vec<u8>, String)> = Vec::with_capacity(manifest.layers.len());
557    for descriptor in &manifest.layers {
558        let data = puller
559            .pull_blob(reference, &descriptor.digest, &auth)
560            .await
561            .map_err(|e| ToolchainError::RegistryError {
562                message: format!(
563                    "pull toolchain layer {} for {reference}: {e}",
564                    descriptor.digest
565                ),
566            })?;
567        layers.push((data, descriptor.media_type.clone()));
568    }
569
570    tokio::fs::create_dir_all(dest_dir).await?;
571
572    // Mirror `relocate_pulled`'s guard BEFORE writing anything: a
573    // non-relocatable artifact is null-pad patched in place, so it cannot grow
574    // the prefix. A longer local prefix means "unusable here" → clean + build.
575    let local_prefix = dest_dir.to_string_lossy().into_owned();
576    if !config.relocatable && local_prefix.len() > config.built_prefix.len() {
577        warn!(
578            reference = %reference,
579            recorded_prefix = %config.built_prefix,
580            local_prefix = %local_prefix,
581            "pulled toolchain is not relocatable to this (longer) local prefix; will build"
582        );
583        let _ = tokio::fs::remove_dir_all(dest_dir).await;
584        return Ok(None);
585    }
586
587    let mut unpacker = LayerUnpacker::new(dest_dir.to_path_buf());
588    unpacker
589        .unpack_layers(&layers)
590        .await
591        .map_err(|e| ToolchainError::RegistryError {
592            message: format!("unpack toolchain layers into {}: {e}", dest_dir.display()),
593        })?;
594
595    relocate_pulled(dest_dir, &config.built_prefix, dest_dir, config.relocatable).await?;
596
597    // Re-home the embedded manifest onto the local dir and record provenance.
598    let mut rehomed = config.manifest;
599    rehome_manifest(&mut rehomed, &config.built_prefix, &local_prefix);
600    rehomed.source = ToolchainSource::Registry {
601        reference: reference.to_string(),
602        digest: digest.clone(),
603    };
604    rehomed.write_to_toolchain(dest_dir).await?;
605
606    info!(reference = %reference, digest = %digest, dest = %dest_dir.display(), "toolchain artifact pulled");
607    Ok(Some(PulledToolchain {
608        reference: reference.to_string(),
609        digest,
610    }))
611}
612
613/// Resolve the artifact manifest: anonymous first, retry with env auth on an
614/// auth error. `Ok(None)` on not-found OR a persistent auth failure; `Err` only
615/// on a real transport error. The returned [`RegistryAuth`] is the one that
616/// worked (reused for blob pulls).
617///
618/// A persistent 401 (anonymous 401 → retry with env creds → still 401) is
619/// treated as a MISS, not an error: GHCR returns 401 for a repo/tag that does
620/// not exist yet, so for our OWN toolchain repo "unauthorized after trying real
621/// creds" means "nothing published here" → build it (pull-miss → build is always
622/// safe). This keeps a cold/empty registry from logging a scary auth WARN on
623/// every resolve.
624async fn resolve_manifest(
625    puller: &ImagePuller,
626    reference: &str,
627) -> Result<Option<(OciImageManifest, String, RegistryAuth)>> {
628    match puller
629        .pull_manifest(reference, &RegistryAuth::Anonymous)
630        .await
631    {
632        Ok((manifest, digest)) => Ok(Some((manifest, digest, RegistryAuth::Anonymous))),
633        Err(ref e) if err_is_not_found(e) => Ok(None),
634        Err(ref e) if err_is_auth(e) => {
635            let auth = registry_auth_from_env(registry_host(reference));
636            match puller.pull_manifest(reference, &auth).await {
637                Ok((manifest, digest)) => Ok(Some((manifest, digest, auth))),
638                // Not-found OR still-unauthorized after real creds == not
639                // published to us yet → miss, build it (never a scary warn).
640                Err(ref e2) if err_is_not_found(e2) || err_is_auth(e2) => Ok(None),
641                Err(e2) => Err(ToolchainError::RegistryError {
642                    message: format!("pull toolchain manifest {reference} (authed): {e2}"),
643                }),
644            }
645        }
646        Err(e) => Err(ToolchainError::RegistryError {
647            message: format!("pull toolchain manifest {reference}: {e}"),
648        }),
649    }
650}
651
652/// Pull + parse the config blob referenced by `manifest.config`.
653async fn pull_config(
654    puller: &ImagePuller,
655    reference: &str,
656    manifest: &OciImageManifest,
657    auth: &RegistryAuth,
658) -> Result<ToolchainArtifactConfig> {
659    let bytes = puller
660        .pull_blob(reference, &manifest.config.digest, auth)
661        .await
662        .map_err(|e| ToolchainError::RegistryError {
663            message: format!("pull toolchain config blob for {reference}: {e}"),
664        })?;
665    serde_json::from_slice(&bytes).map_err(|e| ToolchainError::RegistryError {
666        message: format!("parse toolchain config blob for {reference}: {e}"),
667    })
668}
669
670/// Rewrite every absolute `recorded`-prefixed string in the manifest's
671/// `path_dirs` / `env` values to `local` (the pulled dir).
672fn rehome_manifest(manifest: &mut ToolchainManifest, recorded: &str, local: &str) {
673    for dir in &mut manifest.path_dirs {
674        if dir.contains(recorded) {
675            *dir = dir.replace(recorded, local);
676        }
677    }
678    for value in manifest.env.values_mut() {
679        if value.contains(recorded) {
680            *value = value.replace(recorded, local);
681        }
682    }
683}
684
685/// Does this registry error mean "the manifest/tag does not exist" (→ `Ok(None)`)
686/// as opposed to a real transport failure (→ `Err`)?
687///
688/// A structural [`RegistryError::NotFound`] is definitive. `oci-client` surfaces a
689/// registry 404 as an [`RegistryError::Oci`] whose `Display` we cannot match
690/// structurally without depending on `oci-client`, so we string-match the
691/// documented not-found shapes (see [`msg_is_not_found`]).
692fn err_is_not_found(err: &RegistryError) -> bool {
693    matches!(err, RegistryError::NotFound { .. }) || msg_is_not_found(&err.to_string())
694}
695
696/// Not-found substrings across `oci-client`'s error `Display`s:
697/// `ImageManifestNotFoundError` ("… not found"), a 404 `ServerError`
698/// ("code: 404"), and registry-envelope codes
699/// (`MANIFEST_UNKNOWN`/`NAME_UNKNOWN`/`BLOB_UNKNOWN`/`NOT_FOUND`).
700fn msg_is_not_found(msg: &str) -> bool {
701    let m = msg.to_ascii_lowercase();
702    m.contains("not found")
703        || m.contains("notfound")
704        || m.contains("not_found")
705        || m.contains("manifest unknown")
706        || m.contains("manifestunknown")
707        || m.contains("manifest_unknown")
708        || m.contains("name unknown")
709        || m.contains("nameunknown")
710        || m.contains("name_unknown")
711        || m.contains("blob unknown")
712        || m.contains("blobunknown")
713        || m.contains("blob_unknown")
714        || m.contains("code: 404")
715        || m.contains("404")
716}
717
718/// Does this error indicate an auth failure (→ retry with env credentials)?
719fn err_is_auth(err: &RegistryError) -> bool {
720    matches!(err, RegistryError::AuthFailed { .. }) || msg_is_auth(&err.to_string())
721}
722
723/// Auth-failure substrings across `oci-client`'s error `Display`s
724/// (`AuthenticationFailure`, `Not authorized`, a 401 `ServerError`).
725fn msg_is_auth(msg: &str) -> bool {
726    let m = msg.to_ascii_lowercase();
727    m.contains("authentication fail")
728        || m.contains("not authorized")
729        || m.contains("unauthorized")
730        || m.contains("code: 401")
731        || m.contains("401")
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate::relocate::TEXT_PLACEHOLDER;
738    use std::collections::HashMap;
739    use std::sync::Arc;
740    use zlayer_registry::{BlobCache, BlobCacheBackend, LocalRegistry};
741
742    // -- tag sanitization ---------------------------------------------------
743
744    #[test]
745    fn sanitize_lowercases_and_replaces_specials() {
746        assert_eq!(sanitize_tag_component("openssl@3"), "openssl-3");
747        assert_eq!(sanitize_tag_component("GIT"), "git");
748        assert_eq!(sanitize_tag_component("2.55.0"), "2.55.0");
749        assert_eq!(
750            sanitize_tag_component("weird/name space!"),
751            "weird-name-space-"
752        );
753        // Dots, dashes, underscores survive; everything else collapses to `-`.
754        assert_eq!(sanitize_tag_component("a_b-c.d"), "a_b-c.d");
755    }
756
757    #[test]
758    fn artifact_and_latest_tags_are_structured_and_sanitized() {
759        assert_eq!(
760            artifact_tag("openssl@3", "3", "macos", "arm64"),
761            "openssl-3-3-macos-arm64"
762        );
763        assert_eq!(
764            artifact_tag("GIT", "2.55.0", "macOS", "ARM64"),
765            "git-2.55.0-macos-arm64"
766        );
767        assert_eq!(latest_tag("jq", "macos", "arm64"), "jq-latest-macos-arm64");
768
769        let id = ToolchainArtifactId {
770            tool: "jq".to_string(),
771            version: "1.8.1".to_string(),
772            os: "macos".to_string(),
773            arch: "arm64".to_string(),
774        };
775        assert_eq!(id.artifact_tag(), "jq-1.8.1-macos-arm64");
776        assert_eq!(id.latest_tag(), "jq-latest-macos-arm64");
777        assert_ne!(id.artifact_tag(), id.latest_tag());
778    }
779
780    #[test]
781    fn registry_repo_defaults_and_reads_env() {
782        // The default holds unless the env var is set; exercised without
783        // mutating process env (which would race other tests).
784        assert_eq!(
785            DEFAULT_TOOLCHAIN_REGISTRY,
786            "ghcr.io/blackleafdigital/zlayer/toolchains"
787        );
788        assert!(registry_repo().contains("toolchains"));
789    }
790
791    // -- 404-vs-transport / auth classifiers --------------------------------
792
793    #[test]
794    fn not_found_classifier_matches_oci_not_found_shapes() {
795        assert!(msg_is_not_found("Image manifest not found: foo"));
796        assert!(msg_is_not_found(
797            "Registry error: url x, envelope: MANIFEST_UNKNOWN"
798        ));
799        assert!(msg_is_not_found(
800            "Server error: url x, code: 404, message: nope"
801        ));
802        assert!(msg_is_not_found("NAME_UNKNOWN"));
803        // Transport failures are NOT not-found.
804        assert!(!msg_is_not_found("connection refused"));
805        assert!(!msg_is_not_found("dns error: failed to lookup host"));
806        assert!(!msg_is_not_found("operation timed out"));
807
808        // Structural NotFound is definitive.
809        assert!(err_is_not_found(&RegistryError::NotFound {
810            registry: "local".to_string(),
811            image: "x".to_string(),
812        }));
813    }
814
815    #[test]
816    fn auth_classifier_matches_oci_auth_shapes() {
817        assert!(msg_is_auth("Authentication failure: bad creds"));
818        assert!(msg_is_auth("Not authorized: url https://ghcr.io/token"));
819        assert!(msg_is_auth(
820            "Server error: url x, code: 401, message: denied"
821        ));
822        assert!(!msg_is_auth("Image manifest not found: foo"));
823        assert!(err_is_auth(&RegistryError::AuthFailed {
824            registry: "ghcr.io".to_string(),
825            reason: "denied".to_string(),
826        }));
827    }
828
829    #[test]
830    fn rehome_manifest_rewrites_prefix_paths() {
831        let mut env = HashMap::new();
832        env.insert("JQ_LIB".to_string(), "/built/tc/lib".to_string());
833        env.insert("UNRELATED".to_string(), "keepme".to_string());
834        let mut m = ToolchainManifest {
835            tool: "jq".to_string(),
836            version: "1.8.1".to_string(),
837            arch: "arm64".to_string(),
838            platform: "macos".to_string(),
839            path_dirs: vec!["/built/tc/bin".to_string()],
840            env,
841            source: ToolchainSource::SourceBuild {
842                url: String::new(),
843                sha256: String::new(),
844            },
845            build_deps: vec![],
846            provisioned_at: String::new(),
847        };
848        rehome_manifest(&mut m, "/built/tc", "/local/dest");
849        assert_eq!(m.path_dirs, vec!["/local/dest/bin".to_string()]);
850        assert_eq!(m.env.get("JQ_LIB").unwrap(), "/local/dest/lib");
851        assert_eq!(m.env.get("UNRELATED").unwrap(), "keepme");
852    }
853
854    #[test]
855    fn base64_decode_round_trips_user_pass() {
856        // base64("_token:ghp_abc123") == "X3Rva2VuOmdocF9hYmMxMjM="
857        let decoded = base64_decode("X3Rva2VuOmdocF9hYmMxMjM=").unwrap();
858        assert_eq!(String::from_utf8(decoded).unwrap(), "_token:ghp_abc123");
859        assert!(base64_decode("not base64 %%%").is_none());
860    }
861
862    // -- LocalRegistry-backed pull round trip (in-process, no network) -------
863
864    /// A tiny fixture toolchain tree: a bin file, a lib symlink, a text file
865    /// containing the relocation placeholder, and top-level `.build`/`.ready`
866    /// stamps that MUST be excluded from the layer.
867    #[cfg(unix)]
868    async fn build_fixture_toolchain(src: &Path) {
869        tokio::fs::create_dir_all(src.join("bin")).await.unwrap();
870        tokio::fs::create_dir_all(src.join("lib")).await.unwrap();
871        tokio::fs::create_dir_all(src.join("share")).await.unwrap();
872        tokio::fs::write(src.join("bin/jq"), b"#!/bin/sh\nexit 0\n")
873            .await
874            .unwrap();
875        // A .pc-style text file as a PUBLISHED ARTIFACT carries it: the built
876        // prefix has been replaced by the placeholder (on the publish copy).
877        // The pull path (relocate_pulled) restores it to the local prefix.
878        tokio::fs::write(
879            src.join("share/jq.pc"),
880            format!("prefix={TEXT_PLACEHOLDER}\nlibdir={TEXT_PLACEHOLDER}/lib\n"),
881        )
882        .await
883        .unwrap();
884        std::os::unix::fs::symlink("../bin/jq", src.join("lib/jq-link")).unwrap();
885        tokio::fs::create_dir_all(src.join(".build")).await.unwrap();
886        tokio::fs::write(src.join(".build/stamp"), b"x")
887            .await
888            .unwrap();
889        tokio::fs::write(src.join(".ready"), b"").await.unwrap();
890    }
891
892    /// Store a toolchain artifact (config + one layer + annotated manifest)
893    /// directly into a [`LocalRegistry`] under `{repo}:{tag}`, exactly as a
894    /// publish would leave it — so the pull path resolves it with no network.
895    /// Returns the manifest content digest.
896    async fn seed_artifact(
897        registry: &LocalRegistry,
898        repo: &str,
899        tag: &str,
900        layer_bytes: &[u8],
901        layer_media: &str,
902        config: &ToolchainArtifactConfig,
903    ) -> String {
904        let config_blob = serde_json::to_vec(config).unwrap();
905        let config_digest = registry.put_blob(&config_blob).await.unwrap();
906        let layer_digest = registry.put_blob(layer_bytes).await.unwrap();
907        let manifest_json = format!(
908            concat!(
909                r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","#,
910                r#""artifactType":"{artifact_type}","#,
911                r#""config":{{"mediaType":"{config_media}","digest":"{config_digest}","size":{config_size}}},"#,
912                r#""layers":[{{"mediaType":"{layer_media}","digest":"{layer_digest}","size":{layer_size}}}],"#,
913                r#""annotations":{{"{ann_tool}":"{tool}","{ann_reloc}":"{reloc}"}}}}"#
914            ),
915            artifact_type = ARTIFACT_TYPE,
916            config_media = CONFIG_MEDIA_TYPE,
917            config_digest = config_digest,
918            config_size = config_blob.len(),
919            layer_media = layer_media,
920            layer_digest = layer_digest,
921            layer_size = layer_bytes.len(),
922            ann_tool = ANN_TOOL,
923            tool = config.tool,
924            ann_reloc = ANN_RELOCATABLE,
925            reloc = config.relocatable,
926        );
927        registry
928            .put_manifest(repo, tag, manifest_json.as_bytes())
929            .await
930            .unwrap()
931    }
932
933    fn local_puller(registry: &Arc<LocalRegistry>) -> ImagePuller {
934        let cache: Arc<Box<dyn BlobCacheBackend>> = Arc::new(Box::new(BlobCache::new().unwrap()));
935        ImagePuller::with_cache(cache).with_local_registry(registry.clone())
936    }
937
938    fn fixture_config(built_prefix: &str, relocatable: bool) -> ToolchainArtifactConfig {
939        let mut env = HashMap::new();
940        env.insert("JQ_LIB".to_string(), format!("{built_prefix}/lib"));
941        ToolchainArtifactConfig {
942            format: 1,
943            tool: "jq".to_string(),
944            version: "1.8.1".to_string(),
945            os: "macos".to_string(),
946            arch: "arm64".to_string(),
947            built_prefix: built_prefix.to_string(),
948            relocatable,
949            manifest: ToolchainManifest {
950                tool: "jq".to_string(),
951                version: "1.8.1".to_string(),
952                arch: "arm64".to_string(),
953                platform: "macos".to_string(),
954                path_dirs: vec![format!("{built_prefix}/bin")],
955                env,
956                source: ToolchainSource::SourceBuild {
957                    url: String::new(),
958                    sha256: String::new(),
959                },
960                build_deps: vec![],
961                provisioned_at: "2026-07-06T00:00:00Z".to_string(),
962            },
963        }
964    }
965
966    /// The publish path must NOT mutate the live toolchain tree: text files
967    /// keep their real prefix on disk (so same-machine dependents read them),
968    /// while the PACKED artifact carries the placeholder. Proven end-to-end by
969    /// `make_relocatable` (records) → `build_artifact` (packs a placeholdered
970    /// copy) → pull (rehomes the placeholder to the dest prefix).
971    #[cfg(unix)]
972    #[tokio::test]
973    async fn publish_placeholders_a_copy_not_the_live_tree() {
974        let tmp = tempfile::tempdir().unwrap();
975        let src = tmp.path().join("jq-1.8.1-macos-arm64");
976        tokio::fs::create_dir_all(src.join("bin")).await.unwrap();
977        tokio::fs::create_dir_all(src.join("share")).await.unwrap();
978        tokio::fs::write(src.join("bin/jq"), b"#!/bin/sh\nexit 0\n")
979            .await
980            .unwrap();
981        let real = src.to_string_lossy().into_owned();
982        let pc = src.join("share/jq.pc");
983        tokio::fs::write(&pc, format!("prefix={real}\nlibdir={real}/lib\n"))
984            .await
985            .unwrap();
986
987        let report = crate::relocate::make_relocatable(&src, &src, &[])
988            .await
989            .unwrap();
990        assert_eq!(report.text_files_with_prefix, vec![pc.clone()]);
991        assert!(
992            tokio::fs::read_to_string(&pc)
993                .await
994                .unwrap()
995                .contains(&real),
996            "make_relocatable must leave the live .pc real-pathed"
997        );
998
999        let config = fixture_config(&real, report.is_fully_relocatable());
1000        let id = ToolchainArtifactId {
1001            tool: "jq".to_string(),
1002            version: "1.8.1".to_string(),
1003            os: "macos".to_string(),
1004            arch: "arm64".to_string(),
1005        };
1006        let (_cfg, layer, _ann) = build_artifact(
1007            &src,
1008            &config.manifest,
1009            &src,
1010            &id,
1011            report.is_fully_relocatable(),
1012            &report,
1013        )
1014        .await
1015        .unwrap();
1016
1017        // The publish packing left the LIVE tree untouched.
1018        assert!(
1019            tokio::fs::read_to_string(&pc)
1020                .await
1021                .unwrap()
1022                .contains(&real),
1023            "publish must not mutate the live toolchain tree"
1024        );
1025
1026        // The packed artifact carries the placeholder — prove via a pull.
1027        let registry = Arc::new(LocalRegistry::new(tmp.path().join("reg")).await.unwrap());
1028        let repo = registry_repo();
1029        let tag = id.artifact_tag();
1030        seed_artifact(
1031            &registry,
1032            &repo,
1033            &tag,
1034            &layer.data,
1035            &layer.media_type,
1036            &config,
1037        )
1038        .await;
1039        let dest = tmp.path().join("dest");
1040        pull_with(&local_puller(&registry), &format!("{repo}:{tag}"), &dest)
1041            .await
1042            .unwrap()
1043            .expect("pull");
1044        let dest_pc = tokio::fs::read_to_string(dest.join("share/jq.pc"))
1045            .await
1046            .unwrap();
1047        assert!(
1048            !dest_pc.contains(TEXT_PLACEHOLDER),
1049            "pull left a placeholder: {dest_pc}"
1050        );
1051        assert!(
1052            dest_pc.contains(&dest.to_string_lossy().into_owned()),
1053            "pulled .pc must be rehomed to the dest prefix; got: {dest_pc}"
1054        );
1055    }
1056
1057    #[cfg(unix)]
1058    #[tokio::test]
1059    async fn pull_round_trip_from_local_registry_rehomes_and_stamps_source() {
1060        let tmp = tempfile::tempdir().unwrap();
1061        let src = tmp.path().join("src");
1062        build_fixture_toolchain(&src).await;
1063
1064        let built_prefix = "/opt/zlayer/toolchains/jq-1.8.1-macos-arm64";
1065        let (layer_bytes, layer_media) =
1066            pack_dir_tar_zstd(&src, &[".build", ".ready"], DEFAULT_ZSTD_LEVEL).unwrap();
1067        let config = fixture_config(built_prefix, true);
1068
1069        let registry = Arc::new(LocalRegistry::new(tmp.path().join("reg")).await.unwrap());
1070        let repo = registry_repo();
1071        let tag = artifact_tag("jq", "1.8.1", "macos", "arm64");
1072        let man_digest =
1073            seed_artifact(&registry, &repo, &tag, &layer_bytes, &layer_media, &config).await;
1074
1075        let puller = local_puller(&registry);
1076        let reference = format!("{repo}:{tag}");
1077        let dest = tmp.path().join("dest");
1078        let got = pull_with(&puller, &reference, &dest)
1079            .await
1080            .unwrap()
1081            .expect("published toolchain should pull");
1082
1083        assert_eq!(got.reference, reference);
1084        assert_eq!(got.digest, man_digest);
1085
1086        // Tree materialized; `.build`/`.ready` excluded.
1087        assert!(dest.join("bin/jq").is_file());
1088        assert!(std::fs::symlink_metadata(dest.join("lib/jq-link"))
1089            .unwrap()
1090            .is_symlink());
1091        assert!(!dest.join(".build").exists());
1092        assert!(!dest.join(".ready").exists());
1093
1094        // Placeholder rewritten to the LOCAL dest prefix.
1095        let pc = tokio::fs::read_to_string(dest.join("share/jq.pc"))
1096            .await
1097            .unwrap();
1098        assert!(!pc.contains(TEXT_PLACEHOLDER), "placeholder survived: {pc}");
1099        assert!(
1100            pc.contains(&dest.to_string_lossy().into_owned()),
1101            "not re-homed: {pc}"
1102        );
1103
1104        // Manifest written, source == Registry, paths re-homed onto dest.
1105        let m = ToolchainManifest::read_from_toolchain(&dest)
1106            .await
1107            .unwrap()
1108            .unwrap();
1109        match &m.source {
1110            ToolchainSource::Registry {
1111                reference: r,
1112                digest: d,
1113            } => {
1114                assert_eq!(r, &reference);
1115                assert_eq!(d, &man_digest);
1116            }
1117            other => panic!("expected Registry source, got {other:?}"),
1118        }
1119        assert_eq!(m.path_dirs, vec![dest.join("bin").display().to_string()]);
1120        assert_eq!(
1121            m.env.get("JQ_LIB").unwrap(),
1122            &dest.join("lib").display().to_string()
1123        );
1124
1125        // `.ready` is the caller's to write — pull must not stamp it.
1126        assert!(!dest.join(".ready").exists());
1127    }
1128
1129    #[tokio::test]
1130    async fn pull_miss_returns_none() {
1131        let tmp = tempfile::tempdir().unwrap();
1132        // Empty local registry + an unqualified reference with no default
1133        // registry ⇒ a clean not-found, no network.
1134        let registry = Arc::new(LocalRegistry::new(tmp.path().join("reg")).await.unwrap());
1135        let puller = local_puller(&registry);
1136        let dest = tmp.path().join("dest");
1137        let res = pull_with(&puller, "zlayertesttoolchains:jq-9.9.9-macos-arm64", &dest)
1138            .await
1139            .unwrap();
1140        assert!(res.is_none(), "a never-published tag must pull to None");
1141    }
1142
1143    #[cfg(unix)]
1144    #[tokio::test]
1145    async fn latest_alias_resolves_to_same_digest_as_immutable_tag() {
1146        let tmp = tempfile::tempdir().unwrap();
1147        let src = tmp.path().join("src");
1148        build_fixture_toolchain(&src).await;
1149        let (layer_bytes, layer_media) =
1150            pack_dir_tar_zstd(&src, &[".build", ".ready"], DEFAULT_ZSTD_LEVEL).unwrap();
1151        let config = fixture_config("/opt/zlayer/toolchains/jq-1.8.1-macos-arm64", true);
1152
1153        let registry = Arc::new(LocalRegistry::new(tmp.path().join("reg")).await.unwrap());
1154        let repo = registry_repo();
1155        let tag = artifact_tag("jq", "1.8.1", "macos", "arm64");
1156        let latest = latest_tag("jq", "macos", "arm64");
1157
1158        // Publish repoints `latest` to the SAME content as the immutable tag;
1159        // store both to the same bytes and prove both refs resolve identically.
1160        let tag_digest =
1161            seed_artifact(&registry, &repo, &tag, &layer_bytes, &layer_media, &config).await;
1162        let latest_digest = seed_artifact(
1163            &registry,
1164            &repo,
1165            &latest,
1166            &layer_bytes,
1167            &layer_media,
1168            &config,
1169        )
1170        .await;
1171        assert_eq!(
1172            tag_digest, latest_digest,
1173            "same content ⇒ same manifest digest"
1174        );
1175
1176        let puller = local_puller(&registry);
1177        let (_m1, d1) = puller
1178            .pull_manifest(&format!("{repo}:{tag}"), &RegistryAuth::Anonymous)
1179            .await
1180            .unwrap();
1181        let (_m2, d2) = puller
1182            .pull_manifest(&format!("{repo}:{latest}"), &RegistryAuth::Anonymous)
1183            .await
1184            .unwrap();
1185        assert_eq!(
1186            d1, d2,
1187            "latest alias must resolve to the immutable tag's digest"
1188        );
1189    }
1190
1191    #[cfg(unix)]
1192    #[tokio::test]
1193    async fn unrelocatable_longer_prefix_cleans_dest_and_returns_none() {
1194        let tmp = tempfile::tempdir().unwrap();
1195        let src = tmp.path().join("src");
1196        build_fixture_toolchain(&src).await;
1197        let (layer_bytes, layer_media) =
1198            pack_dir_tar_zstd(&src, &[".build", ".ready"], DEFAULT_ZSTD_LEVEL).unwrap();
1199        // relocatable=false + a SHORT recorded prefix ⇒ any tempdir dest is
1200        // longer ⇒ null-pad patching impossible ⇒ decline + clean.
1201        let config = fixture_config("/x", false);
1202
1203        let registry = Arc::new(LocalRegistry::new(tmp.path().join("reg")).await.unwrap());
1204        let repo = registry_repo();
1205        let tag = artifact_tag("jq", "1.8.1", "macos", "arm64");
1206        seed_artifact(&registry, &repo, &tag, &layer_bytes, &layer_media, &config).await;
1207
1208        let puller = local_puller(&registry);
1209        let dest = tmp.path().join("dest-much-longer-than-slash-x");
1210        let res = pull_with(&puller, &format!("{repo}:{tag}"), &dest)
1211            .await
1212            .unwrap();
1213        assert!(res.is_none(), "unrelocatable-to-longer-prefix must decline");
1214        assert!(!dest.exists(), "partial dest must be cleaned");
1215    }
1216
1217    /// The real network path: `publish_toolchain` pushes to an actual registry
1218    /// (immutable tag + `latest` repoint), then `try_pull_toolchain` pulls it
1219    /// back. Ignored — needs `ZLAYER_TOOLCHAIN_REGISTRY` pointing at a writable
1220    /// repo plus `GHCR_TOKEN`/`GITHUB_TOKEN` (or `~/.docker/config.json`) auth.
1221    /// Run with: `cargo test -p zlayer-toolchain -- --ignored live_publish`.
1222    #[cfg(unix)]
1223    #[tokio::test]
1224    #[ignore = "live: pushes to a real OCI registry (ZLAYER_TOOLCHAIN_REGISTRY + auth)"]
1225    async fn live_publish_then_pull_round_trip() {
1226        let tmp = tempfile::tempdir().unwrap();
1227        let src = tmp.path().join("src");
1228        build_fixture_toolchain(&src).await;
1229
1230        let built_prefix = src.canonicalize().unwrap();
1231        let id = ToolchainArtifactId {
1232            tool: "zlayer-selftest".to_string(),
1233            version: "0.0.1".to_string(),
1234            os: "macos".to_string(),
1235            arch: "arm64".to_string(),
1236        };
1237        let manifest = fixture_config(&built_prefix.to_string_lossy(), true).manifest;
1238        let report = RelocationReport::default(); // no residue ⇒ fully relocatable
1239
1240        let published = publish_toolchain(&src, &manifest, &report, &built_prefix, &id)
1241            .await
1242            .expect("publish should succeed against the configured registry");
1243        assert!(published.reference.ends_with(&id.artifact_tag()));
1244
1245        let dest = tmp.path().join("dest");
1246        let pulled = try_pull_toolchain(&id, &dest)
1247            .await
1248            .expect("pull should not error")
1249            .expect("the just-published toolchain should pull");
1250        assert_eq!(pulled.digest, published.digest);
1251        assert!(dest.join("bin/jq").is_file());
1252    }
1253}