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