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 SUPPORTED_READ_VERSIONS: &[u32] = &[1, 2, 3];
70
71pub fn is_supported_read_version(version: u32) -> bool {
73 SUPPORTED_READ_VERSIONS.contains(&version)
74}
75
76pub const SIDECAR_EXTENSION: &str = ".smolmachine";
78
79pub const FOOTER_SIZE: usize = 64;
81
82pub const SECTION_HEADER_SIZE: usize = 32;
84
85pub const LIBS_FOOTER_SIZE: usize = 32;
87
88#[derive(Debug, Clone, Copy)]
108pub struct SectionHeader {
109 pub manifest_size: u32,
111 pub assets_size: u64,
113 pub checksum: u32,
115}
116
117impl SectionHeader {
118 pub fn to_bytes(&self) -> [u8; SECTION_HEADER_SIZE] {
120 let mut buf = [0u8; SECTION_HEADER_SIZE];
121
122 buf[0..8].copy_from_slice(SECTION_MAGIC);
124
125 buf[8..12].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
127
128 buf[12..16].copy_from_slice(&self.manifest_size.to_le_bytes());
130
131 buf[16..24].copy_from_slice(&self.assets_size.to_le_bytes());
133
134 buf[24..28].copy_from_slice(&self.checksum.to_le_bytes());
136
137 buf
140 }
141
142 pub fn from_bytes(buf: &[u8]) -> Result<Self> {
144 if buf.len() < SECTION_HEADER_SIZE {
145 return Err(PackError::InvalidMagic);
146 }
147
148 if &buf[0..8] != SECTION_MAGIC {
150 return Err(PackError::InvalidMagic);
151 }
152
153 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#[derive(Debug, Clone, Copy)]
185pub struct LibsFooter {
186 pub libs_offset: u64,
188 pub libs_size: u64,
190}
191
192impl LibsFooter {
193 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()); 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 buf
202 }
203
204 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#[derive(Debug, Clone, Copy)]
240pub struct PackFooter {
241 pub stub_size: u64,
243 pub assets_offset: u64,
245 pub assets_size: u64,
247 pub manifest_offset: u64,
249 pub manifest_size: u64,
251 pub checksum: u32,
253}
254
255impl PackFooter {
256 pub fn to_bytes(&self) -> [u8; FOOTER_SIZE] {
258 let mut buf = [0u8; FOOTER_SIZE];
259
260 buf[0..8].copy_from_slice(MAGIC);
262
263 buf[8..12].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
265
266 buf[12..20].copy_from_slice(&self.stub_size.to_le_bytes());
268
269 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 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 buf[52..56].copy_from_slice(&self.checksum.to_le_bytes());
279
280 buf
283 }
284
285 pub fn from_bytes(buf: &[u8; FOOTER_SIZE]) -> Result<Self> {
287 if &buf[0..8] != MAGIC {
289 return Err(PackError::InvalidMagic);
290 }
291
292 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#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
325#[serde(rename_all = "lowercase")]
326pub enum PackMode {
327 #[default]
329 Container,
330 Vm,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct PackManifest {
337 #[serde(default)]
339 pub mode: PackMode,
340
341 pub image: String,
343
344 pub digest: String,
346
347 pub platform: String,
349
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
352 pub entrypoint: Vec<String>,
353
354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
356 pub cmd: Vec<String>,
357
358 #[serde(default, skip_serializing_if = "Vec::is_empty")]
360 pub env: Vec<String>,
361
362 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
367 pub secret_refs: std::collections::BTreeMap<String, smolvm_protocol::SecretRef>,
368
369 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub workdir: Option<String>,
372
373 pub cpus: u8,
375
376 pub mem: u32,
378
379 #[serde(default)]
382 pub image_size: u64,
383
384 #[serde(default)]
386 pub network: bool,
387
388 #[serde(default)]
390 pub gpu: bool,
391
392 #[serde(default)]
397 pub cuda: bool,
398
399 #[serde(default)]
405 pub host_platform: String,
406
407 #[serde(default)]
410 pub created: String,
411
412 #[serde(default)]
415 pub smolvm_version: String,
416
417 pub assets: AssetInventory,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct AssetInventory {
424 pub libraries: Vec<AssetEntry>,
426
427 pub agent_rootfs: AssetEntry,
429
430 pub layers: Vec<LayerEntry>,
432
433 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub storage_template: Option<AssetEntry>,
437
438 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub overlay_template: Option<AssetEntry>,
442
443 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub overlay_logical_size: Option<u64>,
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct AssetEntry {
455 pub path: String,
457
458 pub size: u64,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct LayerEntry {
465 pub digest: String,
467
468 pub path: String,
470
471 pub size: u64,
473}
474
475fn 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 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 pub fn to_json(&self) -> Result<Vec<u8>> {
520 Ok(serde_json::to_vec_pretty(self)?)
521 }
522
523 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 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()); 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 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 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}