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` (and `/sbin/init` pointing at it), `/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
21use 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
35/// Capacity of the default busybox rootfs image. The image file is written
36/// sparsely; per-sandbox writes land in the dm-snapshot COW overlay, so this
37/// bounds a sandbox's writable space, not host disk use.
38const DEFAULT_ROOTFS_SIZE: u64 = 512 * 1024 * 1024;
39
40/// Where the rootfs builder finds its inputs and keeps its outputs.
41#[derive(Debug, Clone)]
42pub struct RootfsPaths {
43    /// The `vm-agent` binary injected into every image at `/sbin/vm-agent`.
44    pub vm_agent: PathBuf,
45    /// Directory the generated ext4 images (and their `.ext4.tmp` build
46    /// files) live in.
47    pub cache_dir: PathBuf,
48    /// Static busybox that becomes the default rootfs's userland
49    /// (`/bin/busybox` plus one applet symlink per `busybox --list` entry).
50    pub busybox: PathBuf,
51}
52
53/// Builds sandbox rootfs images; see the module docs.
54pub struct RootfsBuilder {
55    paths: RootfsPaths,
56    /// Loop-device attach/detach for the `vm-agent` injection mount.
57    #[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
67/// Serializes default-rootfs builds so concurrent creates don't each rebuild
68/// the 512 MiB image. Process-wide, not per builder: two builders over the
69/// same paths (the service's and a startup sweep's) must not race each other
70/// either. The atomic rename already prevents corruption; this only avoids
71/// the redundant work.
72fn 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    /// A builder over `paths`, mounting through `block_tools`.
79    pub fn new(paths: RootfsPaths, block_tools: Arc<dyn BlockTools>) -> Self {
80        Self { paths, block_tools }
81    }
82
83    /// The paths this builder was composed with.
84    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
93/// Check if a file has a valid ext4 superblock magic (0x53EF at offset 0x438).
94pub 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
105/// Marker file that identifies an OCI image layout directory.
106const OCI_LAYOUT_MARKER: &str = "oci-layout";
107
108/// True when `dir` is an OCI image layout rather than an overlay2 layer.
109///
110/// Dispatching on the marker keeps the choice explicit: a `docker save`
111/// layout also carries a legacy `manifest.json`, so leaning on the
112/// `oci2rootfs` autodetect heuristics would be guesswork.
113fn is_oci_layout(dir: &Path) -> bool {
114    dir.join(OCI_LAYOUT_MARKER).is_file()
115}
116
117impl RootfsBuilder {
118    /// Convert an image source directory to a bootable ext4 rootfs.
119    ///
120    /// `layer_path` is a directory: either an OCI image layout staged by the
121    /// host CLI (the `--from-image` / `--from-dockerfile` path) or a Docker
122    /// overlay2 chain-id directory (e.g. `/var/lib/docker/overlay2/<chain-id>`).
123    ///
124    /// `pinned` are images that must survive the superseded-image sweep
125    /// because a snapshot still needs them as its dm-snapshot origin
126    /// (`SandboxManager::pinned_rootfs_paths`).
127    ///
128    /// Returns the path to the generated (or cached) ext4 image.
129    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        // The cached image has two ingredients, so the key names both: the layer
145        // (whose directory name carries the image digest for either source kind,
146        // making the path a stable identifier) and the `vm-agent` binary injected
147        // below. Keying on the layer alone meant a newer agent never reached an
148        // already-converted image — the sandbox kept booting the old init with no
149        // sign anything was stale.
150        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        // Check cache.
159        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        // Convert via the oci2rootfs library (blocking CPU/IO work).
175        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        // Inject vm-agent.
201        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        // Atomic rename into cache.
208        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    /// Content key for the `vm-agent` binary that gets injected into every
219    /// image.
220    ///
221    /// Content rather than mtime: this binary is copied into place by
222    /// installers and by hand during development, so an older build can
223    /// easily carry a newer timestamp — which an mtime check would read as
224    /// fresh and then bake in.
225    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
236/// Delete images for `layer_key` built against a different `vm-agent`.
237///
238/// Bounds the cache at one image per layer instead of accumulating one per
239/// agent version.
240///
241/// Two kinds of reference survive this:
242///
243/// - a live sandbox's open image, which unlinks fine on Linux and frees its
244///   space when the last reference closes;
245/// - anything in `pinned`, i.e. an image a snapshot records as its
246///   dm-snapshot origin. That reference is durable (it lives in the snapshot
247///   catalog's `meta.json`, so it outlasts reboots) and unrepairable:
248///   re-converting the layer with a different `vm-agent` yields different bytes
249///   than the snapshot's guest memory was captured against, so a regenerated
250///   image is worse than none.
251async 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
280/// True when `name` is a cached image for `layer_key` built against some other
281/// `vm-agent` than `agent_key`.
282fn 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        // Pre-`agent_key` naming (`rootfs-<layer>.ext4`): its injected agent is
291        // unknown, so treat it as superseded rather than keep it forever.
292        return keys == layer_key;
293    };
294    layer == layer_key && agent != agent_key
295}
296
297impl RootfsBuilder {
298    /// Remove leftover `*.ext4.tmp` build artifacts from the rootfs cache dir.
299    ///
300    /// A rootfs build that crashed or panicked leaves a `.default-<uuid>.ext4.tmp`
301    /// or `.rootfs-<id>.ext4.tmp` (each up to the image size) with no owner;
302    /// sweep them at startup so repeated failures don't accrue disk usage.
303    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    /// Ensure the default busybox + vm-agent rootfs exists at `path` and is
321    /// newer than its source binaries. Returns without touching the image
322    /// when it is already up to date.
323    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        // Single-flight: a concurrent create for the same default image waits here,
334        // then the re-check lets it reuse the just-built image instead of
335        // redundantly rebuilding 512 MiB.
336        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    /// True when the default rootfs image can be used as-is (no rebuild
381    /// needed).
382    ///
383    /// A valid ext4 image is fresh unless a build source (busybox or
384    /// vm-agent) is **present and newer** than it. A *missing* source is not
385    /// staleness: we cannot rebuild from an absent binary, so an existing
386    /// valid image — e.g. a caller-supplied default rootfs, or the production
387    /// image on a host without the dev build sources — is kept rather than
388    /// clobbered.
389    fn is_default_rootfs_fresh(&self, image: &Path) -> bool {
390        is_fresh_against(image, &[&self.paths.busybox, &self.paths.vm_agent])
391    }
392
393    /// List busybox applets via `busybox --list`.
394    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
412/// [`RootfsBuilder::is_default_rootfs_fresh`] over explicit sources.
413fn 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        // Only a source that exists AND is newer forces a rebuild.
422        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
431/// Inputs for [`build_default_rootfs`].
432struct DefaultRootfsSpec {
433    /// Static busybox binary copied to `/bin/busybox`.
434    busybox: std::path::PathBuf,
435    /// vm-agent binary copied to `/sbin/vm-agent` (the sandbox init).
436    vm_agent: std::path::PathBuf,
437    /// Applet names to symlink into `/bin` (from `busybox --list`).
438    applets: Vec<String>,
439    /// ext4 image capacity in bytes.
440    size: u64,
441}
442
443/// Write a minimal bootable rootfs: busybox + applet symlinks + vm-agent as
444/// `/sbin/vm-agent` (and `/sbin/init`), plus the standard directory skeleton.
445fn 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    // World-writable sticky /tmp.
460    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    // Fallback for boot args that omit init=: PID 1 is still vm-agent.
509    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    // resolv.conf lives on tmpfs (/run, mounted by vm-agent): DNS writes on
547    // the post-restore reconfig path must not touch the block device — the
548    // clone's first ext4 write pays a synchronous dm-snapshot CoW exception
549    // through the nested I/O stack, measured at ~30 ms (CORE-75).
550    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    /// Inject vm-agent into an ext4 image through a loop mount.
568    ///
569    /// The image is attached as a loop device through the composer's
570    /// [`BlockTools`] and mounted with `mount(2)`; nothing here shells out.
571    /// Success means the agent is in the image *and* every resource is
572    /// released again: an image whose mount or loop device could not be
573    /// torn down is not published, because the caller's rename would cache
574    /// a file another mount holds and a leaked `/dev/loopN` per build would
575    /// eventually exhaust the pool.
576    #[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        // Attach first: a failed attach (no free loop device, tooling
583        // missing) then leaves nothing behind.
584        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            // Nothing is mounted yet: give the loop device back and report
597            // the original failure (plus the detach's, should it also fail).
598            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        // Release everything, collecting rather than short-circuiting so a
612        // failed step never skips the ones after it.
613        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    /// Copy the agent to `/sbin/vm-agent` (mode 755) and point
630    /// `/etc/resolv.conf` at the `/run` tmpfs inside the mounted image.
631    #[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        // Point resolv.conf at tmpfs, mirroring the default template: DNS
646        // rewrites must stay off the CoW block device (CORE-75). Rewriting
647        // is safe for Docker images — Docker itself never ships meaningful
648        // resolv.conf content (it bind-mounts one at run time).
649        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    /// Loop mounts are a Linux operation; the builder compiles elsewhere but
661    /// cannot inject.
662    #[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    /// [`BlockTools::attach_loop`] on a blocking thread.
668    #[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    /// [`BlockTools::detach_loop`] on a blocking thread.
679    ///
680    /// The kernel accepts `LOOP_CLR_FD` on a device that still has users
681    /// (it flags the device autoclear and frees it when the last one goes),
682    /// so this succeeds after a lazy unmount as well as a clean one.
683    #[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/// `mount(2)` an ext4 image's loop device at `dir`, off the executor —
695/// mounting can replay the journal.
696#[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/// `umount(2)` `dir`, falling back to a lazy unmount (`MNT_DETACH`) when
717/// something still holds the tree, so the following loop detach can at
718/// least schedule the device's release instead of stranding it.
719#[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        // Still busy: detach the tree lazily so the loop device can be
727        // released once the last user goes, and report the unmount as
728        // failed either way — the image is not safe to publish.
729        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/// Collect a step's failure without stopping the cleanup sequence.
742#[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
756/// Derive a stable cache key from the layer path.
757fn 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        // Same layer, older agent build: must go, or the sandbox keeps booting
776        // the stale init.
777        assert!(is_superseded_image("rootfs-aaaa-0000.ext4", "aaaa", "1111"));
778        // The image we just published.
779        assert!(!is_superseded_image(
780            "rootfs-aaaa-1111.ext4",
781            "aaaa",
782            "1111"
783        ));
784        // A different layer is a different image, not a superseded one.
785        assert!(!is_superseded_image(
786            "rootfs-bbbb-0000.ext4",
787            "aaaa",
788            "1111"
789        ));
790        // Legacy name from before the agent key existed: same layer, unknown
791        // agent, so treat as superseded; other layers stay.
792        assert!(is_superseded_image("rootfs-aaaa.ext4", "aaaa", "1111"));
793        assert!(!is_superseded_image("rootfs-bbbb.ext4", "aaaa", "1111"));
794        // Anything that is not a published image is left alone, including the
795        // in-progress temp files sweep_stale_tmp owns.
796        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        // An overlay2 chain-id directory has no marker.
814        std::fs::create_dir(dir.path().join("diff")).unwrap();
815        assert!(!is_oci_layout(dir.path()));
816
817        // A `docker save` export does, alongside a legacy manifest.json.
818        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        // Superseded and unreferenced: gone.
844        assert!(!image("rootfs-aaaa-0000.ext4").exists());
845        // Superseded but a snapshot records it as its dm-snapshot origin, and a
846        // re-conversion would not reproduce those bytes.
847        assert!(image("rootfs-aaaa.ext4").exists());
848        // Current image and other layers are untouched.
849        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        // A valid ext4 default rootfs must be reused as-is when the dev build
911        // sources (/bin/busybox, /arcbox/bin/vm-agent) don't exist — the case
912        // of a caller-supplied default rootfs on a host without the build
913        // toolchain. Regression for the sandbox_service_manager integration
914        // test, which passes an empty request rootfs backed by a real image.
915        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        // Sources that do not exist exercise the missing-source branch
929        // directly.
930        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        // A source that exists and is newer than the image forces a rebuild.
940        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}