virtiofs: add --mount-mode {unshare,direct}
CI / build (pull_request) Failing after 8s

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).
This commit is contained in:
marvin committed 2026-09-26 14:36:49 +02:00
1 parent 8ad6754328
commit 1206728446
2 files changed
+79 -25

No files matched your search

+16 -6
View File
@@ -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=<base64>`
//! 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 <tag> <dest>` (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 <tag> <dest>`
//! (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<String>,
/// 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
+63 -19
View File
@@ -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<VirtiofsShare> {
pub fn spawn_share(
index: usize,
host_path: &str,
guest_path: String,
mode: MountMode,
) -> Result<VirtiofsShare> {
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",
])