zlayer-toolchain 0.14.3

Runtime toolchain provisioning (macOS Homebrew bottle resolver/installer) for ZLayer
Documentation
//! The unified toolchain manifest (`toolchain.json`).
//!
//! Every provisioned toolchain — whether built from source ([`crate::source_build`])
//! or fetched as a self-contained prebuilt ([`crate::prebuilt`]) — carries a
//! `toolchain.json` next to its `.ready` marker. The manifest is the single
//! source of truth for how to *run* the tool: which directories to prepend to
//! `PATH` and which environment variables to set. The resolver
//! ([`crate::probe_ready_toolchain`], handle construction) reads the manifest
//! generically, so no tool is special-cased in the handle path.
//!
//! Backward compatibility: a toolchain laid down before manifests existed (an old
//! `git-<ver>-<arch>` toolchain with only `.ready`) has its manifest *synthesized*
//! from the on-disk layout — see [`ToolchainManifest::synthesize_from_toolchain`].

use std::collections::HashMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::error::{Result, ToolchainError};

/// Name of the per-toolchain manifest file, written next to `.ready`.
pub const MANIFEST_FILE: &str = "toolchain.json";

/// How a toolchain was provisioned. Recorded in the manifest for provenance.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolchainSource {
    /// Built from the Homebrew formula's `urls.stable.url` source tarball with
    /// the host Command Line Tools, at an absolute cache prefix.
    SourceBuild {
        /// The source tarball URL the toolchain was built from.
        url: String,
        /// The sha256 (bare hex) of the source tarball, verified on download.
        /// Empty for a pre-integrity toolchain.
        #[serde(default)]
        sha256: String,
    },
    /// Fetched as a self-contained, relocation-free prebuilt vendor archive
    /// (the language toolchains: go/node/rust/...).
    Prebuilt {
        /// The vendor download URL the toolchain was extracted from.
        url: String,
        /// The sha256 (bare hex) of the downloaded archive, verified on download
        /// when an upstream/lockfile digest was available, else the digest
        /// computed over the bytes. Empty for a pre-integrity toolchain.
        #[serde(default)]
        sha256: String,
    },
    /// Pulled as a published toolchain artifact from the OCI toolchain
    /// registry (single repo, structured tags — see the registry module).
    ///
    /// Additive variant: old binaries (which only know `source_build` /
    /// `prebuilt`) cannot read a manifest carrying `registry` — the change is
    /// backward-compatible, not forward-compatible.
    Registry {
        /// Full pull reference, e.g.
        /// `ghcr.io/blackleafdigital/zlayer/toolchains:jq-1.8.1-macos-arm64`.
        reference: String,
        /// Manifest digest (`sha256:...`) the pull was pinned to.
        #[serde(default)]
        digest: String,
    },
}

/// Per-toolchain manifest: identity + how to run the provisioned tool.
///
/// `path_dirs` and `env` use **absolute** toolchain-rooted paths, so a
/// [`crate::ToolchainHandle`] is a direct projection of the manifest — the
/// resolver never has to know which tool it is dealing with.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolchainManifest {
    /// Homebrew formula / tool name (e.g. `git`, `jq`, `go`).
    pub tool: String,
    /// Resolved stable version (e.g. `2.55.0`).
    pub version: String,
    /// Host architecture token (`arm64` / `x86_64`).
    pub arch: String,
    /// Target platform (`macos`).
    pub platform: String,
    /// Absolute directories to prepend to `PATH` (the toolchain's `bin`).
    pub path_dirs: Vec<String>,
    /// Extra environment variables to set when running the tool.
    pub env: HashMap<String, String>,
    /// How this toolchain was provisioned.
    pub source: ToolchainSource,
    /// Build dependencies resolved (as sibling toolchains) to produce this toolchain.
    /// Empty for prebuilts.
    pub build_deps: Vec<String>,
    /// RFC 3339 timestamp of when the toolchain was provisioned.
    pub provisioned_at: String,
}

impl ToolchainManifest {
    /// Serialize and write the manifest into `toolchain/toolchain.json`.
    ///
    /// # Errors
    ///
    /// Returns [`ToolchainError::CacheError`] if serialization fails, or an I/O
    /// error if the file cannot be written.
    pub async fn write_to_toolchain(&self, toolchain: &Path) -> Result<()> {
        let json = serde_json::to_string_pretty(self).map_err(|e| ToolchainError::CacheError {
            message: format!(
                "failed to serialize toolchain manifest for {}: {e}",
                self.tool
            ),
        })?;
        tokio::fs::write(toolchain.join(MANIFEST_FILE), json).await?;
        Ok(())
    }

