use std::collections::HashMap;
use std::fs;
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
use std::time::SystemTime;
use sha2::{Digest, Sha256};
use crate::error::{VmRuntimeError, VmRuntimeResult};
pub const DEFAULT_TEMPLATE_DIR: &str = "/var/lib/microvm/rootfs-templates";
pub const DEFAULT_CLONES_DIR: &str = "/var/lib/microvm/rootfs";
pub const TEMPLATE_ROOTFS_FILE: &str = "rootfs.ext4";
pub const CLONE_ROOTFS_FILE: &str = "rootfs.ext4";
pub const CLONE_STAMP_FILE: &str = "rootfs.ext4.sha256";
pub const CLONE_SIZE_STAMP_FILE: &str = "rootfs.ext4.size";
const RESIZE2FS_BIN_ENV: &str = "MICROVM_RESIZE2FS_BIN";
pub const DEFAULT_RESIZE2FS_BIN: &str = "resize2fs";
const HASH_BUF_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct RootfsConfig {
pub template_dir: PathBuf,
pub clones_dir: PathBuf,
pub resize2fs_bin: PathBuf,
}
impl Default for RootfsConfig {
fn default() -> Self {
Self {
template_dir: PathBuf::from(DEFAULT_TEMPLATE_DIR),
clones_dir: PathBuf::from(DEFAULT_CLONES_DIR),
resize2fs_bin: PathBuf::from(DEFAULT_RESIZE2FS_BIN),
}
}
}
impl RootfsConfig {
pub fn from_env() -> Self {
let defaults = Self::default();
let template_dir = std::env::var("MICROVM_ROOTFS_TEMPLATE_DIR")
.map(PathBuf::from)
.unwrap_or(defaults.template_dir);
let clones_dir = std::env::var("MICROVM_ROOTFS_CLONES_DIR")
.map(PathBuf::from)
.unwrap_or(defaults.clones_dir);
let resize2fs_bin = std::env::var(RESIZE2FS_BIN_ENV)
.map(PathBuf::from)
.unwrap_or(defaults.resize2fs_bin);
Self {
template_dir,
clones_dir,
resize2fs_bin,
}
}
}
#[derive(Debug, Clone)]
pub struct StackInfo {
pub name: String,
pub template_path: PathBuf,
pub size_bytes: u64,
pub sha256: String,
}
#[derive(Debug, Clone)]
pub struct VmRootfs {
pub vm_id: String,
pub stack: String,
pub path: PathBuf,
pub source_sha256: String,
pub size_bytes: u64,
}
#[derive(Debug, Default)]
struct HashCache {
inner: HashMap<(PathBuf, SystemTime), String>,
}
#[derive(Debug)]
pub struct RootfsRegistry {
config: RootfsConfig,
hash_cache: Mutex<HashCache>,
}
impl RootfsRegistry {
pub fn new(config: RootfsConfig) -> Self {
Self {
config,
hash_cache: Mutex::new(HashCache::default()),
}
}
pub fn from_env() -> Self {
Self::new(RootfsConfig::from_env())
}
pub fn config(&self) -> &RootfsConfig {
&self.config
}
pub fn stacks(&self) -> VmRuntimeResult<Vec<StackInfo>> {
let read = match fs::read_dir(&self.config.template_dir) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(VmRuntimeError::Rootfs(format!(
"read template dir {}: {e}",
self.config.template_dir.display()
)));
}
};
let mut out = Vec::new();
for entry in read {
let entry = entry.map_err(|e| {
VmRuntimeError::Rootfs(format!(
"iterate template dir {}: {e}",
self.config.template_dir.display()
))
})?;
let ftype = entry.file_type().map_err(|e| {
VmRuntimeError::Rootfs(format!(
"stat template entry {}: {e}",
entry.path().display()
))
})?;
if !ftype.is_dir() {
continue;
}
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
let template_path = entry.path().join(TEMPLATE_ROOTFS_FILE);
let meta = match fs::metadata(&template_path) {
Ok(m) if m.is_file() => m,
Ok(_) | Err(_) => continue,
};
let size_bytes = meta.len();
let sha256 = self.cached_hash(&template_path, &meta)?;
out.push(StackInfo {
name,
template_path,
size_bytes,
sha256,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn stack(&self, name: &str) -> VmRuntimeResult<Option<StackInfo>> {
let template_path = self
.config
.template_dir
.join(name)
.join(TEMPLATE_ROOTFS_FILE);
let meta = match fs::metadata(&template_path) {
Ok(m) if m.is_file() => m,
Ok(_) => return Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(VmRuntimeError::Rootfs(format!(
"stat template {}: {e}",
template_path.display()
)));
}
};
let sha256 = self.cached_hash(&template_path, &meta)?;
Ok(Some(StackInfo {
name: name.to_owned(),
template_path,
size_bytes: meta.len(),
sha256,
}))
}
pub fn clone_for_vm(&self, vm_id: &str, stack_name: &str) -> VmRuntimeResult<VmRootfs> {
self.clone_for_vm_inner(vm_id, stack_name, CloneMode::SharedOk)
}
fn clone_for_vm_inner(
&self,
vm_id: &str,
stack_name: &str,
mode: CloneMode,
) -> VmRuntimeResult<VmRootfs> {
let safe_id = safe_vm_id(vm_id);
if safe_id.is_empty() {
return Err(VmRuntimeError::Rootfs(format!(
"vm_id '{vm_id}' sanitises to an empty string"
)));
}
let stack = self.stack(stack_name)?.ok_or_else(|| {
VmRuntimeError::Rootfs(format!(
"stack '{stack_name}' not found under {}",
self.config.template_dir.display()
))
})?;
let vm_dir = self.config.clones_dir.join(&safe_id);
let clone_path = vm_dir.join(CLONE_ROOTFS_FILE);
let stamp_path = vm_dir.join(CLONE_STAMP_FILE);
if clone_path.exists() {
let stamp = read_stamp(&stamp_path)?;
if stamp == stack.sha256 {
let size_bytes = current_size_bytes(&clone_path)?;
return Ok(VmRootfs {
vm_id: safe_id,
stack: stack.name,
path: clone_path,
source_sha256: stamp,
size_bytes,
});
}
return Err(VmRuntimeError::Rootfs(format!(
"vm '{safe_id}' clone stamp mismatch (stamp={stamp}, template={}) — release the VM before rotating stack '{}'",
stack.sha256, stack.name,
)));
}
fs::create_dir_all(&vm_dir).map_err(|e| {
VmRuntimeError::Rootfs(format!("create clone dir {}: {e}", vm_dir.display()))
})?;
clone_file_with_mode(&stack.template_path, &clone_path, mode)?;
write_stamp(&stamp_path, &stack.sha256)?;
Ok(VmRootfs {
vm_id: safe_id,
stack: stack.name,
path: clone_path,
source_sha256: stack.sha256,
size_bytes: stack.size_bytes,
})
}
pub fn clone_for_vm_with_size(
&self,
vm_id: &str,
stack_name: &str,
target_bytes: u64,
) -> VmRuntimeResult<VmRootfs> {
let safe_id = safe_vm_id(vm_id);
if safe_id.is_empty() {
return Err(VmRuntimeError::Rootfs(format!(
"vm_id '{vm_id}' sanitises to an empty string"
)));
}
let stack = self.stack(stack_name)?.ok_or_else(|| {
VmRuntimeError::Rootfs(format!(
"stack '{stack_name}' not found under {}",
self.config.template_dir.display()
))
})?;
if target_bytes < stack.size_bytes {
return Err(VmRuntimeError::Rootfs(format!(
"resize-down would lose data: target {target_bytes} < source {} for stack '{}'",
stack.size_bytes, stack.name,
)));
}
let vm_dir = self.config.clones_dir.join(&safe_id);
let size_stamp_path = vm_dir.join(CLONE_SIZE_STAMP_FILE);
if size_stamp_path.exists() {
let recorded = read_size_stamp(&size_stamp_path)?;
if recorded != target_bytes {
return Err(VmRuntimeError::Rootfs(format!(
"vm '{safe_id}' size stamp mismatch (stamp={recorded}, requested={target_bytes}) — release the VM before resizing"
)));
}
}
let clone_mode = if target_bytes == stack.size_bytes {
CloneMode::SharedOk
} else {
CloneMode::Independent
};
let mut rootfs = self.clone_for_vm_inner(vm_id, stack_name, clone_mode)?;
if target_bytes == stack.size_bytes {
if size_stamp_path.exists() {
rootfs.size_bytes = read_size_stamp(&size_stamp_path)?;
}
return Ok(rootfs);
}
if size_stamp_path.exists() {
rootfs.size_bytes = target_bytes;
return Ok(rootfs);
}
ensure_independent_inode(&stack.template_path, &rootfs.path)?;
resize_ext4_image_with_bin(&rootfs.path, target_bytes, &self.config.resize2fs_bin)?;
write_size_stamp(&size_stamp_path, target_bytes)?;
rootfs.size_bytes = target_bytes;
Ok(rootfs)
}
pub fn resize_ext4_image(path: &Path, target_bytes: u64) -> VmRuntimeResult<()> {
let bin = std::env::var(RESIZE2FS_BIN_ENV)
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_RESIZE2FS_BIN));
resize_ext4_image_with_bin(path, target_bytes, &bin)
}
pub fn release(&self, vm_id: &str) -> VmRuntimeResult<()> {
let safe_id = safe_vm_id(vm_id);
if safe_id.is_empty() {
return Err(VmRuntimeError::Rootfs(format!(
"vm_id '{vm_id}' sanitises to an empty string"
)));
}
let vm_dir = self.config.clones_dir.join(&safe_id);
match fs::remove_dir_all(&vm_dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(VmRuntimeError::Rootfs(format!(
"remove clone dir {}: {e}",
vm_dir.display()
))),
}
}
pub fn hash_file(path: &Path) -> VmRuntimeResult<String> {
let file = fs::File::open(path).map_err(|e| {
VmRuntimeError::Rootfs(format!("open {} for hashing: {e}", path.display()))
})?;
let mut reader = BufReader::with_capacity(HASH_BUF_BYTES, file);
let mut hasher = Sha256::new();
let mut buf = vec![0u8; HASH_BUF_BYTES];
loop {
let n = reader.read(&mut buf).map_err(|e| {
VmRuntimeError::Rootfs(format!("read {} for hashing: {e}", path.display()))
})?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex_encode(&hasher.finalize()))
}
fn cached_hash(&self, path: &Path, meta: &fs::Metadata) -> VmRuntimeResult<String> {
let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
let key = (path.to_path_buf(), mtime);
{
let cache = self
.hash_cache
.lock()
.map_err(|_| VmRuntimeError::StatePoisoned)?;
if let Some(v) = cache.inner.get(&key) {
return Ok(v.clone());
}
}
let digest = Self::hash_file(path)?;
let mut cache = self
.hash_cache
.lock()
.map_err(|_| VmRuntimeError::StatePoisoned)?;
cache.inner.insert(key, digest.clone());
Ok(digest)
}
}
pub fn safe_vm_id(vm_id: &str) -> String {
vm_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn resize_ext4_image_with_bin(
path: &Path,
target_bytes: u64,
resize2fs_bin: &Path,
) -> VmRuntimeResult<()> {
let meta = fs::metadata(path).map_err(|e| {
VmRuntimeError::Rootfs(format!("stat image {} for resize: {e}", path.display()))
})?;
let current = meta.len();
if target_bytes < current {
return Err(VmRuntimeError::Rootfs(format!(
"resize-down would lose data: target {target_bytes} < current {current} for {}",
path.display(),
)));
}
if target_bytes == current {
return Ok(());
}
{
let f = fs::OpenOptions::new().write(true).open(path).map_err(|e| {
VmRuntimeError::Rootfs(format!("open image {} for resize: {e}", path.display()))
})?;
f.set_len(target_bytes).map_err(|e| {
VmRuntimeError::Rootfs(format!(
"extend image {} to {target_bytes} bytes: {e}",
path.display(),
))
})?;
}
let output = Command::new(resize2fs_bin)
.arg("-f")
.arg("--")
.arg(path)
.output()
.map_err(|e| {
VmRuntimeError::Rootfs(format!(
"spawn {} on {}: {e}",
resize2fs_bin.display(),
path.display(),
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VmRuntimeError::Rootfs(format!(
"{} {} exit {}: {}",
resize2fs_bin.display(),
path.display(),
output.status,
stderr.trim(),
)));
}
Ok(())
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum CloneMode {
SharedOk,
Independent,
}
fn clone_file_with_mode(source: &Path, dest: &Path, mode: CloneMode) -> VmRuntimeResult<()> {
if try_reflink(source, dest).is_ok() {
return Ok(());
}
let _ = fs::remove_file(dest);
if mode == CloneMode::SharedOk {
if fs::hard_link(source, dest).is_ok() {
return Ok(());
}
let _ = fs::remove_file(dest);
}
fs::copy(source, dest).map_err(|e| {
VmRuntimeError::Rootfs(format!(
"copy {} -> {}: {e}",
source.display(),
dest.display()
))
})?;
Ok(())
}
fn try_reflink(source: &Path, dest: &Path) -> std::io::Result<()> {
let status = Command::new("cp")
.arg("--reflink=always")
.arg("--")
.arg(source)
.arg(dest)
.stderr(std::process::Stdio::null())
.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other(format!(
"cp --reflink=always exit {status}"
)))
}
}
fn read_stamp(path: &Path) -> VmRuntimeResult<String> {
let raw = fs::read_to_string(path)
.map_err(|e| VmRuntimeError::Rootfs(format!("read stamp {}: {e}", path.display())))?;
Ok(raw.trim().to_owned())
}
fn write_stamp(path: &Path, digest: &str) -> VmRuntimeResult<()> {
fs::write(path, digest)
.map_err(|e| VmRuntimeError::Rootfs(format!("write stamp {}: {e}", path.display())))
}
fn read_size_stamp(path: &Path) -> VmRuntimeResult<u64> {
let raw = fs::read_to_string(path)
.map_err(|e| VmRuntimeError::Rootfs(format!("read size stamp {}: {e}", path.display())))?;
raw.trim().parse::<u64>().map_err(|e| {
VmRuntimeError::Rootfs(format!(
"parse size stamp {} (contents={:?}): {e}",
path.display(),
raw,
))
})
}
fn write_size_stamp(path: &Path, target_bytes: u64) -> VmRuntimeResult<()> {
fs::write(path, target_bytes.to_string())
.map_err(|e| VmRuntimeError::Rootfs(format!("write size stamp {}: {e}", path.display())))
}
fn current_size_bytes(path: &Path) -> VmRuntimeResult<u64> {
fs::metadata(path)
.map(|m| m.len())
.map_err(|e| VmRuntimeError::Rootfs(format!("stat {} for size: {e}", path.display())))
}
fn ensure_independent_inode(source: &Path, clone: &Path) -> VmRuntimeResult<()> {
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::MetadataExt;
let src_meta = fs::metadata(source).map_err(|e| {
VmRuntimeError::Rootfs(format!("stat source {} for inode: {e}", source.display()))
})?;
let clone_meta = fs::metadata(clone).map_err(|e| {
VmRuntimeError::Rootfs(format!("stat clone {} for inode: {e}", clone.display()))
})?;
if src_meta.dev() != clone_meta.dev() || src_meta.ino() != clone_meta.ino() {
return Ok(());
}
let tmp = clone.with_extension("ext4.unlinkme");
let _ = fs::remove_file(&tmp);
fs::copy(clone, &tmp).map_err(|e| {
VmRuntimeError::Rootfs(format!(
"break hardlink via copy {} -> {}: {e}",
clone.display(),
tmp.display(),
))
})?;
fs::rename(&tmp, clone).map_err(|e| {
let _ = fs::remove_file(&tmp);
VmRuntimeError::Rootfs(format!(
"rename {} -> {}: {e}",
tmp.display(),
clone.display(),
))
})?;
}
#[cfg(not(target_os = "linux"))]
{
let _ = (source, clone);
}
Ok(())
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::time::{Duration, SystemTime};
use tempfile::TempDir;
fn registry(tempdir: &TempDir) -> (RootfsRegistry, PathBuf, PathBuf) {
registry_with_resize_bin(tempdir, PathBuf::from("true"))
}
fn registry_with_resize_bin(
tempdir: &TempDir,
resize2fs_bin: PathBuf,
) -> (RootfsRegistry, PathBuf, PathBuf) {
let template_dir = tempdir.path().join("templates");
let clones_dir = tempdir.path().join("clones");
fs::create_dir_all(&template_dir).unwrap();
let cfg = RootfsConfig {
template_dir: template_dir.clone(),
clones_dir: clones_dir.clone(),
resize2fs_bin,
};
(RootfsRegistry::new(cfg), template_dir, clones_dir)
}
fn write_template(template_dir: &Path, stack: &str, bytes: &[u8]) -> PathBuf {
let dir = template_dir.join(stack);
fs::create_dir_all(&dir).unwrap();
let path = dir.join(TEMPLATE_ROOTFS_FILE);
fs::write(&path, bytes).unwrap();
path
}
fn rewind_mtime(path: &Path, secs: u64) {
let now = SystemTime::now();
let earlier = now - Duration::from_secs(secs);
let f = fs::File::open(path).unwrap();
f.set_modified(earlier).unwrap();
}
#[test]
fn stacks_lists_all_subdirs_with_rootfs() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, _cdir) = registry(&tmp);
write_template(&tdir, "base", b"BASE-ROOTFS");
write_template(&tdir, "node-20", b"NODE-20-ROOTFS");
fs::create_dir_all(tdir.join("incomplete")).unwrap();
let stacks = reg.stacks().unwrap();
assert_eq!(stacks.len(), 2);
assert_eq!(stacks[0].name, "base");
assert_eq!(stacks[1].name, "node-20");
assert_eq!(stacks[0].size_bytes, b"BASE-ROOTFS".len() as u64);
assert_ne!(stacks[0].sha256, stacks[1].sha256);
}
#[test]
fn stacks_returns_empty_when_template_dir_missing() {
let tmp = TempDir::new().unwrap();
let cfg = RootfsConfig {
template_dir: tmp.path().join("does-not-exist"),
clones_dir: tmp.path().join("clones"),
resize2fs_bin: PathBuf::from(DEFAULT_RESIZE2FS_BIN),
};
let reg = RootfsRegistry::new(cfg);
assert!(reg.stacks().unwrap().is_empty());
}
#[test]
fn stack_by_name_returns_none_for_missing() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, _) = registry(&tmp);
write_template(&tdir, "base", b"x");
assert!(reg.stack("base").unwrap().is_some());
assert!(reg.stack("nope").unwrap().is_none());
}
#[test]
fn hash_cache_skips_rehash_when_mtime_unchanged() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, _) = registry(&tmp);
let path = write_template(&tdir, "base", b"first-bytes");
rewind_mtime(&path, 5);
let first = reg.stack("base").unwrap().unwrap().sha256;
let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
let mut f = fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&path)
.unwrap();
f.write_all(b"second-bytes-different-length").unwrap();
drop(f);
fs::File::open(&path)
.unwrap()
.set_modified(mtime_before)
.unwrap();
let second = reg.stack("base").unwrap().unwrap().sha256;
assert_eq!(first, second, "cache must hold while mtime unchanged");
}
#[test]
fn hash_cache_invalidates_when_mtime_advances() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, _) = registry(&tmp);
let path = write_template(&tdir, "base", b"first");
rewind_mtime(&path, 5);
let first = reg.stack("base").unwrap().unwrap().sha256;
fs::write(&path, b"second").unwrap();
let second = reg.stack("base").unwrap().unwrap().sha256;
assert_ne!(first, second);
}
#[test]
fn clone_creates_per_vm_copy_with_stamp() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, cdir) = registry(&tmp);
write_template(&tdir, "base", b"BASE-CONTENTS");
let live_hash = reg.stack("base").unwrap().unwrap().sha256;
let rootfs = reg.clone_for_vm("vm-1", "base").unwrap();
assert_eq!(rootfs.vm_id, "vm-1");
assert_eq!(rootfs.stack, "base");
assert_eq!(rootfs.path, cdir.join("vm-1").join(CLONE_ROOTFS_FILE));
assert_eq!(rootfs.source_sha256, live_hash);
assert_eq!(rootfs.size_bytes, b"BASE-CONTENTS".len() as u64);
let bytes = fs::read(&rootfs.path).unwrap();
assert_eq!(bytes, b"BASE-CONTENTS");
let stamp = fs::read_to_string(cdir.join("vm-1").join(CLONE_STAMP_FILE)).unwrap();
assert_eq!(stamp.trim(), live_hash);
assert!(!cdir.join("vm-1").join(CLONE_SIZE_STAMP_FILE).exists());
}
#[test]
fn clone_is_idempotent_with_matching_stamp() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, _) = registry(&tmp);
write_template(&tdir, "base", b"abc");
let a = reg.clone_for_vm("vm-1", "base").unwrap();
let b = reg.clone_for_vm("vm-1", "base").unwrap();
assert_eq!(a.path, b.path);
assert_eq!(a.source_sha256, b.source_sha256);
}
#[test]
fn clone_errors_when_stamp_mismatches_live_template() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, cdir) = registry(&tmp);
write_template(&tdir, "base", b"abc");
reg.clone_for_vm("vm-1", "base").unwrap();
fs::write(
cdir.join("vm-1").join(CLONE_STAMP_FILE),
"deadbeef".repeat(8),
)
.unwrap();
let err = reg.clone_for_vm("vm-1", "base").unwrap_err();
match err {
VmRuntimeError::Rootfs(msg) => {
assert!(
msg.contains("stamp mismatch"),
"expected stamp mismatch, got: {msg}"
);
}
other => panic!("expected Rootfs error, got: {other:?}"),
}
}
#[test]
fn clone_errors_when_stack_missing() {
let tmp = TempDir::new().unwrap();
let (reg, _tdir, _) = registry(&tmp);
let err = reg.clone_for_vm("vm-1", "ghost").unwrap_err();
assert!(matches!(err, VmRuntimeError::Rootfs(_)));
}
#[test]
fn clone_fallback_works_on_non_reflink_filesystem() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, _) = registry(&tmp);
write_template(&tdir, "base", b"fallback-must-succeed");
let rootfs = reg.clone_for_vm("vm-1", "base").unwrap();
let bytes = fs::read(&rootfs.path).unwrap();
assert_eq!(bytes, b"fallback-must-succeed");
}
#[test]
fn clone_sanitises_vm_id_in_path() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, cdir) = registry(&tmp);
write_template(&tdir, "base", b"x");
let rootfs = reg.clone_for_vm("vm/with:weird*chars", "base").unwrap();
assert_eq!(rootfs.vm_id, "vm_with_weird_chars");
assert!(rootfs.path.starts_with(cdir.join("vm_with_weird_chars")));
}
#[test]
fn clone_rejects_empty_sanitised_id() {
let tmp = TempDir::new().unwrap();
let (reg, _, _) = registry(&tmp);
let err = reg.clone_for_vm("", "base").unwrap_err();
assert!(matches!(err, VmRuntimeError::Rootfs(_)));
}
#[test]
fn release_removes_per_vm_dir_and_is_idempotent() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, cdir) = registry(&tmp);
write_template(&tdir, "base", b"x");
reg.clone_for_vm("vm-1", "base").unwrap();
assert!(cdir.join("vm-1").exists());
reg.release("vm-1").unwrap();
assert!(!cdir.join("vm-1").exists());
reg.release("vm-1").unwrap();
}
#[test]
fn hash_file_matches_standard_abc_test_vector() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("abc");
fs::write(&path, b"abc").unwrap();
let got = RootfsRegistry::hash_file(&path).unwrap();
assert_eq!(
got,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn hash_file_handles_empty_input() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("empty");
fs::write(&path, b"").unwrap();
let got = RootfsRegistry::hash_file(&path).unwrap();
assert_eq!(
got,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn hash_file_errors_on_missing_path() {
let err = RootfsRegistry::hash_file(Path::new("/nonexistent/path/to/nothing")).unwrap_err();
assert!(matches!(err, VmRuntimeError::Rootfs(_)));
}
#[test]
fn hash_file_handles_multi_chunk_input() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("zeros");
let f = fs::File::create(&path).unwrap();
f.set_len(10 * 1024 * 1024).unwrap();
let digest = RootfsRegistry::hash_file(&path).unwrap();
assert_eq!(
digest,
"e5b844cc57f57094ea4585e235f36c78c1cd222262bb89d53c94dcb4d6b3e55d"
);
}
#[test]
fn safe_vm_id_matches_adapter_convention() {
assert_eq!(safe_vm_id("vm-1"), "vm-1");
assert_eq!(safe_vm_id("vm_1"), "vm_1");
assert_eq!(safe_vm_id("VM1"), "VM1");
assert_eq!(safe_vm_id("vm/1:foo"), "vm_1_foo");
assert_eq!(safe_vm_id("a.b/c"), "a_b_c");
}
#[test]
fn config_from_env_picks_up_overrides() {
let saved_t = std::env::var("MICROVM_ROOTFS_TEMPLATE_DIR").ok();
let saved_c = std::env::var("MICROVM_ROOTFS_CLONES_DIR").ok();
let saved_r = std::env::var(RESIZE2FS_BIN_ENV).ok();
unsafe {
std::env::set_var("MICROVM_ROOTFS_TEMPLATE_DIR", "/tmp/mvm-test-templates");
std::env::set_var("MICROVM_ROOTFS_CLONES_DIR", "/tmp/mvm-test-clones");
std::env::set_var(RESIZE2FS_BIN_ENV, "/tmp/mvm-test-resize2fs");
}
let cfg = RootfsConfig::from_env();
assert_eq!(cfg.template_dir, PathBuf::from("/tmp/mvm-test-templates"));
assert_eq!(cfg.clones_dir, PathBuf::from("/tmp/mvm-test-clones"));
assert_eq!(cfg.resize2fs_bin, PathBuf::from("/tmp/mvm-test-resize2fs"));
unsafe {
match saved_t {
Some(v) => std::env::set_var("MICROVM_ROOTFS_TEMPLATE_DIR", v),
None => std::env::remove_var("MICROVM_ROOTFS_TEMPLATE_DIR"),
}
match saved_c {
Some(v) => std::env::set_var("MICROVM_ROOTFS_CLONES_DIR", v),
None => std::env::remove_var("MICROVM_ROOTFS_CLONES_DIR"),
}
match saved_r {
Some(v) => std::env::set_var(RESIZE2FS_BIN_ENV, v),
None => std::env::remove_var(RESIZE2FS_BIN_ENV),
}
}
}
#[test]
fn config_default_paths_match_documented_constants() {
let cfg = RootfsConfig::default();
assert_eq!(cfg.template_dir, PathBuf::from(DEFAULT_TEMPLATE_DIR));
assert_eq!(cfg.clones_dir, PathBuf::from(DEFAULT_CLONES_DIR));
assert_eq!(cfg.resize2fs_bin, PathBuf::from(DEFAULT_RESIZE2FS_BIN));
}
fn template_bytes(n: usize) -> Vec<u8> {
(0..n).map(|i| (i % 251) as u8).collect()
}
#[test]
fn clone_with_size_equal_to_source_is_no_op_fast_path() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, cdir) =
registry_with_resize_bin(&tmp, PathBuf::from("/nonexistent-resize2fs-bin"));
let src = template_bytes(4096);
write_template(&tdir, "base", &src);
let target = src.len() as u64;
let rootfs = reg.clone_for_vm_with_size("vm-1", "base", target).unwrap();
assert_eq!(rootfs.size_bytes, target);
assert!(!cdir.join("vm-1").join(CLONE_SIZE_STAMP_FILE).exists());
assert_eq!(fs::read(&rootfs.path).unwrap(), src);
}
#[test]
fn clone_with_size_smaller_than_source_errors_and_leaves_image_untouched() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, cdir) = registry(&tmp);
let src = template_bytes(8192);
write_template(&tdir, "base", &src);
let err = reg
.clone_for_vm_with_size("vm-1", "base", 4096)
.unwrap_err();
match err {
VmRuntimeError::Rootfs(msg) => assert!(
msg.contains("resize-down would lose data"),
"expected shrink message, got: {msg}"
),
other => panic!("expected Rootfs error, got: {other:?}"),
}
assert!(!cdir.join("vm-1").exists());
}
#[test]
fn clone_with_size_grows_image_and_writes_size_stamp() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, cdir) = registry(&tmp);
let src = template_bytes(4096);
write_template(&tdir, "base", &src);
let template_path = tdir.join("base").join(TEMPLATE_ROOTFS_FILE);
let template_len_before = fs::metadata(&template_path).unwrap().len();
let target = (src.len() * 4) as u64;
let rootfs = reg.clone_for_vm_with_size("vm-1", "base", target).unwrap();
assert_eq!(rootfs.size_bytes, target);
assert_eq!(fs::metadata(&rootfs.path).unwrap().len(), target);
assert_eq!(
fs::metadata(&template_path).unwrap().len(),
template_len_before,
);
let stamp = fs::read_to_string(cdir.join("vm-1").join(CLONE_SIZE_STAMP_FILE)).unwrap();
assert_eq!(stamp.trim().parse::<u64>().unwrap(), target);
let read = fs::read(&rootfs.path).unwrap();
assert_eq!(&read[..src.len()], &src[..]);
assert!(read[src.len()..].iter().all(|&b| b == 0));
}
#[test]
fn clone_with_size_breaks_pre_existing_hardlink_before_resize() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, _cdir) = registry(&tmp);
let src = template_bytes(4096);
write_template(&tdir, "base", &src);
let template_path = tdir.join("base").join(TEMPLATE_ROOTFS_FILE);
let first = reg.clone_for_vm("vm-1", "base").unwrap();
{
use std::os::unix::fs::MetadataExt;
let t = fs::metadata(&template_path).unwrap();
let c = fs::metadata(&first.path).unwrap();
assert_eq!(t.ino(), c.ino(), "test pre-condition: hardlinked clone");
}
let target = (src.len() * 2) as u64;
let resized = reg.clone_for_vm_with_size("vm-1", "base", target).unwrap();
assert_eq!(fs::metadata(&resized.path).unwrap().len(), target);
assert_eq!(
fs::metadata(&template_path).unwrap().len(),
src.len() as u64,
"template must not grow when a hardlinked clone is resized",
);
{
use std::os::unix::fs::MetadataExt;
let t = fs::metadata(&template_path).unwrap();
let c = fs::metadata(&resized.path).unwrap();
assert_ne!(t.ino(), c.ino(), "resize must break the hardlink");
}
}
#[test]
fn clone_with_size_is_idempotent_and_does_not_rewrite_size_stamp() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, cdir) = registry(&tmp);
let src = template_bytes(4096);
write_template(&tdir, "base", &src);
let target = (src.len() * 2) as u64;
let first = reg.clone_for_vm_with_size("vm-1", "base", target).unwrap();
let stamp_path = cdir.join("vm-1").join(CLONE_SIZE_STAMP_FILE);
let first_mtime = fs::metadata(&stamp_path).unwrap().modified().unwrap();
let backdated = first_mtime - Duration::from_secs(5);
fs::File::open(&stamp_path)
.unwrap()
.set_modified(backdated)
.unwrap();
let second = reg.clone_for_vm_with_size("vm-1", "base", target).unwrap();
assert_eq!(first.size_bytes, second.size_bytes);
assert_eq!(first.path, second.path);
let second_mtime = fs::metadata(&stamp_path).unwrap().modified().unwrap();
assert_eq!(
backdated, second_mtime,
"second call must not rewrite the size stamp"
);
}
#[test]
fn clone_with_size_rejects_different_target_on_second_call() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, cdir) = registry(&tmp);
let src = template_bytes(4096);
write_template(&tdir, "base", &src);
let first_target = (src.len() * 2) as u64;
reg.clone_for_vm_with_size("vm-1", "base", first_target)
.unwrap();
let clone_path = cdir.join("vm-1").join(CLONE_ROOTFS_FILE);
let before_meta = fs::metadata(&clone_path).unwrap();
let before_len = before_meta.len();
let before_mtime = before_meta.modified().unwrap();
let second_target = (src.len() * 3) as u64;
let err = reg
.clone_for_vm_with_size("vm-1", "base", second_target)
.unwrap_err();
match err {
VmRuntimeError::Rootfs(msg) => assert!(
msg.contains("size stamp mismatch"),
"expected size stamp mismatch, got: {msg}"
),
other => panic!("expected Rootfs error, got: {other:?}"),
}
let after_meta = fs::metadata(&clone_path).unwrap();
assert_eq!(before_len, after_meta.len());
assert_eq!(before_mtime, after_meta.modified().unwrap());
}
#[test]
fn clone_with_size_then_equal_to_source_passes_through_recorded_size() {
let tmp = TempDir::new().unwrap();
let (reg, tdir, _cdir) = registry(&tmp);
let src = template_bytes(4096);
write_template(&tdir, "base", &src);
reg.clone_for_vm_with_size("vm-1", "base", 8192).unwrap();
let err = reg
.clone_for_vm_with_size("vm-1", "base", src.len() as u64)
.unwrap_err();
match err {
VmRuntimeError::Rootfs(msg) => assert!(
msg.contains("size stamp mismatch"),
"expected size stamp mismatch, got: {msg}"
),
other => panic!("expected Rootfs error, got: {other:?}"),
}
}
#[test]
fn resize_ext4_image_with_bin_rejects_target_less_than_current() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("img.ext4");
fs::write(&path, vec![0u8; 8192]).unwrap();
let err = resize_ext4_image_with_bin(&path, 4096, Path::new("true")).unwrap_err();
match err {
VmRuntimeError::Rootfs(msg) => assert!(
msg.contains("resize-down would lose data"),
"expected shrink message, got: {msg}"
),
other => panic!("expected Rootfs error, got: {other:?}"),
}
assert_eq!(fs::metadata(&path).unwrap().len(), 8192);
}
#[test]
fn resize_ext4_image_with_bin_target_equal_to_current_is_no_op() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("img.ext4");
let payload = template_bytes(4096);
fs::write(&path, &payload).unwrap();
resize_ext4_image_with_bin(&path, 4096, Path::new("/nonexistent-resize2fs-bin")).unwrap();
assert_eq!(fs::read(&path).unwrap(), payload);
}
#[test]
fn resize_ext4_image_with_bin_extends_file_via_set_len() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("img.ext4");
let payload = template_bytes(4096);
fs::write(&path, &payload).unwrap();
resize_ext4_image_with_bin(&path, 16384, Path::new("true")).unwrap();
let meta = fs::metadata(&path).unwrap();
assert_eq!(meta.len(), 16384);
let bytes = fs::read(&path).unwrap();
assert_eq!(&bytes[..payload.len()], &payload[..]);
assert!(bytes[payload.len()..].iter().all(|&b| b == 0));
}
#[test]
fn resize_ext4_image_with_bin_surfaces_resize2fs_failure() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("img.ext4");
fs::write(&path, vec![0u8; 4096]).unwrap();
let err = resize_ext4_image_with_bin(&path, 8192, Path::new("false")).unwrap_err();
match err {
VmRuntimeError::Rootfs(msg) => assert!(
msg.contains("exit"),
"expected non-zero exit message, got: {msg}"
),
other => panic!("expected Rootfs error, got: {other:?}"),
}
assert_eq!(fs::metadata(&path).unwrap().len(), 8192);
}
#[test]
fn resize_ext4_image_with_bin_surfaces_spawn_failure() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("img.ext4");
fs::write(&path, vec![0u8; 4096]).unwrap();
let err = resize_ext4_image_with_bin(&path, 8192, Path::new("/nonexistent-resize2fs"))
.unwrap_err();
match err {
VmRuntimeError::Rootfs(msg) => assert!(
msg.contains("spawn"),
"expected spawn failure message, got: {msg}"
),
other => panic!("expected Rootfs error, got: {other:?}"),
}
}
#[test]
#[ignore = "requires e2fsprogs (mke2fs + resize2fs + dumpe2fs) on PATH"]
fn resize_ext4_image_integration_with_real_ext4() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("img.ext4");
let initial: u64 = 4 * 1024 * 1024;
let target: u64 = 8 * 1024 * 1024;
{
let f = fs::File::create(&path).unwrap();
f.set_len(initial).unwrap();
}
let mke2fs = Command::new("mke2fs")
.arg("-q")
.arg("-t")
.arg("ext4")
.arg("-F")
.arg(&path)
.output()
.expect("mke2fs not on PATH — install e2fsprogs");
assert!(
mke2fs.status.success(),
"mke2fs failed: {}",
String::from_utf8_lossy(&mke2fs.stderr),
);
RootfsRegistry::resize_ext4_image(&path, target).expect("resize2fs failed");
assert_eq!(fs::metadata(&path).unwrap().len(), target);
let dump = Command::new("dumpe2fs")
.arg("-h")
.arg(&path)
.output()
.expect("dumpe2fs not on PATH");
assert!(dump.status.success());
let stdout = String::from_utf8_lossy(&dump.stdout);
let blocks_line = stdout
.lines()
.find(|l| l.starts_with("Block count:"))
.expect("Block count missing from dumpe2fs output");
let blocks: u64 = blocks_line
.split_whitespace()
.last()
.unwrap()
.parse()
.unwrap();
assert_eq!(blocks, target / 4096);
}
}