1#![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
56pub const DEFAULT_TOOLCHAIN_REGISTRY: &str = "ghcr.io/blackleafdigital/zlayer/toolchains";
59
60pub const ARTIFACT_TYPE: &str = "application/vnd.zlayer.toolchain.v1";
62
63pub const CONFIG_MEDIA_TYPE: &str = "application/vnd.zlayer.toolchain.config.v1+json";
66
67const LAYER_TITLE: &str = "toolchain.tar.zst";
69
70const 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#[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#[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#[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#[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#[derive(Debug, Clone)]
135pub struct ToolchainArtifactId {
136 pub tool: String,
138 pub version: String,
140 pub os: String,
142 pub arch: String,
144}
145
146impl ToolchainArtifactId {
147 #[must_use]
149 pub fn artifact_tag(&self) -> String {
150 artifact_tag(&self.tool, &self.version, &self.os, &self.arch)
151 }
152
153 #[must_use]
155 pub fn latest_tag(&self) -> String {
156 latest_tag(&self.tool, &self.os, &self.arch)
157 }
158}
159
160#[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
185fn 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
206fn 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
236fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ToolchainArtifactConfig {
251 pub format: u32,
253 pub tool: String,
255 pub version: String,
257 pub os: String,
259 pub arch: String,
261 pub built_prefix: String,
264 pub relocatable: bool,
268 pub manifest: ToolchainManifest,
271}
272
273#[derive(Debug, Clone)]
275pub struct PublishedToolchain {
276 pub reference: String,
278 pub digest: String,
280}
281
282pub 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 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
375async 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 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, ©_root).await?;
427 crate::relocate::apply_text_placeholders(
428 ©_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 };
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
464async 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 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#[derive(Debug, Clone)]
501pub struct PulledToolchain {
502 pub reference: String,
504 pub digest: String,
506}
507
508pub 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
541async 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 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 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 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
613async 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 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
652async 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
670fn 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
685fn err_is_not_found(err: &RegistryError) -> bool {
693 matches!(err, RegistryError::NotFound { .. }) || msg_is_not_found(&err.to_string())
694}
695
696fn 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
718fn err_is_auth(err: &RegistryError) -> bool {
720 matches!(err, RegistryError::AuthFailed { .. }) || msg_is_auth(&err.to_string())
721}
722
723fn 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 #[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 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 assert_eq!(
785 DEFAULT_TOOLCHAIN_REGISTRY,
786 "ghcr.io/blackleafdigital/zlayer/toolchains"
787 );
788 assert!(registry_repo().contains("toolchains"));
789 }
790
791 #[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 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 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 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 #[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 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 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 #[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 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 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 ®istry,
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(®istry), &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(®istry, &repo, &tag, &layer_bytes, &layer_media, &config).await;
1074
1075 let puller = local_puller(®istry);
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 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 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 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 assert!(!dest.join(".ready").exists());
1127 }
1128
1129 #[tokio::test]
1130 async fn pull_miss_returns_none() {
1131 let tmp = tempfile::tempdir().unwrap();
1132 let registry = Arc::new(LocalRegistry::new(tmp.path().join("reg")).await.unwrap());
1135 let puller = local_puller(®istry);
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 let tag_digest =
1161 seed_artifact(®istry, &repo, &tag, &layer_bytes, &layer_media, &config).await;
1162 let latest_digest = seed_artifact(
1163 ®istry,
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(®istry);
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 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(®istry, &repo, &tag, &layer_bytes, &layer_media, &config).await;
1207
1208 let puller = local_puller(®istry);
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 #[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(); 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}