    /// Read `toolchain/toolchain.json` if present, returning `None` when it is absent
    /// (a pre-manifest toolchain) and an error only on a corrupt manifest.
    ///
    /// # Errors
    ///
    /// Returns [`ToolchainError::CacheError`] if the file exists but cannot be
    /// parsed.
    pub async fn read_from_toolchain(toolchain: &Path) -> Result<Option<Self>> {
        let path = toolchain.join(MANIFEST_FILE);
        match tokio::fs::read(&path).await {
            Ok(bytes) => {
                let manifest: Self =
                    serde_json::from_slice(&bytes).map_err(|e| ToolchainError::CacheError {
                        message: format!("corrupt toolchain manifest at {}: {e}", path.display()),
                    })?;
                Ok(Some(manifest))
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(ToolchainError::IoError(e)),
        }
    }

    /// Load a toolchain's manifest, synthesizing one from the on-disk layout if the
    /// toolchain predates manifests (an old `git` toolchain with only `.ready`).
    ///
    /// Synthesis rules (cover every toolchain shape that existed before manifests):
    /// - `path_dirs` = `[<toolchain>/bin]` when that directory exists,
    /// - `env` gets `GIT_EXEC_PATH=<toolchain>/libexec/git-core` when that directory
    ///   exists (the source-built `git` toolchain layout), otherwise stays empty.
    ///
    /// # Errors
    ///
    /// Returns an error only when an existing manifest is corrupt.
    pub async fn load_or_synthesize(toolchain: &Path) -> Result<Self> {
        if let Some(manifest) = Self::read_from_toolchain(toolchain).await? {
            return Ok(manifest);
        }
        Ok(Self::synthesize_from_toolchain(toolchain).await)
    }

    /// Build a manifest purely from a toolchain's on-disk layout (no `toolchain.json`).
    /// Used for backward compatibility with pre-manifest toolchains.
    pub async fn synthesize_from_toolchain(toolchain: &Path) -> Self {
        let mut path_dirs = Vec::new();
        let bin = toolchain.join("bin");
        if tokio::fs::try_exists(&bin).await.unwrap_or(false) {
            path_dirs.push(bin.display().to_string());
        }

        let mut env = HashMap::new();
        let git_exec = toolchain.join("libexec/git-core");
        if tokio::fs::try_exists(&git_exec).await.unwrap_or(false) {
            env.insert("GIT_EXEC_PATH".to_string(), git_exec.display().to_string());
        }

        // Best-effort identity from the directory name `<tool>-<version>-<arch>`.
        let (tool, version, arch) = parse_toolchain_dir_name(toolchain);

        Self {
            tool,
            version,
            arch,
            platform: "macos".to_string(),
            path_dirs,
            env,
            source: ToolchainSource::SourceBuild {
                url: String::new(),
                sha256: String::new(),
            },
            build_deps: Vec::new(),
            provisioned_at: String::new(),
        }
    }
}

/// Parse a `<tool>-<version>-<arch>` toolchain directory name into its parts,
/// tolerating tools whose name itself contains `-`. Returns best-effort
/// `(tool, version, arch)`; unknown parts fall back to the whole dir name /
/// empty strings.
fn parse_toolchain_dir_name(toolchain: &Path) -> (String, String, String) {
    let name = toolchain
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or_default()
        .to_string();
    let parts: Vec<&str> = name.rsplitn(3, '-').collect(); // [arch, version, tool...]
    match parts.as_slice() {
        [arch, version, tool] => (
            (*tool).to_string(),
            (*version).to_string(),
            (*arch).to_string(),
        ),
        _ => (name, String::new(), String::new()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn round_trips_through_disk() {
        let tmp = tempfile::tempdir().unwrap();
        let toolchain = tmp.path();
        let mut env = HashMap::new();
        env.insert(
            "GIT_EXEC_PATH".to_string(),
            "/x/libexec/git-core".to_string(),
        );
        let manifest = ToolchainManifest {
            tool: "git".to_string(),
            version: "2.55.0".to_string(),
            arch: "arm64".to_string(),
            platform: "macos".to_string(),
            path_dirs: vec!["/x/bin".to_string()],
            env,
            source: ToolchainSource::SourceBuild {
                url: "https://example/git.tar.xz".to_string(),
                sha256: "deadbeef".to_string(),
            },
            build_deps: vec![],
            provisioned_at: "2026-06-30T00:00:00Z".to_string(),
        };
        manifest.write_to_toolchain(toolchain).await.unwrap();

        let read = ToolchainManifest::read_from_toolchain(toolchain)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(read.tool, "git");
        assert_eq!(read.version, "2.55.0");
        assert_eq!(
            read.env.get("GIT_EXEC_PATH").map(String::as_str),
            Some("/x/libexec/git-core")
        );
        assert!(matches!(
            read.source,
            ToolchainSource::SourceBuild { ref sha256, .. } if sha256 == "deadbeef"
        ));
    }

    #[tokio::test]
    async fn missing_manifest_reads_none() {
        let tmp = tempfile::tempdir().unwrap();
        assert!(ToolchainManifest::read_from_toolchain(tmp.path())
            .await
            .unwrap()
            .is_none());
    }

    #[tokio::test]
    async fn synthesizes_git_layout_when_manifest_absent() {
        let tmp = tempfile::tempdir().unwrap();
        let toolchain = tmp.path().join("git-2.55.0-arm64");
        tokio::fs::create_dir_all(toolchain.join("bin"))
            .await
            .unwrap();
        tokio::fs::create_dir_all(toolchain.join("libexec/git-core"))
            .await
            .unwrap();

        let manifest = ToolchainManifest::load_or_synthesize(&toolchain)
            .await
            .unwrap();
        assert_eq!(manifest.tool, "git");
        assert_eq!(manifest.version, "2.55.0");
        assert_eq!(manifest.arch, "arm64");
        assert_eq!(
            manifest.path_dirs,
            vec![toolchain.join("bin").display().to_string()]
        );
        assert_eq!(
            manifest.env.get("GIT_EXEC_PATH"),
            Some(&toolchain.join("libexec/git-core").display().to_string())
        );
        assert!(!manifest.env.contains_key("GIT_CONFIG_SYSTEM"));
        assert!(!manifest.env.contains_key("DYLD_FALLBACK_LIBRARY_PATH"));
    }

    #[test]
    fn parses_toolchain_dir_name_with_dashed_tool() {
        let (tool, version, arch) =
            parse_toolchain_dir_name(Path::new("/cache/openssl-3.4.0-arm64"));
        assert_eq!(tool, "openssl");
        assert_eq!(version, "3.4.0");
        assert_eq!(arch, "arm64");
    }

    #[test]
    fn registry_source_round_trips_through_serde() {
        let manifest = ToolchainManifest {
            tool: "jq".to_string(),
            version: "1.8.1".to_string(),
            arch: "arm64".to_string(),
            platform: "macos".to_string(),
            path_dirs: vec!["/x/bin".to_string()],
            env: HashMap::new(),
            source: ToolchainSource::Registry {
                reference: "ghcr.io/blackleafdigital/zlayer/toolchains:jq-1.8.1-macos-arm64"
                    .to_string(),
                digest: "sha256:deadbeef".to_string(),
            },
            build_deps: vec![],
            provisioned_at: "2026-07-06T00:00:00Z".to_string(),
        };
        let json = serde_json::to_string_pretty(&manifest).unwrap();
        assert!(
            json.contains("\"registry\""),
            "externally tagged as registry: {json}"
        );

        let read: ToolchainManifest = serde_json::from_slice(json.as_bytes()).unwrap();
        assert_eq!(read.source, manifest.source);
    }

    #[test]
    fn old_source_build_manifest_still_parses() {
        let json = r#"{
            "tool": "git",
            "version": "2.55.0",
            "arch": "arm64",
            "platform": "macos",
            "path_dirs": ["/x/bin"],
            "env": {},
            "source": {
                "source_build": {
                    "url": "https://example/git.tar.xz",
                    "sha256": "deadbeef"
                }
            },
            "build_deps": [],
            "provisioned_at": "2026-06-30T00:00:00Z"
        }"#;
        let manifest: ToolchainManifest = serde_json::from_slice(json.as_bytes()).unwrap();
        assert!(matches!(
            manifest.source,
            ToolchainSource::SourceBuild { ref sha256, .. } if sha256 == "deadbeef"
        ));
    }

    #[test]
    fn registry_manifest_parses_to_registry_variant() {
        let json = r#"{
            "tool": "jq",
            "version": "1.8.1",
            "arch": "arm64",
            "platform": "macos",
            "path_dirs": ["/x/bin"],
            "env": {},
            "source": {
                "registry": {
                    "reference": "ghcr.io/blackleafdigital/zlayer/toolchains:jq-1.8.1-macos-arm64"
                }
            },
            "build_deps": [],
            "provisioned_at": "2026-07-06T00:00:00Z"
        }"#;
        let manifest: ToolchainManifest = serde_json::from_slice(json.as_bytes()).unwrap();
        assert!(matches!(
            manifest.source,
            ToolchainSource::Registry { ref reference, ref digest }
                if reference.ends_with("jq-1.8.1-macos-arm64") && digest.is_empty()
        ));
    }
}