Skip to main content

zlayer_toolchain/
manifest.rs

1//! The unified keg manifest (`toolchain.json`).
2//!
3//! Every provisioned keg — 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 keg laid down before manifests existed (an old
12//! `git-<ver>-<arch>` keg with only `.ready`) has its manifest *synthesized*
13//! from the on-disk layout — see [`KegManifest::synthesize_from_keg`].
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-keg manifest file, written next to `.ready`.
23pub const MANIFEST_FILE: &str = "toolchain.json";
24
25/// How a keg was provisioned. Recorded in the manifest for provenance.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum KegSource {
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 keg was built from.
33        url: String,
34        /// The sha256 (bare hex) of the source tarball, verified on download.
35        /// Empty for a pre-integrity keg.
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 keg 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 keg.
47        #[serde(default)]
48        sha256: String,
49    },
50}
51
52/// Per-keg manifest: identity + how to run the provisioned tool.
53///
54/// `path_dirs` and `env` use **absolute** keg-rooted paths, so a
55/// [`crate::ToolchainHandle`] is a direct projection of the manifest — the
56/// resolver never has to know which tool it is dealing with.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct KegManifest {
59    /// Homebrew formula / tool name (e.g. `git`, `jq`, `go`).
60    pub tool: String,
61    /// Resolved stable version (e.g. `2.55.0`).
62    pub version: String,
63    /// Host architecture token (`arm64` / `x86_64`).
64    pub arch: String,
65    /// Target platform (`macos`).
66    pub platform: String,
67    /// Absolute directories to prepend to `PATH` (the keg's `bin`).
68    pub path_dirs: Vec<String>,
69    /// Extra environment variables to set when running the tool.
70    pub env: HashMap<String, String>,
71    /// How this keg was provisioned.
72    pub source: KegSource,
73    /// Build dependencies resolved (as sibling kegs) to produce this keg.
74    /// Empty for prebuilts.
75    pub build_deps: Vec<String>,
76    /// RFC 3339 timestamp of when the keg was provisioned.
77    pub provisioned_at: String,
78}
79
80impl KegManifest {
81    /// Serialize and write the manifest into `keg/toolchain.json`.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`ToolchainError::CacheError`] if serialization fails, or an I/O
86    /// error if the file cannot be written.
87    pub async fn write_to_keg(&self, keg: &Path) -> Result<()> {
88        let json = serde_json::to_string_pretty(self).map_err(|e| ToolchainError::CacheError {
89            message: format!("failed to serialize keg manifest for {}: {e}", self.tool),
90        })?;
91        tokio::fs::write(keg.join(MANIFEST_FILE), json).await?;
92        Ok(())
93    }
94
95    /// Read `keg/toolchain.json` if present, returning `None` when it is absent
96    /// (a pre-manifest keg) and an error only on a corrupt manifest.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`ToolchainError::CacheError`] if the file exists but cannot be
101    /// parsed.
102    pub async fn read_from_keg(keg: &Path) -> Result<Option<Self>> {
103        let path = keg.join(MANIFEST_FILE);
104        match tokio::fs::read(&path).await {
105            Ok(bytes) => {
106                let manifest: Self =
107                    serde_json::from_slice(&bytes).map_err(|e| ToolchainError::CacheError {
108                        message: format!("corrupt keg manifest at {}: {e}", path.display()),
109                    })?;
110                Ok(Some(manifest))
111            }
112            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
113            Err(e) => Err(ToolchainError::IoError(e)),
114        }
115    }
116
117    /// Load a keg's manifest, synthesizing one from the on-disk layout if the
118    /// keg predates manifests (an old `git` keg with only `.ready`).
119    ///
120    /// Synthesis rules (cover every keg shape that existed before manifests):
121    /// - `path_dirs` = `[<keg>/bin]` when that directory exists,
122    /// - `env` gets `GIT_EXEC_PATH=<keg>/libexec/git-core` when that directory
123    ///   exists (the source-built `git` keg layout), otherwise stays empty.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error only when an existing manifest is corrupt.
128    pub async fn load_or_synthesize(keg: &Path) -> Result<Self> {
129        if let Some(manifest) = Self::read_from_keg(keg).await? {
130            return Ok(manifest);
131        }
132        Ok(Self::synthesize_from_keg(keg).await)
133    }
134
135    /// Build a manifest purely from a keg's on-disk layout (no `toolchain.json`).
136    /// Used for backward compatibility with pre-manifest kegs.
137    pub async fn synthesize_from_keg(keg: &Path) -> Self {
138        let mut path_dirs = Vec::new();
139        let bin = keg.join("bin");
140        if tokio::fs::try_exists(&bin).await.unwrap_or(false) {
141            path_dirs.push(bin.display().to_string());
142        }
143
144        let mut env = HashMap::new();
145        let git_exec = keg.join("libexec/git-core");
146        if tokio::fs::try_exists(&git_exec).await.unwrap_or(false) {
147            env.insert("GIT_EXEC_PATH".to_string(), git_exec.display().to_string());
148        }
149
150        // Best-effort identity from the directory name `<tool>-<version>-<arch>`.
151        let (tool, version, arch) = parse_keg_dir_name(keg);
152
153        Self {
154            tool,
155            version,
156            arch,
157            platform: "macos".to_string(),
158            path_dirs,
159            env,
160            source: KegSource::SourceBuild {
161                url: String::new(),
162                sha256: String::new(),
163            },
164            build_deps: Vec::new(),
165            provisioned_at: String::new(),
166        }
167    }
168}
169
170/// Parse a `<tool>-<version>-<arch>` keg directory name into its parts,
171/// tolerating tools whose name itself contains `-`. Returns best-effort
172/// `(tool, version, arch)`; unknown parts fall back to the whole dir name /
173/// empty strings.
174fn parse_keg_dir_name(keg: &Path) -> (String, String, String) {
175    let name = keg
176        .file_name()
177        .and_then(|s| s.to_str())
178        .unwrap_or_default()
179        .to_string();
180    let parts: Vec<&str> = name.rsplitn(3, '-').collect(); // [arch, version, tool...]
181    match parts.as_slice() {
182        [arch, version, tool] => (
183            (*tool).to_string(),
184            (*version).to_string(),
185            (*arch).to_string(),
186        ),
187        _ => (name, String::new(), String::new()),
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[tokio::test]
196    async fn round_trips_through_disk() {
197        let tmp = tempfile::tempdir().unwrap();
198        let keg = tmp.path();
199        let mut env = HashMap::new();
200        env.insert(
201            "GIT_EXEC_PATH".to_string(),
202            "/x/libexec/git-core".to_string(),
203        );
204        let manifest = KegManifest {
205            tool: "git".to_string(),
206            version: "2.55.0".to_string(),
207            arch: "arm64".to_string(),
208            platform: "macos".to_string(),
209            path_dirs: vec!["/x/bin".to_string()],
210            env,
211            source: KegSource::SourceBuild {
212                url: "https://example/git.tar.xz".to_string(),
213                sha256: "deadbeef".to_string(),
214            },
215            build_deps: vec![],
216            provisioned_at: "2026-06-30T00:00:00Z".to_string(),
217        };
218        manifest.write_to_keg(keg).await.unwrap();
219
220        let read = KegManifest::read_from_keg(keg).await.unwrap().unwrap();
221        assert_eq!(read.tool, "git");
222        assert_eq!(read.version, "2.55.0");
223        assert_eq!(
224            read.env.get("GIT_EXEC_PATH").map(String::as_str),
225            Some("/x/libexec/git-core")
226        );
227        assert!(matches!(
228            read.source,
229            KegSource::SourceBuild { ref sha256, .. } if sha256 == "deadbeef"
230        ));
231    }
232
233    #[tokio::test]
234    async fn missing_manifest_reads_none() {
235        let tmp = tempfile::tempdir().unwrap();
236        assert!(KegManifest::read_from_keg(tmp.path())
237            .await
238            .unwrap()
239            .is_none());
240    }
241
242    #[tokio::test]
243    async fn synthesizes_git_layout_when_manifest_absent() {
244        let tmp = tempfile::tempdir().unwrap();
245        let keg = tmp.path().join("git-2.55.0-arm64");
246        tokio::fs::create_dir_all(keg.join("bin")).await.unwrap();
247        tokio::fs::create_dir_all(keg.join("libexec/git-core"))
248            .await
249            .unwrap();
250
251        let manifest = KegManifest::load_or_synthesize(&keg).await.unwrap();
252        assert_eq!(manifest.tool, "git");
253        assert_eq!(manifest.version, "2.55.0");
254        assert_eq!(manifest.arch, "arm64");
255        assert_eq!(
256            manifest.path_dirs,
257            vec![keg.join("bin").display().to_string()]
258        );
259        assert_eq!(
260            manifest.env.get("GIT_EXEC_PATH"),
261            Some(&keg.join("libexec/git-core").display().to_string())
262        );
263        assert!(!manifest.env.contains_key("GIT_CONFIG_SYSTEM"));
264        assert!(!manifest.env.contains_key("DYLD_FALLBACK_LIBRARY_PATH"));
265    }
266
267    #[test]
268    fn parses_keg_dir_name_with_dashed_tool() {
269        let (tool, version, arch) = parse_keg_dir_name(Path::new("/cache/openssl-3.4.0-arm64"));
270        assert_eq!(tool, "openssl");
271        assert_eq!(version, "3.4.0");
272        assert_eq!(arch, "arm64");
273    }
274}