Skip to main content

smolvm_pack/
packer.rs

1//! Binary packer for assembling packed executables.
2//!
3//! This module handles combining the stub executable, compressed assets,
4//! manifest, and footer into a self-contained `.smolmachine` package.
5//! See [`crate::format`] for the binary format specification.
6
7use std::fs::{self, File};
8use std::io::{Read, Seek, SeekFrom, Write};
9use std::path::{Path, PathBuf};
10
11use crate::assets::{crc32_file_range, AssetCollector};
12use crate::format::{PackFooter, PackManifest, FOOTER_SIZE, SIDECAR_EXTENSION};
13use crate::Result;
14
15/// Maximum allowed manifest size (16 MiB) to prevent malicious/corrupt sidecars
16/// from causing excessive memory allocation.
17const MAX_MANIFEST_SIZE: u64 = 16 * 1024 * 1024;
18
19/// Binary packer for creating self-contained executables.
20pub struct Packer {
21    stub_path: Option<std::path::PathBuf>,
22    manifest: PackManifest,
23    asset_collector: Option<AssetCollector>,
24}
25
26/// Error type for try_pack_embedded_macho (internal).
27#[cfg(target_os = "macos")]
28enum PackedEmbeddedError {
29    /// Not a valid Mach-O with required section, return Packer to try append mode.
30    NotMachO(Box<Packer>),
31    /// Other pack error.
32    PackError(crate::PackError),
33}
34
35impl Packer {
36    /// Create a new packer with the given manifest.
37    pub fn new(manifest: PackManifest) -> Self {
38        Self {
39            stub_path: None,
40            manifest,
41            asset_collector: None,
42        }
43    }
44
45    /// Set the path to the stub executable.
46    pub fn with_stub(mut self, stub_path: impl AsRef<Path>) -> Self {
47        self.stub_path = Some(stub_path.as_ref().to_path_buf());
48        self
49    }
50
51    /// Set the asset collector and update manifest with its inventory.
52    pub fn with_assets(mut self, collector: AssetCollector) -> Self {
53        // Update manifest with the collector's inventory
54        self.manifest.assets = collector.inventory().clone();
55        self.asset_collector = Some(collector);
56        self
57    }
58
59    /// Set the asset collector without updating the manifest.
60    ///
61    /// Use this when the manifest already has the correct asset inventory
62    /// (e.g., when it was set from a different collector that was consumed).
63    pub fn with_asset_collector(mut self, collector: AssetCollector) -> Self {
64        self.asset_collector = Some(collector);
65        self
66    }
67
68    /// Get a mutable reference to the manifest.
69    pub fn manifest_mut(&mut self) -> &mut PackManifest {
70        &mut self.manifest
71    }
72
73    /// Pack everything into the output file using sidecar format.
74    ///
75    /// Creates two files:
76    /// 1. `output` - Stub executable (pure Mach-O, signable)
77    /// 2. `output.smolmachine` - Compressed assets + manifest + footer
78    ///
79    /// This keeps the binary as a pure Mach-O executable that can be
80    /// properly code-signed on macOS.
81    pub fn pack(self, output: impl AsRef<Path>) -> Result<PackedInfo> {
82        let output = output.as_ref();
83        let temp_dir = tempfile::tempdir()?;
84
85        // Get stub executable
86        let stub_path = self
87            .stub_path
88            .as_ref()
89            .ok_or_else(|| crate::PackError::AssetNotFound("stub executable".to_string()))?;
90
91        // 1. Copy stub executable to output (libs are appended later, after signing)
92        let stub_data = fs::read(stub_path)?;
93        let stub_size = stub_data.len() as u64;
94        fs::write(output, &stub_data)?;
95
96        // Make executable
97        #[cfg(unix)]
98        {
99            use std::os::unix::fs::PermissionsExt;
100            let mut perms = fs::metadata(output)?.permissions();
101            perms.set_mode(0o755);
102            fs::set_permissions(output, perms)?;
103        }
104
105        // 3. Build sidecar file with: assets (no libs) + manifest + footer
106        let sidecar_path = sidecar_path_for(output);
107        let mut sidecar_file = File::create(&sidecar_path)?;
108
109        // 3a. Write compressed assets (excludes lib/ — those are embedded in the stub)
110        let assets_temp = temp_dir.path().join("assets.tar.zst");
111        let assets_size = if let Some(collector) = &self.asset_collector {
112            collector.compress(&assets_temp, true)?
113        } else {
114            let empty_file = File::create(&assets_temp)?;
115            let encoder = zstd::stream::Encoder::new(empty_file, 1)?;
116            let tar_builder = tar::Builder::new(encoder);
117            let encoder = tar_builder.into_inner()?;
118            encoder.finish()?;
119            fs::metadata(&assets_temp)?.len()
120        };
121
122        let mut assets_file = File::open(&assets_temp)?;
123        std::io::copy(&mut assets_file, &mut sidecar_file)?;
124
125        // 3b. Write manifest JSON
126        let manifest_offset = assets_size;
127        let manifest_json = self.manifest.to_json()?;
128        let manifest_size = manifest_json.len() as u64;
129        sidecar_file.write_all(&manifest_json)?;
130
131        // 3c. Calculate checksum of assets + manifest
132        sidecar_file.flush()?;
133        drop(sidecar_file);
134        let checksum_size = assets_size + manifest_size;
135        let checksum = crc32_file_range(&sidecar_path, 0, checksum_size)?;
136
137        // 3d. Write footer to sidecar
138        let footer = PackFooter {
139            stub_size: 0,     // Not used in sidecar mode
140            assets_offset: 0, // Assets start at beginning of sidecar
141            assets_size,
142            manifest_offset,
143            manifest_size,
144            checksum,
145        };
146
147        let mut sidecar_file = fs::OpenOptions::new().append(true).open(&sidecar_path)?;
148        sidecar_file.write_all(&footer.to_bytes())?;
149
150        let sidecar_total = assets_size + manifest_size + FOOTER_SIZE as u64;
151        let total_size = stub_size + sidecar_total;
152
153        Ok(PackedInfo {
154            stub_size,
155            assets_size,
156            manifest_size,
157            total_size,
158            checksum,
159            sidecar_path: Some(sidecar_path),
160        })
161    }
162
163    /// Pack everything into a single executable file (embedded format).
164    ///
165    /// On macOS, this uses Mach-O section manipulation to embed assets inside
166    /// the `__SMOLVM,__smolvm` section, allowing proper code signing.
167    ///
168    /// On other platforms, this appends assets to the binary (simpler but
169    /// not signable on macOS).
170    pub fn pack_embedded(self, output: impl AsRef<Path>) -> Result<PackedInfo> {
171        #[cfg(target_os = "macos")]
172        {
173            // Try Mach-O section mode first
174            match self.try_pack_embedded_macho(output.as_ref()) {
175                Ok(info) => Ok(info),
176                Err(PackedEmbeddedError::NotMachO(packer)) => {
177                    // Not a valid Mach-O, fall back to append mode
178                    packer.pack_embedded_append(output)
179                }
180                Err(PackedEmbeddedError::PackError(e)) => Err(e),
181            }
182        }
183        #[cfg(not(target_os = "macos"))]
184        {
185            self.pack_embedded_append(output)
186        }
187    }
188
189    /// Try to pack using Mach-O section manipulation.
190    ///
191    /// Returns NotMachO with self if the stub isn't a valid Mach-O with
192    /// the required __smolvm section, allowing fallback to append mode.
193    #[cfg(target_os = "macos")]
194    fn try_pack_embedded_macho(
195        self,
196        output: &Path,
197    ) -> std::result::Result<PackedInfo, PackedEmbeddedError> {
198        use crate::macho::MachoFile;
199
200        // Get stub path
201        let stub_path = match self.stub_path.as_ref() {
202            Some(p) => p,
203            None => {
204                return Err(PackedEmbeddedError::PackError(
205                    crate::PackError::AssetNotFound("stub executable".to_string()),
206                ))
207            }
208        };
209
210        // Read stub data
211        let stub_data = match fs::read(stub_path) {
212            Ok(data) => data,
213            Err(e) => return Err(PackedEmbeddedError::PackError(e.into())),
214        };
215
216        // Try to parse as Mach-O
217        let macho = match MachoFile::parse(&stub_data) {
218            Ok(m) => m,
219            Err(_) => return Err(PackedEmbeddedError::NotMachO(Box::new(self))), // Not a Mach-O, fall back
220        };
221
222        // Check for __smolvm section
223        if macho.find_section("__SMOLVM", "__smolvm").is_none() {
224            return Err(PackedEmbeddedError::NotMachO(Box::new(self))); // No section, fall back
225        }
226
227        // Valid Mach-O with section, proceed with Mach-O packing
228        match self.pack_embedded_macho_inner(output, stub_data, macho) {
229            Ok(info) => Ok(info),
230            Err(e) => Err(PackedEmbeddedError::PackError(e)),
231        }
232    }
233
234    /// Pack using Mach-O section manipulation (macOS), inner implementation.
235    ///
236    /// This writes assets to the `__SMOLVM,__smolvm` section, keeping the
237    /// binary as a valid Mach-O that can be properly code-signed.
238    #[cfg(target_os = "macos")]
239    fn pack_embedded_macho_inner(
240        self,
241        output: &Path,
242        stub_data: Vec<u8>,
243        mut macho: crate::macho::MachoFile,
244    ) -> Result<PackedInfo> {
245        use crate::format::{SectionHeader, SECTION_HEADER_SIZE};
246
247        let stub_size = stub_data.len() as u64;
248        let temp_dir = tempfile::tempdir()?;
249
250        // Compress assets
251        let assets_temp = temp_dir.path().join("assets.tar.zst");
252        let assets_size = if let Some(collector) = &self.asset_collector {
253            collector.compress(&assets_temp, false)? // include libs in single-file mode
254        } else {
255            let empty_file = File::create(&assets_temp)?;
256            let encoder = zstd::stream::Encoder::new(empty_file, 1)?;
257            let tar_builder = tar::Builder::new(encoder);
258            let encoder = tar_builder.into_inner()?;
259            encoder.finish()?;
260            fs::metadata(&assets_temp)?.len()
261        };
262
263        // Serialize manifest
264        let manifest_json = self.manifest.to_json()?;
265        let manifest_size = manifest_json.len() as u32;
266
267        // Calculate checksum of manifest + assets
268        let mut hasher = crc32fast::Hasher::new();
269        hasher.update(&manifest_json);
270        let assets_data = fs::read(&assets_temp)?;
271        hasher.update(&assets_data);
272        let checksum = hasher.finalize();
273
274        // Build section data: header + manifest + assets
275        let header = SectionHeader {
276            manifest_size,
277            assets_size,
278            checksum,
279        };
280
281        let mut section_data =
282            Vec::with_capacity(SECTION_HEADER_SIZE + manifest_json.len() + assets_data.len());
283        section_data.extend_from_slice(&header.to_bytes());
284        section_data.extend_from_slice(&manifest_json);
285        section_data.extend_from_slice(&assets_data);
286
287        // Write section data to Mach-O
288        macho
289            .write_section("__SMOLVM", "__smolvm", &section_data)
290            .map_err(|e| crate::PackError::Signing(format!("failed to write section: {}", e)))?;
291
292        // Sign the binary with adhoc signature
293        macho
294            .sign_adhoc()
295            .map_err(|e| crate::PackError::Signing(format!("failed to sign: {}", e)))?;
296
297        // Write output
298        let output_data = macho.write();
299        let total_size = output_data.len() as u64;
300        fs::write(output, &output_data)?;
301
302        // Make executable
303        #[cfg(unix)]
304        {
305            use std::os::unix::fs::PermissionsExt;
306            let mut perms = fs::metadata(output)?.permissions();
307            perms.set_mode(0o755);
308            fs::set_permissions(output, perms)?;
309        }
310
311        Ok(PackedInfo {
312            stub_size,
313            assets_size,
314            manifest_size: manifest_size as u64,
315            total_size,
316            checksum,
317            sidecar_path: None,
318        })
319    }
320
321    /// Pack by appending assets to binary.
322    ///
323    /// This is the fallback method on macOS (when stub isn't a valid Mach-O)
324    /// and the default method on other platforms.
325    fn pack_embedded_append(self, output: impl AsRef<Path>) -> Result<PackedInfo> {
326        let output = output.as_ref();
327        let temp_dir = tempfile::tempdir()?;
328
329        // Get stub executable
330        let stub_path = self
331            .stub_path
332            .as_ref()
333            .ok_or_else(|| crate::PackError::AssetNotFound("stub executable".to_string()))?;
334
335        // 1. Copy stub executable to output
336        let stub_data = fs::read(stub_path)?;
337        let stub_size = stub_data.len() as u64;
338        let mut output_file = File::create(output)?;
339        output_file.write_all(&stub_data)?;
340
341        // 2. Compress and append assets
342        let assets_temp = temp_dir.path().join("assets.tar.zst");
343        let assets_size = if let Some(collector) = &self.asset_collector {
344            collector.compress(&assets_temp, false)? // include libs in append mode
345        } else {
346            let empty_file = File::create(&assets_temp)?;
347            let encoder = zstd::stream::Encoder::new(empty_file, 1)?;
348            let tar_builder = tar::Builder::new(encoder);
349            let encoder = tar_builder.into_inner()?;
350            encoder.finish()?;
351            fs::metadata(&assets_temp)?.len()
352        };
353
354        let assets_offset = stub_size; // Assets start right after stub
355        let mut assets_file = File::open(&assets_temp)?;
356        std::io::copy(&mut assets_file, &mut output_file)?;
357
358        // 3. Append manifest JSON
359        let manifest_offset = stub_size + assets_size;
360        let manifest_json = self.manifest.to_json()?;
361        let manifest_size = manifest_json.len() as u64;
362        output_file.write_all(&manifest_json)?;
363
364        // 4. Calculate checksum of assets + manifest
365        output_file.flush()?;
366        drop(output_file);
367        let checksum_size = assets_size + manifest_size;
368        let checksum = crc32_file_range(output, assets_offset, checksum_size)?;
369
370        // 5. Append footer
371        let footer = PackFooter {
372            stub_size,
373            assets_offset, // Non-zero indicates embedded mode
374            assets_size,
375            manifest_offset,
376            manifest_size,
377            checksum,
378        };
379
380        let mut output_file = fs::OpenOptions::new().append(true).open(output)?;
381        output_file.write_all(&footer.to_bytes())?;
382
383        // Make executable
384        #[cfg(unix)]
385        {
386            use std::os::unix::fs::PermissionsExt;
387            let mut perms = fs::metadata(output)?.permissions();
388            perms.set_mode(0o755);
389            fs::set_permissions(output, perms)?;
390        }
391
392        let total_size = stub_size + assets_size + manifest_size + FOOTER_SIZE as u64;
393
394        Ok(PackedInfo {
395            stub_size,
396            assets_size,
397            manifest_size,
398            total_size,
399            checksum,
400            sidecar_path: None, // No sidecar in embedded mode
401        })
402    }
403}
404
405/// Append compressed runtime libraries to a packed binary.
406///
407/// Call this AFTER code signing — the SMOLLIBS footer must be the last
408/// bytes of the file so `extract_libs_from_binary()` can find it.
409pub fn embed_libs_in_binary(binary_path: &Path, staging_dir: &Path) -> Result<()> {
410    use crate::format::LibsFooter;
411
412    let lib_dir = staging_dir.join("lib");
413    if !lib_dir.exists() {
414        return Ok(()); // No libs to embed
415    }
416
417    // Check if there are actual library files
418    let has_libs = fs::read_dir(&lib_dir)?
419        .filter_map(|e| e.ok())
420        .any(|e| e.path().is_file());
421    if !has_libs {
422        return Ok(());
423    }
424
425    let temp_dir = tempfile::tempdir()?;
426    let libs_temp = temp_dir.path().join("libs.tar.zst");
427
428    // Compress libs
429    let output_file = File::create(&libs_temp)?;
430    let encoder = zstd::stream::Encoder::new(output_file, crate::assets::ZSTD_LEVEL)
431        .map_err(|e| crate::PackError::Compression(e.to_string()))?;
432    let mut tar_builder = tar::Builder::new(encoder);
433    tar_builder
434        .append_dir_all("lib", &lib_dir)
435        .map_err(|e| crate::PackError::Tar(e.to_string()))?;
436    let encoder = tar_builder
437        .into_inner()
438        .map_err(|e| crate::PackError::Tar(e.to_string()))?;
439    encoder
440        .finish()
441        .map_err(|e| crate::PackError::Compression(e.to_string()))?;
442
443    let libs_size = fs::metadata(&libs_temp)?.len();
444    let binary_size = fs::metadata(binary_path)?.len();
445
446    // Append libs blob + footer
447    let mut output_file = fs::OpenOptions::new().append(true).open(binary_path)?;
448    let mut libs_file = File::open(&libs_temp)?;
449    std::io::copy(&mut libs_file, &mut output_file)?;
450
451    let libs_footer = LibsFooter {
452        libs_offset: binary_size,
453        libs_size,
454    };
455    output_file.write_all(&libs_footer.to_bytes())?;
456
457    Ok(())
458}
459
460/// Get the sidecar path for a packed binary.
461pub fn sidecar_path_for(binary_path: impl AsRef<Path>) -> PathBuf {
462    let mut path = binary_path.as_ref().to_path_buf();
463    let filename = path
464        .file_name()
465        .map(|s| s.to_string_lossy().to_string())
466        .unwrap_or_default();
467    path.set_file_name(format!("{}{}", filename, SIDECAR_EXTENSION));
468    path
469}
470
471/// Information about a packed binary.
472#[derive(Debug, Clone)]
473pub struct PackedInfo {
474    /// Size of stub executable.
475    pub stub_size: u64,
476    /// Size of compressed assets.
477    pub assets_size: u64,
478    /// Size of manifest JSON.
479    pub manifest_size: u64,
480    /// Total size (binary + sidecar).
481    pub total_size: u64,
482    /// CRC32 checksum of assets.
483    pub checksum: u32,
484    /// Path to sidecar file (if using sidecar mode).
485    pub sidecar_path: Option<PathBuf>,
486}
487
488/// Read footer from a sidecar file.
489///
490/// Validates structural bounds: footer-derived sizes must be consistent with
491/// the actual file size and within safe limits.
492pub fn read_footer_from_sidecar(sidecar_path: impl AsRef<Path>) -> Result<PackFooter> {
493    let mut file = File::open(sidecar_path.as_ref())?;
494    let file_size = file.metadata()?.len();
495
496    if file_size < FOOTER_SIZE as u64 {
497        return Err(crate::PackError::Io(std::io::Error::new(
498            std::io::ErrorKind::InvalidData,
499            "sidecar file too small to contain footer",
500        )));
501    }
502
503    file.seek(SeekFrom::End(-(FOOTER_SIZE as i64)))?;
504    let mut footer_bytes = [0u8; FOOTER_SIZE];
505    file.read_exact(&mut footer_bytes)?;
506
507    let footer = PackFooter::from_bytes(&footer_bytes)?;
508
509    // Validate footer-derived sizes against actual file size
510    validate_footer_bounds(&footer, file_size)?;
511
512    Ok(footer)
513}
514
515/// Read manifest from a sidecar file.
516pub fn read_manifest_from_sidecar(sidecar_path: impl AsRef<Path>) -> Result<PackManifest> {
517    let footer = read_footer_from_sidecar(sidecar_path.as_ref())?;
518
519    let mut file = File::open(sidecar_path.as_ref())?;
520    file.seek(SeekFrom::Start(footer.manifest_offset))?;
521
522    let mut manifest_bytes = vec![0u8; footer.manifest_size as usize];
523    file.read_exact(&mut manifest_bytes)?;
524
525    PackManifest::from_json(&manifest_bytes)
526}
527
528/// Read footer from a packed binary (deprecated - use sidecar instead).
529pub fn read_footer(path: impl AsRef<Path>) -> Result<PackFooter> {
530    // Try sidecar first
531    let sidecar = sidecar_path_for(path.as_ref());
532    if sidecar.exists() {
533        return read_footer_from_sidecar(&sidecar);
534    }
535
536    // Fall back to embedded (v1 format)
537    let mut file = File::open(path.as_ref())?;
538    let file_size = file.metadata()?.len();
539
540    if file_size < FOOTER_SIZE as u64 {
541        return Err(crate::PackError::Io(std::io::Error::new(
542            std::io::ErrorKind::InvalidData,
543            "file too small to contain footer",
544        )));
545    }
546
547    file.seek(SeekFrom::End(-(FOOTER_SIZE as i64)))?;
548    let mut footer_bytes = [0u8; FOOTER_SIZE];
549    file.read_exact(&mut footer_bytes)?;
550
551    PackFooter::from_bytes(&footer_bytes)
552}
553
554/// Read manifest from a packed binary (deprecated - use sidecar instead).
555pub fn read_manifest(path: impl AsRef<Path>) -> Result<PackManifest> {
556    // Try sidecar first
557    let sidecar = sidecar_path_for(path.as_ref());
558    if sidecar.exists() {
559        return read_manifest_from_sidecar(&sidecar);
560    }
561
562    // Fall back to embedded (v1 format)
563    let footer = read_footer(path.as_ref())?;
564
565    let mut file = File::open(path.as_ref())?;
566    file.seek(SeekFrom::Start(footer.manifest_offset))?;
567
568    let mut manifest_bytes = vec![0u8; footer.manifest_size as usize];
569    file.read_exact(&mut manifest_bytes)?;
570
571    PackManifest::from_json(&manifest_bytes)
572}
573
574/// Check if a packed binary uses sidecar mode.
575pub fn is_sidecar_mode(footer: &PackFooter) -> bool {
576    footer.assets_offset == 0
577}
578
579/// Verify checksum of a packed binary.
580pub fn verify_checksum(path: impl AsRef<Path>) -> Result<bool> {
581    let footer = read_footer(path.as_ref())?;
582
583    if is_sidecar_mode(&footer) {
584        // Sidecar mode: checksum is of assets + manifest (not the footer)
585        let sidecar = sidecar_path_for(path.as_ref());
586        if !sidecar.exists() {
587            return Ok(false);
588        }
589        let checksum_size = footer.assets_size + footer.manifest_size;
590        let actual = crc32_file_range(&sidecar, 0, checksum_size)?;
591        Ok(actual == footer.checksum)
592    } else {
593        // Embedded mode: checksum is of assets + manifest
594        let checksum_size = footer.assets_size + footer.manifest_size;
595        let actual = crc32_file_range(path.as_ref(), footer.assets_offset, checksum_size)?;
596        Ok(actual == footer.checksum)
597    }
598}
599
600/// Verify checksum of a sidecar file.
601///
602/// Computes CRC32 over the assets + manifest region and compares to the
603/// checksum stored in the footer.
604pub fn verify_sidecar_checksum(
605    sidecar_path: impl AsRef<Path>,
606    footer: &PackFooter,
607) -> Result<bool> {
608    let checksum_size = footer
609        .assets_size
610        .checked_add(footer.manifest_size)
611        .ok_or_else(|| {
612            crate::PackError::Io(std::io::Error::new(
613                std::io::ErrorKind::InvalidData,
614                "assets_size + manifest_size overflow",
615            ))
616        })?;
617    let actual = crc32_file_range(sidecar_path.as_ref(), 0, checksum_size)?;
618    Ok(actual == footer.checksum)
619}
620
621/// Validate that footer-derived sizes are consistent with the actual file size.
622fn validate_footer_bounds(footer: &PackFooter, file_size: u64) -> Result<()> {
623    // Cap manifest size to prevent excessive memory allocation
624    if footer.manifest_size > MAX_MANIFEST_SIZE {
625        return Err(crate::PackError::Io(std::io::Error::new(
626            std::io::ErrorKind::InvalidData,
627            format!(
628                "manifest size ({} bytes) exceeds maximum ({} bytes)",
629                footer.manifest_size, MAX_MANIFEST_SIZE
630            ),
631        )));
632    }
633
634    // Verify that assets + manifest + footer fit within the file
635    let content_end = footer
636        .assets_size
637        .checked_add(footer.manifest_size)
638        .and_then(|s| s.checked_add(FOOTER_SIZE as u64));
639
640    match content_end {
641        Some(end) if end <= file_size => {}
642        _ => {
643            return Err(crate::PackError::Io(std::io::Error::new(
644                std::io::ErrorKind::InvalidData,
645                "footer sizes exceed file size (corrupt or truncated sidecar)",
646            )));
647        }
648    }
649
650    // In sidecar mode (assets_offset == 0), the layout is:
651    //   [assets_blob | manifest_json | footer]
652    // so manifest_offset MUST equal assets_size.  If it doesn't, the
653    // manifest bytes that read_manifest_from_sidecar parses could differ
654    // from the region covered by the checksum (0..assets_size+manifest_size),
655    // creating a trust gap between verified and parsed data.
656    if footer.assets_offset == 0 && footer.manifest_offset != footer.assets_size {
657        return Err(crate::PackError::Io(std::io::Error::new(
658            std::io::ErrorKind::InvalidData,
659            format!(
660                "sidecar manifest_offset ({}) does not match assets_size ({}): corrupt footer",
661                footer.manifest_offset, footer.assets_size
662            ),
663        )));
664    }
665
666    // Verify manifest_offset + manifest_size doesn't exceed the pre-footer region
667    let manifest_end = footer
668        .manifest_offset
669        .checked_add(footer.manifest_size)
670        .and_then(|end| end.checked_add(FOOTER_SIZE as u64));
671
672    match manifest_end {
673        Some(end) if end <= file_size => {}
674        _ => {
675            return Err(crate::PackError::Io(std::io::Error::new(
676                std::io::ErrorKind::InvalidData,
677                "manifest offset/size exceeds file bounds",
678            )));
679        }
680    }
681
682    Ok(())
683}
684
685/// Extract assets from a packed binary to a directory.
686pub fn extract_assets(packed_path: impl AsRef<Path>, output_dir: impl AsRef<Path>) -> Result<()> {
687    let footer = read_footer(packed_path.as_ref())?;
688
689    if is_sidecar_mode(&footer) {
690        // Sidecar mode: read from .smolmachine file
691        let sidecar = sidecar_path_for(packed_path.as_ref());
692        crate::assets::decompress_assets_from_file(&sidecar, output_dir.as_ref())?;
693    } else {
694        // Embedded mode: read from the binary itself
695        let mut file = File::open(packed_path.as_ref())?;
696        file.seek(SeekFrom::Start(footer.assets_offset))?;
697
698        // Read compressed assets
699        let mut compressed = vec![0u8; footer.assets_size as usize];
700        file.read_exact(&mut compressed)?;
701
702        // Decompress
703        crate::assets::decompress_assets(&compressed, output_dir.as_ref())?;
704    }
705
706    Ok(())
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use std::io::Write;
713
714    #[test]
715    fn test_pack_and_read() {
716        let temp_dir = tempfile::tempdir().unwrap();
717
718        // Create a dummy stub
719        let stub_path = temp_dir.path().join("stub");
720        let mut stub_file = File::create(&stub_path).unwrap();
721        stub_file.write_all(b"#!/bin/sh\necho stub").unwrap();
722
723        // Create manifest
724        let manifest = PackManifest::new(
725            "alpine:latest".to_string(),
726            "sha256:abc123".to_string(),
727            "linux/arm64".to_string(),
728            "darwin/arm64".to_string(),
729        );
730
731        // Pack
732        let output_path = temp_dir.path().join("packed");
733        let packer = Packer::new(manifest).with_stub(&stub_path);
734        let info = packer.pack(&output_path).unwrap();
735
736        assert!(info.stub_size > 0);
737        assert!(info.total_size > info.stub_size);
738
739        // Verify sidecar file exists
740        let sidecar = sidecar_path_for(&output_path);
741        assert!(sidecar.exists());
742
743        // Read back - sidecar mode has stub_size=0 in footer
744        let footer = read_footer(&output_path).unwrap();
745        assert_eq!(footer.stub_size, 0); // Sidecar mode doesn't track stub size in footer
746        assert_eq!(footer.assets_offset, 0); // Sidecar mode indicator
747
748        let manifest = read_manifest(&output_path).unwrap();
749        assert_eq!(manifest.image, "alpine:latest");
750
751        // Verify checksum
752        assert!(verify_checksum(&output_path).unwrap());
753    }
754
755    #[test]
756    fn test_pack_with_assets() {
757        let temp_dir = tempfile::tempdir().unwrap();
758
759        // Create a dummy stub
760        let stub_path = temp_dir.path().join("stub");
761        fs::write(&stub_path, b"#!/bin/sh\necho stub").unwrap();
762
763        // Create staging directory with a test file
764        let staging = temp_dir.path().join("staging");
765        let mut collector = AssetCollector::new(staging).unwrap();
766
767        // Add a fake layer
768        collector
769            .add_layer("sha256:abc123def456", b"layer content")
770            .unwrap();
771
772        // Create manifest
773        let manifest = PackManifest::new(
774            "test:latest".to_string(),
775            "sha256:test".to_string(),
776            "linux/arm64".to_string(),
777            "darwin/arm64".to_string(),
778        );
779
780        // Pack with assets
781        let output_path = temp_dir.path().join("packed");
782        let packer = Packer::new(manifest)
783            .with_stub(&stub_path)
784            .with_assets(collector);
785        packer.pack(&output_path).unwrap();
786
787        // Verify we can read the manifest with layer info
788        let manifest = read_manifest(&output_path).unwrap();
789        assert_eq!(manifest.assets.layers.len(), 1);
790        assert_eq!(manifest.assets.layers[0].digest, "sha256:abc123def456");
791
792        // Extract and verify assets
793        let extract_dir = temp_dir.path().join("extracted");
794        extract_assets(&output_path, &extract_dir).unwrap();
795
796        let layer_file = extract_dir.join("layers/abc123def456.tar");
797        assert!(layer_file.exists());
798        assert_eq!(fs::read_to_string(&layer_file).unwrap(), "layer content");
799    }
800
801    #[test]
802    fn test_pack_embedded() {
803        let temp_dir = tempfile::tempdir().unwrap();
804
805        // Create a dummy stub
806        let stub_path = temp_dir.path().join("stub");
807        fs::write(&stub_path, b"#!/bin/sh\necho stub").unwrap();
808
809        // Create manifest
810        let manifest = PackManifest::new(
811            "alpine:latest".to_string(),
812            "sha256:abc123".to_string(),
813            "linux/arm64".to_string(),
814            "darwin/arm64".to_string(),
815        );
816
817        // Pack embedded (single file)
818        let output_path = temp_dir.path().join("packed-single");
819        let packer = Packer::new(manifest).with_stub(&stub_path);
820        let info = packer.pack_embedded(&output_path).unwrap();
821
822        // Should have no sidecar
823        assert!(info.sidecar_path.is_none());
824        assert!(info.stub_size > 0);
825        assert!(info.total_size > info.stub_size);
826
827        // Sidecar file should NOT exist
828        let sidecar = sidecar_path_for(&output_path);
829        assert!(!sidecar.exists());
830
831        // Output file should contain everything
832        let output_size = fs::metadata(&output_path).unwrap().len();
833        assert_eq!(output_size, info.total_size);
834
835        // Read back - embedded mode has non-zero assets_offset
836        let footer = read_footer(&output_path).unwrap();
837        assert!(footer.assets_offset > 0); // Embedded mode indicator
838        assert_eq!(footer.stub_size, info.stub_size);
839
840        let manifest = read_manifest(&output_path).unwrap();
841        assert_eq!(manifest.image, "alpine:latest");
842
843        // Verify checksum
844        assert!(verify_checksum(&output_path).unwrap());
845    }
846
847    #[test]
848    fn test_pack_embedded_with_assets() {
849        let temp_dir = tempfile::tempdir().unwrap();
850
851        // Create a dummy stub
852        let stub_path = temp_dir.path().join("stub");
853        fs::write(&stub_path, b"#!/bin/sh\necho stub").unwrap();
854
855        // Create staging directory with a test file
856        let staging = temp_dir.path().join("staging");
857        let mut collector = AssetCollector::new(staging).unwrap();
858
859        // Add a fake layer. The digest must be at least 12 characters after
860        // `sha256:` and valid hex — it is used as a filesystem path, so
861        // `add_layer` rejects anything else (see 680ba8d9).
862        collector
863            .add_layer("sha256:abcdef123456", b"embedded layer content")
864            .unwrap();
865
866        // Create manifest
867        let manifest = PackManifest::new(
868            "test:embedded".to_string(),
869            "sha256:test".to_string(),
870            "linux/arm64".to_string(),
871            "darwin/arm64".to_string(),
872        );
873
874        // Pack embedded with assets
875        let output_path = temp_dir.path().join("packed-embedded");
876        let packer = Packer::new(manifest)
877            .with_stub(&stub_path)
878            .with_assets(collector);
879        packer.pack_embedded(&output_path).unwrap();
880
881        // Verify we can read the manifest with layer info
882        let manifest = read_manifest(&output_path).unwrap();
883        assert_eq!(manifest.assets.layers.len(), 1);
884        assert_eq!(manifest.assets.layers[0].digest, "sha256:abcdef123456");
885
886        // Verify footer indicates embedded mode
887        let footer = read_footer(&output_path).unwrap();
888        assert!(footer.assets_offset > 0);
889        assert!(!is_sidecar_mode(&footer));
890
891        // Extract and verify assets
892        let extract_dir = temp_dir.path().join("extracted");
893        extract_assets(&output_path, &extract_dir).unwrap();
894
895        let layer_file = extract_dir.join("layers/abcdef123456.tar"); // First 12 chars
896        assert!(layer_file.exists());
897        assert_eq!(
898            fs::read_to_string(&layer_file).unwrap(),
899            "embedded layer content"
900        );
901    }
902
903    #[test]
904    fn test_sidecar_checksum_verification() {
905        let temp_dir = tempfile::tempdir().unwrap();
906
907        // Create a dummy stub
908        let stub_path = temp_dir.path().join("stub");
909        fs::write(&stub_path, b"#!/bin/sh\necho stub").unwrap();
910
911        let manifest = PackManifest::new(
912            "alpine:latest".to_string(),
913            "sha256:abc123".to_string(),
914            "linux/arm64".to_string(),
915            "darwin/arm64".to_string(),
916        );
917
918        // Pack sidecar mode
919        let output_path = temp_dir.path().join("packed");
920        let packer = Packer::new(manifest).with_stub(&stub_path);
921        packer.pack(&output_path).unwrap();
922
923        let sidecar = sidecar_path_for(&output_path);
924        let footer = read_footer_from_sidecar(&sidecar).unwrap();
925
926        // Valid sidecar should pass
927        assert!(verify_sidecar_checksum(&sidecar, &footer).unwrap());
928
929        // Corrupt the sidecar by flipping a byte in the assets region
930        let mut data = fs::read(&sidecar).unwrap();
931        if !data.is_empty() {
932            data[0] ^= 0xFF;
933        }
934        fs::write(&sidecar, &data).unwrap();
935
936        // Corrupted sidecar should fail checksum
937        assert!(!verify_sidecar_checksum(&sidecar, &footer).unwrap());
938    }
939
940    #[test]
941    fn test_footer_bounds_reject_oversized_manifest() {
942        let temp_dir = tempfile::tempdir().unwrap();
943        let sidecar = temp_dir.path().join("bad.smolmachine");
944
945        // Craft a footer with manifest_size exceeding the 16 MiB cap
946        let footer = PackFooter {
947            stub_size: 0,
948            assets_offset: 0,
949            assets_size: 100,
950            manifest_offset: 100,
951            manifest_size: 32 * 1024 * 1024, // 32 MiB — exceeds cap
952            checksum: 0,
953        };
954
955        // Write a minimal sidecar: some bytes + footer
956        let footer_bytes = footer.to_bytes();
957        let mut data = vec![0u8; 200]; // some dummy content
958        data.extend_from_slice(&footer_bytes);
959        fs::write(&sidecar, &data).unwrap();
960
961        let result = read_footer_from_sidecar(&sidecar);
962        assert!(result.is_err());
963        let err_msg = result.unwrap_err().to_string();
964        assert!(err_msg.contains("exceeds maximum"), "got: {}", err_msg);
965    }
966
967    #[test]
968    fn test_footer_bounds_reject_manifest_offset_mismatch() {
969        let temp_dir = tempfile::tempdir().unwrap();
970        let sidecar = temp_dir.path().join("mismatch.smolmachine");
971
972        // In sidecar mode (assets_offset=0), manifest_offset must == assets_size.
973        // Craft a footer where manifest_offset points elsewhere.
974        let footer = PackFooter {
975            stub_size: 0,
976            assets_offset: 0, // sidecar mode
977            assets_size: 100,
978            manifest_offset: 50, // should be 100 — points into assets region
979            manifest_size: 50,
980            checksum: 0,
981        };
982
983        let footer_bytes = footer.to_bytes();
984        // File needs: 100 (assets) + 50 (manifest) + 64 (footer) = 214 bytes
985        let mut data = vec![0u8; 150];
986        data.extend_from_slice(&footer_bytes);
987        fs::write(&sidecar, &data).unwrap();
988
989        let result = read_footer_from_sidecar(&sidecar);
990        assert!(result.is_err());
991        let err_msg = result.unwrap_err().to_string();
992        assert!(
993            err_msg.contains("manifest_offset") && err_msg.contains("does not match"),
994            "got: {}",
995            err_msg
996        );
997    }
998
999    #[test]
1000    fn test_sidecar_vs_embedded_mode_detection() {
1001        let temp_dir = tempfile::tempdir().unwrap();
1002
1003        // Create a dummy stub
1004        let stub_path = temp_dir.path().join("stub");
1005        fs::write(&stub_path, b"#!/bin/sh\necho stub").unwrap();
1006
1007        let manifest = PackManifest::new(
1008            "test:latest".to_string(),
1009            "sha256:test".to_string(),
1010            "linux/arm64".to_string(),
1011            "darwin/arm64".to_string(),
1012        );
1013
1014        // Pack sidecar mode
1015        let sidecar_output = temp_dir.path().join("sidecar-mode");
1016        let packer = Packer::new(manifest.clone()).with_stub(&stub_path);
1017        packer.pack(&sidecar_output).unwrap();
1018
1019        // Pack embedded mode
1020        let embedded_output = temp_dir.path().join("embedded-mode");
1021        let packer = Packer::new(manifest).with_stub(&stub_path);
1022        packer.pack_embedded(&embedded_output).unwrap();
1023
1024        // Verify mode detection
1025        let sidecar_footer = read_footer(&sidecar_output).unwrap();
1026        let embedded_footer = read_footer(&embedded_output).unwrap();
1027
1028        assert!(is_sidecar_mode(&sidecar_footer));
1029        assert!(!is_sidecar_mode(&embedded_footer));
1030
1031        // Both should have valid checksums
1032        assert!(verify_checksum(&sidecar_output).unwrap());
1033        assert!(verify_checksum(&embedded_output).unwrap());
1034    }
1035}