use std::fs::{File, OpenOptions};
use std::io;
use std::os::fd::AsRawFd;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use crate::flock::{FlockMode, acquire_flock_with_timeout, try_flock};
use crate::vmm::disk_config::Filesystem;
const CACHE_SUFFIX: &str = "disk_templates";
const TEMPLATE_FILENAME: &str = "template.img";
const LOCK_DIR_NAME: &str = ".locks";
const FICLONE_IOCTL: libc::c_ulong = 0x4004_9409;
const TEMPLATE_LOCK_TIMEOUT: Duration = Duration::from_secs(600);
#[cfg(not(target_pointer_width = "64"))]
compile_error!(
"ktstr's disk-template f_type comparison requires a 64-bit \
target. On 32-bit Linux `__fsword_t` is `i32`; sign-extension \
of `BTRFS_SUPER_MAGIC` (bit 31 set) into u64 silently breaks \
the magic comparison and rejects valid btrfs cache directories. \
Porting to 32-bit requires casting through u32 to clear the \
high bits before widening to u64."
);
const BTRFS_SUPER_MAGIC: u64 = 0x9123_683e;
const XFS_SUPER_MAGIC: u64 = 0x5846_5342;
fn statfs_path(path: &Path) -> Result<libc::statfs> {
let cstr = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
.with_context(|| format!("path contains nul bytes: {path:?}"))?;
let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::statfs(cstr.as_ptr(), &mut buf) };
if rc != 0 {
let err = io::Error::last_os_error();
return Err(anyhow!("statfs({path:?}) failed: {err}"));
}
Ok(buf)
}
pub(crate) fn cache_root() -> Result<PathBuf> {
crate::cache::resolve_cache_root_with_suffix(CACHE_SUFFIX)
}
pub(crate) fn verify_cache_dir_supports_reflink(dir: &Path) -> Result<()> {
let mut probe: PathBuf = dir.to_path_buf();
loop {
if probe.exists() {
break;
}
match probe.parent() {
Some(p) => probe = p.to_path_buf(),
None => bail!(
"no existing ancestor of {dir:?} found while probing \
cache filesystem; cannot verify FICLONE support",
),
}
}
let buf = statfs_path(&probe).with_context(|| {
format!(
"cannot verify FICLONE support for cache directory {dir:?} \
(probed ancestor {probe:?})"
)
})?;
let fs_type = buf.f_type as u64;
if fs_type == BTRFS_SUPER_MAGIC || fs_type == XFS_SUPER_MAGIC {
return Ok(());
}
let probe_note = if probe == dir {
String::new()
} else {
format!(
" (no part of {dir:?} exists yet; the f_type was read from \
ancestor {probe:?} — once {dir:?} is created on that same \
filesystem the cache will inherit f_type=0x{fs_type:x}, \
so create the intermediate mount first if you intended a \
different filesystem)"
)
};
bail!(
"ktstr disk-template cache requires a btrfs or xfs filesystem \
for FICLONE-based per-test fan-out; cache directory {dir:?} \
lives on a filesystem whose statfs.f_type=0x{fs_type:x} (not \
btrfs=0x{btrfs:x}, not xfs=0x{xfs:x}).{probe_note} Set \
KTSTR_CACHE_DIR to a directory on a btrfs/xfs mount, or use \
Filesystem::Raw which does not need a reflink-capable cache.",
btrfs = BTRFS_SUPER_MAGIC,
xfs = XFS_SUPER_MAGIC,
);
}
pub(crate) fn template_cache_key(fs: Filesystem, capacity_bytes: u64, version_fp: &str) -> String {
let mib = capacity_bytes / (1024 * 1024);
let tag = fs.cache_tag();
format!("{tag}-{mib}m-{version_fp}")
}
const NOVERSION_FP: &str = "noversion";
fn mkfs_version_fingerprint_cache()
-> &'static std::sync::Mutex<std::collections::HashMap<PathBuf, String>> {
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<PathBuf, String>>,
> = std::sync::OnceLock::new();
CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
fn mkfs_version_fingerprint(mkfs_path: &Path) -> Result<String> {
if let Some(cached) = mkfs_version_fingerprint_cache()
.lock()
.expect("mkfs_version_fingerprint cache mutex poisoned")
.get(mkfs_path)
{
return Ok(cached.clone());
}
use sha2::Digest;
let output = std::process::Command::new(mkfs_path)
.arg("--version")
.output()
.with_context(|| format!("spawn {mkfs_path:?} --version for cache-key fingerprint"))?;
if output.stdout.is_empty() && output.stderr.is_empty() {
bail!(
"{mkfs_path:?} --version produced no output \
(stdout/stderr both empty, status={status:?}). Cannot \
fingerprint the binary for the disk-template cache \
key — the binary may be a stub or corrupted.",
status = output.status,
);
}
let mut hasher = sha2::Sha256::new();
hasher.update(&output.stdout);
hasher.update(&output.stderr);
let digest = hasher.finalize();
let fp = hex::encode(&digest[..8]);
mkfs_version_fingerprint_cache()
.lock()
.expect("mkfs_version_fingerprint cache mutex poisoned")
.insert(mkfs_path.to_path_buf(), fp.clone());
Ok(fp)
}
pub(crate) fn template_path_for_key(key: &str) -> Result<PathBuf> {
let root = cache_root()?;
Ok(root.join(key).join(TEMPLATE_FILENAME))
}
fn lock_path_for_key(key: &str) -> Result<PathBuf> {
let root = cache_root()?;
Ok(root.join(LOCK_DIR_NAME).join(format!("{key}.lock")))
}
pub(crate) fn lookup(key: &str) -> Result<Option<PathBuf>> {
let path = template_path_for_key(key)?;
match std::fs::metadata(&path) {
Ok(meta) if meta.is_file() => Ok(Some(path)),
Ok(_) => Ok(None),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("stat cached template {path:?}")),
}
}
pub(crate) fn store_atomic(key: &str, src_path: &Path) -> Result<PathBuf> {
let root = cache_root()?;
std::fs::create_dir_all(&root)
.with_context(|| format!("create disk-template cache root {root:?}"))?;
let final_dir = root.join(key);
if final_dir.exists() {
let _ = std::fs::remove_file(src_path);
return Ok(final_dir.join(TEMPLATE_FILENAME));
}
let src_buf = statfs_path(src_path)
.with_context(|| format!("statfs source {src_path:?} for cross-fs check"))?;
let dest_buf = statfs_path(&root)
.with_context(|| format!("statfs cache root {root:?} for cross-fs check"))?;
if src_buf.f_type != dest_buf.f_type || fsid_bytes(&src_buf) != fsid_bytes(&dest_buf) {
bail!(
"disk-template store_atomic: source {src_path:?} \
(f_type=0x{src_type:x}) and cache root {root:?} \
(f_type=0x{dest_type:x}) live on different filesystems. \
rename(2) would return EXDEV. Stage the template image \
on the cache filesystem before calling store_atomic.",
src_type = src_buf.f_type as u64,
dest_type = dest_buf.f_type as u64,
);
}
let staging = root.join(format!("{key}.tmp.{pid}", pid = std::process::id()));
if staging.exists() {
std::fs::remove_dir_all(&staging)
.with_context(|| format!("remove stale staging directory {staging:?}"))?;
}
std::fs::create_dir_all(&staging)
.with_context(|| format!("create staging directory {staging:?}"))?;
let staging_image = staging.join(TEMPLATE_FILENAME);
if let Err(e) = std::fs::rename(src_path, &staging_image) {
let _ = std::fs::remove_dir_all(&staging);
return Err(e).with_context(|| format!("rename {src_path:?} -> {staging_image:?}"));
}
if let Err(e) = std::fs::rename(&staging, &final_dir) {
let _ = std::fs::remove_dir_all(&staging);
return Err(e).with_context(|| {
format!("publish staging {staging:?} -> {final_dir:?} (cache key {key})",)
});
}
Ok(final_dir.join(TEMPLATE_FILENAME))
}
fn fsid_bytes(buf: &libc::statfs) -> [u8; std::mem::size_of::<libc::fsid_t>()] {
let mut out = [0u8; std::mem::size_of::<libc::fsid_t>()];
unsafe {
std::ptr::copy_nonoverlapping(
&buf.f_fsid as *const libc::fsid_t as *const u8,
out.as_mut_ptr(),
std::mem::size_of::<libc::fsid_t>(),
);
}
out
}
pub(crate) fn acquire_template_lock(key: &str) -> Result<std::os::fd::OwnedFd> {
let lock_path = lock_path_for_key(key)?;
acquire_flock_with_timeout(
&lock_path,
FlockMode::Exclusive,
TEMPLATE_LOCK_TIMEOUT,
&format!("disk-template cache entry {key}"),
Some(
"A peer ktstr process is currently building this template. \
Wait for it to finish, kill the peer with the listed PID, \
or remove the lockfile if you are sure it is stale.",
),
)
}
pub(crate) fn clone_to_per_test(src_path: &Path, dest_path: &Path) -> Result<File> {
let src = OpenOptions::new()
.read(true)
.open(src_path)
.with_context(|| format!("open template source {src_path:?}"))?;
let dest = OpenOptions::new()
.write(true)
.create_new(true)
.open(dest_path)
.with_context(|| format!("open dest path {dest_path:?} for FICLONE"))?;
let rc = unsafe { libc::ioctl(dest.as_raw_fd(), FICLONE_IOCTL, src.as_raw_fd()) };
if rc != 0 {
let err = io::Error::last_os_error();
let _ = std::fs::remove_file(dest_path);
return Err(anyhow!(
"FICLONE {src_path:?} -> {dest_path:?} failed: {err}. \
This usually means the destination filesystem does not \
support reflinks (btrfs/xfs only) or the source and \
destination live on different filesystems. Set \
KTSTR_CACHE_DIR to a directory on a btrfs/xfs mount.",
));
}
Ok(dest)
}
pub(crate) fn locate_host_mkfs(fs: Filesystem) -> Result<Option<(PathBuf, &'static str)>> {
let Some(name) = fs.mkfs_binary_name() else {
return Ok(None);
};
let path = locate_host_binary(name, mkfs_package_hint(fs))?;
Ok(Some((path, name)))
}
fn mkfs_package_hint(fs: Filesystem) -> &'static str {
match fs {
Filesystem::Btrfs => "btrfs-progs",
Filesystem::Raw => "<none — Raw needs no formatter>",
}
}
fn locate_host_binary(name: &str, package_hint: &str) -> Result<PathBuf> {
let path_var = std::env::var_os("PATH")
.ok_or_else(|| anyhow!("PATH environment variable is unset; cannot locate {name}"))?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
let Ok(meta) = std::fs::metadata(&candidate) else {
continue;
};
if !meta.is_file() {
continue;
}
if meta.len() == 0 {
continue;
}
let canonical = std::fs::canonicalize(&candidate)
.with_context(|| format!("canonicalize {candidate:?}"))?;
return Ok(canonical);
}
bail!(
"{name} not found on PATH. \
Install the {package_hint} package (or your distro's \
equivalent) so the disk-template VM can format the requested \
filesystem. PATH={path:?}",
path = path_var,
)
}
pub(crate) fn ensure_template(fs: Filesystem, capacity_bytes: u64) -> Result<PathBuf> {
let version_fp = match locate_host_mkfs(fs)? {
Some((mkfs_path, _name)) => mkfs_version_fingerprint(&mkfs_path)?,
None => NOVERSION_FP.to_string(),
};
let key = template_cache_key(fs, capacity_bytes, &version_fp);
if let Some(hit) = lookup(&key)? {
return Ok(hit);
}
let root = cache_root()?;
verify_cache_dir_supports_reflink(&root)?;
std::fs::create_dir_all(&root)
.with_context(|| format!("create disk-template cache root {root:?}"))?;
verify_cache_dir_supports_reflink(&root)?;
let _lock = acquire_template_lock(&key)?;
if let Some(hit) = lookup(&key)? {
return Ok(hit);
}
let staged = build_template_via_vm(fs, capacity_bytes, &root, &key)
.with_context(|| format!("build disk template for {key}"))?;
let final_path = match store_atomic(&key, &staged) {
Ok(p) => p,
Err(e) => {
let _ = std::fs::remove_file(&staged);
return Err(e).with_context(|| format!("install disk template {key}"));
}
};
Ok(final_path)
}
fn staging_image_path(cache_root: &Path, cache_key: &str, pid: u32) -> PathBuf {
cache_root.join(format!("template.img.in-flight.{cache_key}.{pid}"))
}
fn create_and_size_staging_image(staging_path: &Path, capacity_bytes: u64) -> Result<()> {
if staging_path.exists() {
std::fs::remove_file(staging_path).with_context(|| {
format!("remove leftover staging image {staging_path:?} before rebuild")
})?;
}
let staging_file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(staging_path)
.with_context(|| format!("create staging image {staging_path:?}"))?;
if let Err(e) = staging_file.set_len(capacity_bytes) {
drop(staging_file);
let _ = std::fs::remove_file(staging_path);
return Err(e).with_context(|| {
format!(
"set staging image length to {capacity_bytes} bytes \
({staging_path:?})"
)
});
}
drop(staging_file);
Ok(())
}
fn build_template_via_vm(
fs: Filesystem,
capacity_bytes: u64,
cache_root: &Path,
cache_key: &str,
) -> Result<PathBuf> {
let (mkfs, mkfs_name) = locate_host_mkfs(fs)?.ok_or_else(|| {
anyhow!(
"build_template_via_vm called with Filesystem::{fs:?} — \
this filesystem variant has no userspace formatter \
(mkfs_binary_name() returned None) so there is no \
template image to build. ensure_template should only \
invoke this path for filesystem variants that require \
pre-formatting; this call indicates a bypass of the gate \
in init_virtio_blk."
)
})?;
let kernel = crate::find_kernel()
.context("locate kernel image for template-build VM")?
.ok_or_else(|| {
anyhow!(
"no kernel image found for template-build VM. {}",
crate::KTSTR_KERNEL_HINT,
)
})?;
std::fs::create_dir_all(cache_root)
.with_context(|| format!("create cache root {cache_root:?} for staging image"))?;
verify_cache_dir_supports_reflink(cache_root)?;
let staging_path = staging_image_path(cache_root, cache_key, std::process::id());
create_and_size_staging_image(&staging_path, capacity_bytes)?;
let mkfs_archive_path = format!("bin/{mkfs_name}");
let capacity_mb = u32::try_from(capacity_bytes / (1024 * 1024)).with_context(|| {
format!(
"capacity_mb overflow: capacity_bytes={capacity_bytes} \
yields {} MiB which exceeds u32::MAX. DiskConfig::capacity_mb \
is u32; use a smaller capacity.",
capacity_bytes / (1024 * 1024),
)
})?;
let disk = crate::vmm::disk_config::DiskConfig::default()
.capacity_mb(capacity_mb)
.filesystem(Filesystem::Raw);
let build_result = crate::vmm::KtstrVm::builder()
.kernel(kernel)
.topology(1, 1, 1, 1)
.memory_mb(256)
.timeout(std::time::Duration::from_secs(120))
.cmdline("KTSTR_MODE=disk_template")
.disk(disk)
.template_staging_image(staging_path.clone())
.include_files(vec![(mkfs_archive_path, mkfs)])
.busybox(true)
.build();
let vm = match build_result {
Ok(vm) => vm,
Err(e) => {
let _ = std::fs::remove_file(&staging_path);
return Err(e).with_context(|| {
format!("build template-VM for {fs:?} capacity_bytes={capacity_bytes}")
});
}
};
let result = vm.run().with_context(|| {
format!("run template-build VM for {fs:?} capacity_bytes={capacity_bytes}")
});
let result = match result {
Ok(r) => r,
Err(e) => {
let _ = std::fs::remove_file(&staging_path);
return Err(e);
}
};
if result.timed_out || result.exit_code != 0 || !result.success {
let _ = std::fs::remove_file(&staging_path);
bail!(
"template-build VM did not complete cleanly \
(timed_out={}, exit_code={}, success={}). \
Tail of guest stderr: {}",
result.timed_out,
result.exit_code,
result.success,
tail_lines(&result.stderr, 20),
);
}
Ok(staging_path)
}
#[allow(dead_code)]
pub fn clean_orphaned_tmp_dirs(cache_root: &Path) -> Result<usize> {
if !cache_root.is_dir() {
return Ok(0);
}
let read_dir = match std::fs::read_dir(cache_root) {
Ok(rd) => rd,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
Err(e) => {
return Err(anyhow!("read cache root {cache_root:?}: {e}"));
}
};
let mut removed: usize = 0;
for dir_entry in read_dir {
let dir_entry = match dir_entry {
Ok(d) => d,
Err(e) => {
tracing::warn!(
err = %format!("{e:#}"),
"skip unreadable disk-template cache root entry",
);
continue;
}
};
let name = match dir_entry.file_name().into_string() {
Ok(n) => n,
Err(_) => continue,
};
let pid_str = if let Some(rest) = name.strip_prefix("template.img.in-flight.") {
match rest.rsplit_once('.') {
Some((_, suffix)) if !suffix.is_empty() => suffix,
_ => continue,
}
} else if let Some(rest) = name.strip_prefix(".per-test-") {
match rest.split_once('-') {
Some((pid_token, _)) if !pid_token.is_empty() => pid_token,
_ => continue,
}
} else if name.contains(".tmp.") {
match name.rsplit_once(".tmp.") {
Some((_, suffix)) if !suffix.is_empty() => suffix,
_ => continue,
}
} else {
continue;
};
let pid: i32 = match pid_str.parse() {
Ok(p) => p,
Err(_) => continue,
};
if pid <= 0 {
continue;
}
let dead = matches!(
nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None),
Err(nix::errno::Errno::ESRCH),
);
if !dead {
continue;
}
let path = dir_entry.path();
let result = match dir_entry.file_type() {
Ok(ft) if ft.is_dir() => std::fs::remove_dir_all(&path),
Ok(_) => std::fs::remove_file(&path),
Err(e) => {
tracing::warn!(
err = %format!("{e:#}"),
path = %path.display(),
"skip disk-template cache entry; \
file_type() failed",
);
continue;
}
};
match result {
Ok(()) => {
tracing::info!(
path = %path.display(),
orphan_pid = pid,
"cleaned orphaned disk-template debris from \
prior crashed process",
);
removed += 1;
}
Err(e) => {
tracing::warn!(
err = %format!("{e:#}"),
path = %path.display(),
"failed to remove orphaned disk-template debris; \
leaving in place",
);
}
}
}
Ok(removed)
}
#[allow(dead_code)]
pub fn clean_all() -> Result<usize> {
let root = cache_root()?;
if !root.is_dir() {
return Ok(0);
}
let _debris = clean_orphaned_tmp_dirs(&root)?;
let lock_dir = root.join(LOCK_DIR_NAME);
std::fs::create_dir_all(&lock_dir).with_context(|| {
format!(
"create disk-template lock subdirectory {} for clean_all",
lock_dir.display(),
)
})?;
let read_dir = match std::fs::read_dir(&root) {
Ok(rd) => rd,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
Err(e) => {
return Err(anyhow!("read cache root {root:?}: {e}"));
}
};
let mut removed: usize = 0;
for dir_entry in read_dir {
let dir_entry = match dir_entry {
Ok(d) => d,
Err(e) => {
tracing::warn!(
err = %format!("{e:#}"),
"skip unreadable disk-template cache root entry \
during clean_all",
);
continue;
}
};
let file_type = match dir_entry.file_type() {
Ok(ft) => ft,
Err(e) => {
tracing::warn!(
err = %format!("{e:#}"),
path = %dir_entry.path().display(),
"skip disk-template entry; file_type() failed",
);
continue;
}
};
if !file_type.is_dir() {
continue;
}
let name = match dir_entry.file_name().into_string() {
Ok(n) => n,
Err(_) => continue,
};
if name == LOCK_DIR_NAME {
continue;
}
if name.contains(".tmp.") {
continue;
}
let entry_path = dir_entry.path();
let lock_path = match lock_path_for_key(&name) {
Ok(p) => p,
Err(e) => {
tracing::warn!(
err = %format!("{e:#}"),
cache_key = %name,
"skip disk-template entry; lock_path resolution \
failed",
);
continue;
}
};
let lock_fd = match try_flock(&lock_path, FlockMode::Exclusive) {
Ok(Some(fd)) => fd,
Ok(None) => {
tracing::info!(
cache_key = %name,
lockfile = %lock_path.display(),
"skip disk-template entry during clean_all — \
locked by live peer",
);
continue;
}
Err(e) => {
tracing::warn!(
err = %format!("{e:#}"),
cache_key = %name,
"skip disk-template entry; try_flock failed",
);
continue;
}
};
match std::fs::remove_dir_all(&entry_path) {
Ok(()) => {
tracing::info!(
cache_key = %name,
path = %entry_path.display(),
"removed disk-template cache entry during clean_all",
);
removed += 1;
}
Err(e) => {
tracing::warn!(
err = %format!("{e:#}"),
cache_key = %name,
path = %entry_path.display(),
"failed to remove disk-template cache entry \
during clean_all; leaving in place",
);
}
}
drop(lock_fd);
}
Ok(removed)
}
fn tail_lines(text: &str, n: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_key_renders_capacity_in_mib_and_version_fp() {
let key = template_cache_key(Filesystem::Btrfs, 256 * 1024 * 1024, "deadbeef");
assert_eq!(key, "btrfs-256m-deadbeef");
let key = template_cache_key(Filesystem::Raw, 1024 * 1024 * 1024, NOVERSION_FP);
assert_eq!(key, "raw-1024m-noversion");
}
#[test]
fn cache_key_truncates_sub_mib_capacity_to_zero() {
let key = template_cache_key(Filesystem::Btrfs, 1024, "deadbeef");
assert_eq!(key, "btrfs-0m-deadbeef");
}
#[test]
fn cache_key_rotates_with_version_fp() {
let v1 = template_cache_key(Filesystem::Btrfs, 256 * 1024 * 1024, "fp_v1");
let v2 = template_cache_key(Filesystem::Btrfs, 256 * 1024 * 1024, "fp_v2");
assert_ne!(v1, v2, "cache key must rotate when version_fp changes");
assert_eq!(v1, "btrfs-256m-fp_v1");
assert_eq!(v2, "btrfs-256m-fp_v2");
}
#[test]
fn template_path_includes_filename_constant() {
let tmp = tempfile::tempdir().expect("create tempdir");
let _guard =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_CACHE_DIR", tmp.path());
let path = template_path_for_key("btrfs-256m").expect("resolve template path");
assert!(path.ends_with(format!("btrfs-256m/{TEMPLATE_FILENAME}")));
}
#[test]
fn lookup_missing_returns_none() {
let tmp = tempfile::tempdir().expect("create tempdir");
let _guard =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_CACHE_DIR", tmp.path());
let result = lookup("missing-key").expect("lookup must not error on miss");
assert!(result.is_none());
}
#[test]
fn store_atomic_publishes_then_lookup_finds() {
let tmp = tempfile::tempdir().expect("create tempdir");
let _guard =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_CACHE_DIR", tmp.path());
let cache_root_path = cache_root().unwrap();
std::fs::create_dir_all(&cache_root_path).unwrap();
let staged = cache_root_path.join("staged.img");
std::fs::write(&staged, b"FAKE_TEMPLATE_BODY").unwrap();
let key = "test-key";
let installed = store_atomic(key, &staged).expect("store_atomic publishes");
assert!(installed.ends_with(format!("{key}/{TEMPLATE_FILENAME}")));
let found = lookup(key).expect("lookup ok").expect("lookup must hit");
assert_eq!(found, installed);
let body = std::fs::read(&found).unwrap();
assert_eq!(body, b"FAKE_TEMPLATE_BODY");
}
#[test]
fn store_atomic_idempotent_on_existing_entry() {
let tmp = tempfile::tempdir().expect("create tempdir");
let _guard =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_CACHE_DIR", tmp.path());
let cache_root_path = cache_root().unwrap();
std::fs::create_dir_all(&cache_root_path).unwrap();
let staged1 = cache_root_path.join("staged1.img");
std::fs::write(&staged1, b"FIRST").unwrap();
let key = "idem-key";
let installed1 = store_atomic(key, &staged1).unwrap();
let staged2 = cache_root_path.join("staged2.img");
std::fs::write(&staged2, b"SECOND").unwrap();
let installed2 = store_atomic(key, &staged2).unwrap();
assert_eq!(installed1, installed2);
let body = std::fs::read(&installed2).unwrap();
assert_eq!(body, b"FIRST");
}
#[test]
fn store_atomic_unlinks_src_on_idempotent_early_return() {
let tmp = tempfile::tempdir().expect("create tempdir");
let _guard =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_CACHE_DIR", tmp.path());
let cache_root_path = cache_root().unwrap();
std::fs::create_dir_all(&cache_root_path).unwrap();
let staged1 = cache_root_path.join("staged1.img");
std::fs::write(&staged1, b"FIRST").unwrap();
let key = "early-return-key";
store_atomic(key, &staged1).unwrap();
let staged2 = cache_root_path.join("staged2.img");
std::fs::write(&staged2, b"SECOND").unwrap();
store_atomic(key, &staged2).unwrap();
assert!(
!staged2.exists(),
"early-return path must unlink the obsolete staging image \
at {staged2:?}; without this cleanup the cache root \
accumulates orphan staging files across every concurrent \
peer that loses the publish race",
);
}
#[test]
fn locate_host_binary_actionable_error_when_missing() {
let tmp = tempfile::tempdir().expect("create tempdir");
let _guard = crate::test_support::test_helpers::EnvVarGuard::set("PATH", tmp.path());
let err = locate_host_binary("nonexistent-binary-9242", "imagined-package")
.expect_err("must error when binary absent");
let msg = err.to_string();
assert!(
msg.contains("nonexistent-binary-9242"),
"error names the binary: {msg}",
);
assert!(
msg.contains("imagined-package"),
"error names the package hint: {msg}",
);
}
#[test]
fn locate_host_mkfs_raw_returns_none() {
let tmp = tempfile::tempdir().expect("create tempdir");
let _path_guard = crate::test_support::test_helpers::EnvVarGuard::set("PATH", tmp.path());
let result =
locate_host_mkfs(Filesystem::Raw).expect("Raw must short-circuit before any PATH walk");
assert!(
result.is_none(),
"Filesystem::Raw has no userspace formatter; \
locate_host_mkfs must return Ok(None) without consulting \
PATH. Got: {result:?}",
);
}
#[test]
fn mkfs_version_fingerprint_is_deterministic() {
let path_var = match std::env::var_os("PATH") {
Some(p) => p,
None => return,
};
let mut working_binary: Option<PathBuf> = None;
for name in &["cat", "ls", "true"] {
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if !std::fs::metadata(&candidate)
.map(|m| m.is_file())
.unwrap_or(false)
{
continue;
}
let probe = std::process::Command::new(&candidate)
.arg("--version")
.output();
let Ok(output) = probe else {
continue;
};
if !output.stdout.is_empty() || !output.stderr.is_empty() {
working_binary = Some(candidate);
break;
}
}
if working_binary.is_some() {
break;
}
}
let Some(binary_path) = working_binary else {
return;
};
let fp1 = mkfs_version_fingerprint(&binary_path)
.expect("first --version invocation must succeed");
let fp2 = mkfs_version_fingerprint(&binary_path)
.expect("second --version invocation must succeed");
assert_eq!(
fp1, fp2,
"fingerprint must be deterministic across repeated \
invocations of the same binary"
);
assert_eq!(
fp1.len(),
16,
"fingerprint must render as 16 hex chars (64 bits): {fp1}",
);
assert!(
fp1.chars().all(|c| c.is_ascii_hexdigit()),
"fingerprint must be hex-only: {fp1}",
);
let cached = mkfs_version_fingerprint_cache()
.lock()
.expect("cache mutex")
.get(&binary_path)
.cloned();
assert_eq!(
cached.as_deref(),
Some(fp1.as_str()),
"first call must populate the per-process fingerprint cache; \
without the cache, ensure_template re-execs `--version` on \
every VM boot",
);
}
#[test]
fn build_template_via_vm_rejects_raw_filesystem() {
let tmp = tempfile::tempdir().expect("create tempdir");
let _guard =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_CACHE_DIR", tmp.path());
let err = build_template_via_vm(Filesystem::Raw, 256 * 1024 * 1024, tmp.path(), "raw-256m")
.expect_err("Raw must be rejected");
let msg = err.to_string();
assert!(
msg.contains("Filesystem::Raw"),
"error must name the rejected variant: {msg}",
);
assert!(
msg.contains("init_virtio_blk"),
"error must name the gate location for the operator: {msg}",
);
}
#[test]
fn verify_cache_dir_walks_up_to_existing_ancestor() {
let tmp = tempfile::tempdir().expect("create tempdir");
let nonexistent = tmp.path().join("nonexistent/sub/dir");
match verify_cache_dir_supports_reflink(&nonexistent) {
Ok(()) => { }
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("statfs.f_type") || msg.contains("FICLONE"),
"unexpected error wording: {msg}",
);
}
}
}
#[test]
fn verify_cache_dir_probe_note_fires_when_probe_differs_from_dir() {
let tmp = tempfile::tempdir().expect("create tempdir");
let nonexistent = tmp.path().join("nonexistent/sub/dir");
match verify_cache_dir_supports_reflink(&nonexistent) {
Ok(()) => {
}
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("ancestor") && msg.contains("no part of"),
"walk-up diagnostic must surface the probed \
ancestor when probe != dir; got: {msg}",
);
}
}
}
#[test]
fn verify_cache_dir_probe_note_absent_when_probe_equals_dir() {
let tmp = tempfile::tempdir().expect("create tempdir");
match verify_cache_dir_supports_reflink(tmp.path()) {
Ok(()) => {
}
Err(e) => {
let msg = e.to_string();
assert!(
!msg.contains("ancestor") && !msg.contains("no part of"),
"probe == dir branch must NOT emit the probe_note \
text; got: {msg}",
);
assert!(
msg.contains("statfs.f_type") || msg.contains("FICLONE"),
"diagnostic must still name the f_type; got: {msg}",
);
}
}
}
#[cfg(target_os = "linux")]
#[test]
fn verify_cache_dir_walks_through_dangling_symlink() {
let tmp = tempfile::tempdir().expect("create tempdir");
let symlink_path = tmp.path().join("dangling");
std::os::unix::fs::symlink("/nonexistent-symlink-target-9242", &symlink_path)
.expect("create dangling symlink");
let probe_path = symlink_path.join("sub");
match verify_cache_dir_supports_reflink(&probe_path) {
Ok(()) => {
}
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("statfs.f_type") || msg.contains("FICLONE"),
"symlink walk-up must produce an f_type-named \
diagnostic, not a symlink-resolution error; got: {msg}",
);
}
}
}
#[test]
fn staging_image_path_is_unique_per_key_and_pid() {
let cache_root = std::path::Path::new("/tmp/ktstr-fake-cache-root");
let pid = 12_345u32;
let p_256 = staging_image_path(cache_root, "btrfs-256m", pid);
let p_1024 = staging_image_path(cache_root, "btrfs-1024m", pid);
assert_ne!(
p_256, p_1024,
"cache_key qualifier missing from staging-image path: \
distinct keys collided",
);
assert!(
p_256
.to_string_lossy()
.contains("template.img.in-flight.btrfs-256m.12345"),
"256m staging path missing key/pid token: {p_256:?}",
);
assert!(
p_1024
.to_string_lossy()
.contains("template.img.in-flight.btrfs-1024m.12345"),
"1024m staging path missing key/pid token: {p_1024:?}",
);
let p_256_other_pid = staging_image_path(cache_root, "btrfs-256m", 67_890);
assert_ne!(p_256, p_256_other_pid);
assert_eq!(
p_256,
staging_image_path(cache_root, "btrfs-256m", pid),
"staging_image_path must be a pure function of its inputs",
);
}
#[test]
fn create_and_size_staging_image_cleans_up_on_set_len_failure() {
let tmp = tempfile::tempdir().expect("create tempdir");
let staging_path = tmp.path().join("template.img.in-flight.btrfs-256m.0");
let err = create_and_size_staging_image(&staging_path, u64::MAX)
.expect_err("set_len(u64::MAX) must fail at the i64 cast");
let msg = err.to_string();
assert!(
msg.contains("set staging image length"),
"error must surface the set_len-failed context: {msg}",
);
match std::fs::metadata(&staging_path) {
Err(e) if e.kind() == io::ErrorKind::NotFound => { }
Ok(m) => panic!(
"staging image not cleaned up after set_len failure: \
still exists at {staging_path:?} ({} bytes)",
m.len(),
),
Err(e) => panic!("unexpected stat error: {e}"),
}
}
#[test]
fn fsid_bytes_is_deterministic_for_same_path() {
let tmp = tempfile::tempdir().expect("create tempdir");
let buf1 = statfs_path(tmp.path()).expect("first statfs");
let buf2 = statfs_path(tmp.path()).expect("second statfs");
assert_eq!(
fsid_bytes(&buf1),
fsid_bytes(&buf2),
"fsid_bytes must be deterministic across repeated statfs \
calls against the same path; a mismatch would indicate \
the bytewise f_fsid read produces different output for \
the same input on this host",
);
}
#[test]
fn fsid_bytes_distinguishes_different_filesystems() {
let tmp = tempfile::tempdir().expect("create tempdir");
let tmp_buf = statfs_path(tmp.path()).expect("statfs tempdir");
let tmp_fsid = fsid_bytes(&tmp_buf);
let candidates: &[&str] = &["/proc", "/sys", "/dev", "/"];
let mut probe_outcomes: Vec<String> = Vec::with_capacity(candidates.len());
for cand in candidates {
let path = std::path::Path::new(cand);
match statfs_path(path) {
Ok(buf) => {
let fsid = fsid_bytes(&buf);
if buf.f_type != tmp_buf.f_type || fsid != tmp_fsid {
assert_ne!(
tmp_fsid, fsid,
"fsid_bytes must differ across distinct filesystems \
(tempdir f_type=0x{:x}, {cand} f_type=0x{:x}); a match \
would indicate the bytewise f_fsid read is producing a \
constant byte pattern instead of the real fsid_t — \
e.g. reading from a wrong offset within libc::statfs",
tmp_buf.f_type, buf.f_type,
);
return;
}
probe_outcomes.push(format!(
"{cand}: same fs (f_type=0x{:x}, fsid==tempdir)",
buf.f_type,
));
}
Err(e) => {
probe_outcomes.push(format!("{cand}: statfs error ({e})"));
}
}
}
panic!(
"fsid_bytes_distinguishes_different_filesystems found no candidate path \
that resolves to a different filesystem from tempdir (f_type=0x{:x}). \
At least one of the standard pseudo filesystems should mount \
independently of /tmp; the absence of any distinguishing path is \
anomalous — the cross-fs property at store_atomic depends on \
distinguishability, so silent-skip would falsely report green. \
Probe outcomes: {probe_outcomes:?}",
tmp_buf.f_type,
);
}
#[test]
fn clean_orphaned_tmp_dirs_handles_missing_root() {
let tmp = tempfile::tempdir().expect("create tempdir");
let nonexistent = tmp.path().join("never-created");
let count = clean_orphaned_tmp_dirs(&nonexistent).expect("missing root must not error");
assert_eq!(count, 0, "missing root sweeps zero entries");
}
#[test]
fn clean_orphaned_tmp_dirs_removes_dead_pid_staging_image() {
let tmp = tempfile::tempdir().expect("create tempdir");
let cache_root = tmp.path();
let dead_pid = i32::MAX;
let leaked = cache_root.join(format!("template.img.in-flight.btrfs-256m.{dead_pid}",));
std::fs::write(&leaked, b"FAKE_STAGING_IMG").unwrap();
let count = clean_orphaned_tmp_dirs(cache_root).expect("sweep must succeed");
assert_eq!(count, 1, "exactly one debris entry removed");
assert!(!leaked.exists(), "dead-pid staging image must be unlinked",);
}
#[test]
fn clean_orphaned_tmp_dirs_removes_dead_pid_staging_directory() {
let tmp = tempfile::tempdir().expect("create tempdir");
let cache_root = tmp.path();
let dead_pid = i32::MAX;
let leaked = cache_root.join(format!("btrfs-256m.tmp.{dead_pid}"));
std::fs::create_dir_all(&leaked).unwrap();
std::fs::write(leaked.join("template.img"), b"PARTIAL").unwrap();
let count = clean_orphaned_tmp_dirs(cache_root).expect("sweep must succeed");
assert_eq!(count, 1, "exactly one debris entry removed");
assert!(
!leaked.exists(),
"dead-pid staging directory must be removed",
);
}
#[test]
fn clean_orphaned_tmp_dirs_removes_dead_pid_per_test_image() {
let tmp = tempfile::tempdir().expect("create tempdir");
let cache_root = tmp.path();
let dead_pid = i32::MAX;
let leaked = cache_root.join(format!(".per-test-{dead_pid}-deadbeef-cafe.img"));
std::fs::write(&leaked, b"FAKE_PER_TEST_IMG").unwrap();
let count = clean_orphaned_tmp_dirs(cache_root).expect("sweep must succeed");
assert_eq!(count, 1, "exactly one debris entry removed");
assert!(
!leaked.exists(),
"dead-pid per-test backing file must be unlinked",
);
}
#[test]
fn clean_orphaned_tmp_dirs_preserves_live_pid_per_test_image() {
let tmp = tempfile::tempdir().expect("create tempdir");
let cache_root = tmp.path();
let live_pid = std::process::id();
let live_file = cache_root.join(format!(".per-test-{live_pid}-deadbeef-cafe.img"));
std::fs::write(&live_file, b"LIVE_PER_TEST_BACKING").unwrap();
let count = clean_orphaned_tmp_dirs(cache_root).expect("sweep must succeed");
assert_eq!(
count, 0,
"live-pid per-test backing must not be removed by sweep",
);
assert!(
live_file.exists(),
"live-pid per-test backing must survive the sweep",
);
}
#[test]
fn clean_orphaned_tmp_dirs_preserves_live_pid_debris() {
let tmp = tempfile::tempdir().expect("create tempdir");
let cache_root = tmp.path();
let live_pid = std::process::id();
let live_image = cache_root.join(format!("template.img.in-flight.btrfs-256m.{live_pid}",));
std::fs::write(&live_image, b"LIVE_PEER_DEBRIS").unwrap();
let count = clean_orphaned_tmp_dirs(cache_root).expect("sweep must succeed");
assert_eq!(
count, 0,
"no entries removed when only live-pid debris exists",
);
assert!(
live_image.exists(),
"live-pid debris must be preserved across sweep",
);
}
#[test]
fn clean_orphaned_tmp_dirs_preserves_published_entries() {
let tmp = tempfile::tempdir().expect("create tempdir");
let cache_root = tmp.path();
let published = cache_root.join("btrfs-256m");
std::fs::create_dir_all(&published).unwrap();
std::fs::write(published.join(TEMPLATE_FILENAME), b"GOOD").unwrap();
let count = clean_orphaned_tmp_dirs(cache_root).expect("sweep must succeed");
assert_eq!(
count, 0,
"published cache entries must not be swept by debris GC",
);
assert!(published.is_dir(), "published entry must survive");
assert!(
published.join(TEMPLATE_FILENAME).is_file(),
"published template.img must survive",
);
}
#[test]
fn clean_orphaned_tmp_dirs_preserves_lock_subdirectory() {
let tmp = tempfile::tempdir().expect("create tempdir");
let cache_root = tmp.path();
let locks = cache_root.join(LOCK_DIR_NAME);
std::fs::create_dir_all(&locks).unwrap();
std::fs::write(locks.join("btrfs-256m.lock"), b"").unwrap();
let count = clean_orphaned_tmp_dirs(cache_root).expect("sweep must succeed");
assert_eq!(count, 0, ".locks/ must be invisible to the debris sweep",);
assert!(locks.is_dir(), ".locks/ subdirectory must survive");
assert!(
locks.join("btrfs-256m.lock").is_file(),
"individual lockfiles must survive",
);
}
#[test]
fn clean_all_removes_published_entry() {
let tmp = tempfile::tempdir().expect("create tempdir");
let _guard =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_CACHE_DIR", tmp.path());
let cache_root_path = cache_root().unwrap();
std::fs::create_dir_all(&cache_root_path).unwrap();
let staged = cache_root_path.join("staged.img");
std::fs::write(&staged, b"FAKE_TEMPLATE").unwrap();
let installed = store_atomic("btrfs-256m", &staged).expect("store_atomic publishes");
assert!(installed.is_file());
let count = clean_all().expect("clean_all must succeed");
assert_eq!(count, 1, "exactly one published entry removed");
assert!(
lookup("btrfs-256m").expect("lookup ok").is_none(),
"published entry must be gone after clean_all",
);
let lock_path = lock_path_for_key("btrfs-256m").unwrap();
if lock_path.exists() {
assert!(lock_path.is_file(), "lockfile inode must survive clean_all",);
}
}
#[test]
fn clean_all_reports_zero_on_empty_cache() {
let tmp = tempfile::tempdir().expect("create tempdir");
let _guard =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_CACHE_DIR", tmp.path());
let count = clean_all().expect("clean_all must succeed on empty");
assert_eq!(count, 0);
}
#[test]
fn clean_all_handles_missing_cache_root() {
let tmp = tempfile::tempdir().expect("create tempdir");
let nonexistent = tmp.path().join("never-created");
let _guard =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_CACHE_DIR", &nonexistent);
let count = clean_all().expect("missing cache root must not error");
assert_eq!(count, 0);
}
#[test]
fn clean_all_skips_entry_locked_by_live_peer() {
let tmp = tempfile::tempdir().expect("create tempdir");
let _guard =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_CACHE_DIR", tmp.path());
let cache_root_path = cache_root().unwrap();
std::fs::create_dir_all(&cache_root_path).unwrap();
let staged = cache_root_path.join("staged.img");
std::fs::write(&staged, b"FAKE_TEMPLATE").unwrap();
let installed = store_atomic("btrfs-256m", &staged).expect("store_atomic publishes");
assert!(installed.is_file());
let _hold = acquire_template_lock("btrfs-256m").expect("acquire template lock");
let count = clean_all().expect("clean_all must succeed");
assert_eq!(count, 0, "locked entry must not be removed by clean_all",);
assert!(
lookup("btrfs-256m").expect("lookup ok").is_some(),
"locked entry must survive clean_all",
);
}
#[test]
fn clean_all_sweeps_debris_alongside_published_entries() {
let tmp = tempfile::tempdir().expect("create tempdir");
let _guard =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_CACHE_DIR", tmp.path());
let cache_root_path = cache_root().unwrap();
std::fs::create_dir_all(&cache_root_path).unwrap();
let staged = cache_root_path.join("staged.img");
std::fs::write(&staged, b"FAKE_TEMPLATE").unwrap();
store_atomic("btrfs-256m", &staged).unwrap();
let dead_pid = i32::MAX;
let debris =
cache_root_path.join(format!("template.img.in-flight.btrfs-1024m.{dead_pid}",));
std::fs::write(&debris, b"DEBRIS").unwrap();
assert!(debris.is_file());
assert!(lookup("btrfs-256m").unwrap().is_some());
let count = clean_all().expect("clean_all must succeed");
assert_eq!(count, 1, "one published entry removed");
assert!(
!debris.exists(),
"debris must be removed by the embedded sweep",
);
assert!(
lookup("btrfs-256m").unwrap().is_none(),
"published entry must be removed by clean_all",
);
}
}