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, OverlayFs, 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 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,
}
pub struct VmConfig {
pub libkrunfw_path: PathBuf,
pub vcpus: u8,
pub memory_mib: u32,
pub rootfs_path: Option<PathBuf>,
pub rootfs_lowers: Vec<PathBuf>,
pub rootfs_upper: Option<PathBuf>,
pub rootfs_staging: Option<PathBuf>,
pub rootfs_disk: Option<PathBuf>,
pub rootfs_disk_format: Option<String>,
pub rootfs_disk_readonly: bool,
pub mounts: Vec<String>,
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_lowers", &self.rootfs_lowers)
.field("rootfs_upper", &self.rootfs_upper)
.field("rootfs_staging", &self.rootfs_staging)
.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("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> {
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}")))?;
let mut relay = tokio_rt.block_on(AgentRelay::new(
&config.agent_sock_path,
Arc::clone(&shared),
))?;
std::fs::create_dir_all(&config.runtime_dir)?;
std::fs::create_dir_all(config.runtime_dir.join("scripts"))?;
let db = tokio_rt.block_on(connect_db(&config.sandbox_db_path))?;
let pid = std::process::id();
let run_db_id = tokio_rt.block_on(insert_run(&db, config.sandbox_id, pid))?;
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();
});
}
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)?;
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 !vm.rootfs_lowers.is_empty() {
let overlay = build_overlay_rootfs(
&vm.rootfs_lowers,
vm.rootfs_upper.as_deref(),
vm.rootfs_staging.as_deref(),
)
.map_err(|e| RuntimeError::Custom(format!("overlay rootfs: {e}")))?;
builder = builder.fs(move |fs| fs.tag("/dev/root").custom(Box::new(overlay)));
} 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(),
..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),
..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)));
}
}
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();
if let Some(ca_pem) = network.ca_cert_pem() {
let tls_dir = config.runtime_dir.join("tls");
let _ = std::fs::create_dir_all(&tls_dir);
let _ = std::fs::write(tls_dir.join("ca.pem"), &ca_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
});
builder = builder.console(|c| {
c.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 (stdout_read, stdout_write) = create_pipe()?;
let (stderr_read, stderr_write) = create_pipe()?;
let orig_stdout: Option<std::fs::File> = if forward {
Some(unsafe { std::fs::File::from_raw_fd(libc::dup(libc::STDOUT_FILENO)) })
} else {
None
};
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(stdout_write.as_raw_fd(), libc::STDOUT_FILENO);
libc::dup2(stderr_write.as_raw_fd(), libc::STDERR_FILENO);
}
drop(stdout_write);
drop(stderr_write);
spawn_log_thread("log-stdout", stdout_read, log_dir, "vm.stdout", orig_stdout)?;
spawn_log_thread("log-stderr", stderr_read, log_dir, "vm.stderr", 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) -> RuntimeResult<DatabaseConnection> {
let url = format!("sqlite://{}?mode=rwc", db_path.display());
let opts = ConnectOptions::new(url).max_connections(1).to_owned();
let db = Database::connect(opts)
.await
.map_err(|e| RuntimeError::Custom(format!("database connect: {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"
));
}
}
pub fn find_runtime_dir(mounts: &[String]) -> Option<PathBuf> {
let tag = microsandbox_protocol::RUNTIME_FS_TAG;
for spec in mounts {
let spec = spec.strip_suffix(":ro").unwrap_or(spec);
if let Some((t, path)) = spec.split_once(':')
&& t == tag
{
return Some(PathBuf::from(path));
}
}
None
}
pub fn build_overlay_rootfs(
layers: &[PathBuf],
upper_dir: Option<&std::path::Path>,
staging_dir: Option<&std::path::Path>,
) -> msb_krun::Result<OverlayFs> {
debug_assert!(
!layers.is_empty(),
"overlay rootfs requires at least one lower layer"
);
let mut overlay_builder = OverlayFs::builder();
for layer in layers {
let index_path = layer.with_extension("index");
if index_path.exists() {
overlay_builder = overlay_builder.layer_with_index(layer, &index_path);
} else {
overlay_builder = overlay_builder.layer(layer);
}
}
match (upper_dir, staging_dir) {
(Some(upper), Some(staging)) => {
overlay_builder = overlay_builder.writable(upper).staging(staging);
}
(None, None) => {
overlay_builder = overlay_builder.read_only();
}
_ => {
return Err(msb_krun::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"overlay rootfs: upper_dir and staging_dir must both be set or both be omitted",
)));
}
}
overlay_builder.build().map_err(msb_krun::Error::Io)
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use microsandbox_utils::index::IndexBuilder;
use tempfile::tempdir;
use super::{
append_block_root_env, build_overlay_rootfs, prepend_scripts_path, validate_disk_format,
};
#[test]
fn test_build_overlay_rootfs_rejects_mismatched_upper_staging() {
let temp = tempdir().unwrap();
let lower = create_dir(temp.path(), "lower.extracted");
let staging = create_dir(temp.path(), "staging");
match build_overlay_rootfs(&[lower.clone()], None, Some(&staging)) {
Ok(_) => panic!("expected mismatched upper/staging to be rejected"),
Err(err) => assert!(err.to_string().contains("both be set or both be omitted")),
}
let upper = create_dir(temp.path(), "rw");
match build_overlay_rootfs(&[lower], Some(&upper), None) {
Ok(_) => panic!("expected mismatched upper/staging to be rejected"),
Err(err) => assert!(err.to_string().contains("both be set or both be omitted")),
}
}
#[test]
fn test_build_overlay_rootfs_read_only() {
let temp = tempdir().unwrap();
let lower = create_dir(temp.path(), "lower.extracted");
build_overlay_rootfs(&[lower], None, None).unwrap();
}
#[test]
fn test_build_overlay_rootfs_accepts_single_lower_without_index() {
let temp = tempdir().unwrap();
let lower = create_dir(temp.path(), "lower.extracted");
let upper = create_dir(temp.path(), "rw");
let staging = create_dir(temp.path(), "staging");
assert!(build_overlay_rootfs(&[lower], Some(&upper), Some(&staging)).is_ok());
}
#[test]
fn test_build_overlay_rootfs_accepts_single_lower_with_conventional_index() {
let temp = tempdir().unwrap();
let lower = create_dir(temp.path(), "lower.extracted");
let upper = create_dir(temp.path(), "rw");
let staging = create_dir(temp.path(), "staging");
let index_path = lower.with_extension("index");
let index = IndexBuilder::new()
.dir("")
.file("", "hello.txt", 0o644)
.build();
std::fs::write(&index_path, index).unwrap();
assert!(build_overlay_rootfs(&[lower], Some(&upper), Some(&staging)).is_ok());
}
#[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()]);
}
#[test]
fn test_build_overlay_rootfs_falls_back_when_conventional_index_is_corrupt() {
let temp = tempdir().unwrap();
let lower = create_dir(temp.path(), "lower.extracted");
let upper = create_dir(temp.path(), "rw");
let staging = create_dir(temp.path(), "staging");
let index_path = lower.with_extension("index");
std::fs::write(&index_path, b"definitely not a valid index").unwrap();
assert!(build_overlay_rootfs(&[lower], Some(&upper), Some(&staging)).is_ok());
}
fn create_dir(root: &Path, name: &str) -> PathBuf {
let path = root.join(name);
std::fs::create_dir_all(&path).unwrap();
path
}
}