use std::collections::{BTreeSet, HashMap};
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use uuid::Uuid;
use crate::config::SnapshotType;
use crate::error::{Result, VmmError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotMeta {
pub id: String,
pub vm_id: String,
pub name: Option<String>,
#[serde(default)]
pub labels: HashMap<String, String>,
pub snapshot_type: SnapshotType,
pub vmstate_path: PathBuf,
pub mem_path: Option<PathBuf>,
pub created_at: DateTime<Utc>,
pub parent_id: Option<String>,
#[serde(default)]
pub kernel_path: Option<String>,
#[serde(default)]
pub rootfs_path: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SnapshotInfo {
pub id: String,
pub vm_id: String,
pub name: Option<String>,
pub labels: HashMap<String, String>,
pub snapshot_type: SnapshotType,
pub vmstate_path: PathBuf,
pub mem_path: Option<PathBuf>,
pub created_at: DateTime<Utc>,
}
impl From<&SnapshotMeta> for SnapshotInfo {
fn from(m: &SnapshotMeta) -> Self {
Self {
id: m.id.clone(),
vm_id: m.vm_id.clone(),
name: m.name.clone(),
labels: m.labels.clone(),
snapshot_type: m.snapshot_type,
vmstate_path: m.vmstate_path.clone(),
mem_path: m.mem_path.clone(),
created_at: m.created_at,
}
}
}
const VMSTATE_FILE: &str = "vmstate";
const MEM_FILE: &str = "mem";
const STAGING_SUFFIX: &str = ".partial";
fn secure_dir(path: &Path) -> Result<()> {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(VmmError::Io)
}
fn sync_private_file(path: &Path) -> Result<()> {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(VmmError::Io)?;
std::fs::File::open(path)
.and_then(|file| file.sync_all())
.map_err(VmmError::Io)
}
fn write_private_file(path: &Path, bytes: &[u8]) -> Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(VmmError::Io)?;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(VmmError::Io)?;
file.write_all(bytes).map_err(VmmError::Io)?;
file.sync_all().map_err(VmmError::Io)
}
fn sync_dir(path: &Path) -> Result<()> {
std::fs::File::open(path)
.and_then(|dir| dir.sync_all())
.map_err(VmmError::Io)
}
fn is_catalog_entry(path: &Path) -> bool {
path.is_dir()
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| !name.starts_with('.'))
}
#[derive(Debug)]
pub struct SnapshotDraft {
pub name: Option<String>,
pub labels: HashMap<String, String>,
pub snapshot_type: SnapshotType,
pub parent_id: Option<String>,
pub kernel_path: Option<String>,
pub rootfs_path: Option<String>,
}
pub struct PendingSnapshot<'a> {
catalog: &'a SnapshotCatalog,
vm_id: String,
id: String,
published: bool,
}
impl PendingSnapshot<'_> {
pub fn id(&self) -> &str {
&self.id
}
pub fn dir(&self) -> PathBuf {
self.catalog.staging_dir(&self.vm_id, &self.id)
}
pub fn commit(mut self, draft: SnapshotDraft) -> Result<SnapshotMeta> {
let staging = self.dir();
let published = self.catalog.snapshot_dir(&self.vm_id, &self.id);
let has_mem = staging.join(MEM_FILE).exists();
let meta = SnapshotMeta {
id: self.id.clone(),
vm_id: self.vm_id.clone(),
name: draft.name,
labels: draft.labels,
snapshot_type: draft.snapshot_type,
vmstate_path: published.join(VMSTATE_FILE),
mem_path: has_mem.then(|| published.join(MEM_FILE)),
created_at: Utc::now(),
parent_id: draft.parent_id,
kernel_path: draft.kernel_path,
rootfs_path: draft.rootfs_path,
};
sync_private_file(&staging.join(VMSTATE_FILE))?;
if has_mem {
sync_private_file(&staging.join(MEM_FILE))?;
}
let json = serde_json::to_vec_pretty(&meta)?;
write_private_file(&SnapshotCatalog::meta_path(&staging), &json)?;
secure_dir(&staging)?;
sync_dir(&staging)?;
std::fs::rename(&staging, &published).map_err(VmmError::Io)?;
self.published = true;
let owner = published.parent().expect("snapshot directory has an owner");
sync_dir(owner).map_err(|error| {
VmmError::Unavailable(format!(
"snapshot {} is visible, but publish durability is unconfirmed: {error}",
meta.id
))
})?;
info!(snapshot_id = %meta.id, vm_id = %meta.vm_id, "snapshot registered");
Ok(meta)
}
}
impl Drop for PendingSnapshot<'_> {
fn drop(&mut self) {
if self.published {
return;
}
let dir = self.dir();
match std::fs::remove_dir_all(&dir) {
Ok(()) => {
info!(snapshot_id = %self.id, vm_id = %self.vm_id, "discarded incomplete snapshot");
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
warn!(path = %dir.display(), error = %e, "failed to discard incomplete snapshot");
}
}
}
}
pub struct SnapshotCatalog {
root: PathBuf,
}
impl SnapshotCatalog {
pub fn new(data_dir: &str) -> Self {
Self {
root: PathBuf::from(data_dir).join("snapshots"),
}
}
pub fn begin(&self, vm_id: &str) -> Result<PendingSnapshot<'_>> {
let pending = PendingSnapshot {
catalog: self,
vm_id: vm_id.to_owned(),
id: Uuid::new_v4().to_string(),
published: false,
};
std::fs::create_dir_all(pending.dir()).map_err(VmmError::Io)?;
secure_dir(&self.root)?;
secure_dir(&self.vm_dir(vm_id))?;
secure_dir(&pending.dir())?;
sync_dir(&self.vm_dir(vm_id))?;
sync_dir(&self.root)?;
if let Some(data_dir) = self.root.parent() {
sync_dir(data_dir)?;
}
Ok(pending)
}
pub fn list(&self, vm_id: &str) -> Result<Vec<SnapshotInfo>> {
let dir = self.vm_dir(vm_id);
if !dir.exists() {
return Ok(vec![]);
}
let mut entries: Vec<SnapshotMeta> = std::fs::read_dir(&dir)
.map_err(VmmError::Io)?
.filter_map(|e| e.ok())
.filter(|e| is_catalog_entry(&e.path()))
.map(|e| self.read_meta(&e.path()))
.collect::<Result<_>>()?;
entries.sort_by_key(|m| m.created_at);
Ok(entries.iter().map(SnapshotInfo::from).collect())
}
pub fn get(&self, vm_id: &str, snapshot_id: &str) -> Result<SnapshotMeta> {
let path = self.snapshot_dir(vm_id, snapshot_id);
self.read_meta(&path)
}
pub fn delete(&self, vm_id: &str, snapshot_id: &str) -> Result<()> {
let path = self.snapshot_dir(vm_id, snapshot_id);
if !path.exists() {
return Err(VmmError::Snapshot(format!(
"snapshot {snapshot_id} not found for VM {vm_id}"
)));
}
std::fs::remove_dir_all(&path).map_err(VmmError::Io)?;
sync_dir(&self.vm_dir(vm_id))?;
info!(snapshot_id, vm_id, "snapshot deleted");
Ok(())
}
pub fn sweep_incomplete(&self) {
let Ok(vms) = std::fs::read_dir(&self.root) else {
return;
};
for vm in vms.flatten() {
let Ok(entries) = std::fs::read_dir(vm.path()) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let staged = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| name.starts_with('.') && name.ends_with(STAGING_SUFFIX));
if staged && path.is_dir() {
match std::fs::remove_dir_all(&path) {
Ok(()) => info!(path = %path.display(), "removed incomplete snapshot"),
Err(e) => {
warn!(path = %path.display(), error = %e, "failed to remove incomplete snapshot");
}
}
}
}
}
}
pub fn find_by_id(&self, snapshot_id: &str) -> Result<SnapshotMeta> {
if !self.root.exists() {
return Err(VmmError::Snapshot(format!(
"snapshot {snapshot_id} not found"
)));
}
for entry in std::fs::read_dir(&self.root).map_err(VmmError::Io)? {
let entry = entry.map_err(VmmError::Io)?;
if entry.path().is_dir() {
let snap_path = entry.path().join(snapshot_id);
if is_catalog_entry(&snap_path) {
return self.read_meta(&snap_path);
}
}
}
Err(VmmError::Snapshot(format!(
"snapshot {snapshot_id} not found"
)))
}
pub fn list_all(&self) -> Result<Vec<SnapshotInfo>> {
if !self.root.exists() {
return Ok(vec![]);
}
let mut all: Vec<SnapshotInfo> = vec![];
for entry in std::fs::read_dir(&self.root).map_err(VmmError::Io)? {
let entry = entry.map_err(VmmError::Io)?;
if entry.path().is_dir() {
let owner_id = entry.file_name().to_string_lossy().into_owned();
let mut infos = self.list(&owner_id)?;
all.append(&mut infos);
}
}
all.sort_by_key(|s| s.created_at);
Ok(all)
}
pub fn referenced_rootfs_paths(&self) -> Result<BTreeSet<PathBuf>> {
if !self.root.exists() {
return Ok(BTreeSet::new());
}
let mut pinned = BTreeSet::new();
for vm in std::fs::read_dir(&self.root).map_err(VmmError::Io)? {
let vm_dir = vm.map_err(VmmError::Io)?.path();
if !vm_dir.is_dir() {
continue;
}
for snapshot in std::fs::read_dir(&vm_dir).map_err(VmmError::Io)? {
let snapshot_dir = snapshot.map_err(VmmError::Io)?.path();
if !is_catalog_entry(&snapshot_dir) {
continue;
}
if let Some(rootfs) = self.read_meta(&snapshot_dir)?.rootfs_path {
pinned.insert(PathBuf::from(rootfs));
}
}
}
Ok(pinned)
}
pub fn delete_by_id(&self, snapshot_id: &str) -> Result<()> {
let meta = self.find_by_id(snapshot_id)?;
self.delete(&meta.vm_id, snapshot_id)
}
fn vm_dir(&self, vm_id: &str) -> PathBuf {
self.root.join(vm_id)
}
fn snapshot_dir(&self, vm_id: &str, snapshot_id: &str) -> PathBuf {
self.vm_dir(vm_id).join(snapshot_id)
}
fn staging_dir(&self, vm_id: &str, snapshot_id: &str) -> PathBuf {
self.vm_dir(vm_id)
.join(format!(".{snapshot_id}{STAGING_SUFFIX}"))
}
fn meta_path(dir: &Path) -> PathBuf {
dir.join("meta.json")
}
fn read_meta(&self, dir: &Path) -> Result<SnapshotMeta> {
let json = std::fs::read_to_string(Self::meta_path(dir)).map_err(VmmError::Io)?;
let meta = serde_json::from_str(&json)?;
Ok(meta)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SnapshotType;
fn publish(catalog: &SnapshotCatalog, vm_id: &str, draft: SnapshotDraft) -> SnapshotMeta {
let pending = catalog.begin(vm_id).unwrap();
std::fs::write(pending.dir().join(VMSTATE_FILE), b"vmstate").unwrap();
pending.commit(draft).unwrap()
}
fn draft() -> SnapshotDraft {
SnapshotDraft {
labels: HashMap::new(),
name: None,
snapshot_type: SnapshotType::Full,
parent_id: None,
kernel_path: None,
rootfs_path: None,
}
}
fn register_one(catalog: &SnapshotCatalog, vm_id: &str) -> SnapshotMeta {
publish(catalog, vm_id, draft())
}
#[test]
fn commit_publishes_atomically_and_records_catalog_paths() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let pending = catalog.begin("vm-1").unwrap();
let staging = pending.dir();
let id = pending.id().to_owned();
std::fs::write(staging.join(VMSTATE_FILE), b"vmstate").unwrap();
std::fs::write(staging.join(MEM_FILE), b"mem").unwrap();
assert!(catalog.list("vm-1").unwrap().is_empty());
let meta = pending.commit(draft()).unwrap();
assert!(!staging.exists());
let published = catalog.snapshot_dir("vm-1", &id);
assert_eq!(meta.vmstate_path, published.join(VMSTATE_FILE));
assert_eq!(meta.mem_path, Some(published.join(MEM_FILE)));
assert_eq!(catalog.list("vm-1").unwrap().len(), 1);
assert_eq!(
std::fs::metadata(&published).unwrap().permissions().mode() & 0o777,
0o700
);
for file in [VMSTATE_FILE, MEM_FILE, "meta.json"] {
assert_eq!(
std::fs::metadata(published.join(file))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
}
#[test]
fn commit_records_no_mem_when_the_snapshot_has_none() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let meta = register_one(&catalog, "vm-1");
assert_eq!(meta.mem_path, None);
}
#[test]
fn labels_survive_the_catalog_round_trip() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let pending = catalog.begin("vm-1").unwrap();
std::fs::write(pending.dir().join(VMSTATE_FILE), b"vmstate").unwrap();
let labels = HashMap::from([("env".to_owned(), "prod".to_owned())]);
pending
.commit(SnapshotDraft {
labels: labels.clone(),
..draft()
})
.unwrap();
let listed = catalog.list("vm-1").unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].labels, labels);
}
#[test]
fn snapshots_catalogued_before_labels_existed_still_load() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let snap_dir = catalog.snapshot_dir("vm-1", "old-snap");
std::fs::create_dir_all(&snap_dir).unwrap();
std::fs::write(
SnapshotCatalog::meta_path(&snap_dir),
r#"{"id":"old-snap","vm_id":"vm-1","name":null,"snapshot_type":"Full",
"vmstate_path":"/tmp/vmstate","mem_path":null,
"created_at":"2026-01-01T00:00:00Z","parent_id":null,
"kernel_path":null,"rootfs_path":null}"#,
)
.unwrap();
let listed = catalog.list("vm-1").unwrap();
assert_eq!(listed.len(), 1, "pre-labels snapshot must still load");
assert!(listed[0].labels.is_empty());
}
#[test]
fn dropping_without_commit_leaves_no_trace() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let staging = {
let pending = catalog.begin("vm-1").unwrap();
let staging = pending.dir();
std::fs::write(staging.join(MEM_FILE), b"partial").unwrap();
staging
};
assert!(!staging.exists());
assert!(catalog.list("vm-1").unwrap().is_empty());
assert!(catalog.referenced_rootfs_paths().unwrap().is_empty());
}
#[test]
fn sweep_incomplete_reclaims_staging_left_by_a_crash() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let kept = register_one(&catalog, "vm-1");
let orphan = catalog.staging_dir("vm-1", "22222222-dead-beef");
std::fs::create_dir_all(&orphan).unwrap();
std::fs::write(orphan.join(MEM_FILE), b"partial").unwrap();
catalog.sweep_incomplete();
assert!(!orphan.exists());
assert!(catalog.get("vm-1", &kept.id).is_ok());
}
#[test]
fn referenced_rootfs_paths_spans_every_vm_and_skips_snapshots_without_one() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
assert!(catalog.referenced_rootfs_paths().unwrap().is_empty());
let with_rootfs = |vm_id: &str, rootfs: Option<String>| {
publish(
&catalog,
vm_id,
SnapshotDraft {
rootfs_path: rootfs,
..draft()
},
)
};
with_rootfs("vm-1", Some("/var/lib/arcbox/sandbox/rootfs-a.ext4".into()));
with_rootfs("vm-2", Some("/var/lib/arcbox/sandbox/rootfs-b.ext4".into()));
with_rootfs("vm-2", None);
assert_eq!(
catalog.referenced_rootfs_paths().unwrap(),
BTreeSet::from([
PathBuf::from("/var/lib/arcbox/sandbox/rootfs-a.ext4"),
PathBuf::from("/var/lib/arcbox/sandbox/rootfs-b.ext4"),
])
);
}
#[test]
fn referenced_rootfs_paths_fails_on_an_unreadable_record() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let meta = register_one(&catalog, "vm-1");
std::fs::write(
SnapshotCatalog::meta_path(&catalog.snapshot_dir("vm-1", &meta.id)),
"{ truncated",
)
.unwrap();
assert!(catalog.referenced_rootfs_paths().is_err());
}
#[test]
fn test_list_empty() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
assert!(catalog.list("vm-1").unwrap().is_empty());
assert!(catalog.list_all().unwrap().is_empty());
}
#[test]
fn test_register_and_list() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let meta = register_one(&catalog, "vm-1");
let snapshots = catalog.list("vm-1").unwrap();
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].id, meta.id);
assert_eq!(snapshots[0].vm_id, "vm-1");
}
#[test]
fn test_register_and_get() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let meta = publish(
&catalog,
"vm-2",
SnapshotDraft {
name: Some("my-snap".into()),
snapshot_type: SnapshotType::Diff,
..draft()
},
);
let loaded = catalog.get("vm-2", &meta.id).unwrap();
assert_eq!(loaded.id, meta.id);
assert_eq!(loaded.snapshot_type, SnapshotType::Diff);
assert_eq!(loaded.name.as_deref(), Some("my-snap"));
}
#[test]
fn test_delete_removes_snapshot() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let meta = register_one(&catalog, "vm-1");
catalog.delete("vm-1", &meta.id).unwrap();
assert!(catalog.list("vm-1").unwrap().is_empty());
}
#[test]
fn test_find_by_id_across_vms() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let meta = register_one(&catalog, "vm-42");
let found = catalog.find_by_id(&meta.id).unwrap();
assert_eq!(found.vm_id, "vm-42");
}
#[test]
fn test_list_all_across_multiple_vms() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
register_one(&catalog, "vm-a");
register_one(&catalog, "vm-a");
register_one(&catalog, "vm-b");
let all = catalog.list_all().unwrap();
assert_eq!(all.len(), 3);
}
#[test]
fn test_delete_by_id() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let meta = register_one(&catalog, "vm-1");
catalog.delete_by_id(&meta.id).unwrap();
assert!(catalog.list_all().unwrap().is_empty());
}
}