Skip to main content

arcbox_computer_runtime/
rootfs.rs

1//! Build bootable ext4 rootfs images for sandboxes.
2//!
3//! Two builders live here, both backed by the pure-Rust `oci2rootfs` /
4//! `arcbox-ext4` stack (no external binary, no mount, no root required for
5//! the ext4 write itself):
6//!
7//! - [`RootfsBuilder::convert_layer_to_rootfs`] — convert a staged OCI image
8//!   layout (or a Docker overlay2 layer directory) to ext4, then inject
9//!   `/sbin/vm-agent` through a loop mount. Cached ext4 images are reused to
10//!   avoid redundant conversions.
11//! - [`RootfsBuilder::ensure_default_rootfs`] — build the default busybox +
12//!   vm-agent image used when a create supplies no rootfs. Rebuilt when the
13//!   source binaries are newer than the cached image.
14//!
15//! The rootfs convention the boot protocol relies on — the agent binary at
16//! `/sbin/vm-agent`, `/etc/resolv.conf`
17//! symlinked into the `/run` tmpfs — is enforced only by the guest failing to
18//! boot, so it is implemented once, here, with the agent binary source and
19//! the output location supplied by the composer through [`RootfsPaths`].
20//! Converted images retain their own `/sbin/init`; select the agent with
21//! `init=/sbin/vm-agent`. The default busybox image also links `/sbin/init`.
22
23mod 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
42/// Capacity of the default busybox rootfs image. The image file is written
43/// sparsely; per-sandbox writes land in the dm-snapshot COW overlay, so this
44/// bounds a sandbox's writable space, not host disk use.
45const DEFAULT_ROOTFS_SIZE: u64 = 512 * 1024 * 1024;
46
47/// Initial template capacity; the formatter can grow the image to fit its source.
48/// Keep capacity out of the cache key because
49/// template catalogs use the existing stem as their image digest input.
50const TEMPLATE_ROOTFS_SIZE: u64 = 512 * 1024 * 1024;
51
52/// Agent path in every built image. Use this path in the kernel's `init=` argument.
53pub const VM_AGENT_PATH: &str = "/sbin/vm-agent";
54
55/// Where the rootfs builder finds its inputs and keeps its outputs.
56#[derive(Debug, Clone)]
57pub struct RootfsPaths {
58    /// The `vm-agent` binary injected into every image at `/sbin/vm-agent`.
59    pub vm_agent: PathBuf,
60    /// Directory the generated ext4 images (and their `.ext4.tmp` build
61    /// files) live in.
62    pub cache_dir: PathBuf,
63    /// Static busybox that becomes the default rootfs's userland
64    /// (`/bin/busybox` plus one applet symlink per `busybox --list` entry).
65    pub busybox: PathBuf,
66}
67
68/// Builds sandbox rootfs images; see the module docs.
69pub struct RootfsBuilder {
70    paths: RootfsPaths,
71    /// Loop-device attach/detach for the `vm-agent` injection mount.
72    #[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
82/// Serializes default-rootfs builds so concurrent creates don't each rebuild
83/// the 512 MiB image. Process-wide, not per builder: two builders over the
84/// same paths (the service's and a startup sweep's) must not race each other
85/// either. The atomic rename already prevents corruption; this only avoids
86/// the redundant work.
87fn 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    /// A builder over `paths`, mounting through `block_tools`.
94    pub fn new(paths: RootfsPaths, block_tools: Arc<dyn BlockTools>) -> Self {
95        Self { paths, block_tools }
96    }
97
98    /// The paths this builder was composed with.
99    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
108/// Check if a file has a valid ext4 superblock magic (0x53EF at offset 0x438).
109pub 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
120/// Marker file that identifies an OCI image layout directory.
121const OCI_LAYOUT_MARKER: &str = "oci-layout";
122
123/// True when `dir` is an OCI image layout rather than an overlay2 layer.
124///
125/// Dispatching on the marker keeps the choice explicit: a `docker save`
126/// layout also carries a legacy `manifest.json`, so leaning on the
127/// `oci2rootfs` autodetect heuristics would be guesswork.
128fn is_oci_layout(dir: &Path) -> bool {
129    dir.join(OCI_LAYOUT_MARKER).is_file()
130}
131
132impl RootfsBuilder {
133    /// Convert an image source directory to a bootable ext4 rootfs.
134    ///
135    /// `layer_path` is a directory: either an OCI image layout staged by the
136    /// host CLI (the `--from-image` / `--from-dockerfile` path) or a Docker
137    /// overlay2 chain-id directory (e.g. `/var/lib/docker/overlay2/<chain-id>`).
138    ///
139    /// `pinned` are images that must survive the superseded-image sweep
140    /// because a durable record, snapshot, or template still needs them
141    /// (`ComputerManager::pinned_rootfs_paths`).
142    ///
143    /// Returns the path to the generated (or cached) ext4 image.
144    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        // The cached image has two ingredients, so the key names both: the layer
160        // (whose directory name carries the image digest for either source kind,
161        // making the path a stable identifier) and the `vm-agent` binary injected
162        // below. Keying on the layer alone meant a newer agent never reached an
163        // already-converted image — the sandbox kept booting the old init with no
164        // sign anything was stale.
165        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        // Check cache.
174        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    /// Content key for the `vm-agent` binary that gets injected into every
194    /// image.
195    ///
196    /// Content rather than mtime: this binary is copied into place by
197    /// installers and by hand during development, so an older build can
198    /// easily carry a newer timestamp — which an mtime check would read as
199    /// fresh and then bake in.
200    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
211/// Delete images for `layer_key` built against a different `vm-agent`.
212///
213/// Retain images pinned by snapshots, templates, or durable sandbox records.
214/// These references require the original path and bytes across restarts.
215/// Rebuilding with a different agent cannot reproduce checkpoint provenance.
216/// An unpinned open image survives unlink until its last file reference closes.
217async 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
246/// True when `name` is a cached image for `layer_key` built against some other
247/// `vm-agent` than `agent_key`.
248fn 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        // Pre-`agent_key` naming (`rootfs-<layer>.ext4`): its injected agent is
257        // unknown, so treat it as superseded rather than keep it forever.
258        return keys == layer_key;
259    };
260    layer == layer_key && agent != agent_key
261}
262
263impl RootfsBuilder {
264    /// Remove leftover `*.ext4.tmp` build artifacts from the rootfs cache dir.
265    ///
266    /// A rootfs build that crashed or panicked leaves a `.default-<uuid>.ext4.tmp`
267    /// or `.rootfs-<id>.ext4.tmp` (each up to the image size) with no owner;
268    /// sweep them at startup so repeated failures don't accrue disk usage.
269    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    /// Ensure the default busybox + vm-agent rootfs exists at `path` and is
287    /// newer than its source binaries. Returns without touching the image
288    /// when it is already up to date.
289    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        // Single-flight: a concurrent create for the same default image waits here,
300        // then the re-check lets it reuse the just-built image instead of
301        // redundantly rebuilding 512 MiB.
302        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    /// True when the default rootfs image can be used as-is (no rebuild
347    /// needed).
348    ///
349    /// A valid ext4 image is fresh unless a build source (busybox or
350    /// vm-agent) is **present and newer** than it. A *missing* source is not
351    /// staleness: we cannot rebuild from an absent binary, so an existing
352    /// valid image — e.g. a caller-supplied default rootfs, or the production
353    /// image on a host without the dev build sources — is kept rather than
354    /// clobbered.
355    fn is_default_rootfs_fresh(&self, image: &Path) -> bool {
356        is_fresh_against(image, &[&self.paths.busybox, &self.paths.vm_agent])
357    }
358
359    /// List busybox applets via `busybox --list`.
360    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
378/// [`RootfsBuilder::is_default_rootfs_fresh`] over explicit sources.
379fn 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        // Only a source that exists AND is newer forces a rebuild.
388        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
397/// Inputs for [`build_default_rootfs`].
398struct DefaultRootfsSpec {
399    /// Static busybox binary copied to `/bin/busybox`.
400    busybox: std::path::PathBuf,
401    /// vm-agent binary copied to `/sbin/vm-agent` (the sandbox init).
402    vm_agent: std::path::PathBuf,
403    /// Applet names to symlink into `/bin` (from `busybox --list`).
404    applets: Vec<String>,
405    /// ext4 image capacity in bytes.
406    size: u64,
407}
408
409/// Write a minimal bootable rootfs: busybox + applet symlinks + vm-agent as
410/// `/sbin/vm-agent` (and `/sbin/init`), plus the standard directory skeleton.
411fn 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    // World-writable sticky /tmp.
426    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    // Fallback for boot args that omit init=: PID 1 is still vm-agent.
475    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    // resolv.conf lives on tmpfs (/run, mounted by vm-agent): DNS writes on
513    // the post-restore reconfig path must not touch the block device — the
514    // clone's first ext4 write pays a synchronous dm-snapshot CoW exception
515    // through the nested I/O stack, measured at ~30 ms (CORE-75).
516    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
532/// Derive a stable cache key from the layer path.
533fn 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        // Callsite interest is process-global; another test can register the
550        // cleanup event before the scoped subscriber observes that event.
551        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        // Same layer, older agent build: must go, or the sandbox keeps booting
573        // the stale init.
574        assert!(is_superseded_image("rootfs-aaaa-0000.ext4", "aaaa", "1111"));
575        // The image we just published.
576        assert!(!is_superseded_image(
577            "rootfs-aaaa-1111.ext4",
578            "aaaa",
579            "1111"
580        ));
581        // A different layer is a different image, not a superseded one.
582        assert!(!is_superseded_image(
583            "rootfs-bbbb-0000.ext4",
584            "aaaa",
585            "1111"
586        ));
587        // Legacy name from before the agent key existed: same layer, unknown
588        // agent, so treat as superseded; other layers stay.
589        assert!(is_superseded_image("rootfs-aaaa.ext4", "aaaa", "1111"));
590        assert!(!is_superseded_image("rootfs-bbbb.ext4", "aaaa", "1111"));
591        // Anything that is not a published image is left alone, including the
592        // in-progress temp files sweep_stale_tmp owns.
593        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        // An overlay2 chain-id directory has no marker.
611        std::fs::create_dir(dir.path().join("diff")).unwrap();
612        assert!(!is_oci_layout(dir.path()));
613
614        // A `docker save` export does, alongside a legacy manifest.json.
615        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        // Superseded and unreferenced: gone.
641        assert!(!image("rootfs-aaaa-0000.ext4").exists());
642        // Superseded but a snapshot records it as its dm-snapshot origin, and a
643        // re-conversion would not reproduce those bytes.
644        assert!(image("rootfs-aaaa.ext4").exists());
645        // Current image and other layers are untouched.
646        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        // A valid ext4 default rootfs must be reused as-is when the dev build
708        // sources (/bin/busybox, /arcbox/bin/vm-agent) don't exist — the case
709        // of a caller-supplied default rootfs on a host without the build
710        // toolchain. Regression for the sandbox_service_manager integration
711        // test, which passes an empty request rootfs backed by a real image.
712        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        // Sources that do not exist exercise the missing-source branch
726        // directly.
727        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        // A source that exists and is newer than the image forces a rebuild.
737        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}