diff --git a/example/test.sh b/example/test.sh index 53aceb5..9f027d2 100755 --- a/example/test.sh +++ b/example/test.sh @@ -107,9 +107,13 @@ test_mount() { echo "=== Testing --mount (9p shares) ===" work="$(mktemp -d)" + + # --- Image 1: no WORKINGDIR, test host:guest and multi-mount --- img="slim-test-mount" share_dir="$work/share" share_dir2="$work/share2" + guest_dir="/mnt/guest" + guest_dir2="/mnt/guest2" mkdir -p "$share_dir" "$share_dir2" echo "hello from host" > "$share_dir/testfile.txt" @@ -120,7 +124,7 @@ FROM alpine:latest CMD ["/bin/sh", "-c", "poweroff -f"] EOF - echo "-- Building container image..." + echo "-- Building container image (no WORKINGDIR)..." if ! podman build --network=none -t "$img" -f "$work/Containerfile" >/dev/null 2>&1; then report fail "--mount (podman build failed)" rm -rf "$work" @@ -135,25 +139,63 @@ EOF 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") share_canon2=$(readlink -f "$share_dir2") - echo "-- Booting VM with --mount $share_dir --mount $share_dir2 ..." + echo "-- Test 1: --mount host:guest (absolute guest path, multi-mount)..." output=$(timeout "$TIMEOUT" "$SLIM_BIN" run "$img" \ - --mount "$share_dir" \ - --mount "$share_dir2" \ - --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; cat \"${share_canon2}/testfile2.txt\" 2>/dev/null || echo NO_FILE2; echo MOUNT_VERIFY_DONE; poweroff -f" \ + --mount "${share_dir}:${guest_dir}" \ + --mount "${share_dir2}:${guest_dir2}" \ + --cmd "for f in /sys/bus/virtio/drivers/9pnet_virtio/virtio*/mount_tag; do [ -f \"\$f\" ] && echo \"\$f: \$(tr -d '\\0' < \"\$f\")\"; done; mount -v; cat ${guest_dir}/testfile.txt 2>/dev/null || echo NO_FILE; cat ${guest_dir2}/testfile2.txt 2>/dev/null || echo NO_FILE2; echo MOUNT_VERIFY_DONE; poweroff -f" \ 2>&1 || true) check_output "$output" "mount_tag: slim0" "--mount 9p share detected" check_output "$output" "mount_tag: slim1" "--mount second 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" "second share" "--mount second share file accessible in VM" + check_output "$output" "hello from host" "--mount file accessible at guest path" + check_output "$output" "second share" "--mount second file accessible at guest path" check_output "$output" "MOUNT_VERIFY_DONE" "--mount VM ran to completion" + cleanup "$img" "$img" + + # --- Image 2: with WORKINGDIR, test relative guest path --- + img="slim-test-mount-wd" + share_dir3="$work/share3" + + mkdir -p "$share_dir3" + echo "relative share" > "$share_dir3/relfile.txt" + + cat > "$work/Containerfile.wd" <<'EOF' +FROM alpine:latest +WORKDIR /app +CMD ["/bin/sh", "-c", "poweroff -f"] +EOF + + echo "-- Building container image (WORKINGDIR /app)..." + if ! podman build --network=none -t "$img" -f "$work/Containerfile.wd" >/dev/null 2>&1; then + report fail "--mount relative (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 relative (slim build failed)" + cleanup "$img" "$img" + rm -rf "$work" + return + fi + + echo "-- Test 2: --mount host:relative-guest (resolved against WORKINGDIR)..." + output=$(timeout "$TIMEOUT" "$SLIM_BIN" run "$img" \ + --mount "${share_dir3}:data" \ + --cmd "mount -v; cat /app/data/relfile.txt 2>/dev/null || echo NO_REL_FILE; echo REL_VERIFY_DONE; poweroff -f" \ + 2>&1 || true) + + check_output "$output" "type 9p" "--mount relative 9p filesystem in mount list" + check_output "$output" "relative share" "--mount relative file accessible at WORKINGDIR/data" + check_output "$output" "REL_VERIFY_DONE" "--mount relative VM ran to completion" + cleanup "$img" "$img" rm -rf "$work" } diff --git a/src/build.rs b/src/build.rs index 189b9dc..df287a5 100644 --- a/src/build.rs +++ b/src/build.rs @@ -78,7 +78,7 @@ fn build_inner( let command = cmd.unwrap_or_else(|| inject::infer_command(&config)); let exec_script = inject::build_exec_script(&command, &config.env, config.working_dir.as_deref()); - inject::inject(mount_path, &exec_script)?; + inject::inject(mount_path, &exec_script, config.working_dir.as_deref())?; println!("Injected /slim/ (init + exec)"); match kind { diff --git a/src/inject.rs b/src/inject.rs index ea3ea0b..957a65d 100644 --- a/src/inject.rs +++ b/src/inject.rs @@ -85,8 +85,9 @@ pub fn build_exec_script(command: &str, env: &[String], working_dir: Option<&str lines } -/// Inject /slim/init and /slim/exec into a mounted container image rootfs. -pub fn inject(mount_path: &Path, exec_script: &str) -> Result<()> { +/// Inject /slim/init and /slim/exec (and optionally /slim/workdir) into a +/// mounted container image rootfs. +pub fn inject(mount_path: &Path, exec_script: &str, working_dir: Option<&str>) -> Result<()> { let mount_str = mount_path.to_str().context("mount path is not UTF-8")?; let init_temp = tempfile::NamedTempFile::new()?; @@ -113,5 +114,26 @@ pub fn inject(mount_path: &Path, exec_script: &str) -> Result<()> { "podman", "unshare", "--", "install", "-D", "-m", "755", exec_src, &exec_dest, ])?; + if let Some(dir) = working_dir.filter(|d| !d.is_empty()) { + let workdir_temp = tempfile::NamedTempFile::new()?; + fs::write(workdir_temp.path(), dir)?; + let workdir_src = workdir_temp + .path() + .to_str() + .context("temp path is not UTF-8")?; + let workdir_dest = format!("{mount_str}/slim/workdir"); + cmd(&[ + "podman", + "unshare", + "--", + "install", + "-D", + "-m", + "644", + workdir_src, + &workdir_dest, + ])?; + } + Ok(()) } diff --git a/src/qemu.rs b/src/qemu.rs index 7780e01..e5cc29d 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 _, path::PathBuf, process::Command}; +use std::{fmt::Write as _, process::Command}; use crate::{ forward::PortForward, @@ -33,10 +33,12 @@ pub struct RunCmd { #[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. + /// Share a host directory into the VM via 9p. Format: + /// `` or `:`. When the guest path is + /// omitted, the host path is used. Non-absolute guest paths are + /// relative to the image's WORKINGDIR. Can be repeated. #[clap(long)] - mount: Vec, + mount: Vec, } pub(crate) fn run( @@ -88,25 +90,30 @@ pub(crate) fn run( } // 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=:. + // because 9p mount_tag has a ~31-byte limit. The guest destination path + // is passed on the kernel cmdline as slim.mount=:. + // If no guest path is specified, the host path is used as the guest path. let mut virtfs_args: Vec = Vec::new(); - for (i, host_path) in mount.iter().enumerate() { - let canonical = host_path + for (i, spec) in mount.iter().enumerate() { + let (host_path, guest_path) = match spec.split_once(':') { + Some((h, g)) => (h, g), + None => (spec.as_str(), spec.as_str()), + }; + let canonical = std::path::Path::new(host_path) .canonicalize() - .with_context(|| format!("Cannot resolve mount path '{}'", host_path.display()))?; - let path_str = canonical + .with_context(|| format!("Cannot resolve mount path '{host_path}'"))?; + let host_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()); + let dest_b64 = base64::engine::general_purpose::STANDARD.encode(guest_path.as_bytes()); // QEMU's QemuOpts splits on commas — escape literal commas in the // path as ",," per QEMU convention. - let path_escaped = path_str.replace(',', ",,"); + let host_escaped = host_str.replace(',', ",,"); virtfs_args.push("-virtfs".into()); virtfs_args.push(format!( - "local,path={path_escaped},mount_tag={tag},security_model=mapped-xattr" + "local,path={host_escaped},mount_tag={tag},security_model=mapped-xattr" )); cmdline.push(format!("slim.mount={tag}:{dest_b64}")); } diff --git a/src/scripts/slim-init.sh b/src/scripts/slim-init.sh index 483dd6b..78f4b8f 100644 --- a/src/scripts/slim-init.sh +++ b/src/scripts/slim-init.sh @@ -40,9 +40,13 @@ ip route add default via 10.0.2.2 2>/dev/null || echo "ip route add default fail 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 +# 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. +# has a ~31-byte limit; the guest path is base64-encoded. +# Non-absolute guest paths are resolved against /slim/workdir (the +# image's WORKINGDIR). +SLIM_WORKDIR="" +[ -f /slim/workdir ] && SLIM_WORKDIR=$(cat /slim/workdir 2>/dev/null) # 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 @@ -52,6 +56,11 @@ for tok in $(cat /proc/cmdline 2>/dev/null); do dest_b64=${v#*:} dest=$(b64dec "$dest_b64") || echo "base64 decode failed" if [ -n "$tag" ] && [ -n "$dest" ]; then + # Resolve relative paths against the image's WORKINGDIR + case "$dest" in + /*) ;; + *) dest="${SLIM_WORKDIR:-/}/${dest}" ;; + esac mkdir -p "$dest" mount -t 9p "$tag" "$dest" -o trans=virtio,version=9p2000.L 2>/dev/null \ && echo "mounted 9p '$tag' -> '$dest'" \