From f931705971b1d68e3acf008bd6065eed3617ff45 Mon Sep 17 00:00:00 2001 From: marvin Date: Thu, 10 Sep 2026 00:18:38 +0200 Subject: [PATCH] Add --mount flag for 9p host directory sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for sharing host directories into the VM via QEMU 9p (virtio-9p). The --mount flag can be repeated; each host directory appears at the same absolute path inside the VM. Design: - QEMU: each --mount gets a short 9p tag (slim0, slim1, …) via -virtfs local,path=…,mount_tag=slimN,security_model=mapped-xattr. Tags are kept short because 9p mount_tag has a ~31-byte limit. - Kernel cmdline: the full destination path is passed as slim.mount=: so the init script knows where to mount each tag. Base64 avoids issues with spaces/special chars. - slim-init.sh: after networking, parse slim.mount= entries, mkdir -p the destination, and mount -t 9p -o trans=virtio,version=9p2000.L Tests verify: 9p share detection via sysfs mount_tag, 9p entry in mount output, file content accessible at the expected path, and clean VM exit. --- example/test.sh | 51 ++++++++++++++++++++++++++++++++++++++++ src/qemu.rs | 30 ++++++++++++++++++++++- src/scripts/slim-init.sh | 24 +++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/example/test.sh b/example/test.sh index e4aaade..80bda65 100755 --- a/example/test.sh +++ b/example/test.sh @@ -103,6 +103,55 @@ EOF rm -rf "$work" } +test_mount() { + echo "=== Testing --mount (9p shares) ===" + + work="$(mktemp -d)" + img="slim-test-mount" + share_dir="$work/share" + + mkdir -p "$share_dir" + echo "hello from host" > "$share_dir/testfile.txt" + + cat > "$work/Containerfile" <<'EOF' +FROM alpine:latest +CMD ["/bin/sh", "-c", "poweroff -f"] +EOF + + echo "-- Building container image..." + if ! podman build --network=none -t "$img" -f "$work/Containerfile" >/dev/null 2>&1; then + report fail "--mount (podman build failed)" + rm -rf "$work" + return + fi + + echo "-- Building slim VM..." + if ! "$SLIM_BIN" build qcow2 "$img" >/dev/null 2>&1; then + report fail "--mount (slim build failed)" + cleanup "$img" "$img" + rm -rf "$work" + return + fi + + # The 9p share appears at the same path inside the VM as on the host. + # Use --cmd to run the verification snippet with the canonical path. + share_canon=$(readlink -f "$share_dir") + + echo "-- Booting VM with --mount $share_dir ..." + output=$(timeout "$TIMEOUT" "$SLIM_BIN" run "$img" \ + --mount "$share_dir" \ + --cmd "echo Available 9p shares:; for f in /sys/bus/virtio/drivers/9pnet_virtio/virtio*/mount_tag; do [ -f \"\$f\" ] && echo \"\$f: \$(tr -d '\\0' < \"\$f\")\"; done; echo Mounts:; mount -v; cat \"${share_canon}/testfile.txt\" 2>/dev/null || echo NO_FILE; echo MOUNT_VERIFY_DONE; poweroff -f" \ + 2>&1 || true) + + check_output "$output" "mount_tag: slim0" "--mount 9p share detected" + check_output "$output" "type 9p" "--mount 9p filesystem in mount list" + check_output "$output" "hello from host" "--mount file accessible in VM" + check_output "$output" "MOUNT_VERIFY_DONE" "--mount VM ran to completion" + + cleanup "$img" "$img" + rm -rf "$work" +} + test_service() { echo "=== Testing nextcloud service ===" @@ -171,6 +220,8 @@ cargo build 2>&1 test_distro "alpine" "alpine:latest" "" test_distro "archlinux" "archlinux:latest" "RUN pacman -Sy --noconfirm iproute2 wget; pacman -Sc --noconfirm" +test_mount + test_service echo "" diff --git a/src/qemu.rs b/src/qemu.rs index b230042..e794f16 100644 --- a/src/qemu.rs +++ b/src/qemu.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result, bail}; use base64::Engine; use clap::Args; -use std::{fmt::Write as _, process::Command}; +use std::{fmt::Write as _, path::PathBuf, process::Command}; use crate::{ forward::PortForward, @@ -32,6 +32,11 @@ pub struct RunCmd { /// cmdline as slim.cmd=). Overrides the CMD inferred at build time. #[clap(long)] cmd: Option, + + /// Share a host directory into the VM via 9p. The directory appears at + /// the same path inside the VM. Can be repeated for multiple mounts. + #[clap(long)] + mount: Vec, } pub(crate) fn run( @@ -40,6 +45,7 @@ pub(crate) fn run( memory, forward, cmd, + mount, }: RunCmd, ) -> Result<()> { let reg_dir = registry_dir(&name)?; @@ -81,6 +87,27 @@ pub(crate) fn run( cmdline.push(format!("slim.cmd={encoded}")); } + // Build 9p shares for each --mount. Tags are short (slim0, slim1, …) + // because 9p mount_tag has a ~31-byte limit. The full destination path + // is passed on the kernel cmdline as slim.mount=:. + let mut virtfs_args: Vec = Vec::new(); + for (i, host_path) in mount.iter().enumerate() { + let canonical = host_path + .canonicalize() + .with_context(|| format!("Cannot resolve mount path '{}'", host_path.display()))?; + let path_str = canonical + .to_str() + .context("Mount path is not valid UTF-8")?; + let tag = format!("slim{i}"); + let dest_b64 = base64::engine::general_purpose::STANDARD.encode(path_str.as_bytes()); + + virtfs_args.push("-virtfs".into()); + virtfs_args.push(format!( + "local,path={path_str},mount_tag={tag},security_model=mapped-xattr" + )); + cmdline.push(format!("slim.mount={tag}:{dest_b64}")); + } + println!("Booting {name} from registry: {}", reg_dir.display()); let cmdline = cmdline.join(" "); @@ -101,6 +128,7 @@ pub(crate) fn run( .arg("-kernel") .arg(vmlinuz_path.as_os_str()) .args(&fs_args) + .args(&virtfs_args) .args(["-snapshot"]) .args(["-no-reboot"]) .args(["-append", &cmdline]) diff --git a/src/scripts/slim-init.sh b/src/scripts/slim-init.sh index e976080..7685e22 100644 --- a/src/scripts/slim-init.sh +++ b/src/scripts/slim-init.sh @@ -34,6 +34,30 @@ ip addr add 10.0.2.15/24 dev eth0 2>/dev/null || echo "ip addr add eth0 failed" ip route add default via 10.0.2.2 2>/dev/null || echo "ip route add default failed" echo nameserver 10.0.2.3 > /etc/resolv.conf +# === 9p shares (host directories shared via --mount) === +# Each share is passed as slim.mount=: on the +# kernel cmdline. The 9p tag is short (e.g. slim0) because mount_tag +# has a ~31-byte limit; the full destination path is base64-encoded. +# shellcheck disable=SC2013 # word-splitting is intentional: cmdline tokens are space-separated +for tok in $(cat /proc/cmdline 2>/dev/null); do + case "$tok" in + slim.mount=*) + v=${tok#slim.mount=} + tag=${v%%:*} + dest_b64=${v#*:} + dest=$(printf '%s' "$dest_b64" | base64 -d 2>/dev/null) \ + || dest=$(printf '%s' "$dest_b64" | openssl base64 -d 2>/dev/null) \ + || echo "base64 decode failed" + if [ -n "$tag" ] && [ -n "$dest" ]; then + mkdir -p "$dest" + mount -t 9p "$tag" "$dest" -o trans=virtio,version=9p2000.L 2>/dev/null \ + && echo "mounted 9p '$tag' -> '$dest'" \ + || echo "failed to mount 9p '$tag' -> '$dest'" + fi + ;; + esac +done + # === Execute the configured command === # If slim.cmd= is on the kernel cmdline, decode and exec it. # Otherwise, exec /slim/exec (generated from the image's CMD/ENTRYPOINT).