Skip to main content

cargoless_cas/
lib.rs

1//! Content-addressed storage + the build-input hashing that keys it.
2//!
3//! The [`ContentStore`] trait is the v1 remote-backend seam — it MUST exist in
4//! v0 even though only [`LocalDiskStore`] implements it, or v1 becomes a
5//! rewrite (decision D10). v0 ships local-disk only; S3/RustFS are v1.
6//!
7//! This crate also owns the half of the `cargoless-proto` contract that `cargoless-proto`
8//! deliberately does *not* specify: the hash algorithm ([`sha256`]) and the
9//! `BuildIdentity → InputHash` reduction ([`input_hash`]) that the whole AC#5
10//! dedupe / AC#4 provenance guarantee rests on. The daemon assembles a
11//! `BuildIdentity`; this crate turns it into the CAS key and stores the bytes.
12//!
13//! | Concern | Where |
14//! |---|---|
15//! | stable hash primitive | [`sha256`] |
16//! | `BuildIdentity → InputHash`, per-file content hashing | [`identity`] |
17//! | deterministic whole-source-tree hash | [`tree`] |
18//! | `tf clean` wipe semantics + safety guard | [`clean`] |
19//! | artifact byte storage (trait + local disk) | [`ContentStore`] |
20
21pub mod clean;
22pub mod identity;
23pub mod sha256;
24pub mod tree;
25
26pub use clean::{UnsafeCacheRoot, clean_cache, guard_cache_root};
27pub use identity::{absent_marker, content_hash, input_hash};
28pub use sha256::sha256_hex;
29pub use tree::hash_source_tree;
30
31use std::fs;
32use std::io;
33use std::io::Write;
34use std::path::{Path, PathBuf};
35use std::sync::atomic::{AtomicU64, Ordering};
36
37use cargoless_proto::InputHash;
38
39/// Per-process monotonic counter for temp-file names. Combined with the
40/// PID it makes every in-flight `put` temp path unique across threads
41/// *and* across the multiple cargoless processes that share one
42/// `--cas-dir` in a Model R fleet — the prerequisite for the
43/// write-to-temp-then-atomic-rename store being concurrency-safe.
44static PUT_SEQ: AtomicU64 = AtomicU64::new(0);
45
46/// A store that maps an [`InputHash`] to opaque artifact bytes.
47pub trait ContentStore {
48    /// Store `bytes` under `key`. Idempotent: storing the same key twice is a
49    /// no-op (this is what makes AC#5 cache-dedupe possible).
50    fn put(&self, key: &InputHash, bytes: &[u8]) -> io::Result<()>;
51
52    /// Fetch the bytes for `key`, or `None` if absent.
53    fn get(&self, key: &InputHash) -> io::Result<Option<Vec<u8>>>;
54
55    /// Whether `key` is already present — the build pipeline checks this to
56    /// skip a build entirely on a cache hit.
57    fn contains(&self, key: &InputHash) -> io::Result<bool> {
58        Ok(self.get(key)?.is_some())
59    }
60}
61
62/// Local filesystem CAS: one file per content hash under `root`.
63pub struct LocalDiskStore {
64    root: PathBuf,
65}
66
67impl LocalDiskStore {
68    pub fn new(root: impl Into<PathBuf>) -> Self {
69        Self { root: root.into() }
70    }
71
72    fn path_for(&self, key: &InputHash) -> PathBuf {
73        self.root.join(key.as_str())
74    }
75
76    /// A unique sibling temp path in the *same directory* as `final_path`
77    /// (so the subsequent rename is same-filesystem ⇒ atomic). The name
78    /// embeds PID + a process-monotonic sequence so two writers — threads
79    /// or separate fleet processes — never collide on the temp file.
80    fn tmp_path(final_path: &Path) -> PathBuf {
81        let seq = PUT_SEQ.fetch_add(1, Ordering::Relaxed);
82        let pid = std::process::id();
83        let name = final_path
84            .file_name()
85            .map(|s| s.to_string_lossy().into_owned())
86            .unwrap_or_default();
87        let dir = final_path
88            .parent()
89            .map(Path::to_path_buf)
90            .unwrap_or_default();
91        dir.join(format!(".tmp.{name}.{pid}.{seq}"))
92    }
93}
94
95impl ContentStore for LocalDiskStore {
96    /// Store `bytes` under `key`, **atomically**.
97    ///
98    /// ## #2 — Model R concurrent-writer safety
99    ///
100    /// Before Model R, daemons were single-writer per process and
101    /// PID-scoped their CAS dir, so an in-place `fs::write` was adequate.
102    /// Model R's whole point is a *shared* `--cas-dir` across a fleet of
103    /// daemons: N writers, multiple processes, one directory. An in-place
104    /// truncate-then-write is **not** safe there — a concurrent reader
105    /// doing `contains()`→`get()` can observe a half-written file.
106    /// Content-addressing guarantees the *final* bytes converge (same key
107    /// ⇒ identical bytes); it does **not** make an interleaved read whole.
108    ///
109    /// Fix: write to a unique sibling temp file, `fsync` it, then
110    /// `rename` it onto the final path. POSIX `rename(2)` within one
111    /// filesystem is atomic — a reader sees either the old state (absent)
112    /// or the complete new file, never a torn one. If another writer wins
113    /// the race the rename simply replaces an identical-content file
114    /// (content-addressed), so the result is still correct and the `put`
115    /// stays idempotent (the AC#5 cache-hit invariant). The `exists()`
116    /// fast-path is kept so a cache hit costs one `stat`, not a rewrite.
117    ///
118    /// (Windows non-atomic-replace is out of scope — `CLAUDE.md` parks
119    /// Windows in v1; macOS + Linux are the supported fleet hosts.)
120    fn put(&self, key: &InputHash, bytes: &[u8]) -> io::Result<()> {
121        let path = self.path_for(key);
122        if path.exists() {
123            return Ok(());
124        }
125        if let Some(parent) = path.parent() {
126            fs::create_dir_all(parent)?;
127        }
128        let tmp = Self::tmp_path(&path);
129        // Scope the file handle so it is closed before the rename.
130        let write_then_sync = || -> io::Result<()> {
131            let mut f = fs::File::create(&tmp)?;
132            f.write_all(bytes)?;
133            f.sync_all()?; // bytes durable before the name becomes visible
134            Ok(())
135        };
136        if let Err(e) = write_then_sync() {
137            let _ = fs::remove_file(&tmp);
138            return Err(e);
139        }
140        match fs::rename(&tmp, &path) {
141            Ok(()) => Ok(()),
142            Err(e) => {
143                let _ = fs::remove_file(&tmp);
144                // A racing writer may have created `path` first; since the
145                // content is addressed by `key`, an existing file is the
146                // correct file — treat as success (idempotent put).
147                if path.exists() { Ok(()) } else { Err(e) }
148            }
149        }
150    }
151
152    fn get(&self, key: &InputHash) -> io::Result<Option<Vec<u8>>> {
153        match fs::read(self.path_for(key)) {
154            Ok(bytes) => Ok(Some(bytes)),
155            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
156            Err(e) => Err(e),
157        }
158    }
159}
160
161/// Test-only helper: a unique temp dir under the OS temp root.
162fn scratch_dir(tag: &str) -> PathBuf {
163    let mut p: PathBuf = std::env::temp_dir();
164    p.push(format!("cargoless-cas-{tag}-{}", std::process::id()));
165    p
166}
167
168/// Public so `scratch_dir` is exercised outside `#[cfg(test)]` and the
169/// skeleton stays clippy-clean under `-D warnings`. Returns the default
170/// cache root for the current process when no config overrides it.
171pub fn default_scratch_root() -> PathBuf {
172    scratch_dir("default")
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn roundtrip_and_dedupe() {
181        let dir = scratch_dir("roundtrip");
182        let _ = fs::remove_dir_all(&dir);
183        let store = LocalDiskStore::new(&dir);
184        let key = InputHash::new("abc123");
185
186        assert!(!store.contains(&key).unwrap());
187        store.put(&key, b"hello").unwrap();
188        assert!(store.contains(&key).unwrap());
189        assert_eq!(store.get(&key).unwrap().as_deref(), Some(&b"hello"[..]));
190
191        // Idempotent put — the AC#5 cache-hit invariant.
192        store.put(&key, b"ignored-second-write").unwrap();
193        assert_eq!(store.get(&key).unwrap().as_deref(), Some(&b"hello"[..]));
194
195        let _ = fs::remove_dir_all(&dir);
196    }
197
198    #[test]
199    fn missing_key_is_none() {
200        let store = LocalDiskStore::new(default_scratch_root());
201        assert!(
202            store
203                .get(&InputHash::new("nope-not-here"))
204                .unwrap()
205                .is_none()
206        );
207    }
208
209    // ---- #2 Model R: concurrent-writer safety on a shared --cas-dir ----
210    //
211    // The payload is deliberately large (256 KiB): a non-atomic in-place
212    // `fs::write` of this size is NOT a single write(2) syscall, so an
213    // interleaved reader would observe a short/torn file. With the
214    // temp+rename store the reader sees only absent-or-complete. These
215    // tests fail loudly on the pre-#2 implementation and pass on the fix.
216
217    use std::sync::Arc;
218    use std::sync::Barrier;
219    use std::thread;
220
221    fn big_payload(seed: u8) -> Vec<u8> {
222        (0..256 * 1024)
223            .map(|i| (i as u8).wrapping_add(seed))
224            .collect()
225    }
226
227    #[test]
228    fn concurrent_same_key_never_torn_read() {
229        let dir = scratch_dir("cc-same-key");
230        let _ = fs::remove_dir_all(&dir);
231        fs::create_dir_all(&dir).unwrap();
232        let store = Arc::new(LocalDiskStore::new(&dir));
233        // Content-addressed: same key ⇒ identical bytes, by definition.
234        let key = InputHash::new(content_hash(&big_payload(0)).as_str());
235        let payload = Arc::new(big_payload(0));
236
237        let writers = 16usize;
238        let readers = 8usize;
239        let gate = Arc::new(Barrier::new(writers + readers));
240        let mut handles = Vec::new();
241
242        for _ in 0..writers {
243            let (s, k, p, g) = (store.clone(), key.clone(), payload.clone(), gate.clone());
244            handles.push(thread::spawn(move || {
245                g.wait();
246                for _ in 0..8 {
247                    s.put(&k, &p).unwrap();
248                }
249            }));
250        }
251        for _ in 0..readers {
252            let (s, k, p, g) = (store.clone(), key.clone(), payload.clone(), gate.clone());
253            handles.push(thread::spawn(move || {
254                g.wait();
255                for _ in 0..200 {
256                    if let Some(got) = s.get(&k).unwrap() {
257                        // The whole point: a present file is ALWAYS the
258                        // complete, correct content — never a torn prefix.
259                        assert_eq!(
260                            got.len(),
261                            p.len(),
262                            "torn read: short file observed mid-write"
263                        );
264                        assert_eq!(&got, &*p, "torn read: corrupt content");
265                    }
266                }
267            }));
268        }
269        for h in handles {
270            h.join().unwrap();
271        }
272        assert_eq!(store.get(&key).unwrap().as_deref(), Some(&payload[..]));
273        let _ = fs::remove_dir_all(&dir);
274    }
275
276    #[test]
277    fn concurrent_distinct_keys_all_land_intact() {
278        let dir = scratch_dir("cc-distinct");
279        let _ = fs::remove_dir_all(&dir);
280        fs::create_dir_all(&dir).unwrap();
281        let store = Arc::new(LocalDiskStore::new(&dir));
282
283        let n = 24u8;
284        let gate = Arc::new(Barrier::new(n as usize));
285        let mut handles = Vec::new();
286        for seed in 0..n {
287            let (s, g) = (store.clone(), gate.clone());
288            handles.push(thread::spawn(move || {
289                let payload = big_payload(seed);
290                let key = InputHash::new(content_hash(&payload).as_str());
291                g.wait();
292                s.put(&key, &payload).unwrap();
293                (key, payload)
294            }));
295        }
296        for h in handles {
297            let (key, payload) = h.join().unwrap();
298            let got = store.get(&key).unwrap().expect("key must be present");
299            assert_eq!(got, payload, "cross-key corruption under concurrency");
300            // content-addressed atomicity: stored bytes re-hash to the key.
301            assert_eq!(content_hash(&got).as_str(), key.as_str());
302        }
303        let _ = fs::remove_dir_all(&dir);
304    }
305
306    #[test]
307    fn hash_reduction_is_concurrency_stable() {
308        // §9a guard within #2's scope: the frozen wire-format byte
309        // constants (the input-hash SCHEME prefix + per-field tags,
310        // defined in identity.rs; the source-tree header in tree.rs) are
311        // NOT touched by this commit (`git diff` proves it; the #9
312        // determinism suite + #96 drift-guard are the standing guards —
313        // and this comment deliberately does not quote those literals, so
314        // it stays outside the D1 drift-guard's banned-token surface).
315        // What #2 must additionally
316        // show is that the reduction stays *consistent under fleet
317        // concurrency* — if any shared state perturbed the preimage, many
318        // threads hashing identical inputs would diverge. They must not.
319        use cargoless_proto::{BuildIdentity, ContentHash, Profile, TargetTriple};
320        let identity = BuildIdentity {
321            source_tree: ContentHash::new("src-tree"),
322            cargo_lock: ContentHash::new("lock"),
323            rust_toolchain: ContentHash::new("toolchain"),
324            tf_config: ContentHash::new("cfg"),
325            target: TargetTriple::new("wasm32-unknown-unknown"),
326            profile: Profile::Dev,
327        };
328        let expect = input_hash(&identity).as_str().to_string();
329        let identity = Arc::new(identity);
330        let gate = Arc::new(Barrier::new(32));
331        let mut handles = Vec::new();
332        for _ in 0..32 {
333            let (id, g, want) = (identity.clone(), gate.clone(), expect.clone());
334            handles.push(thread::spawn(move || {
335                g.wait();
336                for _ in 0..50 {
337                    assert_eq!(
338                        input_hash(&id).as_str(),
339                        want,
340                        "InputHash diverged under concurrency — \
341                         wire-format preimage is not stable"
342                    );
343                }
344            }));
345        }
346        for h in handles {
347            h.join().unwrap();
348        }
349    }
350}