1use std::collections::{BTreeMap, BTreeSet};
4use std::fs::{File, OpenOptions};
5use std::io::{Read, Seek, SeekFrom, Write};
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::{Arc, Mutex};
9use std::time::SystemTime;
10
11use sha2::{Digest as _, Sha256};
12
13use crate::error::{ImageError, ImageResult};
14
15const FILE_MERKLE_LEAF_SIZE: usize = 64 * 1024;
20const MERKLE_LEAF_DOMAIN: &[u8] = b"microsandbox.checkpoint-file/1\0leaf\0";
21const MERKLE_PARENT_DOMAIN: &[u8] = b"microsandbox.checkpoint-file/1\0parent\0";
22const MERKLE_ROOT_DOMAIN: &[u8] = b"microsandbox.checkpoint-file/1\0root\0";
23
24pub use microsandbox_types::snapshot::disk::ObjectId;
29
30#[derive(Clone, Debug)]
32pub struct LocalObjectStore {
33 root: PathBuf,
34 ownership: Arc<StoreOwnership>,
35}
36
37#[derive(Debug, Default)]
38struct StoreOwnership {
39 publication: Mutex<()>,
40}
41
42#[derive(Clone, Debug)]
50pub struct AdmittedObject {
51 id: ObjectId,
52 path: PathBuf,
53 stamp: ObjectStamp,
54 ownership: Arc<StoreOwnership>,
55}
56
57#[derive(Clone, Debug, Eq, PartialEq)]
58struct ObjectStamp {
59 identity: (u64, u64),
60 length: u64,
61 modified: SystemTime,
62}
63
64pub struct CaptureObjectBatch {
69 store: LocalObjectStore,
70 admitted: Mutex<BTreeMap<ObjectId, AdmittedObject>>,
71 directories: Mutex<BTreeSet<PathBuf>>,
72 hashed_bytes: AtomicU64,
73 linked_bytes: AtomicU64,
74 copied_bytes: AtomicU64,
75 directory_syncs: AtomicU64,
76}
77
78#[derive(Clone, Copy, Debug, Default)]
80pub struct CaptureObjectBatchStats {
81 pub hashed_bytes: u64,
83 pub linked_bytes: u64,
85 pub copied_bytes: u64,
87 pub directory_syncs: u64,
89}
90
91#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct SparseFileIntegrity {
94 pub root: String,
96 pub logical_size: u64,
98}
99
100struct MerkleAccumulator {
101 levels: Vec<Option<[u8; 32]>>,
102}
103
104impl LocalObjectStore {
109 pub fn open(root: impl Into<PathBuf>) -> ImageResult<Self> {
111 let root = root.into();
112 std::fs::create_dir_all(root.join("objects").join("sha256"))?;
113 Ok(Self {
114 root,
115 ownership: Arc::new(StoreOwnership::default()),
116 })
117 }
118
119 pub fn put_bytes(&self, bytes: &[u8]) -> ImageResult<ObjectId> {
121 let id = ObjectId::from_bytes(bytes)?;
122 let path = self.object_path(&id);
123 if path.exists() {
124 self.verify_existing(&id, &path)?;
125 sync_directories_through(path.parent().expect("object parent"), &self.root)?;
126 return Ok(id);
127 }
128 let parent = path.parent().expect("object path has a parent");
129 std::fs::create_dir_all(parent)?;
130 let temporary = parent.join(format!(".{}.{}.tmp", id.hex(), rand::random::<u64>()));
131 let mut file = OpenOptions::new()
132 .write(true)
133 .create_new(true)
134 .open(&temporary)?;
135 file.write_all(bytes)?;
136 file.sync_all()?;
137 drop(file);
138 let published = publish_object_file(&temporary, &path, &self.ownership);
139 let _ = std::fs::remove_file(&temporary);
140 if !published? {
141 self.verify_existing(&id, &path)?;
142 }
143 sync_directories_through(parent, &self.root)?;
144 Ok(id)
145 }
146
147 pub fn object_path(&self, id: &ObjectId) -> PathBuf {
149 let encoded = id.hex();
150 self.root
151 .join("objects")
152 .join("sha256")
153 .join(&encoded[..2])
154 .join(encoded)
155 }
156
157 pub fn link_into(&self, id: &ObjectId, closure_root: &Path) -> ImageResult<PathBuf> {
159 let source = self.object_path(id);
160 self.verify_existing(id, &source)?;
161 let encoded = id.hex();
162 let target = closure_root
163 .join("objects")
164 .join("sha256")
165 .join(&encoded[..2])
166 .join(encoded);
167 if target.exists() {
168 self.verify_existing(id, &target)?;
169 return Ok(target);
170 }
171 let parent = target.parent().expect("closure object has a parent");
172 std::fs::create_dir_all(parent)?;
173 match std::fs::hard_link(&source, &target) {
174 Ok(()) => {}
175 Err(_) => {
176 std::fs::copy(&source, &target)?;
177 let sync_result = OpenOptions::new()
180 .read(true)
181 .write(true)
182 .open(&target)
183 .and_then(|file| file.sync_all());
184 if let Err(error) = sync_result {
185 let _ = std::fs::remove_file(&target);
186 return Err(error.into());
187 }
188 }
189 }
190 sync_directories_through(parent, closure_root)?;
191 Ok(target)
192 }
193
194 fn verify_existing(&self, id: &ObjectId, path: &Path) -> ImageResult<()> {
195 let mut file = File::open(path)?;
196 let mut hasher = Sha256::new();
197 let mut buffer = vec![0u8; 1024 * 1024];
198 loop {
199 let read = file.read(&mut buffer)?;
200 if read == 0 {
201 break;
202 }
203 hasher.update(&buffer[..read]);
204 }
205 let actual = format!("sha256:{}", hex::encode(hasher.finalize()));
206 if actual != id.as_str() {
207 return Err(ImageError::DigestMismatch {
208 digest: id.as_str().into(),
209 expected: id.as_str().into(),
210 actual,
211 });
212 }
213 Ok(())
214 }
215}
216
217impl CaptureObjectBatch {
218 pub fn new(store: LocalObjectStore, previous: &[AdmittedObject]) -> Self {
220 let admitted = previous
221 .iter()
222 .filter(|object| Arc::ptr_eq(&object.ownership, &store.ownership))
223 .map(|object| (object.id.clone(), object.clone()))
224 .collect();
225 Self {
226 store,
227 admitted: Mutex::new(admitted),
228 directories: Mutex::new(BTreeSet::new()),
229 hashed_bytes: AtomicU64::new(0),
230 linked_bytes: AtomicU64::new(0),
231 copied_bytes: AtomicU64::new(0),
232 directory_syncs: AtomicU64::new(0),
233 }
234 }
235
236 pub fn put_bytes(&self, bytes: &[u8]) -> ImageResult<ObjectId> {
238 let id = ObjectId::from_bytes(bytes)?;
239 self.hashed_bytes
240 .fetch_add(bytes.len() as u64, Ordering::Relaxed);
241 if let Some(object) = self.admitted.lock().unwrap().get(&id).cloned() {
242 object.validate()?;
243 return Ok(id);
244 }
245 let path = self.store.object_path(&id);
246 if path.exists() {
247 self.admit(&id)?;
248 self.record_directories(path.parent().unwrap(), &self.store.root);
250 return Ok(id);
251 }
252 let parent = path.parent().expect("object parent");
253 std::fs::create_dir_all(parent)?;
254 let temporary = parent.join(format!(".{}.{}.tmp", id.hex(), rand::random::<u64>()));
255 let result = (|| -> ImageResult<AdmittedObject> {
256 let mut file = OpenOptions::new()
257 .write(true)
258 .create_new(true)
259 .open(&temporary)?;
260 file.write_all(bytes)?;
261 file.sync_all()?;
262 drop(file);
263 if publish_object_file(&temporary, &path, &self.store.ownership)? {
264 self.open_admitted(id.clone(), path.clone())
265 } else {
266 self.admit(&id)
267 }
268 })();
269 let _ = std::fs::remove_file(&temporary);
270 let object = result?;
271 self.admitted.lock().unwrap().insert(id.clone(), object);
272 self.record_directories(parent, &self.store.root);
273 Ok(id)
274 }
275
276 pub fn link_into(&self, id: &ObjectId, closure_root: &Path) -> ImageResult<PathBuf> {
278 let known = self.admitted.lock().unwrap().get(id).cloned();
281 let object = match known {
282 Some(object) => object,
283 None => self.admit(id)?,
284 };
285 let pinned = object.pin()?;
286 let encoded = id.hex();
287 let target = closure_root
288 .join("objects")
289 .join("sha256")
290 .join(&encoded[..2])
291 .join(encoded);
292 let parent = target.parent().expect("closure object parent");
293 std::fs::create_dir_all(parent)?;
294 if target.exists() {
295 let file = File::open(&target)?;
298 if ObjectStamp::read(&file)? != object.stamp {
299 self.verify_and_sync_target(id, &target)?;
300 }
301 } else {
302 match std::fs::hard_link(&object.path, &target) {
303 Ok(()) => {
304 if ObjectStamp::read(&File::open(&target)?)? != object.stamp {
305 let _ = std::fs::remove_file(&target);
306 return Err(
307 std::io::Error::other("admitted object path was replaced").into()
308 );
309 }
310 }
311 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
312 self.verify_and_sync_target(id, &target)?;
313 }
314 Err(_) => {
315 copy_admitted_object(&object, &pinned, &target)?;
318 self.copied_bytes
319 .fetch_add(object.stamp.length, Ordering::Relaxed);
320 }
321 }
322 object.validate_pin(&pinned)?;
323 self.linked_bytes
324 .fetch_add(object.stamp.length, Ordering::Relaxed);
325 }
326 self.record_directories(parent, closure_root);
327 Ok(target)
328 }
329
330 fn verify_and_sync_target(&self, id: &ObjectId, path: &Path) -> ImageResult<()> {
331 #[cfg(unix)]
335 let file = File::open(path)?;
336 #[cfg(windows)]
337 let file = OpenOptions::new().read(true).write(true).open(path)?;
338 let stamp = ObjectStamp::read(&file)?;
339 let mut hasher = Sha256::new();
340 let mut buffer = vec![0; 1024 * 1024];
341 let mut offset = 0;
342 loop {
343 let count = read_object_at(&file, &mut buffer, offset)?;
344 if count == 0 {
345 break;
346 }
347 hasher.update(&buffer[..count]);
348 offset += count as u64;
349 }
350 self.hashed_bytes.fetch_add(offset, Ordering::Relaxed);
351 let actual = format!("sha256:{}", hex::encode(hasher.finalize()));
352 if actual != id.as_str() {
353 return Err(ImageError::DigestMismatch {
354 digest: id.to_string(),
355 expected: id.to_string(),
356 actual,
357 });
358 }
359 file.sync_all()?;
360 if ObjectStamp::read(&file)? != stamp || ObjectStamp::read(&File::open(path)?)? != stamp {
361 return Err(std::io::Error::other("closure object changed during verification").into());
362 }
363 Ok(())
364 }
365
366 pub fn finish(&self) -> ImageResult<CaptureObjectBatchStats> {
369 let mut directories = self.directories.lock().unwrap();
370 let mut ordered = directories.iter().collect::<Vec<_>>();
371 ordered.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
372 for path in ordered {
373 #[cfg(unix)]
374 {
375 File::open(path)?.sync_all()?;
376 self.directory_syncs.fetch_add(1, Ordering::Relaxed);
377 }
378 #[cfg(not(unix))]
379 let _ = path;
380 }
381 directories.clear();
382 Ok(self.stats())
383 }
384
385 pub fn retained_objects(&self, ids: &[ObjectId]) -> ImageResult<Vec<AdmittedObject>> {
387 ids.iter().map(|id| self.admit(id)).collect()
388 }
389
390 pub fn stats(&self) -> CaptureObjectBatchStats {
392 CaptureObjectBatchStats {
393 hashed_bytes: self.hashed_bytes.load(Ordering::Relaxed),
394 linked_bytes: self.linked_bytes.load(Ordering::Relaxed),
395 copied_bytes: self.copied_bytes.load(Ordering::Relaxed),
396 directory_syncs: self.directory_syncs.load(Ordering::Relaxed),
397 }
398 }
399
400 fn admit(&self, id: &ObjectId) -> ImageResult<AdmittedObject> {
401 if let Some(object) = self.admitted.lock().unwrap().get(id).cloned() {
402 object.validate()?;
403 return Ok(object);
404 }
405 let object = self.open_admitted(id.clone(), self.store.object_path(id))?;
406 let pinned = object.pin()?;
407 let mut hasher = Sha256::new();
408 let mut buffer = vec![0; 1024 * 1024];
409 let mut offset = 0;
410 loop {
411 let count = read_object_at(&pinned, &mut buffer, offset)?;
412 if count == 0 {
413 break;
414 }
415 hasher.update(&buffer[..count]);
416 offset += count as u64;
417 }
418 self.hashed_bytes.fetch_add(offset, Ordering::Relaxed);
419 let actual = format!("sha256:{}", hex::encode(hasher.finalize()));
420 if actual != id.as_str() {
421 return Err(ImageError::DigestMismatch {
422 digest: id.to_string(),
423 expected: id.to_string(),
424 actual,
425 });
426 }
427 object.validate_pin(&pinned)?;
428 self.admitted
429 .lock()
430 .unwrap()
431 .insert(id.clone(), object.clone());
432 Ok(object)
433 }
434
435 fn open_admitted(&self, id: ObjectId, path: PathBuf) -> ImageResult<AdmittedObject> {
436 let file = File::open(&path)?;
437 let stamp = ObjectStamp::read(&file)?;
438 Ok(AdmittedObject {
439 id,
440 path,
441 stamp,
442 ownership: Arc::clone(&self.store.ownership),
443 })
444 }
445
446 fn record_directories(&self, path: &Path, stop: &Path) {
447 let mut directories = self.directories.lock().unwrap();
448 for directory in path.ancestors() {
449 directories.insert(directory.to_path_buf());
450 if directory == stop {
451 break;
452 }
453 }
454 }
455}
456
457impl AdmittedObject {
458 fn validate(&self) -> ImageResult<()> {
459 self.pin().map(|_| ())
460 }
461
462 fn pin(&self) -> ImageResult<File> {
463 let file = File::open(&self.path)?;
464 self.validate_pin(&file)?;
465 Ok(file)
466 }
467
468 fn validate_pin(&self, file: &File) -> ImageResult<()> {
469 if ObjectStamp::read(file)? != self.stamp {
470 return Err(std::io::Error::other("admitted immutable object was modified").into());
471 }
472 Ok(())
473 }
474}
475
476impl ObjectStamp {
477 fn read(file: &File) -> std::io::Result<Self> {
478 let metadata = file.metadata()?;
479 if !metadata.is_file() {
480 return Err(std::io::Error::other(
481 "immutable object is not a regular file",
482 ));
483 }
484 #[cfg(unix)]
485 let identity = {
486 use std::os::unix::fs::MetadataExt;
487 (metadata.dev(), metadata.ino())
488 };
489 #[cfg(windows)]
490 let identity = {
491 use std::os::windows::io::AsRawHandle;
492 use windows_sys::Win32::Storage::FileSystem::{
493 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
494 };
495 let mut info = std::mem::MaybeUninit::<BY_HANDLE_FILE_INFORMATION>::uninit();
496 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), info.as_mut_ptr()) } == 0 {
498 return Err(std::io::Error::last_os_error());
499 }
500 let info = unsafe { info.assume_init() };
501 (
502 u64::from(info.dwVolumeSerialNumber),
503 (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
504 )
505 };
506 Ok(Self {
507 identity,
508 length: metadata.len(),
509 modified: metadata.modified()?,
510 })
511 }
512}
513
514impl MerkleAccumulator {
515 fn new(height: u32) -> Self {
516 Self {
517 levels: vec![None; height as usize + 1],
518 }
519 }
520
521 fn push_subtree(&mut self, mut height: u32, mut hash: [u8; 32]) {
522 loop {
523 let slot = &mut self.levels[height as usize];
524 match slot.take() {
525 Some(left) => {
526 hash = hash_parent(&left, &hash);
527 height += 1;
528 }
529 None => {
530 *slot = Some(hash);
531 return;
532 }
533 }
534 }
535 }
536
537 fn finish(mut self, height: u32) -> [u8; 32] {
538 self.levels[height as usize]
539 .take()
540 .expect("complete Merkle tree has one root")
541 }
542}
543
544fn publish_object_file(
553 temporary: &Path,
554 path: &Path,
555 ownership: &StoreOwnership,
556) -> std::io::Result<bool> {
557 match std::fs::hard_link(temporary, path) {
559 Ok(()) => Ok(true),
560 Err(_) if path.exists() => Ok(false),
561 Err(_) => {
562 let _publication = ownership.publication.lock().unwrap();
565 if path.exists() {
566 return Ok(false);
567 }
568 std::fs::rename(temporary, path)?;
569 Ok(true)
570 }
571 }
572}
573
574fn read_object_at(file: &File, bytes: &mut [u8], offset: u64) -> std::io::Result<usize> {
575 #[cfg(unix)]
576 {
577 use std::os::unix::fs::FileExt;
578 file.read_at(bytes, offset)
579 }
580 #[cfg(windows)]
581 {
582 use std::os::windows::fs::FileExt;
583 file.seek_read(bytes, offset)
584 }
585}
586
587fn copy_admitted_object(object: &AdmittedObject, source: &File, target: &Path) -> ImageResult<()> {
588 let mut destination = OpenOptions::new()
589 .write(true)
590 .create_new(true)
591 .open(target)?;
592 let result = (|| -> ImageResult<()> {
593 let mut buffer = vec![0; 1024 * 1024];
594 let mut offset = 0;
595 while offset < object.stamp.length {
596 let length = buffer.len().min((object.stamp.length - offset) as usize);
597 let count = read_object_at(source, &mut buffer[..length], offset)?;
598 if count == 0 {
599 return Err(std::io::Error::new(
600 std::io::ErrorKind::UnexpectedEof,
601 "immutable object was truncated",
602 )
603 .into());
604 }
605 destination.write_all(&buffer[..count])?;
606 offset += count as u64;
607 }
608 object.validate_pin(source)?;
609 destination.sync_all()?;
610 Ok(())
611 })();
612 drop(destination);
613 if result.is_err() {
614 let _ = std::fs::remove_file(target);
615 }
616 result
617}
618
619fn sync_directories_through(path: &Path, stop: &Path) -> ImageResult<()> {
620 #[cfg(unix)]
621 {
622 let mut current = Some(path);
623 while let Some(directory) = current {
624 File::open(directory)?.sync_all()?;
625 if directory == stop {
626 break;
627 }
628 current = directory.parent();
629 }
630 }
631 #[cfg(not(unix))]
632 let _ = (path, stop);
633 Ok(())
634}
635
636pub fn sparse_file_integrity(path: &Path) -> ImageResult<SparseFileIntegrity> {
638 let started = std::time::Instant::now();
639 let mut file = File::open(path)?;
640 let logical_size = file.metadata()?.len();
641 let logical_leaves = logical_size.div_ceil(FILE_MERKLE_LEAF_SIZE as u64).max(1);
642 let tree_leaves = logical_leaves.next_power_of_two();
643 let tree_height = tree_leaves.trailing_zeros();
644 let zero_roots = zero_subtree_roots(tree_height);
645 let allocation_map = microsandbox_utils::extent::ExtentMap::scan_file(&file)?;
646 let ranges = allocated_leaf_ranges(allocation_map.as_ref(), logical_size, logical_leaves);
647 let mut accumulator = MerkleAccumulator::new(tree_height);
648 let mut cursor = 0u64;
649 let mut buffer = vec![0u8; FILE_MERKLE_LEAF_SIZE];
650 let mut read_bytes = 0u64;
651 let mut read_leaves = 0u64;
652
653 for (start, end) in ranges {
654 push_zero_range(&mut accumulator, &zero_roots, cursor, start);
655 for leaf in start..end {
656 let offset = leaf * FILE_MERKLE_LEAF_SIZE as u64;
657 let readable = logical_size
658 .saturating_sub(offset)
659 .min(FILE_MERKLE_LEAF_SIZE as u64) as usize;
660 buffer.fill(0);
661 file.seek(SeekFrom::Start(offset))?;
662 file.read_exact(&mut buffer[..readable])?;
663 read_bytes += readable as u64;
664 read_leaves += 1;
665 accumulator.push_subtree(0, hash_leaf(&buffer));
666 }
667 cursor = end;
668 }
669 push_zero_range(&mut accumulator, &zero_roots, cursor, tree_leaves);
670
671 let mut root = blake3::Hasher::new();
672 root.update(MERKLE_ROOT_DOMAIN);
673 root.update(&logical_size.to_le_bytes());
674 root.update(&(FILE_MERKLE_LEAF_SIZE as u32).to_le_bytes());
675 root.update(&tree_height.to_le_bytes());
676 root.update(&accumulator.finish(tree_height));
677 tracing::info!(target: "microsandbox_checkpoint_timing", operation = "disk_hash", logical_bytes = logical_size, read_bytes, read_leaves, hash_us = started.elapsed().as_micros(), "sealed disk integrity timing");
678 Ok(SparseFileIntegrity {
679 root: format!("blake3:{}", root.finalize().to_hex()),
680 logical_size,
681 })
682}
683
684fn allocated_leaf_ranges(
685 map: Option<µsandbox_utils::extent::ExtentMap>,
686 logical_size: u64,
687 logical_leaves: u64,
688) -> Vec<(u64, u64)> {
689 if logical_size == 0 {
690 return Vec::new();
691 }
692 let Some(map) = map else {
693 return vec![(0, logical_leaves)];
694 };
695 let mut ranges: Vec<(u64, u64)> = Vec::new();
696 for (offset, length) in &map.extents {
697 let start = offset / FILE_MERKLE_LEAF_SIZE as u64;
698 let end = offset
699 .saturating_add(*length)
700 .div_ceil(FILE_MERKLE_LEAF_SIZE as u64)
701 .min(logical_leaves);
702 if end <= start {
703 continue;
704 }
705 match ranges.last_mut() {
706 Some((_, previous_end)) if start <= *previous_end => {
707 *previous_end = (*previous_end).max(end);
708 }
709 _ => ranges.push((start, end)),
710 }
711 }
712 ranges
713}
714
715fn zero_subtree_roots(height: u32) -> Vec<[u8; 32]> {
716 let mut roots = vec![hash_leaf(&vec![0u8; FILE_MERKLE_LEAF_SIZE])];
717 for level in 1..=height {
718 let child = roots[level as usize - 1];
719 roots.push(hash_parent(&child, &child));
720 }
721 roots
722}
723
724fn push_zero_range(
725 accumulator: &mut MerkleAccumulator,
726 zero_roots: &[[u8; 32]],
727 mut start: u64,
728 end: u64,
729) {
730 while start < end {
731 let remaining_height = 63 - (end - start).leading_zeros();
732 let alignment_height = if start == 0 {
733 remaining_height
734 } else {
735 start.trailing_zeros().min(remaining_height)
736 };
737 accumulator.push_subtree(alignment_height, zero_roots[alignment_height as usize]);
738 start += 1u64 << alignment_height;
739 }
740}
741
742fn hash_leaf(bytes: &[u8]) -> [u8; 32] {
743 let mut hasher = blake3::Hasher::new();
744 hasher.update(MERKLE_LEAF_DOMAIN);
745 hasher.update(bytes);
746 *hasher.finalize().as_bytes()
747}
748
749fn hash_parent(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
750 let mut hasher = blake3::Hasher::new();
751 hasher.update(MERKLE_PARENT_DOMAIN);
752 hasher.update(left);
753 hasher.update(right);
754 *hasher.finalize().as_bytes()
755}
756
757#[cfg(test)]
762mod tests {
763 use super::*;
764
765 #[test]
766 fn capture_batch_reuses_owned_objects_and_syncs_each_directory_once() {
767 let directory = tempfile::tempdir().unwrap();
768 let store = LocalObjectStore::open(directory.path().join("store")).unwrap();
769 let first = CaptureObjectBatch::new(store.clone(), &[]);
770 let id = first.put_bytes(b"captured immutable RAM").unwrap();
771 first
772 .link_into(&id, &directory.path().join("first"))
773 .unwrap();
774 let stats = first.finish().unwrap();
775 assert_eq!(
776 stats.hashed_bytes, 22,
777 "new objects must not be rehashed when linked"
778 );
779 let retained = first.retained_objects(std::slice::from_ref(&id)).unwrap();
780 let second = CaptureObjectBatch::new(store, &retained);
781 second
782 .link_into(&id, &directory.path().join("second"))
783 .unwrap();
784 second
785 .link_into(&id, &directory.path().join("second"))
786 .unwrap();
787 let stats = second.finish().unwrap();
788 assert_eq!(stats.hashed_bytes, 0);
789 assert_eq!(stats.linked_bytes, 22);
790 #[cfg(unix)]
791 assert_eq!(
792 stats.directory_syncs, 4,
793 "prefix, algorithm, objects and closure directories"
794 );
795 assert_eq!(
796 second.finish().unwrap().directory_syncs,
797 stats.directory_syncs
798 );
799 }
800
801 #[test]
802 fn capture_batch_checks_unadmitted_data_and_pinned_copy_keeps_exact_inode() {
803 let directory = tempfile::tempdir().unwrap();
804 let store = LocalObjectStore::open(directory.path().join("store")).unwrap();
805 let id = store.put_bytes(b"original").unwrap();
806 let batch = CaptureObjectBatch::new(store.clone(), &[]);
807 batch
808 .link_into(&id, &directory.path().join("first"))
809 .unwrap();
810 assert_eq!(batch.stats().hashed_bytes, 8);
811 let admitted = batch
812 .retained_objects(std::slice::from_ref(&id))
813 .unwrap()
814 .remove(0);
815 let pinned = admitted.pin().unwrap();
816 std::fs::remove_file(store.object_path(&id)).unwrap();
819 let target = directory.path().join("pinned-copy");
820 copy_admitted_object(&admitted, &pinned, &target).unwrap();
821 assert_eq!(std::fs::read(target).unwrap(), b"original");
822 assert!(
823 batch
824 .link_into(&id, &directory.path().join("second"))
825 .is_err()
826 );
827
828 let corrupt_id = store.put_bytes(b"must be checked").unwrap();
829 std::fs::write(store.object_path(&corrupt_id), b"corrupted").unwrap();
830 let fresh = CaptureObjectBatch::new(store, &[]);
831 assert!(
832 fresh
833 .link_into(&corrupt_id, &directory.path().join("bad"))
834 .is_err()
835 );
836 }
837
838 #[test]
839 fn capture_batch_rejects_replaced_or_modified_admitted_inodes() {
840 let directory = tempfile::tempdir().unwrap();
841 let store = LocalObjectStore::open(directory.path().join("store")).unwrap();
842 let batch = CaptureObjectBatch::new(store.clone(), &[]);
843 let id = batch.put_bytes(b"original").unwrap();
844 let path = store.object_path(&id);
845 std::fs::remove_file(&path).unwrap();
846 std::fs::write(&path, b"replaced").unwrap();
847 assert!(
848 batch
849 .link_into(&id, &directory.path().join("replaced"))
850 .is_err()
851 );
852
853 let other = batch.put_bytes(b"another object").unwrap();
854 std::fs::write(store.object_path(&other), b"short").unwrap();
856 assert!(
857 batch
858 .link_into(&other, &directory.path().join("mutated"))
859 .is_err()
860 );
861 assert!(batch.put_bytes(b"another object").is_err());
862 }
863
864 #[test]
865 fn capture_batch_directory_failure_does_not_report_durable_completion() {
866 let directory = tempfile::tempdir().unwrap();
867 let store = LocalObjectStore::open(directory.path().join("store")).unwrap();
868 let batch = CaptureObjectBatch::new(store.clone(), &[]);
869 let id = batch
870 .put_bytes(b"durable data pending publication")
871 .unwrap();
872 let closure = directory.path().join("closure");
873 batch.link_into(&id, &closure).unwrap();
874 #[cfg(unix)]
875 {
876 std::fs::remove_dir_all(&closure).unwrap();
877 assert!(batch.finish().is_err());
878 }
879 assert!(!closure.join("checkpoint.json").exists());
880 assert!(store.object_path(&id).is_file());
881 }
882
883 #[test]
884 fn existing_independent_closure_copies_are_verified_before_reuse() {
885 let directory = tempfile::tempdir().unwrap();
886 let store = LocalObjectStore::open(directory.path().join("store")).unwrap();
887 let batch = CaptureObjectBatch::new(store.clone(), &[]);
888 let id = batch.put_bytes(b"original").unwrap();
889 let closure = directory.path().join("closure");
890 let target = LocalObjectStore::open(&closure).unwrap().object_path(&id);
891 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
892 std::fs::write(&target, b"original").unwrap();
894 batch.link_into(&id, &closure).unwrap();
895 assert_eq!(batch.stats().hashed_bytes, 16);
896 batch.finish().unwrap();
897 std::fs::write(&target, b"modified").unwrap();
898 assert!(batch.link_into(&id, &closure).is_err());
899 assert_eq!(
900 std::fs::read(&target).unwrap(),
901 b"modified",
902 "never delete a pre-existing target on verification failure"
903 );
904 assert_eq!(std::fs::read(store.object_path(&id)).unwrap(), b"original");
905 }
906
907 #[test]
908 fn concurrent_checked_and_batched_writers_keep_the_winning_inode() {
909 let directory = tempfile::tempdir().unwrap();
910 let store = LocalObjectStore::open(directory.path().join("store")).unwrap();
911 let batch = Arc::new(CaptureObjectBatch::new(store.clone(), &[]));
912 let gate = std::sync::Barrier::new(8);
913 let payload = vec![7; 65536];
914 std::thread::scope(|scope| {
915 let handles = (0..8)
916 .map(|index| {
917 let gate = &gate;
918 let payload = &payload;
919 let batch = &batch;
920 let store = &store;
921 scope.spawn(move || {
922 gate.wait();
923 if index % 2 == 0 {
924 store.put_bytes(payload)
925 } else {
926 batch.put_bytes(payload)
927 }
928 .unwrap()
929 })
930 })
931 .collect::<Vec<_>>();
932 let expected = ObjectId::from_bytes(&payload).unwrap();
933 for handle in handles {
934 assert_eq!(handle.join().unwrap(), expected);
935 }
936 });
937 let id = ObjectId::from_bytes(&payload).unwrap();
938 batch
939 .link_into(&id, &directory.path().join("closure"))
940 .unwrap();
941 batch.finish().unwrap();
942 let stamp = ObjectStamp::read(&File::open(store.object_path(&id)).unwrap()).unwrap();
943 assert_eq!(batch.retained_objects(&[id]).unwrap()[0].stamp, stamp);
944 }
945
946 #[test]
947 #[ignore = "opt-in old/new object-store experiment; prints measured times, not a CI latency threshold"]
948 fn capture_store_full_incremental_experiment() {
949 const COUNT: usize = 16;
950 const SIZE: usize = 1024 * 1024;
951 const DELTA: usize = 4096;
952 let directory = tempfile::tempdir().unwrap();
953 let payloads = (0..COUNT)
954 .map(|index| vec![index as u8 + 1; SIZE])
955 .collect::<Vec<_>>();
956 let changed = vec![255; DELTA];
957 for round in 0..3 {
958 let old =
959 LocalObjectStore::open(directory.path().join(format!("old-{round}"))).unwrap();
960 let started = std::time::Instant::now();
961 let mut old_ids = Vec::new();
962 for bytes in &payloads {
963 let id = old.put_bytes(bytes).unwrap();
964 old.link_into(&id, &directory.path().join(format!("old-full-{round}")))
965 .unwrap();
966 old_ids.push(id);
967 }
968 let old_full_us = started.elapsed().as_micros();
969 let started = std::time::Instant::now();
970 old_ids.push(old.put_bytes(&changed).unwrap());
971 for id in &old_ids {
972 old.link_into(id, &directory.path().join(format!("old-delta-{round}")))
973 .unwrap();
974 }
975 let old_delta_us = started.elapsed().as_micros();
976
977 let new =
978 LocalObjectStore::open(directory.path().join(format!("new-{round}"))).unwrap();
979 let full = CaptureObjectBatch::new(new.clone(), &[]);
980 let started = std::time::Instant::now();
981 let mut new_ids = Vec::new();
982 for bytes in &payloads {
983 let id = full.put_bytes(bytes).unwrap();
984 full.link_into(&id, &directory.path().join(format!("new-full-{round}")))
985 .unwrap();
986 new_ids.push(id);
987 }
988 let full_stats = full.finish().unwrap();
989 let receipts = full.retained_objects(&new_ids).unwrap();
990 let new_full_us = started.elapsed().as_micros();
991 let delta = CaptureObjectBatch::new(new, &receipts);
992 let started = std::time::Instant::now();
993 new_ids.push(delta.put_bytes(&changed).unwrap());
994 for id in &new_ids {
995 delta
996 .link_into(id, &directory.path().join(format!("new-delta-{round}")))
997 .unwrap();
998 }
999 let delta_stats = delta.finish().unwrap();
1000 let new_delta_us = started.elapsed().as_micros();
1001 assert_eq!(
1002 old_ids, new_ids,
1003 "the optimized publication preserves content identities"
1004 );
1005 assert_eq!(full_stats.hashed_bytes, (COUNT * SIZE) as u64);
1006 assert_eq!(
1007 delta_stats.hashed_bytes, DELTA as u64,
1008 "inherited payload must not be read again"
1009 );
1010 #[cfg(unix)]
1011 {
1012 assert!(full_stats.directory_syncs <= (2 * (COUNT + 3)) as u64);
1013 assert!(delta_stats.directory_syncs <= (COUNT + 8) as u64);
1014 }
1015 println!(
1018 "{}",
1019 serde_json::json!({
1020 "experiment": "capture_object_store", "round": round,
1021 "baseline_bytes": COUNT * SIZE, "changed_bytes": DELTA,
1022 "old_full_us": old_full_us, "new_full_us": new_full_us,
1023 "old_incremental_us": old_delta_us, "new_incremental_us": new_delta_us,
1024 "old_full_expected_hashed_bytes": 2 * COUNT * SIZE,
1025 "old_incremental_expected_hashed_bytes": COUNT * SIZE + 2 * DELTA,
1026 "new_full_hashed_bytes": full_stats.hashed_bytes,
1027 "new_incremental_hashed_bytes": delta_stats.hashed_bytes,
1028 "new_full_directory_syncs": full_stats.directory_syncs,
1029 "new_incremental_directory_syncs": delta_stats.directory_syncs
1030 })
1031 );
1032 }
1033 }
1034
1035 #[test]
1036 fn admission_receipts_do_not_escape_their_store_lifetime() {
1037 let directory = tempfile::tempdir().unwrap();
1038 let store = LocalObjectStore::open(directory.path()).unwrap();
1039 let first = CaptureObjectBatch::new(store.clone(), &[]);
1040 let id = first.put_bytes(b"payload").unwrap();
1041 let receipts = first.retained_objects(std::slice::from_ref(&id)).unwrap();
1042 first.finish().unwrap();
1043 let reopened =
1045 CaptureObjectBatch::new(LocalObjectStore::open(directory.path()).unwrap(), &receipts);
1046 reopened
1047 .retained_objects(std::slice::from_ref(&id))
1048 .unwrap();
1049 assert_eq!(reopened.stats().hashed_bytes, 7);
1050 }
1051
1052 #[cfg(unix)]
1053 #[test]
1054 fn retained_receipts_fit_low_fd_budget() {
1055 const CHILD: &str = "MSB_STORE_LOW_FD_TEST_CHILD";
1056 if std::env::var_os(CHILD).is_none() {
1057 let result = std::process::Command::new(std::env::current_exe().unwrap())
1058 .args([
1059 "--exact",
1060 "checkpoint::store::tests::retained_receipts_fit_low_fd_budget",
1061 "--nocapture",
1062 ])
1063 .env(CHILD, "1")
1064 .status()
1065 .unwrap();
1066 assert!(result.success());
1067 return;
1068 }
1069 let mut limit = std::mem::MaybeUninit::<libc::rlimit>::uninit();
1071 assert_eq!(
1072 unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, limit.as_mut_ptr()) },
1073 0
1074 );
1075 let mut limit = unsafe { limit.assume_init() };
1076 limit.rlim_cur = limit.rlim_cur.min(64);
1077 assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &limit) }, 0);
1078 let directory = tempfile::tempdir().unwrap();
1079 let store = LocalObjectStore::open(directory.path().join("store")).unwrap();
1080 let first = CaptureObjectBatch::new(store.clone(), &[]);
1081 let ids = (0_u32..512)
1082 .map(|index| first.put_bytes(&index.to_le_bytes()).unwrap())
1083 .collect::<Vec<_>>();
1084 first.finish().unwrap();
1085 let receipts = first.retained_objects(&ids).unwrap();
1086 drop(first);
1087 let second = CaptureObjectBatch::new(store, &receipts);
1088 for id in &ids {
1089 second
1090 .link_into(id, &directory.path().join("closure"))
1091 .unwrap();
1092 }
1093 assert_eq!(second.finish().unwrap().hashed_bytes, 0);
1094 }
1095
1096 #[test]
1097 fn identical_objects_are_reused_and_linked_into_a_closure() {
1098 let directory = tempfile::tempdir().unwrap();
1099 let store = LocalObjectStore::open(directory.path().join("store")).unwrap();
1100 let first = store.put_bytes(b"same bytes").unwrap();
1101 let second = store.put_bytes(b"same bytes").unwrap();
1102 assert_eq!(first, second);
1103
1104 let linked = store
1105 .link_into(&first, &directory.path().join("checkpoint"))
1106 .unwrap();
1107 assert_eq!(std::fs::read(linked).unwrap(), b"same bytes");
1108 }
1109
1110 #[test]
1111 fn sparse_integrity_does_not_depend_on_hole_allocation() {
1112 let directory = tempfile::tempdir().unwrap();
1113 let sparse = directory.path().join("sparse.raw");
1114 let dense = directory.path().join("dense.raw");
1115 let sparse_file = OpenOptions::new()
1116 .write(true)
1117 .create_new(true)
1118 .open(&sparse)
1119 .unwrap();
1120 microsandbox_utils::extent::mark_sparse(&sparse_file).unwrap();
1121 sparse_file.set_len(8 * 1024 * 1024).unwrap();
1122 sparse_file.sync_all().unwrap();
1123 std::fs::write(&dense, vec![0u8; 8 * 1024 * 1024]).unwrap();
1124
1125 assert_eq!(
1126 sparse_file_integrity(&sparse).unwrap(),
1127 sparse_file_integrity(&dense).unwrap()
1128 );
1129 }
1130}