Skip to main content

microsandbox_types/snapshot/
cloud_manifest.rs

1//! Snapshot descriptor schema and canonical (de)serialization.
2//!
3//! The descriptor (`snapshot.json`) is the source of truth for a snapshot
4//! artifact. Its SHA-256 digest over the normalized canonical byte form is the
5//! snapshot's identity. Canonical form has no insignificant whitespace, keeps
6//! struct fields in declaration order, recursively sorts map keys, and never
7//! elides required fields.
8
9use std::collections::{BTreeMap, HashSet};
10use std::fmt;
11use std::path::{Component, Path};
12
13use chrono::{DateTime, SecondsFormat, Utc};
14use serde::de::{Error as _, MapAccess, SeqAccess, Visitor};
15use serde::{Deserialize, Deserializer, Serialize};
16use sha2::{Digest as _, Sha256};
17
18use crate::error::{SnapshotManifestError, SnapshotManifestResult};
19
20//--------------------------------------------------------------------------------------------------
21// Constants
22//--------------------------------------------------------------------------------------------------
23
24/// Current snapshot descriptor schema version.
25pub const SCHEMA_VERSION: u32 = 1;
26
27/// Canonical filename for the descriptor inside an artifact directory.
28pub const DESCRIPTOR_FILENAME: &str = "snapshot.json";
29
30/// Expected artifact kind for snapshot descriptors.
31pub const SNAPSHOT_ARTIFACT_KIND: &str = "snapshot";
32
33/// Default filename for a raw file-state upper layer.
34pub const DEFAULT_UPPER_FILE: &str = "upper.ext4";
35
36/// Semantic sparse-file digest for raw upper files.
37pub const SPARSE_SHA256_V1: &str = "msb-sparse-sha256-v1";
38
39/// Sparse-aware Merkle integrity for current file snapshot payloads.
40pub const FILE_MERKLE_BLAKE3_V1: &str = "msb-file-merkle-blake3-v1";
41
42/// Fixed leaf size defined by [`FILE_MERKLE_BLAKE3_V1`].
43pub const FILE_MERKLE_BLAKE3_LEAF_SIZE: u32 = 64 * 1024;
44
45/// Largest integer that all public JSON consumers can represent exactly.
46pub const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
47
48/// Extension keys understood by this runtime.
49pub const SUPPORTED_REQUIRES: &[&str] = &[];
50
51/// Filenames reserved for descriptor publication and migration bookkeeping.
52const RESERVED_ARTIFACT_FILENAMES: &[&str] = &[
53    DESCRIPTOR_FILENAME,
54    "manifest.json",
55    ".manifest.json.legacy",
56    ".snapshot-migration.lock",
57];
58
59//--------------------------------------------------------------------------------------------------
60// Types
61//--------------------------------------------------------------------------------------------------
62
63/// On-disk format of a file-state upper layer.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
66#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
67#[serde(rename_all = "lowercase")]
68pub enum SnapshotFormat {
69    /// Raw disk image.
70    Raw,
71    /// Qcow2 image. Restore remains capability-gated until its full chain
72    /// contract is implemented.
73    Qcow2,
74}
75
76/// Snapshot payload scope.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
79#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
80#[serde(rename_all = "lowercase")]
81pub enum SnapshotScope {
82    /// Disk-only state.
83    Disk,
84    /// Disk, memory, and device state that can resume execution.
85    Resumable,
86}
87
88/// Reference to the pinned OCI image used by the snapshot.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
91#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
92#[serde(deny_unknown_fields)]
93pub struct ImageRef {
94    /// Human-readable image reference.
95    #[serde(rename = "ref")]
96    pub reference: String,
97    /// Pinned OCI manifest digest.
98    pub manifest_digest: String,
99}
100
101/// Captured file-state upper-layer metadata.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
104#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
105#[serde(deny_unknown_fields)]
106pub struct UpperLayer {
107    /// One normal filename relative to the artifact directory.
108    pub file: String,
109    /// Apparent file size, including sparse holes.
110    pub size_bytes: u64,
111    /// Optional semantic payload integrity. The field itself is required so
112    /// readers distinguish an intentional `null` from a malformed descriptor.
113    #[serde(deserialize_with = "deserialize_required_option")]
114    pub integrity: Option<UpperIntegrity>,
115}
116
117/// Content integrity descriptor for a file-state upper layer.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
120#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
121#[serde(tag = "algorithm", deny_unknown_fields)]
122pub enum UpperIntegrity {
123    /// Ordinary SHA-256 retained only for exact legacy compatibility and
124    /// archive metadata. New file snapshots never emit this variant.
125    #[serde(rename = "sha256")]
126    Sha256 {
127        /// Algorithm output in qualified digest form.
128        digest: String,
129    },
130    /// Released logical-byte sparse SHA-256 representation.
131    #[serde(rename = "msb-sparse-sha256-v1")]
132    SparseSha256V1 {
133        /// Algorithm output in qualified digest form.
134        digest: String,
135    },
136    /// Current sparse-aware fixed-leaf BLAKE3 Merkle representation.
137    #[serde(rename = "msb-file-merkle-blake3-v1")]
138    FileMerkleBlake3V1 {
139        /// Domain-separated Merkle root in qualified digest form.
140        root: String,
141        /// Exact logical file length bound into the final root.
142        logical_size: u64,
143        /// Fixed leaf size. Exactly [`FILE_MERKLE_BLAKE3_LEAF_SIZE`].
144        leaf_size: u32,
145    },
146}
147
148/// Concrete file-backed snapshot state.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
151#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
152#[serde(deny_unknown_fields)]
153pub struct FileSnapshotState {
154    /// On-disk payload format.
155    pub format: SnapshotFormat,
156    /// Filesystem type inside the payload.
157    pub fstype: String,
158    /// File-backed upper-layer binding.
159    pub upper: UpperLayer,
160}
161
162/// Immutable checkpoint-manifest-backed snapshot state.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
165#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
166#[serde(deny_unknown_fields)]
167pub struct CheckpointSnapshotState {
168    /// Stable identifier for the captured cut.
169    pub checkpoint_id: String,
170    /// SHA-256 identity of the disk or composite checkpoint manifest.
171    pub manifest: String,
172}
173
174/// Closed snapshot state family.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
177#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
178#[serde(tag = "kind", rename_all = "lowercase")]
179pub enum SnapshotState {
180    /// Concrete file-backed disk state.
181    File(FileSnapshotState),
182    /// Manifest-backed disk or resumable state.
183    Checkpoint(CheckpointSnapshotState),
184}
185
186/// Final schema-1 snapshot descriptor.
187///
188/// Field order is identity-bearing. Do not reorder these fields.
189///
190/// Generated bindings and API schemas expose this type as `SnapshotManifest`.
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema), schema(as = SnapshotManifest))]
193#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(rename = "SnapshotManifest"))]
194#[serde(deny_unknown_fields)]
195pub struct Manifest {
196    /// Schema version. Exactly [`SCHEMA_VERSION`].
197    pub schema: u32,
198    /// Artifact kind. Exactly [`SNAPSHOT_ARTIFACT_KIND`].
199    pub artifact: String,
200    /// Snapshot payload scope.
201    pub scope: SnapshotScope,
202    /// Normalized RFC 3339 creation timestamp.
203    pub created_at: String,
204    /// Exact snapshot identity of the logical lineage parent.
205    #[serde(deserialize_with = "deserialize_required_option")]
206    pub parent: Option<String>,
207    /// Pinned base image.
208    pub image: ImageRef,
209    /// Informational source-sandbox name.
210    #[serde(deserialize_with = "deserialize_required_option")]
211    pub source_sandbox: Option<String>,
212    /// Closed file/checkpoint state variant.
213    pub state: SnapshotState,
214    /// User-supplied labels, sorted by key in canonical form.
215    pub labels: BTreeMap<String, String>,
216    /// Namespaced additive extension values.
217    #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
218    #[cfg_attr(feature = "ts", ts(type = "{ [key in string]: unknown }"))]
219    pub extensions: BTreeMap<String, serde_json::Value>,
220    /// Sorted unique must-understand extension keys.
221    pub requires: Vec<String>,
222}
223
224/// Descriptive alias for callers that prefer descriptor terminology.
225pub type SnapshotDescriptor = Manifest;
226
227/// JSON visitor used only to reject duplicate object keys at every nesting
228/// level before ordinary typed decoding occurs.
229struct DuplicateCheckedJson;
230
231//--------------------------------------------------------------------------------------------------
232// Methods
233//--------------------------------------------------------------------------------------------------
234
235impl SnapshotState {
236    /// Return the stable state discriminant used by index and SDK projections.
237    pub const fn kind(&self) -> &'static str {
238        match self {
239            Self::File(_) => "file",
240            Self::Checkpoint(_) => "checkpoint",
241        }
242    }
243
244    /// Return file state when this descriptor is file-backed.
245    pub const fn as_file(&self) -> Option<&FileSnapshotState> {
246        match self {
247            Self::File(state) => Some(state),
248            Self::Checkpoint(_) => None,
249        }
250    }
251
252    /// Return checkpoint state when this descriptor is manifest-backed.
253    pub const fn as_checkpoint(&self) -> Option<&CheckpointSnapshotState> {
254        match self {
255            Self::File(_) => None,
256            Self::Checkpoint(state) => Some(state),
257        }
258    }
259}
260
261impl UpperIntegrity {
262    /// Return the stable algorithm identifier serialized in the descriptor.
263    pub const fn algorithm(&self) -> &'static str {
264        match self {
265            Self::Sha256 { .. } => "sha256",
266            Self::SparseSha256V1 { .. } => SPARSE_SHA256_V1,
267            Self::FileMerkleBlake3V1 { .. } => FILE_MERKLE_BLAKE3_V1,
268        }
269    }
270
271    /// Return the qualified digest or root used by SDK projections.
272    pub fn value(&self) -> &str {
273        match self {
274            Self::Sha256 { digest } | Self::SparseSha256V1 { digest } => digest,
275            Self::FileMerkleBlake3V1 { root, .. } => root,
276        }
277    }
278}
279
280impl Manifest {
281    /// Validate descriptor invariants.
282    pub fn validate(&self) -> SnapshotManifestResult<()> {
283        if self.schema != SCHEMA_VERSION {
284            return descriptor_error(format!(
285                "unsupported schema version {} (expected {})",
286                self.schema, SCHEMA_VERSION
287            ));
288        }
289        if self.artifact != SNAPSHOT_ARTIFACT_KIND {
290            return descriptor_error(format!(
291                "unsupported artifact kind {} (expected {})",
292                self.artifact, SNAPSHOT_ARTIFACT_KIND
293            ));
294        }
295        if self.image.reference.is_empty() {
296            return descriptor_error("empty image.ref");
297        }
298        validate_sha256_digest(&self.image.manifest_digest, "image.manifest_digest")?;
299        if let Some(parent) = self.parent.as_deref() {
300            validate_sha256_digest(parent, "parent")?;
301        }
302        normalize_timestamp(&self.created_at)?;
303
304        match &self.state {
305            SnapshotState::File(file) => {
306                if self.scope != SnapshotScope::Disk {
307                    return descriptor_error("state.kind=file requires scope=disk");
308                }
309                if file.fstype.is_empty() {
310                    return descriptor_error("empty state.fstype");
311                }
312                validate_artifact_filename(&file.upper.file, "state.upper.file")?;
313                if file.upper.size_bytes > MAX_JSON_SAFE_INTEGER {
314                    return descriptor_error(format!(
315                        "state.upper.size_bytes exceeds JSON safe-integer limit: {}",
316                        file.upper.size_bytes
317                    ));
318                }
319                if let Some(integrity) = &file.upper.integrity {
320                    match integrity {
321                        UpperIntegrity::Sha256 { digest }
322                        | UpperIntegrity::SparseSha256V1 { digest } => {
323                            validate_sha256_digest(digest, "state.upper.integrity.digest")?;
324                        }
325                        UpperIntegrity::FileMerkleBlake3V1 {
326                            root,
327                            logical_size,
328                            leaf_size,
329                        } => {
330                            validate_blake3_digest(root, "state.upper.integrity.root")?;
331                            if *logical_size != file.upper.size_bytes {
332                                return descriptor_error(format!(
333                                    "state.upper.integrity.logical_size {} does not match state.upper.size_bytes {}",
334                                    logical_size, file.upper.size_bytes
335                                ));
336                            }
337                            if *leaf_size != FILE_MERKLE_BLAKE3_LEAF_SIZE {
338                                return descriptor_error(format!(
339                                    "state.upper.integrity.leaf_size must be {}: {}",
340                                    FILE_MERKLE_BLAKE3_LEAF_SIZE, leaf_size
341                                ));
342                            }
343                        }
344                    }
345                }
346            }
347            SnapshotState::Checkpoint(checkpoint) => {
348                if checkpoint.checkpoint_id.is_empty() {
349                    return descriptor_error("empty state.checkpoint_id");
350                }
351                validate_sha256_digest(&checkpoint.manifest, "state.manifest")?;
352            }
353        }
354
355        let mut previous: Option<&str> = None;
356        for key in &self.requires {
357            if key.is_empty() {
358                return descriptor_error("empty requires entry");
359            }
360            if !self.extensions.contains_key(key) {
361                return descriptor_error(format!(
362                    "requires names '{key}' but extensions has no such key"
363                ));
364            }
365            if previous.is_some_and(|value| value >= key.as_str()) {
366                return descriptor_error(format!(
367                    "requires must be sorted and unique (at '{key}')"
368                ));
369            }
370            previous = Some(key);
371        }
372
373        Ok(())
374    }
375
376    /// Return unknown must-understand extension keys.
377    pub fn unsupported_requires(&self) -> Vec<&str> {
378        self.requires
379            .iter()
380            .map(String::as_str)
381            .filter(|key| !SUPPORTED_REQUIRES.contains(key))
382            .collect()
383    }
384
385    /// Serialize the normalized semantic value to canonical identity bytes.
386    pub fn to_canonical_bytes(&self) -> SnapshotManifestResult<Vec<u8>> {
387        let normalized = self.normalized()?;
388        serde_json::to_vec(&normalized).map_err(|error| {
389            SnapshotManifestError::ManifestParse(format!(
390                "snapshot descriptor: serialize failed: {error}"
391            ))
392        })
393    }
394
395    /// Parse, normalize, and validate one strict schema-1 descriptor.
396    pub fn from_bytes(bytes: &[u8]) -> SnapshotManifestResult<Self> {
397        reject_duplicate_json_keys(bytes)?;
398        let parsed: Self = serde_json::from_slice(bytes).map_err(|error| {
399            SnapshotManifestError::ManifestParse(format!(
400                "snapshot descriptor: parse failed: {error}"
401            ))
402        })?;
403        parsed.normalized()
404    }
405
406    /// Compute the snapshot identity over normalized canonical bytes.
407    pub fn digest(&self) -> SnapshotManifestResult<String> {
408        let mut hasher = Sha256::new();
409        hasher.update(self.to_canonical_bytes()?);
410        Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
411    }
412
413    fn normalized(&self) -> SnapshotManifestResult<Self> {
414        let mut normalized = self.clone();
415        normalized.created_at = normalize_timestamp(&normalized.created_at)?;
416        for value in normalized.extensions.values_mut() {
417            normalize_json_value(value);
418        }
419        normalized.validate()?;
420        Ok(normalized)
421    }
422}
423
424//--------------------------------------------------------------------------------------------------
425// Trait Implementations
426//--------------------------------------------------------------------------------------------------
427
428impl<'de> Deserialize<'de> for DuplicateCheckedJson {
429    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
430    where
431        D: Deserializer<'de>,
432    {
433        deserializer.deserialize_any(DuplicateCheckedJsonVisitor)
434    }
435}
436
437struct DuplicateCheckedJsonVisitor;
438
439impl<'de> Visitor<'de> for DuplicateCheckedJsonVisitor {
440    type Value = DuplicateCheckedJson;
441
442    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
443        formatter.write_str("a JSON value without duplicate object keys")
444    }
445
446    fn visit_bool<E>(self, _value: bool) -> Result<Self::Value, E> {
447        Ok(DuplicateCheckedJson)
448    }
449
450    fn visit_i64<E>(self, _value: i64) -> Result<Self::Value, E> {
451        Ok(DuplicateCheckedJson)
452    }
453
454    fn visit_u64<E>(self, _value: u64) -> Result<Self::Value, E> {
455        Ok(DuplicateCheckedJson)
456    }
457
458    fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E> {
459        Ok(DuplicateCheckedJson)
460    }
461
462    fn visit_str<E>(self, _value: &str) -> Result<Self::Value, E>
463    where
464        E: serde::de::Error,
465    {
466        Ok(DuplicateCheckedJson)
467    }
468
469    fn visit_string<E>(self, _value: String) -> Result<Self::Value, E> {
470        Ok(DuplicateCheckedJson)
471    }
472
473    fn visit_none<E>(self) -> Result<Self::Value, E> {
474        Ok(DuplicateCheckedJson)
475    }
476
477    fn visit_unit<E>(self) -> Result<Self::Value, E> {
478        Ok(DuplicateCheckedJson)
479    }
480
481    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
482    where
483        A: SeqAccess<'de>,
484    {
485        while sequence.next_element::<DuplicateCheckedJson>()?.is_some() {}
486        Ok(DuplicateCheckedJson)
487    }
488
489    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
490    where
491        A: MapAccess<'de>,
492    {
493        let mut keys = HashSet::new();
494        while let Some(key) = map.next_key::<String>()? {
495            if !keys.insert(key.clone()) {
496                return Err(A::Error::custom(format!("duplicate object key '{key}'")));
497            }
498            map.next_value::<DuplicateCheckedJson>()?;
499        }
500        Ok(DuplicateCheckedJson)
501    }
502}
503
504//--------------------------------------------------------------------------------------------------
505// Functions: Helpers
506//--------------------------------------------------------------------------------------------------
507
508fn descriptor_error<T>(message: impl Into<String>) -> SnapshotManifestResult<T> {
509    Err(SnapshotManifestError::ManifestParse(format!(
510        "snapshot descriptor: {}",
511        message.into()
512    )))
513}
514
515fn validate_sha256_digest(value: &str, field: &str) -> SnapshotManifestResult<()> {
516    let Some(encoded) = value.strip_prefix("sha256:") else {
517        return descriptor_error(format!("{field} must use sha256: {value}"));
518    };
519    if encoded.len() != 64
520        || !encoded
521            .bytes()
522            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
523    {
524        return descriptor_error(format!(
525            "{field} must contain 64 lowercase hexadecimal digits: {value}"
526        ));
527    }
528    Ok(())
529}
530
531fn validate_blake3_digest(value: &str, field: &str) -> SnapshotManifestResult<()> {
532    let Some(encoded) = value.strip_prefix("blake3:") else {
533        return descriptor_error(format!("{field} must use blake3: {value}"));
534    };
535    if encoded.len() != 64
536        || !encoded
537            .bytes()
538            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
539    {
540        return descriptor_error(format!(
541            "{field} must contain 64 lowercase hexadecimal digits: {value}"
542        ));
543    }
544    Ok(())
545}
546
547fn validate_artifact_filename(value: &str, field: &str) -> SnapshotManifestResult<()> {
548    let mut components = Path::new(value).components();
549    let Some(Component::Normal(name)) = components.next() else {
550        return descriptor_error(format!(
551            "{field} must be one relative normal filename: {value}"
552        ));
553    };
554    if name.is_empty() || components.next().is_some() {
555        return descriptor_error(format!(
556            "{field} must be one relative normal filename: {value}"
557        ));
558    }
559    // Payloads sharing a name with artifact metadata can overwrite the
560    // descriptor or disrupt the adjacent-release migration journal.
561    if RESERVED_ARTIFACT_FILENAMES.contains(&value) {
562        return descriptor_error(format!("{field} uses reserved filename: {value}"));
563    }
564    Ok(())
565}
566
567fn normalize_timestamp(value: &str) -> SnapshotManifestResult<String> {
568    let parsed = DateTime::parse_from_rfc3339(value)
569        .map_err(|error| {
570            SnapshotManifestError::ManifestParse(format!(
571                "snapshot descriptor: created_at is not RFC 3339: {error}"
572            ))
573        })?
574        .with_timezone(&Utc);
575    let mut normalized = parsed.to_rfc3339_opts(SecondsFormat::Nanos, true);
576    if let Some(dot) = normalized.find('.') {
577        let z = normalized.len() - 1;
578        let trimmed = normalized[dot + 1..z].trim_end_matches('0');
579        normalized = if trimmed.is_empty() {
580            format!("{}Z", &normalized[..dot])
581        } else {
582            format!("{}.{}Z", &normalized[..dot], trimmed)
583        };
584    }
585    Ok(normalized)
586}
587
588fn normalize_json_value(value: &mut serde_json::Value) {
589    match value {
590        serde_json::Value::Array(values) => {
591            for value in values {
592                normalize_json_value(value);
593            }
594        }
595        serde_json::Value::Object(object) => {
596            let old = std::mem::take(object);
597            let mut sorted = BTreeMap::new();
598            for (key, mut value) in old {
599                normalize_json_value(&mut value);
600                sorted.insert(key, value);
601            }
602            object.extend(sorted);
603        }
604        _ => {}
605    }
606}
607
608fn reject_duplicate_json_keys(bytes: &[u8]) -> SnapshotManifestResult<()> {
609    let mut deserializer = serde_json::Deserializer::from_slice(bytes);
610    DuplicateCheckedJson::deserialize(&mut deserializer).map_err(|error| {
611        SnapshotManifestError::ManifestParse(format!("snapshot descriptor: parse failed: {error}"))
612    })?;
613    deserializer.end().map_err(|error| {
614        SnapshotManifestError::ManifestParse(format!("snapshot descriptor: parse failed: {error}"))
615    })
616}
617
618fn deserialize_required_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
619where
620    D: Deserializer<'de>,
621    T: Deserialize<'de>,
622{
623    Option::<T>::deserialize(deserializer)
624}
625
626//--------------------------------------------------------------------------------------------------
627// Tests
628//--------------------------------------------------------------------------------------------------
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    fn sample_manifest() -> Manifest {
635        Manifest {
636            schema: SCHEMA_VERSION,
637            artifact: SNAPSHOT_ARTIFACT_KIND.into(),
638            scope: SnapshotScope::Disk,
639            created_at: "2026-05-01T12:00:00Z".into(),
640            parent: None,
641            image: ImageRef {
642                reference: "docker.io/library/python:3.12".into(),
643                manifest_digest:
644                    "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
645                        .into(),
646            },
647            source_sandbox: Some("build-1".into()),
648            state: SnapshotState::File(FileSnapshotState {
649                format: SnapshotFormat::Raw,
650                fstype: "ext4".into(),
651                upper: UpperLayer {
652                    file: DEFAULT_UPPER_FILE.into(),
653                    size_bytes: 4_294_967_296,
654                    integrity: Some(UpperIntegrity::SparseSha256V1 {
655                        digest:
656                            "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
657                                .into(),
658                    }),
659                },
660            }),
661            labels: BTreeMap::from([
662                ("owner".into(), "alice".into()),
663                ("stage".into(), "post-pip-install".into()),
664            ]),
665            extensions: BTreeMap::new(),
666            requires: Vec::new(),
667        }
668    }
669
670    #[test]
671    fn final_file_descriptor_matches_golden_bytes_and_digest() {
672        let manifest = sample_manifest();
673        let expected = r#"{"schema":1,"artifact":"snapshot","scope":"disk","created_at":"2026-05-01T12:00:00Z","parent":null,"image":{"ref":"docker.io/library/python:3.12","manifest_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"source_sandbox":"build-1","state":{"kind":"file","format":"raw","fstype":"ext4","upper":{"file":"upper.ext4","size_bytes":4294967296,"integrity":{"algorithm":"msb-sparse-sha256-v1","digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}},"labels":{"owner":"alice","stage":"post-pip-install"},"extensions":{},"requires":[]}"#;
674        assert_eq!(manifest.to_canonical_bytes().unwrap(), expected.as_bytes());
675        assert_eq!(
676            manifest.digest().unwrap(),
677            "sha256:5b9ca7611f40ec61fea70c1b1ac9881ed63a16091922682028222cdaef997572"
678        );
679    }
680
681    #[test]
682    fn semantic_normalization_preserves_identity() {
683        let canonical = sample_manifest();
684        let reordered = br#"{
685          "requires": [], "extensions": {}, "labels": {"stage":"post-pip-install","owner":"alice"},
686          "state": {"upper":{"integrity":{"digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","algorithm":"msb-sparse-sha256-v1"},"size_bytes":4294967296,"file":"upper.ext4"},"fstype":"ext4","format":"raw","kind":"file"},
687          "source_sandbox":"build-1", "image":{"manifest_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","ref":"docker.io/library/python:3.12"},
688          "parent":null,"created_at":"2026-05-01T13:00:00+01:00","scope":"disk","artifact":"snapshot","schema":1
689        }"#;
690        let parsed = Manifest::from_bytes(reordered).unwrap();
691        assert_eq!(parsed.digest().unwrap(), canonical.digest().unwrap());
692        assert_eq!(parsed.created_at, "2026-05-01T12:00:00Z");
693    }
694
695    #[test]
696    fn checkpoint_variant_round_trips() {
697        let mut manifest = sample_manifest();
698        manifest.scope = SnapshotScope::Resumable;
699        manifest.state = SnapshotState::Checkpoint(CheckpointSnapshotState {
700            checkpoint_id: "ckpt_example".into(),
701            manifest: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
702                .into(),
703        });
704        let bytes = manifest.to_canonical_bytes().unwrap();
705        assert!(std::str::from_utf8(&bytes).unwrap().contains(
706            r#""state":{"kind":"checkpoint","checkpoint_id":"ckpt_example","manifest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}"#
707        ));
708        assert_eq!(Manifest::from_bytes(&bytes).unwrap(), manifest);
709    }
710
711    #[test]
712    fn rejects_file_state_without_integrity() {
713        let bytes = sample_manifest().to_canonical_bytes().unwrap();
714        let value = String::from_utf8(bytes)
715            .unwrap()
716            .replace(
717                r#","integrity":{"algorithm":"msb-sparse-sha256-v1","digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}"#,
718                "",
719            );
720        let error = Manifest::from_bytes(value.as_bytes()).unwrap_err();
721        assert!(error.to_string().contains("integrity"));
722    }
723
724    #[test]
725    fn accepts_explicitly_unrecorded_integrity() {
726        let mut manifest = sample_manifest();
727        let file = manifest.state.as_file().unwrap().clone();
728        manifest.state = SnapshotState::File(FileSnapshotState {
729            upper: UpperLayer {
730                integrity: None,
731                ..file.upper
732            },
733            ..file
734        });
735
736        let bytes = manifest.to_canonical_bytes().unwrap();
737        assert!(
738            std::str::from_utf8(&bytes)
739                .unwrap()
740                .contains(r#""integrity":null"#)
741        );
742        assert_eq!(Manifest::from_bytes(&bytes).unwrap(), manifest);
743    }
744
745    #[test]
746    fn validates_current_merkle_shape() {
747        let mut manifest = sample_manifest();
748        let file = manifest.state.as_file().unwrap().clone();
749        manifest.state = SnapshotState::File(FileSnapshotState {
750            upper: UpperLayer {
751                integrity: Some(UpperIntegrity::FileMerkleBlake3V1 {
752                    root: "blake3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
753                        .into(),
754                    logical_size: file.upper.size_bytes,
755                    leaf_size: FILE_MERKLE_BLAKE3_LEAF_SIZE,
756                }),
757                ..file.upper
758            },
759            ..file
760        });
761
762        assert!(manifest.to_canonical_bytes().is_ok());
763    }
764
765    #[test]
766    fn rejects_file_state_for_resumable_scope() {
767        let mut manifest = sample_manifest();
768        manifest.scope = SnapshotScope::Resumable;
769        let error = manifest.to_canonical_bytes().unwrap_err();
770        assert!(error.to_string().contains("requires scope=disk"));
771    }
772
773    #[test]
774    fn rejects_duplicate_keys_at_any_depth() {
775        let bytes = sample_manifest().to_canonical_bytes().unwrap();
776        let value = String::from_utf8(bytes).unwrap().replace(
777            r#""extensions":{}"#,
778            r#""extensions":{"msb.example/1":{"x":1,"x":2}}"#,
779        );
780        let error = Manifest::from_bytes(value.as_bytes()).unwrap_err();
781        assert!(error.to_string().contains("duplicate object key 'x'"));
782    }
783
784    #[test]
785    fn recursively_sorts_extension_object_keys() {
786        let mut manifest = sample_manifest();
787        manifest.extensions.insert(
788            "msb.example/1".into(),
789            serde_json::json!({"z": {"b": 2, "a": 1}, "a": 0}),
790        );
791        let text = String::from_utf8(manifest.to_canonical_bytes().unwrap()).unwrap();
792        assert!(text.contains(r#""msb.example/1":{"a":0,"z":{"a":1,"b":2}}"#));
793    }
794
795    #[test]
796    fn rejects_unsafe_upper_filename_and_unsafe_integer() {
797        let mut manifest = sample_manifest();
798        let file = manifest.state.as_file().unwrap().clone();
799        manifest.state = SnapshotState::File(FileSnapshotState {
800            upper: UpperLayer {
801                file: "../upper.ext4".into(),
802                ..file.upper
803            },
804            ..file
805        });
806        assert!(manifest.to_canonical_bytes().is_err());
807
808        let mut manifest = sample_manifest();
809        let file = manifest.state.as_file().unwrap().clone();
810        manifest.state = SnapshotState::File(FileSnapshotState {
811            upper: UpperLayer {
812                size_bytes: MAX_JSON_SAFE_INTEGER + 1,
813                ..file.upper
814            },
815            ..file
816        });
817        assert!(manifest.to_canonical_bytes().is_err());
818    }
819
820    #[test]
821    fn rejects_reserved_upper_filenames() {
822        for reserved in RESERVED_ARTIFACT_FILENAMES {
823            let mut manifest = sample_manifest();
824            let file = manifest.state.as_file().unwrap().clone();
825            manifest.state = SnapshotState::File(FileSnapshotState {
826                upper: UpperLayer {
827                    file: (*reserved).into(),
828                    ..file.upper
829                },
830                ..file
831            });
832
833            let error = manifest.to_canonical_bytes().unwrap_err().to_string();
834            assert!(
835                error.contains("reserved filename"),
836                "unexpected error for {reserved}: {error}"
837            );
838        }
839    }
840
841    #[test]
842    fn unknown_required_extension_parses_but_blocks_use() {
843        let mut manifest = sample_manifest();
844        manifest
845            .extensions
846            .insert("msb.future/1".into(), serde_json::json!({}));
847        manifest.requires.push("msb.future/1".into());
848        let parsed = Manifest::from_bytes(&manifest.to_canonical_bytes().unwrap()).unwrap();
849        assert_eq!(parsed.unsupported_requires(), vec!["msb.future/1"]);
850    }
851}