From 1206728446654f5d14ce31b7efb0d7619b8eda17 Mon Sep 17 00:00:00 2001 From: marvin Date: Sat, 26 Sep 2026 14:36:49 +0200 Subject: [PATCH] virtiofs: add --mount-mode {unshare,direct} Adds a boot flag choosing how virtiofsd is launched per --mount: - unshare (default, previous behavior): virtiofsd runs inside 'podman unshare' with --sandbox chroot, mapping guest UIDs into the invoking user's subuid range. - direct: virtiofsd runs as the invoking user with --sandbox none, mapping guest UIDs 1:1 to host UIDs. The direct mode is needed when the shared directory itself lives on a virtiofs/fuse mount that rejects subuid-range UIDs (e.g. nested boco): guest writes through the subuid mapping then fail with EINVAL, breaking anything that writes to shares as a non-root guest user (databases, agent workspaces). Direct mode also cannot use the chroot sandbox (unprivileged chroot() is not permitted), so it falls back to --sandbox none; served trees are then not symlink-escape-protected. Verified both modes boot and serve mounts (alpine guest). --- src/qemu.rs | 22 +++++++++---- src/virtiofs.rs | 82 +++++++++++++++++++++++++++++++++++++------------ 2 files changed, 79 insertions(+), 25 deletions(-) diff --git a/src/qemu.rs b/src/qemu.rs index a9dab63..04691b2 100644 --- a/src/qemu.rs +++ b/src/qemu.rs @@ -4,10 +4,12 @@ //! at build time) handles VM bootstrap; /boco/exec runs the container's //! CMD/ENTRYPOINT. Runtime overrides are delivered as `boco.cmd=` //! on the kernel cmdline. Host directories are shared into the guest via -//! virtio-fs: one virtiofsd daemon (under `podman unshare` for rootless -//! UID mapping) per `--mount`, plus a `-chardev socket` + `-device -//! vhost-user-fs-pci` pair on the QEMU side. The guest mounts each share -//! with `mount -t virtiofs ` (see `boco.mount=` cmdline). +//! virtio-fs: one virtiofsd daemon per `--mount`, plus a `-chardev socket` +//! + `-device vhost-user-fs-pci` pair on the QEMU side. virtiofsd runs +//! under `podman unshare` (rootless UID mapping) by default, or directly +//! as the invoking user with `--mount-mode direct` (1:1 UID mapping). +//! The guest mounts each share with `mount -t virtiofs ` +//! (see `boco.mount=` cmdline). use anyhow::{Context, Result, bail}; use base64::Engine; @@ -17,7 +19,7 @@ use std::{fmt::Write as _, process::Command}; use crate::{ forward::PortForward, registry::{registry_base_dir, registry_dir}, - virtiofs::{self, VirtiofsShare}, + virtiofs::{self, MountMode, VirtiofsShare}, }; #[derive(Args, Debug)] @@ -44,6 +46,13 @@ pub struct BootCmd { /// relative to the image's WORKINGDIR. Can be repeated. #[clap(long)] mount: Vec, + + /// How virtiofsd is launched: `unshare` runs it inside + /// `podman unshare` (rootless UID mapping, guest UIDs map into the + /// subuid range); `direct` runs it as the invoking user (guest UIDs + /// map 1:1 to host UIDs — needed for nested virtiofs shares). + #[clap(long, value_enum, default_value = "unshare")] + mount_mode: MountMode, } pub(crate) fn boot( @@ -53,6 +62,7 @@ pub(crate) fn boot( forward, cmd, mount, + mount_mode, }: BootCmd, ) -> Result<()> { let reg_dir = registry_dir(&name)?; @@ -108,7 +118,7 @@ pub(crate) fn boot( Some((h, g)) => (h, g.to_string()), None => (spec.as_str(), spec.to_string()), }; - let share = virtiofs::spawn_share(i, host_path, guest_path)?; + let share = virtiofs::spawn_share(i, host_path, guest_path, mount_mode)?; let tag = share.tag.clone(); let chardev = format!("char{i}"); let socket = share diff --git a/src/virtiofs.rs b/src/virtiofs.rs index a4ab416..a821deb 100644 --- a/src/virtiofs.rs +++ b/src/virtiofs.rs @@ -6,13 +6,21 @@ //! hands the socket path to QEMU via `-chardev socket,path=...`, and //! cleans up when the VM exits. //! -//! To serve files owned by arbitrary guest UIDs (e.g. when an image -//! uses the `USER` directive) without running as root, virtiofsd is -//! launched under `podman unshare` in a user namespace with -//! `--sandbox chroot`. This reuses rootless podman's existing -//! subuid/subgid mapping: guest UID 0 maps to the invoking user, and -//! other guest UIDs map into the subuid range, so each guest UID -//! becomes a distinct host UID. +//! Launch modes ([`MountMode`]): +//! +//! * `unshare` (default): virtiofsd runs under `podman unshare` in a +//! user namespace with `--sandbox chroot`. This serves files owned +//! by arbitrary guest UIDs (e.g. when an image uses the `USER` +//! directive) without running as root, reusing rootless podman's +//! subuid/subgid mapping: guest UID 0 maps to the invoking user, and +//! other guest UIDs map into the subuid range, so each guest UID +//! becomes a distinct host UID. +//! +//! * `direct`: virtiofsd runs as the invoking user with `--sandbox +//! none`. Guest UIDs map 1:1 to host UIDs. Use this when the +//! shared directory lives on a filesystem that rejects subuid-range +//! UIDs (e.g. nested virtiofs), or when the guest should act as the +//! invoking user rather than the subuid range. //! //! Lifecycle: boco spawns all virtiofsd children before launching QEMU, //! waits for each listening socket to appear, then drops the @@ -28,10 +36,26 @@ use std::thread; use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow, bail}; +use clap::ValueEnum; use tempfile::TempDir; use crate::command::cmd; +/// How virtiofsd is launched for a share. +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum MountMode { + /// Run virtiofsd inside `podman unshare` (rootless UID mapping: + /// guest UID 0 maps to the invoking user, other guest UIDs map + /// into the subuid range). + Unshare, + /// Run virtiofsd directly as the invoking user. Guest UIDs map 1:1 + /// to host UIDs. Needed when the shared directory itself lives on + /// a virtiofs/fuse mount that rejects subuid-range UIDs (e.g. + /// nested boco), where writes through the subuid mapping fail with + /// EINVAL. + Direct, +} + /// Locations searched for the virtiofsd binary, in order. const VIRTIOFSD_CANDIDATES: &[&str] = &[ "/usr/libexec/virtiofsd", @@ -114,7 +138,12 @@ fn wait_for_socket(socket: &Path) -> Result<()> { /// /// `index` becomes the suffix of the tag (`boco{index}`) and is used to /// derive a unique socket filename. -pub fn spawn_share(index: usize, host_path: &str, guest_path: String) -> Result { +pub fn spawn_share( + index: usize, + host_path: &str, + guest_path: String, + mode: MountMode, +) -> Result { let virtiofsd = find_virtiofsd()?; let canonical = resolve_host_dir(host_path)?; let host_str = canonical @@ -128,17 +157,29 @@ pub fn spawn_share(index: usize, host_path: &str, guest_path: String) -> Result< let tag = format!("boco{index}"); // short tag to fit on cmdline let socket = dir.path().join(format!("{tag}.sock")); - // TODO: Make rootless mode optional - - // Run virtiofsd under `podman unshare` so it can serve files owned - // by arbitrary guest UIDs without root, mapping them into the host - // user's subuid range. `--sandbox chroot` is required because the - // outer `podman unshare` already entered a user namespace (nested - // user namespaces are not allowed); chroot still prevents symlink - // escapes from the shared dir. - let mut command = Command::new("podman"); + // In `unshare` mode, run virtiofsd under `podman unshare` so it can + // serve files owned by arbitrary guest UIDs without root, mapping + // them into the host user's subuid range. `--sandbox chroot` is + // required because the outer `podman unshare` already entered a user + // namespace (nested user namespaces are not allowed); chroot still + // prevents symlink escapes from the shared dir. + // + // In `direct` mode, run virtiofsd as the invoking user with 1:1 UID + // mapping. This cannot use the chroot sandbox (unprivileged + // chroot is not permitted), so it falls back to `--sandbox none`. + let mut command = match mode { + MountMode::Unshare => { + let mut command = Command::new("podman"); + command.args(["unshare", "--", virtiofsd_str, "--socket-path"]); + command + } + MountMode::Direct => { + let mut command = Command::new(virtiofsd_str); + command.arg("--socket-path"); + command + } + }; command - .args(["unshare", "--", virtiofsd_str, "--socket-path"]) .arg(&socket) .args([ "--shared-dir", @@ -146,7 +187,10 @@ pub fn spawn_share(index: usize, host_path: &str, guest_path: String) -> Result< "--tag", &tag, "--sandbox", - "chroot", + match mode { + MountMode::Unshare => "chroot", + MountMode::Direct => "none", + }, "--cache", "auto", ])