1use 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
14pub const SCHEMA: &str = "microsandbox.snapshot/1";
20pub const SCHEMA_VERSION: u32 = 1;
22pub const DESCRIPTOR_FILENAME: &str = "snapshot.json";
24pub const SNAPSHOT_ARTIFACT_KIND: &str = "snapshot";
26pub const DEFAULT_UPPER_FILE: &str = "upper.ext4";
28pub const LAYERS_DIRECTORY: &str = "layers";
30pub const SPARSE_SHA256_V1: &str = "msb-sparse-sha256-v1";
32pub const FILE_MERKLE_BLAKE3_V1: &str = "msb-file-merkle-blake3-v1";
34pub const FILE_MERKLE_BLAKE3_LEAF_SIZE: u32 = 64 * 1024;
36pub const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
38pub const MAX_DESCRIPTOR_BYTES: usize = 1024 * 1024;
40pub const MAX_FILE_LAYERS: usize = 256;
42pub const SUPPORTED_REQUIRES: &[&str] = &[
44 super::RESTORE_DEFAULTS_EXTENSION,
45 super::OWNED_VOLUMES_EXTENSION,
46];
47
48#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
54#[serde(transparent)]
55pub struct SnapshotId(String);
56
57#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
59#[serde(transparent)]
60pub struct DiskLayerId(String);
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum SnapshotFormat {
66 Raw,
68 Qcow2,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74pub enum SnapshotScope {
75 #[serde(rename = "file")]
77 Disk,
78 #[serde(rename = "checkpoint")]
80 Full,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "kebab-case")]
86pub enum SnapshotConsistency {
87 CrashConsistent,
89 FilesystemConsistent,
91 ApplicationConsistent,
93}
94
95#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(tag = "layout", rename_all = "lowercase", deny_unknown_fields)]
103pub enum SnapshotRootDisk {
104 #[default]
106 Managed,
107 Flat,
109 Tmpfs {
111 #[serde(deserialize_with = "deserialize_required_option")]
113 size_mib: Option<u32>,
114 },
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct ImageRef {
121 pub reference: String,
123 pub manifest_digest: String,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct SnapshotCapture {
131 pub created_at: String,
133 #[serde(deserialize_with = "deserialize_required_option")]
135 pub source_lineage: Option<String>,
136 #[serde(deserialize_with = "deserialize_required_option")]
138 pub source_checkpoint: Option<String>,
139 pub consistency: SnapshotConsistency,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(tag = "algorithm", deny_unknown_fields)]
146pub enum UpperIntegrity {
147 #[serde(rename = "sha256")]
149 Sha256 {
150 digest: String,
152 },
153 #[serde(rename = "msb-sparse-sha256-v1")]
155 SparseSha256V1 {
156 digest: String,
158 },
159 #[serde(rename = "msb-file-merkle-blake3-v1")]
161 FileMerkleBlake3V1 {
162 root: String,
164 logical_size: u64,
166 leaf_size: u32,
168 },
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "kebab-case")]
174pub enum LayerFileKind {
175 Regular,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct LayerPayload {
183 pub file_kind: LayerFileKind,
185 #[serde(deserialize_with = "deserialize_required_option")]
187 pub integrity: Option<UpperIntegrity>,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct DiskLayer {
194 pub layer_id: DiskLayerId,
196 pub format: SnapshotFormat,
198 pub virtual_size: u64,
200 #[serde(deserialize_with = "deserialize_required_option")]
202 pub backing: Option<DiskLayerId>,
203 pub payload: LayerPayload,
205}
206
207pub type UpperLayer = DiskLayer;
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(deny_unknown_fields)]
213pub struct FileSnapshotState {
214 pub disk_format: SnapshotFormat,
216 pub filesystem: String,
218 pub virtual_size: u64,
220 pub head: DiskLayerId,
222 pub layers: Vec<DiskLayer>,
224}
225
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct CheckpointSnapshotState {
230 pub checkpoint_id: String,
232 pub checkpoint_root: String,
234 pub restore_intents: Vec<String>,
236 pub requirements_summary: BTreeMap<String, serde_json::Value>,
238}
239
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
242#[serde(tag = "kind", rename_all = "lowercase")]
243pub enum SnapshotState {
244 File(FileSnapshotState),
246 Checkpoint(CheckpointSnapshotState),
248}
249
250#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct Manifest {
254 pub schema: String,
256 pub snapshot_id: SnapshotId,
258 pub scope: SnapshotScope,
260 pub state: SnapshotState,
262 pub capture: SnapshotCapture,
264 pub image: ImageRef,
266 #[serde(default)]
268 pub root_disk: SnapshotRootDisk,
269 #[serde(deserialize_with = "deserialize_required_option")]
271 pub parent: Option<SnapshotId>,
272 pub requires: Vec<String>,
274 pub extensions: BTreeMap<String, serde_json::Value>,
276}
277
278pub type SnapshotDescriptor = Manifest;
280
281struct DuplicateCheckedJson;
283
284impl SnapshotId {
289 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 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 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 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 pub const fn kind(&self) -> &'static str {
331 match self {
332 Self::File(_) => "file",
333 Self::Checkpoint(_) => "checkpoint",
334 }
335 }
336
337 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 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 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 pub fn layer_path(&self, layer: &DiskLayer) -> PathBuf {
365 layer_path(&layer.layer_id, layer.format)
366 }
367
368 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 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 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 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 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 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 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 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
530impl<'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
599pub 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
612fn 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 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
738pub 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
784pub 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 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#[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}