1use std::fmt::Write as _;
24use std::fs;
25use std::io;
26use std::path::PathBuf;
27
28use sha2::{Digest, Sha256};
29
30#[derive(Debug, Clone)]
32pub struct Cache {
33 pub root: PathBuf,
34}
35
36impl Cache {
37 pub fn user_default() -> Cache {
41 let root = std::env::home_dir()
42 .unwrap_or_default()
43 .join(".ridl")
44 .join("cache");
45 Cache { root }
46 }
47
48 pub fn lookup(&self, url: &str, sha256: &str) -> Option<PathBuf> {
52 let path = self.entry_dir(url, sha256);
53 path.is_dir().then_some(path)
54 }
55
56 pub fn store(&self, url: &str, bytes: &[u8]) -> io::Result<(String, PathBuf)> {
66 let sha256 = sha256_hex(bytes);
67 let dest = self.entry_dir(url, &sha256);
68 if dest.is_dir() {
69 return Ok((sha256, dest));
70 }
71
72 let parent = self.url_dir(url);
73 fs::create_dir_all(&parent)?;
74
75 let staging = parent.join(format!(".staging-{sha256}-{}", std::process::id()));
81 let _ = fs::remove_dir_all(&staging);
84 fs::create_dir_all(&staging)?;
85
86 let unpack_result = tar::Archive::new(bytes).unpack(&staging);
87 if let Err(err) = unpack_result {
88 let _ = fs::remove_dir_all(&staging);
89 return Err(err);
90 }
91
92 match fs::rename(&staging, &dest) {
93 Ok(()) => Ok((sha256, dest)),
94 Err(_) if dest.is_dir() => {
98 let _ = fs::remove_dir_all(&staging);
99 Ok((sha256, dest))
100 }
101 Err(err) => {
102 let _ = fs::remove_dir_all(&staging);
103 Err(err)
104 }
105 }
106 }
107
108 fn url_dir(&self, url: &str) -> PathBuf {
110 self.root.join(sha256_hex(url.as_bytes()))
111 }
112
113 fn entry_dir(&self, url: &str, content_sha: &str) -> PathBuf {
115 self.url_dir(url).join(content_sha)
116 }
117}
118
119fn sha256_hex(bytes: &[u8]) -> String {
121 let digest = Sha256::digest(bytes);
122 let mut hex = String::with_capacity(64);
123 for byte in digest {
124 let _ = write!(hex, "{byte:02x}");
126 }
127 hex
128}
129
130#[cfg(test)]
131mod tests {
132 use std::path::{Path, PathBuf};
133 use std::sync::atomic::{AtomicUsize, Ordering};
134
135 use super::*;
136
137 struct TempDir(PathBuf);
139
140 impl TempDir {
141 fn new(label: &str) -> Self {
142 static COUNTER: AtomicUsize = AtomicUsize::new(0);
143 let mut path = std::env::temp_dir();
144 path.push(format!(
145 "ridl-core-cache-{label}-{}-{}",
146 std::process::id(),
147 COUNTER.fetch_add(1, Ordering::SeqCst),
148 ));
149 fs::create_dir_all(&path).expect("create the temp dir");
150 Self(path)
151 }
152
153 fn path(&self) -> &Path {
154 &self.0
155 }
156 }
157
158 impl Drop for TempDir {
159 fn drop(&mut self) {
160 let _ = fs::remove_dir_all(&self.0);
161 }
162 }
163
164 fn make_tar(name: &str, contents: &[u8]) -> Vec<u8> {
167 let mut builder = tar::Builder::new(Vec::new());
168 let mut header = tar::Header::new_gnu();
169 header.set_size(contents.len() as u64);
170 header.set_mode(0o644);
171 header.set_cksum();
172 builder
173 .append_data(&mut header, name, contents)
174 .expect("append the file to the tar");
175 builder.into_inner().expect("finish the tar")
176 }
177
178 fn cache(dir: &TempDir) -> Cache {
179 Cache {
180 root: dir.path().join("cache"),
181 }
182 }
183
184 #[test]
187 fn sha256_hex_matches_the_known_empty_hash() {
188 assert_eq!(
189 sha256_hex(b""),
190 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
191 );
192 }
193
194 #[test]
198 fn store_unpacks_and_lookup_finds_it() {
199 let dir = TempDir::new("store-lookup");
200 let cache = cache(&dir);
201 let url = "https://registry.example.com/veh/common@v1.0.0";
202 let tar = make_tar("common.typl", b"package veh.common\ntype Speed: km/h\n");
203
204 let (sha, path) = cache.store(url, &tar).expect("the artifact stores");
205 assert_eq!(sha.len(), 64, "the hash is a 64-char hex string");
206 assert!(path.is_dir(), "the entry directory exists");
207
208 let unpacked = fs::read_to_string(path.join("common.typl")).expect("the file unpacked");
209 assert_eq!(unpacked, "package veh.common\ntype Speed: km/h\n");
210
211 assert_eq!(
213 cache.lookup(url, &sha),
214 Some(path),
215 "lookup finds the stored entry",
216 );
217 }
218
219 #[test]
221 fn lookup_misses_on_an_unknown_hash() {
222 let dir = TempDir::new("miss");
223 let cache = cache(&dir);
224 let url = "https://registry.example.com/veh/common@v1.0.0";
225 cache
226 .store(url, &make_tar("a.typl", b"package veh.common\n"))
227 .expect("store");
228
229 assert_eq!(
230 cache.lookup(url, &"0".repeat(64)),
231 None,
232 "an unstored hash is a cache miss",
233 );
234 let sha = sha256_hex(&make_tar("a.typl", b"package veh.common\n"));
237 assert_eq!(
238 cache.lookup("https://elsewhere.example.com/veh/common@v1.0.0", &sha),
239 None,
240 "the cache is indexed by URL as well as content hash",
241 );
242 }
243
244 #[test]
250 fn store_does_not_let_a_tar_escape_the_entry_directory() {
251 let dir = TempDir::new("traversal");
252 let cache = cache(&dir);
253 let url = "https://registry.example.com/veh/evil@v1.0.0";
254
255 let (_, benign_path) = cache
259 .store(
260 "https://registry.example.com/veh/benign@v1.0.0",
261 &tar_with_raw_name("benign.typl", b"package veh.benign\n"),
262 )
263 .expect("a benign hand-crafted tar stores");
264 assert!(
265 benign_path.join("benign.typl").is_file(),
266 "the hand-crafted tar format is valid and unpacks",
267 );
268
269 let tar = tar_with_raw_name("../poison", b"pwned");
272
273 let _ = cache.store(url, &tar);
276 assert!(
277 !contains_file_named(dir.path(), "poison"),
278 "an escaping tar entry must never be written anywhere in the cache tree",
279 );
280 }
281
282 fn tar_with_raw_name(name: &str, contents: &[u8]) -> Vec<u8> {
286 let mut header = [0u8; 512];
287 let name_bytes = name.as_bytes();
288 header[..name_bytes.len()].copy_from_slice(name_bytes);
289 header[100..108].copy_from_slice(b"0000644\0"); header[108..116].copy_from_slice(b"0000000\0"); header[116..124].copy_from_slice(b"0000000\0"); header[124..136].copy_from_slice(format!("{:011o}\0", contents.len()).as_bytes()); header[136..148].copy_from_slice(b"00000000000\0"); header[156] = b'0'; header[257..263].copy_from_slice(b"ustar\0"); header[263..265].copy_from_slice(b"00"); for byte in &mut header[148..156] {
301 *byte = b' ';
302 }
303 let checksum: u32 = header.iter().map(|&byte| u32::from(byte)).sum();
304 header[148..156].copy_from_slice(format!("{checksum:06o}\0 ").as_bytes());
305
306 let mut out = Vec::new();
307 out.extend_from_slice(&header);
308 out.extend_from_slice(contents);
309 out.resize(out.len() + (512 - contents.len() % 512) % 512, 0);
311 out.resize(out.len() + 1024, 0);
312 out
313 }
314
315 fn contains_file_named(root: &Path, name: &str) -> bool {
317 let mut stack = vec![root.to_path_buf()];
318 while let Some(dir) = stack.pop() {
319 let Ok(entries) = fs::read_dir(&dir) else {
320 continue;
321 };
322 for entry in entries.flatten() {
323 let path = entry.path();
324 if path.file_name().is_some_and(|found| found == name) {
325 return true;
326 }
327 if path.is_dir() {
328 stack.push(path);
329 }
330 }
331 }
332 false
333 }
334
335 #[test]
338 fn store_is_idempotent() {
339 let dir = TempDir::new("idempotent");
340 let cache = cache(&dir);
341 let url = "https://registry.example.com/veh/common@v1.0.0";
342 let tar = make_tar("a.typl", b"package veh.common\n");
343
344 let (sha1, path1) = cache.store(url, &tar).expect("first store");
345 let (sha2, path2) = cache.store(url, &tar).expect("second store");
346 assert_eq!(sha1, sha2, "the same bytes hash the same");
347 assert_eq!(path1, path2, "the same entry directory is returned");
348 }
349}