diff --git a/Cargo.lock b/Cargo.lock index 7f498fca..48db33fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2488,6 +2488,7 @@ dependencies = [ "microsandbox-runtime", "microsandbox-utils", "nix 0.30.1", + "rand 0.10.0", "reqwest", "scopeguard", "sea-orm", diff --git a/crates/agentd/lib/init.rs b/crates/agentd/lib/init.rs index f6a48238..b5206785 100644 --- a/crates/agentd/lib/init.rs +++ b/crates/agentd/lib/init.rs @@ -22,28 +22,39 @@ struct BlockRootSpec<'a> { fstype: Option<&'a str>, } -/// Parsed virtiofs volume mount specification. +/// Parsed virtiofs directory volume mount specification. #[derive(Debug)] -struct VolumeMountSpec<'a> { +struct DirMountSpec<'a> { tag: &'a str, guest_path: &'a str, readonly: bool, } +/// Parsed virtiofs file volume mount specification. +#[derive(Debug)] +struct FileMountSpec<'a> { + tag: &'a str, + filename: &'a str, + guest_path: &'a str, + readonly: bool, +} + //-------------------------------------------------------------------------------------------------- // Functions //-------------------------------------------------------------------------------------------------- /// Performs synchronous PID 1 initialization. /// -/// Mounts essential filesystems, applies volume and tmpfs mounts from -/// `MSB_MOUNTS` / `MSB_TMPFS` env vars, configures networking from -/// `MSB_NET*` env vars, and prepares runtime directories. +/// Mounts essential filesystems, applies directory mounts from +/// `MSB_DIR_MOUNTS`, file mounts from `MSB_FILE_MOUNTS`, and tmpfs mounts +/// from `MSB_TMPFS`. Configures networking from `MSB_NET*` env vars and +/// prepares runtime directories. pub fn init() -> AgentdResult<()> { linux::mount_filesystems()?; linux::mount_runtime()?; linux::mount_block_root()?; - linux::apply_volume_mounts()?; + linux::apply_dir_mounts()?; + linux::apply_file_mounts()?; crate::network::apply_hostname()?; linux::apply_tmpfs_mounts()?; linux::ensure_standard_tmp_permissions()?; @@ -123,12 +134,12 @@ fn parse_block_root(val: &str) -> AgentdResult> { Ok(BlockRootSpec { device, fstype }) } -/// Parses a single virtiofs volume mount entry: `tag:guest_path[:ro]` -fn parse_volume_mount_entry(entry: &str) -> AgentdResult> { +/// Parses a single virtiofs directory volume mount entry: `tag:guest_path[:ro]` +fn parse_dir_mount_entry(entry: &str) -> AgentdResult> { let parts: Vec<&str> = entry.split(':').collect(); if parts.len() < 2 { return Err(AgentdError::Init(format!( - "MSB_MOUNTS entry must be tag:path[:ro], got: {entry}" + "MSB_DIR_MOUNTS entry must be tag:path[:ro], got: {entry}" ))); } @@ -139,33 +150,87 @@ fn parse_volume_mount_entry(entry: &str) -> AgentdResult> { None => false, Some(flag) => { return Err(AgentdError::Init(format!( - "MSB_MOUNTS unknown flag '{flag}' (expected 'ro')" + "MSB_DIR_MOUNTS unknown flag '{flag}' (expected 'ro')" ))); } }; if parts.len() > 3 { return Err(AgentdError::Init(format!( - "MSB_MOUNTS entry has too many parts: {entry}" + "MSB_DIR_MOUNTS entry has too many parts: {entry}" ))); } if tag.is_empty() { - return Err(AgentdError::Init("MSB_MOUNTS entry has empty tag".into())); + return Err(AgentdError::Init( + "MSB_DIR_MOUNTS entry has empty tag".into(), + )); } if guest_path.is_empty() || !guest_path.starts_with('/') { return Err(AgentdError::Init(format!( - "MSB_MOUNTS guest path must be absolute: {guest_path}" + "MSB_DIR_MOUNTS guest path must be absolute: {guest_path}" ))); } - Ok(VolumeMountSpec { + Ok(DirMountSpec { tag, guest_path, readonly, }) } +/// Parses a single virtiofs file volume mount entry: `tag:filename:guest_path[:ro]` +fn parse_file_mount_entry(entry: &str) -> AgentdResult> { + let parts: Vec<&str> = entry.split(':').collect(); + if parts.len() < 3 { + return Err(AgentdError::Init(format!( + "MSB_FILE_MOUNTS entry must be tag:filename:path[:ro], got: {entry}" + ))); + } + + let tag = parts[0]; + let filename = parts[1]; + let guest_path = parts[2]; + let readonly = match parts.get(3) { + Some(&"ro") => true, + None => false, + Some(flag) => { + return Err(AgentdError::Init(format!( + "MSB_FILE_MOUNTS unknown flag '{flag}' (expected 'ro')" + ))); + } + }; + + if parts.len() > 4 { + return Err(AgentdError::Init(format!( + "MSB_FILE_MOUNTS entry has too many parts: {entry}" + ))); + } + + if tag.is_empty() { + return Err(AgentdError::Init( + "MSB_FILE_MOUNTS entry has empty tag".into(), + )); + } + if filename.is_empty() { + return Err(AgentdError::Init( + "MSB_FILE_MOUNTS entry has empty filename".into(), + )); + } + if guest_path.is_empty() || !guest_path.starts_with('/') { + return Err(AgentdError::Init(format!( + "MSB_FILE_MOUNTS guest path must be absolute: {guest_path}" + ))); + } + + Ok(FileMountSpec { + tag, + filename, + guest_path, + readonly, + }) +} + fn ensure_scripts_profile_block(profile: &str) -> String { const START_MARKER: &str = "# >>> microsandbox scripts path >>>"; const END_MARKER: &str = "# <<< microsandbox scripts path <<<"; @@ -194,7 +259,7 @@ mod linux { }; use nix::{ - mount::{MsFlags, mount}, + mount::{MntFlags, MsFlags, mount, umount2}, sys::stat::Mode, unistd::{chdir, chroot, mkdir}, }; @@ -390,16 +455,16 @@ mod linux { ))) } - /// Reads `MSB_MOUNTS` env var and mounts each virtiofs volume. + /// Reads `MSB_DIR_MOUNTS` env var and mounts each virtiofs directory volume. /// /// For each entry, creates the guest mount point directory and mounts the /// virtiofs share using the tag provided by the host. If the entry /// specifies `:ro`, the mount is made read-only via `MS_RDONLY`. /// - /// Missing env var is not an error (no volume mounts requested). + /// Missing env var is not an error (no directory volume mounts requested). /// Parse failures and mount failures are hard errors. - pub fn apply_volume_mounts() -> AgentdResult<()> { - let val = match std::env::var(microsandbox_protocol::ENV_MOUNTS) { + pub fn apply_dir_mounts() -> AgentdResult<()> { + let val = match std::env::var(microsandbox_protocol::ENV_DIR_MOUNTS) { Ok(v) if !v.is_empty() => v, _ => return Ok(()), }; @@ -409,15 +474,15 @@ mod linux { continue; } - let spec = super::parse_volume_mount_entry(entry)?; - mount_virtiofs(&spec)?; + let spec = super::parse_dir_mount_entry(entry)?; + mount_dir(&spec)?; } Ok(()) } - /// Mounts a single virtiofs share from a parsed spec. - fn mount_virtiofs(spec: &super::VolumeMountSpec<'_>) -> AgentdResult<()> { + /// Mounts a single virtiofs directory share from a parsed spec. + fn mount_dir(spec: &super::DirMountSpec<'_>) -> AgentdResult<()> { let path = spec.guest_path; // Create the mount point directory. @@ -439,6 +504,135 @@ mod linux { Ok(()) } + /// Reads `MSB_FILE_MOUNTS` env var and bind-mounts each file. + /// + /// Missing env var is not an error (no file mounts requested). + /// Parse failures and mount failures are hard errors. + pub fn apply_file_mounts() -> AgentdResult<()> { + let val = match std::env::var(microsandbox_protocol::ENV_FILE_MOUNTS) { + Ok(v) if !v.is_empty() => v, + _ => return Ok(()), + }; + + // Create the staging root directory. + std::fs::create_dir_all(microsandbox_protocol::FILE_MOUNTS_DIR).map_err(|e| { + AgentdError::Init(format!( + "failed to create file mounts dir {}: {e}", + microsandbox_protocol::FILE_MOUNTS_DIR + )) + })?; + + for entry in val.split(';') { + if entry.is_empty() { + continue; + } + + let spec = super::parse_file_mount_entry(entry)?; + mount_file(&spec)?; + } + + // Best-effort cleanup of the staging root (succeeds only if all + // per-tag subdirs were already removed inside mount_file). + let _ = std::fs::remove_dir(microsandbox_protocol::FILE_MOUNTS_DIR); + + Ok(()) + } + + /// Mounts a single file from a virtiofs share via bind mount. + fn mount_file(spec: &super::FileMountSpec<'_>) -> AgentdResult<()> { + let staging_path = format!("{}/{}", microsandbox_protocol::FILE_MOUNTS_DIR, spec.tag); + + // 1. Create the staging mount point directory. + std::fs::create_dir_all(&staging_path).map_err(|e| { + AgentdError::Init(format!("failed to create staging dir {staging_path}: {e}")) + })?; + + // 2. Mount the virtiofs share at the staging directory. + let mut flags = MsFlags::MS_NOSUID | MsFlags::MS_NODEV | MsFlags::MS_RELATIME; + if spec.readonly { + flags |= MsFlags::MS_RDONLY; + } + + mount( + Some(spec.tag), + staging_path.as_str(), + Some("virtiofs"), + flags, + None::<&str>, + ) + .map_err(|e| { + AgentdError::Init(format!( + "failed to mount virtiofs tag '{}' at {staging_path}: {e}", + spec.tag + )) + })?; + + // 3. Create parent directories for the guest path. + let guest = Path::new(spec.guest_path); + if let Some(parent) = guest.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + AgentdError::Init(format!( + "failed to create parent dirs for {}: {e}", + spec.guest_path + )) + })?; + } + + // 4. Create the target file (touch) as a bind mount target. + std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(spec.guest_path) + .map_err(|e| { + AgentdError::Init(format!( + "failed to create bind target {}: {e}", + spec.guest_path + )) + })?; + + // 5. Bind mount the file from staging to the guest path. + let source_path = format!("{staging_path}/{}", spec.filename); + mount( + Some(source_path.as_str()), + spec.guest_path, + None::<&str>, + MsFlags::MS_BIND, + None::<&str>, + ) + .map_err(|e| { + AgentdError::Init(format!( + "failed to bind mount {source_path} to {}: {e}", + spec.guest_path + )) + })?; + + // 6. If read-only, remount the bind mount as read-only. + if spec.readonly { + mount( + None::<&str>, + spec.guest_path, + None::<&str>, + MsFlags::MS_BIND | MsFlags::MS_REMOUNT | MsFlags::MS_RDONLY, + None::<&str>, + ) + .map_err(|e| { + AgentdError::Init(format!( + "failed to remount {} as read-only: {e}", + spec.guest_path + )) + })?; + } + + // 7. Unmount the staging virtiofs share and remove the directory. + // The bind mount keeps the file accessible at the guest path; + // removing the share prevents alternate-path access. + let _ = umount2(staging_path.as_str(), MntFlags::MNT_DETACH); + let _ = std::fs::remove_dir(&staging_path); + + Ok(()) + } + /// Reads `MSB_TMPFS` env var and mounts each tmpfs entry. /// /// Missing env var is not an error (no tmpfs mounts requested). @@ -700,6 +894,51 @@ mod tests { assert!(err.to_string().contains("empty fstype")); } + #[test] + fn test_parse_file_mount_entry_basic() { + let spec = parse_file_mount_entry("fm_config:app.conf:/etc/app.conf").unwrap(); + assert_eq!(spec.tag, "fm_config"); + assert_eq!(spec.filename, "app.conf"); + assert_eq!(spec.guest_path, "/etc/app.conf"); + assert!(!spec.readonly); + } + + #[test] + fn test_parse_file_mount_entry_readonly() { + let spec = parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:ro").unwrap(); + assert!(spec.readonly); + } + + #[test] + fn test_parse_file_mount_entry_too_few_parts() { + assert!(parse_file_mount_entry("fm_config:/etc/app.conf").is_err()); + } + + #[test] + fn test_parse_file_mount_entry_empty_filename() { + assert!(parse_file_mount_entry("fm_config::/etc/app.conf").is_err()); + } + + #[test] + fn test_parse_file_mount_entry_relative_path() { + assert!(parse_file_mount_entry("fm_config:app.conf:relative/path").is_err()); + } + + #[test] + fn test_parse_file_mount_entry_too_many_parts() { + assert!(parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:ro:extra").is_err()); + } + + #[test] + fn test_parse_file_mount_entry_unknown_flag() { + assert!(parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:rw").is_err()); + } + + #[test] + fn test_parse_file_mount_entry_empty_tag() { + assert!(parse_file_mount_entry(":app.conf:/etc/app.conf").is_err()); + } + #[test] fn test_ensure_scripts_profile_block_appends_block() { let updated = ensure_scripts_profile_block("export PATH=/usr/bin:/bin\n"); diff --git a/crates/microsandbox/Cargo.toml b/crates/microsandbox/Cargo.toml index 6a35ca3e..41638787 100644 --- a/crates/microsandbox/Cargo.toml +++ b/crates/microsandbox/Cargo.toml @@ -34,9 +34,11 @@ microsandbox-protocol = { version = "0.3.12", path = "../protocol" } microsandbox-runtime = { version = "0.3.12", path = "../runtime", default-features = false } microsandbox-utils = { version = "0.3.12", path = "../utils" } nix = { workspace = true, features = ["process", "signal"] } +rand.workspace = true reqwest.workspace = true scopeguard.workspace = true tar.workspace = true +tempfile.workspace = true sea-orm.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/microsandbox/lib/runtime/handle.rs b/crates/microsandbox/lib/runtime/handle.rs index 486458ab..c06b048e 100644 --- a/crates/microsandbox/lib/runtime/handle.rs +++ b/crates/microsandbox/lib/runtime/handle.rs @@ -9,6 +9,7 @@ use nix::{ sys::signal::{self, Signal}, unistd::Pid, }; +use tempfile::TempDir; use tokio::process::Child; use crate::MicrosandboxResult; @@ -30,6 +31,10 @@ pub struct ProcessHandle { /// When true, the Drop impl will NOT send SIGTERM. detached: bool, + + /// Ephemeral staging directory for file mounts. Dropped when the + /// process handle is dropped, which auto-removes all staged files. + _file_mounts_staging: Option, } //-------------------------------------------------------------------------------------------------- @@ -38,12 +43,18 @@ pub struct ProcessHandle { impl ProcessHandle { /// Create a new handle. - pub(crate) fn new(pid: u32, sandbox_name: String, child: Child) -> Self { + pub(crate) fn new( + pid: u32, + sandbox_name: String, + child: Child, + file_mounts_staging: Option, + ) -> Self { Self { pid, sandbox_name, child, detached: false, + _file_mounts_staging: file_mounts_staging, } } @@ -89,8 +100,17 @@ impl ProcessHandle { /// Disarm the SIGTERM safety net so the sandbox keeps running after /// this handle is dropped. Used by detached sandbox flows. + /// + /// Also prevents the file-mounts staging directory from being deleted, + /// since the detached VM process still needs the backing files. pub fn disarm(&mut self) { self.detached = true; + + // Consume the TempDir without deleting its contents — the detached + // VM process still reads from it via virtiofs. + if let Some(td) = self._file_mounts_staging.take() { + let _ = td.keep(); + } } } diff --git a/crates/microsandbox/lib/runtime/spawn.rs b/crates/microsandbox/lib/runtime/spawn.rs index e2dff21c..e77a141f 100644 --- a/crates/microsandbox/lib/runtime/spawn.rs +++ b/crates/microsandbox/lib/runtime/spawn.rs @@ -7,9 +7,16 @@ #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -use std::{ffi::OsString, path::Path, process::Stdio}; +use std::{ + collections::HashMap, + ffi::OsString, + path::{Path, PathBuf}, + process::Stdio, +}; +use rand::RngExt; use serde::Deserialize; +use tempfile::TempDir; use tokio::{io::AsyncBufReadExt, process::Command}; use crate::{ @@ -56,7 +63,7 @@ pub async fn spawn_sandbox( config: &SandboxConfig, sandbox_id: i32, mode: SpawnMode, -) -> MicrosandboxResult<(ProcessHandle, std::path::PathBuf)> { +) -> MicrosandboxResult<(ProcessHandle, PathBuf)> { // Resolve paths. let msb_path = config::resolve_msb_path()?; let libkrunfw_path = config::resolve_libkrunfw_path()?; @@ -105,6 +112,11 @@ pub async fn spawn_sandbox( // Compute the agent relay socket path. let agent_sock_path = runtime_dir.join("agent.sock"); + // Stage file bind mounts: each file gets its own isolated directory so + // that virtio-fs (which requires directories) can share it without + // exposing adjacent files on the host. + let (staged_file_mounts, file_mounts_staging) = stage_file_mounts(config).await?; + // Build the command. let mut cmd = Command::new(&msb_path); cmd.args(sandbox_cli_args( @@ -118,6 +130,7 @@ pub async fn spawn_sandbox( &staging_dir, &agent_sock_path, &libkrunfw_path, + &staged_file_mounts, )); // Prevent the sandbox process from inheriting the parent's terminal on @@ -191,7 +204,7 @@ pub async fn spawn_sandbox( "spawn_sandbox: startup JSON received" ); - let handle = ProcessHandle::new(startup.pid, config.name.clone(), child); + let handle = ProcessHandle::new(startup.pid, config.name.clone(), child, file_mounts_staging); Ok((handle, agent_sock_path)) } @@ -207,8 +220,107 @@ async fn terminate_startup_process( child.wait().await.ok() } +/// Scan `config.mounts` for file bind mounts and stage each file in its own +/// isolated directory inside an ephemeral [`TempDir`]. +/// +/// Returns a map from guest path to `(file_mount_dir, filename, tag)` for +/// each staged file, plus the `TempDir` handle that must be kept alive for +/// the VM's lifetime. +async fn stage_file_mounts( + config: &SandboxConfig, +) -> MicrosandboxResult<(HashMap, Option)> { + // Collect file bind mounts first so we can skip TempDir creation when + // there are none. + let file_mounts: Vec<_> = config + .mounts + .iter() + .filter_map(|m| match m { + VolumeMount::Bind { + host, + guest, + readonly, + } if host.is_file() => Some((host, guest, *readonly)), + _ => None, + }) + .collect(); + + if file_mounts.is_empty() { + return Ok((HashMap::new(), None)); + } + + let tempdir = tempfile::tempdir()?; + let mut staged = HashMap::new(); + + for (host, guest, readonly) in file_mounts { + // Generate a random tag to avoid collisions. + let id: u32 = rand::rng().random(); + let tag = format!("fm_{id:08x}"); + + let file_mount_dir = tempdir.path().join(&tag); + tokio::fs::create_dir_all(&file_mount_dir).await?; + + let filename_os = host.file_name().ok_or_else(|| { + crate::MicrosandboxError::InvalidConfig(format!( + "file mount has no filename: {}", + host.display() + )) + })?; + + let filename = filename_os.to_str().ok_or_else(|| { + crate::MicrosandboxError::InvalidConfig(format!( + "file mount filename is not valid UTF-8: {}", + host.display() + )) + })?; + + // The MSB_FILE_MOUNTS protocol uses `:` and `;` as delimiters. + if filename.contains(':') || filename.contains(';') { + return Err(crate::MicrosandboxError::InvalidConfig(format!( + "file mount filename must not contain ':' or ';': {filename}" + ))); + } + + let target = file_mount_dir.join(filename); + + // Hard-link preserves the same inode — writes in the guest propagate + // to the host and vice-versa. Falls back to copy for cross-filesystem + // mounts (different device IDs). + match tokio::fs::hard_link(host, &target).await { + Ok(()) => { + tracing::debug!( + host = %host.display(), + file_mount_dir = %target.display(), + "file mount: hard-linked" + ); + } + Err(e) if e.raw_os_error() == Some(libc::EXDEV) => { + if !readonly { + tracing::warn!( + host = %host.display(), + file_mount_dir = %target.display(), + "file mount: cross-filesystem, falling back to copy \ + (guest writes will NOT propagate to host)" + ); + } else { + tracing::debug!( + host = %host.display(), + file_mount_dir = %target.display(), + "file mount: cross-filesystem, copying (read-only)" + ); + } + tokio::fs::copy(host, &target).await?; + } + Err(e) => return Err(e.into()), + } + + staged.insert(guest.clone(), (file_mount_dir, filename.to_string(), tag)); + } + + Ok((staged, Some(tempdir))) +} + /// Push a `--mount tag:host_path[:ro]` arg pair. -fn push_mount_arg( +fn push_dir_mount_arg( args: &mut Vec, guest: &str, host_display: &impl std::fmt::Display, @@ -223,17 +335,48 @@ fn push_mount_arg( args.push(OsString::from(arg)); } -/// Append a `tag:guest_path[:ro]` entry to the `MSB_MOUNTS` env var value. -fn push_mounts_spec(mounts_val: &mut String, guest: &str, readonly: bool) { - if !mounts_val.is_empty() { - mounts_val.push(';'); +/// Append a `tag:guest_path[:ro]` entry to the `MSB_DIR_MOUNTS` env var value. +fn push_dir_mounts_spec(dir_mounts_val: &mut String, guest: &str, readonly: bool) { + if !dir_mounts_val.is_empty() { + dir_mounts_val.push(';'); } let tag = guest_mount_tag(guest); - mounts_val.push_str(&tag); - mounts_val.push(':'); - mounts_val.push_str(guest); + dir_mounts_val.push_str(&tag); + dir_mounts_val.push(':'); + dir_mounts_val.push_str(guest); + if readonly { + dir_mounts_val.push_str(":ro"); + } +} + +/// Push a `--mount fm_tag:file_mount_dir[:ro]` arg pair. +fn push_file_mount_arg(args: &mut Vec, tag: &str, file_mount_dir: &Path, readonly: bool) { + let mut arg = format!("{tag}:{}", file_mount_dir.display()); if readonly { - mounts_val.push_str(":ro"); + arg.push_str(":ro"); + } + args.push(OsString::from("--mount")); + args.push(OsString::from(arg)); +} + +/// Append a `tag:filename:guest_path[:ro]` entry to the `MSB_FILE_MOUNTS` env var value. +fn push_file_mounts_spec( + file_mounts_val: &mut String, + tag: &str, + filename: &str, + guest: &str, + readonly: bool, +) { + if !file_mounts_val.is_empty() { + file_mounts_val.push(';'); + } + file_mounts_val.push_str(tag); + file_mounts_val.push(':'); + file_mounts_val.push_str(filename); + file_mounts_val.push(':'); + file_mounts_val.push_str(guest); + if readonly { + file_mounts_val.push_str(":ro"); } } @@ -261,6 +404,7 @@ fn sandbox_cli_args( staging_dir: &Path, agent_sock_path: &Path, libkrunfw_path: &Path, + staged_file_mounts: &HashMap, ) -> Vec { let mut args = vec![OsString::from("sandbox")]; @@ -311,7 +455,7 @@ fn sandbox_cli_args( // Scratch-style OCI images can legitimately have zero filesystem layers. let synthetic_empty_lower; - let lowers: &[std::path::PathBuf] = if config.resolved_rootfs_layers.is_empty() { + let lowers: &[PathBuf] = if config.resolved_rootfs_layers.is_empty() { synthetic_empty_lower = vec![empty_rootfs_dir.to_path_buf()]; &synthetic_empty_lower } else { @@ -349,7 +493,8 @@ fn sandbox_cli_args( // Process mounts: emit --mount args for virtiofs mounts, collect tmpfs and // virtiofs guest-side mount specs as env vars for agentd. let mut tmpfs_val = String::new(); - let mut mounts_val = String::new(); + let mut dir_mounts_val = String::new(); + let mut file_mounts_val = String::new(); for mount in &config.mounts { match mount { VolumeMount::Bind { @@ -357,8 +502,13 @@ fn sandbox_cli_args( guest, readonly, } => { - push_mount_arg(&mut args, guest, &host.display(), *readonly); - push_mounts_spec(&mut mounts_val, guest, *readonly); + if let Some((file_mount_dir, filename, tag)) = staged_file_mounts.get(guest) { + push_file_mount_arg(&mut args, tag, file_mount_dir, *readonly); + push_file_mounts_spec(&mut file_mounts_val, tag, filename, guest, *readonly); + } else { + push_dir_mount_arg(&mut args, guest, &host.display(), *readonly); + push_dir_mounts_spec(&mut dir_mounts_val, guest, *readonly); + } } VolumeMount::Named { name, @@ -366,8 +516,8 @@ fn sandbox_cli_args( readonly, } => { let vol_path = config::config().volumes_dir().join(name); - push_mount_arg(&mut args, guest, &vol_path.display(), *readonly); - push_mounts_spec(&mut mounts_val, guest, *readonly); + push_dir_mount_arg(&mut args, guest, &vol_path.display(), *readonly); + push_dir_mounts_spec(&mut dir_mounts_val, guest, *readonly); } VolumeMount::Tmpfs { guest, size_mib } => { if !tmpfs_val.is_empty() { @@ -389,11 +539,19 @@ fn sandbox_cli_args( ))); } - if !mounts_val.is_empty() { + if !dir_mounts_val.is_empty() { + args.push(OsString::from("--env")); + args.push(OsString::from(format!( + "{}={dir_mounts_val}", + microsandbox_protocol::ENV_DIR_MOUNTS + ))); + } + + if !file_mounts_val.is_empty() { args.push(OsString::from("--env")); args.push(OsString::from(format!( - "{}={mounts_val}", - microsandbox_protocol::ENV_MOUNTS + "{}={file_mounts_val}", + microsandbox_protocol::ENV_FILE_MOUNTS ))); } @@ -445,7 +603,8 @@ fn sandbox_cli_args( #[cfg(test)] mod tests { - use std::path::Path; + use std::collections::HashMap; + use std::path::{Path, PathBuf}; use super::sandbox_cli_args; use crate::{ @@ -472,6 +631,7 @@ mod tests { Path::new("/tmp/staging"), Path::new("/tmp/agent.sock"), Path::new("/tmp/libkrunfw.dylib"), + &HashMap::new(), ); assert!(args.iter().any(|arg| arg == "--debug")); @@ -495,6 +655,7 @@ mod tests { Path::new("/tmp/staging"), Path::new("/tmp/agent.sock"), Path::new("/tmp/libkrunfw.dylib"), + &HashMap::new(), ); assert!(!args.iter().any(|arg| { @@ -523,6 +684,7 @@ mod tests { Path::new("/tmp/staging"), Path::new("/tmp/agent.sock"), Path::new("/tmp/libkrunfw.dylib"), + &HashMap::new(), ); let rendered = args @@ -554,6 +716,7 @@ mod tests { Path::new("/tmp/staging"), Path::new("/tmp/agent.sock"), Path::new("/tmp/libkrunfw.dylib"), + &HashMap::new(), ); let rendered = args @@ -584,6 +747,7 @@ mod tests { Path::new("/tmp/staging"), Path::new("/tmp/agent.sock"), Path::new("/tmp/libkrunfw.dylib"), + &HashMap::new(), ); let rendered = args @@ -616,6 +780,7 @@ mod tests { Path::new("/tmp/staging"), Path::new("/tmp/agent.sock"), Path::new("/tmp/libkrunfw.dylib"), + &HashMap::new(), ); let rendered = args @@ -648,6 +813,7 @@ mod tests { Path::new("/tmp/staging"), Path::new("/tmp/agent.sock"), Path::new("/tmp/libkrunfw.dylib"), + &HashMap::new(), ); let rendered = args @@ -679,6 +845,7 @@ mod tests { Path::new("/tmp/staging"), Path::new("/tmp/agent.sock"), Path::new("/tmp/libkrunfw.dylib"), + &HashMap::new(), ); let rendered = args @@ -707,6 +874,7 @@ mod tests { Path::new("/tmp/staging"), Path::new("/tmp/agent.sock"), Path::new("/tmp/libkrunfw.dylib"), + &HashMap::new(), ); let rendered = args @@ -737,6 +905,7 @@ mod tests { Path::new("/tmp/staging"), Path::new("/tmp/agent.sock"), Path::new("/tmp/libkrunfw.dylib"), + &HashMap::new(), ); let rendered = args @@ -777,6 +946,7 @@ mod tests { Path::new("/tmp/staging"), Path::new("/tmp/agent.sock"), Path::new("/tmp/libkrunfw.dylib"), + &HashMap::new(), ); let rendered = args @@ -794,4 +964,103 @@ mod tests { assert!(!rendered.contains(&"--rootfs-path".to_string())); assert!(!rendered.contains(&"--rootfs-lower".to_string())); } + + #[test] + fn test_sandbox_cli_args_file_mount_generates_correct_args() { + let config = SandboxBuilder::new("test") + .image("/tmp/rootfs") + .volume("/guest/config.txt", |m| m.bind("/host/config.txt")) + .build() + .unwrap(); + + let mut staged_file_mounts = HashMap::new(); + staged_file_mounts.insert( + "/guest/config.txt".to_string(), + ( + PathBuf::from("/tmp/staging/fm_aabbccdd"), + "config.txt".to_string(), + "fm_aabbccdd".to_string(), + ), + ); + + let args = sandbox_cli_args( + &config, + 42, + Path::new("/tmp/msb.db"), + Path::new("/tmp/logs"), + Path::new("/tmp/runtime"), + Path::new("/tmp/rootfs-base"), + Path::new("/tmp/rw"), + Path::new("/tmp/staging"), + Path::new("/tmp/agent.sock"), + Path::new("/tmp/libkrunfw.dylib"), + &staged_file_mounts, + ); + + let rendered = args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + // File mount should use staging dir in --mount. + assert!( + rendered + .windows(2) + .any(|pair| pair[0] == "--mount" + && pair[1] == "fm_aabbccdd:/tmp/staging/fm_aabbccdd") + ); + // MSB_FILE_MOUNTS should contain the spec. + assert!( + rendered + .contains(&"MSB_FILE_MOUNTS=fm_aabbccdd:config.txt:/guest/config.txt".to_string()) + ); + // MSB_DIR_MOUNTS should NOT contain the file mount. + assert!(!rendered.iter().any(|a| a.starts_with("MSB_DIR_MOUNTS="))); + } + + #[test] + fn test_sandbox_cli_args_mixed_file_and_dir_mounts() { + let config = SandboxBuilder::new("test") + .image("/tmp/rootfs") + .volume("/data", |m| m.bind("/host/data")) + .volume("/guest/file.txt", |m| m.bind("/host/file.txt")) + .build() + .unwrap(); + + let mut staged_file_mounts = HashMap::new(); + staged_file_mounts.insert( + "/guest/file.txt".to_string(), + ( + PathBuf::from("/tmp/staging/fm_11223344"), + "file.txt".to_string(), + "fm_11223344".to_string(), + ), + ); + + let args = sandbox_cli_args( + &config, + 42, + Path::new("/tmp/msb.db"), + Path::new("/tmp/logs"), + Path::new("/tmp/runtime"), + Path::new("/tmp/rootfs-base"), + Path::new("/tmp/rw"), + Path::new("/tmp/staging"), + Path::new("/tmp/agent.sock"), + Path::new("/tmp/libkrunfw.dylib"), + &staged_file_mounts, + ); + + let rendered = args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + // Directory mount in MSB_DIR_MOUNTS. + assert!(rendered.contains(&"MSB_DIR_MOUNTS=data:/data".to_string())); + // File mount in MSB_FILE_MOUNTS. + assert!( + rendered.contains(&"MSB_FILE_MOUNTS=fm_11223344:file.txt:/guest/file.txt".to_string()) + ); + } } diff --git a/crates/protocol/lib/lib.rs b/crates/protocol/lib/lib.rs index f47ef454..693c88f8 100644 --- a/crates/protocol/lib/lib.rs +++ b/crates/protocol/lib/lib.rs @@ -18,6 +18,9 @@ pub const RUNTIME_FS_TAG: &str = "msb_runtime"; /// Guest mount point for the runtime filesystem. pub const RUNTIME_MOUNT_POINT: &str = "/.msb"; +/// Guest directory for file mount virtiofs shares. +pub const FILE_MOUNTS_DIR: &str = "/.msb/file-mounts"; + /// Guest path for named scripts (added to PATH by agentd). pub const SCRIPTS_PATH: &str = "/.msb/scripts"; @@ -87,7 +90,7 @@ pub const ENV_NET_IPV4: &str = "MSB_NET_IPV4"; /// - `MSB_NET_IPV6=addr=fd42:6d73:62:2a::2/64,gw=fd42:6d73:62:2a::1,dns=fd42:6d73:62:2a::1` pub const ENV_NET_IPV6: &str = "MSB_NET_IPV6"; -/// Environment variable carrying virtiofs volume mount specs for guest init. +/// Environment variable carrying virtiofs directory volume mount specs for guest init. /// /// Format: `tag:guest_path[:ro][;tag:guest_path[:ro];...]` /// @@ -98,10 +101,33 @@ pub const ENV_NET_IPV6: &str = "MSB_NET_IPV6"; /// Entries are separated by `;`. /// /// Examples: -/// - `MSB_MOUNTS=data:/data` — mount virtiofs tag `data` at `/data` -/// - `MSB_MOUNTS=data:/data:ro` — mount read-only -/// - `MSB_MOUNTS=data:/data;cache:/cache:ro` — two mounts -pub const ENV_MOUNTS: &str = "MSB_MOUNTS"; +/// - `MSB_DIR_MOUNTS=data:/data` — mount virtiofs tag `data` at `/data` +/// - `MSB_DIR_MOUNTS=data:/data:ro` — mount read-only +/// - `MSB_DIR_MOUNTS=data:/data;cache:/cache:ro` — two mounts +pub const ENV_DIR_MOUNTS: &str = "MSB_DIR_MOUNTS"; + +/// Environment variable carrying virtiofs **file** volume mount specs for guest init. +/// +/// Used when the host path is a single file rather than a directory. The SDK +/// wraps each file in an isolated staging directory (hard-linked to preserve +/// the same inode) and shares that directory via virtiofs. Agentd mounts the +/// share at [`FILE_MOUNTS_DIR`]`//` and bind-mounts the file to the +/// guest path. +/// +/// Format: `tag:filename:guest_path[:ro][;tag:filename:guest_path[:ro];...]` +/// +/// - `tag` — virtiofs tag name (required, matches the tag used in `--mount`) +/// - `filename` — name of the file inside the virtiofs share (required) +/// - `guest_path` — final file path inside the guest (required) +/// - `ro` — mount read-only (optional suffix) +/// +/// Entries are separated by `;`. +/// +/// Examples: +/// - `MSB_FILE_MOUNTS=fm_config:app.conf:/etc/app.conf` +/// - `MSB_FILE_MOUNTS=fm_config:app.conf:/etc/app.conf:ro` +/// - `MSB_FILE_MOUNTS=fm_a:a.sh:/usr/bin/a.sh;fm_b:b.sh:/usr/bin/b.sh` +pub const ENV_FILE_MOUNTS: &str = "MSB_FILE_MOUNTS"; /// Environment variable carrying the default guest user for agentd execs. ///