use std::{
ffi::CString,
fs,
net::Ipv4Addr,
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
};
use getset::Getters;
use typed_path::Utf8UnixPathBuf;
use crate::{
config::{EnvPair, PathPair, PortPair},
utils, InvalidMicroVMConfigError, MonocoreError, MonocoreResult,
};
use super::{ffi, LinuxRlimit, MicroVmBuilder, MicroVmConfigBuilder};
use crate::config::validate::normalize_path;
const VIRTIOFS_TAG_PREFIX: &str = "virtiofs";
#[derive(Debug, Getters)]
pub struct MicroVm {
ctx_id: u32,
#[get = "pub with_prefix"]
config: MicroVmConfig,
}
#[derive(Debug)]
pub struct MicroVmConfig {
pub log_level: LogLevel,
pub root_path: PathBuf,
pub num_vcpus: u8,
pub ram_mib: u32,
pub mapped_dirs: Vec<PathPair>,
pub port_map: Vec<PortPair>,
pub rlimits: Vec<LinuxRlimit>,
pub workdir_path: Option<Utf8UnixPathBuf>,
pub exec_path: Option<Utf8UnixPathBuf>,
pub args: Vec<String>,
pub env: Vec<EnvPair>,
pub console_output: Option<Utf8UnixPathBuf>,
pub assigned_ip: Option<Ipv4Addr>,
pub local_only: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[repr(u32)]
pub enum LogLevel {
#[default]
Off = 0,
Error = 1,
Warn = 2,
Info = 3,
Debug = 4,
Trace = 5,
}
impl MicroVm {
pub fn from_config(config: MicroVmConfig) -> MonocoreResult<Self> {
let ctx_id = Self::create_ctx();
config.validate()?;
Self::apply_config(ctx_id, &config);
Ok(Self { ctx_id, config })
}
pub fn builder() -> MicroVmBuilder<(), ()> {
MicroVmBuilder::default()
}
pub fn start(&self) -> MonocoreResult<i32> {
let ctx_id = self.ctx_id;
let status = unsafe { ffi::krun_start_enter(ctx_id) };
if status < 0 {
tracing::error!("Failed to start micro VM: {}", status);
return Err(MonocoreError::StartVmFailed(status));
}
tracing::info!("Micro VM exited with status: {}", status);
Ok(status)
}
fn create_ctx() -> u32 {
let ctx_id = unsafe { ffi::krun_create_ctx() };
assert!(ctx_id >= 0, "Failed to create micro VM context: {}", ctx_id);
ctx_id as u32
}
fn update_rootfs_fstab(root_path: &Path, mapped_dirs: &[PathPair]) -> MonocoreResult<()> {
let fstab_path = root_path.join("etc/fstab");
if let Some(parent) = fstab_path.parent() {
fs::create_dir_all(parent)?;
}
let mut fstab_content = if fstab_path.exists() {
fs::read_to_string(&fstab_path)?
} else {
String::new()
};
if fstab_content.is_empty() {
fstab_content.push_str(
"# /etc/fstab: static file system information.\n\
# <file system>\t<mount point>\t<type>\t<options>\t<dump>\t<pass>\n",
);
}
for (idx, dir) in mapped_dirs.iter().enumerate() {
let tag = format!("{}_{}", VIRTIOFS_TAG_PREFIX, idx);
let guest_path = dir.get_guest();
fstab_content.push_str(&format!(
"{}\t{}\tvirtiofs\tdefaults\t0\t0\n",
tag, guest_path
));
let guest_path_str = guest_path.as_str();
let relative_path = guest_path_str.strip_prefix('/').unwrap_or(guest_path_str);
let mount_point = root_path.join(relative_path);
fs::create_dir_all(mount_point)?;
}
fs::write(&fstab_path, fstab_content)?;
let perms = fs::metadata(&fstab_path)?.permissions();
let mut new_perms = perms;
new_perms.set_mode(0o644);
fs::set_permissions(&fstab_path, new_perms)?;
Ok(())
}
fn apply_config(ctx_id: u32, config: &MicroVmConfig) {
unsafe {
let status = ffi::krun_set_log_level(config.log_level as u32);
assert!(status >= 0, "Failed to set log level: {}", status);
}
unsafe {
let status = ffi::krun_set_vm_config(ctx_id, config.num_vcpus, config.ram_mib);
assert!(status >= 0, "Failed to set VM config: {}", status);
}
let c_root_path = CString::new(config.root_path.to_str().unwrap().as_bytes()).unwrap();
unsafe {
let status = ffi::krun_set_root(ctx_id, c_root_path.as_ptr());
assert!(status >= 0, "Failed to set root path: {}", status);
}
let root_path = &config.root_path;
let mapped_dirs = &config.mapped_dirs;
if let Err(e) = Self::update_rootfs_fstab(root_path, mapped_dirs) {
tracing::error!("Failed to update rootfs fstab: {}", e);
panic!("Failed to update rootfs fstab: {}", e);
}
for (idx, dir) in mapped_dirs.iter().enumerate() {
let tag = CString::new(format!("{}_{}", VIRTIOFS_TAG_PREFIX, idx)).unwrap();
let host_path = CString::new(dir.get_host().to_string().as_bytes()).unwrap();
unsafe {
let status = ffi::krun_add_virtiofs(ctx_id, tag.as_ptr(), host_path.as_ptr());
assert!(status >= 0, "Failed to add mapped directory: {}", status);
}
}
let c_port_map: Vec<_> = config
.port_map
.iter()
.map(|p| CString::new(p.to_string()).unwrap())
.collect();
let c_port_map_ptrs = utils::to_null_terminated_c_array(&c_port_map);
unsafe {
let status = ffi::krun_set_port_map(ctx_id, c_port_map_ptrs.as_ptr());
assert!(status >= 0, "Failed to set port map: {}", status);
}
if !config.rlimits.is_empty() {
let c_rlimits: Vec<_> = config
.rlimits
.iter()
.map(|s| CString::new(s.to_string()).unwrap())
.collect();
let c_rlimits_ptrs = utils::to_null_terminated_c_array(&c_rlimits);
unsafe {
let status = ffi::krun_set_rlimits(ctx_id, c_rlimits_ptrs.as_ptr());
assert!(status >= 0, "Failed to set resource limits: {}", status);
}
}
if let Some(workdir) = &config.workdir_path {
let c_workdir = CString::new(workdir.to_string().as_bytes()).unwrap();
unsafe {
let status = ffi::krun_set_workdir(ctx_id, c_workdir.as_ptr());
assert!(status >= 0, "Failed to set working directory: {}", status);
}
}
if let Some(exec_path) = &config.exec_path {
let c_exec_path = CString::new(exec_path.to_string().as_bytes()).unwrap();
let c_argv: Vec<_> = config
.args
.iter()
.map(|s| CString::new(s.as_str()).unwrap())
.collect();
let c_argv_ptrs = utils::to_null_terminated_c_array(&c_argv);
let c_env: Vec<_> = config
.env
.iter()
.map(|s| CString::new(s.to_string()).unwrap())
.collect();
let c_env_ptrs = utils::to_null_terminated_c_array(&c_env);
unsafe {
let status = ffi::krun_set_exec(
ctx_id,
c_exec_path.as_ptr(),
c_argv_ptrs.as_ptr(),
c_env_ptrs.as_ptr(),
);
assert!(
status >= 0,
"Failed to set executable configuration: {}",
status
);
}
} else {
let c_env: Vec<_> = config
.env
.iter()
.map(|s| CString::new(s.to_string()).unwrap())
.collect();
let c_env_ptrs = utils::to_null_terminated_c_array(&c_env);
unsafe {
let status = ffi::krun_set_env(ctx_id, c_env_ptrs.as_ptr());
assert!(
status >= 0,
"Failed to set environment variables: {}",
status
);
}
}
if let Some(console_output) = &config.console_output {
let c_console_output = CString::new(console_output.to_string().as_bytes()).unwrap();
unsafe {
let status = ffi::krun_set_console_output(ctx_id, c_console_output.as_ptr());
assert!(status >= 0, "Failed to set console output: {}", status);
}
}
if let Some(assigned_ip) = &config.assigned_ip {
let ip_str = assigned_ip.to_string();
let c_assigned_ip = CString::new(ip_str).unwrap();
unsafe {
let status = ffi::krun_set_tsi_rewrite_ip(ctx_id, c_assigned_ip.as_ptr());
assert!(status >= 0, "Failed to set assigned IP: {}", status);
}
}
unsafe {
let status = ffi::krun_enable_tsi_local_only(ctx_id, config.local_only);
assert!(status >= 0, "Failed to set local only mode: {}", status);
}
}
}
impl MicroVmConfig {
pub fn builder() -> MicroVmConfigBuilder<(), ()> {
MicroVmConfigBuilder::default()
}
fn validate_guest_paths(mapped_dirs: &[PathPair]) -> MonocoreResult<()> {
if mapped_dirs.len() <= 1 {
return Ok(());
}
let normalized_paths: Vec<_> = mapped_dirs
.iter()
.map(|dir| normalize_path(dir.get_guest().as_str(), true))
.collect::<Result<Vec<_>, _>>()?;
for i in 0..normalized_paths.len() {
let path1 = &normalized_paths[i];
for path2 in &normalized_paths[i + 1..] {
if utils::paths_overlap(path1, path2) {
return Err(MonocoreError::InvalidMicroVMConfig(
InvalidMicroVMConfigError::ConflictingGuestPaths(
path1.clone(),
path2.clone(),
),
));
}
}
}
Ok(())
}
pub fn validate(&self) -> MonocoreResult<()> {
if !self.root_path.exists() {
return Err(MonocoreError::InvalidMicroVMConfig(
InvalidMicroVMConfigError::RootPathDoesNotExist(
self.root_path.to_str().unwrap().into(),
),
));
}
for dir in &self.mapped_dirs {
let host_path = PathBuf::from(dir.get_host().as_str());
if !host_path.exists() {
return Err(MonocoreError::InvalidMicroVMConfig(
InvalidMicroVMConfigError::HostPathDoesNotExist(
host_path.to_str().unwrap().into(),
),
));
}
}
if self.num_vcpus == 0 {
return Err(MonocoreError::InvalidMicroVMConfig(
InvalidMicroVMConfigError::NumVCPUsIsZero,
));
}
if self.ram_mib == 0 {
return Err(MonocoreError::InvalidMicroVMConfig(
InvalidMicroVMConfigError::RamIsZero,
));
}
if let Some(exec_path) = &self.exec_path {
Self::validate_command_line(exec_path.as_ref())?;
}
for arg in &self.args {
Self::validate_command_line(arg)?;
}
Self::validate_guest_paths(&self.mapped_dirs)?;
Ok(())
}
pub fn validate_command_line(s: &str) -> MonocoreResult<()> {
fn valid_char(c: char) -> bool {
matches!(c, ' '..='~')
}
if s.chars().all(valid_char) {
Ok(())
} else {
Err(MonocoreError::InvalidMicroVMConfig(
InvalidMicroVMConfigError::InvalidCommandLineString(s.to_string()),
))
}
}
}
impl Drop for MicroVm {
fn drop(&mut self) {
unsafe { ffi::krun_free_ctx(self.ctx_id) };
}
}
#[cfg(test)]
mod tests {
use crate::config::DEFAULT_NUM_VCPUS;
use super::*;
use std::{os::unix::fs::PermissionsExt, path::PathBuf};
use tempfile::TempDir;
#[test]
fn test_microvm_config_builder() {
let config = MicroVmConfig::builder()
.log_level(LogLevel::Info)
.root_path(PathBuf::from("/tmp"))
.ram_mib(512)
.build();
assert!(config.log_level == LogLevel::Info);
assert_eq!(config.root_path, PathBuf::from("/tmp"));
assert_eq!(config.ram_mib, 512);
assert_eq!(config.num_vcpus, DEFAULT_NUM_VCPUS);
}
#[test]
fn test_microvm_config_validation_success() {
let temp_dir = TempDir::new().unwrap();
let config = MicroVmConfig::builder()
.log_level(LogLevel::Info)
.root_path(temp_dir.path().to_path_buf())
.ram_mib(512)
.build();
assert!(config.validate().is_ok());
}
#[test]
fn test_microvm_config_validation_failure_root_path() {
let config = MicroVmConfig::builder()
.log_level(LogLevel::Info)
.root_path(PathBuf::from("/non/existent/path"))
.ram_mib(512)
.build();
assert!(matches!(
config.validate(),
Err(MonocoreError::InvalidMicroVMConfig(
InvalidMicroVMConfigError::RootPathDoesNotExist(_)
))
));
}
#[test]
fn test_microvm_config_validation_failure_zero_ram() {
let temp_dir = TempDir::new().unwrap();
let config = MicroVmConfig::builder()
.log_level(LogLevel::Info)
.root_path(temp_dir.path().to_path_buf())
.ram_mib(0)
.build();
assert!(matches!(
config.validate(),
Err(MonocoreError::InvalidMicroVMConfig(
InvalidMicroVMConfigError::RamIsZero
))
));
}
#[test]
fn test_validate_command_line_valid_strings() {
assert!(MicroVmConfig::validate_command_line("hello").is_ok());
assert!(MicroVmConfig::validate_command_line("hello world").is_ok());
assert!(MicroVmConfig::validate_command_line("Hello, World!").is_ok());
assert!(MicroVmConfig::validate_command_line(" ").is_ok()); assert!(MicroVmConfig::validate_command_line("~").is_ok());
assert!(MicroVmConfig::validate_command_line("!@#$%^&*()").is_ok());
assert!(MicroVmConfig::validate_command_line("path/to/file").is_ok());
assert!(MicroVmConfig::validate_command_line("user-name_123").is_ok());
}
#[test]
fn test_validate_command_line_invalid_strings() {
assert!(MicroVmConfig::validate_command_line("\n").is_err()); assert!(MicroVmConfig::validate_command_line("\t").is_err()); assert!(MicroVmConfig::validate_command_line("\r").is_err()); assert!(MicroVmConfig::validate_command_line("\x1B").is_err());
assert!(MicroVmConfig::validate_command_line("hello🌎").is_err()); assert!(MicroVmConfig::validate_command_line("über").is_err()); assert!(MicroVmConfig::validate_command_line("café").is_err()); assert!(MicroVmConfig::validate_command_line("ä½ å¥½").is_err());
assert!(MicroVmConfig::validate_command_line("hello\nworld").is_err());
assert!(MicroVmConfig::validate_command_line("path/to/file\0").is_err()); assert!(MicroVmConfig::validate_command_line("hello\x7F").is_err()); }
#[test]
fn test_validate_command_line_in_config() {
let temp_dir = TempDir::new().unwrap();
let config = MicroVmConfig::builder()
.root_path(temp_dir.path().to_path_buf())
.ram_mib(512)
.exec_path("/bin/hello\nworld")
.build();
assert!(matches!(
config.validate(),
Err(MonocoreError::InvalidMicroVMConfig(
InvalidMicroVMConfigError::InvalidCommandLineString(_)
))
));
let config = MicroVmConfig::builder()
.root_path(temp_dir.path().to_path_buf())
.ram_mib(512)
.exec_path("/bin/echo")
.args(["hello\tworld"])
.build();
assert!(matches!(
config.validate(),
Err(MonocoreError::InvalidMicroVMConfig(
InvalidMicroVMConfigError::InvalidCommandLineString(_)
))
));
}
#[test]
fn test_update_rootfs_fstab() -> anyhow::Result<()> {
let root_dir = TempDir::new()?;
let root_path = root_dir.path();
let host_dir = TempDir::new()?;
let host_data = host_dir.path().join("data");
let host_config = host_dir.path().join("config");
let host_app = host_dir.path().join("app");
fs::create_dir_all(&host_data)?;
fs::create_dir_all(&host_config)?;
fs::create_dir_all(&host_app)?;
let mapped_dirs = vec![
format!("{}:/container/data", host_data.display()).parse::<PathPair>()?,
format!("{}:/etc/app/config", host_config.display()).parse::<PathPair>()?,
format!("{}:/app", host_app.display()).parse::<PathPair>()?,
];
MicroVm::update_rootfs_fstab(root_path, &mapped_dirs)?;
let fstab_path = root_path.join("etc/fstab");
assert!(fstab_path.exists());
let fstab_content = fs::read_to_string(&fstab_path)?;
assert!(fstab_content.contains("# /etc/fstab: static file system information"));
assert!(fstab_content
.contains("<file system>\t<mount point>\t<type>\t<options>\t<dump>\t<pass>"));
assert!(fstab_content.contains("virtiofs_0\t/container/data\tvirtiofs\tdefaults\t0\t0"));
assert!(fstab_content.contains("virtiofs_1\t/etc/app/config\tvirtiofs\tdefaults\t0\t0"));
assert!(fstab_content.contains("virtiofs_2\t/app\tvirtiofs\tdefaults\t0\t0"));
assert!(root_path.join("container/data").exists());
assert!(root_path.join("etc/app/config").exists());
assert!(root_path.join("app").exists());
let perms = fs::metadata(&fstab_path)?.permissions();
assert_eq!(perms.mode() & 0o777, 0o644);
let host_logs = host_dir.path().join("logs");
fs::create_dir_all(&host_logs)?;
let new_mapped_dirs = vec![
format!("{}:/container/data", host_data.display()).parse::<PathPair>()?, format!("{}:/var/log", host_logs.display()).parse::<PathPair>()?, ];
MicroVm::update_rootfs_fstab(root_path, &new_mapped_dirs)?;
let updated_content = fs::read_to_string(&fstab_path)?;
assert!(updated_content.contains("virtiofs_0\t/container/data\tvirtiofs\tdefaults\t0\t0"));
assert!(updated_content.contains("virtiofs_1\t/var/log\tvirtiofs\tdefaults\t0\t0"));
assert!(root_path.join("var/log").exists());
Ok(())
}
#[test]
fn test_update_rootfs_fstab_permission_errors() -> anyhow::Result<()> {
if std::env::var("CI").is_ok() {
println!("Skipping permission test in CI environment");
return Ok(());
}
let readonly_dir = TempDir::new()?;
let readonly_path = readonly_dir.path();
let etc_path = readonly_path.join("etc");
fs::create_dir_all(&etc_path)?;
let mut perms = fs::metadata(&etc_path)?.permissions();
perms.set_mode(0o400); fs::set_permissions(&etc_path, perms)?;
let actual_perms = fs::metadata(&etc_path)?.permissions();
println!("Set /etc permissions to: {:o}", actual_perms.mode());
let host_dir = TempDir::new()?;
let host_path = host_dir.path().join("test");
fs::create_dir_all(&host_path)?;
let mapped_dirs =
vec![format!("{}:/container/data", host_path.display()).parse::<PathPair>()?];
let result = MicroVm::update_rootfs_fstab(readonly_path, &mapped_dirs);
if result.is_ok() {
println!("Warning: Write succeeded despite read-only permissions");
println!(
"Current /etc permissions: {:o}",
fs::metadata(&etc_path)?.permissions().mode()
);
if etc_path.join("fstab").exists() {
println!(
"fstab file was created with permissions: {:o}",
fs::metadata(etc_path.join("fstab"))?.permissions().mode()
);
}
}
assert!(
result.is_err(),
"Expected error when writing fstab to read-only /etc directory. \
Current /etc permissions: {:o}",
fs::metadata(&etc_path)?.permissions().mode()
);
assert!(matches!(result.unwrap_err(), MonocoreError::Io(_)));
Ok(())
}
#[test]
fn test_validate_guest_paths() -> anyhow::Result<()> {
let valid_paths = vec![
"/app".parse::<PathPair>()?,
"/data".parse()?,
"/var/log".parse()?,
"/etc/config".parse()?,
];
assert!(MicroVmConfig::validate_guest_paths(&valid_paths).is_ok());
let conflicting_paths = vec![
"/app".parse()?,
"/data".parse()?,
"/app".parse()?, ];
assert!(MicroVmConfig::validate_guest_paths(&conflicting_paths).is_err());
let subset_paths = vec![
"/app".parse()?,
"/app/data".parse()?, "/var/log".parse()?,
];
assert!(MicroVmConfig::validate_guest_paths(&subset_paths).is_err());
let parent_paths = vec![
"/var/log".parse()?,
"/var".parse()?, "/etc".parse()?,
];
assert!(MicroVmConfig::validate_guest_paths(&parent_paths).is_err());
let unnormalized_paths = vec![
"/app/./data".parse()?,
"/var/log".parse()?,
"/etc//config".parse()?,
];
assert!(MicroVmConfig::validate_guest_paths(&unnormalized_paths).is_ok());
let normalized_conflicts = vec![
"/app/./data".parse()?,
"/app/data/".parse()?, "/var/log".parse()?,
];
assert!(MicroVmConfig::validate_guest_paths(&normalized_conflicts).is_err());
Ok(())
}
#[test]
fn test_microvm_config_validation_with_guest_paths() -> anyhow::Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let host_dir1 = temp_dir.path().join("dir1");
let host_dir2 = temp_dir.path().join("dir2");
std::fs::create_dir_all(&host_dir1)?;
std::fs::create_dir_all(&host_dir2)?;
let valid_config = MicroVmConfig::builder()
.root_path(temp_dir.path())
.ram_mib(1024)
.mapped_dirs([
format!("{}:/app", host_dir1.display()).parse()?,
format!("{}:/data", host_dir2.display()).parse()?,
])
.build();
assert!(valid_config.validate().is_ok());
let invalid_config = MicroVmConfig::builder()
.root_path(temp_dir.path())
.ram_mib(1024)
.mapped_dirs([
format!("{}:/app/data", host_dir1.display()).parse()?,
format!("{}:/app", host_dir2.display()).parse()?,
])
.build();
assert!(matches!(
invalid_config.validate(),
Err(MonocoreError::InvalidMicroVMConfig(
InvalidMicroVMConfigError::ConflictingGuestPaths(_, _)
))
));
Ok(())
}
}