Skip to main content

ridl_core/
cache.rs

1//! The content-addressed package cache (docs/ROADMAP.md epic E1.6, ADR-0002
2//! §7).
3//!
4//! The cache lives at `~/.ridl/cache` (ADR-0002 §7), indexed by URL and by the
5//! SHA-256 content hash of the fetched artifact. A cached entry is never
6//! re-fetched as long as the hash on record matches. The layout is
7//!
8//! ```text
9//! <root>/<sha256(url)>/<sha256(artifact)>/…unpacked package files…
10//! ```
11//!
12//! The URL is hashed into the first path segment so the cache is indexed by URL
13//! (ADR-0002 §7); the artifact hash is the second segment so the same URL can
14//! hold more than one pinned version. The fetched artifact is an uncompressed
15//! tar archive of one package directory (ADR-0007 decision 12, provisional
16//! until the registry spec E7.4); [`Cache::store`] unpacks it into the entry
17//! directory.
18//!
19//! This module sits behind the `fetch` feature: it hashes with `sha2` and
20//! unpacks with `tar`, and the content-addressed cache only exists when remote
21//! fetch is compiled in.
22
23use std::fmt::Write as _;
24use std::fs;
25use std::io;
26use std::path::PathBuf;
27
28use sha2::{Digest, Sha256};
29
30/// The on-disk package cache, rooted at [`Cache::root`].
31#[derive(Debug, Clone)]
32pub struct Cache {
33    pub root: PathBuf,
34}
35
36impl Cache {
37    /// The cache rooted at `~/.ridl/cache` (ADR-0002 §7). When the home
38    /// directory is unknown it falls back to `.ridl/cache` relative to the
39    /// current directory.
40    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    /// The unpacked package directory for `url` pinned to `sha256`, if it is
49    /// present in the cache. A hit means the artifact with that content hash is
50    /// already unpacked, so no fetch is needed.
51    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    /// Stores the artifact `bytes` fetched from `url`, unpacking the tar into
57    /// the content-addressed entry directory. Returns the artifact's SHA-256
58    /// content hash (lowercase hex) and the unpacked directory.
59    ///
60    /// The tar is unpacked into a temporary sibling directory and then renamed
61    /// into place, so a fetch interrupted mid-unpack never leaves a partial
62    /// directory that a later [`lookup`](Cache::lookup) would treat as a hit. A
63    /// re-store of already-cached content is a no-op that returns the existing
64    /// directory.
65    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        // The staging name keys on the process id and the content hash, not a
76        // per-call token, so `store` is not safe for two concurrent
77        // same-process calls storing the *same* content — they would share this
78        // directory. That is fine today: `materialize_imports` is sequential and
79        // deduplicates URLs, so no two concurrent stores race here.
80        let staging = parent.join(format!(".staging-{sha256}-{}", std::process::id()));
81        // A stale staging directory from a crashed run would make `create_dir`
82        // fail; clear it first.
83        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            // A concurrent store may have created `dest` between the `is_dir`
95            // check and the rename; the content is identical (same hash), so
96            // adopt it and drop the staging copy.
97            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    /// The first path segment for `url`: `<root>/<sha256(url)>`.
109    fn url_dir(&self, url: &str) -> PathBuf {
110        self.root.join(sha256_hex(url.as_bytes()))
111    }
112
113    /// The full entry directory for `url` pinned to `content_sha`.
114    fn entry_dir(&self, url: &str, content_sha: &str) -> PathBuf {
115        self.url_dir(url).join(content_sha)
116    }
117}
118
119/// The lowercase-hex SHA-256 of `bytes`.
120fn 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        // Writing to a String is infallible.
125        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    /// A unique directory under the system temp dir, removed on drop.
138    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    /// Builds an uncompressed tar archive holding one file at `name` with the
165    /// given `contents` — the provisional fetch artifact (ADR-0007 decision 12).
166    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    /// `sha256_hex` matches the SHA-256 of the empty input — the known NIST
185    /// value — so the hashing is byte-correct, not just self-consistent.
186    #[test]
187    fn sha256_hex_matches_the_known_empty_hash() {
188        assert_eq!(
189            sha256_hex(b""),
190            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
191        );
192    }
193
194    /// Storing an artifact unpacks the tar into the content-addressed entry
195    /// directory and returns the artifact's hash; a subsequent lookup by that
196    /// hash finds the unpacked package.
197    #[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        // A lookup by the same URL and hash finds the same directory.
212        assert_eq!(
213            cache.lookup(url, &sha),
214            Some(path),
215            "lookup finds the stored entry",
216        );
217    }
218
219    /// A lookup with a hash that was never stored is a miss.
220    #[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        // A different URL with the right hash still misses: the URL is part of
235        // the index (ADR-0002 §7).
236        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    /// A tar carrying a parent-directory-escaping entry (`../poison`) must not
245    /// write anything outside the entry directory. The `tar` crate skips such
246    /// entries on unpack today; this test locks the security property so a
247    /// future swap of the unpack mechanism cannot silently reintroduce path
248    /// traversal.
249    #[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        // Sanity: the hand-crafting is valid, so a benign raw-name tar unpacks.
256        // This proves the escaping case below is stopped by the traversal guard,
257        // not by a malformed archive the reader rejects outright.
258        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        // The tar builder refuses to *write* a `..` path, so the malicious
270        // archive is hand-crafted to reach the unpack-side guard.
271        let tar = tar_with_raw_name("../poison", b"pwned");
272
273        // Storing may succeed (skipping the escaping entry) or error; neither
274        // outcome may write outside the entry directory.
275        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    /// Hand-builds a single-entry POSIX tar whose file name is written verbatim
283    /// — bypassing the `tar` builder's refusal to emit `..` paths — so a
284    /// path-traversal archive can be constructed for the unpack guard test.
285    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"); // mode
290        header[108..116].copy_from_slice(b"0000000\0"); // uid
291        header[116..124].copy_from_slice(b"0000000\0"); // gid
292        header[124..136].copy_from_slice(format!("{:011o}\0", contents.len()).as_bytes()); // size
293        header[136..148].copy_from_slice(b"00000000000\0"); // mtime
294        header[156] = b'0'; // typeflag: regular file
295        header[257..263].copy_from_slice(b"ustar\0"); // magic
296        header[263..265].copy_from_slice(b"00"); // version
297
298        // Checksum: sum every header byte with the checksum field taken as
299        // spaces, then write it as six octal digits, a NUL, and a space.
300        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        // Pad the data to a 512-byte boundary, then end with two zero blocks.
310        out.resize(out.len() + (512 - contents.len() % 512) % 512, 0);
311        out.resize(out.len() + 1024, 0);
312        out
313    }
314
315    /// Whether any file named `name` exists anywhere under `root`.
316    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    /// Re-storing already-cached content is a no-op that returns the existing
336    /// directory with the same hash.
337    #[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}