use std::io::Write;
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use microsandbox_db::entity::run as run_entity;
use microsandbox_filesystem::{DynFileSystem, PassthroughConfig, PassthroughFs};
use msb_krun::VmBuilder;
use sea_orm::{ColumnTrait, ConnectOptions, Database, DatabaseConnection, EntityTrait, Set};
use serde::Serialize;
use crate::console::{AgentConsoleBackend, ConsoleSharedState};
use crate::heartbeat::HeartbeatReader;
use crate::logging::LogLevel;
use crate::metrics::run_metrics_sampler;
use crate::relay::AgentRelay;
use crate::{RuntimeError, RuntimeResult};
const EXIT_REASON_COMPLETED: u8 = 0;
const EXIT_REASON_IDLE_TIMEOUT: u8 = 1;
const EXIT_REASON_MAX_DURATION: u8 = 2;
const EXIT_REASON_SIGNAL: u8 = 3;
#[derive(Debug)]
pub struct Config {
pub sandbox_name: String,
pub sandbox_id: i32,
pub log_level: Option<LogLevel>,
pub sandbox_db_path: PathBuf,
pub sandbox_db_connect_timeout_secs: u64,
pub log_dir: PathBuf,
pub runtime_dir: PathBuf,
pub agent_sock_path: PathBuf,
pub forward_output: bool,
pub idle_timeout_secs: Option<u64>,
pub max_duration_secs: Option<u64>,
pub vm: VmConfig,
}
#[derive(Debug, Clone)]
pub struct DiskMountSpec {
pub id: String,
pub host: PathBuf,
pub guest: String,
pub format: msb_krun::DiskImageFormat,
pub fstype: Option<String>,
pub readonly: bool,
}
pub struct VmConfig {
pub libkrunfw_path: PathBuf,
pub vcpus: u8,
pub memory_mib: u32,
pub rootfs_path: Option<PathBuf>,
pub rootfs_disk: Option<PathBuf>,
pub rootfs_disk_format: Option<String>,
pub rootfs_disk_readonly: bool,
pub rootfs_vmdk: Option<PathBuf>,
pub rootfs_upper: Option<PathBuf>,
pub mounts: Vec<String>,
pub disks: Vec<DiskMountSpec>,
pub backends: Vec<(String, Box<dyn DynFileSystem + Send + Sync>)>,
pub init_path: Option<PathBuf>,
pub env: Vec<String>,
pub workdir: Option<PathBuf>,
pub exec_path: Option<PathBuf>,
pub exec_args: Vec<String>,
#[cfg(feature = "net")]
pub network: microsandbox_network::config::NetworkConfig,
#[cfg(feature = "net")]
pub sandbox_slot: u64,
}
#[derive(Debug, Serialize)]
struct StartupInfo {
pid: u32,
}
#[cfg(feature = "net")]
type NetworkTerminationHandle = microsandbox_network::network::TerminationHandle;
#[cfg(not(feature = "net"))]
type NetworkTerminationHandle = ();
#[cfg(feature = "net")]
type NetworkMetricsHandle = microsandbox_network::network::MetricsHandle;
#[cfg(not(feature = "net"))]
type NetworkMetricsHandle = ();
impl std::fmt::Debug for VmConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VmConfig")
.field("libkrunfw_path", &self.libkrunfw_path)
.field("vcpus", &self.vcpus)
.field("memory_mib", &self.memory_mib)
.field("rootfs_path", &self.rootfs_path)
.field("rootfs_vmdk", &self.rootfs_vmdk)
.field("rootfs_upper", &self.rootfs_upper)
.field("rootfs_disk", &self.rootfs_disk)
.field("rootfs_disk_format", &self.rootfs_disk_format)
.field("rootfs_disk_readonly", &self.rootfs_disk_readonly)
.field("mounts", &self.mounts)
.field("disks", &self.disks)
.field("backends", &format!("[{} backend(s)]", self.backends.len()))
.field("init_path", &self.init_path)
.field("env", &self.env)
.field("workdir", &self.workdir)
.field("exec_path", &self.exec_path)
.field("exec_args", &self.exec_args)
.finish()
}
}
pub fn enter(config: Config) -> ! {
let result = run(config);
match result {
Ok(infallible) => match infallible {},
Err(e) => {
eprintln!("sandbox error: {e}");
std::process::exit(1);
}
}
}
fn run(config: Config) -> RuntimeResult<std::convert::Infallible> {
let pid = std::process::id();
let startup = StartupInfo { pid };
let startup_json = serde_json::to_string(&startup)
.map_err(|e| RuntimeError::Custom(format!("serialize startup: {e}")))?;
write_startup_info(&startup_json)?;
setup_log_capture(&config.log_dir, config.forward_output)?;
tracing::info!(sandbox = %config.sandbox_name, "sandbox starting");
let shared = Arc::new(ConsoleSharedState::new());
let console_backend = AgentConsoleBackend::new(Arc::clone(&shared));
let tokio_rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(|e| RuntimeError::Custom(format!("tokio runtime: {e}")))?;
std::fs::create_dir_all(&config.runtime_dir)?;
std::fs::create_dir_all(config.runtime_dir.join("scripts"))?;
let (mut relay, db, run_db_id) = tokio_rt.block_on(async {
let relay = AgentRelay::new(&config.agent_sock_path, Arc::clone(&shared));
let db = connect_db(
&config.sandbox_db_path,
config.sandbox_db_connect_timeout_secs,
);
let (relay, db) = tokio::try_join!(relay, db)?;
let run_db_id = insert_run(&db, config.sandbox_id, pid).await?;
Ok::<_, RuntimeError>((relay, db, run_db_id))
})?;
let exit_reason: Arc<std::sync::atomic::AtomicU8> =
Arc::new(std::sync::atomic::AtomicU8::new(EXIT_REASON_COMPLETED));
let rt_handle = tokio_rt.handle().clone();
let exit_db = db.clone();
let exit_sandbox_id = config.sandbox_id;
let exit_run_id = run_db_id;
let exit_reason_for_observer = Arc::clone(&exit_reason);
let exit_sock_path = config.agent_sock_path.clone();
let (vm, _network_termination_handle, network_metrics_handle) = match build_vm(
&config,
console_backend,
move |exit_code: i32| {
use microsandbox_db::entity::sandbox as sandbox_entity;
use sea_orm::QueryFilter;
use sea_orm::sea_query::Expr;
let reason_tag = exit_reason_for_observer.load(std::sync::atomic::Ordering::SeqCst);
let reason = match reason_tag {
EXIT_REASON_IDLE_TIMEOUT => run_entity::TerminationReason::IdleTimeout,
EXIT_REASON_MAX_DURATION => run_entity::TerminationReason::MaxDurationExceeded,
EXIT_REASON_SIGNAL => run_entity::TerminationReason::Signal,
_ if exit_code == 0 => run_entity::TerminationReason::Completed,
_ => run_entity::TerminationReason::Failed,
};
rt_handle.block_on(async {
let now = chrono::Utc::now().naive_utc();
let _ = run_entity::Entity::update_many()
.col_expr(
run_entity::Column::Status,
Expr::value(run_entity::RunStatus::Terminated),
)
.col_expr(run_entity::Column::TerminationReason, Expr::value(reason))
.col_expr(run_entity::Column::ExitCode, Expr::value(exit_code))
.col_expr(run_entity::Column::TerminatedAt, Expr::value(now))
.filter(run_entity::Column::Id.eq(exit_run_id))
.exec(&exit_db)
.await;
let _ = sandbox_entity::Entity::update_many()
.col_expr(
sandbox_entity::Column::Status,
Expr::value(sandbox_entity::SandboxStatus::Stopped),
)
.col_expr(sandbox_entity::Column::UpdatedAt, Expr::value(now))
.filter(sandbox_entity::Column::Id.eq(exit_sandbox_id))
.exec(&exit_db)
.await;
});
let _ = std::fs::remove_file(&exit_sock_path);
},
tokio_rt.handle().clone(),
) {
Ok(vm) => vm,
Err(e) => {
let _ = tokio_rt.block_on(mark_run_failed(&db, run_db_id));
return Err(e);
}
};
let exit_handle = vm.exit_handle();
#[cfg(feature = "net")]
if let Some(network_termination_handle) = _network_termination_handle {
let network_exit_handle = exit_handle.clone();
let network_reason = Arc::clone(&exit_reason);
network_termination_handle.set_hook(Arc::new(move || {
tracing::warn!("secret violation requested sandbox termination");
network_reason.store(EXIT_REASON_SIGNAL, std::sync::atomic::Ordering::SeqCst);
network_exit_handle.trigger();
}));
}
tokio_rt.spawn(run_metrics_sampler(
db.clone(),
config.sandbox_id,
pid,
network_metrics_handle
.map(|handle| Box::new(handle) as Box<dyn crate::metrics::NetworkMetrics>),
));
let (_relay_shutdown_tx, relay_shutdown_rx) = tokio::sync::watch::channel(false);
let (relay_drain_tx, mut relay_drain_rx) = tokio::sync::mpsc::channel::<()>(1);
tokio_rt.spawn(async move {
let ready_result =
tokio::task::spawn_blocking(move || relay.wait_ready().map(|()| relay)).await;
match ready_result {
Ok(Ok(relay)) => {
if let Err(e) = relay.run(relay_shutdown_rx, relay_drain_tx).await {
tracing::error!("agent relay error: {e}");
}
}
Ok(Err(e)) => tracing::error!("agent relay wait_ready failed: {e}"),
Err(e) => tracing::error!("agent relay wait_ready task panicked: {e}"),
}
});
{
let shutdown_exit_handle = exit_handle.clone();
tokio_rt.spawn(async move {
if relay_drain_rx.recv().await.is_some() {
tracing::info!("core.shutdown received, triggering exit");
shutdown_exit_handle.trigger();
}
});
}
if let Some(idle_secs) = config.idle_timeout_secs {
let heartbeat_reader = HeartbeatReader::new(&config.runtime_dir);
let idle_exit_handle = exit_handle.clone();
let idle_reason = Arc::clone(&exit_reason);
tokio_rt.spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
loop {
interval.tick().await;
if heartbeat_reader.is_idle(idle_secs) {
tracing::info!("sandbox idle for {idle_secs}s, triggering exit");
idle_reason.store(
EXIT_REASON_IDLE_TIMEOUT,
std::sync::atomic::Ordering::SeqCst,
);
idle_exit_handle.trigger();
break;
}
}
});
}
if let Some(max_secs) = config.max_duration_secs {
let max_exit_handle = exit_handle.clone();
let max_reason = Arc::clone(&exit_reason);
tokio_rt.spawn(async move {
tokio::time::sleep(Duration::from_secs(max_secs)).await;
tracing::info!("max duration {max_secs}s exceeded, triggering exit");
max_reason.store(
EXIT_REASON_MAX_DURATION,
std::sync::atomic::Ordering::SeqCst,
);
max_exit_handle.trigger();
});
}
std::mem::forget(tokio_rt);
tracing::info!(sandbox = %config.sandbox_name, "entering VM");
vm.enter()
.map_err(|e| RuntimeError::Custom(format!("VM enter: {e}")))
}
fn build_vm(
config: &Config,
console_backend: AgentConsoleBackend,
on_exit: impl Fn(i32) + Send + 'static,
tokio_handle: tokio::runtime::Handle,
) -> RuntimeResult<(
msb_krun::Vm,
Option<NetworkTerminationHandle>,
Option<NetworkMetricsHandle>,
)> {
let mut exec_env = config.vm.env.clone();
let vm = &config.vm;
let mut builder = VmBuilder::new()
.machine(|m| m.vcpus(vm.vcpus).memory_mib(vm.memory_mib as usize))
.kernel(|k| {
let k = k.krunfw_path(&vm.libkrunfw_path);
if let Some(ref init_path) = vm.init_path {
k.init_path(init_path)
} else {
k
}
});
if let Some(ref rootfs_path) = vm.rootfs_path {
let cfg = PassthroughConfig {
root_dir: rootfs_path.clone(),
..Default::default()
};
let backend =
PassthroughFs::new(cfg).map_err(|e| RuntimeError::Custom(format!("rootfs: {e}")))?;
builder = builder.fs(move |fs| fs.tag("/dev/root").custom(Box::new(backend)));
} else if let Some(ref vmdk_path) = vm.rootfs_vmdk {
let empty_trampoline = tempfile::tempdir()?;
let cfg = PassthroughConfig {
root_dir: empty_trampoline.path().to_path_buf(),
..Default::default()
};
let backend = PassthroughFs::new(cfg)
.map_err(|e| RuntimeError::Custom(format!("trampoline rootfs: {e}")))?;
builder = builder.fs(move |fs| fs.tag("/dev/root").custom(Box::new(backend)));
let vmdk = vmdk_path.clone();
builder = builder.disk(move |d| {
d.path(&vmdk)
.format(msb_krun::DiskImageFormat::Vmdk)
.read_only(true)
});
if let Some(ref upper) = vm.rootfs_upper {
let upper = upper.clone();
builder = builder.disk(move |d| {
d.path(&upper)
.format(msb_krun::DiskImageFormat::Raw)
.read_only(false)
});
}
let _ = empty_trampoline.keep();
} else if let Some(ref disk_path) = vm.rootfs_disk {
let empty_trampoline = tempfile::tempdir()?;
let cfg = PassthroughConfig {
root_dir: empty_trampoline.path().to_path_buf(),
..Default::default()
};
let backend = PassthroughFs::new(cfg)
.map_err(|e| RuntimeError::Custom(format!("trampoline rootfs: {e}")))?;
builder = builder.fs(move |fs| fs.tag("/dev/root").custom(Box::new(backend)));
let format = validate_disk_format(vm.rootfs_disk_format.as_deref())
.map_err(|e| RuntimeError::Custom(format!("disk format: {e}")))?;
let disk_path = disk_path.clone();
let readonly = vm.rootfs_disk_readonly;
builder = builder.disk(move |d| d.path(&disk_path).format(format).read_only(readonly));
append_block_root_env(&mut exec_env);
let _ = empty_trampoline.keep();
}
{
let runtime_tag = microsandbox_protocol::RUNTIME_FS_TAG.to_string();
let cfg = PassthroughConfig {
root_dir: config.runtime_dir.clone(),
inject_init: false,
..Default::default()
};
let backend = PassthroughFs::new(cfg)
.map_err(|e| RuntimeError::Custom(format!("runtime mount: {e}")))?;
builder = builder.fs(move |fs| fs.tag(&runtime_tag).custom(Box::new(backend)));
}
for mount_spec in &vm.mounts {
let (spec, _readonly) = match mount_spec.strip_suffix(":ro") {
Some(s) => (s, true),
None => (mount_spec.as_str(), false),
};
if let Some((tag, path)) = spec.split_once(':') {
let tag = tag.to_string();
let cfg = PassthroughConfig {
root_dir: PathBuf::from(path),
inject_init: false,
..Default::default()
};
let backend = PassthroughFs::new(cfg)
.map_err(|e| RuntimeError::Custom(format!("mount {tag}: {e}")))?;
builder = builder.fs(move |fs| fs.tag(&tag).custom(Box::new(backend)));
}
}
for disk in &vm.disks {
if !disk.host.exists() {
return Err(RuntimeError::Custom(format!(
"disk {}: host path not found: {}",
disk.id,
disk.host.display()
)));
}
tracing::debug!(
id = %disk.id,
guest = %disk.guest,
host = %disk.host.display(),
?disk.format,
fstype = ?disk.fstype,
readonly = disk.readonly,
"attaching disk-image volume",
);
let id = disk.id.clone();
let host = disk.host.clone();
let format = disk.format;
let readonly = disk.readonly;
builder = builder.disk(move |d| {
let mut d = d.id(&id).path(&host).format(format).read_only(readonly);
if readonly {
d = d
.cache(msb_krun::CacheMode::Unsafe)
.sync(msb_krun::SyncMode::None);
}
d
});
}
let mut network_termination_handle = None;
let mut network_metrics_handle = None;
#[cfg(feature = "net")]
if vm.network.enabled {
let _ = rustls::crypto::ring::default_provider().install_default();
let mut network =
microsandbox_network::network::SmoltcpNetwork::new(vm.network.clone(), vm.sandbox_slot);
network_termination_handle = Some(network.termination_handle());
network_metrics_handle = Some(network.metrics_handle());
network.start(tokio_handle.clone());
let guest_mac = network.guest_mac();
let net_backend = network.take_backend();
{
let tls_dir = config.runtime_dir.join("tls");
let _ = std::fs::create_dir_all(&tls_dir);
if let Some(ca_pem) = network.ca_cert_pem() {
let _ = std::fs::write(tls_dir.join("ca.pem"), &ca_pem);
}
if let Some(host_cas_pem) = network.host_cas_cert_pem() {
let _ = std::fs::write(tls_dir.join("host-cas.pem"), &host_cas_pem);
}
}
for (key, value) in network.guest_env_vars() {
exec_env.push(format!("{key}={value}"));
}
builder = builder.net(move |n| n.mac(guest_mac).custom(net_backend));
}
prepend_scripts_path(&mut exec_env);
builder = builder.exec(|mut e| {
if let Some(ref path) = vm.exec_path {
e = e.path(path);
}
if !vm.exec_args.is_empty() {
e = e.args(&vm.exec_args);
}
for env_str in &exec_env {
if let Some((key, value)) = env_str.split_once('=') {
e = e.env(key, value);
}
}
if let Some(ref workdir) = vm.workdir {
e = e.workdir(workdir);
}
e
});
let guest_log_path = config.log_dir.join("guest.log");
builder = builder.console(|c| {
c.output(&guest_log_path).custom(
microsandbox_protocol::AGENT_PORT_NAME,
Box::new(console_backend),
)
});
builder = builder.on_exit(on_exit);
let vm = builder
.build()
.map_err(|e| RuntimeError::Custom(format!("build VM: {e}")))?;
Ok((vm, network_termination_handle, network_metrics_handle))
}
fn setup_log_capture(log_dir: &std::path::Path, forward: bool) -> RuntimeResult<()> {
let devnull = std::fs::OpenOptions::new().write(true).open("/dev/null")?;
unsafe {
libc::dup2(devnull.as_raw_fd(), libc::STDOUT_FILENO);
}
drop(devnull);
let (stderr_read, stderr_write) = create_pipe()?;
let orig_stderr: Option<std::fs::File> = if forward {
Some(unsafe { std::fs::File::from_raw_fd(libc::dup(libc::STDERR_FILENO)) })
} else {
None
};
unsafe {
libc::dup2(stderr_write.as_raw_fd(), libc::STDERR_FILENO);
}
drop(stderr_write);
spawn_log_thread("log-host", stderr_read, log_dir, "host", orig_stderr)?;
Ok(())
}
fn write_startup_info(json: &str) -> RuntimeResult<()> {
let mut stdout = std::io::stdout().lock();
writeln!(stdout, "{json}")?;
stdout.flush()?;
Ok(())
}
async fn connect_db(
db_path: &std::path::Path,
connect_timeout_secs: u64,
) -> RuntimeResult<DatabaseConnection> {
let url = format!("sqlite://{}?mode=rwc", db_path.display());
let opts = ConnectOptions::new(url)
.max_connections(1)
.connect_timeout(Duration::from_secs(connect_timeout_secs))
.sqlx_logging(false)
.to_owned();
let db = Database::connect(opts)
.await
.map_err(|e| RuntimeError::Custom(format!("database connect: {e}")))?;
use sea_orm::ConnectionTrait;
db.execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Sqlite,
microsandbox_utils::SQLITE_PRAGMAS,
))
.await
.map_err(|e| RuntimeError::Custom(format!("database pragmas: {e}")))?;
Ok(db)
}
async fn insert_run(db: &DatabaseConnection, sandbox_id: i32, pid: u32) -> RuntimeResult<i32> {
let now = chrono::Utc::now().naive_utc();
let record = run_entity::ActiveModel {
sandbox_id: Set(sandbox_id),
pid: Set(Some(pid as i32)),
status: Set(run_entity::RunStatus::Running),
started_at: Set(Some(now)),
..Default::default()
};
let result = run_entity::Entity::insert(record)
.exec(db)
.await
.map_err(|e| RuntimeError::Custom(format!("insert run: {e}")))?;
Ok(result.last_insert_id)
}
async fn mark_run_failed(db: &DatabaseConnection, run_id: i32) -> RuntimeResult<()> {
use sea_orm::QueryFilter;
use sea_orm::sea_query::Expr;
let now = chrono::Utc::now().naive_utc();
run_entity::Entity::update_many()
.col_expr(
run_entity::Column::Status,
Expr::value(run_entity::RunStatus::Terminated),
)
.col_expr(
run_entity::Column::TerminationReason,
Expr::value(run_entity::TerminationReason::InternalError),
)
.col_expr(run_entity::Column::TerminatedAt, Expr::value(now))
.filter(run_entity::Column::Id.eq(run_id))
.exec(db)
.await
.map_err(|e| RuntimeError::Custom(format!("mark run failed: {e}")))?;
Ok(())
}
fn create_pipe() -> RuntimeResult<(OwnedFd, OwnedFd)> {
let mut fds = [0i32; 2];
if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
return Err(RuntimeError::Io(std::io::Error::last_os_error()));
}
Ok(unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) })
}
fn spawn_log_thread(
name: &str,
pipe_read: OwnedFd,
log_dir: &std::path::Path,
log_prefix: &str,
forward: Option<std::fs::File>,
) -> RuntimeResult<()> {
use crate::logging::RotatingLog;
use std::io::Read;
const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024;
let log_dir = log_dir.to_path_buf();
let log_prefix = log_prefix.to_string();
std::thread::Builder::new()
.name(name.into())
.spawn(move || {
let mut log = match RotatingLog::new(&log_dir, &log_prefix, MAX_LOG_BYTES) {
Ok(log) => log,
Err(e) => {
let _ = writeln!(std::io::stderr(), "failed to create {log_prefix} log: {e}");
return;
}
};
let mut reader = unsafe { std::fs::File::from_raw_fd(pipe_read.into_raw_fd()) };
let mut fwd = forward;
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = log.write(&buf[..n]);
if let Some(ref mut f) = fwd {
let _ = std::io::Write::write_all(f, &buf[..n]);
}
}
Err(_) => break,
}
}
})
.map_err(|e| RuntimeError::Custom(format!("spawn {name} thread: {e}")))?;
Ok(())
}
pub fn validate_disk_format(format: Option<&str>) -> msb_krun::Result<msb_krun::DiskImageFormat> {
match format.unwrap_or("raw") {
"qcow2" => Ok(msb_krun::DiskImageFormat::Qcow2),
"raw" => Ok(msb_krun::DiskImageFormat::Raw),
"vmdk" => Ok(msb_krun::DiskImageFormat::Vmdk),
other => Err(msb_krun::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown disk image format: {other}"),
))),
}
}
pub fn append_block_root_env(env: &mut Vec<String>) {
let prefix = format!("{}=", microsandbox_protocol::ENV_BLOCK_ROOT);
if env.iter().any(|entry| entry.starts_with(&prefix)) {
return;
}
env.push(format!("{prefix}/dev/vda"));
}
pub fn prepend_scripts_path(env: &mut Vec<String>) {
let scripts = microsandbox_protocol::SCRIPTS_PATH;
let prefix = "PATH=";
if let Some(entry) = env.iter_mut().find(|entry| entry.starts_with(prefix)) {
let existing = &entry[prefix.len()..];
if !existing.split(':').any(|segment| segment == scripts) {
*entry = format!("{prefix}{scripts}:{existing}");
}
} else {
env.push(format!(
"{prefix}{scripts}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
));
}
}
#[cfg(test)]
mod tests {
use super::{append_block_root_env, prepend_scripts_path, validate_disk_format};
#[test]
fn test_validate_disk_format_rejects_unknown_values() {
let err = validate_disk_format(Some("iso")).unwrap_err();
assert!(err.to_string().contains("unknown disk image format"));
}
#[test]
fn test_append_block_root_env_adds_default_device() {
let mut env = vec!["FOO=bar".to_string()];
append_block_root_env(&mut env);
assert!(env.contains(&"FOO=bar".to_string()));
assert!(env.contains(&format!(
"{}=/dev/vda",
microsandbox_protocol::ENV_BLOCK_ROOT
)));
}
#[test]
fn test_append_block_root_env_preserves_existing_value() {
let existing = format!(
"{}=/dev/vdb,fstype=xfs",
microsandbox_protocol::ENV_BLOCK_ROOT
);
let mut env = vec![existing.clone()];
append_block_root_env(&mut env);
assert_eq!(env, vec![existing]);
}
#[test]
fn test_prepend_scripts_path_updates_existing_path() {
let mut env = vec!["PATH=/usr/bin:/bin".to_string()];
prepend_scripts_path(&mut env);
assert_eq!(env, vec!["PATH=/.msb/scripts:/usr/bin:/bin".to_string()]);
}
#[test]
fn test_prepend_scripts_path_adds_default_path_when_missing() {
let mut env = vec!["LANG=C.UTF-8".to_string()];
prepend_scripts_path(&mut env);
assert!(
env.contains(
&"PATH=/.msb/scripts:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
.to_string()
)
);
}
#[test]
fn test_prepend_scripts_path_avoids_duplicates() {
let mut env = vec!["PATH=/.msb/scripts:/usr/bin".to_string()];
prepend_scripts_path(&mut env);
assert_eq!(env, vec!["PATH=/.msb/scripts:/usr/bin".to_string()]);
}
}