Skip to main content

microsandbox_image/checkpoint/
store.rs

1//! Crash-safe local immutable-object storage.
2
3use 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
15//--------------------------------------------------------------------------------------------------
16// Constants
17//--------------------------------------------------------------------------------------------------
18
19const 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
24//--------------------------------------------------------------------------------------------------
25// Types
26//--------------------------------------------------------------------------------------------------
27
28pub use microsandbox_types::snapshot::disk::ObjectId;
29
30/// Filesystem-backed content-addressed object store.
31#[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/// A verified immutable inode identity in a live runtime's owned object store.
43///
44/// This receipt is neither serializable nor constructible from a path, and is scoped to its store
45/// instance. The owning runtime must retain its published object names and never mutate their bytes.
46/// Reuse opens and pins the exact inode only for the active operation, checking identity and stamp;
47/// missing, replaced or modified members fail closed. Retaining generations therefore costs no FD
48/// per object. Unadmitted stores/imports still verify payloads instead of trusting these receipts.
49#[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
64/// Capture-local object publication with deferred directory durability, but durable file data.
65///
66/// Finish this batch before publishing any manifest root. Existing `LocalObjectStore::put_bytes`
67/// keeps its immediate durability contract; only this explicitly scoped API batches directory sync.
68pub 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/// Actual work performed by a capture object batch, independent of its logical RAM size.
79#[derive(Clone, Copy, Debug, Default)]
80pub struct CaptureObjectBatchStats {
81    /// Bytes hashed to create or admit immutable objects.
82    pub hashed_bytes: u64,
83    /// Bytes referenced by newly installed closure links, including copy fallbacks.
84    pub linked_bytes: u64,
85    /// Bytes physically copied when hardlinks were unavailable.
86    pub copied_bytes: u64,
87    /// Directory durability barriers issued by this batch.
88    pub directory_syncs: u64,
89}
90
91/// Sparse-aware immutable identity of one physical layer file.
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct SparseFileIntegrity {
94    /// BLAKE3 Merkle root.
95    pub root: String,
96    /// Logical file length bound by the root.
97    pub logical_size: u64,
98}
99
100struct MerkleAccumulator {
101    levels: Vec<Option<[u8; 32]>>,
102}
103
104//--------------------------------------------------------------------------------------------------
105// Methods
106//--------------------------------------------------------------------------------------------------
107
108impl LocalObjectStore {
109    /// Open or create a local object store rooted at `root`.
110    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    /// Store exact bytes durably and return their immutable identity.
120    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    /// Return the confined path of a stored object.
148    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    /// Link one existing object into a self-contained checkpoint closure.
158    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                // `FlushFileBuffers`, used by `sync_all` on Windows, requires a handle opened
178                // with write access even though the immutable bytes are already complete.
179                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    /// Start a new batch, retaining only explicitly supplied previous-generation capabilities.
219    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    /// Hash new bytes once and store durable file data. Directory entries commit at `finish`.
237    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            // Also sync the path of an object left by a previously interrupted batch.
249            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    /// Link admitted bytes without rehashing the generation's complete inherited RAM payload.
277    pub fn link_into(&self, id: &ObjectId, closure_root: &Path) -> ImageResult<PathBuf> {
278        // A receipt itself is not sufficient authority to read bytes. Pin/check it once below;
279        // avoid the redundant open that admitting an already-owned receipt would otherwise do.
280        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            // Existing targets are not automatically part of this batch's ownership. Retain
296            // the checked public behavior, except for the exact already-admitted inode.
297            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 the retained inode, not a potentially replaced path. Positioned
316                    // reads avoid shared cursor races when several closures reuse one object.
317                    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        // A pre-existing independent copy may have been written without a durability barrier.
332        // Verify and sync the very same open file, rather than hashing one path then flushing a
333        // replacement. Windows requires write access for FlushFileBuffers.
334        #[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    /// Make every new directory entry durable before its caller publishes a root descriptor.
367    /// Call only after all batch writers have joined. A failed sync leaves the set available for retry.
368    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    /// Retain exactly the next generation's referenced receipts without keeping object FDs open.
386    pub fn retained_objects(&self, ids: &[ObjectId]) -> ImageResult<Vec<AdmittedObject>> {
387        ids.iter().map(|id| self.admit(id)).collect()
388    }
389
390    /// Read counters without adding timing or content-verification work.
391    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            // SAFETY: the file owns a valid handle and the API initializes the output on success.
497            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
544//--------------------------------------------------------------------------------------------------
545// Trait Implementations
546//--------------------------------------------------------------------------------------------------
547
548//--------------------------------------------------------------------------------------------------
549// Functions: Helpers
550//--------------------------------------------------------------------------------------------------
551
552fn publish_object_file(
553    temporary: &Path,
554    path: &Path,
555    ownership: &StoreOwnership,
556) -> std::io::Result<bool> {
557    // Atomic no-replace publication keeps admitted inode bindings stable for concurrent writers.
558    match std::fs::hard_link(temporary, path) {
559        Ok(()) => Ok(true),
560        Err(_) if path.exists() => Ok(false),
561        Err(_) => {
562            // Some filesystems do not support hardlinks. All writers in this runtime's store
563            // share the fallback namespace lock; it covers only the final check and rename.
564            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
636/// Compute a sparse-aware fixed-leaf Merkle root without reading unallocated holes.
637pub 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<&microsandbox_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//--------------------------------------------------------------------------------------------------
758// Tests
759//--------------------------------------------------------------------------------------------------
760
761#[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        // An active operation's pin is sufficient for a copy even if its original entry is
817        // unlinked. A later operation must refuse: the runtime no longer owns that name.
818        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        // Length change is portable and reliably visible even on coarse timestamp filesystems.
855        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        // A separate inode, initially written without sync_all, must not inherit source admission.
893        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            // Old byte/sync counts follow the unchanged checked public path's exact loop; new
1016            // counts come from runtime counters. Timings are measured; no speed ratio is asserted.
1017            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        // Opening the same path does not confer the old runtime's ownership. Re-admit bytes.
1044        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        // Set a low limit only in this isolated test process, never in the parallel test runner.
1070        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}