Skip to main content

microsandbox_image/checkpoint/
manifest.rs

1//! Canonical schemas for one same-epoch checkpoint closure.
2
3use std::collections::BTreeMap;
4
5use serde::de::DeserializeOwned;
6use serde::{Deserialize, Serialize};
7
8use crate::error::{ImageError, ImageResult};
9
10use super::ObjectId;
11
12//--------------------------------------------------------------------------------------------------
13// Constants
14//--------------------------------------------------------------------------------------------------
15
16const MAX_MANIFEST_BYTES: usize = 8 * 1024 * 1024;
17const MAX_COMPONENTS: usize = 4096;
18const MAX_MEMORY_EXTENTS: usize = 4 * 1024 * 1024;
19
20//--------------------------------------------------------------------------------------------------
21// Types
22//--------------------------------------------------------------------------------------------------
23
24/// Why a checkpoint was captured.
25#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum CaptureIntent {
28    /// A user-requested full snapshot.
29    FullSnapshot,
30    /// A local idle/park checkpoint.
31    Park,
32    /// A transparent continuity operation.
33    TransparentTransfer,
34}
35
36/// Whether memory bytes were produced completely or from a retained runtime baseline.
37#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
38#[serde(rename_all = "lowercase")]
39pub enum MemoryCaptureMode {
40    /// Every ordinary memory range was read.
41    Full,
42    /// Only dirty ranges were read; unchanged references were reused.
43    Incremental,
44}
45
46/// A byte range backed by one immutable object.
47#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct ContentRef {
50    /// Object containing the bytes.
51    pub object: ObjectId,
52    /// Byte offset within the object.
53    pub object_offset: u64,
54}
55
56/// Content of one memory range.
57#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "lowercase")]
59pub enum MemoryExtentContent {
60    /// Exact bytes stored in an immutable object.
61    Object(ContentRef),
62    /// An all-zero range that requires no object.
63    Zero,
64}
65
66/// One sorted, non-overlapping memory range.
67#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct MemoryExtent {
70    /// Guest-physical start address.
71    pub start: u64,
72    /// Non-zero range length.
73    pub length: u64,
74    /// Range content.
75    pub content: MemoryExtentContent,
76}
77
78/// Complete logical memory generation.
79#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct MemoryManifest {
82    /// Schema identifier.
83    pub schema: String,
84    /// Guest architecture.
85    pub architecture: String,
86    /// Guest page size in bytes.
87    pub guest_page_size: u64,
88    /// Runtime-local memory topology generation.
89    pub topology_generation: u64,
90    /// Published memory content generation.
91    pub generation: u64,
92    /// How bytes for this generation were produced.
93    pub capture_mode: MemoryCaptureMode,
94    /// VM-wide pause boundary shared with execution and device state.
95    pub pause_generation: u64,
96    /// Complete sorted logical content table.
97    pub extents: Vec<MemoryExtent>,
98}
99
100pub use microsandbox_types::snapshot::disk::{DiskGenerationManifest, DiskLayerRef};
101
102/// Treatment selected for a runtime resource.
103#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "lowercase")]
105pub enum ResourceTreatment {
106    /// Exact reusable state is serialized.
107    Serialize,
108    /// Destination reconstructs a host binding before activation.
109    Reconnect,
110    /// The resource deliberately starts a fresh observation/session epoch.
111    Reset,
112    /// The resource makes this checkpoint ineligible.
113    Reject,
114}
115
116/// Frozen logical resource-plan entry.
117#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct ResourceDescriptor {
120    /// Stable resource identity within the VM.
121    pub id: String,
122    /// Resource family.
123    pub kind: String,
124    /// Selected treatment.
125    pub treatment: ResourceTreatment,
126    /// Restore-relevant logical binding, excluding host-local paths.
127    pub binding: BTreeMap<String, String>,
128}
129
130/// One device-state object bound to its logical resource.
131#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct DeviceStateRef {
134    /// Virtio device type.
135    pub device_type: u32,
136    /// Stable device identifier.
137    pub device_id: String,
138    /// Encoded transport/device state object.
139    pub state: ObjectId,
140}
141
142/// Original construction layout, independent of live CPU/memory resize targets.
143#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct CheckpointGeometry {
146    /// CPU count supplied at construction, not the current online count.
147    pub vcpus: u8,
148    /// Number of possible CPUs constructed for this VM.
149    pub max_vcpus: u8,
150    /// Initially populated RAM in MiB; hotplug RAM occupies a separate address range.
151    pub memory_mib: u32,
152    /// Reserved RAM capacity in MiB, including the initial RAM.
153    pub max_memory_mib: u32,
154}
155
156/// Root manifest binding one complete same-epoch checkpoint.
157#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct CheckpointManifest {
160    /// Schema identifier.
161    pub schema: String,
162    /// Stable checkpoint identity.
163    pub checkpoint_id: String,
164    /// Capture purpose.
165    pub capture_intent: CaptureIntent,
166    /// Guest architecture.
167    pub architecture: String,
168    /// Immutable layout required to reconstruct the captured address space and devices.
169    pub geometry: CheckpointGeometry,
170    /// VM-wide pause boundary shared by every captured participant.
171    pub pause_generation: u64,
172    /// Encoded hypervisor execution state.
173    pub execution_state: ObjectId,
174    /// Complete logical memory-generation manifest.
175    pub memory: ObjectId,
176    /// Sealed disk-generation manifests.
177    pub disks: Vec<ObjectId>,
178    /// Required lifetime-owned backing. Older strict checkpoint readers refuse this field.
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub owned_volumes: Vec<crate::snapshot::OwnedVolumeCapture>,
181    /// Device transport/state objects.
182    pub devices: Vec<DeviceStateRef>,
183    /// Frozen resource plan used for admission and restore.
184    pub resources: Vec<ResourceDescriptor>,
185    /// Namespaced must-understand extensions.
186    pub requires: Vec<String>,
187}
188
189//--------------------------------------------------------------------------------------------------
190// Methods
191//--------------------------------------------------------------------------------------------------
192
193impl MemoryManifest {
194    fn validate_body(&self) -> ImageResult<()> {
195        if self.architecture.is_empty() || self.guest_page_size == 0 || self.generation == 0 {
196            return manifest_error("memory manifest has empty architecture or zero geometry");
197        }
198        if self.extents.len() > MAX_MEMORY_EXTENTS {
199            return manifest_error("memory extent count exceeds the format bound");
200        }
201        validate_extents(&self.extents)
202    }
203}
204
205impl CheckpointManifest {
206    fn validate_body(&self) -> ImageResult<()> {
207        if self.checkpoint_id.is_empty() || self.architecture.is_empty() {
208            return manifest_error("checkpoint is missing identity or architecture");
209        }
210        if self.geometry.vcpus == 0
211            || self.geometry.vcpus > self.geometry.max_vcpus
212            || self.geometry.memory_mib == 0
213            || self.geometry.memory_mib > self.geometry.max_memory_mib
214        {
215            return manifest_error("checkpoint has invalid construction geometry");
216        }
217        if self.disks.len() > MAX_COMPONENTS
218            || self.devices.len() > MAX_COMPONENTS
219            || self.resources.len() > MAX_COMPONENTS
220        {
221            return manifest_error("checkpoint component count exceeds the format bound");
222        }
223        let mut devices = std::collections::BTreeSet::new();
224        for device in &self.devices {
225            if !devices.insert((device.device_type, device.device_id.as_str())) {
226                return manifest_error("checkpoint contains a duplicate logical device");
227            }
228        }
229        let mut resources = std::collections::BTreeSet::new();
230        for resource in &self.resources {
231            if resource.id.is_empty() || resource.kind.is_empty() || !resources.insert(&resource.id)
232            {
233                return manifest_error("checkpoint contains an invalid or duplicate resource");
234            }
235            if resource.treatment == ResourceTreatment::Reject {
236                return manifest_error("published checkpoint contains a rejected resource");
237            }
238        }
239        if self.requires.windows(2).any(|pair| pair[0] >= pair[1]) {
240            return manifest_error("checkpoint requires must be sorted and unique");
241        }
242        crate::snapshot::validate_owned_volumes(&self.owned_volumes)?;
243        crate::snapshot::validate_owned_resources(&self.owned_volumes, &self.resources)?;
244        for volume in &self.owned_volumes {
245            if let crate::snapshot::OwnedVolumeData::Disk { generation } = &volume.data
246                && generation.pause_generation != self.pause_generation
247            {
248                return manifest_error("owned disk belongs to another checkpoint epoch");
249            }
250        }
251        Ok(())
252    }
253}
254
255//--------------------------------------------------------------------------------------------------
256// Functions: Helpers
257//--------------------------------------------------------------------------------------------------
258
259fn validate_extents(extents: &[MemoryExtent]) -> ImageResult<()> {
260    let mut previous_end = 0u64;
261    for (index, extent) in extents.iter().enumerate() {
262        if extent.length == 0 {
263            return manifest_error("memory extent has zero length");
264        }
265        let end = extent
266            .start
267            .checked_add(extent.length)
268            .ok_or_else(|| manifest_error_value("memory extent overflows the address space"))?;
269        if index != 0 && extent.start < previous_end {
270            return manifest_error("memory extents overlap or are unsorted");
271        }
272        if let MemoryExtentContent::Object(content) = &extent.content {
273            content
274                .object_offset
275                .checked_add(extent.length)
276                .ok_or_else(|| manifest_error_value("memory object slice overflows"))?;
277        }
278        previous_end = end;
279    }
280    Ok(())
281}
282
283fn canonical_bytes<T: Serialize>(manifest: &T) -> ImageResult<Vec<u8>> {
284    let value = serde_json::to_value(manifest)
285        .map_err(|error| manifest_error_value(format!("serialize failed: {error}")))?;
286    let mut output = Vec::new();
287    crate::snapshot::manifest::write_canonical_json(&value, &mut output)?;
288    if output.len() > MAX_MANIFEST_BYTES {
289        return manifest_error("manifest exceeds the encoded-size bound");
290    }
291    Ok(output)
292}
293
294fn parse_manifest<T>(bytes: &[u8]) -> ImageResult<T>
295where
296    T: DeserializeOwned + Serialize + Validate,
297{
298    if bytes.len() > MAX_MANIFEST_BYTES {
299        return manifest_error("manifest exceeds the encoded-size bound");
300    }
301    crate::snapshot::manifest::reject_duplicate_json_keys(bytes)?;
302    let manifest: T = serde_json::from_slice(bytes)
303        .map_err(|error| manifest_error_value(format!("parse failed: {error}")))?;
304    manifest.validate_manifest()?;
305    if canonical_bytes(&manifest)? != bytes {
306        return manifest_error("stored manifest bytes are not canonical");
307    }
308    Ok(manifest)
309}
310
311trait Validate {
312    fn validate_manifest(&self) -> ImageResult<()>;
313}
314
315impl Validate for MemoryManifest {
316    fn validate_manifest(&self) -> ImageResult<()> {
317        self.validate()
318    }
319}
320
321impl Validate for CheckpointManifest {
322    fn validate_manifest(&self) -> ImageResult<()> {
323        self.validate()
324    }
325}
326
327fn manifest_error<T>(message: impl Into<String>) -> ImageResult<T> {
328    Err(manifest_error_value(message))
329}
330
331fn manifest_error_value(message: impl Into<String>) -> ImageError {
332    ImageError::ManifestParse(format!("checkpoint manifest: {}", message.into()))
333}
334
335//--------------------------------------------------------------------------------------------------
336// Macros
337//--------------------------------------------------------------------------------------------------
338
339macro_rules! manifest_methods {
340    ($type:ty, $schema:literal) => {
341        impl $type {
342            /// Validate structural and same-record invariants.
343            pub fn validate(&self) -> ImageResult<()> {
344                if self.schema != $schema {
345                    return manifest_error(format!(
346                        "unsupported schema {} (expected {})",
347                        self.schema, $schema
348                    ));
349                }
350                self.validate_body()
351            }
352
353            /// Serialize this manifest using the repository's bounded RFC 8785 subset.
354            pub fn to_canonical_bytes(&self) -> ImageResult<Vec<u8>> {
355                self.validate()?;
356                canonical_bytes(self)
357            }
358
359            /// Parse and validate one complete canonical manifest.
360            pub fn from_bytes(bytes: &[u8]) -> ImageResult<Self> {
361                parse_manifest(bytes)
362            }
363
364            /// Compute the immutable SHA-256 identity of canonical bytes.
365            pub fn digest(&self) -> ImageResult<ObjectId> {
366                Ok(ObjectId::from_bytes(&self.to_canonical_bytes()?)?)
367            }
368        }
369    };
370}
371
372manifest_methods!(MemoryManifest, "microsandbox.memory/1");
373manifest_methods!(CheckpointManifest, "microsandbox.checkpoint/1");
374
375//--------------------------------------------------------------------------------------------------
376// Tests
377//--------------------------------------------------------------------------------------------------
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn full_checkpoint_requires_valid_original_geometry() {
385        let object = ObjectId::from_bytes(b"state").unwrap();
386        let mut manifest = CheckpointManifest {
387            schema: "microsandbox.checkpoint/1".into(),
388            checkpoint_id: "checkpoint_geometry".into(),
389            capture_intent: CaptureIntent::FullSnapshot,
390            architecture: "aarch64".into(),
391            geometry: CheckpointGeometry {
392                vcpus: 2,
393                max_vcpus: 8,
394                memory_mib: 8192,
395                max_memory_mib: 32768,
396            },
397            pause_generation: 1,
398            execution_state: object.clone(),
399            memory: object,
400            disks: Vec::new(),
401            devices: Vec::new(),
402            resources: Vec::new(),
403            owned_volumes: Vec::new(),
404            requires: Vec::new(),
405        };
406        let bytes = manifest.to_canonical_bytes().unwrap();
407        assert_eq!(CheckpointManifest::from_bytes(&bytes).unwrap(), manifest);
408        // Earlier development full captures cannot reconstruct hotplug topology reliably.
409        let mut old = serde_json::to_value(&manifest).unwrap();
410        old.as_object_mut().unwrap().remove("geometry");
411        assert!(CheckpointManifest::from_bytes(&serde_json::to_vec(&old).unwrap()).is_err());
412        let original_geometry = manifest.geometry;
413        for geometry in [
414            CheckpointGeometry {
415                vcpus: 0,
416                ..original_geometry
417            },
418            CheckpointGeometry {
419                max_vcpus: 1,
420                ..original_geometry
421            },
422            CheckpointGeometry {
423                memory_mib: 0,
424                ..original_geometry
425            },
426            CheckpointGeometry {
427                max_memory_mib: 4096,
428                ..original_geometry
429            },
430        ] {
431            manifest.geometry = geometry;
432            assert!(manifest.to_canonical_bytes().is_err());
433        }
434    }
435
436    #[test]
437    fn incremental_memory_manifest_may_slice_reused_objects() {
438        let object = ObjectId::from_bytes(b"memory").unwrap();
439        let manifest = MemoryManifest {
440            schema: "microsandbox.memory/1".into(),
441            architecture: "aarch64".into(),
442            guest_page_size: 4096,
443            topology_generation: 1,
444            generation: 2,
445            capture_mode: MemoryCaptureMode::Incremental,
446            pause_generation: 42,
447            extents: vec![MemoryExtent {
448                start: 0,
449                length: 4096,
450                content: MemoryExtentContent::Object(ContentRef {
451                    object,
452                    object_offset: 4096,
453                }),
454            }],
455        };
456
457        let bytes = manifest.to_canonical_bytes().unwrap();
458        assert_eq!(MemoryManifest::from_bytes(&bytes).unwrap(), manifest);
459    }
460
461    #[test]
462    fn disk_layer_identity_cannot_escape_the_closure_directory() {
463        let manifest = DiskGenerationManifest {
464            schema: "microsandbox.disk-generation/1".into(),
465            volume_id: "vol_test".into(),
466            device_id: "vdb".into(),
467            generation: 1,
468            layers: vec![DiskLayerRef {
469                file_size: 4096,
470                layer_id: "../outside".into(),
471                format: "raw".into(),
472                virtual_size: 4096,
473                predecessor: None,
474                integrity_root: Some(format!("blake3:{}", "0".repeat(64))),
475            }],
476            head: "../outside".into(),
477            pause_generation: 1,
478        };
479
480        assert!(manifest.validate().is_err());
481    }
482
483    #[test]
484    fn earlier_disk_generation_defaults_to_managed_device() {
485        let manifest = DiskGenerationManifest {
486            schema: "microsandbox.disk-generation/1".into(),
487            volume_id: "vol_test".into(),
488            device_id: "vdb".into(),
489            generation: 1,
490            layers: vec![DiskLayerRef {
491                file_size: 4096,
492                layer_id: "layer_test".into(),
493                format: "raw".into(),
494                virtual_size: 4096,
495                predecessor: None,
496                integrity_root: Some(format!("blake3:{}", "0".repeat(64))),
497            }],
498            head: "layer_test".into(),
499            pause_generation: 1,
500        };
501        let mut value = serde_json::to_value(manifest).unwrap();
502        value.as_object_mut().unwrap().remove("device_id");
503        let parsed =
504            DiskGenerationManifest::from_bytes(&serde_json::to_vec(&value).unwrap()).unwrap();
505        assert_eq!(parsed.device_id, "vdb");
506    }
507}