1mod build;
24mod inject;
25
26pub use build::{ROOTFS_CAPACITY_GRANULARITY, RootfsSource, RootfsSpec};
27
28use std::collections::BTreeSet;
29use std::io::Read;
30use std::path::{Path, PathBuf};
31use std::sync::Arc;
32
33use anyhow::{Context, Result, bail};
34use arcbox_ext4::constants::file_mode;
35use arcbox_ext4::{FormatOptions, Formatter};
36use arcbox_snapshot::snapshot_cow::BlockTools;
37use tokio::process::Command;
38use uuid::Uuid;
39
40use crate::error::ComputerError;
41
42const DEFAULT_ROOTFS_SIZE: u64 = 512 * 1024 * 1024;
46
47const TEMPLATE_ROOTFS_SIZE: u64 = 512 * 1024 * 1024;
51
52pub const VM_AGENT_PATH: &str = "/sbin/vm-agent";
54
55#[derive(Debug, Clone)]
57pub struct RootfsPaths {
58 pub vm_agent: PathBuf,
60 pub cache_dir: PathBuf,
63 pub busybox: PathBuf,
66}
67
68pub struct RootfsBuilder {
70 paths: RootfsPaths,
71 #[cfg_attr(
73 not(target_os = "linux"),
74 allow(
75 dead_code,
76 reason = "the injection loop mount is Linux-only; other platforms never attach"
77 )
78 )]
79 block_tools: Arc<dyn BlockTools>,
80}
81
82fn build_lock() -> &'static tokio::sync::Mutex<()> {
88 static LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
89 LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
90}
91
92impl RootfsBuilder {
93 pub fn new(paths: RootfsPaths, block_tools: Arc<dyn BlockTools>) -> Self {
95 Self { paths, block_tools }
96 }
97
98 pub fn paths(&self) -> &RootfsPaths {
100 &self.paths
101 }
102}
103
104fn rootfs_err(error: anyhow::Error) -> ComputerError {
105 ComputerError::Rootfs(format!("{error:#}"))
106}
107
108pub fn has_ext4_magic(path: &Path) -> bool {
110 use std::io::{Seek, SeekFrom};
111 let Ok(mut file) = std::fs::File::open(path) else {
112 return false;
113 };
114 let mut magic = [0u8; 2];
115 file.seek(SeekFrom::Start(0x438)).is_ok()
116 && file.read_exact(&mut magic).is_ok()
117 && magic == [0x53, 0xEF]
118}
119
120const OCI_LAYOUT_MARKER: &str = "oci-layout";
122
123fn is_oci_layout(dir: &Path) -> bool {
129 dir.join(OCI_LAYOUT_MARKER).is_file()
130}
131
132impl RootfsBuilder {
133 pub async fn convert_layer_to_rootfs(
145 &self,
146 layer_path: &str,
147 pinned: &BTreeSet<PathBuf>,
148 ) -> crate::error::Result<String> {
149 self.convert_layer(layer_path, pinned)
150 .await
151 .map_err(rootfs_err)
152 }
153
154 async fn convert_layer(&self, layer_path: &str, pinned: &BTreeSet<PathBuf>) -> Result<String> {
155 if !Path::new(layer_path).exists() {
156 bail!("layer path not found: {layer_path}");
157 }
158
159 let layer_key = path_hash(layer_path);
166 let agent_key = self.vm_agent_key().await?;
167 let cache_dir = &self.paths.cache_dir;
168 let ext4_path = cache_dir
169 .join(format!("rootfs-{layer_key}-{agent_key}.ext4"))
170 .to_string_lossy()
171 .into_owned();
172
173 if Path::new(&ext4_path).exists() && has_ext4_magic(Path::new(&ext4_path)) {
175 tracing::info!(path = %ext4_path, "using cached rootfs");
176 return Ok(ext4_path);
177 }
178
179 tracing::info!(layer = %layer_path, ext4 = %ext4_path, "converting image layer to ext4");
180 self.write_and_publish(
181 RootfsSource::Directory(PathBuf::from(layer_path)),
182 Path::new(&ext4_path),
183 TEMPLATE_ROOTFS_SIZE,
184 )
185 .await?;
186
187 sweep_superseded(cache_dir, &layer_key, &agent_key, pinned).await;
188
189 tracing::info!(path = %ext4_path, "rootfs ready");
190 Ok(ext4_path)
191 }
192
193 async fn vm_agent_key(&self) -> Result<String> {
201 use std::hash::Hasher;
202 let bytes = tokio::fs::read(&self.paths.vm_agent)
203 .await
204 .with_context(|| format!("failed to read {}", self.paths.vm_agent.display()))?;
205 let mut hasher = std::collections::hash_map::DefaultHasher::new();
206 hasher.write(&bytes);
207 Ok(format!("{:016x}", hasher.finish()))
208 }
209}
210
211async fn sweep_superseded(
218 cache_dir: &Path,
219 layer_key: &str,
220 agent_key: &str,
221 pinned: &BTreeSet<PathBuf>,
222) {
223 let Ok(mut entries) = tokio::fs::read_dir(cache_dir).await else {
224 return;
225 };
226 let mut removed = 0usize;
227 while let Ok(Some(entry)) = entries.next_entry().await {
228 let name = entry.file_name().to_string_lossy().into_owned();
229 if !is_superseded_image(&name, layer_key, agent_key) {
230 continue;
231 }
232 let path = entry.path();
233 if pinned.contains(&path) {
234 tracing::debug!(path = %path.display(), "keeping superseded rootfs: pinned");
235 continue;
236 }
237 if tokio::fs::remove_file(&path).await.is_ok() {
238 removed += 1;
239 }
240 }
241 if removed > 0 {
242 tracing::info!(removed, layer = %layer_key, "removed superseded rootfs images");
243 }
244}
245
246fn is_superseded_image(name: &str, layer_key: &str, agent_key: &str) -> bool {
249 let Some(keys) = name
250 .strip_prefix("rootfs-")
251 .and_then(|rest| rest.strip_suffix(".ext4"))
252 else {
253 return false;
254 };
255 let Some((layer, agent)) = keys.split_once('-') else {
256 return keys == layer_key;
259 };
260 layer == layer_key && agent != agent_key
261}
262
263impl RootfsBuilder {
264 pub async fn sweep_stale_tmp(&self) {
270 let Ok(mut entries) = tokio::fs::read_dir(&self.paths.cache_dir).await else {
271 return;
272 };
273 let mut removed = 0usize;
274 while let Ok(Some(entry)) = entries.next_entry().await {
275 if entry.file_name().to_string_lossy().ends_with(".ext4.tmp")
276 && tokio::fs::remove_file(entry.path()).await.is_ok()
277 {
278 removed += 1;
279 }
280 }
281 if removed > 0 {
282 tracing::info!(removed, "swept stale rootfs build artifacts");
283 }
284 }
285
286 pub async fn ensure_default_rootfs(&self, path: &str) -> crate::error::Result<()> {
290 self.ensure_default(path).await.map_err(rootfs_err)
291 }
292
293 async fn ensure_default(&self, path: &str) -> Result<()> {
294 let image = Path::new(path);
295 if self.is_default_rootfs_fresh(image) {
296 return Ok(());
297 }
298
299 let _build = build_lock().lock().await;
303 if self.is_default_rootfs_fresh(image) {
304 return Ok(());
305 }
306
307 if !self.paths.vm_agent.exists() {
308 bail!(
309 "vm-agent not found at {}; it is staged by the host daemon next to arcbox-agent",
310 self.paths.vm_agent.display()
311 );
312 }
313
314 let applets = self.busybox_applets().await?;
315
316 let parent = image
317 .parent()
318 .with_context(|| format!("default rootfs path has no parent: {path}"))?;
319 tokio::fs::create_dir_all(parent)
320 .await
321 .context("failed to create default rootfs dir")?;
322
323 let tmp = parent.join(format!(".default-{}.ext4.tmp", Uuid::new_v4()));
324 let spec = DefaultRootfsSpec {
325 busybox: self.paths.busybox.clone(),
326 vm_agent: self.paths.vm_agent.clone(),
327 applets,
328 size: DEFAULT_ROOTFS_SIZE,
329 };
330
331 tracing::info!(path, "building default sandbox rootfs");
332 {
333 let tmp = tmp.clone();
334 tokio::task::spawn_blocking(move || build_default_rootfs(&spec, &tmp))
335 .await
336 .context("default rootfs build task panicked")??;
337 }
338
339 tokio::fs::rename(&tmp, image)
340 .await
341 .context("failed to move default rootfs into place")?;
342 tracing::info!(path, "default sandbox rootfs ready");
343 Ok(())
344 }
345
346 fn is_default_rootfs_fresh(&self, image: &Path) -> bool {
356 is_fresh_against(image, &[&self.paths.busybox, &self.paths.vm_agent])
357 }
358
359 async fn busybox_applets(&self) -> Result<Vec<String>> {
361 let output = Command::new(&self.paths.busybox)
362 .arg("--list")
363 .output()
364 .await
365 .with_context(|| format!("failed to run {} --list", self.paths.busybox.display()))?;
366 if !output.status.success() {
367 bail!("busybox --list failed with {}", output.status);
368 }
369 Ok(String::from_utf8_lossy(&output.stdout)
370 .lines()
371 .map(str::trim)
372 .filter(|l| !l.is_empty())
373 .map(str::to_owned)
374 .collect())
375 }
376}
377
378fn is_fresh_against(image: &Path, sources: &[&Path]) -> bool {
380 if !has_ext4_magic(image) {
381 return false;
382 }
383 let Ok(image_mtime) = image.metadata().and_then(|m| m.modified()) else {
384 return false;
385 };
386 for source in sources {
387 if let Ok(mtime) = std::fs::metadata(source).and_then(|m| m.modified())
389 && mtime > image_mtime
390 {
391 return false;
392 }
393 }
394 true
395}
396
397struct DefaultRootfsSpec {
399 busybox: std::path::PathBuf,
401 vm_agent: std::path::PathBuf,
403 applets: Vec<String>,
405 size: u64,
407}
408
409fn build_default_rootfs(spec: &DefaultRootfsSpec, out: &Path) -> Result<()> {
412 const DIR: u16 = file_mode::S_IFDIR | 0o755;
413 const EXE: u16 = file_mode::S_IFREG | 0o755;
414 const LNK: u16 = file_mode::S_IFLNK | 0o777;
415
416 let mut fmt = Formatter::with_options(out, FormatOptions::new(spec.size).label("arcbox-sbx"))
417 .context("failed to create ext4 formatter")?;
418
419 for dir in [
420 "/bin", "/sbin", "/dev", "/proc", "/sys", "/run", "/etc", "/root", "/var",
421 ] {
422 fmt.create(dir, DIR, None, None, None, None, None, None)
423 .with_context(|| format!("mkdir {dir}"))?;
424 }
425 fmt.create(
427 "/tmp",
428 file_mode::S_IFDIR | file_mode::S_ISVTX | 0o777,
429 None,
430 None,
431 None,
432 None,
433 None,
434 None,
435 )
436 .context("mkdir /tmp")?;
437
438 let mut busybox = std::fs::File::open(&spec.busybox)
439 .with_context(|| format!("failed to open {}", spec.busybox.display()))?;
440 fmt.create(
441 "/bin/busybox",
442 EXE,
443 None,
444 None,
445 Some(&mut busybox),
446 None,
447 None,
448 None,
449 )
450 .context("write /bin/busybox")?;
451
452 for applet in &spec.applets {
453 if applet == "busybox" || applet.contains('/') {
454 continue;
455 }
456 let path = format!("/bin/{applet}");
457 fmt.create(&path, LNK, Some("busybox"), None, None, None, None, None)
458 .with_context(|| format!("symlink {path}"))?;
459 }
460
461 let mut vm_agent = std::fs::File::open(&spec.vm_agent)
462 .with_context(|| format!("failed to open {}", spec.vm_agent.display()))?;
463 fmt.create(
464 VM_AGENT_PATH,
465 EXE,
466 None,
467 None,
468 Some(&mut vm_agent),
469 None,
470 None,
471 None,
472 )
473 .context("write /sbin/vm-agent")?;
474 fmt.create(
476 "/sbin/init",
477 LNK,
478 Some("vm-agent"),
479 None,
480 None,
481 None,
482 None,
483 None,
484 )
485 .context("symlink /sbin/init")?;
486
487 let passwd = "root:x:0:0:root:/root:/bin/sh\n";
488 fmt.create(
489 "/etc/passwd",
490 file_mode::S_IFREG | 0o644,
491 None,
492 None,
493 Some(&mut passwd.as_bytes()),
494 None,
495 None,
496 None,
497 )
498 .context("write /etc/passwd")?;
499 let group = "root:x:0:\n";
500 fmt.create(
501 "/etc/group",
502 file_mode::S_IFREG | 0o644,
503 None,
504 None,
505 Some(&mut group.as_bytes()),
506 None,
507 None,
508 None,
509 )
510 .context("write /etc/group")?;
511
512 fmt.create(
517 "/etc/resolv.conf",
518 LNK,
519 Some("../run/resolv.conf"),
520 None,
521 None,
522 None,
523 None,
524 None,
525 )
526 .context("symlink /etc/resolv.conf")?;
527
528 fmt.close().context("failed to finalize ext4 image")?;
529 Ok(())
530}
531
532fn path_hash(path: &str) -> String {
534 use std::hash::{Hash, Hasher};
535 let mut hasher = std::collections::hash_map::DefaultHasher::new();
536 path.hash(&mut hasher);
537 format!("{:016x}", hasher.finish())
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 pub(super) fn isolated_log_test(name: &str) -> bool {
545 const CHILD_TEST: &str = "ARCBOX_ROOTFS_LOG_TEST";
546 if std::env::var(CHILD_TEST).as_deref() == Ok(name) {
547 return true;
548 }
549 let output = std::process::Command::new(std::env::current_exe().unwrap())
552 .args(["--exact", name, "--nocapture"])
553 .env(CHILD_TEST, name)
554 .output()
555 .unwrap();
556 let stdout = String::from_utf8_lossy(&output.stdout);
557 assert!(
558 output.status.success() && stdout.contains("1 passed; 0 failed; 0 ignored"),
559 "isolated log test {name} failed:\n{stdout}\n{}",
560 String::from_utf8_lossy(&output.stderr)
561 );
562 false
563 }
564
565 #[test]
566 fn test_has_ext4_magic_nonexistent() {
567 assert!(!has_ext4_magic(Path::new("/nonexistent")));
568 }
569
570 #[test]
571 fn is_superseded_image_matches_only_the_same_layer_with_another_agent() {
572 assert!(is_superseded_image("rootfs-aaaa-0000.ext4", "aaaa", "1111"));
575 assert!(!is_superseded_image(
577 "rootfs-aaaa-1111.ext4",
578 "aaaa",
579 "1111"
580 ));
581 assert!(!is_superseded_image(
583 "rootfs-bbbb-0000.ext4",
584 "aaaa",
585 "1111"
586 ));
587 assert!(is_superseded_image("rootfs-aaaa.ext4", "aaaa", "1111"));
590 assert!(!is_superseded_image("rootfs-bbbb.ext4", "aaaa", "1111"));
591 assert!(!is_superseded_image(
594 ".rootfs-aaaa-1111.ext4.tmp",
595 "aaaa",
596 "1111"
597 ));
598 assert!(!is_superseded_image("default.ext4", "aaaa", "1111"));
599 assert!(!is_superseded_image(
600 "rootfs-aaaa-1111.ext4.bak",
601 "aaaa",
602 "1111"
603 ));
604 }
605
606 #[test]
607 fn is_oci_layout_detects_the_layout_marker() {
608 let dir = tempfile::TempDir::new().unwrap();
609
610 std::fs::create_dir(dir.path().join("diff")).unwrap();
612 assert!(!is_oci_layout(dir.path()));
613
614 std::fs::write(dir.path().join("manifest.json"), "[]").unwrap();
616 std::fs::write(
617 dir.path().join(OCI_LAYOUT_MARKER),
618 r#"{"imageLayoutVersion":"1.0.0"}"#,
619 )
620 .unwrap();
621 assert!(is_oci_layout(dir.path()));
622 }
623
624 #[tokio::test]
625 async fn sweep_superseded_keeps_images_a_snapshot_still_needs() {
626 let dir = tempfile::tempdir().unwrap();
627 let image = |name: &str| dir.path().join(name);
628 for name in [
629 "rootfs-aaaa-0000.ext4",
630 "rootfs-aaaa.ext4",
631 "rootfs-aaaa-1111.ext4",
632 "rootfs-bbbb-0000.ext4",
633 ] {
634 std::fs::write(image(name), b"x").unwrap();
635 }
636 let pinned = BTreeSet::from([image("rootfs-aaaa.ext4")]);
637
638 sweep_superseded(dir.path(), "aaaa", "1111", &pinned).await;
639
640 assert!(!image("rootfs-aaaa-0000.ext4").exists());
642 assert!(image("rootfs-aaaa.ext4").exists());
645 assert!(image("rootfs-aaaa-1111.ext4").exists());
647 assert!(image("rootfs-bbbb-0000.ext4").exists());
648 }
649
650 #[test]
651 fn test_path_hash_deterministic() {
652 let a = path_hash("/var/lib/docker/overlay2/abc123");
653 let b = path_hash("/var/lib/docker/overlay2/abc123");
654 assert_eq!(a, b);
655 assert_eq!(a.len(), 16);
656 }
657
658 #[test]
659 fn test_path_hash_different() {
660 let a = path_hash("/var/lib/docker/overlay2/abc123");
661 let b = path_hash("/var/lib/docker/overlay2/def456");
662 assert_ne!(a, b);
663 }
664
665 #[test]
666 fn default_rootfs_builds_and_contains_init_chain() {
667 let dir = tempfile::tempdir().unwrap();
668 let busybox = dir.path().join("busybox");
669 let vm_agent = dir.path().join("vm-agent");
670 std::fs::write(&busybox, b"#!busybox-stub").unwrap();
671 std::fs::write(&vm_agent, b"#!vm-agent-stub").unwrap();
672
673 let out = dir.path().join("rootfs.ext4");
674 let spec = DefaultRootfsSpec {
675 busybox,
676 vm_agent,
677 applets: vec!["sh".into(), "ls".into(), "busybox".into()],
678 size: 8 * 1024 * 1024,
679 };
680 build_default_rootfs(&spec, &out).unwrap();
681
682 assert!(has_ext4_magic(&out));
683
684 let reader = arcbox_ext4::Reader::new(&out).unwrap();
685 for path in [
686 "/bin/busybox",
687 "/sbin/vm-agent",
688 "/sbin/init",
689 "/bin/sh",
690 "/etc/passwd",
691 ] {
692 assert!(
693 reader.tree().lookup(Path::new(path)).is_some(),
694 "missing {path}"
695 );
696 }
697 assert!(
698 reader
699 .tree()
700 .lookup(Path::new("/bin/nonexistent"))
701 .is_none()
702 );
703 }
704
705 #[test]
706 fn existing_image_is_fresh_when_build_sources_are_absent() {
707 let dir = tempfile::tempdir().unwrap();
713 let image = dir.path().join("rootfs.ext4");
714 let spec = DefaultRootfsSpec {
715 busybox: dir.path().join("busybox"),
716 vm_agent: dir.path().join("vm-agent"),
717 applets: vec!["sh".into()],
718 size: 8 * 1024 * 1024,
719 };
720 std::fs::write(&spec.busybox, b"stub").unwrap();
721 std::fs::write(&spec.vm_agent, b"stub").unwrap();
722 build_default_rootfs(&spec, &image).unwrap();
723 assert!(has_ext4_magic(&image));
724
725 let absent = dir.path().join("absent");
728 assert!(
729 is_fresh_against(&image, &[&absent, &absent]),
730 "a valid image with absent build sources must be reused, not rebuilt"
731 );
732 assert!(!is_fresh_against(
733 &dir.path().join("missing.ext4"),
734 &[&absent]
735 ));
736 std::thread::sleep(std::time::Duration::from_millis(20));
738 let newer = dir.path().join("newer-agent");
739 std::fs::write(&newer, b"stub").unwrap();
740 assert!(!is_fresh_against(&image, &[&newer]));
741 }
742}