1use 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
20pub const SCHEMA_VERSION: u32 = 1;
26
27pub const DESCRIPTOR_FILENAME: &str = "snapshot.json";
29
30pub const SNAPSHOT_ARTIFACT_KIND: &str = "snapshot";
32
33pub const DEFAULT_UPPER_FILE: &str = "upper.ext4";
35
36pub const SPARSE_SHA256_V1: &str = "msb-sparse-sha256-v1";
38
39pub const FILE_MERKLE_BLAKE3_V1: &str = "msb-file-merkle-blake3-v1";
41
42pub const FILE_MERKLE_BLAKE3_LEAF_SIZE: u32 = 64 * 1024;
44
45pub const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
47
48pub const SUPPORTED_REQUIRES: &[&str] = &[];
50
51const RESERVED_ARTIFACT_FILENAMES: &[&str] = &[
53 DESCRIPTOR_FILENAME,
54 "manifest.json",
55 ".manifest.json.legacy",
56 ".snapshot-migration.lock",
57];
58
59#[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,
71 Qcow2,
74}
75
76#[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,
84 Resumable,
86}
87
88#[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 #[serde(rename = "ref")]
96 pub reference: String,
97 pub manifest_digest: String,
99}
100
101#[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 pub file: String,
109 pub size_bytes: u64,
111 #[serde(deserialize_with = "deserialize_required_option")]
114 pub integrity: Option<UpperIntegrity>,
115}
116
117#[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 #[serde(rename = "sha256")]
126 Sha256 {
127 digest: String,
129 },
130 #[serde(rename = "msb-sparse-sha256-v1")]
132 SparseSha256V1 {
133 digest: String,
135 },
136 #[serde(rename = "msb-file-merkle-blake3-v1")]
138 FileMerkleBlake3V1 {
139 root: String,
141 logical_size: u64,
143 leaf_size: u32,
145 },
146}
147
148#[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 pub format: SnapshotFormat,
156 pub fstype: String,
158 pub upper: UpperLayer,
160}
161
162#[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 pub checkpoint_id: String,
170 pub manifest: String,
172}
173
174#[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 File(FileSnapshotState),
182 Checkpoint(CheckpointSnapshotState),
184}
185
186#[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 pub schema: u32,
198 pub artifact: String,
200 pub scope: SnapshotScope,
202 pub created_at: String,
204 #[serde(deserialize_with = "deserialize_required_option")]
206 pub parent: Option<String>,
207 pub image: ImageRef,
209 #[serde(deserialize_with = "deserialize_required_option")]
211 pub source_sandbox: Option<String>,
212 pub state: SnapshotState,
214 pub labels: BTreeMap<String, String>,
216 #[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 pub requires: Vec<String>,
222}
223
224pub type SnapshotDescriptor = Manifest;
226
227struct DuplicateCheckedJson;
230
231impl SnapshotState {
236 pub const fn kind(&self) -> &'static str {
238 match self {
239 Self::File(_) => "file",
240 Self::Checkpoint(_) => "checkpoint",
241 }
242 }
243
244 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 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 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 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 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 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 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 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 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
424impl<'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
504fn 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 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#[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}