1use std::collections::BTreeSet;
22use std::io::Read;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use anyhow::{Context, Result, bail};
27use arcbox_ext4::constants::file_mode;
28use arcbox_ext4::{FormatOptions, Formatter};
29use arcbox_snapshot::snapshot_cow::BlockTools;
30use tokio::process::Command;
31use uuid::Uuid;
32
33use crate::error::VmmError;
34
35const DEFAULT_ROOTFS_SIZE: u64 = 512 * 1024 * 1024;
39
40#[derive(Debug, Clone)]
42pub struct RootfsPaths {
43 pub vm_agent: PathBuf,
45 pub cache_dir: PathBuf,
48 pub busybox: PathBuf,
51}
52
53pub struct RootfsBuilder {
55 paths: RootfsPaths,
56 #[cfg_attr(
58 not(target_os = "linux"),
59 allow(
60 dead_code,
61 reason = "the injection loop mount is Linux-only; other platforms never attach"
62 )
63 )]
64 block_tools: Arc<dyn BlockTools>,
65}
66
67fn build_lock() -> &'static tokio::sync::Mutex<()> {
73 static LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
74 LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
75}
76
77impl RootfsBuilder {
78 pub fn new(paths: RootfsPaths, block_tools: Arc<dyn BlockTools>) -> Self {
80 Self { paths, block_tools }
81 }
82
83 pub fn paths(&self) -> &RootfsPaths {
85 &self.paths
86 }
87}
88
89fn rootfs_err(error: anyhow::Error) -> VmmError {
90 VmmError::Rootfs(format!("{error:#}"))
91}
92
93pub fn has_ext4_magic(path: &Path) -> bool {
95 use std::io::{Seek, SeekFrom};
96 let Ok(mut file) = std::fs::File::open(path) else {
97 return false;
98 };
99 let mut magic = [0u8; 2];
100 file.seek(SeekFrom::Start(0x438)).is_ok()
101 && file.read_exact(&mut magic).is_ok()
102 && magic == [0x53, 0xEF]
103}
104
105const OCI_LAYOUT_MARKER: &str = "oci-layout";
107
108fn is_oci_layout(dir: &Path) -> bool {
114 dir.join(OCI_LAYOUT_MARKER).is_file()
115}
116
117impl RootfsBuilder {
118 pub async fn convert_layer_to_rootfs(
130 &self,
131 layer_path: &str,
132 pinned: &BTreeSet<PathBuf>,
133 ) -> crate::error::Result<String> {
134 self.convert_layer(layer_path, pinned)
135 .await
136 .map_err(rootfs_err)
137 }
138
139 async fn convert_layer(&self, layer_path: &str, pinned: &BTreeSet<PathBuf>) -> Result<String> {
140 if !Path::new(layer_path).exists() {
141 bail!("layer path not found: {layer_path}");
142 }
143
144 let layer_key = path_hash(layer_path);
151 let agent_key = self.vm_agent_key().await?;
152 let cache_dir = &self.paths.cache_dir;
153 let ext4_path = cache_dir
154 .join(format!("rootfs-{layer_key}-{agent_key}.ext4"))
155 .to_string_lossy()
156 .into_owned();
157
158 if Path::new(&ext4_path).exists() && has_ext4_magic(Path::new(&ext4_path)) {
160 tracing::info!(path = %ext4_path, "using cached rootfs");
161 return Ok(ext4_path);
162 }
163
164 tokio::fs::create_dir_all(cache_dir)
165 .await
166 .context("failed to create rootfs cache dir")?;
167
168 let req_id = Uuid::new_v4().to_string();
169 let ext4_tmp = cache_dir
170 .join(format!(".rootfs-{req_id}.ext4.tmp"))
171 .to_string_lossy()
172 .into_owned();
173
174 tracing::info!(layer = %layer_path, ext4 = %ext4_path, "converting image layer to ext4");
176 {
177 let layer = layer_path.to_owned();
178 let out = ext4_tmp.clone();
179 tokio::task::spawn_blocking(move || -> Result<()> {
180 let converter = oci2rootfs::Converter::new(&out);
181 if is_oci_layout(Path::new(&layer)) {
182 let source = oci2rootfs::OciLayoutSource::open(&layer)
183 .context("failed to open OCI image layout")?;
184 converter
185 .convert(source)
186 .context("OCI layout → ext4 conversion failed")?;
187 } else {
188 let source = oci2rootfs::Overlay2Source::open(&layer)
189 .context("failed to open overlay2 layer")?;
190 converter
191 .convert(source)
192 .context("overlay2 → ext4 conversion failed")?;
193 }
194 Ok(())
195 })
196 .await
197 .context("conversion task panicked")??;
198 }
199
200 tracing::info!("injecting vm-agent into rootfs");
202 if let Err(e) = self.inject_vm_agent(&ext4_tmp, &req_id).await {
203 let _ = tokio::fs::remove_file(&ext4_tmp).await;
204 return Err(e);
205 }
206
207 tokio::fs::rename(&ext4_tmp, &ext4_path)
209 .await
210 .context("failed to rename ext4 into cache")?;
211
212 sweep_superseded(cache_dir, &layer_key, &agent_key, pinned).await;
213
214 tracing::info!(path = %ext4_path, "rootfs ready");
215 Ok(ext4_path)
216 }
217
218 async fn vm_agent_key(&self) -> Result<String> {
226 use std::hash::Hasher;
227 let bytes = tokio::fs::read(&self.paths.vm_agent)
228 .await
229 .with_context(|| format!("failed to read {}", self.paths.vm_agent.display()))?;
230 let mut hasher = std::collections::hash_map::DefaultHasher::new();
231 hasher.write(&bytes);
232 Ok(format!("{:016x}", hasher.finish()))
233 }
234}
235
236async fn sweep_superseded(
252 cache_dir: &Path,
253 layer_key: &str,
254 agent_key: &str,
255 pinned: &BTreeSet<PathBuf>,
256) {
257 let Ok(mut entries) = tokio::fs::read_dir(cache_dir).await else {
258 return;
259 };
260 let mut removed = 0usize;
261 while let Ok(Some(entry)) = entries.next_entry().await {
262 let name = entry.file_name().to_string_lossy().into_owned();
263 if !is_superseded_image(&name, layer_key, agent_key) {
264 continue;
265 }
266 let path = entry.path();
267 if pinned.contains(&path) {
268 tracing::debug!(path = %path.display(), "keeping superseded rootfs: pinned by a snapshot");
269 continue;
270 }
271 if tokio::fs::remove_file(&path).await.is_ok() {
272 removed += 1;
273 }
274 }
275 if removed > 0 {
276 tracing::info!(removed, layer = %layer_key, "removed superseded rootfs images");
277 }
278}
279
280fn is_superseded_image(name: &str, layer_key: &str, agent_key: &str) -> bool {
283 let Some(keys) = name
284 .strip_prefix("rootfs-")
285 .and_then(|rest| rest.strip_suffix(".ext4"))
286 else {
287 return false;
288 };
289 let Some((layer, agent)) = keys.split_once('-') else {
290 return keys == layer_key;
293 };
294 layer == layer_key && agent != agent_key
295}
296
297impl RootfsBuilder {
298 pub async fn sweep_stale_tmp(&self) {
304 let Ok(mut entries) = tokio::fs::read_dir(&self.paths.cache_dir).await else {
305 return;
306 };
307 let mut removed = 0usize;
308 while let Ok(Some(entry)) = entries.next_entry().await {
309 if entry.file_name().to_string_lossy().ends_with(".ext4.tmp")
310 && tokio::fs::remove_file(entry.path()).await.is_ok()
311 {
312 removed += 1;
313 }
314 }
315 if removed > 0 {
316 tracing::info!(removed, "swept stale rootfs build artifacts");
317 }
318 }
319
320 pub async fn ensure_default_rootfs(&self, path: &str) -> crate::error::Result<()> {
324 self.ensure_default(path).await.map_err(rootfs_err)
325 }
326
327 async fn ensure_default(&self, path: &str) -> Result<()> {
328 let image = Path::new(path);
329 if self.is_default_rootfs_fresh(image) {
330 return Ok(());
331 }
332
333 let _build = build_lock().lock().await;
337 if self.is_default_rootfs_fresh(image) {
338 return Ok(());
339 }
340
341 if !self.paths.vm_agent.exists() {
342 bail!(
343 "vm-agent not found at {}; it is staged by the host daemon next to arcbox-agent",
344 self.paths.vm_agent.display()
345 );
346 }
347
348 let applets = self.busybox_applets().await?;
349
350 let parent = image
351 .parent()
352 .with_context(|| format!("default rootfs path has no parent: {path}"))?;
353 tokio::fs::create_dir_all(parent)
354 .await
355 .context("failed to create default rootfs dir")?;
356
357 let tmp = parent.join(format!(".default-{}.ext4.tmp", Uuid::new_v4()));
358 let spec = DefaultRootfsSpec {
359 busybox: self.paths.busybox.clone(),
360 vm_agent: self.paths.vm_agent.clone(),
361 applets,
362 size: DEFAULT_ROOTFS_SIZE,
363 };
364
365 tracing::info!(path, "building default sandbox rootfs");
366 {
367 let tmp = tmp.clone();
368 tokio::task::spawn_blocking(move || build_default_rootfs(&spec, &tmp))
369 .await
370 .context("default rootfs build task panicked")??;
371 }
372
373 tokio::fs::rename(&tmp, image)
374 .await
375 .context("failed to move default rootfs into place")?;
376 tracing::info!(path, "default sandbox rootfs ready");
377 Ok(())
378 }
379
380 fn is_default_rootfs_fresh(&self, image: &Path) -> bool {
390 is_fresh_against(image, &[&self.paths.busybox, &self.paths.vm_agent])
391 }
392
393 async fn busybox_applets(&self) -> Result<Vec<String>> {
395 let output = Command::new(&self.paths.busybox)
396 .arg("--list")
397 .output()
398 .await
399 .with_context(|| format!("failed to run {} --list", self.paths.busybox.display()))?;
400 if !output.status.success() {
401 bail!("busybox --list failed with {}", output.status);
402 }
403 Ok(String::from_utf8_lossy(&output.stdout)
404 .lines()
405 .map(str::trim)
406 .filter(|l| !l.is_empty())
407 .map(str::to_owned)
408 .collect())
409 }
410}
411
412fn is_fresh_against(image: &Path, sources: &[&Path]) -> bool {
414 if !has_ext4_magic(image) {
415 return false;
416 }
417 let Ok(image_mtime) = image.metadata().and_then(|m| m.modified()) else {
418 return false;
419 };
420 for source in sources {
421 if let Ok(mtime) = std::fs::metadata(source).and_then(|m| m.modified())
423 && mtime > image_mtime
424 {
425 return false;
426 }
427 }
428 true
429}
430
431struct DefaultRootfsSpec {
433 busybox: std::path::PathBuf,
435 vm_agent: std::path::PathBuf,
437 applets: Vec<String>,
439 size: u64,
441}
442
443fn build_default_rootfs(spec: &DefaultRootfsSpec, out: &Path) -> Result<()> {
446 const DIR: u16 = file_mode::S_IFDIR | 0o755;
447 const EXE: u16 = file_mode::S_IFREG | 0o755;
448 const LNK: u16 = file_mode::S_IFLNK | 0o777;
449
450 let mut fmt = Formatter::with_options(out, FormatOptions::new(spec.size).label("arcbox-sbx"))
451 .context("failed to create ext4 formatter")?;
452
453 for dir in [
454 "/bin", "/sbin", "/dev", "/proc", "/sys", "/run", "/etc", "/root", "/var",
455 ] {
456 fmt.create(dir, DIR, None, None, None, None, None, None)
457 .with_context(|| format!("mkdir {dir}"))?;
458 }
459 fmt.create(
461 "/tmp",
462 file_mode::S_IFDIR | file_mode::S_ISVTX | 0o777,
463 None,
464 None,
465 None,
466 None,
467 None,
468 None,
469 )
470 .context("mkdir /tmp")?;
471
472 let mut busybox = std::fs::File::open(&spec.busybox)
473 .with_context(|| format!("failed to open {}", spec.busybox.display()))?;
474 fmt.create(
475 "/bin/busybox",
476 EXE,
477 None,
478 None,
479 Some(&mut busybox),
480 None,
481 None,
482 None,
483 )
484 .context("write /bin/busybox")?;
485
486 for applet in &spec.applets {
487 if applet == "busybox" || applet.contains('/') {
488 continue;
489 }
490 let path = format!("/bin/{applet}");
491 fmt.create(&path, LNK, Some("busybox"), None, None, None, None, None)
492 .with_context(|| format!("symlink {path}"))?;
493 }
494
495 let mut vm_agent = std::fs::File::open(&spec.vm_agent)
496 .with_context(|| format!("failed to open {}", spec.vm_agent.display()))?;
497 fmt.create(
498 "/sbin/vm-agent",
499 EXE,
500 None,
501 None,
502 Some(&mut vm_agent),
503 None,
504 None,
505 None,
506 )
507 .context("write /sbin/vm-agent")?;
508 fmt.create(
510 "/sbin/init",
511 LNK,
512 Some("vm-agent"),
513 None,
514 None,
515 None,
516 None,
517 None,
518 )
519 .context("symlink /sbin/init")?;
520
521 let passwd = "root:x:0:0:root:/root:/bin/sh\n";
522 fmt.create(
523 "/etc/passwd",
524 file_mode::S_IFREG | 0o644,
525 None,
526 None,
527 Some(&mut passwd.as_bytes()),
528 None,
529 None,
530 None,
531 )
532 .context("write /etc/passwd")?;
533 let group = "root:x:0:\n";
534 fmt.create(
535 "/etc/group",
536 file_mode::S_IFREG | 0o644,
537 None,
538 None,
539 Some(&mut group.as_bytes()),
540 None,
541 None,
542 None,
543 )
544 .context("write /etc/group")?;
545
546 fmt.create(
551 "/etc/resolv.conf",
552 LNK,
553 Some("../run/resolv.conf"),
554 None,
555 None,
556 None,
557 None,
558 None,
559 )
560 .context("symlink /etc/resolv.conf")?;
561
562 fmt.close().context("failed to finalize ext4 image")?;
563 Ok(())
564}
565
566impl RootfsBuilder {
567 #[cfg(target_os = "linux")]
577 async fn inject_vm_agent(&self, ext4_path: &str, req_id: &str) -> Result<()> {
578 if !self.paths.vm_agent.exists() {
579 bail!("vm-agent not found at {}", self.paths.vm_agent.display());
580 }
581
582 let loop_dev = self
585 .attach_loop(ext4_path)
586 .await
587 .context("failed to attach ext4 image for vm-agent injection")?;
588 let mount_dir = std::env::temp_dir().join(format!("arcbox-inject-{req_id}"));
589 let staged = match tokio::fs::create_dir_all(&mount_dir).await {
590 Ok(()) => mount_ext4(&loop_dev, &mount_dir)
591 .await
592 .with_context(|| format!("mount {loop_dev} for vm-agent injection")),
593 Err(e) => Err(e).context("failed to create injection mount dir"),
594 };
595 if let Err(error) = staged {
596 let mut failures = Vec::new();
599 self.detach_loop(&loop_dev)
600 .await
601 .push_failure(&mut failures);
602 let _ = tokio::fs::remove_dir(&mount_dir).await;
603 return Err(match failures.pop() {
604 Some(detach) => error.context(format!("and then {detach}")),
605 None => error,
606 });
607 }
608
609 let injected = self.write_agent_into(&mount_dir).await;
610
611 let mut failures = Vec::new();
614 unmount(&mount_dir).await.push_failure(&mut failures);
615 self.detach_loop(&loop_dev)
616 .await
617 .push_failure(&mut failures);
618 let _ = tokio::fs::remove_dir(&mount_dir).await;
619
620 if !failures.is_empty() {
621 bail!(
622 "vm-agent injection left resources behind: {}",
623 failures.join("; ")
624 );
625 }
626 injected
627 }
628
629 #[cfg(target_os = "linux")]
632 async fn write_agent_into(&self, mount_dir: &Path) -> Result<()> {
633 let sbin = mount_dir.join("sbin");
634 let dest = sbin.join("vm-agent");
635 tokio::fs::create_dir_all(&sbin)
636 .await
637 .context("failed to create /sbin in rootfs")?;
638 tokio::fs::copy(&self.paths.vm_agent, &dest)
639 .await
640 .context("failed to copy vm-agent into rootfs")?;
641 tokio::fs::set_permissions(&dest, std::os::unix::fs::PermissionsExt::from_mode(0o755))
642 .await
643 .context("failed to chmod vm-agent in rootfs")?;
644
645 let etc = mount_dir.join("etc");
650 if tokio::fs::create_dir_all(&etc).await.is_ok() {
651 let resolv = etc.join("resolv.conf");
652 let _ = tokio::fs::remove_file(&resolv).await;
653 if let Err(e) = tokio::fs::symlink("../run/resolv.conf", &resolv).await {
654 tracing::warn!(error = %e, "resolv.conf symlink failed; DNS rewrites will hit the CoW device");
655 }
656 }
657 Ok(())
658 }
659
660 #[cfg(not(target_os = "linux"))]
663 async fn inject_vm_agent(&self, _ext4_path: &str, _req_id: &str) -> Result<()> {
664 bail!("vm-agent injection needs a Linux loop mount")
665 }
666
667 #[cfg(target_os = "linux")]
669 async fn attach_loop(&self, image: &str) -> Result<String> {
670 let tools = Arc::clone(&self.block_tools);
671 let image = PathBuf::from(image);
672 tokio::task::spawn_blocking(move || tools.attach_loop(&image, false))
673 .await
674 .context("loop attach task panicked")?
675 .map_err(anyhow::Error::from)
676 }
677
678 #[cfg(target_os = "linux")]
684 async fn detach_loop(&self, loop_dev: &str) -> Result<()> {
685 let tools = Arc::clone(&self.block_tools);
686 let dev = loop_dev.to_owned();
687 tokio::task::spawn_blocking(move || tools.detach_loop(&dev))
688 .await
689 .context("loop detach task panicked")?
690 .with_context(|| format!("detach {loop_dev}"))
691 }
692}
693
694#[cfg(target_os = "linux")]
697async fn mount_ext4(loop_dev: &str, dir: &Path) -> Result<()> {
698 use nix::mount::{MsFlags, mount};
699
700 let dev = loop_dev.to_owned();
701 let dir = dir.to_path_buf();
702 tokio::task::spawn_blocking(move || {
703 mount(
704 Some(dev.as_str()),
705 &dir,
706 Some("ext4"),
707 MsFlags::empty(),
708 None::<&str>,
709 )
710 })
711 .await
712 .context("mount task panicked")?
713 .map_err(anyhow::Error::from)
714}
715
716#[cfg(target_os = "linux")]
720async fn unmount(dir: &Path) -> Result<()> {
721 use nix::mount::{MntFlags, umount, umount2};
722
723 let target = dir.to_path_buf();
724 tokio::task::spawn_blocking(move || match umount(&target) {
725 Ok(()) => Ok(()),
726 Err(busy) => match umount2(&target, MntFlags::MNT_DETACH) {
730 Ok(()) => bail!(
731 "umount {}: {busy} (lazily detached instead)",
732 target.display()
733 ),
734 Err(lazy) => bail!("umount {}: {busy}; lazy unmount: {lazy}", target.display()),
735 },
736 })
737 .await
738 .context("umount task panicked")?
739}
740
741#[cfg(target_os = "linux")]
743trait PushFailure {
744 fn push_failure(self, failures: &mut Vec<String>);
745}
746
747#[cfg(target_os = "linux")]
748impl PushFailure for Result<()> {
749 fn push_failure(self, failures: &mut Vec<String>) {
750 if let Err(e) = self {
751 failures.push(format!("{e:#}"));
752 }
753 }
754}
755
756fn path_hash(path: &str) -> String {
758 use std::hash::{Hash, Hasher};
759 let mut hasher = std::collections::hash_map::DefaultHasher::new();
760 path.hash(&mut hasher);
761 format!("{:016x}", hasher.finish())
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767
768 #[test]
769 fn test_has_ext4_magic_nonexistent() {
770 assert!(!has_ext4_magic(Path::new("/nonexistent")));
771 }
772
773 #[test]
774 fn is_superseded_image_matches_only_the_same_layer_with_another_agent() {
775 assert!(is_superseded_image("rootfs-aaaa-0000.ext4", "aaaa", "1111"));
778 assert!(!is_superseded_image(
780 "rootfs-aaaa-1111.ext4",
781 "aaaa",
782 "1111"
783 ));
784 assert!(!is_superseded_image(
786 "rootfs-bbbb-0000.ext4",
787 "aaaa",
788 "1111"
789 ));
790 assert!(is_superseded_image("rootfs-aaaa.ext4", "aaaa", "1111"));
793 assert!(!is_superseded_image("rootfs-bbbb.ext4", "aaaa", "1111"));
794 assert!(!is_superseded_image(
797 ".rootfs-aaaa-1111.ext4.tmp",
798 "aaaa",
799 "1111"
800 ));
801 assert!(!is_superseded_image("default.ext4", "aaaa", "1111"));
802 assert!(!is_superseded_image(
803 "rootfs-aaaa-1111.ext4.bak",
804 "aaaa",
805 "1111"
806 ));
807 }
808
809 #[test]
810 fn is_oci_layout_detects_the_layout_marker() {
811 let dir = tempfile::TempDir::new().unwrap();
812
813 std::fs::create_dir(dir.path().join("diff")).unwrap();
815 assert!(!is_oci_layout(dir.path()));
816
817 std::fs::write(dir.path().join("manifest.json"), "[]").unwrap();
819 std::fs::write(
820 dir.path().join(OCI_LAYOUT_MARKER),
821 r#"{"imageLayoutVersion":"1.0.0"}"#,
822 )
823 .unwrap();
824 assert!(is_oci_layout(dir.path()));
825 }
826
827 #[tokio::test]
828 async fn sweep_superseded_keeps_images_a_snapshot_still_needs() {
829 let dir = tempfile::tempdir().unwrap();
830 let image = |name: &str| dir.path().join(name);
831 for name in [
832 "rootfs-aaaa-0000.ext4",
833 "rootfs-aaaa.ext4",
834 "rootfs-aaaa-1111.ext4",
835 "rootfs-bbbb-0000.ext4",
836 ] {
837 std::fs::write(image(name), b"x").unwrap();
838 }
839 let pinned = BTreeSet::from([image("rootfs-aaaa.ext4")]);
840
841 sweep_superseded(dir.path(), "aaaa", "1111", &pinned).await;
842
843 assert!(!image("rootfs-aaaa-0000.ext4").exists());
845 assert!(image("rootfs-aaaa.ext4").exists());
848 assert!(image("rootfs-aaaa-1111.ext4").exists());
850 assert!(image("rootfs-bbbb-0000.ext4").exists());
851 }
852
853 #[test]
854 fn test_path_hash_deterministic() {
855 let a = path_hash("/var/lib/docker/overlay2/abc123");
856 let b = path_hash("/var/lib/docker/overlay2/abc123");
857 assert_eq!(a, b);
858 assert_eq!(a.len(), 16);
859 }
860
861 #[test]
862 fn test_path_hash_different() {
863 let a = path_hash("/var/lib/docker/overlay2/abc123");
864 let b = path_hash("/var/lib/docker/overlay2/def456");
865 assert_ne!(a, b);
866 }
867
868 #[test]
869 fn default_rootfs_builds_and_contains_init_chain() {
870 let dir = tempfile::tempdir().unwrap();
871 let busybox = dir.path().join("busybox");
872 let vm_agent = dir.path().join("vm-agent");
873 std::fs::write(&busybox, b"#!busybox-stub").unwrap();
874 std::fs::write(&vm_agent, b"#!vm-agent-stub").unwrap();
875
876 let out = dir.path().join("rootfs.ext4");
877 let spec = DefaultRootfsSpec {
878 busybox,
879 vm_agent,
880 applets: vec!["sh".into(), "ls".into(), "busybox".into()],
881 size: 8 * 1024 * 1024,
882 };
883 build_default_rootfs(&spec, &out).unwrap();
884
885 assert!(has_ext4_magic(&out));
886
887 let reader = arcbox_ext4::Reader::new(&out).unwrap();
888 for path in [
889 "/bin/busybox",
890 "/sbin/vm-agent",
891 "/sbin/init",
892 "/bin/sh",
893 "/etc/passwd",
894 ] {
895 assert!(
896 reader.tree().lookup(Path::new(path)).is_some(),
897 "missing {path}"
898 );
899 }
900 assert!(
901 reader
902 .tree()
903 .lookup(Path::new("/bin/nonexistent"))
904 .is_none()
905 );
906 }
907
908 #[test]
909 fn existing_image_is_fresh_when_build_sources_are_absent() {
910 let dir = tempfile::tempdir().unwrap();
916 let image = dir.path().join("rootfs.ext4");
917 let spec = DefaultRootfsSpec {
918 busybox: dir.path().join("busybox"),
919 vm_agent: dir.path().join("vm-agent"),
920 applets: vec!["sh".into()],
921 size: 8 * 1024 * 1024,
922 };
923 std::fs::write(&spec.busybox, b"stub").unwrap();
924 std::fs::write(&spec.vm_agent, b"stub").unwrap();
925 build_default_rootfs(&spec, &image).unwrap();
926 assert!(has_ext4_magic(&image));
927
928 let absent = dir.path().join("absent");
931 assert!(
932 is_fresh_against(&image, &[&absent, &absent]),
933 "a valid image with absent build sources must be reused, not rebuilt"
934 );
935 assert!(!is_fresh_against(
936 &dir.path().join("missing.ext4"),
937 &[&absent]
938 ));
939 std::thread::sleep(std::time::Duration::from_millis(20));
941 let newer = dir.path().join("newer-agent");
942 std::fs::write(&newer, b"stub").unwrap();
943 assert!(!is_fresh_against(&image, &[&newer]));
944 }
945}