1use serde::{Deserialize, Serialize};
47
48use crate::{PackError, Result};
49
50pub const MAGIC: &[u8; 8] = b"SMOLPACK";
52
53pub const SECTION_MAGIC: &[u8; 8] = b"SMOLSECT";
55
56pub const LIBS_MAGIC: &[u8; 8] = b"SMOLLIBS";
58
59pub const FORMAT_VERSION: u32 = 1;
61
62pub const SIDECAR_EXTENSION: &str = ".smolmachine";
64
65pub const FOOTER_SIZE: usize = 64;
67
68pub const SECTION_HEADER_SIZE: usize = 32;
70
71pub const LIBS_FOOTER_SIZE: usize = 32;
73
74#[derive(Debug, Clone, Copy)]
94pub struct SectionHeader {
95 pub manifest_size: u32,
97 pub assets_size: u64,
99 pub checksum: u32,
101}
102
103impl SectionHeader {
104 pub fn to_bytes(&self) -> [u8; SECTION_HEADER_SIZE] {
106 let mut buf = [0u8; SECTION_HEADER_SIZE];
107
108 buf[0..8].copy_from_slice(SECTION_MAGIC);
110
111 buf[8..12].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
113
114 buf[12..16].copy_from_slice(&self.manifest_size.to_le_bytes());
116
117 buf[16..24].copy_from_slice(&self.assets_size.to_le_bytes());
119
120 buf[24..28].copy_from_slice(&self.checksum.to_le_bytes());
122
123 buf
126 }
127
128 pub fn from_bytes(buf: &[u8]) -> Result<Self> {
130 if buf.len() < SECTION_HEADER_SIZE {
131 return Err(PackError::InvalidMagic);
132 }
133
134 if &buf[0..8] != SECTION_MAGIC {
136 return Err(PackError::InvalidMagic);
137 }
138
139 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#[derive(Debug, Clone, Copy)]
171pub struct LibsFooter {
172 pub libs_offset: u64,
174 pub libs_size: u64,
176}
177
178impl LibsFooter {
179 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()); 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 buf
188 }
189
190 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#[derive(Debug, Clone, Copy)]
226pub struct PackFooter {
227 pub stub_size: u64,
229 pub assets_offset: u64,
231 pub assets_size: u64,
233 pub manifest_offset: u64,
235 pub manifest_size: u64,
237 pub checksum: u32,
239}
240
241impl PackFooter {
242 pub fn to_bytes(&self) -> [u8; FOOTER_SIZE] {
244 let mut buf = [0u8; FOOTER_SIZE];
245
246 buf[0..8].copy_from_slice(MAGIC);
248
249 buf[8..12].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
251
252 buf[12..20].copy_from_slice(&self.stub_size.to_le_bytes());
254
255 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 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 buf[52..56].copy_from_slice(&self.checksum.to_le_bytes());
265
266 buf
269 }
270
271 pub fn from_bytes(buf: &[u8; FOOTER_SIZE]) -> Result<Self> {
273 if &buf[0..8] != MAGIC {
275 return Err(PackError::InvalidMagic);
276 }
277
278 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#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
311#[serde(rename_all = "lowercase")]
312pub enum PackMode {
313 #[default]
315 Container,
316 Vm,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct PackManifest {
323 #[serde(default)]
325 pub mode: PackMode,
326
327 pub image: String,
329
330 pub digest: String,
332
333 pub platform: String,
335
336 #[serde(default, skip_serializing_if = "Vec::is_empty")]
338 pub entrypoint: Vec<String>,
339
340 #[serde(default, skip_serializing_if = "Vec::is_empty")]
342 pub cmd: Vec<String>,
343
344 #[serde(default, skip_serializing_if = "Vec::is_empty")]
346 pub env: Vec<String>,
347
348 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
353 pub secret_refs: std::collections::BTreeMap<String, smolvm_protocol::SecretRef>,
354
355 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub workdir: Option<String>,
358
359 pub cpus: u8,
361
362 pub mem: u32,
364
365 #[serde(default)]
368 pub image_size: u64,
369
370 #[serde(default)]
372 pub network: bool,
373
374 #[serde(default)]
376 pub gpu: bool,
377
378 pub host_platform: String,
382
383 pub created: String,
385
386 pub smolvm_version: String,
388
389 pub assets: AssetInventory,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct AssetInventory {
396 pub libraries: Vec<AssetEntry>,
398
399 pub agent_rootfs: AssetEntry,
401
402 pub layers: Vec<LayerEntry>,
404
405 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub storage_template: Option<AssetEntry>,
409
410 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub overlay_template: Option<AssetEntry>,
414
415 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub overlay_logical_size: Option<u64>,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct AssetEntry {
427 pub path: String,
429
430 pub size: u64,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct LayerEntry {
437 pub digest: String,
439
440 pub path: String,
442
443 pub size: u64,
445}
446
447fn rfc3339_now() -> String {
449 let now = time::OffsetDateTime::now_utc();
450 now.format(&time::format_description::well_known::Rfc3339)
451 .expect("RFC 3339 formatting should never fail for a valid OffsetDateTime")
452}
453
454impl PackManifest {
455 pub fn new(image: String, digest: String, platform: String, host_platform: String) -> Self {
457 Self {
458 mode: PackMode::default(),
459 image,
460 digest,
461 platform,
462 entrypoint: Vec::new(),
463 cmd: Vec::new(),
464 env: Vec::new(),
465 secret_refs: std::collections::BTreeMap::new(),
466 workdir: None,
467 cpus: 1,
468 mem: 256,
469 image_size: 0,
470 network: false,
471 gpu: false,
472 host_platform,
473 created: rfc3339_now(),
474 smolvm_version: env!("CARGO_PKG_VERSION").to_string(),
475 assets: AssetInventory {
476 libraries: Vec::new(),
477 agent_rootfs: AssetEntry {
478 path: "agent-rootfs.tar".to_string(),
479 size: 0,
480 },
481 layers: Vec::new(),
482 storage_template: None,
483 overlay_template: None,
484 overlay_logical_size: None,
485 },
486 }
487 }
488
489 pub fn to_json(&self) -> Result<Vec<u8>> {
491 Ok(serde_json::to_vec_pretty(self)?)
492 }
493
494 pub fn from_json(data: &[u8]) -> Result<Self> {
496 Ok(serde_json::from_slice(data)?)
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
505 fn test_manifest_secret_refs_roundtrip() {
506 let empty = PackManifest::new(
510 "alpine".to_string(),
511 "sha256:abc".to_string(),
512 "linux/arm64".to_string(),
513 "linux/arm64".to_string(),
514 );
515 let json = String::from_utf8(empty.to_json().unwrap()).unwrap();
516 assert!(
517 !json.contains("secret_refs"),
518 "empty secret_refs must not be serialized"
519 );
520
521 let mut m = empty;
522 m.secret_refs.insert(
523 "DB_PASSWORD".to_string(),
524 smolvm_protocol::SecretRef {
525 from_env: Some("PGPASSWORD".to_string()),
526 from_file: None,
527 },
528 );
529 let restored = PackManifest::from_json(&m.to_json().unwrap()).unwrap();
530 assert_eq!(restored.secret_refs.len(), 1);
531 assert_eq!(
532 restored.secret_refs["DB_PASSWORD"].from_env.as_deref(),
533 Some("PGPASSWORD")
534 );
535 }
536
537 #[test]
538 fn test_footer_roundtrip() {
539 let footer = PackFooter {
540 stub_size: 512 * 1024,
541 assets_offset: 512 * 1024,
542 assets_size: 50 * 1024 * 1024,
543 manifest_offset: 512 * 1024 + 50 * 1024 * 1024,
544 manifest_size: 2048,
545 checksum: 0xDEADBEEF,
546 };
547
548 let bytes = footer.to_bytes();
549 assert_eq!(bytes.len(), FOOTER_SIZE);
550
551 let restored = PackFooter::from_bytes(&bytes).unwrap();
552 assert_eq!(restored.stub_size, footer.stub_size);
553 assert_eq!(restored.assets_offset, footer.assets_offset);
554 assert_eq!(restored.assets_size, footer.assets_size);
555 assert_eq!(restored.manifest_offset, footer.manifest_offset);
556 assert_eq!(restored.manifest_size, footer.manifest_size);
557 assert_eq!(restored.checksum, footer.checksum);
558 }
559
560 #[test]
561 fn test_footer_invalid_magic() {
562 let mut bytes = [0u8; FOOTER_SIZE];
563 bytes[0..8].copy_from_slice(b"BADMAGIC");
564
565 let result = PackFooter::from_bytes(&bytes);
566 assert!(matches!(result, Err(PackError::InvalidMagic)));
567 }
568
569 #[test]
570 fn test_footer_unsupported_version() {
571 let mut bytes = [0u8; FOOTER_SIZE];
572 bytes[0..8].copy_from_slice(MAGIC);
573 bytes[8..12].copy_from_slice(&99u32.to_le_bytes()); let result = PackFooter::from_bytes(&bytes);
576 assert!(matches!(result, Err(PackError::UnsupportedVersion(99))));
577 }
578
579 #[test]
580 fn test_manifest_roundtrip() {
581 let mut manifest = PackManifest::new(
582 "alpine:latest".to_string(),
583 "sha256:abc123".to_string(),
584 "linux/arm64".to_string(),
585 "darwin/arm64".to_string(),
586 );
587 manifest.cpus = 2;
588 manifest.mem = 1024;
589 manifest.entrypoint = vec!["/bin/sh".to_string()];
590 manifest.env = vec!["PATH=/usr/local/bin:/usr/bin:/bin".to_string()];
591 manifest.assets.libraries.push(AssetEntry {
592 path: "lib/libkrun.dylib".to_string(),
593 size: 4 * 1024 * 1024,
594 });
595
596 let json = manifest.to_json().unwrap();
597 let restored = PackManifest::from_json(&json).unwrap();
598
599 assert_eq!(restored.image, "alpine:latest");
600 assert_eq!(restored.digest, "sha256:abc123");
601 assert_eq!(restored.cpus, 2);
602 assert_eq!(restored.mem, 1024);
603 assert_eq!(restored.entrypoint, vec!["/bin/sh"]);
604 assert_eq!(restored.assets.libraries.len(), 1);
605 }
606
607 #[test]
608 fn test_manifest_json_format() {
609 let manifest = PackManifest::new(
610 "ubuntu:22.04".to_string(),
611 "sha256:def456".to_string(),
612 "linux/amd64".to_string(),
613 "linux/amd64".to_string(),
614 );
615
616 let json = String::from_utf8(manifest.to_json().unwrap()).unwrap();
617 assert!(json.contains("\"image\": \"ubuntu:22.04\""));
618 assert!(json.contains("\"platform\": \"linux/amd64\""));
619 assert!(json.contains("\"host_platform\": \"linux/amd64\""));
621 assert!(json.contains("\"smolvm_version\""));
622 assert!(json.contains("\"created\""));
623 }
624
625 #[test]
626 fn test_pack_mode_default_is_container() {
627 assert_eq!(PackMode::default(), PackMode::Container);
628 }
629
630 #[test]
631 fn test_pack_mode_vm_roundtrip() {
632 let mut manifest = PackManifest::new(
633 "vm://myvm".to_string(),
634 "none".to_string(),
635 "linux/arm64".to_string(),
636 "darwin/arm64".to_string(),
637 );
638 manifest.mode = PackMode::Vm;
639 manifest.assets.overlay_template = Some(AssetEntry {
640 path: "overlay.raw".to_string(),
641 size: 2 * 1024 * 1024 * 1024,
642 });
643
644 let json = manifest.to_json().unwrap();
645 let restored = PackManifest::from_json(&json).unwrap();
646 assert_eq!(restored.mode, PackMode::Vm);
647 assert!(restored.assets.overlay_template.is_some());
648 assert_eq!(
649 restored.assets.overlay_template.unwrap().path,
650 "overlay.raw"
651 );
652 }
653}