1use 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
22pub const LOCKFILE_NAME: &str = "zlayer-toolchains.lock";
24
25pub trait ToolchainLockfileExt: Sized {
33 #[must_use]
35 fn new() -> Self;
36
37 fn load(path: &Path) -> Result<Option<Self>>;
49
50 fn save(&self, path: &Path) -> Result<()>;
57
58 fn lookup(&self, tool: &str, platform: &str, arch: &str) -> Option<&LockedTool>;
60
61 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
131pub 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 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}