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";
const HASH_BUF_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct RootfsConfig {
pub template_dir: PathBuf,
pub clones_dir: PathBuf,
}
impl Default for RootfsConfig {
fn default() -> Self {
Self {
template_dir: PathBuf::from(DEFAULT_TEMPLATE_DIR),
clones_dir: PathBuf::from(DEFAULT_CLONES_DIR),
}
}
}
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);
Self {
template_dir,
clones_dir,
}
}
}
#[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,
}
#[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> {
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 {
return Ok(VmRootfs {
vm_id: safe_id,
stack: stack.name,
path: clone_path,
source_sha256: stamp,
});
}
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(&stack.template_path, &clone_path)?;
write_stamp(&stamp_path, &stack.sha256)?;
Ok(VmRootfs {
vm_id: safe_id,
stack: stack.name,
path: clone_path,
source_sha256: stack.sha256,
})
}
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 clone_file(source: &Path, dest: &Path) -> VmRuntimeResult<()> {
if try_reflink(source, dest).is_ok() {
return Ok(());
}
let _ = fs::remove_file(dest);
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 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) {
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(),
};
(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"),
};
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);
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);
}
#[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();
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");
}
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"));
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"),
}
}
}
#[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));
}
}