1use crate::format::{PackFooter, SIDECAR_EXTENSION};
7use std::fs::{self, File};
8use std::io::{Read, Seek, SeekFrom, Write};
9use std::path::{Path, PathBuf};
10
11#[cfg(unix)]
12use std::os::unix::fs::PermissionsExt;
13#[cfg(unix)]
14use std::os::unix::io::AsRawFd;
15
16#[cfg(windows)]
25pub(crate) fn mark_file_sparse(file: &fs::File) -> std::io::Result<()> {
26 use std::os::windows::io::AsRawHandle;
27 use windows_sys::Win32::System::IO::DeviceIoControl;
28 const FSCTL_SET_SPARSE: u32 = 0x000900C4;
30 let mut returned: u32 = 0;
31 let ok = unsafe {
33 DeviceIoControl(
34 file.as_raw_handle(),
35 FSCTL_SET_SPARSE,
36 std::ptr::null(),
37 0,
38 std::ptr::null_mut(),
39 0,
40 &mut returned,
41 std::ptr::null_mut(),
42 )
43 };
44 if ok == 0 {
45 Err(std::io::Error::last_os_error())
46 } else {
47 Ok(())
48 }
49}
50
51#[cfg(unix)]
58fn lock_file_exclusive(lock_file: &fs::File) -> std::io::Result<()> {
59 let ret = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) };
60 if ret != 0 {
61 return Err(std::io::Error::last_os_error());
62 }
63 Ok(())
64}
65
66#[cfg(windows)]
67fn lock_file_exclusive(lock_file: &fs::File) -> std::io::Result<()> {
68 use std::os::windows::io::AsRawHandle;
69 use windows_sys::Win32::Storage::FileSystem::{LockFileEx, LOCKFILE_EXCLUSIVE_LOCK};
70 use windows_sys::Win32::System::IO::OVERLAPPED;
71
72 let handle = lock_file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
73 let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
74 let ret = unsafe {
76 LockFileEx(
77 handle,
78 LOCKFILE_EXCLUSIVE_LOCK,
79 0,
80 u32::MAX,
81 u32::MAX,
82 &mut overlapped,
83 )
84 };
85 if ret == 0 {
86 return Err(std::io::Error::last_os_error());
87 }
88 Ok(())
89}
90
91#[inline]
94fn set_mode(path: &Path, mode: u32) {
95 #[cfg(unix)]
96 {
97 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode));
98 }
99 #[cfg(not(unix))]
100 {
101 let _ = (path, mode);
102 }
103}
104
105const SPARSE_WRITE_THRESHOLD: u64 = 256 * 1024 * 1024; fn unpack_sparse<R: Read>(
120 entry: &mut tar::Entry<R>,
121 path: &Path,
122 entry_size: u64,
123 mode: u32,
124 real_dest: &Path,
125) -> std::io::Result<()> {
126 verify_parent_within_dest(path, real_dest)?;
135
136 if let Some(parent) = path.parent() {
138 fs::create_dir_all(parent)?;
139 }
140
141 match path.symlink_metadata() {
146 Ok(meta) if meta.file_type().is_symlink() => {
147 return Err(std::io::Error::new(
148 std::io::ErrorKind::InvalidData,
149 format!("unpack_sparse: symlink at destination: {}", path.display()),
150 ));
151 }
152 Ok(meta) if meta.file_type().is_dir() => {
153 return Err(std::io::Error::new(
154 std::io::ErrorKind::InvalidData,
155 format!(
156 "unpack_sparse: directory at destination: {}",
157 path.display()
158 ),
159 ));
160 }
161 Ok(_) => {
162 fs::remove_file(path)?;
165 }
166 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
167 Err(e) => return Err(e),
168 }
169
170 #[cfg(unix)]
173 let mut file = {
174 use std::os::unix::fs::OpenOptionsExt;
175 fs::OpenOptions::new()
176 .write(true)
177 .create_new(true)
178 .custom_flags(libc::O_NOFOLLOW)
179 .open(path)?
180 };
181 #[cfg(not(unix))]
182 let mut file = fs::OpenOptions::new()
183 .write(true)
184 .create_new(true)
185 .open(path)?;
186
187 #[cfg(windows)]
192 mark_file_sparse(&file)?;
193
194 file.set_len(entry_size)?;
197
198 let mut offset: u64 = 0;
199 let mut buf = vec![0u8; 64 * 1024];
200
201 loop {
202 let n = entry.read(&mut buf)?;
203 if n == 0 {
204 break;
205 }
206 let chunk = &buf[..n];
207 if chunk.iter().any(|&b| b != 0) {
208 file.seek(SeekFrom::Start(offset))?;
209 file.write_all(chunk)?;
210 }
211 offset += n as u64;
212 }
213
214 #[cfg(unix)]
223 fs::set_permissions(path, fs::Permissions::from_mode(mode & 0o777))?;
224 #[cfg(not(unix))]
225 let _ = mode;
226
227 Ok(())
228}
229
230fn verify_parent_within_dest(path: &Path, real_dest: &Path) -> std::io::Result<()> {
241 let Some(parent) = path.parent() else {
242 return Ok(());
243 };
244 for ancestor in parent.ancestors() {
247 match ancestor.canonicalize() {
248 Ok(real) => {
249 if real.starts_with(real_dest) {
250 return Ok(());
251 }
252 return Err(std::io::Error::new(
253 std::io::ErrorKind::InvalidData,
254 format!(
255 "resolved parent '{}' of '{}' escapes destination '{}'",
256 real.display(),
257 path.display(),
258 real_dest.display()
259 ),
260 ));
261 }
262 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
263 Err(e) => return Err(e),
264 }
265 }
266 Ok(())
267}
268
269fn safe_unpack<R: Read>(archive: &mut tar::Archive<R>, dest: &Path) -> std::io::Result<()> {
282 safe_unpack_with_limits(archive, dest, &SafeUnpackLimits::from_env())
283}
284
285#[derive(Clone, Copy)]
290struct SafeUnpackLimits {
291 max_entries: u64,
293 max_total_bytes: u64,
296 sparse_threshold: u64,
298}
299
300impl SafeUnpackLimits {
301 fn from_env() -> Self {
302 Self {
303 max_entries: max_extract_entries(),
304 max_total_bytes: max_extract_total_bytes(),
305 sparse_threshold: SPARSE_WRITE_THRESHOLD,
306 }
307 }
308}
309
310fn safe_unpack_with_limits<R: Read>(
311 archive: &mut tar::Archive<R>,
312 dest: &Path,
313 limits: &SafeUnpackLimits,
314) -> std::io::Result<()> {
315 let canonical_dest = normalize_path(dest);
322
323 let real_dest = dest
329 .canonicalize()
330 .unwrap_or_else(|_| canonical_dest.clone());
331
332 let max_entries = limits.max_entries;
337 let max_total_bytes = limits.max_total_bytes;
338 let mut entry_count: u64 = 0;
339 let mut total_bytes: u64 = 0;
340
341 let mut deferred_dir_modes: Vec<(PathBuf, u32)> = Vec::new();
348
349 for entry_result in archive.entries()? {
350 let mut entry = entry_result?;
351 let entry_type = entry.header().entry_type();
352 let entry_path = entry.path()?.to_path_buf();
353
354 entry_count += 1;
357 if entry_count > max_entries {
358 return Err(std::io::Error::new(
359 std::io::ErrorKind::InvalidData,
360 format!("tar archive exceeds max entry count ({max_entries})"),
361 ));
362 }
363 total_bytes = total_bytes.saturating_add(entry.header().size().unwrap_or(0));
364 if total_bytes > max_total_bytes {
365 return Err(std::io::Error::new(
366 std::io::ErrorKind::InvalidData,
367 format!("tar archive exceeds max total size ({max_total_bytes} bytes)"),
368 ));
369 }
370
371 match entry_type {
372 tar::EntryType::Regular
373 | tar::EntryType::GNUSparse
374 | tar::EntryType::Directory
375 | tar::EntryType::Continuous => {}
376 tar::EntryType::GNULongName
380 | tar::EntryType::GNULongLink
381 | tar::EntryType::XGlobalHeader
382 | tar::EntryType::XHeader => {
383 continue;
384 }
385 tar::EntryType::Symlink => {
386 if let Some(link_target) = entry.link_name()? {
388 let link_target = link_target.to_path_buf();
389 let target_str = link_target.to_string_lossy();
394 let resolved = if target_str.starts_with('/') {
396 dest.join(target_str.trim_start_matches('/'))
398 } else {
399 let parent = entry_path.parent().unwrap_or(Path::new(""));
400 dest.join(parent).join(&link_target)
401 };
402 let normalized = normalize_path(&resolved);
404 if !normalized.starts_with(&canonical_dest) {
405 return Err(std::io::Error::new(
406 std::io::ErrorKind::InvalidData,
407 format!(
408 "tar symlink '{}' -> '{}' escapes destination directory",
409 entry_path.display(),
410 link_target.display()
411 ),
412 ));
413 }
414 if target_str.starts_with('/') && normalized == canonical_dest {
425 return Err(std::io::Error::new(
426 std::io::ErrorKind::InvalidData,
427 format!(
428 "tar symlink '{}' -> '{}' aliases the destination root",
429 entry_path.display(),
430 link_target.display()
431 ),
432 ));
433 }
434 }
435 }
436 tar::EntryType::Link => {
437 if let Some(link_target) = entry.link_name()? {
439 let target_str = link_target.to_string_lossy();
441 let full_target = if target_str.starts_with('/') {
442 dest.join(target_str.trim_start_matches('/'))
443 } else {
444 dest.join(link_target.as_ref())
445 };
446 let normalized = normalize_path(&full_target);
447 if !normalized.starts_with(&canonical_dest) {
448 return Err(std::io::Error::new(
449 std::io::ErrorKind::InvalidData,
450 format!(
451 "tar hardlink '{}' escapes destination directory",
452 entry_path.display()
453 ),
454 ));
455 }
456 if !normalized.exists() {
460 continue;
461 }
462 }
463 }
464 tar::EntryType::Char | tar::EntryType::Block | tar::EntryType::Fifo => {
465 continue;
471 }
472 _other => {
473 continue;
479 }
480 }
481
482 let full_path = dest.join(&entry_path);
484 let normalized = normalize_path(&full_path);
485 if !normalized.starts_with(&canonical_dest) {
486 return Err(std::io::Error::new(
487 std::io::ErrorKind::InvalidData,
488 format!(
489 "tar entry '{}' escapes destination directory",
490 entry_path.display()
491 ),
492 ));
493 }
494
495 if let Some(parent) = full_path.parent() {
499 if parent.is_dir() {
500 set_mode(parent, 0o755);
501 }
502 }
503
504 if entry_type == tar::EntryType::Directory {
506 let mode = entry.header().mode().unwrap_or(0o755);
507 if mode & 0o200 == 0 {
508 deferred_dir_modes.push((full_path.clone(), mode));
509 }
510 }
511
512 let is_regular =
513 entry_type == tar::EntryType::Regular || entry_type == tar::EntryType::GNUSparse;
514
515 if is_regular && entry.header().size().unwrap_or(0) >= limits.sparse_threshold {
520 let entry_size = entry.header().size()?;
521 let mode = entry.header().mode().unwrap_or(0o644);
522 if let Err(e) = unpack_sparse(&mut entry, &full_path, entry_size, mode, &real_dest) {
523 return Err(std::io::Error::new(
524 e.kind(),
525 format!("failed to unpack '{}': {}", entry_path.display(), e),
526 ));
527 }
528 } else {
529 if let Err(e) = entry.unpack_in(dest) {
530 if !is_regular {
535 continue;
536 }
537 return Err(std::io::Error::new(
538 e.kind(),
539 format!("failed to unpack '{}': {}", entry_path.display(), e),
540 ));
541 }
542
543 if entry_type == tar::EntryType::Directory && full_path.is_dir() {
547 set_mode(&full_path, 0o755);
548 }
549 }
550 }
551
552 for (path, mode) in deferred_dir_modes {
554 if path.is_dir() {
555 set_mode(&path, mode);
556 }
557 }
558
559 Ok(())
560}
561
562fn normalize_path(path: &Path) -> PathBuf {
565 let mut components = Vec::new();
566 for component in path.components() {
567 match component {
568 std::path::Component::ParentDir => {
569 components.pop();
570 }
571 std::path::Component::CurDir => {}
572 c => components.push(c),
573 }
574 }
575 components.iter().collect()
576}
577
578pub fn resolve_cache_asset_path(
581 cache_dir: &Path,
582 asset_rel_path: &str,
583 context: &str,
584) -> std::io::Result<PathBuf> {
585 if asset_rel_path.is_empty() {
586 return Err(std::io::Error::new(
587 std::io::ErrorKind::InvalidInput,
588 format!("{} path is empty", context),
589 ));
590 }
591
592 let rel = Path::new(asset_rel_path);
593 if rel.is_absolute() {
594 return Err(std::io::Error::new(
595 std::io::ErrorKind::InvalidInput,
596 format!("{} path must be relative", context),
597 ));
598 }
599
600 for component in rel.components() {
601 match component {
602 std::path::Component::Normal(_) => {}
603 std::path::Component::ParentDir
604 | std::path::Component::CurDir
605 | std::path::Component::RootDir
606 | std::path::Component::Prefix(_) => {
607 return Err(std::io::Error::new(
608 std::io::ErrorKind::InvalidInput,
609 format!("{} path contains disallowed components", context),
610 ));
611 }
612 }
613 }
614
615 let cache_root = cache_dir
616 .canonicalize()
617 .unwrap_or_else(|_| normalize_path(cache_dir));
618 let candidate = cache_dir.join(rel);
619
620 let resolved = if candidate.exists() {
621 candidate.canonicalize()?
622 } else {
623 let parent = candidate.parent().unwrap_or(&candidate);
629 let canonical_parent = parent
630 .canonicalize()
631 .unwrap_or_else(|_| normalize_path(parent));
632 canonical_parent.join(candidate.file_name().unwrap_or_default())
633 };
634
635 if !resolved.starts_with(&cache_root) {
636 return Err(std::io::Error::new(
637 std::io::ErrorKind::InvalidInput,
638 format!("{} path escapes cache directory", context),
639 ));
640 }
641
642 Ok(resolved)
643}
644
645const EXTRACTION_MARKER: &str = ".smolvm-extracted";
647
648pub fn get_cache_dir(checksum: u32) -> std::io::Result<PathBuf> {
658 let base = dirs::cache_dir()
659 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no cache directory"))?;
660
661 Ok(base.join("smolvm-pack").join(format!("{:08x}", checksum)))
662}
663
664pub fn is_extracted(cache_dir: &Path) -> bool {
666 cache_dir.join(EXTRACTION_MARKER).exists()
667}
668
669pub fn pack_cache_max_bytes() -> u64 {
672 const DEFAULT: u64 = 5 * 1024 * 1024 * 1024;
673 std::env::var("SMOLVM_PACK_CACHE_MAX_BYTES")
674 .ok()
675 .and_then(|s| s.trim().parse::<u64>().ok())
676 .filter(|&n| n > 0)
677 .unwrap_or(DEFAULT)
678}
679
680fn max_extract_entries() -> u64 {
684 const DEFAULT: u64 = 2_000_000;
685 std::env::var("SMOLVM_PACK_MAX_ENTRIES")
686 .ok()
687 .and_then(|s| s.trim().parse::<u64>().ok())
688 .filter(|&n| n > 0)
689 .unwrap_or(DEFAULT)
690}
691
692fn max_extract_total_bytes() -> u64 {
697 const DEFAULT: u64 = 128 * 1024 * 1024 * 1024;
698 std::env::var("SMOLVM_PACK_MAX_EXTRACT_BYTES")
699 .ok()
700 .and_then(|s| s.trim().parse::<u64>().ok())
701 .filter(|&n| n > 0)
702 .unwrap_or(DEFAULT)
703}
704
705#[cfg(unix)]
710fn file_disk_usage(meta: &fs::Metadata) -> u64 {
711 use std::os::unix::fs::MetadataExt;
712 meta.blocks().saturating_mul(512)
713}
714#[cfg(not(unix))]
715fn file_disk_usage(meta: &fs::Metadata) -> u64 {
716 meta.len()
717}
718
719fn dir_disk_usage(path: &Path) -> u64 {
722 let mut total = 0u64;
723 let entries = match fs::read_dir(path) {
724 Ok(e) => e,
725 Err(_) => return 0,
726 };
727 for entry in entries.flatten() {
728 let meta = match entry.metadata() {
729 Ok(m) => m,
730 Err(_) => continue,
731 };
732 if meta.is_dir() {
733 total = total.saturating_add(dir_disk_usage(&entry.path()));
734 } else if meta.is_file() {
735 total = total.saturating_add(file_disk_usage(&meta));
736 }
737 }
738 total
739}
740
741pub fn evict_cache_to_size(cache_root: &Path, max_bytes: u64) -> u64 {
751 evict_cache_to_size_protecting(cache_root, max_bytes, None)
752}
753
754pub fn evict_cache_to_size_protecting(
766 cache_root: &Path,
767 max_bytes: u64,
768 protect: Option<&Path>,
769) -> u64 {
770 let protect_canon = protect.and_then(|p| fs::canonicalize(p).ok());
771 let mut entries: Vec<(PathBuf, std::time::SystemTime, u64)> = Vec::new();
772 let read_dir = match fs::read_dir(cache_root) {
773 Ok(rd) => rd,
774 Err(_) => return 0,
775 };
776 for entry in read_dir.flatten() {
777 let path = entry.path();
778 let meta = match fs::metadata(&path) {
779 Ok(m) => m,
780 Err(_) => continue,
781 };
782 if !meta.is_dir() {
783 continue; }
785 let modified = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
786 entries.push((path, modified, dir_disk_usage(&entry.path())));
787 }
788
789 let total: u64 = entries.iter().map(|(_, _, s)| *s).sum();
790 if total <= max_bytes {
791 return 0;
792 }
793
794 entries.sort_by_key(|(_, modified, _)| *modified);
796
797 let mut over = total - max_bytes;
798 let mut freed = 0u64;
799 for (path, _, size) in entries {
800 if over == 0 {
801 break;
802 }
803 if has_active_leases(&path) {
804 continue; }
806 if protect_canon
807 .as_deref()
808 .is_some_and(|pc| fs::canonicalize(&path).ok().as_deref() == Some(pc))
809 {
810 continue; }
812 force_detach_layers_volume(&path);
813 if fs::remove_dir_all(&path).is_ok() {
814 let _ = fs::remove_file(path.with_extension("lock"));
816 freed = freed.saturating_add(size);
817 over = over.saturating_sub(size);
818 }
819 }
820 freed
821}
822
823fn is_sidecar_mode(footer: &PackFooter) -> bool {
825 footer.assets_offset == 0
826}
827
828pub fn sidecar_path_for(exe_path: &Path) -> PathBuf {
830 let filename = exe_path
831 .file_name()
832 .map(|s| s.to_string_lossy().to_string())
833 .unwrap_or_default();
834 exe_path.with_file_name(format!("{}{}", filename, SIDECAR_EXTENSION))
835}
836
837pub fn extract_sidecar(
848 sidecar_path: &Path,
849 cache_dir: &Path,
850 footer: &PackFooter,
851 force: bool,
852 debug: bool,
853) -> std::io::Result<()> {
854 extract_sidecar_capped(sidecar_path, cache_dir, footer, force, debug, true)
857}
858
859fn extract_sidecar_capped(
871 sidecar_path: &Path,
872 cache_dir: &Path,
873 footer: &PackFooter,
874 force: bool,
875 debug: bool,
876 cap_cache: bool,
877) -> std::io::Result<()> {
878 if !sidecar_path.exists() {
879 return Err(std::io::Error::new(
880 std::io::ErrorKind::NotFound,
881 format!("sidecar file not found: {}", sidecar_path.display()),
882 ));
883 }
884
885 if let Some(parent) = cache_dir.parent() {
887 fs::create_dir_all(parent)?;
888 }
889
890 let lock_path = cache_dir.with_extension("lock");
893 let lock_file = fs::OpenOptions::new()
896 .create(true)
897 .write(true)
898 .truncate(false)
899 .open(&lock_path)?;
900
901 lock_file_exclusive(&lock_file)?;
902
903 if !force && is_extracted(cache_dir) {
906 if debug {
907 eprintln!("debug: assets already extracted (possibly by another process)");
908 }
909 return Ok(());
911 }
912
913 if force && cache_dir.exists() {
916 force_detach_layers_volume(cache_dir);
917 let _ = fs::remove_dir_all(cache_dir);
918 }
919
920 let result = extract_sidecar_inner(sidecar_path, cache_dir, footer, debug);
921
922 if result.is_err() && cache_dir.exists() && !is_extracted(cache_dir) {
927 let _ = fs::remove_dir_all(cache_dir);
928 }
929
930 if result.is_ok() && cap_cache {
935 if let Some(root) = cache_dir.parent() {
936 let freed =
942 evict_cache_to_size_protecting(root, pack_cache_max_bytes(), Some(cache_dir));
943 if freed > 0 && debug {
944 eprintln!("debug: pack cache evicted {freed} bytes to stay under cap");
945 }
946 }
947 }
948
949 result
950 }
952
953pub fn shared_extract_enabled() -> bool {
963 cfg!(target_os = "linux") && std::env::var_os("SMOLVM_DISABLE_SHARED_EXTRACT").is_none()
964}
965
966pub fn shared_pack_dir(shared_root: &Path, checksum: u32) -> PathBuf {
968 shared_root.join(format!("{:08x}", checksum))
969}
970
971pub fn extract_sidecar_shared(
991 sidecar_path: &Path,
992 shared_root: &Path,
993 footer: &PackFooter,
994 debug: bool,
995) -> std::io::Result<PathBuf> {
996 let shared_dir = shared_pack_dir(shared_root, footer.checksum);
997 extract_sidecar_capped(sidecar_path, &shared_dir, footer, false, debug, false)?;
1002 restrict_to_owner(shared_root);
1006 restrict_to_owner(&shared_dir);
1007 Ok(shared_dir)
1008}
1009
1010fn restrict_to_owner(dir: &Path) {
1014 #[cfg(unix)]
1015 {
1016 use std::os::unix::fs::PermissionsExt;
1017 if let Ok(meta) = fs::metadata(dir) {
1018 let mut perms = meta.permissions();
1019 perms.set_mode(0o700);
1020 let _ = fs::set_permissions(dir, perms);
1021 }
1022 }
1023 #[cfg(not(unix))]
1024 let _ = dir;
1025}
1026
1027fn extract_sidecar_inner(
1029 sidecar_path: &Path,
1030 cache_dir: &Path,
1031 footer: &PackFooter,
1032 debug: bool,
1033) -> std::io::Result<()> {
1034 fs::create_dir_all(cache_dir)?;
1035
1036 if debug {
1037 eprintln!(
1038 "debug: reading {} bytes of compressed assets from sidecar {}",
1039 footer.assets_size,
1040 sidecar_path.display()
1041 );
1042 }
1043
1044 let sidecar_file = File::open(sidecar_path)?;
1045 let limited_reader = sidecar_file.take(footer.assets_size);
1046
1047 let decoder = zstd::stream::Decoder::new(limited_reader)
1048 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1049
1050 let mut archive = tar::Archive::new(decoder);
1051 safe_unpack(&mut archive, cache_dir)?;
1052
1053 if debug {
1054 eprintln!("debug: extracted assets to {}", cache_dir.display());
1055 }
1056
1057 let layer_order = crate::packer::read_manifest_from_sidecar(sidecar_path)
1060 .ok()
1061 .map(|m| {
1062 m.assets
1063 .layers
1064 .iter()
1065 .filter_map(|l| layer_id_from_asset_path(&l.path))
1066 .collect::<Vec<_>>()
1067 })
1068 .unwrap_or_default();
1069
1070 post_process_extraction(cache_dir, &layer_order, debug)?;
1071 Ok(())
1072}
1073
1074pub fn extract_from_binary(
1079 exe_path: &Path,
1080 cache_dir: &Path,
1081 footer: &PackFooter,
1082 debug: bool,
1083) -> std::io::Result<()> {
1084 fs::create_dir_all(cache_dir)?;
1085
1086 if is_sidecar_mode(footer) {
1087 let sidecar = sidecar_path_for(exe_path);
1088 extract_sidecar(&sidecar, cache_dir, footer, false, debug)
1089 } else {
1090 let mut exe_file = File::open(exe_path)?;
1092 exe_file.seek(SeekFrom::Start(footer.assets_offset))?;
1093
1094 if debug {
1095 eprintln!(
1096 "debug: reading {} bytes of compressed assets from offset {}",
1097 footer.assets_size, footer.assets_offset
1098 );
1099 }
1100
1101 let limited_reader = (&mut exe_file).take(footer.assets_size);
1102
1103 let decoder = zstd::stream::Decoder::new(limited_reader)
1104 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1105
1106 let mut archive = tar::Archive::new(decoder);
1107 safe_unpack(&mut archive, cache_dir)?;
1108
1109 if debug {
1110 eprintln!("debug: extracted assets to {}", cache_dir.display());
1111 }
1112
1113 post_process_extraction(cache_dir, &[], debug)?;
1116 Ok(())
1117 }
1118}
1119
1120#[cfg(target_os = "macos")]
1127pub unsafe fn extract_from_section(
1128 cache_dir: &Path,
1129 assets_ptr: *const u8,
1130 assets_size: usize,
1131 debug: bool,
1132) -> std::io::Result<()> {
1133 fs::create_dir_all(cache_dir)?;
1134
1135 if debug {
1136 eprintln!(
1137 "debug: extracting {} bytes of compressed assets from section",
1138 assets_size
1139 );
1140 }
1141
1142 let assets_slice = unsafe { std::slice::from_raw_parts(assets_ptr, assets_size) };
1143 let cursor = std::io::Cursor::new(assets_slice);
1144
1145 let decoder = zstd::stream::Decoder::new(cursor)
1146 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1147
1148 let mut archive = tar::Archive::new(decoder);
1149 safe_unpack(&mut archive, cache_dir)?;
1150
1151 if debug {
1152 eprintln!("debug: extracted assets to {}", cache_dir.display());
1153 }
1154
1155 post_process_extraction(cache_dir, &[], debug)?;
1157 Ok(())
1158}
1159
1160const LAYER_ORDER_FILE: &str = "layer-order";
1166
1167fn layer_id_from_asset_path(path: &str) -> Option<String> {
1170 let rel = Path::new(path);
1171 (!rel.is_absolute())
1172 .then_some(rel)?
1173 .file_stem()
1174 .and_then(|s| s.to_str())
1175 .filter(|s| !s.is_empty())
1176 .map(ToOwned::to_owned)
1177}
1178
1179fn post_process_extraction(
1184 cache_dir: &Path,
1185 layer_order: &[String],
1186 debug: bool,
1187) -> std::io::Result<()> {
1188 let rootfs_tar = cache_dir.join("agent-rootfs.tar");
1190 let rootfs_dir = cache_dir.join("agent-rootfs");
1191 if rootfs_tar.exists() && !rootfs_dir.exists() {
1192 if debug {
1193 eprintln!("debug: extracting agent-rootfs.tar...");
1194 }
1195 fs::create_dir_all(&rootfs_dir)?;
1196 let tar_file = File::open(&rootfs_tar)?;
1197 let mut archive = tar::Archive::new(tar_file);
1198 safe_unpack(&mut archive, &rootfs_dir)?;
1199 }
1200
1201 let layers_dir = cache_dir.join("layers");
1214 if layers_dir.exists() {
1215 if debug {
1216 eprintln!("debug: extracting OCI layers...");
1217 }
1218 let extract_dir = extraction_layers_dir(cache_dir, debug)?;
1223
1224 for entry in fs::read_dir(&layers_dir)? {
1225 let entry = entry?;
1226 let path = entry.path();
1227 if path.extension().is_some_and(|ext| ext == "tar") {
1228 let stem = path.file_stem().unwrap_or_default().to_string_lossy();
1229 let layer_dir = extract_dir.join(&*stem);
1230 if !layer_dir.exists() {
1231 if debug {
1232 eprintln!("debug: extracting layer {}...", stem);
1233 }
1234 fs::create_dir_all(&layer_dir)?;
1235 let tar_file = File::open(&path)?;
1236 let mut archive = tar::Archive::new(tar_file);
1237 safe_unpack(&mut archive, &layer_dir)?;
1238 }
1239 }
1240 }
1241
1242 if !layer_order.is_empty() {
1246 let lines: Vec<&str> = layer_order
1247 .iter()
1248 .filter(|id| extract_dir.join(id).is_dir())
1249 .map(String::as_str)
1250 .collect();
1251 if !lines.is_empty() {
1252 fs::write(extract_dir.join(LAYER_ORDER_FILE), lines.join("\n"))?;
1253 }
1254 }
1255 }
1256
1257 fs::write(cache_dir.join(EXTRACTION_MARKER), "")?;
1259
1260 let lib_dir = cache_dir.join("lib");
1262 if lib_dir.exists() {
1263 #[cfg(unix)]
1264 {
1265 use std::os::unix::fs::PermissionsExt;
1266 for entry in fs::read_dir(&lib_dir)? {
1267 let entry = entry?;
1268 let path = entry.path();
1269 if path.is_file() {
1270 let mut perms = fs::metadata(&path)?.permissions();
1271 perms.set_mode(0o755);
1272 fs::set_permissions(&path, perms)?;
1273 }
1274 }
1275 }
1276 }
1277
1278 Ok(())
1279}
1280
1281#[cfg(target_os = "macos")]
1305const CS_IMAGE_NAME: &str = "layers-cs.sparseimage";
1306
1307#[cfg(target_os = "macos")]
1309const CS_MOUNT_DIR: &str = "layers-cs";
1310
1311#[cfg(target_os = "macos")]
1313const LEASES_DIR: &str = "leases";
1314
1315#[cfg(target_os = "macos")]
1317const LEASES_LOCK: &str = "leases.lock";
1318
1319pub struct LayersVolumeLease {
1325 pub path: PathBuf,
1328 #[cfg(target_os = "macos")]
1330 cache_dir: PathBuf,
1331}
1332
1333impl Drop for LayersVolumeLease {
1334 fn drop(&mut self) {
1335 #[cfg(target_os = "macos")]
1336 {
1337 release_lease(&self.cache_dir);
1338 }
1339 }
1340}
1341
1342pub fn acquire_layers_lease(cache_dir: &Path, debug: bool) -> std::io::Result<LayersVolumeLease> {
1353 #[cfg(target_os = "macos")]
1354 {
1355 let image_path = cache_dir.join(CS_IMAGE_NAME);
1356 if image_path.exists() || has_layer_tars(cache_dir) {
1357 let path = acquire_lease(cache_dir, debug)?;
1361 return Ok(LayersVolumeLease {
1362 path,
1363 cache_dir: cache_dir.to_path_buf(),
1364 });
1365 }
1366 }
1367
1368 let _ = debug;
1369 Ok(LayersVolumeLease {
1370 path: cache_dir.join("layers"),
1371 #[cfg(target_os = "macos")]
1372 cache_dir: cache_dir.to_path_buf(),
1373 })
1374}
1375
1376pub fn acquire_daemon_lease(
1385 cache_dir: &Path,
1386 daemon_pid: i32,
1387 debug: bool,
1388) -> std::io::Result<PathBuf> {
1389 #[cfg(target_os = "macos")]
1390 {
1391 let image_path = cache_dir.join(CS_IMAGE_NAME);
1392 if image_path.exists() || has_layer_tars(cache_dir) {
1393 let leases_dir = cache_dir.join(LEASES_DIR);
1394 fs::create_dir_all(&leases_dir)?;
1395 let lock = lock_leases(cache_dir)?;
1396 gc_stale_leases(&leases_dir);
1397 ensure_cs_volume_mounted(cache_dir, debug)?;
1398 fs::write(leases_dir.join("daemon"), format!("{}", daemon_pid))?;
1399 drop(lock);
1400 return Ok(cache_dir.join(CS_MOUNT_DIR));
1401 }
1402 }
1403
1404 let _ = (daemon_pid, debug);
1405 Ok(cache_dir.join("layers"))
1406}
1407
1408pub fn release_daemon_lease(cache_dir: &Path) {
1412 #[cfg(target_os = "macos")]
1413 {
1414 let leases_dir = cache_dir.join(LEASES_DIR);
1415 let daemon_lease = leases_dir.join("daemon");
1416 if !daemon_lease.exists() {
1417 return;
1418 }
1419
1420 let Ok(lock) = lock_leases(cache_dir) else {
1421 let _ = fs::remove_file(&daemon_lease);
1422 return;
1423 };
1424
1425 let _ = fs::remove_file(&daemon_lease);
1426 gc_stale_leases(&leases_dir);
1427 detach_if_unused(cache_dir);
1428 drop(lock);
1429 }
1430
1431 #[cfg(not(target_os = "macos"))]
1432 {
1433 let _ = cache_dir;
1434 }
1435}
1436
1437pub fn has_active_leases(cache_dir: &Path) -> bool {
1442 #[cfg(target_os = "macos")]
1443 {
1444 let leases_dir = cache_dir.join(LEASES_DIR);
1445 if !leases_dir.exists() {
1446 return false;
1447 }
1448
1449 let Ok(lock) = lock_leases(cache_dir) else {
1450 return false;
1451 };
1452 gc_stale_leases(&leases_dir);
1453 let active = count_leases(&leases_dir);
1454 drop(lock);
1455 active > 0
1456 }
1457
1458 #[cfg(not(target_os = "macos"))]
1459 {
1460 let _ = cache_dir;
1461 false
1462 }
1463}
1464
1465pub fn force_detach_layers_volume(cache_dir: &Path) {
1471 if cache_dir
1475 .symlink_metadata()
1476 .map(|m| m.file_type().is_symlink())
1477 .unwrap_or(false)
1478 {
1479 return;
1480 }
1481 #[cfg(target_os = "macos")]
1482 {
1483 let mount_point = cache_dir.join(CS_MOUNT_DIR);
1484 if mount_point.exists() && is_mount_point(&mount_point) {
1485 let _ = std::process::Command::new("hdiutil")
1486 .args(["detach", "-quiet", "-force"])
1487 .arg(&mount_point)
1488 .output();
1489 }
1490 let _ = fs::remove_dir_all(cache_dir.join(LEASES_DIR));
1492 }
1493
1494 #[cfg(not(target_os = "macos"))]
1495 {
1496 let _ = cache_dir;
1497 }
1498}
1499
1500fn extraction_layers_dir(cache_dir: &Path, debug: bool) -> std::io::Result<PathBuf> {
1504 #[cfg(target_os = "macos")]
1505 {
1506 ensure_cs_volume_mounted(cache_dir, debug)?;
1507 Ok(cache_dir.join(CS_MOUNT_DIR))
1508 }
1509
1510 #[cfg(not(target_os = "macos"))]
1511 {
1512 let _ = debug;
1513 Ok(cache_dir.join("layers"))
1514 }
1515}
1516
1517#[cfg(target_os = "macos")]
1520fn has_layer_tars(cache_dir: &Path) -> bool {
1521 let layers_dir = cache_dir.join("layers");
1522 layers_dir.exists()
1523 && fs::read_dir(&layers_dir)
1524 .ok()
1525 .map(|rd| {
1526 rd.filter_map(|e| e.ok())
1527 .any(|e| e.path().extension().is_some_and(|ext| ext == "tar"))
1528 })
1529 .unwrap_or(false)
1530}
1531
1532#[cfg(target_os = "macos")]
1534fn sum_tar_sizes(dir: &Path) -> u64 {
1535 let Ok(entries) = fs::read_dir(dir) else {
1536 return 0;
1537 };
1538 entries
1539 .filter_map(|e| e.ok())
1540 .filter(|e| e.path().extension().is_some_and(|ext| ext == "tar"))
1541 .filter_map(|e| e.metadata().ok())
1542 .map(|m| m.len())
1543 .sum()
1544}
1545
1546#[cfg(target_os = "macos")]
1548fn is_mount_point(path: &Path) -> bool {
1549 use std::os::unix::fs::MetadataExt;
1550 let Ok(meta) = fs::metadata(path) else {
1551 return false;
1552 };
1553 let Ok(parent_meta) = fs::metadata(path.parent().unwrap_or(Path::new("/"))) else {
1554 return false;
1555 };
1556 meta.dev() != parent_meta.dev()
1557}
1558
1559#[cfg(target_os = "macos")]
1561fn acquire_lease(cache_dir: &Path, debug: bool) -> std::io::Result<PathBuf> {
1562 let mount_point = cache_dir.join(CS_MOUNT_DIR);
1563 let leases_dir = cache_dir.join(LEASES_DIR);
1564 fs::create_dir_all(&leases_dir)?;
1565
1566 let lock = lock_leases(cache_dir)?;
1567
1568 gc_stale_leases(&leases_dir);
1570
1571 reap_orphan_volumes(cache_dir);
1573
1574 ensure_cs_volume_mounted(cache_dir, debug)?;
1576
1577 let lease_path = leases_dir.join(format!("{}", std::process::id()));
1579 fs::write(&lease_path, "")?;
1580
1581 drop(lock);
1582 Ok(mount_point)
1583}
1584
1585#[cfg(target_os = "macos")]
1587fn release_lease(cache_dir: &Path) {
1588 let leases_dir = cache_dir.join(LEASES_DIR);
1589 let lease_path = leases_dir.join(format!("{}", std::process::id()));
1590
1591 let Ok(lock) = lock_leases(cache_dir) else {
1592 let _ = fs::remove_file(&lease_path);
1593 return;
1594 };
1595
1596 let _ = fs::remove_file(&lease_path);
1597 gc_stale_leases(&leases_dir);
1598 detach_if_unused(cache_dir);
1599 drop(lock);
1600}
1601
1602#[cfg(target_os = "macos")]
1607fn gc_stale_leases(leases_dir: &Path) {
1608 let Ok(entries) = fs::read_dir(leases_dir) else {
1609 return;
1610 };
1611 for entry in entries.filter_map(|e| e.ok()) {
1612 let name = entry.file_name();
1613 let name_str = name.to_string_lossy();
1614
1615 if name_str == "daemon" {
1616 if let Ok(content) = fs::read_to_string(entry.path()) {
1618 if let Ok(pid) = content.trim().parse::<i32>() {
1619 if unsafe { libc::kill(pid, 0) } != 0 {
1620 let _ = fs::remove_file(entry.path());
1621 }
1622 }
1623 }
1624 } else if let Ok(pid) = name_str.parse::<i32>() {
1625 if unsafe { libc::kill(pid, 0) } != 0 {
1627 let _ = fs::remove_file(entry.path());
1628 }
1629 }
1630 }
1631}
1632
1633#[cfg(target_os = "macos")]
1635fn count_leases(leases_dir: &Path) -> usize {
1636 fs::read_dir(leases_dir)
1637 .ok()
1638 .map(|rd| rd.filter_map(|e| e.ok()).count())
1639 .unwrap_or(0)
1640}
1641
1642#[cfg(target_os = "macos")]
1644fn detach_if_unused(cache_dir: &Path) {
1645 let leases_dir = cache_dir.join(LEASES_DIR);
1646 if count_leases(&leases_dir) == 0 {
1647 let mount_point = cache_dir.join(CS_MOUNT_DIR);
1648 if mount_point.exists() && is_mount_point(&mount_point) {
1649 let _ = std::process::Command::new("hdiutil")
1650 .args(["detach", "-quiet"])
1651 .arg(&mount_point)
1652 .output();
1653 }
1654 }
1655}
1656
1657#[cfg(target_os = "macos")]
1664fn sibling_cache_dirs(cache_dir: &Path) -> Vec<PathBuf> {
1665 let nested = cache_dir.file_name().is_some_and(|n| n == "pack");
1666 let Some(root) = (if nested {
1667 cache_dir.parent().and_then(Path::parent)
1668 } else {
1669 cache_dir.parent()
1670 }) else {
1671 return Vec::new();
1672 };
1673 let Ok(entries) = fs::read_dir(root) else {
1674 return Vec::new();
1675 };
1676 entries
1677 .filter_map(|e| e.ok())
1678 .map(|e| {
1679 if nested {
1680 e.path().join("pack")
1681 } else {
1682 e.path()
1683 }
1684 })
1685 .filter(|p| p != cache_dir && p.is_dir())
1686 .collect()
1687}
1688
1689#[cfg(target_os = "macos")]
1702fn reap_orphan_volumes(cache_dir: &Path) {
1703 for dir in sibling_cache_dirs(cache_dir) {
1704 let mount_point = dir.join(CS_MOUNT_DIR);
1705 if !mount_point.exists() || !is_mount_point(&mount_point) {
1706 continue;
1707 }
1708 let Ok(lock) = try_lock_leases(&dir) else {
1709 continue;
1710 };
1711 gc_stale_leases(&dir.join(LEASES_DIR));
1712 detach_if_unused(&dir);
1713 drop(lock);
1714 }
1715}
1716
1717#[cfg(target_os = "macos")]
1719fn try_lock_leases(cache_dir: &Path) -> std::io::Result<File> {
1720 let lock_path = cache_dir.join(LEASES_LOCK);
1721 let lock_file = fs::OpenOptions::new()
1722 .create(true)
1723 .write(true)
1724 .truncate(false)
1725 .open(&lock_path)?;
1726 let ret = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
1727 if ret != 0 {
1728 return Err(std::io::Error::last_os_error());
1729 }
1730 Ok(lock_file)
1731}
1732
1733#[cfg(target_os = "macos")]
1735fn lock_leases(cache_dir: &Path) -> std::io::Result<File> {
1736 let lock_path = cache_dir.join(LEASES_LOCK);
1737 let lock_file = fs::OpenOptions::new()
1738 .create(true)
1739 .write(true)
1740 .truncate(false)
1741 .open(&lock_path)?;
1742 let ret = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) };
1743 if ret != 0 {
1744 return Err(std::io::Error::last_os_error());
1745 }
1746 Ok(lock_file)
1747}
1748
1749#[cfg(target_os = "macos")]
1751fn ensure_cs_volume_mounted(cache_dir: &Path, debug: bool) -> std::io::Result<()> {
1752 let image_path = cache_dir.join(CS_IMAGE_NAME);
1753 let mount_point = cache_dir.join(CS_MOUNT_DIR);
1754
1755 if mount_point.exists() && is_mount_point(&mount_point) {
1757 return Ok(());
1758 }
1759
1760 if !image_path.exists() {
1762 let layers_dir = cache_dir.join("layers");
1763 let total_tar_bytes = sum_tar_sizes(&layers_dir);
1764 let size_bytes = std::cmp::max(
1767 (total_tar_bytes as f64 * 2.5) as u64 + 512 * 1024 * 1024,
1768 1024 * 1024 * 1024,
1769 );
1770 let size_gib = size_bytes / (1024 * 1024 * 1024) + 1;
1771 let size_arg = format!("{}g", size_gib);
1772
1773 if debug {
1774 eprintln!(
1775 "debug: creating case-sensitive APFS sparse image ({}g from {} bytes of tars)...",
1776 size_gib, total_tar_bytes
1777 );
1778 }
1779 let output = std::process::Command::new("hdiutil")
1780 .args([
1781 "create",
1782 "-size",
1783 &size_arg,
1784 "-fs",
1785 "Case-sensitive APFS",
1786 "-type",
1787 "SPARSE",
1788 "-volname",
1789 "smolvm-layers",
1790 ])
1791 .arg(&image_path)
1792 .output()?;
1793 if !output.status.success() {
1794 return Err(std::io::Error::other(format!(
1795 "hdiutil create failed: {}",
1796 String::from_utf8_lossy(&output.stderr)
1797 )));
1798 }
1799 }
1800
1801 fs::create_dir_all(&mount_point)?;
1803 if debug {
1804 eprintln!(
1805 "debug: mounting case-sensitive volume at {}",
1806 mount_point.display()
1807 );
1808 }
1809 let output = std::process::Command::new("hdiutil")
1810 .args(["attach", "-mountpoint"])
1811 .arg(&mount_point)
1812 .args(["-nobrowse", "-noautoopen"])
1813 .arg(&image_path)
1814 .output()?;
1815 if !output.status.success() {
1816 return Err(std::io::Error::other(format!(
1817 "hdiutil attach failed: {}",
1818 String::from_utf8_lossy(&output.stderr)
1819 )));
1820 }
1821
1822 Ok(())
1823}
1824
1825const LIBS_EXTRACTION_MARKER: &str = ".smolvm-libs-extracted";
1827
1828pub fn extract_libs_from_binary(exe_path: &Path, debug: bool) -> std::io::Result<Option<PathBuf>> {
1836 use crate::format::{LibsFooter, LIBS_FOOTER_SIZE};
1837
1838 let mut file = File::open(exe_path)?;
1839 let file_size = file.metadata()?.len();
1840 if file_size < LIBS_FOOTER_SIZE as u64 {
1841 return Ok(None);
1842 }
1843
1844 file.seek(SeekFrom::End(-(LIBS_FOOTER_SIZE as i64)))?;
1846 let mut footer_buf = [0u8; LIBS_FOOTER_SIZE];
1847 file.read_exact(&mut footer_buf)?;
1848
1849 let footer = match LibsFooter::from_bytes(&footer_buf) {
1850 Ok(f) => f,
1851 Err(_) => return Ok(None), };
1853
1854 if debug {
1855 eprintln!(
1856 "debug: found SMOLLIBS footer: offset={}, size={}",
1857 footer.libs_offset, footer.libs_size
1858 );
1859 }
1860
1861 file.seek(SeekFrom::Start(footer.libs_offset))?;
1863 let mut hasher = crc32fast::Hasher::new();
1864 let mut remaining = footer.libs_size;
1865 let mut buf = [0u8; 64 * 1024];
1866 while remaining > 0 {
1867 let to_read = remaining.min(buf.len() as u64) as usize;
1868 let n = file.read(&mut buf[..to_read])?;
1869 if n == 0 {
1870 break;
1871 }
1872 hasher.update(&buf[..n]);
1873 remaining -= n as u64;
1874 }
1875 let libs_checksum = hasher.finalize();
1876
1877 let cache_base = dirs::cache_dir()
1878 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no cache directory"))?;
1879 let libs_cache_dir = cache_base
1880 .join("smolvm-libs")
1881 .join(format!("{:08x}", libs_checksum));
1882 let lib_dir = libs_cache_dir.join("lib");
1883
1884 if let Some(parent) = libs_cache_dir.parent() {
1886 fs::create_dir_all(parent)?;
1887 }
1888 let lock_path = libs_cache_dir.with_extension("lock");
1889 let lock_file = fs::OpenOptions::new()
1890 .create(true)
1891 .write(true)
1892 .truncate(false)
1893 .open(&lock_path)?;
1894
1895 lock_file_exclusive(&lock_file)?;
1896
1897 if libs_cache_dir.join(LIBS_EXTRACTION_MARKER).exists() {
1899 if debug {
1900 eprintln!("debug: libs already extracted at {}", lib_dir.display());
1901 }
1902 let _ = lock_file;
1904 return Ok(Some(lib_dir));
1905 }
1906
1907 fs::create_dir_all(&libs_cache_dir)?;
1909 file.seek(SeekFrom::Start(footer.libs_offset))?;
1910 let limited_reader = (&mut file).take(footer.libs_size);
1911 let decoder = zstd::stream::Decoder::new(limited_reader)
1912 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1913 let mut archive = tar::Archive::new(decoder);
1914 safe_unpack(&mut archive, &libs_cache_dir)?;
1915
1916 if lib_dir.exists() {
1918 #[cfg(unix)]
1919 {
1920 use std::os::unix::fs::PermissionsExt;
1921 for entry in fs::read_dir(&lib_dir)? {
1922 let entry = entry?;
1923 if entry.path().is_file() {
1924 let mut perms = fs::metadata(entry.path())?.permissions();
1925 perms.set_mode(0o755);
1926 fs::set_permissions(entry.path(), perms)?;
1927 }
1928 }
1929 }
1930 }
1931
1932 fs::write(libs_cache_dir.join(LIBS_EXTRACTION_MARKER), "")?;
1933 let _ = lock_file;
1935
1936 if debug {
1937 eprintln!("debug: extracted libs to {}", lib_dir.display());
1938 }
1939
1940 Ok(Some(lib_dir))
1941}
1942
1943pub(crate) fn sparse_copy(src: &Path, dst: &Path) -> std::io::Result<()> {
1974 let mut src_file = File::open(src)?;
1975 let size = src_file.metadata()?.len();
1976
1977 let mut dst_file = File::create(dst)?;
1978 #[cfg(windows)]
1982 mark_file_sparse(&dst_file)?;
1983 dst_file.set_len(size)?;
1984
1985 #[cfg(unix)]
1989 if copy_data_extents(&mut src_file, &mut dst_file, size)? {
1990 return Ok(());
1991 }
1992
1993 src_file.seek(SeekFrom::Start(0))?;
1996 let mut buf = vec![0u8; 512 * 1024];
1997 let mut offset: u64 = 0;
1998 while offset < size {
1999 let to_read = (size - offset).min(buf.len() as u64) as usize;
2000 let n = src_file.read(&mut buf[..to_read])?;
2001 if n == 0 {
2002 break;
2003 }
2004 let chunk = &buf[..n];
2005 if chunk.iter().any(|&b| b != 0) {
2006 dst_file.seek(SeekFrom::Start(offset))?;
2007 dst_file.write_all(chunk)?;
2008 }
2009 offset += n as u64;
2010 }
2011
2012 Ok(())
2013}
2014
2015#[cfg(unix)]
2023fn copy_data_extents(src: &mut File, dst: &mut File, size: u64) -> std::io::Result<bool> {
2024 use std::os::unix::io::AsRawFd;
2025
2026 let fd = src.as_raw_fd();
2027 let seek = |from: u64, whence: libc::c_int| -> Option<i64> {
2028 let r = unsafe { libc::lseek(fd, from as libc::off_t, whence) };
2029 if r < 0 {
2030 None
2031 } else {
2032 Some(r as i64)
2033 }
2034 };
2035
2036 if seek(0, libc::SEEK_DATA).is_none() {
2040 let e = std::io::Error::last_os_error();
2041 if e.raw_os_error() == Some(libc::ENXIO) {
2042 return Ok(true); }
2044 return Ok(false);
2045 }
2046
2047 let mut buf = vec![0u8; 512 * 1024];
2048 let mut offset: u64 = 0;
2049 while offset < size {
2050 let Some(data_start) = seek(offset, libc::SEEK_DATA) else {
2052 break; };
2054 let data_start = data_start as u64;
2055 if data_start >= size {
2056 break;
2057 }
2058 let data_end = seek(data_start, libc::SEEK_HOLE)
2061 .map(|v| (v as u64).min(size))
2062 .unwrap_or(size);
2063
2064 src.seek(SeekFrom::Start(data_start))?;
2065 dst.seek(SeekFrom::Start(data_start))?;
2066 let mut remaining = data_end.saturating_sub(data_start);
2067 while remaining > 0 {
2068 let want = remaining.min(buf.len() as u64) as usize;
2069 let n = src.read(&mut buf[..want])?;
2070 if n == 0 {
2071 break;
2072 }
2073 let chunk = &buf[..n];
2076 if chunk.iter().any(|&b| b != 0) {
2077 dst.write_all(chunk)?;
2078 } else {
2079 dst.seek(SeekFrom::Current(n as i64))?;
2080 }
2081 remaining -= n as u64;
2082 }
2083 offset = data_end.max(data_start + 1);
2084 }
2085
2086 Ok(true)
2087}
2088
2089pub fn create_storage_disk(path: &Path, size: u64) -> std::io::Result<()> {
2091 let file = File::create(path)?;
2092 #[cfg(windows)]
2095 mark_file_sparse(&file)?;
2096 file.set_len(size)?;
2097 Ok(())
2098}
2099
2100pub fn copy_overlay_template(
2110 cache_dir: &Path,
2111 template_path: Option<&str>,
2112 dest: &Path,
2113 size_gb_override: Option<u64>,
2114 overlay_logical_size: Option<u64>,
2115) -> std::io::Result<()> {
2116 let template = template_path.ok_or_else(|| {
2117 std::io::Error::new(
2118 std::io::ErrorKind::NotFound,
2119 "overlay template not specified in manifest",
2120 )
2121 })?;
2122
2123 let src = resolve_cache_asset_path(cache_dir, template, "overlay template")?;
2124 if !src.exists() {
2125 return Err(std::io::Error::new(
2126 std::io::ErrorKind::NotFound,
2127 format!("overlay template not found: {}", src.display()),
2128 ));
2129 }
2130
2131 sparse_copy(&src, dest)?;
2134
2135 let copied_size = fs::metadata(dest)?.len();
2141 let target = [
2142 Some(copied_size),
2143 overlay_logical_size,
2144 size_gb_override.map(|gb| gb * 1024 * 1024 * 1024),
2145 ]
2146 .into_iter()
2147 .flatten()
2148 .max()
2149 .unwrap_or(copied_size);
2150
2151 if target > copied_size {
2152 let file = fs::OpenOptions::new().write(true).open(dest)?;
2153 #[cfg(windows)]
2156 mark_file_sparse(&file)?;
2157 file.set_len(target)?;
2158 }
2159
2160 Ok(())
2161}
2162
2163pub fn create_or_copy_storage_disk(
2171 cache_dir: &Path,
2172 template_path: Option<&str>,
2173 storage_path: &Path,
2174 size_gb_override: Option<u64>,
2175) -> std::io::Result<()> {
2176 if let Some(template) = template_path {
2177 let template_path = resolve_cache_asset_path(cache_dir, template, "storage template")?;
2178 if template_path.exists() {
2179 sparse_copy(&template_path, storage_path)?;
2184 if let Some(gb) = size_gb_override {
2187 let desired = gb * 1024 * 1024 * 1024;
2188 let current = fs::metadata(storage_path)?.len();
2189 if desired > current {
2190 let file = fs::OpenOptions::new().write(true).open(storage_path)?;
2191 #[cfg(windows)]
2195 mark_file_sparse(&file)?;
2196 file.set_len(desired)?;
2197 }
2198 }
2199 return Ok(());
2200 }
2201 }
2202 let size = match size_gb_override {
2204 Some(gb) => gb * 1024 * 1024 * 1024,
2205 None => 512 * 1024 * 1024,
2206 };
2207 create_storage_disk(storage_path, size)
2208}
2209
2210#[cfg(test)]
2211mod tests {
2212 use super::*;
2213
2214 fn make_tar(name: &str, data: &[u8]) -> Vec<u8> {
2216 let mut builder = tar::Builder::new(Vec::new());
2217 let mut header = tar::Header::new_gnu();
2218 header.set_size(data.len() as u64);
2219 header.set_mode(0o644);
2220 header.set_cksum();
2221 builder.append_data(&mut header, name, data).unwrap();
2222 builder.into_inner().unwrap()
2223 }
2224
2225 #[cfg(unix)]
2226 #[test]
2227 fn test_unpack_sparse_rejects_symlink_at_destination() {
2228 use std::os::unix::fs::symlink;
2229
2230 let temp_dir = tempfile::tempdir().unwrap();
2231 let outside = temp_dir.path().join("outside.bin");
2232 let dest = temp_dir.path().join("overlay.raw");
2233
2234 fs::write(&outside, b"untouched").unwrap();
2235 symlink(&outside, &dest).unwrap(); let data = vec![0xFFu8; 512];
2238 let tar_bytes = make_tar("overlay.raw", &data);
2239 let mut archive = tar::Archive::new(tar_bytes.as_slice());
2240 let mut entry = archive.entries().unwrap().next().unwrap().unwrap();
2241
2242 let real_dest = temp_dir.path().canonicalize().unwrap();
2243 let result = unpack_sparse(&mut entry, &dest, data.len() as u64, 0o644, &real_dest);
2244
2245 assert!(result.is_err(), "should reject symlink at destination");
2246 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
2247 assert_eq!(fs::read(&outside).unwrap(), b"untouched");
2249 }
2250
2251 fn make_symlink_tar(name: &str, link_target: &str) -> Vec<u8> {
2254 let mut builder = tar::Builder::new(Vec::new());
2255 let mut header = tar::Header::new_gnu();
2256 header.set_entry_type(tar::EntryType::Symlink);
2257 header.set_size(0);
2258 header.set_mode(0o777);
2259 builder.append_link(&mut header, name, link_target).unwrap();
2260 builder.into_inner().unwrap()
2261 }
2262
2263 #[cfg(unix)]
2264 #[test]
2265 fn test_safe_unpack_rejects_symlink_entry_escaping_dest_relative() {
2266 let temp_dir = tempfile::tempdir().unwrap();
2270 let outside = temp_dir.path().join("outside.bin");
2271 fs::write(&outside, b"untouched").unwrap();
2272
2273 let dest = temp_dir.path().join("dest");
2274 fs::create_dir(&dest).unwrap();
2275
2276 let tar_bytes = make_symlink_tar("evil", "../../outside.bin");
2277 let mut archive = tar::Archive::new(tar_bytes.as_slice());
2278 let result = safe_unpack(&mut archive, &dest);
2279
2280 assert!(
2281 result.is_err(),
2282 "escaping relative symlink must be rejected"
2283 );
2284 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
2285 assert!(!dest.join("evil").exists(), "symlink must not be created");
2286 assert_eq!(fs::read(&outside).unwrap(), b"untouched");
2287 }
2288
2289 #[cfg(unix)]
2290 #[test]
2291 fn test_safe_unpack_rejects_symlink_entry_escaping_dest_absolute() {
2292 let temp_dir = tempfile::tempdir().unwrap();
2297 let dest = temp_dir.path().join("dest");
2298 fs::create_dir(&dest).unwrap();
2299
2300 let tar_bytes = make_symlink_tar("evil", "/../../escape");
2301 let mut archive = tar::Archive::new(tar_bytes.as_slice());
2302 let result = safe_unpack(&mut archive, &dest);
2303
2304 assert!(
2305 result.is_err(),
2306 "escaping absolute symlink must be rejected"
2307 );
2308 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
2309 assert!(!dest.join("evil").exists(), "symlink must not be created");
2310 }
2311
2312 #[cfg(unix)]
2313 #[test]
2314 fn test_safe_unpack_allows_in_dest_symlink() {
2315 let temp_dir = tempfile::tempdir().unwrap();
2317 let dest = temp_dir.path().join("dest");
2318 fs::create_dir(&dest).unwrap();
2319
2320 let tar_bytes = make_symlink_tar("link", "target");
2321 let mut archive = tar::Archive::new(tar_bytes.as_slice());
2322 safe_unpack(&mut archive, &dest).unwrap();
2323
2324 let meta = fs::symlink_metadata(dest.join("link")).unwrap();
2325 assert!(
2326 meta.file_type().is_symlink(),
2327 "in-dest symlink should exist"
2328 );
2329 }
2330
2331 #[cfg(unix)]
2339 #[test]
2340 fn test_safe_unpack_sparse_symlink_parent_escape_blocked() {
2341 use std::os::unix::fs::symlink;
2342
2343 let temp_dir = tempfile::tempdir().unwrap();
2344
2345 let sentinel = temp_dir.path().join("ESCAPE");
2348 fs::create_dir(&sentinel).unwrap();
2349
2350 let dest = temp_dir.path().join("dest");
2351 fs::create_dir(&dest).unwrap();
2352
2353 symlink(&sentinel, dest.join("foo")).unwrap();
2358
2359 let payload = vec![0xABu8; 4096];
2363 let tar_bytes = make_tar("foo/pwned.bin", &payload);
2364
2365 let mut archive = tar::Archive::new(tar_bytes.as_slice());
2366 let limits = SafeUnpackLimits {
2367 max_entries: 1_000,
2368 max_total_bytes: 1 << 30,
2369 sparse_threshold: 512, };
2371 let result = safe_unpack_with_limits(&mut archive, &dest, &limits);
2372
2373 assert!(
2374 result.is_err(),
2375 "symlink-parent escape via the sparse path must be rejected"
2376 );
2377 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
2378 assert!(
2380 !sentinel.join("pwned.bin").exists(),
2381 "host file was written OUTSIDE dest — escape not blocked"
2382 );
2383 }
2384
2385 #[cfg(unix)]
2391 #[test]
2392 fn test_safe_unpack_rejects_absolute_symlink_aliasing_root() {
2393 let temp_dir = tempfile::tempdir().unwrap();
2394 let dest = temp_dir.path().join("dest");
2395 fs::create_dir(&dest).unwrap();
2396
2397 let tar_bytes = make_symlink_tar("foo", "/");
2398 let mut archive = tar::Archive::new(tar_bytes.as_slice());
2399 let result = safe_unpack(&mut archive, &dest);
2400
2401 assert!(
2402 result.is_err(),
2403 "absolute symlink aliasing dest root must be rejected"
2404 );
2405 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
2406 assert!(
2407 !dest.join("foo").exists() && fs::symlink_metadata(dest.join("foo")).is_err(),
2408 "root-aliasing symlink must never be created on disk"
2409 );
2410 }
2411
2412 #[cfg(unix)]
2417 #[test]
2418 fn test_unpack_sparse_strips_setuid() {
2419 use std::os::unix::fs::PermissionsExt;
2420
2421 let temp_dir = tempfile::tempdir().unwrap();
2422 let dest = temp_dir.path().join("suid.bin");
2423 let real_dest = temp_dir.path().canonicalize().unwrap();
2424
2425 let data = vec![0x11u8; 4096];
2426 let tar_bytes = make_tar("suid.bin", &data);
2427 let mut archive = tar::Archive::new(tar_bytes.as_slice());
2428 let mut entry = archive.entries().unwrap().next().unwrap().unwrap();
2429
2430 unpack_sparse(&mut entry, &dest, data.len() as u64, 0o6755, &real_dest).unwrap();
2432
2433 let mode = fs::metadata(&dest).unwrap().permissions().mode() & 0o7777;
2434 assert_eq!(
2435 mode, 0o0755,
2436 "setuid/setgid/sticky bits must be stripped on the sparse path"
2437 );
2438 }
2439
2440 #[test]
2444 fn test_safe_unpack_rejects_too_many_entries() {
2445 let temp_dir = tempfile::tempdir().unwrap();
2446 let dest = temp_dir.path().join("dest");
2447 fs::create_dir(&dest).unwrap();
2448
2449 let mut builder = tar::Builder::new(Vec::new());
2450 for i in 0..10 {
2451 let data = b"x";
2452 let mut h = tar::Header::new_gnu();
2453 h.set_size(data.len() as u64);
2454 h.set_mode(0o644);
2455 builder
2456 .append_data(&mut h, format!("file{i}.txt"), &data[..])
2457 .unwrap();
2458 }
2459 let tar_bytes = builder.into_inner().unwrap();
2460
2461 let mut archive = tar::Archive::new(tar_bytes.as_slice());
2462 let limits = SafeUnpackLimits {
2463 max_entries: 3,
2464 max_total_bytes: 1 << 30,
2465 sparse_threshold: SPARSE_WRITE_THRESHOLD,
2466 };
2467 let err = safe_unpack_with_limits(&mut archive, &dest, &limits).unwrap_err();
2468 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2469 assert!(err.to_string().contains("max entry count"));
2470 }
2471
2472 #[test]
2476 fn test_safe_unpack_rejects_total_bytes_over_cap() {
2477 let temp_dir = tempfile::tempdir().unwrap();
2478 let dest = temp_dir.path().join("dest");
2479 fs::create_dir(&dest).unwrap();
2480
2481 let data = vec![0u8; 4096];
2482 let tar_bytes = make_tar("big.bin", &data);
2483
2484 let mut archive = tar::Archive::new(tar_bytes.as_slice());
2485 let limits = SafeUnpackLimits {
2486 max_entries: 1_000,
2487 max_total_bytes: 1024, sparse_threshold: SPARSE_WRITE_THRESHOLD,
2489 };
2490 let err = safe_unpack_with_limits(&mut archive, &dest, &limits).unwrap_err();
2491 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2492 assert!(err.to_string().contains("max total size"));
2493 }
2494
2495 #[test]
2496 fn test_unpack_sparse_preserves_data_integrity() {
2497 let temp_dir = tempfile::tempdir().unwrap();
2498 let dest = temp_dir.path().join("data.raw");
2499
2500 let block = 64 * 1024;
2504 let mut data = vec![0u8; 8 * block];
2505 for i in (0..8).step_by(2) {
2506 data[i * block..(i + 1) * block].fill(0xFF);
2507 }
2508
2509 let tar_bytes = make_tar("data.raw", &data);
2510 let mut archive = tar::Archive::new(tar_bytes.as_slice());
2511 let mut entry = archive.entries().unwrap().next().unwrap().unwrap();
2512
2513 let real_dest = temp_dir.path().canonicalize().unwrap();
2514 unpack_sparse(&mut entry, &dest, data.len() as u64, 0o644, &real_dest).unwrap();
2515
2516 assert_eq!(fs::read(&dest).unwrap(), data);
2517 }
2518
2519 #[test]
2520 fn test_cache_dir_format() {
2521 let dir = get_cache_dir(0xDEADBEEF).unwrap();
2522 assert!(dir.to_string_lossy().contains("deadbeef"));
2523 }
2524
2525 #[test]
2526 fn test_is_extracted() {
2527 let temp_dir = tempfile::tempdir().unwrap();
2528
2529 assert!(!is_extracted(temp_dir.path()));
2530
2531 fs::write(temp_dir.path().join(EXTRACTION_MARKER), "").unwrap();
2532 assert!(is_extracted(temp_dir.path()));
2533 }
2534
2535 #[test]
2536 fn test_is_extracted_partial() {
2537 let temp_dir = tempfile::tempdir().unwrap();
2538
2539 fs::create_dir_all(temp_dir.path().join("lib")).unwrap();
2541 fs::write(temp_dir.path().join("lib/libkrun.dylib"), "partial").unwrap();
2542
2543 assert!(!is_extracted(temp_dir.path()));
2544 }
2545
2546 #[test]
2547 fn test_sidecar_path_for() {
2548 let exe = Path::new("/path/to/my-app");
2549 let sidecar = sidecar_path_for(exe);
2550 assert_eq!(sidecar, PathBuf::from("/path/to/my-app.smolmachine"));
2551 }
2552
2553 #[test]
2554 fn test_sidecar_mode_detection() {
2555 let sidecar_footer = PackFooter {
2556 stub_size: 0,
2557 assets_offset: 0,
2558 assets_size: 1000,
2559 manifest_offset: 1000,
2560 manifest_size: 500,
2561 checksum: 0x12345678,
2562 };
2563 assert!(is_sidecar_mode(&sidecar_footer));
2564
2565 let embedded_footer = PackFooter {
2566 stub_size: 50000,
2567 assets_offset: 50000,
2568 assets_size: 1000,
2569 manifest_offset: 51000,
2570 manifest_size: 500,
2571 checksum: 0x12345678,
2572 };
2573 assert!(!is_sidecar_mode(&embedded_footer));
2574 }
2575
2576 #[test]
2577 fn test_create_storage_disk() {
2578 let temp_dir = tempfile::tempdir().unwrap();
2579 let disk_path = temp_dir.path().join("test.ext4");
2580
2581 create_storage_disk(&disk_path, 1024 * 1024).unwrap();
2582
2583 assert!(disk_path.exists());
2584 assert_eq!(fs::metadata(&disk_path).unwrap().len(), 1024 * 1024);
2585 }
2586
2587 #[test]
2588 fn test_copy_overlay_template_fails_when_none() {
2589 let temp_dir = tempfile::tempdir().unwrap();
2590 let dest = temp_dir.path().join("overlay.raw");
2591
2592 let result = copy_overlay_template(temp_dir.path(), None, &dest, None, None);
2593 assert!(result.is_err());
2594 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
2595 }
2596
2597 #[test]
2598 fn test_copy_overlay_template_fails_when_missing() {
2599 let temp_dir = tempfile::tempdir().unwrap();
2600 let dest = temp_dir.path().join("overlay.raw");
2601
2602 let result =
2603 copy_overlay_template(temp_dir.path(), Some("nonexistent.raw"), &dest, None, None);
2604 assert!(result.is_err());
2605 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
2606 }
2607
2608 #[test]
2609 fn test_copy_overlay_template_copies_and_extends() {
2610 let temp_dir = tempfile::tempdir().unwrap();
2611 let template = temp_dir.path().join("overlay.raw");
2612 let dest = temp_dir.path().join("output.raw");
2613
2614 let template_data = vec![0u8; 1024];
2616 fs::write(&template, &template_data).unwrap();
2617
2618 copy_overlay_template(temp_dir.path(), Some("overlay.raw"), &dest, None, None).unwrap();
2620 assert_eq!(fs::metadata(&dest).unwrap().len(), 1024);
2621
2622 let dest2 = temp_dir.path().join("output2.raw");
2624 copy_overlay_template(
2625 temp_dir.path(),
2626 Some("overlay.raw"),
2627 &dest2,
2628 None,
2629 Some(4096),
2630 )
2631 .unwrap();
2632 assert_eq!(fs::metadata(&dest2).unwrap().len(), 4096);
2633 }
2634
2635 #[test]
2636 fn test_copy_overlay_template_size_gb_takes_max() {
2637 let temp_dir = tempfile::tempdir().unwrap();
2638 let template = temp_dir.path().join("overlay.raw");
2639 fs::write(&template, vec![0u8; 1024]).unwrap();
2640
2641 let dest = temp_dir.path().join("out_a.raw");
2643 copy_overlay_template(
2644 temp_dir.path(),
2645 Some("overlay.raw"),
2646 &dest,
2647 Some(1), Some(4096),
2649 )
2650 .unwrap();
2651 assert_eq!(fs::metadata(&dest).unwrap().len(), 1024 * 1024 * 1024);
2652
2653 let dest2 = temp_dir.path().join("out_b.raw");
2655 copy_overlay_template(
2656 temp_dir.path(),
2657 Some("overlay.raw"),
2658 &dest2,
2659 None,
2660 Some(8192), )
2662 .unwrap();
2663 assert_eq!(fs::metadata(&dest2).unwrap().len(), 8192);
2664 }
2665
2666 #[test]
2667 fn test_copy_overlay_template_rejects_traversal_path() {
2668 let temp_dir = tempfile::tempdir().unwrap();
2669 let outside = temp_dir.path().join("outside.raw");
2670 let dest = temp_dir.path().join("overlay.raw");
2671 fs::write(&outside, b"x").unwrap();
2672
2673 let result =
2674 copy_overlay_template(temp_dir.path(), Some("../outside.raw"), &dest, None, None);
2675 assert!(result.is_err());
2676 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
2677 }
2678
2679 #[cfg(unix)]
2680 #[test]
2681 fn test_create_or_copy_storage_disk_rejects_symlink_escape() {
2682 use std::os::unix::fs::symlink;
2683
2684 let temp_dir = tempfile::tempdir().unwrap();
2685 let outside_dir = tempfile::tempdir().unwrap();
2686 let outside_file = outside_dir.path().join("storage-template.ext4");
2687 fs::write(&outside_file, b"template").unwrap();
2688
2689 symlink(outside_dir.path(), temp_dir.path().join("symlink-out")).unwrap();
2690
2691 let storage_path = temp_dir.path().join("storage.ext4");
2692 let result = create_or_copy_storage_disk(
2693 temp_dir.path(),
2694 Some("symlink-out/storage-template.ext4"),
2695 &storage_path,
2696 None,
2697 );
2698 assert!(result.is_err());
2699 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
2700 }
2701
2702 #[cfg(unix)]
2703 #[test]
2704 fn test_create_or_copy_storage_disk_preserves_sparseness() {
2705 use std::os::unix::fs::MetadataExt;
2706
2707 let cache_dir = tempfile::tempdir().unwrap();
2710 let template = cache_dir.path().join("storage-template.ext4");
2711 {
2712 let mut f = File::create(&template).unwrap();
2713 f.write_all(b"real ext4 superblock stand-in").unwrap();
2715 f.set_len(1024 * 1024 * 1024).unwrap();
2717 }
2718
2719 let dest = cache_dir.path().join("storage.ext4");
2720 create_or_copy_storage_disk(cache_dir.path(), Some("storage-template.ext4"), &dest, None)
2721 .unwrap();
2722
2723 let meta = fs::metadata(&dest).unwrap();
2724 assert_eq!(meta.len(), 1024 * 1024 * 1024);
2726 let allocated_bytes = meta.blocks() * 512;
2730 assert!(
2731 allocated_bytes < 16 * 1024 * 1024,
2732 "storage disk was densified: {allocated_bytes} bytes allocated for a sparse template"
2733 );
2734 }
2735
2736 #[test]
2737 fn test_extract_sidecar_skips_when_already_extracted() {
2738 let temp_dir = tempfile::tempdir().unwrap();
2741 let cache_dir = temp_dir.path().join("cache");
2742 fs::create_dir_all(&cache_dir).unwrap();
2743
2744 fs::write(cache_dir.join(EXTRACTION_MARKER), "").unwrap();
2746
2747 let dummy_footer = PackFooter {
2748 stub_size: 0,
2749 assets_offset: 0,
2750 assets_size: 0,
2751 manifest_offset: 0,
2752 manifest_size: 0,
2753 checksum: 0,
2754 };
2755
2756 let result = extract_sidecar(
2759 Path::new("/nonexistent/sidecar.smolmachine"),
2760 &cache_dir,
2761 &dummy_footer,
2762 false, false,
2764 );
2765 drop(result);
2771
2772 let dummy_sidecar = temp_dir.path().join("dummy.smolmachine");
2773 fs::write(&dummy_sidecar, b"").unwrap();
2774
2775 let result = extract_sidecar(
2776 &dummy_sidecar,
2777 &cache_dir,
2778 &dummy_footer,
2779 false, false,
2781 );
2782 assert!(result.is_ok());
2783 }
2784
2785 #[test]
2786 fn test_extract_sidecar_force_clears_marker() {
2787 let temp_dir = tempfile::tempdir().unwrap();
2792 let cache_dir = temp_dir.path().join("cache-force");
2793 fs::create_dir_all(&cache_dir).unwrap();
2794
2795 fs::write(cache_dir.join(EXTRACTION_MARKER), "").unwrap();
2797 assert!(is_extracted(&cache_dir));
2798
2799 let dummy_sidecar = temp_dir.path().join("force.smolmachine");
2801 fs::write(&dummy_sidecar, b"not-a-real-zstd-stream").unwrap();
2802
2803 let dummy_footer = PackFooter {
2804 stub_size: 0,
2805 assets_offset: 0,
2806 assets_size: 22, manifest_offset: 22,
2808 manifest_size: 0,
2809 checksum: 0,
2810 };
2811
2812 let result = extract_sidecar(
2813 &dummy_sidecar,
2814 &cache_dir,
2815 &dummy_footer,
2816 true, false,
2818 );
2819
2820 assert!(
2823 result.is_err(),
2824 "force extraction should attempt (and fail on dummy data)"
2825 );
2826 }
2827
2828 fn build_tar(entries: &[(&str, bool, &[u8])]) -> Vec<u8> {
2831 let mut builder = tar::Builder::new(Vec::new());
2832 for (path, is_dir, content) in entries {
2833 let mut header = tar::Header::new_gnu();
2834 if *is_dir {
2835 header.set_entry_type(tar::EntryType::Directory);
2836 header.set_size(0);
2837 header.set_mode(0o755);
2838 } else {
2839 header.set_entry_type(tar::EntryType::Regular);
2840 header.set_size(content.len() as u64);
2841 header.set_mode(0o644);
2842 }
2843 header.set_cksum();
2844 builder
2845 .append_data(&mut header, *path, &content[..])
2846 .unwrap();
2847 }
2848 builder.into_inner().unwrap()
2849 }
2850
2851 #[test]
2852 fn test_safe_unpack_normal_tar() {
2853 let temp_dir = tempfile::tempdir().unwrap();
2854 let dest_raw = temp_dir.path().join("out");
2855 fs::create_dir_all(&dest_raw).unwrap();
2856 let dest = dest_raw.canonicalize().unwrap();
2858
2859 let tar_data = build_tar(&[("dir/", true, b""), ("dir/file.txt", false, b"hello")]);
2860 let mut archive = tar::Archive::new(tar_data.as_slice());
2861 safe_unpack(&mut archive, &dest).unwrap();
2862
2863 assert!(dest.join("dir").is_dir());
2864 assert_eq!(
2865 fs::read_to_string(dest.join("dir/file.txt")).unwrap(),
2866 "hello"
2867 );
2868 }
2869
2870 #[test]
2871 #[cfg(target_os = "macos")]
2872 fn test_safe_unpack_case_collision_fails_on_case_insensitive_fs() {
2873 let temp_dir = tempfile::tempdir().unwrap();
2877 let dest_raw = temp_dir.path().join("out");
2878 fs::create_dir_all(&dest_raw).unwrap();
2879 let dest = dest_raw.canonicalize().unwrap();
2880
2881 let tar_data = build_tar(&[
2882 ("share/", true, b""),
2883 ("share/pkg/", true, b""),
2884 ("share/pkg/lower", false, b"script content"),
2885 ("share/pkg/Lower/", true, b""),
2886 ("share/pkg/Lower/__init__.py", false, b"python code"),
2887 ]);
2888 let mut archive = tar::Archive::new(tar_data.as_slice());
2889
2890 let result = safe_unpack(&mut archive, &dest);
2893 assert!(
2894 result.is_err(),
2895 "case collision should fail on case-insensitive FS"
2896 );
2897 }
2898
2899 #[test]
2900 #[cfg(target_os = "macos")]
2901 fn test_layers_lease_creates_and_cleans_volume() {
2902 let temp_dir = tempfile::tempdir().unwrap();
2906 let cache_dir = temp_dir.path().join("cache");
2907 fs::create_dir_all(cache_dir.join("layers")).unwrap();
2909 fs::write(cache_dir.join("layers/dummy.tar"), b"").unwrap();
2910
2911 let lease = match acquire_layers_lease(&cache_dir, false) {
2912 Ok(l) => l,
2913 Err(e) => {
2914 eprintln!("SKIP: hdiutil unavailable: {}", e);
2915 return;
2916 }
2917 };
2918 assert!(lease.path.exists());
2919 assert!(is_mount_point(&lease.path));
2920
2921 fs::write(lease.path.join("lower"), "file").unwrap();
2923 fs::create_dir_all(lease.path.join("Lower")).unwrap();
2924 assert!(lease.path.join("lower").exists());
2925 assert!(lease.path.join("Lower").is_dir());
2926
2927 let lease_file = cache_dir
2929 .join(LEASES_DIR)
2930 .join(format!("{}", std::process::id()));
2931 assert!(lease_file.exists());
2932
2933 let mount_point = lease.path.clone();
2935 drop(lease);
2936 assert!(
2937 !is_mount_point(&mount_point),
2938 "volume should be detached after last lease drop"
2939 );
2940 }
2941
2942 #[test]
2943 fn test_safe_unpack_skips_char_and_block_devices() {
2944 let temp_dir = tempfile::tempdir().unwrap();
2947 let dest_raw = temp_dir.path().join("out");
2948 fs::create_dir_all(&dest_raw).unwrap();
2949 let dest = dest_raw.canonicalize().unwrap();
2950
2951 let mut builder = tar::Builder::new(Vec::new());
2952
2953 let mut header = tar::Header::new_gnu();
2955 header.set_entry_type(tar::EntryType::Regular);
2956 header.set_size(5);
2957 header.set_mode(0o644);
2958 header.set_path("before.txt").unwrap();
2959 header.set_cksum();
2960 builder
2961 .append_data(&mut header, "before.txt", &b"hello"[..])
2962 .unwrap();
2963
2964 let mut header = tar::Header::new_gnu();
2966 header.set_entry_type(tar::EntryType::Char);
2967 header.set_size(0);
2968 header.set_mode(0o644);
2969 header.set_path("etc/alternatives/pager.1.gz").unwrap();
2970 header.set_cksum();
2971 builder
2972 .append_data(&mut header, "etc/alternatives/pager.1.gz", &b""[..])
2973 .unwrap();
2974
2975 let mut header = tar::Header::new_gnu();
2977 header.set_entry_type(tar::EntryType::Block);
2978 header.set_size(0);
2979 header.set_mode(0o644);
2980 header.set_path("dev/sda").unwrap();
2981 header.set_cksum();
2982 builder
2983 .append_data(&mut header, "dev/sda", &b""[..])
2984 .unwrap();
2985
2986 let mut header = tar::Header::new_gnu();
2988 header.set_entry_type(tar::EntryType::Regular);
2989 header.set_size(5);
2990 header.set_mode(0o644);
2991 header.set_path("after.txt").unwrap();
2992 header.set_cksum();
2993 builder
2994 .append_data(&mut header, "after.txt", &b"world"[..])
2995 .unwrap();
2996
2997 let tar_data = builder.into_inner().unwrap();
2998
2999 let mut archive = tar::Archive::new(tar_data.as_slice());
3000 let result = safe_unpack(&mut archive, &dest);
3001 assert!(
3002 result.is_ok(),
3003 "Char/Block entries should be skipped: {:?}",
3004 result.err()
3005 );
3006
3007 assert_eq!(
3009 fs::read_to_string(dest.join("before.txt")).unwrap(),
3010 "hello"
3011 );
3012 assert_eq!(fs::read_to_string(dest.join("after.txt")).unwrap(), "world");
3013
3014 assert!(!dest.join("etc/alternatives/pager.1.gz").exists());
3016 assert!(!dest.join("dev/sda").exists());
3017 }
3018
3019 #[test]
3020 fn test_safe_unpack_skips_hardlink_to_whiteout() {
3021 let temp_dir = tempfile::tempdir().unwrap();
3026 let dest_raw = temp_dir.path().join("out");
3027 fs::create_dir_all(&dest_raw).unwrap();
3028 let dest = dest_raw.canonicalize().unwrap();
3029
3030 let mut builder = tar::Builder::new(Vec::new());
3031
3032 let mut header = tar::Header::new_gnu();
3034 header.set_entry_type(tar::EntryType::Char);
3035 header.set_size(0);
3036 header.set_mode(0o000);
3037 header.set_path("usr/lib/.build-id/84/target").unwrap();
3038 header.set_cksum();
3039 builder
3040 .append_data(&mut header, "usr/lib/.build-id/84/target", &b""[..])
3041 .unwrap();
3042
3043 let mut header = tar::Header::new_gnu();
3045 header.set_entry_type(tar::EntryType::Link);
3046 header.set_size(0);
3047 header.set_mode(0o000);
3048 header.set_path("usr/lib/.build-id/d9/link").unwrap();
3049 header.set_link_name("usr/lib/.build-id/84/target").unwrap();
3050 header.set_cksum();
3051 builder
3052 .append_data(&mut header, "usr/lib/.build-id/d9/link", &b""[..])
3053 .unwrap();
3054
3055 let mut header = tar::Header::new_gnu();
3057 header.set_entry_type(tar::EntryType::Regular);
3058 header.set_size(2);
3059 header.set_mode(0o644);
3060 header.set_path("ok.txt").unwrap();
3061 header.set_cksum();
3062 builder
3063 .append_data(&mut header, "ok.txt", &b"ok"[..])
3064 .unwrap();
3065
3066 let tar_data = builder.into_inner().unwrap();
3067
3068 let mut archive = tar::Archive::new(tar_data.as_slice());
3069 let result = safe_unpack(&mut archive, &dest);
3070 assert!(
3071 result.is_ok(),
3072 "hardlink to skipped whiteout should be skipped: {:?}",
3073 result.err()
3074 );
3075
3076 assert!(!dest.join("usr/lib/.build-id/84/target").exists());
3078 assert!(!dest.join("usr/lib/.build-id/d9/link").exists());
3079 assert_eq!(fs::read_to_string(dest.join("ok.txt")).unwrap(), "ok");
3081 }
3082
3083 #[test]
3084 fn test_safe_unpack_readonly_parent_dir_does_not_block_children() {
3085 let temp_dir = tempfile::tempdir().unwrap();
3089 let dest_raw = temp_dir.path().join("out");
3090 fs::create_dir_all(&dest_raw).unwrap();
3091 let dest = dest_raw.canonicalize().unwrap();
3092
3093 let mut builder = tar::Builder::new(Vec::new());
3094
3095 let mut header = tar::Header::new_gnu();
3097 header.set_entry_type(tar::EntryType::Directory);
3098 header.set_size(0);
3099 header.set_mode(0o555); header.set_path("usr/lib64/pm-utils/").unwrap();
3101 header.set_cksum();
3102 builder
3103 .append_data(&mut header, "usr/lib64/pm-utils/", &b""[..])
3104 .unwrap();
3105
3106 let mut header = tar::Header::new_gnu();
3108 header.set_entry_type(tar::EntryType::Directory);
3109 header.set_size(0);
3110 header.set_mode(0o555);
3111 header.set_path("usr/lib64/pm-utils/module.d/").unwrap();
3112 header.set_cksum();
3113 builder
3114 .append_data(&mut header, "usr/lib64/pm-utils/module.d/", &b""[..])
3115 .unwrap();
3116
3117 let mut header = tar::Header::new_gnu();
3119 header.set_entry_type(tar::EntryType::Regular);
3120 header.set_size(4);
3121 header.set_mode(0o644);
3122 header
3123 .set_path("usr/lib64/pm-utils/module.d/test.conf")
3124 .unwrap();
3125 header.set_cksum();
3126 builder
3127 .append_data(
3128 &mut header,
3129 "usr/lib64/pm-utils/module.d/test.conf",
3130 &b"data"[..],
3131 )
3132 .unwrap();
3133
3134 let tar_data = builder.into_inner().unwrap();
3135
3136 let mut archive = tar::Archive::new(tar_data.as_slice());
3137 let result = safe_unpack(&mut archive, &dest);
3138 assert!(
3139 result.is_ok(),
3140 "read-only parent should not block children: {:?}",
3141 result.err()
3142 );
3143
3144 assert!(dest.join("usr/lib64/pm-utils/module.d").is_dir());
3146 assert_eq!(
3147 fs::read_to_string(dest.join("usr/lib64/pm-utils/module.d/test.conf")).unwrap(),
3148 "data"
3149 );
3150
3151 #[cfg(unix)]
3153 {
3154 use std::os::unix::fs::PermissionsExt;
3155 let mode = fs::metadata(dest.join("usr/lib64/pm-utils"))
3156 .unwrap()
3157 .permissions()
3158 .mode()
3159 & 0o777;
3160 assert_eq!(mode, 0o555, "deferred directory mode should be 555");
3161 }
3162 }
3163
3164 #[test]
3165 fn test_safe_unpack_mixed_fedora_overlay_layer() {
3166 let temp_dir = tempfile::tempdir().unwrap();
3170 let dest_raw = temp_dir.path().join("out");
3171 fs::create_dir_all(&dest_raw).unwrap();
3172 let dest = dest_raw.canonicalize().unwrap();
3173
3174 let mut builder = tar::Builder::new(Vec::new());
3175
3176 let mut header = tar::Header::new_gnu();
3178 header.set_entry_type(tar::EntryType::Directory);
3179 header.set_size(0);
3180 header.set_mode(0o755);
3181 header.set_path("usr/").unwrap();
3182 header.set_cksum();
3183 builder.append_data(&mut header, "usr/", &b""[..]).unwrap();
3184
3185 let mut header = tar::Header::new_gnu();
3187 header.set_entry_type(tar::EntryType::Regular);
3188 header.set_size(11);
3189 header.set_mode(0o644);
3190 header.set_path("usr/good1.txt").unwrap();
3191 header.set_cksum();
3192 builder
3193 .append_data(&mut header, "usr/good1.txt", &b"good file 1"[..])
3194 .unwrap();
3195
3196 let mut header = tar::Header::new_gnu();
3198 header.set_entry_type(tar::EntryType::Char);
3199 header.set_size(0);
3200 header.set_mode(0o000);
3201 header.set_device_major(0).unwrap();
3202 header.set_device_minor(0).unwrap();
3203 header.set_path("usr/.wh.removed-pkg").unwrap();
3204 header.set_cksum();
3205 builder
3206 .append_data(&mut header, "usr/.wh.removed-pkg", &b""[..])
3207 .unwrap();
3208
3209 let mut header = tar::Header::new_gnu();
3211 header.set_entry_type(tar::EntryType::Regular);
3212 header.set_size(11);
3213 header.set_mode(0o644);
3214 header.set_path("usr/good2.txt").unwrap();
3215 header.set_cksum();
3216 builder
3217 .append_data(&mut header, "usr/good2.txt", &b"good file 2"[..])
3218 .unwrap();
3219
3220 let mut header = tar::Header::new_gnu();
3222 header.set_entry_type(tar::EntryType::Link);
3223 header.set_size(0);
3224 header.set_mode(0o000);
3225 header.set_path("usr/link-to-removed").unwrap();
3226 header.set_link_name("usr/.wh.removed-pkg").unwrap();
3227 header.set_cksum();
3228 builder
3229 .append_data(&mut header, "usr/link-to-removed", &b""[..])
3230 .unwrap();
3231
3232 let mut header = tar::Header::new_gnu();
3234 header.set_entry_type(tar::EntryType::Regular);
3235 header.set_size(19); header.set_mode(0o755);
3237 header.set_path("usr/good3.sh").unwrap();
3238 header.set_cksum();
3239 builder
3240 .append_data(&mut header, "usr/good3.sh", &b"#!/usr/bin/env bash"[..])
3241 .unwrap();
3242
3243 let mut header = tar::Header::new_gnu();
3245 header.set_entry_type(tar::EntryType::Char);
3246 header.set_size(0);
3247 header.set_mode(0o000);
3248 header.set_device_major(0).unwrap();
3249 header.set_device_minor(0).unwrap();
3250 header.set_path("usr/.wh.another-removed").unwrap();
3251 header.set_cksum();
3252 builder
3253 .append_data(&mut header, "usr/.wh.another-removed", &b""[..])
3254 .unwrap();
3255
3256 let mut header = tar::Header::new_gnu();
3258 header.set_entry_type(tar::EntryType::Regular);
3259 header.set_size(5);
3260 header.set_mode(0o644);
3261 header.set_path("usr/good4.dat").unwrap();
3262 header.set_cksum();
3263 builder
3264 .append_data(&mut header, "usr/good4.dat", &b"final"[..])
3265 .unwrap();
3266
3267 let tar_data = builder.into_inner().unwrap();
3268
3269 let mut archive = tar::Archive::new(tar_data.as_slice());
3270 let result = safe_unpack(&mut archive, &dest);
3271 assert!(
3272 result.is_ok(),
3273 "mixed Fedora overlay should extract cleanly: {:?}",
3274 result.err()
3275 );
3276
3277 assert_eq!(
3279 fs::read_to_string(dest.join("usr/good1.txt")).unwrap(),
3280 "good file 1"
3281 );
3282 assert_eq!(
3283 fs::read_to_string(dest.join("usr/good2.txt")).unwrap(),
3284 "good file 2"
3285 );
3286 assert_eq!(
3287 fs::read_to_string(dest.join("usr/good3.sh")).unwrap(),
3288 "#!/usr/bin/env bash"
3289 );
3290 assert_eq!(
3291 fs::read_to_string(dest.join("usr/good4.dat")).unwrap(),
3292 "final"
3293 );
3294
3295 assert!(!dest.join("usr/.wh.removed-pkg").exists());
3297 assert!(!dest.join("usr/link-to-removed").exists());
3298 assert!(!dest.join("usr/.wh.another-removed").exists());
3299 }
3300
3301 #[test]
3302 fn test_safe_unpack_unknown_tar_type_byte() {
3303 let temp_dir = tempfile::tempdir().unwrap();
3308 let dest_raw = temp_dir.path().join("out");
3309 fs::create_dir_all(&dest_raw).unwrap();
3310 let dest = dest_raw.canonicalize().unwrap();
3311
3312 let mut builder = tar::Builder::new(Vec::new());
3313
3314 let mut header = tar::Header::new_gnu();
3316 header.set_entry_type(tar::EntryType::Regular);
3317 header.set_size(6);
3318 header.set_mode(0o644);
3319 header.set_path("before.txt").unwrap();
3320 header.set_cksum();
3321 builder
3322 .append_data(&mut header, "before.txt", &b"before"[..])
3323 .unwrap();
3324
3325 let mut header = tar::Header::new_gnu();
3327 header.set_entry_type(tar::EntryType::new(b'A'));
3328 header.set_size(0);
3329 header.set_mode(0o644);
3330 header.set_path("unknown-type-entry").unwrap();
3331 header.set_cksum();
3332 builder
3333 .append_data(&mut header, "unknown-type-entry", &b""[..])
3334 .unwrap();
3335
3336 let mut header = tar::Header::new_gnu();
3338 header.set_entry_type(tar::EntryType::Regular);
3339 header.set_size(5);
3340 header.set_mode(0o644);
3341 header.set_path("after.txt").unwrap();
3342 header.set_cksum();
3343 builder
3344 .append_data(&mut header, "after.txt", &b"after"[..])
3345 .unwrap();
3346
3347 let tar_data = builder.into_inner().unwrap();
3348
3349 let mut archive = tar::Archive::new(tar_data.as_slice());
3350 let result = safe_unpack(&mut archive, &dest);
3351 assert!(
3352 result.is_ok(),
3353 "unknown tar type should be skipped: {:?}",
3354 result.err()
3355 );
3356
3357 assert_eq!(
3358 fs::read_to_string(dest.join("before.txt")).unwrap(),
3359 "before"
3360 );
3361 assert_eq!(fs::read_to_string(dest.join("after.txt")).unwrap(), "after");
3362 assert!(!dest.join("unknown-type-entry").exists());
3363 }
3364
3365 #[cfg(unix)]
3368 #[test]
3369 fn test_evict_cache_to_size_lru() {
3370 use std::ffi::CString;
3371 let tmp = tempfile::tempdir().unwrap();
3372 let root = tmp.path();
3373 let mk = |name: &str, mtime_secs: i64| {
3374 let d = root.join(name);
3375 fs::create_dir_all(&d).unwrap();
3376 fs::write(d.join("data"), vec![0u8; 1024 * 1024]).unwrap(); let c = CString::new(d.to_string_lossy().as_bytes()).unwrap();
3378 let tv = libc::timeval {
3379 tv_sec: mtime_secs,
3380 tv_usec: 0,
3381 };
3382 let times = [tv, tv];
3383 unsafe {
3384 libc::utimes(c.as_ptr(), times.as_ptr());
3385 }
3386 d
3387 };
3388 let old = mk("aaaa", 1_000_000);
3389 let mid = mk("bbbb", 2_000_000);
3390 let new = mk("cccc", 3_000_000);
3391
3392 assert_eq!(evict_cache_to_size(root, 100 * 1024 * 1024), 0);
3394 assert!(old.exists() && mid.exists() && new.exists());
3395
3396 let freed = evict_cache_to_size(root, 5 * 1024 * 1024 / 2);
3398 assert!(freed > 0, "expected some bytes freed");
3399 assert!(!old.exists(), "oldest extraction should be evicted");
3400 assert!(mid.exists() && new.exists(), "newer extractions kept");
3401 }
3402
3403 #[cfg(unix)]
3404 #[test]
3405 fn test_evict_cache_protects_current_extraction() {
3406 use std::ffi::CString;
3407 let tmp = tempfile::tempdir().unwrap();
3408 let root = tmp.path();
3409 let mk = |name: &str, mtime_secs: i64| {
3410 let d = root.join(name);
3411 fs::create_dir_all(&d).unwrap();
3412 fs::write(d.join("data"), vec![0u8; 4 * 1024 * 1024]).unwrap(); let c = CString::new(d.to_string_lossy().as_bytes()).unwrap();
3414 let tv = libc::timeval {
3415 tv_sec: mtime_secs,
3416 tv_usec: 0,
3417 };
3418 let times = [tv, tv];
3419 unsafe {
3420 libc::utimes(c.as_ptr(), times.as_ptr());
3421 }
3422 d
3423 };
3424 let old = mk("aaaa", 1_000_000);
3429 let current = mk("cccc", 3_000_000);
3430
3431 let freed = evict_cache_to_size_protecting(root, 1024 * 1024, Some(¤t));
3433 assert!(freed > 0, "the old entry should still be evicted");
3434 assert!(!old.exists(), "oldest unprotected extraction evicted");
3435 assert!(
3436 current.exists() && current.join("data").exists(),
3437 "the protected (just-extracted) dir must survive even over cap"
3438 );
3439 }
3440
3441 #[cfg(target_os = "macos")]
3444 #[test]
3445 fn test_evict_cache_skips_active_lease() {
3446 let tmp = tempfile::tempdir().unwrap();
3447 let root = tmp.path();
3448 let d = root.join("aaaa");
3449 fs::create_dir_all(&d).unwrap();
3450 fs::write(d.join("data"), vec![0u8; 2 * 1024 * 1024]).unwrap();
3451 let leases = d.join(LEASES_DIR);
3453 fs::create_dir_all(&leases).unwrap();
3454 fs::write(leases.join("daemon"), format!("{}", std::process::id())).unwrap();
3455 assert!(has_active_leases(&d), "live lease should be detected");
3456 let freed = evict_cache_to_size(root, 0);
3458 assert_eq!(freed, 0, "leased extraction must not be evicted");
3459 assert!(d.exists());
3460 }
3461}
3462
3463#[cfg(all(test, target_os = "macos"))]
3465mod orphan_reap_tests {
3466 use super::*;
3467
3468 #[test]
3470 fn flat_layout_sees_its_peers() {
3471 let tmp = tempfile::tempdir().expect("tempdir");
3472 let root = tmp.path();
3473 for h in ["aaa", "bbb", "ccc"] {
3474 fs::create_dir_all(root.join(h)).expect("mkdir");
3475 }
3476 let peers = sibling_cache_dirs(&root.join("aaa"));
3477
3478 assert_eq!(peers.len(), 2, "both peers, and never itself");
3479 assert!(peers.contains(&root.join("bbb")));
3480 assert!(peers.contains(&root.join("ccc")));
3481 assert!(!peers.contains(&root.join("aaa")));
3482 }
3483
3484 #[test]
3487 fn nested_layout_keeps_the_pack_suffix() {
3488 let tmp = tempfile::tempdir().expect("tempdir");
3489 let root = tmp.path();
3490 for h in ["aaa", "bbb"] {
3491 fs::create_dir_all(root.join(h).join("pack")).expect("mkdir");
3492 }
3493 let peers = sibling_cache_dirs(&root.join("aaa").join("pack"));
3494
3495 assert_eq!(peers, vec![root.join("bbb").join("pack")]);
3496 }
3497
3498 #[test]
3501 fn nested_layout_skips_peers_without_a_pack_dir() {
3502 let tmp = tempfile::tempdir().expect("tempdir");
3503 let root = tmp.path();
3504 fs::create_dir_all(root.join("aaa").join("pack")).expect("mkdir");
3505 fs::create_dir_all(root.join("bbb")).expect("mkdir");
3506
3507 assert!(sibling_cache_dirs(&root.join("aaa").join("pack")).is_empty());
3508 }
3509
3510 #[test]
3513 fn a_held_lock_is_skipped_not_waited_on() {
3514 let tmp = tempfile::tempdir().expect("tempdir");
3515 let dir = tmp.path();
3516 let held = lock_leases(dir).expect("first lock");
3517
3518 assert!(
3519 try_lock_leases(dir).is_err(),
3520 "must fail immediately while held"
3521 );
3522
3523 drop(held);
3524 assert!(try_lock_leases(dir).is_ok(), "succeeds once released");
3525 }
3526
3527 #[test]
3530 fn unmounted_peers_are_left_alone() {
3531 let tmp = tempfile::tempdir().expect("tempdir");
3532 let root = tmp.path();
3533 let peer = root.join("bbb");
3534 fs::create_dir_all(peer.join(LEASES_DIR)).expect("mkdir");
3535 fs::create_dir_all(root.join("aaa")).expect("mkdir");
3536 let marker = peer.join(LEASES_DIR).join("12345");
3537 fs::write(&marker, "").expect("write lease");
3538
3539 reap_orphan_volumes(&root.join("aaa"));
3540
3541 assert!(peer.exists(), "peer directory must survive");
3542 }
3543}
3544
3545#[cfg(test)]
3547mod sparse_copy_tests {
3548 use super::*;
3549
3550 fn roundtrip(build: impl FnOnce(&mut File)) -> (Vec<u8>, Vec<u8>, u64) {
3552 let tmp = tempfile::tempdir().expect("tempdir");
3553 let src = tmp.path().join("src.img");
3554 let dst = tmp.path().join("dst.img");
3555 let mut f = File::create(&src).expect("create src");
3556 build(&mut f);
3557 f.sync_all().expect("sync");
3558 drop(f);
3559
3560 sparse_copy(&src, &dst).expect("sparse_copy");
3561
3562 let want = fs::read(&src).expect("read src");
3563 let got = fs::read(&dst).expect("read dst");
3564 let blocks = dst_blocks(&dst);
3565 (want, got, blocks)
3566 }
3567
3568 #[cfg(unix)]
3569 fn dst_blocks(p: &Path) -> u64 {
3570 use std::os::unix::fs::MetadataExt;
3571 fs::metadata(p).expect("stat").blocks()
3572 }
3573 #[cfg(not(unix))]
3574 fn dst_blocks(_p: &Path) -> u64 {
3575 0
3576 }
3577
3578 #[test]
3580 fn data_separated_by_a_large_hole_round_trips() {
3581 let (want, got, _) = roundtrip(|f| {
3582 f.write_all(b"HEAD").expect("head");
3583 f.seek(SeekFrom::Start(64 * 1024 * 1024)).expect("seek");
3584 f.write_all(b"TAIL").expect("tail");
3585 });
3586 assert_eq!(want.len(), 64 * 1024 * 1024 + 4);
3587 assert_eq!(want, got, "copy must be byte-identical across the hole");
3588 }
3589
3590 #[cfg(unix)]
3592 #[test]
3593 fn the_hole_is_not_materialized() {
3594 let (want, _got, blocks) = roundtrip(|f| {
3595 f.write_all(b"HEAD").expect("head");
3596 f.seek(SeekFrom::Start(64 * 1024 * 1024)).expect("seek");
3597 f.write_all(b"TAIL").expect("tail");
3598 });
3599 let dense = (want.len() as u64) / 512;
3602 assert!(
3603 blocks < dense / 10,
3604 "destination should stay sparse: {blocks} blocks vs {dense} if dense"
3605 );
3606 }
3607
3608 #[test]
3611 fn an_all_zero_file_round_trips() {
3612 let (want, got, _) = roundtrip(|f| {
3613 f.set_len(8 * 1024 * 1024).expect("set_len");
3614 });
3615 assert_eq!(want.len(), 8 * 1024 * 1024);
3616 assert_eq!(got, want, "all-zero file must copy as all zeros");
3617 }
3618
3619 #[test]
3621 fn a_dense_file_round_trips() {
3622 let (want, got, _) = roundtrip(|f| {
3623 let data: Vec<u8> = (0..=255u8).cycle().take(3 * 1024 * 1024).collect();
3624 f.write_all(&data).expect("write");
3625 });
3626 assert_eq!(want, got, "dense content must be preserved exactly");
3627 assert!(want.iter().any(|&b| b != 0));
3628 }
3629
3630 #[test]
3632 fn an_empty_file_round_trips() {
3633 let (want, got, _) = roundtrip(|_f| {});
3634 assert!(want.is_empty() && got.is_empty());
3635 }
3636
3637 #[test]
3640 fn an_extent_larger_than_the_buffer_round_trips() {
3641 let (want, got, _) = roundtrip(|f| {
3642 let data: Vec<u8> = (0..=255u8).cycle().take(1_500_000).collect();
3643 f.write_all(&data).expect("write");
3644 f.seek(SeekFrom::Start(32 * 1024 * 1024)).expect("seek");
3645 f.write_all(b"END").expect("end");
3646 });
3647 assert_eq!(want, got, "multi-chunk extent must copy exactly");
3648 }
3649}
3650
3651#[cfg(test)]
3654mod sparse_copy_differential_tests {
3655 use super::*;
3656 fn reference_scan_copy(src: &Path, dst: &Path) -> std::io::Result<()> {
3659 let mut src_file = File::open(src)?;
3660 let size = src_file.metadata()?.len();
3661 let mut dst_file = File::create(dst)?;
3662 dst_file.set_len(size)?;
3663 let mut buf = vec![0u8; 512 * 1024];
3664 let mut offset: u64 = 0;
3665 while offset < size {
3666 let to_read = (size - offset).min(buf.len() as u64) as usize;
3667 let n = src_file.read(&mut buf[..to_read])?;
3668 if n == 0 {
3669 break;
3670 }
3671 let chunk = &buf[..n];
3672 if chunk.iter().any(|&b| b != 0) {
3673 dst_file.seek(SeekFrom::Start(offset))?;
3674 dst_file.write_all(chunk)?;
3675 }
3676 offset += n as u64;
3677 }
3678 Ok(())
3679 }
3680
3681 fn files_equal(a: &Path, b: &Path) -> bool {
3685 let (mut fa, mut fb) = (
3686 File::open(a).expect("open a"),
3687 File::open(b).expect("open b"),
3688 );
3689 if fa.metadata().expect("meta").len() != fb.metadata().expect("meta").len() {
3690 return false;
3691 }
3692 let (mut ba, mut bb) = (vec![0u8; 4 << 20], vec![0u8; 4 << 20]);
3693 loop {
3694 let na = fa.read(&mut ba).expect("read a");
3695 let nb = fb.read(&mut bb).expect("read b");
3696 if na != nb {
3697 return false;
3698 }
3699 if na == 0 {
3700 return true;
3701 }
3702 if ba[..na] != bb[..nb] {
3703 return false;
3704 }
3705 }
3706 }
3707
3708 #[cfg(unix)]
3709 fn blocks(p: &Path) -> u64 {
3710 use std::os::unix::fs::MetadataExt;
3711 fs::metadata(p).expect("stat").blocks()
3712 }
3713
3714 struct Lcg(u64);
3716 impl Lcg {
3717 fn next(&mut self) -> u64 {
3718 self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
3719 self.0 >> 11
3720 }
3721 }
3722
3723 #[test]
3726 fn fuzz_layouts_agree_with_the_old_algorithm() {
3727 let tmp = tempfile::tempdir().expect("tempdir");
3728 let mut rng = Lcg(0x5EED);
3729
3730 for case in 0..60 {
3731 let src = tmp.path().join(format!("s{case}.img"));
3732 let fast = tmp.path().join(format!("f{case}.img"));
3733 let slow = tmp.path().join(format!("r{case}.img"));
3734
3735 let size = 1 + (rng.next() % (24 * 1024 * 1024));
3736 let mut f = File::create(&src).expect("create");
3737 f.set_len(size).expect("set_len");
3738 let runs = rng.next() % 7;
3740 for _ in 0..runs {
3741 let at = rng.next() % size;
3742 let len = (1 + rng.next() % (2 * 1024 * 1024)).min(size - at);
3743 let byte = (1 + (rng.next() % 254)) as u8;
3744 f.seek(SeekFrom::Start(at)).expect("seek");
3745 f.write_all(&vec![byte; len as usize]).expect("write");
3746 }
3747 f.sync_all().expect("sync");
3748 drop(f);
3749
3750 sparse_copy(&src, &fast).expect("fast");
3751 reference_scan_copy(&src, &slow).expect("slow");
3752
3753 assert!(
3754 files_equal(&fast, &src),
3755 "case {case}: fast path diverged from source"
3756 );
3757 assert!(
3758 files_equal(&fast, &slow),
3759 "case {case}: fast path diverged from old algorithm"
3760 );
3761 #[cfg(unix)]
3762 assert!(
3763 blocks(&fast) <= blocks(&slow),
3764 "case {case}: fast path is less sparse than the old one"
3765 );
3766 }
3767 }
3768
3769 #[test]
3771 fn data_beyond_four_gib_round_trips() {
3772 let tmp = tempfile::tempdir().expect("tempdir");
3773 let src = tmp.path().join("big.img");
3774 let dst = tmp.path().join("big-copy.img");
3775 let far: u64 = 6 * 1024 * 1024 * 1024; let mut f = File::create(&src).expect("create");
3778 f.write_all(b"START").expect("start");
3779 f.seek(SeekFrom::Start(far)).expect("seek");
3780 f.write_all(b"BEYOND-4GIB").expect("far write");
3781 f.sync_all().expect("sync");
3782 drop(f);
3783
3784 sparse_copy(&src, &dst).expect("copy");
3785
3786 assert!(files_equal(&dst, &src), "6 GiB sparse file must round-trip");
3787 #[cfg(unix)]
3788 assert!(
3789 blocks(&dst) < 4096,
3790 "must stay sparse, got {}",
3791 blocks(&dst)
3792 );
3793 }
3794
3795 #[test]
3797 fn data_at_the_very_end_round_trips() {
3798 let tmp = tempfile::tempdir().expect("tempdir");
3799 let src = tmp.path().join("tail.img");
3800 let dst = tmp.path().join("tail-copy.img");
3801 let mut f = File::create(&src).expect("create");
3802 f.set_len(8 * 1024 * 1024).expect("len");
3803 f.seek(SeekFrom::Start(8 * 1024 * 1024 - 3)).expect("seek");
3804 f.write_all(b"EOF").expect("write");
3805 f.sync_all().expect("sync");
3806 drop(f);
3807
3808 sparse_copy(&src, &dst).expect("copy");
3809 assert!(files_equal(&dst, &src));
3810 }
3811}