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}