1mod 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 pub fn open(dir: &Path) -> Result<Cas, StorageError> {
50 Cas::open_with(Box::new(RealFs::new()), dir)
51 }
52
53 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 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 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 pub fn contains(&self, hash: &B3Hash) -> bool {
119 let (_, path) = self.object_path(hash);
120 self.vfs.exists(&path)
121 }
122
123 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 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 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
181static PUTS_BEGUN: AtomicU64 = AtomicU64::new(0);
183
184struct TmpName(String);
186
187impl TmpName {
188 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 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 #[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 #[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(), Range::bytes(16, 20).unwrap(), Range::lines(1, 4).unwrap(), 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 #[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 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 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 #[test]
400 fn power_cut_matrix_leaves_only_tmp_halfware() {
401 let content: &[u8] = b"the object under power loss";
402
403 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 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 assert_eq!(cas.get(&hash).unwrap(), content);
442 }
443 cas.put(content).unwrap();
445 assert_eq!(cas.get(&hash).unwrap(), content);
446 }
447 }
448 }
449}