Skip to main content

zlayer_toolchain/
lockfile.rs

1//! On-disk I/O for the toolchain lockfile (`zlayer-toolchains.lock`).
2//!
3//! The lockfile shapes ([`ToolchainLockfile`], [`LockedTool`]) are pure serde
4//! types in `zlayer-types`; this module adds the TOML load/save, the
5//! `(tool, platform, arch)` lookup/upsert, and the sha256 recomputation helper.
6//! The provisioning crate only ever *consumes* a lock (verifying downloads
7//! against pinned digests) — it never writes one. The writer is the CLI
8//! (`zlayer toolchains lock`).
9//!
10//! A [`ToolchainLockfile`] is stored as TOML with a stable `[[tool]]` ordering
11//! (sorted by `(tool, platform, arch)`), so the file is diff-friendly across
12//! regenerations.
13
14use std::path::Path;
15
16use sha2::{Digest, Sha256};
17
18use crate::error::{Result, ToolchainError};
19
20pub use zlayer_types::toolchain_lock::{LockedTool, ToolchainLockfile, TOOLCHAIN_LOCK_SCHEMA};
21
22/// Canonical file name for the toolchain lockfile.
23pub const LOCKFILE_NAME: &str = "zlayer-toolchains.lock";
24
25/// I/O + query extension methods for the pure-serde [`ToolchainLockfile`].
26///
27/// Implemented as an extension trait because the type is defined in
28/// `zlayer-types` (which must stay free of `tokio`/`toml` I/O), yet the file
29/// operations belong here in the provisioning crate. With this trait in scope,
30/// `ToolchainLockfile::new()` / `ToolchainLockfile::load(path)` and
31/// `lock.save(path)` / `lock.lookup(..)` / `lock.upsert(..)` all read naturally.
32pub trait ToolchainLockfileExt: Sized {
33    /// A fresh, empty lockfile stamped with the current schema + timestamp.
34    #[must_use]
35    fn new() -> Self;
36
37    /// Load a lockfile from `path`.
38    ///
39    /// Returns `Ok(None)` when the file is absent (a cold repo), a loud
40    /// [`ToolchainError::CacheError`] on a parse failure or a schema mismatch,
41    /// and `Ok(Some(..))` otherwise.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`ToolchainError::CacheError`] on a corrupt file or an
46    /// unsupported schema, and [`ToolchainError::IoError`] on a read failure
47    /// other than "not found".
48    fn load(path: &Path) -> Result<Option<Self>>;
49
50    /// Serialize to TOML at `path` with a stable `(tool, platform, arch)` order.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`ToolchainError::CacheError`] on a serialization failure and
55    /// [`ToolchainError::IoError`] on a write failure.
56    fn save(&self, path: &Path) -> Result<()>;
57
58    /// The pinned entry for `(tool, platform, arch)`, if present.
59    fn lookup(&self, tool: &str, platform: &str, arch: &str) -> Option<&LockedTool>;
60
61    /// Insert `entry`, replacing any existing pin for its
62    /// `(tool, platform, arch)`.
63    fn upsert(&mut self, entry: LockedTool);
64}
65
66impl ToolchainLockfileExt for ToolchainLockfile {
67    fn new() -> Self {
68        Self {
69            schema: TOOLCHAIN_LOCK_SCHEMA,
70            generated_at: chrono::Utc::now().to_rfc3339(),
71            tools: Vec::new(),
72        }
73    }
74
75    fn load(path: &Path) -> Result<Option<Self>> {
76        let text = match std::fs::read_to_string(path) {
77            Ok(text) => text,
78            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
79            Err(e) => return Err(ToolchainError::IoError(e)),
80        };
81        let lock: Self = toml::from_str(&text).map_err(|e| ToolchainError::CacheError {
82            message: format!("failed to parse lockfile {}: {e}", path.display()),
83        })?;
84        if lock.schema != TOOLCHAIN_LOCK_SCHEMA {
85            return Err(ToolchainError::CacheError {
86                message: format!(
87                    "lockfile {} has schema {} but this build supports schema {TOOLCHAIN_LOCK_SCHEMA}",
88                    path.display(),
89                    lock.schema
90                ),
91            });
92        }
93        Ok(Some(lock))
94    }
95
96    fn save(&self, path: &Path) -> Result<()> {
97        let mut out = self.clone();
98        out.schema = TOOLCHAIN_LOCK_SCHEMA;
99        out.generated_at = chrono::Utc::now().to_rfc3339();
100        out.tools
101            .sort_by(|a, b| (&a.tool, &a.platform, &a.arch).cmp(&(&b.tool, &b.platform, &b.arch)));
102        let text = toml::to_string_pretty(&out).map_err(|e| ToolchainError::CacheError {
103            message: format!("failed to serialize lockfile: {e}"),
104        })?;
105        if let Some(parent) = path.parent() {
106            std::fs::create_dir_all(parent)?;
107        }
108        std::fs::write(path, text)?;
109        Ok(())
110    }
111
112    fn lookup(&self, tool: &str, platform: &str, arch: &str) -> Option<&LockedTool> {
113        self.tools
114            .iter()
115            .find(|t| t.tool == tool && t.platform == platform && t.arch == arch)
116    }
117
118    fn upsert(&mut self, entry: LockedTool) {
119        if let Some(slot) = self
120            .tools
121            .iter_mut()
122            .find(|t| t.tool == entry.tool && t.platform == entry.platform && t.arch == entry.arch)
123        {
124            *slot = entry;
125        } else {
126            self.tools.push(entry);
127        }
128    }
129}
130
131/// Compute the sha256 (bare lowercase hex) of the file at `path`, streaming it in
132/// chunks so a large artifact never has to be fully buffered.
133///
134/// # Errors
135///
136/// Returns [`ToolchainError::IoError`] if the file cannot be read.
137pub fn compute_sha256(path: &Path) -> Result<String> {
138    use std::io::Read;
139    let mut file = std::fs::File::open(path)?;
140    let mut hasher = Sha256::new();
141    let mut buf = [0u8; 8 * 1024];
142    loop {
143        let n = file.read(&mut buf)?;
144        if n == 0 {
145            break;
146        }
147        hasher.update(&buf[..n]);
148    }
149    Ok(hex::encode(hasher.finalize()))
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn tool(name: &str, platform: &str, arch: &str, version: &str) -> LockedTool {
157        LockedTool {
158            tool: name.to_string(),
159            platform: platform.to_string(),
160            arch: arch.to_string(),
161            version: version.to_string(),
162            url: format!("https://example/{name}-{version}"),
163            sha256: "abc123".to_string(),
164            resolved_at: "2026-07-01T00:00:00Z".to_string(),
165        }
166    }
167
168    #[test]
169    fn round_trips_through_disk() {
170        let tmp = tempfile::tempdir().unwrap();
171        let path = tmp.path().join(LOCKFILE_NAME);
172
173        let mut lock = ToolchainLockfile::new();
174        lock.upsert(tool("git", "macos", "arm64", "2.55.0"));
175        lock.upsert(tool("node@lts", "macos", "arm64", "22.1.0"));
176        lock.save(&path).unwrap();
177
178        let read = ToolchainLockfile::load(&path).unwrap().unwrap();
179        assert_eq!(read.schema, TOOLCHAIN_LOCK_SCHEMA);
180        assert_eq!(read.tools.len(), 2);
181        assert_eq!(
182            read.lookup("git", "macos", "arm64")
183                .map(|t| t.version.as_str()),
184            Some("2.55.0")
185        );
186        assert!(read.lookup("git", "macos", "x86_64").is_none());
187    }
188
189    #[test]
190    fn absent_file_loads_none() {
191        let tmp = tempfile::tempdir().unwrap();
192        let path = tmp.path().join(LOCKFILE_NAME);
193        assert!(ToolchainLockfile::load(&path).unwrap().is_none());
194    }
195
196    #[test]
197    fn schema_mismatch_is_a_loud_error() {
198        let tmp = tempfile::tempdir().unwrap();
199        let path = tmp.path().join(LOCKFILE_NAME);
200        std::fs::write(&path, "schema = 999\ngenerated_at = \"x\"\n").unwrap();
201        let err = ToolchainLockfile::load(&path).unwrap_err();
202        assert!(matches!(err, ToolchainError::CacheError { .. }));
203    }
204
205    #[test]
206    fn corrupt_toml_is_a_loud_error() {
207        let tmp = tempfile::tempdir().unwrap();
208        let path = tmp.path().join(LOCKFILE_NAME);
209        std::fs::write(&path, "not = = valid toml").unwrap();
210        assert!(matches!(
211            ToolchainLockfile::load(&path).unwrap_err(),
212            ToolchainError::CacheError { .. }
213        ));
214    }
215
216    #[test]
217    fn upsert_replaces_matching_key() {
218        let mut lock = ToolchainLockfile::new();
219        lock.upsert(tool("git", "macos", "arm64", "2.55.0"));
220        lock.upsert(tool("git", "macos", "arm64", "2.56.0"));
221        assert_eq!(lock.tools.len(), 1);
222        assert_eq!(
223            lock.lookup("git", "macos", "arm64")
224                .map(|t| t.version.as_str()),
225            Some("2.56.0")
226        );
227        // A different arch is a distinct entry.
228        lock.upsert(tool("git", "macos", "x86_64", "2.56.0"));
229        assert_eq!(lock.tools.len(), 2);
230    }
231
232    #[test]
233    fn save_sorts_tools_by_key() {
234        let tmp = tempfile::tempdir().unwrap();
235        let path = tmp.path().join(LOCKFILE_NAME);
236        let mut lock = ToolchainLockfile::new();
237        lock.upsert(tool("zig", "macos", "arm64", "0.13.0"));
238        lock.upsert(tool("git", "macos", "x86_64", "2.55.0"));
239        lock.upsert(tool("git", "macos", "arm64", "2.55.0"));
240        lock.save(&path).unwrap();
241        let read = ToolchainLockfile::load(&path).unwrap().unwrap();
242        let keys: Vec<_> = read
243            .tools
244            .iter()
245            .map(|t| (t.tool.as_str(), t.platform.as_str(), t.arch.as_str()))
246            .collect();
247        assert_eq!(
248            keys,
249            vec![
250                ("git", "macos", "arm64"),
251                ("git", "macos", "x86_64"),
252                ("zig", "macos", "arm64"),
253            ]
254        );
255    }
256
257    #[test]
258    fn compute_sha256_matches_known_vector() {
259        let tmp = tempfile::tempdir().unwrap();
260        let path = tmp.path().join("f.bin");
261        std::fs::write(&path, b"hello world").unwrap();
262        assert_eq!(
263            compute_sha256(&path).unwrap(),
264            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
265        );
266    }
267}