Skip to main content

smolvm_pack/
format.rs

1//! `.smolmachine` binary format specification.
2//!
3//! # Overview
4//!
5//! A `.smolmachine` is a portable, self-contained microVM artifact. It bundles
6//! everything needed to run a workload: OCI image layers, the agent rootfs,
7//! runtime libraries (libkrun), and a manifest describing the configuration.
8//!
9//! # File Layout
10//!
11//! A `.smolmachine` file is a zstd-compressed tar archive with a JSON manifest
12//! appended as a footer. The manifest is also stored inside the OCI registry
13//! as the config blob when pushed.
14//!
15//! ```text
16//! +---------------------------+
17//! | Assets Blob (zstd tar)    |  30-150 MB
18//! |  - agent-rootfs.tar       |  Guest init system
19//! |  - layers/*.tar           |  OCI image layers
20//! |  - lib/libkrun.*          |  Runtime libraries (platform-specific)
21//! |  - storage.ext4 (opt)     |  Pre-formatted disk template
22//! |  - overlay.raw (opt)      |  VM snapshot (VM mode only)
23//! +---------------------------+
24//! | Manifest (JSON)           |  ~2 KB (PackManifest)
25//! +---------------------------+
26//! | Footer (64 bytes)         |
27//! |  - magic: "SMOLPACK"      |
28//! |  - version: 1             |
29//! |  - offsets + sizes         |
30//! |  - CRC32 checksum         |
31//! +---------------------------+
32//! ```
33//!
34//! # OCI Registry Representation
35//!
36//! When pushed to an OCI registry, a `.smolmachine` is stored as:
37//! - **Config blob**: `PackManifest` JSON (`application/vnd.smolmachines.machine.config.v1+json`)
38//! - **Layer blob**: The full `.smolmachine` file (`application/vnd.smolmachines.smolmachine.v1`)
39//! - **Manifest**: Standard OCI Image Manifest referencing both blobs
40//!
41//! # Execution Modes
42//!
43//! - **Container mode** (default): OCI image layers are unpacked and run via crun.
44//! - **VM mode**: An overlay disk snapshot is restored directly into the VM.
45
46use serde::{Deserialize, Serialize};
47
48use crate::{PackError, Result};
49
50/// Magic bytes identifying a packed smolvm binary.
51pub const MAGIC: &[u8; 8] = b"SMOLPACK";
52
53/// Magic bytes for embedded section header.
54pub const SECTION_MAGIC: &[u8; 8] = b"SMOLSECT";
55
56/// Magic bytes for libs footer appended to the stub binary.
57pub const LIBS_MAGIC: &[u8; 8] = b"SMOLLIBS";
58
59/// Current format version.
60pub const FORMAT_VERSION: u32 = 1;
61
62/// Extension for sidecar assets file.
63pub const SIDECAR_EXTENSION: &str = ".smolmachine";
64
65/// Footer size in bytes (fixed).
66pub const FOOTER_SIZE: usize = 64;
67
68/// Embedded section header size (fixed).
69pub const SECTION_HEADER_SIZE: usize = 32;
70
71/// Libs footer size in bytes (fixed).
72pub const LIBS_FOOTER_SIZE: usize = 32;
73
74/// Header for data embedded in the __SMOLVM,__smolvm Mach-O section.
75///
76/// This format is used for macOS single-file binaries where assets are
77/// stored inside the executable's Mach-O structure, allowing proper code signing.
78///
79/// Layout (32 bytes total):
80/// ```text
81/// Offset  Size  Field
82/// 0       8     magic ("SMOLSECT")
83/// 8       4     version (u32 LE)
84/// 12      4     manifest_size (u32 LE)
85/// 16      8     assets_size (u64 LE)
86/// 24      4     checksum (u32 LE)
87/// 28      4     reserved (zeroes)
88/// ```
89///
90/// Following the header:
91/// - Manifest JSON (manifest_size bytes)
92/// - Compressed assets (assets_size bytes)
93#[derive(Debug, Clone, Copy)]
94pub struct SectionHeader {
95    /// Size of manifest JSON in bytes.
96    pub manifest_size: u32,
97    /// Size of compressed assets in bytes.
98    pub assets_size: u64,
99    /// CRC32 checksum of manifest + assets.
100    pub checksum: u32,
101}
102
103impl SectionHeader {
104    /// Serialize header to bytes.
105    pub fn to_bytes(&self) -> [u8; SECTION_HEADER_SIZE] {
106        let mut buf = [0u8; SECTION_HEADER_SIZE];
107
108        // Magic
109        buf[0..8].copy_from_slice(SECTION_MAGIC);
110
111        // Version
112        buf[8..12].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
113
114        // Manifest size
115        buf[12..16].copy_from_slice(&self.manifest_size.to_le_bytes());
116
117        // Assets size
118        buf[16..24].copy_from_slice(&self.assets_size.to_le_bytes());
119
120        // Checksum
121        buf[24..28].copy_from_slice(&self.checksum.to_le_bytes());
122
123        // Reserved (already zeroed)
124
125        buf
126    }
127
128    /// Deserialize header from bytes.
129    pub fn from_bytes(buf: &[u8]) -> Result<Self> {
130        if buf.len() < SECTION_HEADER_SIZE {
131            return Err(PackError::InvalidMagic);
132        }
133
134        // Validate magic
135        if &buf[0..8] != SECTION_MAGIC {
136            return Err(PackError::InvalidMagic);
137        }
138
139        // Check version
140        let version = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
141        if version != FORMAT_VERSION {
142            return Err(PackError::UnsupportedVersion(version));
143        }
144
145        Ok(Self {
146            manifest_size: u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]),
147            assets_size: u64::from_le_bytes([
148                buf[16], buf[17], buf[18], buf[19], buf[20], buf[21], buf[22], buf[23],
149            ]),
150            checksum: u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]),
151        })
152    }
153}
154
155/// Footer appended to the stub binary to locate embedded runtime libraries.
156///
157/// The stub reads its own last 32 bytes to find the compressed libs bundle,
158/// extracts them to a cache directory, and dlopen's libkrun from there.
159/// This keeps the .smolmachine sidecar cross-platform (no platform-specific libs).
160///
161/// Layout (32 bytes total):
162/// ```text
163/// Offset  Size  Field
164/// 0       8     magic ("SMOLLIBS")
165/// 8       4     version (u32 LE)
166/// 12      8     libs_offset (u64 LE) - offset to compressed libs blob
167/// 20      8     libs_size (u64 LE) - size of compressed libs blob
168/// 28      4     reserved (zeroes)
169/// ```
170#[derive(Debug, Clone, Copy)]
171pub struct LibsFooter {
172    /// Offset from start of file to the compressed libs blob.
173    pub libs_offset: u64,
174    /// Size of the compressed libs blob.
175    pub libs_size: u64,
176}
177
178impl LibsFooter {
179    /// Serialize footer to bytes.
180    pub fn to_bytes(&self) -> [u8; LIBS_FOOTER_SIZE] {
181        let mut buf = [0u8; LIBS_FOOTER_SIZE];
182        buf[0..8].copy_from_slice(LIBS_MAGIC);
183        buf[8..12].copy_from_slice(&1u32.to_le_bytes()); // version 1
184        buf[12..20].copy_from_slice(&self.libs_offset.to_le_bytes());
185        buf[20..28].copy_from_slice(&self.libs_size.to_le_bytes());
186        // 28..32 reserved (zeroed)
187        buf
188    }
189
190    /// Deserialize footer from bytes.
191    pub fn from_bytes(buf: &[u8; LIBS_FOOTER_SIZE]) -> Result<Self> {
192        if &buf[0..8] != LIBS_MAGIC {
193            return Err(PackError::InvalidMagic);
194        }
195        let version = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
196        if version != 1 {
197            return Err(PackError::UnsupportedVersion(version));
198        }
199        Ok(Self {
200            libs_offset: u64::from_le_bytes([
201                buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19],
202            ]),
203            libs_size: u64::from_le_bytes([
204                buf[20], buf[21], buf[22], buf[23], buf[24], buf[25], buf[26], buf[27],
205            ]),
206        })
207    }
208}
209
210/// Fixed-size footer at the end of a packed binary.
211///
212/// Layout (64 bytes total):
213/// ```text
214/// Offset  Size  Field
215/// 0       8     magic ("SMOLPACK")
216/// 8       4     version (u32 LE)
217/// 12      8     stub_size (u64 LE) - size of stub executable
218/// 20      8     assets_offset (u64 LE) - offset to compressed assets
219/// 28      8     assets_size (u64 LE) - size of compressed assets
220/// 36      8     manifest_offset (u64 LE) - offset to manifest JSON
221/// 44      8     manifest_size (u64 LE) - size of manifest JSON
222/// 52      4     checksum (u32 LE) - CRC32 of assets + manifest
223/// 56      8     reserved (zeroes)
224/// ```
225#[derive(Debug, Clone, Copy)]
226pub struct PackFooter {
227    /// Size of the stub executable.
228    pub stub_size: u64,
229    /// Offset to compressed assets blob.
230    pub assets_offset: u64,
231    /// Size of compressed assets blob.
232    pub assets_size: u64,
233    /// Offset to manifest JSON.
234    pub manifest_offset: u64,
235    /// Size of manifest JSON.
236    pub manifest_size: u64,
237    /// CRC32 checksum of assets + manifest.
238    pub checksum: u32,
239}
240
241impl PackFooter {
242    /// Serialize footer to bytes.
243    pub fn to_bytes(&self) -> [u8; FOOTER_SIZE] {
244        let mut buf = [0u8; FOOTER_SIZE];
245
246        // Magic
247        buf[0..8].copy_from_slice(MAGIC);
248
249        // Version
250        buf[8..12].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
251
252        // Stub size
253        buf[12..20].copy_from_slice(&self.stub_size.to_le_bytes());
254
255        // Assets offset and size
256        buf[20..28].copy_from_slice(&self.assets_offset.to_le_bytes());
257        buf[28..36].copy_from_slice(&self.assets_size.to_le_bytes());
258
259        // Manifest offset and size
260        buf[36..44].copy_from_slice(&self.manifest_offset.to_le_bytes());
261        buf[44..52].copy_from_slice(&self.manifest_size.to_le_bytes());
262
263        // Checksum
264        buf[52..56].copy_from_slice(&self.checksum.to_le_bytes());
265
266        // Reserved (already zeroed)
267
268        buf
269    }
270
271    /// Deserialize footer from bytes.
272    pub fn from_bytes(buf: &[u8; FOOTER_SIZE]) -> Result<Self> {
273        // Validate magic
274        if &buf[0..8] != MAGIC {
275            return Err(PackError::InvalidMagic);
276        }
277
278        // Check version
279        let version = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
280        if version != FORMAT_VERSION {
281            return Err(PackError::UnsupportedVersion(version));
282        }
283
284        Ok(Self {
285            stub_size: u64::from_le_bytes([
286                buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19],
287            ]),
288            assets_offset: u64::from_le_bytes([
289                buf[20], buf[21], buf[22], buf[23], buf[24], buf[25], buf[26], buf[27],
290            ]),
291            assets_size: u64::from_le_bytes([
292                buf[28], buf[29], buf[30], buf[31], buf[32], buf[33], buf[34], buf[35],
293            ]),
294            manifest_offset: u64::from_le_bytes([
295                buf[36], buf[37], buf[38], buf[39], buf[40], buf[41], buf[42], buf[43],
296            ]),
297            manifest_size: u64::from_le_bytes([
298                buf[44], buf[45], buf[46], buf[47], buf[48], buf[49], buf[50], buf[51],
299            ]),
300            checksum: u32::from_le_bytes([buf[52], buf[53], buf[54], buf[55]]),
301        })
302    }
303}
304
305/// Execution mode for packed binaries.
306///
307/// Determines how commands are executed at runtime:
308/// - `Container`: commands run inside a crun container (OCI layers)
309/// - `Vm`: commands run directly in the VM rootfs (overlay disk)
310#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
311#[serde(rename_all = "lowercase")]
312pub enum PackMode {
313    /// Container mode: OCI image layers + crun container execution.
314    #[default]
315    Container,
316    /// VM mode: overlay disk + direct VM execution.
317    Vm,
318}
319
320/// Manifest describing the packed image and configuration.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct PackManifest {
323    /// Execution mode (container or VM).
324    #[serde(default)]
325    pub mode: PackMode,
326
327    /// Original image reference (e.g., "alpine:latest").
328    pub image: String,
329
330    /// Image digest (sha256:...).
331    pub digest: String,
332
333    /// Target platform (e.g., "linux/arm64").
334    pub platform: String,
335
336    /// Entrypoint command (from image config or override).
337    #[serde(default, skip_serializing_if = "Vec::is_empty")]
338    pub entrypoint: Vec<String>,
339
340    /// Default command arguments (from image config or override).
341    #[serde(default, skip_serializing_if = "Vec::is_empty")]
342    pub cmd: Vec<String>,
343
344    /// Default environment variables.
345    #[serde(default, skip_serializing_if = "Vec::is_empty")]
346    pub env: Vec<String>,
347
348    /// Secret references carried with the pack. Each maps an env var name to a
349    /// reference (host store entry, host env var, or file) — never an inline
350    /// value. The plaintext is resolved on the run host at exec time, so a
351    /// `.smolmachine` never contains secret material at rest.
352    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
353    pub secret_refs: std::collections::BTreeMap<String, smolvm_protocol::SecretRef>,
354
355    /// Working directory (from image config or override).
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub workdir: Option<String>,
358
359    /// Default number of vCPUs.
360    pub cpus: u8,
361
362    /// Default memory in MiB.
363    pub mem: u32,
364
365    /// Total extracted (on-disk) image size in bytes.
366    /// Used to auto-size the storage disk at runtime.
367    #[serde(default)]
368    pub image_size: u64,
369
370    /// Whether outbound networking is enabled by default.
371    #[serde(default)]
372    pub network: bool,
373
374    /// Enable GPU acceleration (Vulkan via virtio-gpu).
375    #[serde(default)]
376    pub gpu: bool,
377
378    /// Enable CUDA-over-vsock: the runtime starts a host CUDA server and bridges
379    /// the guest's CUDA client to it. Preserved from the source VM's `cuda` flag
380    /// when packing `--from-vm`. `#[serde(default)]` keeps older sidecars (which
381    /// lack the field) loadable.
382    #[serde(default)]
383    pub cuda: bool,
384
385    /// Host platform this .smolmachine runs on (e.g., "darwin/arm64").
386    /// Distinct from `platform` which is the guest architecture (always linux).
387    /// Used for registry Image Index resolution.
388    pub host_platform: String,
389
390    /// RFC 3339 timestamp when this machine was packed.
391    pub created: String,
392
393    /// smolvm version that built this machine (e.g., "0.1.15").
394    pub smolvm_version: String,
395
396    /// Asset inventory - files included in the assets blob.
397    pub assets: AssetInventory,
398}
399
400/// Inventory of assets included in the packed binary.
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct AssetInventory {
403    /// Runtime libraries (relative paths within assets).
404    pub libraries: Vec<AssetEntry>,
405
406    /// Agent rootfs tarball.
407    pub agent_rootfs: AssetEntry,
408
409    /// OCI layer tarballs.
410    pub layers: Vec<LayerEntry>,
411
412    /// Pre-formatted storage disk template (optional).
413    /// When present, copied to cache on first run instead of formatting at runtime.
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub storage_template: Option<AssetEntry>,
416
417    /// Overlay disk template (optional, VM mode only).
418    /// Contains the VM's persistent rootfs state from a `--from-vm` pack.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub overlay_template: Option<AssetEntry>,
421
422    /// Original sparse size of the overlay disk, in bytes (optional, VM mode only).
423    ///
424    /// When set, the overlay.raw entry in the archive is a truncated copy with
425    /// the trailing sparse hole removed. On extraction the file is extended to
426    /// this size via `ftruncate`, restoring the sparse skeleton the VM expects.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub overlay_logical_size: Option<u64>,
429}
430
431/// An asset file entry.
432#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct AssetEntry {
434    /// Path within the assets archive.
435    pub path: String,
436
437    /// Uncompressed size in bytes.
438    pub size: u64,
439}
440
441/// An OCI layer entry.
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct LayerEntry {
444    /// Layer digest (sha256:...).
445    pub digest: String,
446
447    /// Path within the assets archive.
448    pub path: String,
449
450    /// Uncompressed size in bytes.
451    pub size: u64,
452}
453
454/// Generate an RFC 3339 timestamp for the current time in UTC.
455fn rfc3339_now() -> String {
456    let now = time::OffsetDateTime::now_utc();
457    now.format(&time::format_description::well_known::Rfc3339)
458        .expect("RFC 3339 formatting should never fail for a valid OffsetDateTime")
459}
460
461impl PackManifest {
462    /// Create a new manifest with default values.
463    pub fn new(image: String, digest: String, platform: String, host_platform: String) -> Self {
464        Self {
465            mode: PackMode::default(),
466            image,
467            digest,
468            platform,
469            entrypoint: Vec::new(),
470            cmd: Vec::new(),
471            env: Vec::new(),
472            secret_refs: std::collections::BTreeMap::new(),
473            workdir: None,
474            cpus: 1,
475            mem: 256,
476            image_size: 0,
477            network: false,
478            gpu: false,
479            cuda: false,
480            host_platform,
481            created: rfc3339_now(),
482            smolvm_version: env!("CARGO_PKG_VERSION").to_string(),
483            assets: AssetInventory {
484                libraries: Vec::new(),
485                agent_rootfs: AssetEntry {
486                    path: "agent-rootfs.tar".to_string(),
487                    size: 0,
488                },
489                layers: Vec::new(),
490                storage_template: None,
491                overlay_template: None,
492                overlay_logical_size: None,
493            },
494        }
495    }
496
497    /// Serialize manifest to JSON.
498    pub fn to_json(&self) -> Result<Vec<u8>> {
499        Ok(serde_json::to_vec_pretty(self)?)
500    }
501
502    /// Deserialize manifest from JSON.
503    pub fn from_json(data: &[u8]) -> Result<Self> {
504        Ok(serde_json::from_slice(data)?)
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn test_manifest_secret_refs_roundtrip() {
514        // Secret refs survive a JSON round-trip through the manifest, and are
515        // omitted entirely when empty (skip_serializing_if) so existing packs
516        // stay byte-compatible.
517        let empty = PackManifest::new(
518            "alpine".to_string(),
519            "sha256:abc".to_string(),
520            "linux/arm64".to_string(),
521            "linux/arm64".to_string(),
522        );
523        let json = String::from_utf8(empty.to_json().unwrap()).unwrap();
524        assert!(
525            !json.contains("secret_refs"),
526            "empty secret_refs must not be serialized"
527        );
528
529        let mut m = empty;
530        m.secret_refs.insert(
531            "DB_PASSWORD".to_string(),
532            smolvm_protocol::SecretRef {
533                from_env: Some("PGPASSWORD".to_string()),
534                from_file: None,
535            },
536        );
537        let restored = PackManifest::from_json(&m.to_json().unwrap()).unwrap();
538        assert_eq!(restored.secret_refs.len(), 1);
539        assert_eq!(
540            restored.secret_refs["DB_PASSWORD"].from_env.as_deref(),
541            Some("PGPASSWORD")
542        );
543    }
544
545    #[test]
546    fn test_footer_roundtrip() {
547        let footer = PackFooter {
548            stub_size: 512 * 1024,
549            assets_offset: 512 * 1024,
550            assets_size: 50 * 1024 * 1024,
551            manifest_offset: 512 * 1024 + 50 * 1024 * 1024,
552            manifest_size: 2048,
553            checksum: 0xDEADBEEF,
554        };
555
556        let bytes = footer.to_bytes();
557        assert_eq!(bytes.len(), FOOTER_SIZE);
558
559        let restored = PackFooter::from_bytes(&bytes).unwrap();
560        assert_eq!(restored.stub_size, footer.stub_size);
561        assert_eq!(restored.assets_offset, footer.assets_offset);
562        assert_eq!(restored.assets_size, footer.assets_size);
563        assert_eq!(restored.manifest_offset, footer.manifest_offset);
564        assert_eq!(restored.manifest_size, footer.manifest_size);
565        assert_eq!(restored.checksum, footer.checksum);
566    }
567
568    #[test]
569    fn test_footer_invalid_magic() {
570        let mut bytes = [0u8; FOOTER_SIZE];
571        bytes[0..8].copy_from_slice(b"BADMAGIC");
572
573        let result = PackFooter::from_bytes(&bytes);
574        assert!(matches!(result, Err(PackError::InvalidMagic)));
575    }
576
577    #[test]
578    fn test_footer_unsupported_version() {
579        let mut bytes = [0u8; FOOTER_SIZE];
580        bytes[0..8].copy_from_slice(MAGIC);
581        bytes[8..12].copy_from_slice(&99u32.to_le_bytes()); // Bad version
582
583        let result = PackFooter::from_bytes(&bytes);
584        assert!(matches!(result, Err(PackError::UnsupportedVersion(99))));
585    }
586
587    #[test]
588    fn test_manifest_roundtrip() {
589        let mut manifest = PackManifest::new(
590            "alpine:latest".to_string(),
591            "sha256:abc123".to_string(),
592            "linux/arm64".to_string(),
593            "darwin/arm64".to_string(),
594        );
595        manifest.cpus = 2;
596        manifest.mem = 1024;
597        manifest.entrypoint = vec!["/bin/sh".to_string()];
598        manifest.env = vec!["PATH=/usr/local/bin:/usr/bin:/bin".to_string()];
599        manifest.assets.libraries.push(AssetEntry {
600            path: "lib/libkrun.dylib".to_string(),
601            size: 4 * 1024 * 1024,
602        });
603
604        let json = manifest.to_json().unwrap();
605        let restored = PackManifest::from_json(&json).unwrap();
606
607        assert_eq!(restored.image, "alpine:latest");
608        assert_eq!(restored.digest, "sha256:abc123");
609        assert_eq!(restored.cpus, 2);
610        assert_eq!(restored.mem, 1024);
611        assert_eq!(restored.entrypoint, vec!["/bin/sh"]);
612        assert_eq!(restored.assets.libraries.len(), 1);
613    }
614
615    #[test]
616    fn test_manifest_json_format() {
617        let manifest = PackManifest::new(
618            "ubuntu:22.04".to_string(),
619            "sha256:def456".to_string(),
620            "linux/amd64".to_string(),
621            "linux/amd64".to_string(),
622        );
623
624        let json = String::from_utf8(manifest.to_json().unwrap()).unwrap();
625        assert!(json.contains("\"image\": \"ubuntu:22.04\""));
626        assert!(json.contains("\"platform\": \"linux/amd64\""));
627        // Phase 0 fields: verify key names serialize correctly
628        assert!(json.contains("\"host_platform\": \"linux/amd64\""));
629        assert!(json.contains("\"smolvm_version\""));
630        assert!(json.contains("\"created\""));
631    }
632
633    #[test]
634    fn test_pack_mode_default_is_container() {
635        assert_eq!(PackMode::default(), PackMode::Container);
636    }
637
638    #[test]
639    fn test_pack_mode_vm_roundtrip() {
640        let mut manifest = PackManifest::new(
641            "vm://myvm".to_string(),
642            "none".to_string(),
643            "linux/arm64".to_string(),
644            "darwin/arm64".to_string(),
645        );
646        manifest.mode = PackMode::Vm;
647        manifest.assets.overlay_template = Some(AssetEntry {
648            path: "overlay.raw".to_string(),
649            size: 2 * 1024 * 1024 * 1024,
650        });
651
652        let json = manifest.to_json().unwrap();
653        let restored = PackManifest::from_json(&json).unwrap();
654        assert_eq!(restored.mode, PackMode::Vm);
655        assert!(restored.assets.overlay_template.is_some());
656        assert_eq!(
657            restored.assets.overlay_template.unwrap().path,
658            "overlay.raw"
659        );
660    }
661}