Skip to main content

storage/
cas.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! CAS: content-addressed storage under BLAKE3
7//! (`crates/storage/spec/Cas.lean` §8-3).
8//!
9//! Three engineering facts owned here:
10//! - writes go tmp + rename: power loss leaves half-done temp files only,
11//!   never a corrupt named object (A3 point 2). A temp name carries the
12//!   content hash, the writing process and that process's put count, so
13//!   two handles writing the same bytes at once each rename a whole file
14//!   of their own, and opening a handle sweeps only what another process
15//!   left: the tmp files of this process may belong to a put still in
16//!   flight on another handle. No randomness anywhere.
17//! - dedup is existence: a second put of the same bytes returns the hash
18//!   without re-materializing.
19//! - range retrieval follows the Locator grammar: `L` 1-based closed
20//!   (interior newlines kept, final terminator excluded), `B` 0-based
21//!   closed; out of bounds refuses, never clamps.
22//!
23//! Full reads re-verify the hash (BLAKE3 runs at GB/s); range reads
24//! trust the object as verified at put and read only the bytes they
25//! answer with, which is why an object is never lifted whole to hand
26//! back a fragment of it.
27
28mod origin;
29mod ranges;
30
31pub use origin::BlockOrigin;
32
33use std::path::{Path, PathBuf};
34use std::sync::atomic::{AtomicU64, Ordering};
35
36use kernel::{B3Hash, Range};
37
38use crate::error::{StorageError, io_err};
39use crate::real_fs::RealFs;
40use crate::vfs::Vfs;
41
42pub struct Cas {
43    vfs: Box<dyn Vfs>,
44    dir: PathBuf,
45}
46
47impl Cas {
48    /// Production entrance: std filesystem underneath.
49    pub fn open(dir: &Path) -> Result<Cas, StorageError> {
50        Cas::open_with(Box::new(RealFs::new()), dir)
51    }
52
53    /// Injection point for the fault adapter (tests only).
54    pub(crate) fn open_with(mut vfs: Box<dyn Vfs>, dir: &Path) -> Result<Cas, StorageError> {
55        let objects = dir.join("b3");
56        let tmp = dir.join("tmp");
57        vfs.create_dir_all(&objects)
58            .map_err(io_err("create cas dir", &objects))?;
59        vfs.create_dir_all(&tmp)
60            .map_err(io_err("create cas tmp dir", &tmp))?;
61        // Sweep crash residue: rename is atomic, so a tmp file another
62        // process wrote is a half-done write whose put never returned Ok.
63        let leftovers = vfs.list(&tmp).map_err(io_err("list cas tmp", &tmp))?;
64        for leftover in leftovers
65            .into_iter()
66            .filter(|leftover| !TmpName::written_here(leftover))
67        {
68            vfs.remove_file(&leftover)
69                .map_err(io_err("sweep cas tmp", &leftover))?;
70        }
71        Ok(Cas {
72            vfs,
73            dir: dir.to_path_buf(),
74        })
75    }
76
77    fn object_path(&self, hash: &B3Hash) -> (PathBuf, PathBuf) {
78        let hex = hash.to_string();
79        let shard = hex.get(..2).unwrap_or("00");
80        let shard_dir = self.dir.join("b3").join(shard);
81        let path = shard_dir.join(&hex);
82        (shard_dir, path)
83    }
84
85    /// Content-addressed put: hash, dedup by existence, tmp + sync +
86    /// rename + dir sync. `Ok` means the named object is durable.
87    pub fn put(&mut self, bytes: &[u8]) -> Result<B3Hash, StorageError> {
88        let hash = B3Hash::from_bytes(*blake3::hash(bytes).as_bytes());
89        let (shard_dir, path) = self.object_path(&hash);
90        if self.vfs.exists(&path) {
91            return Ok(hash);
92        }
93        self.vfs
94            .create_dir_all(&shard_dir)
95            .map_err(io_err("create cas shard", &shard_dir))?;
96        let tmp = self.dir.join("tmp").join(TmpName::next(&hash).0);
97        if self.vfs.exists(&tmp) {
98            self.vfs
99                .truncate(&tmp, 0)
100                .map_err(io_err("reset cas tmp", &tmp))?;
101        }
102        self.vfs
103            .append(&tmp, bytes)
104            .map_err(io_err("write cas tmp", &tmp))?;
105        self.vfs
106            .sync_data(&tmp)
107            .map_err(io_err("sync cas tmp", &tmp))?;
108        self.vfs
109            .rename(&tmp, &path)
110            .map_err(io_err("rename cas object", &path))?;
111        self.vfs
112            .sync_dir(&shard_dir)
113            .map_err(io_err("sync cas shard", &shard_dir))?;
114        Ok(hash)
115    }
116
117    /// Existence judgment; no failure surface.
118    pub fn contains(&self, hash: &B3Hash) -> bool {
119        let (_, path) = self.object_path(hash);
120        self.vfs.exists(&path)
121    }
122
123    /// Full read, re-verified against the address.
124    pub fn get(&self, hash: &B3Hash) -> Result<Vec<u8>, StorageError> {
125        let (_, path) = self.object_path(hash);
126        if !self.vfs.exists(&path) {
127            return Err(StorageError::CasMissing {
128                hash: hash.to_string(),
129            });
130        }
131        let bytes = self
132            .vfs
133            .read(&path)
134            .map_err(io_err("read cas object", &path))?;
135        let echo = blake3::hash(&bytes);
136        if echo.as_bytes() != hash.as_bytes() {
137            return Err(StorageError::CasCorrupt {
138                hash: hash.to_string(),
139                path,
140            });
141        }
142        Ok(bytes)
143    }
144
145    /// Range retrieval per the Locator grammar; out of bounds refuses.
146    ///
147    /// The answer is read where it sits, so a fragment of a two hundred
148    /// megabyte object costs the fragment. The price of that is stated
149    /// in [`ranges`]: a range read cannot re-verify an address that
150    /// covers the whole object, so it trusts what `put` verified, and a
151    /// caller that needs the address proved calls [`Cas::get`].
152    pub fn get_range(&self, hash: &B3Hash, range: &Range) -> Result<Vec<u8>, StorageError> {
153        let (_, path) = self.object_path(hash);
154        if !self.vfs.exists(&path) {
155            return Err(StorageError::CasMissing {
156                hash: hash.to_string(),
157            });
158        }
159        ranges::of_object(self.vfs.as_ref(), &path, hash, range)
160    }
161
162    /// The object's length in bytes, read from the filesystem rather than
163    /// from the object, so asking how long a two hundred megabyte version
164    /// is costs nothing like two hundred megabytes. A window of a stored
165    /// document version needs it to keep its request inside the object
166    /// (`crates/storage/spec/Cas.lean` §8-36). Like a range read, it trusts what `put`
167    /// verified.
168    pub fn size(&self, hash: &B3Hash) -> Result<u64, StorageError> {
169        let (_, path) = self.object_path(hash);
170        if !self.vfs.exists(&path) {
171            return Err(StorageError::CasMissing {
172                hash: hash.to_string(),
173            });
174        }
175        self.vfs
176            .size(&path)
177            .map_err(io_err("measure cas object", &path))
178    }
179}
180
181/// The puts this process has begun, across every handle it holds.
182static PUTS_BEGUN: AtomicU64 = AtomicU64::new(0);
183
184/// A put's temp file name: `<hex64>.<pid>.<put count>.part`.
185struct TmpName(String);
186
187impl TmpName {
188    /// A name no other put in this process has used or will use.
189    fn next(hash: &B3Hash) -> TmpName {
190        let count = PUTS_BEGUN.fetch_add(1, Ordering::Relaxed);
191        TmpName(format!("{hash}.{}.{count}.part", std::process::id()))
192    }
193
194    /// Whether this process wrote the temp file at `path`, which makes
195    /// it possibly a put in flight on another handle rather than residue.
196    fn written_here(path: &Path) -> bool {
197        let pid = std::process::id().to_string();
198        path.file_name()
199            .and_then(|name| name.to_str())
200            .and_then(|name| name.split('.').nth(1))
201            .is_some_and(|writer| writer == pid)
202    }
203}
204
205#[cfg(test)]
206#[allow(
207    clippy::unwrap_used,
208    clippy::expect_used,
209    clippy::panic,
210    clippy::indexing_slicing,
211    reason = "test code"
212)]
213mod tests {
214    use super::*;
215    use crate::fault_fs::{FaultFs, FaultPlan, TornTail};
216    use kernel::{AxCode, Range};
217    use std::fs;
218
219    #[test]
220    fn put_get_roundtrip_and_reference_hash() {
221        let dir = tempfile::tempdir().unwrap();
222        let mut cas = Cas::open(dir.path()).unwrap();
223        let hash = cas.put(b"hello world").unwrap();
224        assert_eq!(
225            hash.to_string(),
226            blake3::hash(b"hello world").to_hex().to_string()
227        );
228        assert!(cas.contains(&hash));
229        assert_eq!(cas.get(&hash).unwrap(), b"hello world");
230    }
231
232    /// A stored document version states its length without being read,
233    /// and a version the store never kept is named rather than measured
234    /// as empty.
235    #[test]
236    fn a_stored_object_states_its_size_and_a_missing_one_is_named() {
237        let dir = tempfile::tempdir().unwrap();
238        let mut cas = Cas::open(dir.path()).unwrap();
239        let hash = cas.put(&[7_u8; 70_000]).unwrap();
240        assert_eq!(cas.size(&hash).unwrap(), 70_000);
241        let absent = kernel::B3Hash::digest(b"never stored");
242        assert!(
243            matches!(cas.size(&absent), Err(StorageError::CasMissing { .. })),
244            "{:?}",
245            cas.size(&absent)
246        );
247    }
248
249    #[test]
250    fn dedup_skips_rematerialization_and_tmp_stays_empty() {
251        let dir = tempfile::tempdir().unwrap();
252        let mut cas = Cas::open(dir.path()).unwrap();
253        let first = cas.put(b"same bytes").unwrap();
254        let second = cas.put(b"same bytes").unwrap();
255        assert_eq!(first, second);
256        let tmp_entries = fs::read_dir(dir.path().join("tmp")).unwrap().count();
257        assert_eq!(tmp_entries, 0, "no leftover temp files after clean puts");
258    }
259
260    /// A second handle opened while the first is between writing its
261    /// temp file and renaming it leaves that file alone, and still
262    /// sweeps what another process left behind.
263    #[test]
264    fn opening_a_handle_sweeps_only_what_another_process_left() {
265        let dir = tempfile::tempdir().unwrap();
266        Cas::open(dir.path()).unwrap();
267        let hash = kernel::B3Hash::from_bytes(*blake3::hash(b"in flight").as_bytes());
268        let tmp = dir.path().join("tmp");
269        let in_flight = tmp.join(TmpName::next(&hash).0);
270        let residue = tmp.join(format!(
271            "{hash}.{}.0.part",
272            std::process::id().wrapping_add(1)
273        ));
274        fs::write(&in_flight, b"in flight").unwrap();
275        fs::write(&residue, b"half").unwrap();
276        Cas::open(dir.path()).unwrap();
277        let left: Vec<PathBuf> = fs::read_dir(&tmp)
278            .unwrap()
279            .map(|entry| entry.unwrap().path())
280            .collect();
281        assert_eq!(left, vec![in_flight]);
282    }
283
284    #[test]
285    fn get_reverifies_and_reports_corruption() {
286        let dir = tempfile::tempdir().unwrap();
287        let mut cas = Cas::open(dir.path()).unwrap();
288        let hash = cas.put(b"precious").unwrap();
289        let hex = hash.to_string();
290        let path = dir.path().join("b3").join(hex.get(..2).unwrap()).join(&hex);
291        let mut bytes = fs::read(&path).unwrap();
292        bytes[0] ^= 0xFF;
293        fs::write(&path, &bytes).unwrap();
294        let err = cas.get(&hash).unwrap_err();
295        assert_eq!(err.into_ax().code(), &AxCode::CasCorrupt);
296    }
297
298    #[test]
299    fn missing_object_is_path_not_found() {
300        let dir = tempfile::tempdir().unwrap();
301        let cas = Cas::open(dir.path()).unwrap();
302        let ghost = kernel::B3Hash::from_bytes([9; 32]);
303        assert!(!cas.contains(&ghost));
304        let err = cas.get(&ghost).unwrap_err();
305        assert_eq!(err.into_ax().code(), &AxCode::PathNotFound);
306    }
307
308    #[test]
309    fn byte_and_line_ranges_follow_locator_semantics() {
310        let dir = tempfile::tempdir().unwrap();
311        let mut cas = Cas::open(dir.path()).unwrap();
312        let hash = cas.put(b"alpha\nbeta\ngamma").unwrap();
313
314        let bytes = cas.get_range(&hash, &Range::bytes(0, 4).unwrap()).unwrap();
315        assert_eq!(bytes, b"alpha");
316        let tail = cas
317            .get_range(&hash, &Range::bytes(11, 15).unwrap())
318            .unwrap();
319        assert_eq!(tail, b"gamma");
320
321        let one = cas.get_range(&hash, &Range::lines(2, 2).unwrap()).unwrap();
322        assert_eq!(one, b"beta");
323        let two = cas.get_range(&hash, &Range::lines(1, 2).unwrap()).unwrap();
324        assert_eq!(two, b"alpha\nbeta");
325        let all = cas.get_range(&hash, &Range::lines(1, 3).unwrap()).unwrap();
326        assert_eq!(all, b"alpha\nbeta\ngamma");
327
328        for bad in [
329            Range::bytes(0, 16).unwrap(),  // past the end
330            Range::bytes(16, 20).unwrap(), // fully outside
331            Range::lines(1, 4).unwrap(),   // more lines than exist
332            Range::lines(4, 5).unwrap(),
333        ] {
334            let err = cas.get_range(&hash, &bad).unwrap_err();
335            assert_eq!(err.into_ax().code(), &AxCode::InvalidArgs, "{bad:?}");
336        }
337    }
338
339    /// A range read moves the range; a full read moves the object and
340    /// pays for the verification the range read does not do.
341    ///
342    /// The counter is the assertion, because the bytes handed back are
343    /// identical either way: an implementation that lifts the object
344    /// and slices it answers every grammar test above and charges the
345    /// length of the object for every fragment.
346    #[test]
347    fn a_range_read_lifts_the_range_rather_than_the_object() {
348        let fs = FaultFs::new(FaultPlan {
349            cut_at_op: None,
350            cut_on_write: None,
351            torn_tail: TornTail::None,
352        });
353        let mut cas = Cas::open_with(Box::new(fs.clone()), std::path::Path::new("c")).unwrap();
354        let mut content = Vec::new();
355        for i in 0..200_000u32 {
356            content.extend_from_slice(format!("line {i} of a large object\n").as_bytes());
357        }
358        let object_len = u64::try_from(content.len()).unwrap();
359        let hash = cas.put(&content).unwrap();
360
361        let before = fs.bytes_read();
362        let five = cas.get_range(&hash, &Range::bytes(6, 10).unwrap()).unwrap();
363        assert_eq!(five, &content[6..=10]);
364        let five_moved = fs.bytes_read() - before;
365        assert!(
366            five_moved <= 64,
367            "a five-byte range moved {five_moved} bytes of a {object_len}-byte object"
368        );
369
370        let before = fs.bytes_read();
371        let second = cas.get_range(&hash, &Range::lines(2, 2).unwrap()).unwrap();
372        assert_eq!(second, b"line 1 of a large object");
373        let moved = fs.bytes_read() - before;
374        assert!(
375            moved <= ranges::SCAN_CHUNK_BYTES && moved.saturating_mul(10) < object_len,
376            "a line near the front moved {moved} bytes of a {object_len}-byte object"
377        );
378
379        // Past the end still refuses rather than answering short.
380        let err = cas
381            .get_range(&hash, &Range::bytes(object_len - 2, object_len).unwrap())
382            .unwrap_err();
383        assert_eq!(err.into_ax().code(), &AxCode::InvalidArgs);
384
385        // The verifying path is the full read, and it costs the object.
386        let before = fs.bytes_read();
387        assert_eq!(cas.get(&hash).unwrap(), content);
388        let whole = fs.bytes_read() - before;
389        assert!(whole >= object_len);
390        eprintln!(
391            "cas_range_read: object {object_len} B, five-byte range {five_moved} B, \
392             line 2 {moved} B, full read {whole} B"
393        );
394    }
395
396    /// A3 point 2: power loss around CAS rename. Cut at every op of a put
397    /// script; named objects never corrupt, only tmp holds leftovers, and
398    /// a retried put lands the object.
399    #[test]
400    fn power_cut_matrix_leaves_only_tmp_halfware() {
401        let content: &[u8] = b"the object under power loss";
402
403        // Baseline op count.
404        let fs0 = FaultFs::new(FaultPlan {
405            cut_at_op: None,
406            cut_on_write: None,
407            torn_tail: TornTail::None,
408        });
409        let mut cas = Cas::open_with(Box::new(fs0.clone()), std::path::Path::new("c")).unwrap();
410        cas.put(content).unwrap();
411        let total_ops = fs0.op_count();
412
413        for torn in [TornTail::None, TornTail::KeepBytes(5)] {
414            for cut in 1..=total_ops {
415                let fs = FaultFs::new(FaultPlan {
416                    cut_at_op: Some(cut),
417                    cut_on_write: None,
418                    torn_tail: torn,
419                });
420                let dir = std::path::Path::new("c");
421                let acknowledged = match Cas::open_with(Box::new(fs.clone()), dir) {
422                    Ok(mut cas) => cas.put(content).is_ok(),
423                    Err(_) => false,
424                };
425
426                // Reopen (second cut impossible: plan consumed). Sweep runs.
427                let mut cas = match Cas::open_with(Box::new(fs.clone()), dir) {
428                    Ok(cas) => cas,
429                    Err(_) => Cas::open_with(Box::new(fs.clone()), dir).unwrap(),
430                };
431                let hash = kernel::B3Hash::from_bytes(*blake3::hash(content).as_bytes());
432                if acknowledged {
433                    assert_eq!(
434                        cas.get(&hash).unwrap(),
435                        content,
436                        "cut {cut} ({torn:?}): acknowledged object must survive"
437                    );
438                }
439                if cas.contains(&hash) {
440                    // Present but never corrupt \u2014 the A3 point-2 claim.
441                    assert_eq!(cas.get(&hash).unwrap(), content);
442                }
443                // Retry always lands the object.
444                cas.put(content).unwrap();
445                assert_eq!(cas.get(&hash).unwrap(), content);
446            }
447        }
448    }
449}