Skip to main content

microsandbox_types/snapshot/
manifest.rs

1//! Final snapshot descriptor schema and canonical serialization.
2
3use std::collections::{BTreeMap, HashSet};
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7use chrono::{DateTime, SecondsFormat, Utc};
8use serde::de::{Error as _, MapAccess, SeqAccess, Visitor};
9use serde::{Deserialize, Deserializer, Serialize};
10use sha2::{Digest as _, Sha256};
11
12use crate::error::{SnapshotManifestError, SnapshotManifestResult};
13
14//--------------------------------------------------------------------------------------------------
15// Constants
16//--------------------------------------------------------------------------------------------------
17
18/// Current snapshot descriptor schema identifier.
19pub const SCHEMA: &str = "microsandbox.snapshot/1";
20/// Numeric source-compatibility alias for the released schema.
21pub const SCHEMA_VERSION: u32 = 1;
22/// Canonical descriptor filename.
23pub const DESCRIPTOR_FILENAME: &str = "snapshot.json";
24/// Source-compatibility alias for the released artifact discriminator.
25pub const SNAPSHOT_ARTIFACT_KIND: &str = "snapshot";
26/// Released flat-payload filename.
27pub const DEFAULT_UPPER_FILE: &str = "upper.ext4";
28/// Directory containing physical file layers.
29pub const LAYERS_DIRECTORY: &str = "layers";
30/// Released sparse SHA-256 algorithm.
31pub const SPARSE_SHA256_V1: &str = "msb-sparse-sha256-v1";
32/// Current file Merkle algorithm.
33pub const FILE_MERKLE_BLAKE3_V1: &str = "msb-file-merkle-blake3-v1";
34/// Fixed leaf size of the current file Merkle algorithm.
35pub const FILE_MERKLE_BLAKE3_LEAF_SIZE: u32 = 64 * 1024;
36/// Largest exact integer shared by public JSON consumers.
37pub const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
38/// Maximum accepted descriptor size.
39pub const MAX_DESCRIPTOR_BYTES: usize = 1024 * 1024;
40/// Maximum physical depth of one file-state closure.
41pub const MAX_FILE_LAYERS: usize = 256;
42/// Must-understand extensions implemented by this runtime.
43pub const SUPPORTED_REQUIRES: &[&str] = &[
44    super::RESTORE_DEFAULTS_EXTENSION,
45    super::OWNED_VOLUMES_EXTENSION,
46];
47
48//--------------------------------------------------------------------------------------------------
49// Types
50//--------------------------------------------------------------------------------------------------
51
52/// Stable opaque snapshot identity.
53#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
54#[serde(transparent)]
55pub struct SnapshotId(String);
56
57/// Stable opaque physical-layer identity.
58#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
59#[serde(transparent)]
60pub struct DiskLayerId(String);
61
62/// Physical disk format.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum SnapshotFormat {
66    /// Raw disk image.
67    Raw,
68    /// Qcow2 disk image.
69    Qcow2,
70}
71
72/// Snapshot state family.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74pub enum SnapshotScope {
75    /// File-backed disk state.
76    #[serde(rename = "file")]
77    Disk,
78    /// Composite checkpoint state.
79    #[serde(rename = "checkpoint")]
80    Full,
81}
82
83/// Consistency guarantee of a capture.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "kebab-case")]
86pub enum SnapshotConsistency {
87    /// Equivalent to storage observed after abrupt power loss.
88    CrashConsistent,
89    /// Captured after filesystem quiescence.
90    FilesystemConsistent,
91    /// Coherently resumable execution state.
92    ApplicationConsistent,
93}
94
95/// Guest-visible OCI root-disk layout captured by the snapshot.
96///
97/// This is required because a one-layer raw disk can either be a managed
98/// OverlayFS upper or a complete flat root filesystem. Those byte payloads
99/// have different attachment and restore semantics even when their physical
100/// layer shapes happen to match.
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(tag = "layout", rename_all = "lowercase", deny_unknown_fields)]
103pub enum SnapshotRootDisk {
104    /// Immutable OCI lower layers plus a writable managed upper device.
105    #[default]
106    Managed,
107    /// One complete filesystem attached directly as the guest root device.
108    Flat,
109    /// An OCI lower with a writable upper held entirely in guest memory.
110    Tmpfs {
111        /// Configured tmpfs ceiling in MiB, or the runtime default.
112        #[serde(deserialize_with = "deserialize_required_option")]
113        size_mib: Option<u32>,
114    },
115}
116
117/// Pinned OCI image reference.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct ImageRef {
121    /// Human-readable image reference.
122    pub reference: String,
123    /// OCI manifest digest.
124    pub manifest_digest: String,
125}
126
127/// Immutable capture provenance.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct SnapshotCapture {
131    /// Normalized RFC 3339 capture time.
132    pub created_at: String,
133    /// Stable source lineage when known.
134    #[serde(deserialize_with = "deserialize_required_option")]
135    pub source_lineage: Option<String>,
136    /// Source checkpoint ID when applicable.
137    #[serde(deserialize_with = "deserialize_required_option")]
138    pub source_checkpoint: Option<String>,
139    /// Capture consistency.
140    pub consistency: SnapshotConsistency,
141}
142
143/// Optional persistent layer integrity.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(tag = "algorithm", deny_unknown_fields)]
146pub enum UpperIntegrity {
147    /// Ordinary SHA-256 retained only for released compatibility.
148    #[serde(rename = "sha256")]
149    Sha256 {
150        /// Qualified digest.
151        digest: String,
152    },
153    /// Released logical-byte sparse SHA-256.
154    #[serde(rename = "msb-sparse-sha256-v1")]
155    SparseSha256V1 {
156        /// Qualified digest.
157        digest: String,
158    },
159    /// Current sparse-aware fixed-leaf BLAKE3 Merkle root.
160    #[serde(rename = "msb-file-merkle-blake3-v1")]
161    FileMerkleBlake3V1 {
162        /// Qualified root.
163        root: String,
164        /// Bound logical payload size.
165        logical_size: u64,
166        /// Fixed leaf size.
167        leaf_size: u32,
168    },
169}
170
171/// Allowed physical payload kind.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "kebab-case")]
174pub enum LayerFileKind {
175    /// Ordinary regular file.
176    Regular,
177}
178
179/// Physical payload metadata.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct LayerPayload {
183    /// Required member kind.
184    pub file_kind: LayerFileKind,
185    /// Optional persistent integrity.
186    #[serde(deserialize_with = "deserialize_required_option")]
187    pub integrity: Option<UpperIntegrity>,
188}
189
190/// One member of an ordered complete file-layer closure.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct DiskLayer {
194    /// Opaque member identity.
195    pub layer_id: DiskLayerId,
196    /// Physical format.
197    pub format: SnapshotFormat,
198    /// Guest-visible virtual length.
199    pub virtual_size: u64,
200    /// Immediate predecessor, if any.
201    #[serde(deserialize_with = "deserialize_required_option")]
202    pub backing: Option<DiskLayerId>,
203    /// Physical payload metadata.
204    pub payload: LayerPayload,
205}
206
207/// Compatibility alias for callers that imported the released name.
208pub type UpperLayer = DiskLayer;
209
210/// Concrete file-backed snapshot state.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(deny_unknown_fields)]
213pub struct FileSnapshotState {
214    /// Format of the head layer.
215    pub disk_format: SnapshotFormat,
216    /// Filesystem inside the virtual disk.
217    pub filesystem: String,
218    /// Guest-visible size of the head layer.
219    pub virtual_size: u64,
220    /// Opaque identity of the head layer.
221    pub head: DiskLayerId,
222    /// Complete physical closure, oldest first.
223    pub layers: Vec<DiskLayer>,
224}
225
226/// Composite-checkpoint-backed state.
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct CheckpointSnapshotState {
230    /// Stable checkpoint identifier.
231    pub checkpoint_id: String,
232    /// Composite checkpoint integrity root.
233    pub checkpoint_root: String,
234    /// Allowed restore intents.
235    pub restore_intents: Vec<String>,
236    /// Bounded early-admission summary.
237    pub requirements_summary: BTreeMap<String, serde_json::Value>,
238}
239
240/// Closed snapshot-state variants.
241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
242#[serde(tag = "kind", rename_all = "lowercase")]
243pub enum SnapshotState {
244    /// Concrete file-backed state.
245    File(FileSnapshotState),
246    /// Composite checkpoint state.
247    Checkpoint(CheckpointSnapshotState),
248}
249
250/// Final schema-1 descriptor.
251#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct Manifest {
254    /// Schema identifier. Exactly [`SCHEMA`].
255    pub schema: String,
256    /// Stable artifact identity.
257    pub snapshot_id: SnapshotId,
258    /// State family.
259    pub scope: SnapshotScope,
260    /// Closed state value.
261    pub state: SnapshotState,
262    /// Capture provenance.
263    pub capture: SnapshotCapture,
264    /// Pinned base image.
265    pub image: ImageRef,
266    /// Root-disk layout whose state is represented by this artifact.
267    #[serde(default)]
268    pub root_disk: SnapshotRootDisk,
269    /// Logical parent by snapshot ID.
270    #[serde(deserialize_with = "deserialize_required_option")]
271    pub parent: Option<SnapshotId>,
272    /// Sorted unique must-understand extension keys.
273    pub requires: Vec<String>,
274    /// Namespaced additive extensions.
275    pub extensions: BTreeMap<String, serde_json::Value>,
276}
277
278/// Descriptive alias for descriptor terminology.
279pub type SnapshotDescriptor = Manifest;
280
281/// Duplicate-key rejecting JSON visitor.
282struct DuplicateCheckedJson;
283
284//--------------------------------------------------------------------------------------------------
285// Methods
286//--------------------------------------------------------------------------------------------------
287
288impl SnapshotId {
289    /// Construct a validated snapshot ID.
290    pub fn new(value: impl Into<String>) -> SnapshotManifestResult<Self> {
291        let value = value.into();
292        validate_opaque_id(&value, "snap_", "snapshot_id")?;
293        Ok(Self(value))
294    }
295
296    /// Return the encoded ID.
297    pub fn as_str(&self) -> &str {
298        &self.0
299    }
300}
301
302impl fmt::Display for SnapshotId {
303    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
304        self.0.fmt(formatter)
305    }
306}
307
308impl DiskLayerId {
309    /// Construct a validated layer ID.
310    pub fn new(value: impl Into<String>) -> SnapshotManifestResult<Self> {
311        let value = value.into();
312        validate_opaque_id(&value, "layer_", "layer_id")?;
313        Ok(Self(value))
314    }
315
316    /// Return the encoded ID.
317    pub fn as_str(&self) -> &str {
318        &self.0
319    }
320}
321
322impl fmt::Display for DiskLayerId {
323    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
324        self.0.fmt(formatter)
325    }
326}
327
328impl SnapshotState {
329    /// Return the index discriminant.
330    pub const fn kind(&self) -> &'static str {
331        match self {
332            Self::File(_) => "file",
333            Self::Checkpoint(_) => "checkpoint",
334        }
335    }
336
337    /// Return file state when present.
338    pub const fn as_file(&self) -> Option<&FileSnapshotState> {
339        match self {
340            Self::File(state) => Some(state),
341            Self::Checkpoint(_) => None,
342        }
343    }
344
345    /// Return checkpoint state when present.
346    pub const fn as_checkpoint(&self) -> Option<&CheckpointSnapshotState> {
347        match self {
348            Self::File(_) => None,
349            Self::Checkpoint(state) => Some(state),
350        }
351    }
352}
353
354impl FileSnapshotState {
355    /// Return the validated head layer.
356    pub fn head_layer(&self) -> SnapshotManifestResult<&DiskLayer> {
357        self.layers
358            .last()
359            .filter(|layer| layer.layer_id == self.head)
360            .ok_or_else(|| descriptor_error_value("state.head does not name the final layer"))
361    }
362
363    /// Return a layer's canonical artifact-relative path.
364    pub fn layer_path(&self, layer: &DiskLayer) -> PathBuf {
365        layer_path(&layer.layer_id, layer.format)
366    }
367
368    /// Return a representation-sensitive state root when every layer records integrity.
369    pub fn state_root(&self) -> SnapshotManifestResult<Option<String>> {
370        if self
371            .layers
372            .iter()
373            .any(|layer| layer.payload.integrity.is_none())
374        {
375            return Ok(None);
376        }
377        let mut input = b"microsandbox.file-state-root/1\0".to_vec();
378        input.extend_from_slice(self.filesystem.as_bytes());
379        input.push(0);
380        input.extend_from_slice(&self.virtual_size.to_le_bytes());
381        for layer in &self.layers {
382            input.extend_from_slice(match layer.format {
383                SnapshotFormat::Raw => b"raw\0",
384                SnapshotFormat::Qcow2 => b"qcow2\0",
385            });
386            input.extend_from_slice(&layer.virtual_size.to_le_bytes());
387            let integrity = layer.payload.integrity.as_ref().expect("checked above");
388            input.extend_from_slice(integrity.algorithm().as_bytes());
389            input.push(0);
390            input.extend_from_slice(integrity.value().as_bytes());
391            input.push(0);
392        }
393        let mut hasher = Sha256::new();
394        hasher.update(input);
395        Ok(Some(format!("sha256:{}", hex::encode(hasher.finalize()))))
396    }
397}
398
399impl UpperIntegrity {
400    /// Return the serialized algorithm name.
401    pub const fn algorithm(&self) -> &'static str {
402        match self {
403            Self::Sha256 { .. } => "sha256",
404            Self::SparseSha256V1 { .. } => SPARSE_SHA256_V1,
405            Self::FileMerkleBlake3V1 { .. } => FILE_MERKLE_BLAKE3_V1,
406        }
407    }
408
409    /// Return the qualified digest or root.
410    pub fn value(&self) -> &str {
411        match self {
412            Self::Sha256 { digest } | Self::SparseSha256V1 { digest } => digest,
413            Self::FileMerkleBlake3V1 { root, .. } => root,
414        }
415    }
416}
417
418impl Manifest {
419    /// Validate all closed descriptor invariants.
420    pub fn validate(&self) -> SnapshotManifestResult<()> {
421        if self.schema != SCHEMA {
422            return descriptor_error(format!(
423                "unsupported schema {} (expected {SCHEMA})",
424                self.schema
425            ));
426        }
427        validate_opaque_id(self.snapshot_id.as_str(), "snap_", "snapshot_id")?;
428        if self.image.reference.is_empty() {
429            return descriptor_error("empty image.reference");
430        }
431        validate_digest(
432            &self.image.manifest_digest,
433            "sha256:",
434            "image.manifest_digest",
435        )?;
436        if let Some(parent) = &self.parent {
437            validate_opaque_id(parent.as_str(), "snap_", "parent")?;
438            if parent == &self.snapshot_id {
439                return descriptor_error("snapshot cannot be its own parent");
440            }
441        }
442        normalize_timestamp(&self.capture.created_at)?;
443        match &self.state {
444            SnapshotState::File(file) => {
445                if self.scope != SnapshotScope::Disk {
446                    return descriptor_error("state.kind=file requires scope=file");
447                }
448                if matches!(self.root_disk, SnapshotRootDisk::Tmpfs { .. }) {
449                    return descriptor_error("file snapshots cannot capture a tmpfs root disk");
450                }
451                validate_file_state(file)?;
452            }
453            SnapshotState::Checkpoint(checkpoint) => {
454                if self.scope != SnapshotScope::Full {
455                    return descriptor_error("state.kind=checkpoint requires scope=checkpoint");
456                }
457                if checkpoint.checkpoint_id.is_empty() {
458                    return descriptor_error("empty state.checkpoint_id");
459                }
460                validate_digest(
461                    &checkpoint.checkpoint_root,
462                    "sha256:",
463                    "state.checkpoint_root",
464                )?;
465            }
466        }
467        let mut previous = None;
468        for key in &self.requires {
469            if key.is_empty() || !self.extensions.contains_key(key) {
470                return descriptor_error(format!("invalid required extension '{key}'"));
471            }
472            if previous.is_some_and(|value: &str| value >= key.as_str()) {
473                return descriptor_error("requires must be sorted and unique");
474            }
475            previous = Some(key.as_str());
476        }
477        for value in self.extensions.values() {
478            validate_json_value(value, 0)?;
479        }
480        self.restore_defaults()?;
481        self.owned_volumes()?;
482        Ok(())
483    }
484
485    /// Return unknown must-understand extensions.
486    pub fn unsupported_requires(&self) -> Vec<&str> {
487        self.requires
488            .iter()
489            .map(String::as_str)
490            .filter(|key| !SUPPORTED_REQUIRES.contains(key))
491            .collect()
492    }
493
494    /// Serialize normalized semantics using the schema's bounded RFC 8785 subset.
495    pub fn to_canonical_bytes(&self) -> SnapshotManifestResult<Vec<u8>> {
496        let normalized = self.normalized()?;
497        let value = serde_json::to_value(normalized)
498            .map_err(|error| descriptor_error_value(format!("serialize failed: {error}")))?;
499        let mut output = Vec::new();
500        write_canonical_json(&value, &mut output)?;
501        Ok(output)
502    }
503
504    /// Parse one strict final descriptor.
505    pub fn from_bytes(bytes: &[u8]) -> SnapshotManifestResult<Self> {
506        if bytes.len() > MAX_DESCRIPTOR_BYTES {
507            return descriptor_error(format!("descriptor exceeds {MAX_DESCRIPTOR_BYTES} bytes"));
508        }
509        reject_duplicate_json_keys(bytes)?;
510        let parsed: Self = serde_json::from_slice(bytes)
511            .map_err(|error| descriptor_error_value(format!("parse failed: {error}")))?;
512        parsed.normalized()
513    }
514
515    /// Compute the descriptor digest, distinct from [`SnapshotId`].
516    pub fn digest(&self) -> SnapshotManifestResult<String> {
517        let mut hasher = Sha256::new();
518        hasher.update(self.to_canonical_bytes()?);
519        Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
520    }
521
522    fn normalized(&self) -> SnapshotManifestResult<Self> {
523        let mut normalized = self.clone();
524        normalized.capture.created_at = normalize_timestamp(&normalized.capture.created_at)?;
525        normalized.validate()?;
526        Ok(normalized)
527    }
528}
529
530//--------------------------------------------------------------------------------------------------
531// Trait Implementations
532//--------------------------------------------------------------------------------------------------
533
534impl<'de> Deserialize<'de> for DuplicateCheckedJson {
535    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
536    where
537        D: Deserializer<'de>,
538    {
539        deserializer.deserialize_any(DuplicateCheckedJsonVisitor)
540    }
541}
542
543struct DuplicateCheckedJsonVisitor;
544
545impl<'de> Visitor<'de> for DuplicateCheckedJsonVisitor {
546    type Value = DuplicateCheckedJson;
547    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
548        formatter.write_str("JSON without duplicate object keys")
549    }
550    fn visit_bool<E>(self, _: bool) -> Result<Self::Value, E> {
551        Ok(DuplicateCheckedJson)
552    }
553    fn visit_i64<E>(self, _: i64) -> Result<Self::Value, E> {
554        Ok(DuplicateCheckedJson)
555    }
556    fn visit_u64<E>(self, _: u64) -> Result<Self::Value, E> {
557        Ok(DuplicateCheckedJson)
558    }
559    fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E> {
560        Ok(DuplicateCheckedJson)
561    }
562    fn visit_str<E>(self, _: &str) -> Result<Self::Value, E>
563    where
564        E: serde::de::Error,
565    {
566        Ok(DuplicateCheckedJson)
567    }
568    fn visit_string<E>(self, _: String) -> Result<Self::Value, E> {
569        Ok(DuplicateCheckedJson)
570    }
571    fn visit_none<E>(self) -> Result<Self::Value, E> {
572        Ok(DuplicateCheckedJson)
573    }
574    fn visit_unit<E>(self) -> Result<Self::Value, E> {
575        Ok(DuplicateCheckedJson)
576    }
577    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
578    where
579        A: SeqAccess<'de>,
580    {
581        while sequence.next_element::<DuplicateCheckedJson>()?.is_some() {}
582        Ok(DuplicateCheckedJson)
583    }
584    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
585    where
586        A: MapAccess<'de>,
587    {
588        let mut keys = HashSet::new();
589        while let Some(key) = map.next_key::<String>()? {
590            if !keys.insert(key.clone()) {
591                return Err(A::Error::custom(format!("duplicate object key '{key}'")));
592            }
593            map.next_value::<DuplicateCheckedJson>()?;
594        }
595        Ok(DuplicateCheckedJson)
596    }
597}
598
599//--------------------------------------------------------------------------------------------------
600// Functions
601//--------------------------------------------------------------------------------------------------
602
603/// Return the canonical artifact-relative path for a layer.
604pub fn layer_path(layer_id: &DiskLayerId, format: SnapshotFormat) -> PathBuf {
605    let extension = match format {
606        SnapshotFormat::Raw => "raw",
607        SnapshotFormat::Qcow2 => "qcow2",
608    };
609    Path::new(LAYERS_DIRECTORY).join(format!("{layer_id}.{extension}"))
610}
611
612//--------------------------------------------------------------------------------------------------
613// Functions: Helpers
614//--------------------------------------------------------------------------------------------------
615
616fn descriptor_error<T>(message: impl Into<String>) -> SnapshotManifestResult<T> {
617    Err(descriptor_error_value(message))
618}
619fn descriptor_error_value(message: impl Into<String>) -> SnapshotManifestError {
620    SnapshotManifestError::ManifestParse(format!("snapshot descriptor: {}", message.into()))
621}
622
623fn validate_opaque_id(value: &str, prefix: &str, field: &str) -> SnapshotManifestResult<()> {
624    let Some(encoded) = value.strip_prefix(prefix) else {
625        return descriptor_error(format!("{field} must start with {prefix}: {value}"));
626    };
627    if encoded.len() != 32
628        || !encoded
629            .bytes()
630            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
631    {
632        return descriptor_error(format!(
633            "{field} must contain 32 lowercase hexadecimal digits after {prefix}: {value}"
634        ));
635    }
636    Ok(())
637}
638
639fn validate_file_state(file: &FileSnapshotState) -> SnapshotManifestResult<()> {
640    if file.filesystem.is_empty() {
641        return descriptor_error("empty state.filesystem");
642    }
643    if file.virtual_size > MAX_JSON_SAFE_INTEGER {
644        return descriptor_error("state.virtual_size exceeds the JSON safe-integer limit");
645    }
646    if file.layers.is_empty() || file.layers.len() > MAX_FILE_LAYERS {
647        return descriptor_error(format!(
648            "state.layers must contain 1..={MAX_FILE_LAYERS} entries"
649        ));
650    }
651    let mut ids = HashSet::with_capacity(file.layers.len());
652    for (index, layer) in file.layers.iter().enumerate() {
653        validate_opaque_id(layer.layer_id.as_str(), "layer_", "state.layers[].layer_id")?;
654        if !ids.insert(layer.layer_id.as_str()) {
655            return descriptor_error(format!("duplicate layer id {}", layer.layer_id));
656        }
657        if layer.virtual_size > MAX_JSON_SAFE_INTEGER {
658            return descriptor_error("layer virtual_size exceeds the JSON safe-integer limit");
659        }
660        match (index, layer.format, layer.backing.as_ref()) {
661            (0, _, None) => {}
662            (0, _, Some(_)) => {
663                return descriptor_error("oldest layer must not name a backing layer");
664            }
665            (_, SnapshotFormat::Raw, _) => {
666                return descriptor_error("raw successor layers are not allowed");
667            }
668            (_, SnapshotFormat::Qcow2, Some(backing))
669                if backing == &file.layers[index - 1].layer_id => {}
670            (_, SnapshotFormat::Qcow2, Some(_)) => {
671                return descriptor_error("qcow2 successor must name its immediate predecessor");
672            }
673            (_, SnapshotFormat::Qcow2, None) => {
674                return descriptor_error("qcow2 successor is missing its backing layer");
675            }
676        }
677        if let Some(integrity) = &layer.payload.integrity {
678            validate_integrity(integrity)?;
679        }
680    }
681    let head = file.head_layer()?;
682    if head.format != file.disk_format || head.virtual_size != file.virtual_size {
683        return descriptor_error("head format/size does not match file state");
684    }
685    Ok(())
686}
687
688fn validate_integrity(integrity: &UpperIntegrity) -> SnapshotManifestResult<()> {
689    match integrity {
690        UpperIntegrity::Sha256 { digest } | UpperIntegrity::SparseSha256V1 { digest } => {
691            validate_digest(digest, "sha256:", "layer integrity digest")
692        }
693        UpperIntegrity::FileMerkleBlake3V1 {
694            root,
695            logical_size,
696            leaf_size,
697        } => {
698            validate_digest(root, "blake3:", "layer integrity root")?;
699            // The integrity size describes the payload file. A raw layer commonly has the same
700            // size as the virtual disk, while a qcow2 layer is a compact container whose file
701            // length is deliberately different from the guest-visible capacity.
702            if *logical_size == 0 {
703                return descriptor_error("layer integrity logical_size must be non-zero");
704            }
705            if *leaf_size != FILE_MERKLE_BLAKE3_LEAF_SIZE {
706                return descriptor_error(format!(
707                    "layer integrity leaf_size must be {FILE_MERKLE_BLAKE3_LEAF_SIZE}"
708                ));
709            }
710            Ok(())
711        }
712    }
713}
714
715fn validate_digest(value: &str, prefix: &str, field: &str) -> SnapshotManifestResult<()> {
716    let Some(encoded) = value.strip_prefix(prefix) else {
717        return descriptor_error(format!("{field} must use {prefix}: {value}"));
718    };
719    if encoded.len() != 64
720        || !encoded
721            .bytes()
722            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
723    {
724        return descriptor_error(format!("invalid {field}: {value}"));
725    }
726    Ok(())
727}
728
729fn normalize_timestamp(value: &str) -> SnapshotManifestResult<String> {
730    let parsed = DateTime::parse_from_rfc3339(value).map_err(|error| {
731        descriptor_error_value(format!("capture.created_at is not RFC 3339: {error}"))
732    })?;
733    Ok(parsed
734        .with_timezone(&Utc)
735        .to_rfc3339_opts(SecondsFormat::Nanos, true))
736}
737
738/// Reject duplicate object keys before decoding canonical metadata.
739pub fn reject_duplicate_json_keys(bytes: &[u8]) -> SnapshotManifestResult<()> {
740    let mut deserializer = serde_json::Deserializer::from_slice(bytes);
741    DuplicateCheckedJson::deserialize(&mut deserializer)
742        .map_err(|error| descriptor_error_value(format!("parse failed: {error}")))?;
743    deserializer
744        .end()
745        .map_err(|error| descriptor_error_value(format!("parse failed: {error}")))
746}
747
748fn validate_json_value(value: &serde_json::Value, depth: usize) -> SnapshotManifestResult<()> {
749    if depth > 64 {
750        return descriptor_error("extension nesting exceeds 64 levels");
751    }
752    match value {
753        serde_json::Value::Number(number) => {
754            let valid = number
755                .as_i64()
756                .map(|n| n.unsigned_abs() <= MAX_JSON_SAFE_INTEGER)
757                .or_else(|| number.as_u64().map(|n| n <= MAX_JSON_SAFE_INTEGER))
758                .unwrap_or(false);
759            if !valid {
760                return descriptor_error("extension numbers must be JSON-safe integers");
761            }
762        }
763        serde_json::Value::Array(items) => {
764            if items.len() > 4096 {
765                return descriptor_error("extension array exceeds 4096 entries");
766            }
767            for item in items {
768                validate_json_value(item, depth + 1)?;
769            }
770        }
771        serde_json::Value::Object(map) => {
772            if map.len() > 4096 {
773                return descriptor_error("extension object exceeds 4096 entries");
774            }
775            for item in map.values() {
776                validate_json_value(item, depth + 1)?;
777            }
778        }
779        _ => {}
780    }
781    Ok(())
782}
783
784/// Write the bounded canonical JSON subset used by snapshots and checkpoints.
785pub fn write_canonical_json(
786    value: &serde_json::Value,
787    output: &mut Vec<u8>,
788) -> SnapshotManifestResult<()> {
789    match value {
790        serde_json::Value::Null => output.extend_from_slice(b"null"),
791        serde_json::Value::Bool(true) => output.extend_from_slice(b"true"),
792        serde_json::Value::Bool(false) => output.extend_from_slice(b"false"),
793        serde_json::Value::Number(number) => {
794            output.extend_from_slice(number.to_string().as_bytes())
795        }
796        serde_json::Value::String(string) => serde_json::to_writer(output, string)
797            .map_err(|e| descriptor_error_value(format!("canonical string: {e}")))?,
798        serde_json::Value::Array(items) => {
799            output.push(b'[');
800            for (index, item) in items.iter().enumerate() {
801                if index != 0 {
802                    output.push(b',');
803                }
804                write_canonical_json(item, output)?;
805            }
806            output.push(b']');
807        }
808        serde_json::Value::Object(map) => {
809            output.push(b'{');
810            let mut entries: Vec<_> = map.iter().collect();
811            // RFC 8785 orders object names by their UTF-16 code units, not
812            // Rust's UTF-8 byte/string ordering. The distinction matters for
813            // non-BMP extension keys and therefore for descriptor identity.
814            entries
815                .sort_unstable_by(|left, right| left.0.encode_utf16().cmp(right.0.encode_utf16()));
816            for (index, (key, item)) in entries.into_iter().enumerate() {
817                if index != 0 {
818                    output.push(b',');
819                }
820                serde_json::to_writer(&mut *output, key)
821                    .map_err(|e| descriptor_error_value(format!("canonical key: {e}")))?;
822                output.push(b':');
823                write_canonical_json(item, output)?;
824            }
825            output.push(b'}');
826        }
827    }
828    Ok(())
829}
830
831fn deserialize_required_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
832where
833    D: Deserializer<'de>,
834    T: Deserialize<'de>,
835{
836    Option::<T>::deserialize(deserializer)
837}
838
839//--------------------------------------------------------------------------------------------------
840// Tests
841//--------------------------------------------------------------------------------------------------
842
843#[cfg(test)]
844mod tests {
845    use super::*;
846
847    fn descriptor() -> Manifest {
848        let layer_id = DiskLayerId::new("layer_0123456789abcdef0123456789abcdef").unwrap();
849        Manifest {
850            schema: SCHEMA.into(),
851            snapshot_id: SnapshotId::new("snap_0123456789abcdef0123456789abcdef").unwrap(),
852            scope: SnapshotScope::Disk,
853            state: SnapshotState::File(FileSnapshotState {
854                disk_format: SnapshotFormat::Raw,
855                filesystem: "ext4".into(),
856                virtual_size: 4,
857                head: layer_id.clone(),
858                layers: vec![DiskLayer {
859                    layer_id,
860                    format: SnapshotFormat::Raw,
861                    virtual_size: 4,
862                    backing: None,
863                    payload: LayerPayload {
864                        file_kind: LayerFileKind::Regular,
865                        integrity: None,
866                    },
867                }],
868            }),
869            capture: SnapshotCapture {
870                created_at: "2026-08-29T00:00:00Z".into(),
871                source_lineage: Some("sandbox-a".into()),
872                source_checkpoint: None,
873                consistency: SnapshotConsistency::CrashConsistent,
874            },
875            image: ImageRef {
876                reference: "docker.io/library/alpine:latest".into(),
877                manifest_digest: format!("sha256:{}", "a".repeat(64)),
878            },
879            root_disk: SnapshotRootDisk::Managed,
880            parent: None,
881            requires: Vec::new(),
882            extensions: BTreeMap::new(),
883        }
884    }
885
886    #[test]
887    fn restore_defaults_are_required_bounded_and_round_trip() {
888        let mut manifest = descriptor();
889        let original = manifest.to_canonical_bytes().unwrap();
890        manifest
891            .set_restore_defaults(super::super::RestoreDefaults::default())
892            .unwrap();
893        assert_eq!(manifest.to_canonical_bytes().unwrap(), original);
894        let defaults = super::super::RestoreDefaults {
895            user: Some("0:0".into()),
896        };
897        manifest.set_restore_defaults(defaults.clone()).unwrap();
898        assert!(
899            manifest
900                .requires
901                .iter()
902                .any(|key| key == super::super::RESTORE_DEFAULTS_EXTENSION)
903        );
904        assert!(manifest.unsupported_requires().is_empty());
905        let restored = Manifest::from_bytes(&manifest.to_canonical_bytes().unwrap()).unwrap();
906        assert_eq!(restored.restore_defaults().unwrap(), defaults);
907        manifest.extensions.insert(
908            super::super::RESTORE_DEFAULTS_EXTENSION.into(),
909            serde_json::json!({"user":""}),
910        );
911        assert!(manifest.validate().is_err());
912    }
913
914    #[test]
915    fn owned_inventory_is_required_but_empty_inventory_keeps_released_bytes() {
916        use super::super::{
917            OWNED_VOLUMES_EXTENSION, OwnedDirectoryPayload, OwnedMountSnapshot, OwnedVolumeCapture,
918            OwnedVolumeData,
919        };
920        let mut manifest = descriptor();
921        let original = manifest.to_canonical_bytes().unwrap();
922        manifest.set_owned_volumes(Vec::new()).unwrap();
923        assert_eq!(manifest.to_canonical_bytes().unwrap(), original);
924        let mount = crate::VolumeMount::Owned {
925            guest: "/cache".into(),
926            storage: crate::OwnedVolumeStorage::Directory { quota_mib: None },
927            options: Default::default(),
928            stat_virtualization: crate::StatVirtualization::Strict,
929            host_permissions: crate::HostPermissions::Private,
930        };
931        manifest
932            .set_owned_volumes(vec![OwnedVolumeCapture {
933                mount_id: crate::owned_volume_mount_id(mount.guest()),
934                mount: OwnedMountSnapshot::from_mount(&mount).unwrap(),
935                data: OwnedVolumeData::Directory {
936                    descriptor: OwnedDirectoryPayload {
937                        digest: "a".repeat(64),
938                        bytes: 20,
939                    },
940                    files: Vec::new(),
941                },
942            }])
943            .unwrap();
944        assert!(
945            manifest
946                .requires
947                .iter()
948                .any(|key| key == OWNED_VOLUMES_EXTENSION)
949        );
950        assert!(manifest.unsupported_requires().is_empty());
951        let restored = Manifest::from_bytes(&manifest.to_canonical_bytes().unwrap()).unwrap();
952        assert_eq!(
953            restored.owned_volumes().unwrap(),
954            manifest.owned_volumes().unwrap()
955        );
956        manifest.requires.clear();
957        assert!(
958            manifest.validate().is_err(),
959            "ownership cannot be advisory extension data"
960        );
961    }
962
963    #[test]
964    fn canonical_descriptor_round_trips() {
965        let descriptor = descriptor();
966        let bytes = descriptor.to_canonical_bytes().unwrap();
967        assert_eq!(
968            Manifest::from_bytes(&bytes)
969                .unwrap()
970                .to_canonical_bytes()
971                .unwrap(),
972            bytes
973        );
974        assert!(
975            std::str::from_utf8(&bytes)
976                .unwrap()
977                .starts_with("{\"capture\":")
978        );
979    }
980
981    #[test]
982    fn descriptor_digest_is_not_snapshot_id() {
983        let descriptor = descriptor();
984        assert_ne!(
985            descriptor.digest().unwrap(),
986            descriptor.snapshot_id.as_str()
987        );
988    }
989
990    #[test]
991    fn earlier_schema_one_descriptor_defaults_to_managed_root() {
992        let mut value = serde_json::to_value(descriptor()).unwrap();
993        value.as_object_mut().unwrap().remove("root_disk");
994        let parsed = Manifest::from_bytes(&serde_json::to_vec(&value).unwrap()).unwrap();
995        assert_eq!(parsed.root_disk, SnapshotRootDisk::Managed);
996    }
997
998    #[test]
999    fn file_snapshot_rejects_tmpfs_root_layout() {
1000        let mut descriptor = descriptor();
1001        descriptor.root_disk = SnapshotRootDisk::Tmpfs {
1002            size_mib: Some(128),
1003        };
1004        assert!(descriptor.validate().is_err());
1005    }
1006
1007    #[test]
1008    fn rejects_nonlinear_closure() {
1009        let mut descriptor = descriptor();
1010        let SnapshotState::File(file) = &mut descriptor.state else {
1011            unreachable!()
1012        };
1013        file.layers.push(DiskLayer {
1014            layer_id: DiskLayerId::new("layer_11111111111111111111111111111111").unwrap(),
1015            format: SnapshotFormat::Qcow2,
1016            virtual_size: 4,
1017            backing: None,
1018            payload: LayerPayload {
1019                file_kind: LayerFileKind::Regular,
1020                integrity: None,
1021            },
1022        });
1023        file.head = file.layers[1].layer_id.clone();
1024        file.disk_format = SnapshotFormat::Qcow2;
1025        assert!(descriptor.validate().is_err());
1026    }
1027
1028    #[test]
1029    fn qcow_integrity_size_describes_payload_not_virtual_disk() {
1030        let mut descriptor = descriptor();
1031        let SnapshotState::File(file) = &mut descriptor.state else {
1032            unreachable!()
1033        };
1034        let predecessor = file.layers[0].layer_id.clone();
1035        let head = DiskLayerId::new("layer_11111111111111111111111111111111").unwrap();
1036        file.layers.push(DiskLayer {
1037            layer_id: head.clone(),
1038            format: SnapshotFormat::Qcow2,
1039            virtual_size: 4,
1040            backing: Some(predecessor),
1041            payload: LayerPayload {
1042                file_kind: LayerFileKind::Regular,
1043                integrity: Some(UpperIntegrity::FileMerkleBlake3V1 {
1044                    root: format!("blake3:{}", "b".repeat(64)),
1045                    logical_size: 1024,
1046                    leaf_size: FILE_MERKLE_BLAKE3_LEAF_SIZE,
1047                }),
1048            },
1049        });
1050        file.head = head;
1051        file.disk_format = SnapshotFormat::Qcow2;
1052
1053        descriptor.validate().unwrap();
1054    }
1055}