#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::{
collections::HashMap,
ffi::OsString,
path::{Path, PathBuf},
process::Stdio,
};
use rand::RngExt;
use serde::Deserialize;
use sha2::{Digest as Sha2Digest, Sha256};
use tempfile::TempDir;
use tokio::{io::AsyncBufReadExt, process::Command};
use microsandbox_image::{Digest, GlobalCache};
use microsandbox_protocol::{
ENV_BLOCK_ROOT, ENV_DIR_MOUNTS, ENV_DISK_MOUNTS, ENV_FILE_MOUNTS, ENV_HOSTNAME, ENV_TMPFS,
ENV_USER,
};
use microsandbox_utils::{DB_FILENAME, DB_SUBDIR};
use crate::{
MicrosandboxResult, config,
runtime::handle::ProcessHandle,
sandbox::{DiskImageFormat, Rlimit, RootfsSource, SandboxConfig, VolumeMount},
};
#[derive(Debug, Deserialize)]
struct StartupInfo {
pid: u32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SpawnMode {
Attached,
Detached,
}
pub async fn spawn_sandbox(
config: &SandboxConfig,
sandbox_id: i32,
mode: SpawnMode,
) -> MicrosandboxResult<(ProcessHandle, PathBuf)> {
let msb_path = config::resolve_msb_path()?;
let libkrunfw_path = match &config.libkrunfw_path {
Some(path) => path.clone(),
None => config::resolve_libkrunfw_path()?,
};
tracing::debug!(
msb = %msb_path.display(),
libkrunfw = %libkrunfw_path.display(),
sandbox = %config.name,
cpus = config.cpus,
memory_mib = config.memory_mib,
mode = ?mode,
"spawn_sandbox: resolved paths"
);
let global = config::config();
let sandbox_dir = global.sandboxes_dir().join(&config.name);
let log_dir = sandbox_dir.join("logs");
let runtime_dir = sandbox_dir.join("runtime");
let scripts_dir = runtime_dir.join("scripts");
let db_dir = global.home().join(DB_SUBDIR);
let db_path = db_dir.join(DB_FILENAME);
tokio::try_join!(
tokio::fs::create_dir_all(&log_dir),
tokio::fs::create_dir_all(&scripts_dir),
)?;
for (name, content) in &config.scripts {
let safe_name = Path::new(name).file_name().ok_or_else(|| {
crate::MicrosandboxError::InvalidConfig(format!("invalid script name: {name}"))
})?;
let script_path = scripts_dir.join(safe_name);
tokio::fs::write(&script_path, content).await?;
#[cfg(unix)]
tokio::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).await?;
}
let agent_sock_path = runtime_dir.join("agent.sock");
let (staged_file_mounts, file_mounts_staging) = stage_file_mounts(config).await?;
let mut cmd = Command::new(&msb_path);
cmd.args(sandbox_cli_args(
config,
sandbox_id,
&db_path,
global.database.connect_timeout_secs,
&log_dir,
&runtime_dir,
&agent_sock_path,
&libkrunfw_path,
&staged_file_mounts,
));
cmd.stdin(Stdio::null());
if mode == SpawnMode::Detached {
cmd.process_group(0);
}
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::inherit());
let mut child = cmd.spawn()?;
let _pid = child.id().ok_or_else(|| {
crate::MicrosandboxError::Runtime("sandbox process exited immediately".into())
})?;
tracing::debug!(pid = _pid, sandbox = %config.name, "spawn_sandbox: process started");
let stdout = child.stdout.take().ok_or_else(|| {
crate::MicrosandboxError::Runtime("failed to capture sandbox stdout".into())
})?;
let mut reader = tokio::io::BufReader::new(stdout);
let mut line = String::new();
match tokio::time::timeout(
std::time::Duration::from_secs(30),
reader.read_line(&mut line),
)
.await
{
Ok(Ok(_)) => {}
Ok(Err(err)) => {
terminate_startup_process(&mut child).await;
return Err(err.into());
}
Err(_) => {
terminate_startup_process(&mut child).await;
return Err(crate::MicrosandboxError::Runtime(
"sandbox startup timeout: no JSON received within 30 seconds".into(),
));
}
}
let startup: StartupInfo = match serde_json::from_str(line.trim()) {
Ok(info) => info,
Err(_) => {
let status = terminate_startup_process(&mut child).await;
tracing::debug!(
raw_line = ?line,
exit_status = ?status,
"spawn_sandbox: failed to parse startup JSON"
);
return Err(crate::MicrosandboxError::Runtime(format!(
"sandbox process exited ({status:?}) before sending startup info \
(line: {line:?}, check stderr above for details)"
)));
}
};
tracing::debug!(
vm_pid = startup.pid,
agent_sock = %agent_sock_path.display(),
"spawn_sandbox: startup JSON received"
);
let handle = ProcessHandle::new(startup.pid, config.name.clone(), child, file_mounts_staging);
Ok((handle, agent_sock_path))
}
async fn terminate_startup_process(
child: &mut tokio::process::Child,
) -> Option<std::process::ExitStatus> {
let _ = child.start_kill();
child.wait().await.ok()
}
async fn stage_file_mounts(
config: &SandboxConfig,
) -> MicrosandboxResult<(HashMap<String, (PathBuf, String, String)>, Option<TempDir>)> {
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 {
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()
))
})?;
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);
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)))
}
fn push_dir_mount_arg(
args: &mut Vec<OsString>,
guest: &str,
host_display: &impl std::fmt::Display,
readonly: bool,
) {
let tag = guest_mount_tag(guest);
let mut arg = format!("{tag}:{host_display}");
if readonly {
arg.push_str(":ro");
}
args.push(OsString::from("--mount"));
args.push(OsString::from(arg));
}
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);
dir_mounts_val.push_str(&tag);
dir_mounts_val.push(':');
dir_mounts_val.push_str(guest);
if readonly {
dir_mounts_val.push_str(":ro");
}
}
fn push_file_mount_arg(args: &mut Vec<OsString>, tag: &str, file_mount_dir: &Path, readonly: bool) {
let mut arg = format!("{tag}:{}", file_mount_dir.display());
if readonly {
arg.push_str(":ro");
}
args.push(OsString::from("--mount"));
args.push(OsString::from(arg));
}
fn push_disk_mount_arg(
args: &mut Vec<OsString>,
id: &str,
host_display: &impl std::fmt::Display,
format: &DiskImageFormat,
readonly: bool,
) {
let mut arg = format!("{id}:{host_display}:{}", format.as_str());
if readonly {
arg.push_str(":ro");
}
args.push(OsString::from("--disk"));
args.push(OsString::from(arg));
}
fn push_disk_mounts_spec(
disk_mounts_val: &mut String,
id: &str,
guest: &str,
fstype: Option<&str>,
readonly: bool,
) {
if !disk_mounts_val.is_empty() {
disk_mounts_val.push(';');
}
disk_mounts_val.push_str(id);
disk_mounts_val.push(':');
disk_mounts_val.push_str(guest);
disk_mounts_val.push(':');
if let Some(fs) = fstype {
disk_mounts_val.push_str(fs);
}
if readonly {
disk_mounts_val.push_str(":ro");
}
}
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");
}
}
fn encode_rlimits(rlimits: &[Rlimit]) -> String {
use std::fmt::Write;
let mut out = String::with_capacity(rlimits.len() * 32);
for (i, rlimit) in rlimits.iter().enumerate() {
if i > 0 {
out.push(';');
}
write!(
out,
"{}={}:{}",
rlimit.resource.as_str(),
rlimit.soft,
rlimit.hard
)
.expect("writing to String cannot fail");
}
out
}
fn guest_mount_tag(guest_path: &str) -> String {
use std::fmt::Write as _;
const SLUG_MAX: usize = 11;
const HASH_HEX_LEN: usize = 8;
let slug: String = guest_path
.replace('/', "_")
.trim_start_matches('_')
.chars()
.take(SLUG_MAX)
.collect();
let mut hasher = Sha256::new();
hasher.update(guest_path.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(slug.len() + 1 + HASH_HEX_LEN);
if !slug.is_empty() {
out.push_str(&slug);
out.push('_');
}
for byte in digest.iter().take(HASH_HEX_LEN / 2) {
let _ = write!(out, "{byte:02x}");
}
out
}
#[allow(clippy::too_many_arguments)]
fn sandbox_cli_args(
config: &SandboxConfig,
sandbox_id: i32,
db_path: &Path,
db_connect_timeout_secs: u64,
log_dir: &Path,
runtime_dir: &Path,
agent_sock_path: &Path,
libkrunfw_path: &Path,
staged_file_mounts: &HashMap<String, (PathBuf, String, String)>,
) -> Vec<OsString> {
let mut args = vec![OsString::from("sandbox")];
if let Some(log_level) = config.log_level {
args.push(OsString::from(log_level.as_cli_flag()));
}
args.push(OsString::from("--name"));
args.push(OsString::from(&config.name));
args.push(OsString::from("--sandbox-id"));
args.push(OsString::from(sandbox_id.to_string()));
args.push(OsString::from("--db-path"));
args.push(db_path.as_os_str().to_os_string());
args.push(OsString::from("--db-connect-timeout-secs"));
args.push(OsString::from(db_connect_timeout_secs.to_string()));
args.push(OsString::from("--log-dir"));
args.push(log_dir.as_os_str().to_os_string());
args.push(OsString::from("--runtime-dir"));
args.push(runtime_dir.as_os_str().to_os_string());
args.push(OsString::from("--agent-sock"));
args.push(agent_sock_path.as_os_str().to_os_string());
let sp = &config.policy;
if let Some(max_dur) = sp.max_duration_secs {
args.push(OsString::from("--max-duration"));
args.push(OsString::from(max_dur.to_string()));
}
if let Some(idle) = sp.idle_timeout_secs {
args.push(OsString::from("--idle-timeout"));
args.push(OsString::from(idle.to_string()));
}
args.push(OsString::from("--libkrunfw-path"));
args.push(libkrunfw_path.as_os_str().to_os_string());
args.push(OsString::from("--vcpus"));
args.push(OsString::from(config.cpus.to_string()));
args.push(OsString::from("--memory-mib"));
args.push(OsString::from(config.memory_mib.to_string()));
match &config.image {
RootfsSource::Bind(path) => {
args.push(OsString::from("--rootfs-path"));
args.push(path.as_os_str().to_os_string());
}
RootfsSource::Oci(_) => {
if let Some(ref digest_str) = config.manifest_digest {
let cache_dir = config::config().cache_dir();
let cache = GlobalCache::new(&cache_dir).expect("cache init");
let digest: Digest = digest_str.parse().expect("invalid manifest digest");
let vmdk_path = cache.vmdk_path(&digest);
let sandbox_dir = config::config().sandboxes_dir().join(&config.name);
let upper_path = sandbox_dir.join("upper.ext4");
args.push(OsString::from("--rootfs-disk"));
args.push(vmdk_path.as_os_str().to_os_string());
args.push(OsString::from("--rootfs-disk-format"));
args.push(OsString::from("vmdk"));
args.push(OsString::from("--rootfs-blk"));
args.push(upper_path.as_os_str().to_os_string());
let block_root = "kind=oci-erofs,lower=/dev/vda,upper=/dev/vdb,upper_fstype=ext4";
args.push(OsString::from("--env"));
args.push(OsString::from(format!("{}={block_root}", ENV_BLOCK_ROOT)));
}
}
RootfsSource::DiskImage {
path,
format,
fstype,
} => {
args.push(OsString::from("--rootfs-disk"));
args.push(path.as_os_str().to_os_string());
args.push(OsString::from("--rootfs-disk-format"));
args.push(OsString::from(format.as_str()));
let mut block_root_val = String::from("kind=disk-image,device=/dev/vda");
if let Some(ft) = fstype {
block_root_val.push_str(&format!(",fstype={ft}"));
}
args.push(OsString::from("--env"));
args.push(OsString::from(format!(
"{}={block_root_val}",
ENV_BLOCK_ROOT
)));
}
}
let mut tmpfs_val = String::new();
let mut dir_mounts_val = String::new();
let mut file_mounts_val = String::new();
let mut disk_mounts_val = String::new();
for mount in &config.mounts {
match mount {
VolumeMount::Bind {
host,
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,
guest,
readonly,
} => {
let vol_path = config::config().volumes_dir().join(name);
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,
readonly,
} => {
if !tmpfs_val.is_empty() {
tmpfs_val.push(';');
}
tmpfs_val.push_str(guest);
if let Some(s) = size_mib {
tmpfs_val.push_str(&format!(",size={s}"));
}
if *readonly {
tmpfs_val.push_str(",ro");
}
}
VolumeMount::DiskImage {
host,
guest,
format,
fstype,
readonly,
} => {
let id = guest_mount_tag(guest);
push_disk_mount_arg(&mut args, &id, &host.display(), format, *readonly);
push_disk_mounts_spec(
&mut disk_mounts_val,
&id,
guest,
fstype.as_deref(),
*readonly,
);
}
}
}
if !tmpfs_val.is_empty() {
args.push(OsString::from("--env"));
args.push(OsString::from(format!("{}={tmpfs_val}", ENV_TMPFS)));
}
if !dir_mounts_val.is_empty() {
args.push(OsString::from("--env"));
args.push(OsString::from(format!(
"{}={dir_mounts_val}",
ENV_DIR_MOUNTS
)));
}
if !file_mounts_val.is_empty() {
args.push(OsString::from("--env"));
args.push(OsString::from(format!(
"{}={file_mounts_val}",
ENV_FILE_MOUNTS
)));
}
if !disk_mounts_val.is_empty() {
args.push(OsString::from("--env"));
args.push(OsString::from(format!(
"{}={disk_mounts_val}",
ENV_DISK_MOUNTS
)));
}
if !config.rlimits.is_empty() {
args.push(OsString::from("--env"));
args.push(OsString::from(format!(
"{}={}",
microsandbox_protocol::ENV_RLIMITS,
encode_rlimits(&config.rlimits)
)));
}
#[cfg(feature = "net")]
{
let net_json =
serde_json::to_string(&config.network).expect("failed to serialize network config");
args.push(OsString::from("--network-config"));
args.push(OsString::from(net_json));
args.push(OsString::from("--sandbox-slot"));
args.push(OsString::from(sandbox_id.to_string()));
}
for (key, value) in &config.env {
args.push(OsString::from("--env"));
args.push(OsString::from(format!("{key}={value}")));
}
if let Some(ref user) = config.user {
args.push(OsString::from("--env"));
args.push(OsString::from(format!("{}={user}", ENV_USER)));
}
{
let hostname = config.hostname.as_deref().unwrap_or(&config.name);
args.push(OsString::from("--env"));
args.push(OsString::from(format!("{}={hostname}", ENV_HOSTNAME)));
}
if let Some(ref workdir) = config.workdir {
args.push(OsString::from("--workdir"));
args.push(OsString::from(workdir));
}
args
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::sandbox_cli_args;
use crate::{
LogLevel,
sandbox::{
DiskImageFormat, Rlimit, RlimitResource, RootfsSource, SandboxBuilder, SandboxConfig,
},
};
fn render_args(config: &SandboxConfig) -> Vec<String> {
sandbox_cli_args(
config,
42,
Path::new("/tmp/msb.db"),
30,
Path::new("/tmp/logs"),
Path::new("/tmp/runtime"),
Path::new("/tmp/agent.sock"),
Path::new("/tmp/libkrunfw.dylib"),
&HashMap::new(),
)
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
fn render_args_with_file_mounts(
config: &SandboxConfig,
staged_file_mounts: &HashMap<String, (PathBuf, String, String)>,
) -> Vec<String> {
sandbox_cli_args(
config,
42,
Path::new("/tmp/msb.db"),
30,
Path::new("/tmp/logs"),
Path::new("/tmp/runtime"),
Path::new("/tmp/agent.sock"),
Path::new("/tmp/libkrunfw.dylib"),
staged_file_mounts,
)
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
#[test]
fn test_sandbox_cli_args_include_selected_log_level() {
let config = SandboxBuilder::new("test")
.image("/tmp/rootfs")
.log_level(LogLevel::Debug)
.build()
.unwrap();
let args = render_args(&config);
assert!(args.iter().any(|arg| arg == "--debug"));
}
#[test]
fn test_sandbox_cli_args_are_silent_by_default() {
let config = SandboxBuilder::new("test")
.image("/tmp/rootfs")
.build()
.unwrap();
let args = render_args(&config);
assert!(!args.iter().any(|arg| {
matches!(
arg.as_str(),
"--error" | "--warn" | "--info" | "--debug" | "--trace"
)
}));
}
#[test]
fn test_sandbox_cli_args_include_agent_sock_path() {
let config = SandboxBuilder::new("test")
.image("/tmp/rootfs")
.build()
.unwrap();
let rendered = render_args(&config);
assert!(
rendered
.windows(2)
.any(|pair| pair == ["--agent-sock", "/tmp/agent.sock"])
);
}
#[test]
fn test_sandbox_cli_args_include_rlimits_env() {
let config = SandboxBuilder::new("test")
.image("/tmp/rootfs")
.rlimit(RlimitResource::Nofile, 65_535)
.build()
.unwrap();
let args = sandbox_cli_args(
&config,
42,
Path::new("/tmp/msb.db"),
30,
Path::new("/tmp/logs"),
Path::new("/tmp/runtime"),
Path::new("/tmp/agent.sock"),
Path::new("/tmp/libkrunfw.dylib"),
&HashMap::new(),
);
let rendered = args
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert!(rendered.windows(2).any(|pair| {
pair[0] == "--env"
&& pair[1] == format!("{}=nofile=65535:65535", microsandbox_protocol::ENV_RLIMITS)
}));
}
#[test]
fn test_encode_rlimits_round_trips_through_protocol_parser() {
use microsandbox_protocol::exec::ExecRlimit;
let rlimits = vec![
Rlimit {
resource: RlimitResource::Nofile,
soft: 4096,
hard: 65_535,
},
Rlimit {
resource: RlimitResource::Nproc,
soft: 1024,
hard: 1024,
},
];
let encoded = super::encode_rlimits(&rlimits);
let parsed: Vec<ExecRlimit> = encoded
.split(';')
.map(|entry| entry.parse::<ExecRlimit>().unwrap())
.collect();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].resource, "nofile");
assert_eq!(parsed[0].soft, 4096);
assert_eq!(parsed[0].hard, 65_535);
assert_eq!(parsed[1].resource, "nproc");
assert_eq!(parsed[1].soft, 1024);
assert_eq!(parsed[1].hard, 1024);
}
#[test]
fn test_sandbox_cli_args_include_db_connect_timeout() {
let config = SandboxBuilder::new("test")
.image("/tmp/rootfs")
.build()
.unwrap();
let rendered = render_args(&config);
assert!(
rendered
.windows(2)
.any(|pair| pair == ["--db-connect-timeout-secs", "30"])
);
}
#[test]
fn test_sandbox_cli_args_use_passthrough_for_bind_rootfs() {
let config = SandboxBuilder::new("test")
.image("/tmp/rootfs")
.build()
.unwrap();
let rendered = render_args(&config);
assert!(rendered.contains(&"--rootfs-path".to_string()));
assert!(rendered.contains(&"/tmp/rootfs".to_string()));
assert!(!rendered.contains(&"--rootfs-lower".to_string()));
assert!(!rendered.contains(&"--rootfs-upper".to_string()));
assert!(!rendered.contains(&"--rootfs-staging".to_string()));
}
#[test]
fn test_sandbox_cli_args_oci_without_manifest_digest_emits_no_block_root() {
let config = SandboxBuilder::new("test").image("alpine").build().unwrap();
assert!(matches!(config.image, RootfsSource::Oci(_)));
let rendered = render_args(&config);
assert!(!rendered.contains(&"--rootfs-blk".to_string()));
assert!(!rendered.contains(&"--rootfs-disk".to_string()));
assert!(!rendered.iter().any(|a| a.starts_with("MSB_BLOCK_ROOT=")));
}
#[test]
fn test_sandbox_cli_args_inject_tmpfs_env_var() {
let config = SandboxBuilder::new("test")
.image("/tmp/rootfs")
.volume("/tmp", |m| m.tmpfs().size(256u32))
.volume("/var/tmp", |m| m.tmpfs())
.build()
.unwrap();
let rendered = render_args(&config);
assert!(rendered.contains(&"MSB_TMPFS=/tmp,size=256;/var/tmp".to_string()));
}
#[test]
fn test_sandbox_cli_args_tmpfs_readonly_appends_ro() {
let config = SandboxBuilder::new("test")
.image("/tmp/rootfs")
.volume("/seed", |m| m.tmpfs().size(64u32).readonly())
.build()
.unwrap();
let rendered = render_args(&config);
assert!(rendered.contains(&"MSB_TMPFS=/seed,size=64,ro".to_string()));
}
#[test]
fn test_sandbox_cli_args_apply_default_oci_tmpfs() {
let mut config = SandboxConfig {
name: "test".into(),
image: RootfsSource::Oci("alpine".into()),
memory_mib: 1024,
manifest_digest: Some(
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
),
..Default::default()
};
config.apply_runtime_defaults();
let rendered = render_args(&config);
assert!(rendered.contains(&"MSB_TMPFS=/tmp,size=256".to_string()));
}
#[test]
fn test_sandbox_cli_args_omit_tmpfs_env_var_when_no_tmpfs() {
let config = SandboxBuilder::new("test")
.image("/tmp/rootfs")
.build()
.unwrap();
let rendered = render_args(&config);
assert!(!rendered.iter().any(|a| a.starts_with("MSB_TMPFS=")));
}
#[test]
fn test_sandbox_cli_args_disk_image_with_fstype() {
let config = SandboxBuilder::new("test")
.image_with(|i| i.disk("/tmp/ubuntu.qcow2").fstype("ext4"))
.build()
.unwrap();
assert!(matches!(config.image, RootfsSource::DiskImage { .. }));
let rendered = render_args(&config);
assert!(rendered.contains(&"--rootfs-disk".to_string()));
assert!(rendered.contains(&"/tmp/ubuntu.qcow2".to_string()));
assert!(rendered.contains(&"--rootfs-disk-format".to_string()));
assert!(rendered.contains(&"qcow2".to_string()));
assert!(
rendered.contains(
&"MSB_BLOCK_ROOT=kind=disk-image,device=/dev/vda,fstype=ext4".to_string()
)
);
assert!(!rendered.contains(&"--rootfs-path".to_string()));
assert!(!rendered.contains(&"--rootfs-lower".to_string()));
assert!(!rendered.contains(&"--rootfs-upper".to_string()));
assert!(!rendered.contains(&"--rootfs-staging".to_string()));
}
#[test]
fn test_sandbox_cli_args_disk_image_without_fstype() {
let config = SandboxBuilder::new("test")
.image_with(|i| i.disk("/tmp/alpine.raw"))
.build()
.unwrap();
assert!(matches!(config.image, RootfsSource::DiskImage { .. }));
let rendered = render_args(&config);
assert!(rendered.contains(&"--rootfs-disk".to_string()));
assert!(rendered.contains(&"/tmp/alpine.raw".to_string()));
assert!(rendered.contains(&"--rootfs-disk-format".to_string()));
assert!(rendered.contains(&"raw".to_string()));
assert!(rendered.contains(&"MSB_BLOCK_ROOT=kind=disk-image,device=/dev/vda".to_string()));
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 rendered = render_args_with_file_mounts(&config, &staged_file_mounts);
assert!(
rendered
.windows(2)
.any(|pair| pair[0] == "--mount"
&& pair[1] == "fm_aabbccdd:/tmp/staging/fm_aabbccdd")
);
assert!(
rendered
.contains(&"MSB_FILE_MOUNTS=fm_aabbccdd:config.txt:/guest/config.txt".to_string())
);
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 rendered = render_args_with_file_mounts(&config, &staged_file_mounts);
let data_tag = super::guest_mount_tag("/data");
assert!(rendered.contains(&format!("MSB_DIR_MOUNTS={data_tag}:/data")));
assert!(
rendered.contains(&"MSB_FILE_MOUNTS=fm_11223344:file.txt:/guest/file.txt".to_string())
);
}
#[test]
fn test_sandbox_cli_args_disk_image_volume() {
let dir = tempfile::tempdir().unwrap();
let host = dir.path().join("data.qcow2");
std::fs::write(&host, []).unwrap();
let host_clone = host.clone();
let config = SandboxBuilder::new("test")
.image("/tmp/rootfs")
.volume("/data", |m| {
m.disk(host_clone)
.format(DiskImageFormat::Qcow2)
.fstype("ext4")
})
.build()
.unwrap();
let rendered = render_args(&config);
let data_tag = super::guest_mount_tag("/data");
let expected_disk_arg = format!("{data_tag}:{}:qcow2", host.display());
assert!(
rendered
.windows(2)
.any(|pair| pair[0] == "--disk" && pair[1] == expected_disk_arg),
"missing --disk arg in {rendered:?}"
);
let expected_env = format!("MSB_DISK_MOUNTS={data_tag}:/data:ext4");
assert!(rendered.contains(&expected_env));
}
#[test]
fn test_sandbox_cli_args_disk_image_readonly() {
let dir = tempfile::tempdir().unwrap();
let host = dir.path().join("seed.raw");
std::fs::write(&host, []).unwrap();
let host_clone = host.clone();
let config = SandboxBuilder::new("test")
.image("/tmp/rootfs")
.volume("/seed", |m| m.disk(host_clone).readonly())
.build()
.unwrap();
let rendered = render_args(&config);
let tag = super::guest_mount_tag("/seed");
assert!(rendered.windows(2).any(
|pair| pair[0] == "--disk" && pair[1] == format!("{tag}:{}:raw:ro", host.display())
));
assert!(rendered.contains(&format!("MSB_DISK_MOUNTS={tag}:/seed::ro")));
}
#[test]
fn test_guest_mount_tag_is_deterministic() {
let a = super::guest_mount_tag("/data");
let b = super::guest_mount_tag("/data");
assert_eq!(a, b);
}
#[test]
fn test_guest_mount_tag_disambiguates_colliding_paths() {
let a = super::guest_mount_tag("/var/log");
let b = super::guest_mount_tag("/var_log");
assert_ne!(a, b);
assert!(a.starts_with("var_log_"));
assert!(b.starts_with("var_log_"));
}
#[test]
fn test_guest_mount_tag_fits_virtio_blk_serial_limit() {
let long = "/a/very/deeply/nested/guest/mount/point/that/exceeds/the/slug/cap";
let tag = super::guest_mount_tag(long);
assert!(tag.len() <= 20, "tag {tag:?} exceeds 20 bytes");
}
#[test]
fn test_guest_mount_tag_slug_prefix_is_readable() {
assert!(super::guest_mount_tag("/data").starts_with("data_"));
assert!(super::guest_mount_tag("/var/log").starts_with("var_log_"));
}
}