Skip to main content

zlayer_toolchain/
manifest.rs

1//! The unified toolchain manifest (`toolchain.json`).
2//!
3//! Every provisioned toolchain — whether built from source ([`crate::source_build`])
4//! or fetched as a self-contained prebuilt ([`crate::prebuilt`]) — carries a
5//! `toolchain.json` next to its `.ready` marker. The manifest is the single
6//! source of truth for how to *run* the tool: which directories to prepend to
7//! `PATH` and which environment variables to set. The resolver
8//! ([`crate::probe_ready_toolchain`], handle construction) reads the manifest
9//! generically, so no tool is special-cased in the handle path.
10//!
11//! Backward compatibility: a toolchain laid down before manifests existed (an old
12//! `git-<ver>-<arch>` toolchain with only `.ready`) has its manifest *synthesized*
13//! from the on-disk layout — see [`ToolchainManifest::synthesize_from_toolchain`].
14
15use std::collections::HashMap;
16use std::path::Path;
17
18use serde::{Deserialize, Serialize};
19
20use crate::error::{Result, ToolchainError};
21
22/// Name of the per-toolchain manifest file, written next to `.ready`.
23pub const MANIFEST_FILE: &str = "toolchain.json";
24
25/// How a toolchain was provisioned. Recorded in the manifest for provenance.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ToolchainSource {
29    /// Built from the Homebrew formula's `urls.stable.url` source tarball with
30    /// the host Command Line Tools, at an absolute cache prefix.
31    SourceBuild {
32        /// The source tarball URL the toolchain was built from.
33        url: String,
34        /// The sha256 (bare hex) of the source tarball, verified on download.
35        /// Empty for a pre-integrity toolchain.
36        #[serde(default)]
37        sha256: String,
38    },
39    /// Fetched as a self-contained, relocation-free prebuilt vendor archive
40    /// (the language toolchains: go/node/rust/...).
41    Prebuilt {
42        /// The vendor download URL the toolchain was extracted from.
43        url: String,
44        /// The sha256 (bare hex) of the downloaded archive, verified on download
45        /// when an upstream/lockfile digest was available, else the digest
46        /// computed over the bytes. Empty for a pre-integrity toolchain.
47        #[serde(default)]
48        sha256: String,
49    },
50    /// Pulled as a published toolchain artifact from the OCI toolchain
51    /// registry (single repo, structured tags — see the registry module).
52    ///
53    /// Additive variant: old binaries (which only know `source_build` /
54    /// `prebuilt`) cannot read a manifest carrying `registry` — the change is
55    /// backward-compatible, not forward-compatible.
56    Registry {
57        /// Full pull reference, e.g.
58        /// `ghcr.io/blackleafdigital/zlayer/toolchains:jq-1.8.1-macos-arm64`.
59        reference: String,
60        /// Manifest digest (`sha256:...`) the pull was pinned to.
61        #[serde(default)]
62        digest: String,
63    },
64}
65
66/// Per-toolchain manifest: identity + how to run the provisioned tool.
67///
68/// `path_dirs` and `env` use **absolute** toolchain-rooted paths, so a
69/// [`crate::ToolchainHandle`] is a direct projection of the manifest — the
70/// resolver never has to know which tool it is dealing with.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ToolchainManifest {
73    /// Homebrew formula / tool name (e.g. `git`, `jq`, `go`).
74    pub tool: String,
75    /// Resolved stable version (e.g. `2.55.0`).
76    pub version: String,
77    /// Host architecture token (`arm64` / `x86_64`).
78    pub arch: String,
79    /// Target platform (`macos`).
80    pub platform: String,
81    /// Absolute directories to prepend to `PATH` (the toolchain's `bin`).
82    pub path_dirs: Vec<String>,
83    /// Extra environment variables to set when running the tool.
84    pub env: HashMap<String, String>,
85    /// How this toolchain was provisioned.
86    pub source: ToolchainSource,
87    /// Build dependencies resolved (as sibling toolchains) to produce this toolchain.
88    /// Empty for prebuilts.
89    pub build_deps: Vec<String>,
90    /// RFC 3339 timestamp of when the toolchain was provisioned.
91    pub provisioned_at: String,
92}
93
94impl ToolchainManifest {
95    /// Serialize and write the manifest into `toolchain/toolchain.json`.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`ToolchainError::CacheError`] if serialization fails, or an I/O
100    /// error if the file cannot be written.
101    pub async fn write_to_toolchain(&self, toolchain: &Path) -> Result<()> {
102        let json = serde_json::to_string_pretty(self).map_err(|e| ToolchainError::CacheError {
103            message: format!(
104                "failed to serialize toolchain manifest for {}: {e}",
105                self.tool
106            ),
107        })?;
108        tokio::fs::write(toolchain.join(MANIFEST_FILE), json).await?;
109        Ok(())
110    }
111
112    /// Read `toolchain/toolchain.json` if present, returning `None` when it is absent
113    /// (a pre-manifest toolchain) and an error only on a corrupt manifest.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`ToolchainError::CacheError`] if the file exists but cannot be
118    /// parsed.
119    pub async fn read_from_toolchain(toolchain: &Path) -> Result<Option<Self>> {
120        let path = toolchain.join(MANIFEST_FILE);
121        match tokio::fs::read(&path).await {
122            Ok(bytes) => {
123                let manifest: Self =
124                    serde_json::from_slice(&bytes).map_err(|e| ToolchainError::CacheError {
125                        message: format!("corrupt toolchain manifest at {}: {e}", path.display()),
126                    })?;
127                Ok(Some(manifest))
128            }
129            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
130            Err(e) => Err(ToolchainError::IoError(e)),
131        }
132    }
133
134    /// Load a toolchain's manifest, synthesizing one from the on-disk layout if the
135    /// toolchain predates manifests (an old `git` toolchain with only `.ready`).
136    ///
137    /// Synthesis rules (cover every toolchain shape that existed before manifests):
138    /// - `path_dirs` = `[<toolchain>/bin]` when that directory exists,
139    /// - `env` gets `GIT_EXEC_PATH=<toolchain>/libexec/git-core` when that directory
140    ///   exists (the source-built `git` toolchain layout), otherwise stays empty.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error only when an existing manifest is corrupt.
145    pub async fn load_or_synthesize(toolchain: &Path) -> Result<Self> {
146        if let Some(manifest) = Self::read_from_toolchain(toolchain).await? {
147            return Ok(manifest);
148        }
149        Ok(Self::synthesize_from_toolchain(toolchain).await)
150    }
151
152    /// Build a manifest purely from a toolchain's on-disk layout (no `toolchain.json`).
153    /// Used for backward compatibility with pre-manifest toolchains.
154    pub async fn synthesize_from_toolchain(toolchain: &Path) -> Self {
155        let mut path_dirs = Vec::new();
156        let bin = toolchain.join("bin");
157        if tokio::fs::try_exists(&bin).await.unwrap_or(false) {
158            path_dirs.push(bin.display().to_string());
159        }
160
161        let mut env = HashMap::new();
162        let git_exec = toolchain.join("libexec/git-core");
163        if tokio::fs::try_exists(&git_exec).await.unwrap_or(false) {
164            env.insert("GIT_EXEC_PATH".to_string(), git_exec.display().to_string());
165        }
166
167        // Best-effort identity from the directory name `<tool>-<version>-<arch>`.
168        let (tool, version, arch) = parse_toolchain_dir_name(toolchain);
169
170        Self {
171            tool,
172            version,
173            arch,
174            platform: "macos".to_string(),
175            path_dirs,
176            env,
177            source: ToolchainSource::SourceBuild {
178                url: String::new(),
179                sha256: String::new(),
180            },
181            build_deps: Vec::new(),
182            provisioned_at: String::new(),
183        }
184    }
185}
186
187/// Parse a `<tool>-<version>-<arch>` toolchain directory name into its parts,
188/// tolerating tools whose name itself contains `-`. Returns best-effort
189/// `(tool, version, arch)`; unknown parts fall back to the whole dir name /
190/// empty strings.
191fn parse_toolchain_dir_name(toolchain: &Path) -> (String, String, String) {
192    let name = toolchain
193        .file_name()
194        .and_then(|s| s.to_str())
195        .unwrap_or_default()
196        .to_string();
197    let parts: Vec<&str> = name.rsplitn(3, '-').collect(); // [arch, version, tool...]
198    match parts.as_slice() {
199        [arch, version, tool] => (
200            (*tool).to_string(),
201            (*version).to_string(),
202            (*arch).to_string(),
203        ),
204        _ => (name, String::new(), String::new()),
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[tokio::test]
213    async fn round_trips_through_disk() {
214        let tmp = tempfile::tempdir().unwrap();
215        let toolchain = tmp.path();
216        let mut env = HashMap::new();
217        env.insert(
218            "GIT_EXEC_PATH".to_string(),
219            "/x/libexec/git-core".to_string(),
220        );
221        let manifest = ToolchainManifest {
222            tool: "git".to_string(),
223            version: "2.55.0".to_string(),
224            arch: "arm64".to_string(),
225            platform: "macos".to_string(),
226            path_dirs: vec!["/x/bin".to_string()],
227            env,
228            source: ToolchainSource::SourceBuild {
229                url: "https://example/git.tar.xz".to_string(),
230                sha256: "deadbeef".to_string(),
231            },
232            build_deps: vec![],
233            provisioned_at: "2026-06-30T00:00:00Z".to_string(),
234        };
235        manifest.write_to_toolchain(toolchain).await.unwrap();
236
237        let read = ToolchainManifest::read_from_toolchain(toolchain)
238            .await
239            .unwrap()
240            .unwrap();
241        assert_eq!(read.tool, "git");
242        assert_eq!(read.version, "2.55.0");
243        assert_eq!(
244            read.env.get("GIT_EXEC_PATH").map(String::as_str),
245            Some("/x/libexec/git-core")
246        );
247        assert!(matches!(
248            read.source,
249            ToolchainSource::SourceBuild { ref sha256, .. } if sha256 == "deadbeef"
250        ));
251    }
252
253    #[tokio::test]
254    async fn missing_manifest_reads_none() {
255        let tmp = tempfile::tempdir().unwrap();
256        assert!(ToolchainManifest::read_from_toolchain(tmp.path())
257            .await
258            .unwrap()
259            .is_none());
260    }
261
262    #[tokio::test]
263    async fn synthesizes_git_layout_when_manifest_absent() {
264        let tmp = tempfile::tempdir().unwrap();
265        let toolchain = tmp.path().join("git-2.55.0-arm64");
266        tokio::fs::create_dir_all(toolchain.join("bin"))
267            .await
268            .unwrap();
269        tokio::fs::create_dir_all(toolchain.join("libexec/git-core"))
270            .await
271            .unwrap();
272
273        let manifest = ToolchainManifest::load_or_synthesize(&toolchain)
274            .await
275            .unwrap();
276        assert_eq!(manifest.tool, "git");
277        assert_eq!(manifest.version, "2.55.0");
278        assert_eq!(manifest.arch, "arm64");
279        assert_eq!(
280            manifest.path_dirs,
281            vec![toolchain.join("bin").display().to_string()]
282        );
283        assert_eq!(
284            manifest.env.get("GIT_EXEC_PATH"),
285            Some(&toolchain.join("libexec/git-core").display().to_string())
286        );
287        assert!(!manifest.env.contains_key("GIT_CONFIG_SYSTEM"));
288        assert!(!manifest.env.contains_key("DYLD_FALLBACK_LIBRARY_PATH"));
289    }
290
291    #[test]
292    fn parses_toolchain_dir_name_with_dashed_tool() {
293        let (tool, version, arch) =
294            parse_toolchain_dir_name(Path::new("/cache/openssl-3.4.0-arm64"));
295        assert_eq!(tool, "openssl");
296        assert_eq!(version, "3.4.0");
297        assert_eq!(arch, "arm64");
298    }
299
300    #[test]
301    fn registry_source_round_trips_through_serde() {
302        let manifest = ToolchainManifest {
303            tool: "jq".to_string(),
304            version: "1.8.1".to_string(),
305            arch: "arm64".to_string(),
306            platform: "macos".to_string(),
307            path_dirs: vec!["/x/bin".to_string()],
308            env: HashMap::new(),
309            source: ToolchainSource::Registry {
310                reference: "ghcr.io/blackleafdigital/zlayer/toolchains:jq-1.8.1-macos-arm64"
311                    .to_string(),
312                digest: "sha256:deadbeef".to_string(),
313            },
314            build_deps: vec![],
315            provisioned_at: "2026-07-06T00:00:00Z".to_string(),
316        };
317        let json = serde_json::to_string_pretty(&manifest).unwrap();
318        assert!(
319            json.contains("\"registry\""),
320            "externally tagged as registry: {json}"
321        );
322
323        let read: ToolchainManifest = serde_json::from_slice(json.as_bytes()).unwrap();
324        assert_eq!(read.source, manifest.source);
325    }
326
327    #[test]
328    fn old_source_build_manifest_still_parses() {
329        let json = r#"{
330            "tool": "git",
331            "version": "2.55.0",
332            "arch": "arm64",
333            "platform": "macos",
334            "path_dirs": ["/x/bin"],
335            "env": {},
336            "source": {
337                "source_build": {
338                    "url": "https://example/git.tar.xz",
339                    "sha256": "deadbeef"
340                }
341            },
342            "build_deps": [],
343            "provisioned_at": "2026-06-30T00:00:00Z"
344        }"#;
345        let manifest: ToolchainManifest = serde_json::from_slice(json.as_bytes()).unwrap();
346        assert!(matches!(
347            manifest.source,
348            ToolchainSource::SourceBuild { ref sha256, .. } if sha256 == "deadbeef"
349        ));
350    }
351
352    #[test]
353    fn registry_manifest_parses_to_registry_variant() {
354        let json = r#"{
355            "tool": "jq",
356            "version": "1.8.1",
357            "arch": "arm64",
358            "platform": "macos",
359            "path_dirs": ["/x/bin"],
360            "env": {},
361            "source": {
362                "registry": {
363                    "reference": "ghcr.io/blackleafdigital/zlayer/toolchains:jq-1.8.1-macos-arm64"
364                }
365            },
366            "build_deps": [],
367            "provisioned_at": "2026-07-06T00:00:00Z"
368        }"#;
369        let manifest: ToolchainManifest = serde_json::from_slice(json.as_bytes()).unwrap();
370        assert!(matches!(
371            manifest.source,
372            ToolchainSource::Registry { ref reference, ref digest }
373                if reference.ends_with("jq-1.8.1-macos-arm64") && digest.is_empty()
374        ));
375    }
376}