Skip to main content

microsandbox_image/checkpoint/
resolver.rs

1//! Strict resolver for one self-contained composite-checkpoint closure.
2
3use std::collections::BTreeSet;
4use std::fs::File;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7use std::time::Instant;
8
9use sha2::{Digest as _, Sha256};
10
11use super::admitted_disk::AdmittedDiskLayers;
12use super::{
13    CheckpointManifest, DiskGenerationManifest, DiskLayerRef, MemoryExtentContent, MemoryManifest,
14    ObjectId,
15};
16use crate::error::{ImageError, ImageResult};
17
18//--------------------------------------------------------------------------------------------------
19// Constants
20//--------------------------------------------------------------------------------------------------
21
22const CHECKPOINT_ROOT_FILE: &str = "checkpoint.json";
23const MAX_MANIFEST_BYTES: u64 = 8 * 1024 * 1024;
24const MAX_EXECUTION_STATE_BYTES: u64 = 512 * 1024 * 1024;
25const MAX_DEVICE_STATE_BYTES: u64 = 1024 * 1024;
26
27//--------------------------------------------------------------------------------------------------
28// Types
29//--------------------------------------------------------------------------------------------------
30
31/// A validated, self-contained checkpoint closure rooted at a published directory.
32///
33/// Opening verifies the canonical root and every transitively referenced manifest, object slice,
34/// and disk layer. Later restore reads still verify object identities so replacing bytes between
35/// admission and consumption fails closed.
36#[derive(Clone, Debug)]
37pub struct CheckpointClosure {
38    root: PathBuf,
39    root_id: ObjectId,
40    checkpoint: CheckpointManifest,
41    memory: MemoryManifest,
42    disks: Vec<DiskGenerationManifest>,
43    admitted_disks: AdmittedDiskLayers,
44}
45
46/// Separate wall times for loading and verifying one checkpoint object.
47#[derive(Clone, Copy, Debug, Default)]
48pub struct CheckpointObjectReadTiming {
49    /// File open, allocation or buffer growth, and read time in microseconds.
50    pub read_us: u128,
51    /// Identity verification time in microseconds.
52    pub hash_us: u128,
53}
54
55//--------------------------------------------------------------------------------------------------
56// Methods
57//--------------------------------------------------------------------------------------------------
58
59impl CheckpointClosure {
60    /// Inspect the bounded, identity-verified root for construction planning, not payload admission.
61    /// The child closure must still be fully opened before its contents are consumed.
62    pub fn inspect_manifest(
63        root: &Path,
64        expected_root: Option<&ObjectId>,
65    ) -> ImageResult<CheckpointManifest> {
66        read_checkpoint_root(root, expected_root).map(|(_, manifest)| manifest)
67    }
68
69    /// Open and validate a checkpoint closure for restore on this host architecture.
70    pub fn open(root: impl Into<PathBuf>, expected_root: Option<&ObjectId>) -> ImageResult<Self> {
71        Self::open_inner(root.into(), expected_root, true)
72    }
73
74    /// Open and validate a checkpoint closure without requiring host architecture compatibility.
75    ///
76    /// Inspection, verification, and archive transport use this path. Construction must use
77    /// [`open`](Self::open) so an incompatible checkpoint cannot reach restore.
78    pub fn open_portable(
79        root: impl Into<PathBuf>,
80        expected_root: Option<&ObjectId>,
81    ) -> ImageResult<Self> {
82        Self::open_inner(root.into(), expected_root, false)
83    }
84
85    fn open_inner(
86        root: PathBuf,
87        expected_root: Option<&ObjectId>,
88        require_host_architecture: bool,
89    ) -> ImageResult<Self> {
90        let (root_id, checkpoint) = read_checkpoint_root(&root, expected_root)?;
91        if require_host_architecture && checkpoint.architecture != std::env::consts::ARCH {
92            return checkpoint_error(format!(
93                "checkpoint architecture {} cannot restore on {}",
94                checkpoint.architecture,
95                std::env::consts::ARCH
96            ));
97        }
98
99        let memory_bytes = read_object_verified(&root, &checkpoint.memory, MAX_MANIFEST_BYTES)?;
100        crate::snapshot::verify_owned_directory_payloads(&root, &checkpoint.owned_volumes)?;
101        let memory = MemoryManifest::from_bytes(&memory_bytes)?;
102        if memory.architecture != checkpoint.architecture
103            || memory.pause_generation != checkpoint.pause_generation
104        {
105            return checkpoint_error("memory state does not belong to the checkpoint epoch");
106        }
107        validate_memory_objects(&root, &memory)?;
108
109        // Execution/device codecs perform their own bounded semantic decoding in the runtime. At
110        // this layer we still prove that every named immutable object exists and matches its id.
111        read_object_verified(
112            &root,
113            &checkpoint.execution_state,
114            MAX_EXECUTION_STATE_BYTES,
115        )?;
116        for device in &checkpoint.devices {
117            read_object_verified(&root, &device.state, MAX_DEVICE_STATE_BYTES)?;
118        }
119
120        let mut disks = Vec::with_capacity(checkpoint.disks.len());
121        let mut admitted_disks = AdmittedDiskLayers::default();
122        let mut volumes = BTreeSet::new();
123        for disk_id in &checkpoint.disks {
124            let bytes = read_object_verified(&root, disk_id, MAX_MANIFEST_BYTES)?;
125            let disk = DiskGenerationManifest::from_bytes(&bytes)?;
126            if disk.pause_generation != checkpoint.pause_generation {
127                return checkpoint_error("disk generation does not belong to the checkpoint epoch");
128            }
129            if !volumes.insert(disk.volume_id.clone()) {
130                return checkpoint_error("checkpoint repeats a logical disk volume");
131            }
132            for layer in &disk.layers {
133                let path = disk_layer_path(&root, layer);
134                if open_regular(&path)?.metadata()?.len() != layer.file_size {
135                    return checkpoint_error("disk layer length differs from captured file size");
136                }
137                if let Some(expected) = &layer.integrity_root {
138                    admitted_disks.admit(&path, expected)?;
139                }
140            }
141            disks.push(disk);
142        }
143        for volume in &checkpoint.owned_volumes {
144            if let crate::snapshot::OwnedVolumeData::Disk { generation } = &volume.data
145                && !disks.contains(generation)
146            {
147                return checkpoint_error("owned disk is absent from the checkpoint closure");
148            }
149        }
150
151        Ok(Self {
152            root,
153            root_id,
154            checkpoint,
155            memory,
156            disks,
157            admitted_disks,
158        })
159    }
160
161    /// Return the immutable root identity computed from canonical `checkpoint.json` bytes.
162    pub fn root_id(&self) -> &ObjectId {
163        &self.root_id
164    }
165
166    /// Root of this verified immutable closure.
167    pub fn root(&self) -> &Path {
168        &self.root
169    }
170
171    /// Return the validated composite manifest.
172    pub fn checkpoint(&self) -> &CheckpointManifest {
173        &self.checkpoint
174    }
175
176    /// Return the complete logical memory generation.
177    pub fn memory(&self) -> &MemoryManifest {
178        &self.memory
179    }
180
181    /// Return the validated disk generations.
182    pub fn disks(&self) -> &[DiskGenerationManifest] {
183        &self.disks
184    }
185
186    /// Read and reverify one immutable object, bounded by `max_len`.
187    pub fn read_object(&self, id: &ObjectId, max_len: u64) -> ImageResult<Vec<u8>> {
188        read_object_verified(&self.root, id, max_len)
189    }
190
191    /// Load and verify one object into reusable storage, without changing its identity contract.
192    /// The buffer must not be consumed when this method fails.
193    pub fn read_object_into(
194        &self,
195        id: &ObjectId,
196        max_len: u64,
197        bytes: &mut Vec<u8>,
198    ) -> ImageResult<CheckpointObjectReadTiming> {
199        read_object_verified_into(&self.root, id, max_len, bytes)
200    }
201
202    /// Return the confined path of a validated disk layer.
203    pub fn disk_layer_path(&self, layer: &DiskLayerRef) -> PathBuf {
204        disk_layer_path(&self.root, layer)
205    }
206
207    /// Reuse a disk root only while the candidate is the exact unchanged admitted file.
208    /// Copies, rewritten qcow headers, replaced names, and uncached layers return no reusable root.
209    /// A later explicit integrity capture may compute one. Retained file handles are bounded.
210    pub fn reused_disk_integrity(&self, path: &Path) -> ImageResult<Option<String>> {
211        self.admitted_disks
212            .reuse_for(path)
213            .map(|root| root.map(str::to_owned))
214    }
215
216    /// Stream and verify every immutable memory payload referenced by the logical generation.
217    ///
218    /// Restore normally fuses this check with copying bytes into guest memory. Explicit artifact
219    /// verification uses this method when no restore read is available to amortize the work.
220    pub fn verify_memory_objects(&self) -> ImageResult<()> {
221        let mut verified = BTreeSet::new();
222        for extent in &self.memory.extents {
223            let MemoryExtentContent::Object(content) = &extent.content else {
224                continue;
225            };
226            if verified.insert(content.object.clone()) {
227                verify_object_streaming(&self.root, &content.object)?;
228            }
229        }
230        Ok(())
231    }
232}
233
234//--------------------------------------------------------------------------------------------------
235// Functions
236//--------------------------------------------------------------------------------------------------
237
238fn read_checkpoint_root(
239    root: &Path,
240    expected_root: Option<&ObjectId>,
241) -> ImageResult<(ObjectId, CheckpointManifest)> {
242    if !std::fs::symlink_metadata(root)?.file_type().is_dir() {
243        return checkpoint_error("checkpoint root is not a directory");
244    }
245    let bytes = read_regular_bounded(&root.join(CHECKPOINT_ROOT_FILE), MAX_MANIFEST_BYTES)?;
246    let id = ObjectId::from_bytes(&bytes)?;
247    if let Some(expected) = expected_root.filter(|expected| *expected != &id) {
248        return Err(ImageError::DigestMismatch {
249            digest: id.to_string(),
250            expected: expected.to_string(),
251            actual: id.to_string(),
252        });
253    }
254    Ok((id, CheckpointManifest::from_bytes(&bytes)?))
255}
256
257fn validate_memory_objects(root: &Path, memory: &MemoryManifest) -> ImageResult<()> {
258    let mut verified = BTreeSet::new();
259    for extent in &memory.extents {
260        let MemoryExtentContent::Object(content) = &extent.content else {
261            continue;
262        };
263        let path = object_path(root, &content.object);
264        if verified.insert(content.object.clone()) {
265            // Payload verification is fused with the inevitable restore read. Admission only
266            // proves member shape and range bounds, avoiding a second full RAM pass.
267            open_regular(&path)?;
268        }
269        let size = std::fs::metadata(&path)?.len();
270        let end = content
271            .object_offset
272            .checked_add(extent.length)
273            .ok_or_else(|| checkpoint_error_value("memory object slice overflows"))?;
274        if end > size {
275            return checkpoint_error("memory extent exceeds its immutable object");
276        }
277    }
278    Ok(())
279}
280
281fn disk_layer_path(root: &Path, layer: &DiskLayerRef) -> PathBuf {
282    root.join("layers")
283        .join(format!("{}.{}", layer.layer_id, layer.format))
284}
285
286fn read_object_verified(root: &Path, id: &ObjectId, max_len: u64) -> ImageResult<Vec<u8>> {
287    let mut bytes = Vec::new();
288    read_object_verified_into(root, id, max_len, &mut bytes)?;
289    Ok(bytes)
290}
291
292fn read_object_verified_into(
293    root: &Path,
294    id: &ObjectId,
295    max_len: u64,
296    bytes: &mut Vec<u8>,
297) -> ImageResult<CheckpointObjectReadTiming> {
298    let started = Instant::now();
299    let path = object_path(root, id);
300    let mut file = open_regular(&path)?;
301    let length = file.metadata()?.len();
302    if length > max_len {
303        return checkpoint_error(format!("checkpoint object exceeds {max_len} bytes"));
304    }
305    let length = usize::try_from(length)
306        .map_err(|_| checkpoint_error_value("checkpoint object exceeds host limits"))?;
307    // Keep the initialized buffer between packs. Unlike clear + resize this does not zero
308    // an entire reused pack before the file read overwrites it. A growing file cannot make
309    // read_to_end allocate beyond the admitted object bound.
310    bytes.resize(length, 0);
311    file.read_exact(bytes)?;
312    if file.read(&mut [0u8; 1])? != 0 {
313        return checkpoint_error("checkpoint object changed length during read");
314    }
315    let read_us = started.elapsed().as_micros();
316    let hash_started = Instant::now();
317    let actual = ObjectId::from_bytes(bytes)?;
318    if &actual != id {
319        return Err(ImageError::DigestMismatch {
320            digest: id.to_string(),
321            expected: id.to_string(),
322            actual: actual.to_string(),
323        });
324    }
325    Ok(CheckpointObjectReadTiming {
326        read_us,
327        hash_us: hash_started.elapsed().as_micros(),
328    })
329}
330
331fn verify_object_streaming(root: &Path, id: &ObjectId) -> ImageResult<()> {
332    let mut file = open_regular(&object_path(root, id))?;
333    let mut hasher = Sha256::new();
334    let mut buffer = vec![0u8; 1024 * 1024];
335    loop {
336        let read = file.read(&mut buffer)?;
337        if read == 0 {
338            break;
339        }
340        hasher.update(&buffer[..read]);
341    }
342    let actual = format!("sha256:{}", hex::encode(hasher.finalize()));
343    if actual != id.as_str() {
344        return Err(ImageError::DigestMismatch {
345            digest: id.to_string(),
346            expected: id.to_string(),
347            actual,
348        });
349    }
350    Ok(())
351}
352
353fn read_regular_bounded(path: &Path, max_len: u64) -> ImageResult<Vec<u8>> {
354    let mut file = open_regular(path)?;
355    let length = file.metadata()?.len();
356    if length > max_len {
357        return checkpoint_error(format!("checkpoint manifest exceeds {max_len} bytes"));
358    }
359    let mut bytes = Vec::with_capacity(length as usize);
360    file.read_to_end(&mut bytes)?;
361    Ok(bytes)
362}
363
364fn open_regular(path: &Path) -> ImageResult<File> {
365    let metadata = std::fs::symlink_metadata(path)?;
366    if !metadata.file_type().is_file() {
367        return checkpoint_error(format!(
368            "checkpoint member is not a regular file: {}",
369            path.display()
370        ));
371    }
372    Ok(File::open(path)?)
373}
374
375fn object_path(root: &Path, id: &ObjectId) -> PathBuf {
376    let encoded = id
377        .as_str()
378        .strip_prefix("sha256:")
379        .expect("ObjectId validates its algorithm");
380    root.join("objects")
381        .join("sha256")
382        .join(&encoded[..2])
383        .join(encoded)
384}
385
386fn checkpoint_error<T>(message: impl Into<String>) -> ImageResult<T> {
387    Err(checkpoint_error_value(message))
388}
389
390fn checkpoint_error_value(message: impl Into<String>) -> ImageError {
391    ImageError::ManifestParse(format!("checkpoint closure: {}", message.into()))
392}
393
394//--------------------------------------------------------------------------------------------------
395// Tests
396//--------------------------------------------------------------------------------------------------
397
398#[cfg(test)]
399mod tests {
400    use std::collections::BTreeMap;
401
402    use super::*;
403    use crate::checkpoint::{
404        CaptureIntent, ContentRef, DeviceStateRef, MemoryCaptureMode, MemoryExtent,
405        ResourceDescriptor, ResourceTreatment,
406    };
407
408    #[test]
409    fn reusable_object_reader_checks_each_identity_and_reuses_allocation() {
410        let directory = tempfile::tempdir().unwrap();
411        let store = super::super::LocalObjectStore::open(directory.path()).unwrap();
412        let first = store.put_bytes(b"first payload").unwrap();
413        let second = store.put_bytes(b"next payload!").unwrap();
414        let mut buffer = Vec::with_capacity(64);
415        let allocation = buffer.as_ptr();
416        read_object_verified_into(directory.path(), &first, 64, &mut buffer).unwrap();
417        assert_eq!(buffer, b"first payload");
418        read_object_verified_into(directory.path(), &second, 64, &mut buffer).unwrap();
419        assert_eq!(buffer, b"next payload!");
420        assert_eq!(buffer.as_ptr(), allocation);
421        assert!(read_object_verified_into(directory.path(), &first, 4, &mut buffer).is_err());
422        std::fs::write(store.object_path(&second), b"bad payload!!").unwrap();
423        assert!(matches!(
424            read_object_verified_into(directory.path(), &second, 64, &mut buffer),
425            Err(ImageError::DigestMismatch { .. })
426        ));
427    }
428
429    fn fixture() -> (tempfile::TempDir, ObjectId) {
430        let directory = tempfile::tempdir().unwrap();
431        let store = super::super::LocalObjectStore::open(directory.path()).unwrap();
432        let memory_bytes = b"memory";
433        let memory_object = store.put_bytes(memory_bytes).unwrap();
434        let memory = MemoryManifest {
435            schema: "microsandbox.memory/1".into(),
436            architecture: std::env::consts::ARCH.into(),
437            guest_page_size: 4096,
438            topology_generation: 1,
439            generation: 1,
440            capture_mode: MemoryCaptureMode::Full,
441            pause_generation: 7,
442            extents: vec![MemoryExtent {
443                start: 0,
444                length: memory_bytes.len() as u64,
445                content: MemoryExtentContent::Object(ContentRef {
446                    object: memory_object,
447                    object_offset: 0,
448                }),
449            }],
450        };
451        let memory_id = store
452            .put_bytes(&memory.to_canonical_bytes().unwrap())
453            .unwrap();
454        let execution_id = store.put_bytes(b"execution").unwrap();
455        let device_id = store.put_bytes(b"device").unwrap();
456        let checkpoint = CheckpointManifest {
457            schema: "microsandbox.checkpoint/1".into(),
458            checkpoint_id: "checkpoint".into(),
459            capture_intent: CaptureIntent::FullSnapshot,
460            geometry: crate::checkpoint::CheckpointGeometry {
461                vcpus: 1,
462                max_vcpus: 1,
463                memory_mib: 128,
464                max_memory_mib: 128,
465            },
466            architecture: std::env::consts::ARCH.into(),
467            pause_generation: 7,
468            execution_state: execution_id,
469            memory: memory_id,
470            disks: Vec::new(),
471            owned_volumes: Vec::new(),
472            devices: vec![DeviceStateRef {
473                device_type: 4,
474                device_id: "rng".into(),
475                state: device_id,
476            }],
477            resources: vec![ResourceDescriptor {
478                id: "virtio:4:rng".into(),
479                kind: "rng".into(),
480                treatment: ResourceTreatment::Reset,
481                binding: BTreeMap::new(),
482            }],
483            requires: Vec::new(),
484        };
485        let root_bytes = checkpoint.to_canonical_bytes().unwrap();
486        let root_id = ObjectId::from_bytes(&root_bytes).unwrap();
487        std::fs::write(directory.path().join(CHECKPOINT_ROOT_FILE), root_bytes).unwrap();
488        (directory, root_id)
489    }
490
491    #[test]
492    fn manifest_inspection_does_not_substitute_for_payload_admission() {
493        let (directory, root) = fixture();
494        let manifest = CheckpointClosure::inspect_manifest(directory.path(), Some(&root)).unwrap();
495        std::fs::remove_file(super::object_path(
496            directory.path(),
497            &manifest.execution_state,
498        ))
499        .unwrap();
500        assert!(CheckpointClosure::inspect_manifest(directory.path(), Some(&root)).is_ok());
501        assert!(CheckpointClosure::open(directory.path(), Some(&root)).is_err());
502        let wrong = ObjectId::from_bytes(b"wrong root").unwrap();
503        assert!(CheckpointClosure::inspect_manifest(directory.path(), Some(&wrong)).is_err());
504    }
505
506    #[test]
507    fn optional_disk_integrity_retains_length_checks_and_detects_opted_in_corruption() {
508        use crate::checkpoint::{
509            DiskGenerationManifest, DiskLayerRef, LocalObjectStore, sparse_file_integrity,
510        };
511        for record_integrity in [false, true] {
512            let (directory, root) = fixture();
513            let mut checkpoint =
514                CheckpointClosure::inspect_manifest(directory.path(), Some(&root)).unwrap();
515            let store = LocalObjectStore::open(directory.path()).unwrap();
516            std::fs::create_dir(directory.path().join("layers")).unwrap();
517            let path = directory.path().join("layers/base.raw");
518            std::fs::write(&path, [17; 4096]).unwrap();
519            let disk = DiskGenerationManifest {
520                schema: "microsandbox.disk-generation/1".into(),
521                volume_id: "volume".into(),
522                device_id: "vdb".into(),
523                generation: 1,
524                pause_generation: checkpoint.pause_generation,
525                head: "base".into(),
526                layers: vec![DiskLayerRef {
527                    layer_id: "base".into(),
528                    format: "raw".into(),
529                    virtual_size: 4096,
530                    file_size: 4096,
531                    predecessor: None,
532                    integrity_root: record_integrity
533                        .then(|| sparse_file_integrity(&path).unwrap().root),
534                }],
535            };
536            checkpoint.disks = vec![
537                store
538                    .put_bytes(&disk.to_canonical_bytes().unwrap())
539                    .unwrap(),
540            ];
541            std::fs::write(
542                directory.path().join(CHECKPOINT_ROOT_FILE),
543                checkpoint.to_canonical_bytes().unwrap(),
544            )
545            .unwrap();
546            assert!(CheckpointClosure::open(directory.path(), None).is_ok());
547            // Same-length content changes are intentionally only detectable when opted in.
548            std::fs::write(&path, [18; 4096]).unwrap();
549            assert_eq!(
550                CheckpointClosure::open(directory.path(), None).is_err(),
551                record_integrity
552            );
553            std::fs::write(&path, [17; 4095]).unwrap();
554            let error = CheckpointClosure::open(directory.path(), None)
555                .err()
556                .unwrap()
557                .to_string();
558            assert!(error.contains("length"), "{error}");
559        }
560    }
561
562    #[test]
563    fn opens_complete_valid_closure() {
564        let (directory, expected) = fixture();
565
566        let closure = CheckpointClosure::open(directory.path(), Some(&expected)).unwrap();
567
568        assert_eq!(closure.root_id(), &expected);
569        assert_eq!(closure.memory().pause_generation, 7);
570    }
571
572    #[test]
573    fn deep_closure_admission_keeps_file_handles_bounded() {
574        #[cfg(unix)]
575        if std::env::var_os("MSB_TEST_DEEP_ADMISSION_LOW_FD").is_none() {
576            use std::os::unix::process::CommandExt;
577
578            // Isolate the process-wide limit from concurrently running tests. The old one-FD-
579            // per-layer implementation cannot admit these 512 files with only 64 descriptors.
580            let mut child = std::process::Command::new(std::env::current_exe().unwrap());
581            child
582                .args([
583                    "--exact",
584                    "checkpoint::resolver::tests::deep_closure_admission_keeps_file_handles_bounded",
585                    "--nocapture",
586                ])
587                .env("MSB_TEST_DEEP_ADMISSION_LOW_FD", "1");
588            // SAFETY: the pre-exec callback only invokes async-signal-safe libc resource-limit
589            // operations; it does not allocate or acquire locks in the forked child.
590            unsafe {
591                child.pre_exec(|| {
592                    let mut limit = std::mem::zeroed::<libc::rlimit>();
593                    if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) != 0 {
594                        return Err(std::io::Error::last_os_error());
595                    }
596                    limit.rlim_cur = limit.rlim_max.min(64);
597                    if libc::setrlimit(libc::RLIMIT_NOFILE, &limit) != 0 {
598                        return Err(std::io::Error::last_os_error());
599                    }
600                    Ok(())
601                });
602            }
603            let output = child.output().unwrap();
604            assert!(
605                output.status.success(),
606                "low-FD admission failed: {}{}",
607                String::from_utf8_lossy(&output.stdout),
608                String::from_utf8_lossy(&output.stderr)
609            );
610            assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
611            return;
612        }
613
614        let (directory, _) = fixture();
615        let store = super::super::LocalObjectStore::open(directory.path()).unwrap();
616        let root_path = directory.path().join(CHECKPOINT_ROOT_FILE);
617        let mut checkpoint =
618            CheckpointManifest::from_bytes(&std::fs::read(&root_path).unwrap()).unwrap();
619        std::fs::create_dir_all(directory.path().join("layers")).unwrap();
620        let mut paths = Vec::new();
621        // Each volume stays inside the existing 256-layer manifest limit. Disk header codecs
622        // are runtime concerns: this resolver fixture exercises byte admission and membership.
623        for volume in 0..2 {
624            let mut layers = Vec::new();
625            for index in 0..256 {
626                let layer_id = format!("volume_{volume}_layer_{index}");
627                let format = if index == 0 { "raw" } else { "qcow2" };
628                let path = directory
629                    .path()
630                    .join("layers")
631                    .join(format!("{layer_id}.{format}"));
632                std::fs::write(&path, [0x55]).unwrap();
633                let integrity_root = super::super::sparse_file_integrity(&path).unwrap().root;
634                layers.push(DiskLayerRef {
635                    file_size: 1,
636                    layer_id,
637                    format: format.into(),
638                    virtual_size: 4096,
639                    predecessor: (index > 0)
640                        .then(|| format!("volume_{volume}_layer_{}", index - 1)),
641                    integrity_root: Some(integrity_root),
642                });
643                paths.push(path);
644            }
645            let disk = DiskGenerationManifest {
646                schema: "microsandbox.disk-generation/1".into(),
647                volume_id: format!("volume_{volume}"),
648                device_id: format!("device_{volume}"),
649                generation: 1,
650                head: layers.last().unwrap().layer_id.clone(),
651                layers,
652                pause_generation: checkpoint.pause_generation,
653            };
654            checkpoint.disks.push(
655                store
656                    .put_bytes(&disk.to_canonical_bytes().unwrap())
657                    .unwrap(),
658            );
659        }
660        let bytes = checkpoint.to_canonical_bytes().unwrap();
661        let root = ObjectId::from_bytes(&bytes).unwrap();
662        std::fs::write(&root_path, bytes).unwrap();
663
664        let closure = CheckpointClosure::open(directory.path(), Some(&root)).unwrap();
665        assert_eq!(closure.disks().len(), 2);
666        assert!(closure.disks().iter().all(|disk| disk.layers.len() == 256));
667        let reusable = paths
668            .iter()
669            .filter(|path| closure.reused_disk_integrity(path).unwrap().is_some())
670            .count();
671        assert_eq!(reusable, 32);
672        drop(closure);
673
674        // A layer outside the retained receipt set must still be verified during admission.
675        std::fs::write(paths.last().unwrap(), [0xAA]).unwrap();
676        assert!(matches!(
677            CheckpointClosure::open(directory.path(), Some(&root)),
678            Err(ImageError::DigestMismatch { .. })
679        ));
680    }
681
682    #[test]
683    fn portable_open_separates_integrity_from_restore_architecture() {
684        let (directory, _expected) = fixture();
685        let root_path = directory.path().join(CHECKPOINT_ROOT_FILE);
686        let mut checkpoint =
687            CheckpointManifest::from_bytes(&std::fs::read(&root_path).unwrap()).unwrap();
688        checkpoint.architecture = "another-architecture".into();
689        let memory_path = object_path(directory.path(), &checkpoint.memory);
690        let mut memory = MemoryManifest::from_bytes(&std::fs::read(&memory_path).unwrap()).unwrap();
691        memory.architecture = checkpoint.architecture.clone();
692        let store = super::super::LocalObjectStore::open(directory.path()).unwrap();
693        checkpoint.memory = store
694            .put_bytes(&memory.to_canonical_bytes().unwrap())
695            .unwrap();
696        let root_bytes = checkpoint.to_canonical_bytes().unwrap();
697        let expected = ObjectId::from_bytes(&root_bytes).unwrap();
698        std::fs::write(root_path, root_bytes).unwrap();
699
700        CheckpointClosure::open_portable(directory.path(), Some(&expected)).unwrap();
701        let error = CheckpointClosure::open(directory.path(), Some(&expected)).unwrap_err();
702        assert!(error.to_string().contains("cannot restore"));
703    }
704
705    #[test]
706    fn rejects_replaced_memory_object() {
707        let (directory, expected) = fixture();
708        let checkpoint = CheckpointManifest::from_bytes(
709            &std::fs::read(directory.path().join(CHECKPOINT_ROOT_FILE)).unwrap(),
710        )
711        .unwrap();
712        let memory = MemoryManifest::from_bytes(
713            &read_object_verified(directory.path(), &checkpoint.memory, MAX_MANIFEST_BYTES)
714                .unwrap(),
715        )
716        .unwrap();
717        let MemoryExtentContent::Object(content) = &memory.extents[0].content else {
718            panic!("fixture uses object memory");
719        };
720        std::fs::write(object_path(directory.path(), &content.object), b"changed").unwrap();
721
722        let closure = CheckpointClosure::open(directory.path(), Some(&expected)).unwrap();
723        let error = closure
724            .read_object(&content.object, MAX_MANIFEST_BYTES)
725            .unwrap_err();
726
727        assert!(matches!(error, ImageError::DigestMismatch { .. }));
728    }
729
730    #[test]
731    fn explicit_verification_detects_replaced_memory_object() {
732        let (directory, expected) = fixture();
733        let closure = CheckpointClosure::open(directory.path(), Some(&expected)).unwrap();
734        let MemoryExtentContent::Object(content) = &closure.memory().extents[0].content else {
735            panic!("fixture uses object memory");
736        };
737        std::fs::write(object_path(directory.path(), &content.object), b"changed").unwrap();
738
739        let error = closure.verify_memory_objects().unwrap_err();
740
741        assert!(matches!(error, ImageError::DigestMismatch { .. }));
742    }
743}