use std::cmp::Reverse;
use std::path::{Path, PathBuf};
use a3s_box_core::error::{BoxError, Result};
use a3s_box_core::rootfs_metadata::RootfsMetadataManifest;
#[cfg(test)]
use a3s_box_core::rootfs_metadata::{
IMAGE_ROOTFS_METADATA_PATH, PREVIOUS_ROOTFS_METADATA_PATH, ROOTFS_METADATA_PATH,
};
use a3s_box_core::snapshot::SnapshotMetadata;
use a3s_box_core::SnapshotStoreBackend;
use crate::file_lock::FileLock;
mod copy;
use copy::{copy_dir_recursive, dir_size, install_rootfs_metadata};
pub struct SnapshotStore {
base_dir: PathBuf,
}
impl SnapshotStore {
pub(crate) fn acquire_exclusive_lock(&self) -> Result<FileLock> {
FileLock::acquire(&self.base_dir.join(".snapshot-store")).map_err(|error| {
BoxError::CacheError(format!(
"Failed to lock snapshot directory {}: {error}",
self.base_dir.display()
))
})
}
pub fn new(base_dir: &Path) -> Result<Self> {
std::fs::create_dir_all(base_dir).map_err(|e| {
BoxError::CacheError(format!(
"Failed to create snapshot directory {}: {}",
base_dir.display(),
e
))
})?;
let _lock = FileLock::acquire(&base_dir.join(".snapshot-store")).map_err(|e| {
BoxError::CacheError(format!(
"Failed to lock snapshot directory {}: {}",
base_dir.display(),
e
))
})?;
if let Ok(entries) = std::fs::read_dir(base_dir) {
for entry in entries.flatten() {
if entry.file_name().to_string_lossy().starts_with(".staging-") {
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
Ok(Self {
base_dir: base_dir.to_path_buf(),
})
}
pub fn default_path() -> Result<Self> {
let home = a3s_box_core::dirs_home();
Self::new(&home.join("snapshots"))
}
pub fn save(
&self,
metadata: SnapshotMetadata,
rootfs_source: &Path,
) -> Result<SnapshotMetadata> {
self.save_inner(metadata, rootfs_source, None)
}
pub(crate) fn save_managed(
&self,
metadata: SnapshotMetadata,
rootfs_source: &Path,
rootfs_metadata: &RootfsMetadataManifest,
) -> Result<SnapshotMetadata> {
self.save_inner(metadata, rootfs_source, Some(rootfs_metadata))
}
fn save_inner(
&self,
mut metadata: SnapshotMetadata,
rootfs_source: &Path,
rootfs_metadata: Option<&RootfsMetadataManifest>,
) -> Result<SnapshotMetadata> {
let _lock = FileLock::acquire(&self.base_dir.join(".snapshot-store")).map_err(|e| {
BoxError::CacheError(format!(
"Failed to lock snapshot directory {}: {}",
self.base_dir.display(),
e
))
})?;
let snap_dir = self.base_dir.join(&metadata.id);
if snap_dir.exists() {
return Err(BoxError::CacheError(format!(
"Snapshot '{}' already exists",
metadata.id
)));
}
let staging_prefix = format!(".staging-{}-{}-", metadata.id, std::process::id());
let staging = tempfile::Builder::new()
.prefix(&staging_prefix)
.tempdir_in(&self.base_dir)
.map_err(|e| {
BoxError::CacheError(format!(
"Failed to create snapshot staging directory in {}: {}",
self.base_dir.display(),
e
))
})?;
let rootfs_dest = staging.path().join("rootfs");
if rootfs_source.exists() {
copy_dir_recursive(rootfs_source, &rootfs_dest)?;
} else {
std::fs::create_dir_all(&rootfs_dest).map_err(|e| {
BoxError::CacheError(format!("Failed to create snapshot rootfs directory: {}", e))
})?;
}
if let Some(rootfs_metadata) = rootfs_metadata {
install_rootfs_metadata(&rootfs_dest, rootfs_metadata)?;
}
metadata.size_bytes = dir_size(&rootfs_dest)?;
let meta_path = staging.path().join("metadata.json");
let json = serde_json::to_string_pretty(&metadata).map_err(|e| {
BoxError::SerializationError(format!("Failed to serialize snapshot metadata: {}", e))
})?;
std::fs::write(&meta_path, &json).map_err(|e| {
BoxError::CacheError(format!(
"Failed to write snapshot metadata {}: {}",
meta_path.display(),
e
))
})?;
std::fs::rename(staging.path(), &snap_dir).map_err(|e| {
BoxError::CacheError(format!(
"Failed to publish snapshot {}: {}",
snap_dir.display(),
e
))
})?;
Ok(metadata)
}
pub fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
let meta_path = self.base_dir.join(id).join("metadata.json");
if !meta_path.exists() {
return Ok(None);
}
let data = std::fs::read_to_string(&meta_path).map_err(|e| {
BoxError::CacheError(format!(
"Failed to read snapshot metadata {}: {}",
meta_path.display(),
e
))
})?;
let metadata: SnapshotMetadata = serde_json::from_str(&data).map_err(|e| {
BoxError::SerializationError(format!("Failed to parse snapshot metadata: {}", e))
})?;
Ok(Some(metadata))
}
pub fn rootfs_path(&self, id: &str) -> PathBuf {
self.base_dir.join(id).join("rootfs")
}
pub fn list(&self) -> Result<Vec<SnapshotMetadata>> {
let mut snapshots = Vec::new();
let entries = match std::fs::read_dir(&self.base_dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(snapshots),
Err(e) => {
return Err(BoxError::CacheError(format!(
"Failed to read snapshot directory: {}",
e
)));
}
};
for entry in entries {
let entry = entry.map_err(|e| {
BoxError::CacheError(format!("Failed to read snapshot entry: {}", e))
})?;
let meta_path = entry.path().join("metadata.json");
if meta_path.exists() {
match std::fs::read_to_string(&meta_path) {
Ok(data) => match serde_json::from_str::<SnapshotMetadata>(&data) {
Ok(meta) => snapshots.push(meta),
Err(e) => tracing::warn!(
path = %meta_path.display(),
error = %e,
"Skipping snapshot with unparseable metadata.json (orphaned on disk)"
),
},
Err(e) => tracing::warn!(
path = %meta_path.display(),
error = %e,
"Skipping snapshot with unreadable metadata.json"
),
}
}
}
snapshots.sort_by_key(|snapshot| Reverse(snapshot.created_at));
Ok(snapshots)
}
pub fn delete(&self, id: &str) -> Result<bool> {
let _lock = self.acquire_exclusive_lock()?;
self.delete_locked(id)
}
pub(crate) fn delete_locked(&self, id: &str) -> Result<bool> {
let snap_dir = self.base_dir.join(id);
if !snap_dir.exists() {
return Ok(false);
}
std::fs::remove_dir_all(&snap_dir).map_err(|e| {
BoxError::CacheError(format!("Failed to delete snapshot {}: {}", id, e))
})?;
Ok(true)
}
pub fn count(&self) -> Result<usize> {
Ok(self.list()?.len())
}
pub fn total_size(&self) -> Result<u64> {
Ok(self.list()?.iter().map(|s| s.size_bytes).sum())
}
pub fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
let protected = self.referenced_rootfs_paths();
let mut snapshots = self.list()?; let mut removed = Vec::new();
loop {
let over_count = max_count > 0 && snapshots.len() > max_count;
let total: u64 = snapshots.iter().map(|s| s.size_bytes).sum();
let over_size = max_bytes > 0 && total > max_bytes;
if !over_count && !over_size {
break;
}
let idx = snapshots
.iter()
.rposition(|s| !protected.contains(&self.rootfs_path(&s.id)));
match idx {
Some(i) => {
let snap = snapshots.remove(i);
self.delete(&snap.id)?;
removed.push(snap.id);
}
None => {
tracing::warn!(
in_use = snapshots.len(),
"snapshot prune kept {} in-use snapshot(s) (each referenced as a \
copy-on-write overlay lower); requested limit not fully met",
snapshots.len()
);
break;
}
}
}
Ok(removed)
}
fn referenced_rootfs_paths(&self) -> std::collections::HashSet<PathBuf> {
let mut set = std::collections::HashSet::new();
let boxes = match self.base_dir.parent() {
Some(home) => home.join("boxes"),
None => return set,
};
if let Ok(entries) = std::fs::read_dir(&boxes) {
for entry in entries.flatten() {
if let Ok(content) = std::fs::read_to_string(entry.path().join(".snapshot-lower")) {
set.insert(PathBuf::from(content.trim()));
}
}
}
set
}
}
impl SnapshotStoreBackend for SnapshotStore {
fn save(&self, metadata: SnapshotMetadata, rootfs_source: &Path) -> Result<SnapshotMetadata> {
self.save(metadata, rootfs_source)
}
fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
self.get(id)
}
fn list(&self) -> Result<Vec<SnapshotMetadata>> {
self.list()
}
fn delete(&self, id: &str) -> Result<bool> {
self.delete(id)
}
fn count(&self) -> Result<usize> {
self.count()
}
fn total_size(&self) -> Result<u64> {
self.total_size()
}
fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
self.prune(max_count, max_bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_metadata(id: &str, name: &str) -> SnapshotMetadata {
SnapshotMetadata::new(
id.to_string(),
name.to_string(),
"box-source".to_string(),
"alpine:latest".to_string(),
)
}
fn make_rootfs(tmp: &TempDir) -> PathBuf {
let rootfs = tmp.path().join("rootfs");
std::fs::create_dir_all(&rootfs).unwrap();
std::fs::write(rootfs.join("bin.sh"), "#!/bin/sh\necho hello").unwrap();
std::fs::create_dir_all(rootfs.join("etc")).unwrap();
std::fs::write(rootfs.join("etc/config"), "key=value").unwrap();
rootfs
}
#[test]
fn test_snapshot_store_new() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
assert!(store.base_dir.exists());
}
#[test]
fn test_snapshot_save_and_get() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = make_rootfs(&tmp);
let meta = make_metadata("snap-1", "first");
let saved = store.save(meta, &rootfs).unwrap();
assert_eq!(saved.id, "snap-1");
assert!(saved.size_bytes > 0);
let loaded = store.get("snap-1").unwrap().unwrap();
assert_eq!(loaded.id, "snap-1");
assert_eq!(loaded.name, "first");
assert_eq!(loaded.image, "alpine:latest");
assert_eq!(loaded.size_bytes, saved.size_bytes);
}
#[test]
fn test_snapshot_get_nonexistent() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
assert!(store.get("nonexistent").unwrap().is_none());
}
#[test]
fn test_snapshot_save_duplicate_fails() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = make_rootfs(&tmp);
let meta = make_metadata("snap-dup", "dup");
store.save(meta.clone(), &rootfs).unwrap();
let result = store.save(meta, &rootfs);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
}
#[test]
fn test_snapshot_rootfs_copied() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = make_rootfs(&tmp);
let meta = make_metadata("snap-fs", "fs-test");
store.save(meta, &rootfs).unwrap();
let snap_rootfs = store.rootfs_path("snap-fs");
assert!(snap_rootfs.join("bin.sh").exists());
assert!(snap_rootfs.join("etc/config").exists());
assert_eq!(
std::fs::read_to_string(snap_rootfs.join("etc/config")).unwrap(),
"key=value"
);
}
#[test]
fn test_snapshot_list_empty() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let list = store.list().unwrap();
assert!(list.is_empty());
}
#[test]
fn test_snapshot_list_multiple() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = make_rootfs(&tmp);
for i in 0..3 {
let meta = make_metadata(&format!("snap-{}", i), &format!("snap-{}", i));
store.save(meta, &rootfs).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
}
let list = store.list().unwrap();
assert_eq!(list.len(), 3);
assert_eq!(list[0].id, "snap-2");
assert_eq!(list[2].id, "snap-0");
}
#[test]
fn test_snapshot_delete() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = make_rootfs(&tmp);
let meta = make_metadata("snap-del", "delete-me");
store.save(meta, &rootfs).unwrap();
assert!(store.delete("snap-del").unwrap());
assert!(store.get("snap-del").unwrap().is_none());
}
#[test]
fn test_snapshot_delete_nonexistent() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
assert!(!store.delete("nope").unwrap());
}
#[test]
fn test_snapshot_count() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = make_rootfs(&tmp);
assert_eq!(store.count().unwrap(), 0);
store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
store.save(make_metadata("s2", "s2"), &rootfs).unwrap();
assert_eq!(store.count().unwrap(), 2);
}
#[test]
fn test_snapshot_total_size() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = make_rootfs(&tmp);
store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
let total = store.total_size().unwrap();
assert!(total > 0);
}
#[test]
fn test_snapshot_prune_by_count() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = make_rootfs(&tmp);
for i in 0..5 {
let meta = make_metadata(&format!("s{}", i), &format!("s{}", i));
store.save(meta, &rootfs).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
}
let removed = store.prune(3, 0).unwrap();
assert_eq!(removed.len(), 2);
assert_eq!(store.count().unwrap(), 3);
assert!(store.get("s0").unwrap().is_none());
assert!(store.get("s1").unwrap().is_none());
assert!(store.get("s4").unwrap().is_some());
assert!(store.get("s3").unwrap().is_some());
assert!(store.get("s2").unwrap().is_some());
}
#[test]
fn test_snapshot_prune_no_limits() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = make_rootfs(&tmp);
for i in 0..3 {
store
.save(
make_metadata(&format!("s{}", i), &format!("s{}", i)),
&rootfs,
)
.unwrap();
}
let removed = store.prune(0, 0).unwrap();
assert!(removed.is_empty());
assert_eq!(store.count().unwrap(), 3);
}
#[test]
fn prune_skips_in_use_snapshots() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = make_rootfs(&tmp);
for i in 0..5 {
store
.save(
make_metadata(&format!("s{}", i), &format!("s{}", i)),
&rootfs,
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
}
let box_dir = tmp.path().join("boxes").join("box1");
std::fs::create_dir_all(&box_dir).unwrap();
std::fs::write(
box_dir.join(".snapshot-lower"),
store.rootfs_path("s0").to_string_lossy().as_bytes(),
)
.unwrap();
let removed = store.prune(3, 0).unwrap();
assert_eq!(removed.len(), 2);
assert!(store.get("s0").unwrap().is_some(), "in-use s0 must be kept");
assert!(store.get("s1").unwrap().is_none());
assert!(store.get("s2").unwrap().is_none());
assert!(store.get("s3").unwrap().is_some());
assert!(store.get("s4").unwrap().is_some());
}
#[test]
fn prune_keeps_everything_when_all_in_use() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = make_rootfs(&tmp);
store.save(make_metadata("a", "a"), &rootfs).unwrap();
store.save(make_metadata("b", "b"), &rootfs).unwrap();
for (i, id) in ["a", "b"].iter().enumerate() {
let bd = tmp.path().join("boxes").join(format!("bx{i}"));
std::fs::create_dir_all(&bd).unwrap();
std::fs::write(
bd.join(".snapshot-lower"),
store.rootfs_path(id).to_string_lossy().as_bytes(),
)
.unwrap();
}
let removed = store.prune(1, 0).unwrap();
assert!(removed.is_empty(), "in-use snapshots must never be pruned");
assert_eq!(store.count().unwrap(), 2);
}
#[test]
fn test_snapshot_save_with_empty_rootfs() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let empty_rootfs = tmp.path().join("nonexistent_rootfs");
let meta = make_metadata("snap-empty", "empty");
let saved = store.save(meta, &empty_rootfs).unwrap();
assert_eq!(saved.id, "snap-empty");
assert!(store.rootfs_path("snap-empty").exists());
}
#[test]
fn test_dir_size() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("sized");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), "hello").unwrap();
std::fs::write(dir.join("b.txt"), "world!").unwrap();
let size = dir_size(&dir).unwrap();
assert_eq!(size, 11); }
#[test]
fn test_dir_size_nested() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("nested");
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("a.txt"), "abc").unwrap();
std::fs::write(dir.join("sub/b.txt"), "defgh").unwrap();
let size = dir_size(&dir).unwrap();
assert_eq!(size, 8); }
#[test]
fn test_dir_size_empty() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("empty");
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(dir_size(&dir).unwrap(), 0);
}
#[test]
fn test_copy_dir_recursive() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src");
let dst = tmp.path().join("dst");
std::fs::create_dir_all(src.join("sub")).unwrap();
std::fs::write(src.join("a.txt"), "hello").unwrap();
std::fs::write(src.join("sub/b.txt"), "world").unwrap();
copy_dir_recursive(&src, &dst).unwrap();
assert!(dst.join("a.txt").exists());
assert!(dst.join("sub/b.txt").exists());
assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
assert_eq!(
std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
"world"
);
}
#[cfg(unix)]
#[test]
fn snapshot_size_does_not_follow_absolute_symlinks() {
use std::os::unix::ffi::OsStrExt;
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = tmp.path().join("rootfs");
std::fs::create_dir(&rootfs).unwrap();
let outside = tmp.path().join("outside");
std::fs::write(&outside, vec![0_u8; 64 * 1024]).unwrap();
std::os::unix::fs::symlink(&outside, rootfs.join("outside-link")).unwrap();
let saved = store
.save(make_metadata("symlink-size", "symlink-size"), &rootfs)
.unwrap();
assert_eq!(
saved.size_bytes,
outside.as_os_str().as_bytes().len() as u64
);
}
#[cfg(unix)]
#[test]
fn snapshot_preserves_hardlinks_and_xattrs() {
use std::os::unix::fs::MetadataExt;
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = tmp.path().join("rootfs");
std::fs::create_dir(&rootfs).unwrap();
let first = rootfs.join("first");
let second = rootfs.join("second");
std::fs::write(&first, b"shared inode").unwrap();
std::fs::hard_link(&first, &second).unwrap();
xattr::set(&first, "user.a3s.snapshot", b"preserved").unwrap();
let saved = store
.save(make_metadata("hardlink-xattr", "hardlink-xattr"), &rootfs)
.unwrap();
let captured = store.rootfs_path(&saved.id);
let captured_first = std::fs::metadata(captured.join("first")).unwrap();
let captured_second = std::fs::metadata(captured.join("second")).unwrap();
assert_eq!(captured_first.dev(), captured_second.dev());
assert_eq!(captured_first.ino(), captured_second.ino());
assert_eq!(saved.size_bytes, b"shared inode".len() as u64);
assert_eq!(
xattr::get(captured.join("first"), "user.a3s.snapshot").unwrap(),
Some(b"preserved".to_vec())
);
}
#[cfg(unix)]
#[test]
fn snapshot_rejects_fifo_without_leaking_staging() {
use std::os::unix::ffi::OsStrExt;
let tmp = TempDir::new().unwrap();
let snapshots = tmp.path().join("snapshots");
let store = SnapshotStore::new(&snapshots).unwrap();
let rootfs = tmp.path().join("rootfs");
std::fs::create_dir(&rootfs).unwrap();
let fifo = rootfs.join("blocking-fifo");
let fifo_path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
assert_eq!(unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) }, 0);
let error = store
.save(make_metadata("fifo", "fifo"), &rootfs)
.unwrap_err();
assert!(error.to_string().contains("unsupported special file"));
assert!(store.get("fifo").unwrap().is_none());
assert!(
std::fs::read_dir(snapshots)
.unwrap()
.flatten()
.all(|entry| !entry.file_name().to_string_lossy().starts_with(".staging-")),
"failed Snapshot must remove its staging tree"
);
}
#[test]
fn managed_snapshot_installs_only_terminal_rootfs_metadata() {
let tmp = TempDir::new().unwrap();
let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
let rootfs = make_rootfs(&tmp);
std::fs::write(
rootfs.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')),
b"stale image metadata",
)
.unwrap();
std::fs::write(
rootfs.join(PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/')),
b"stale replay metadata",
)
.unwrap();
let manifest = RootfsMetadataManifest::new(Vec::new());
store
.save_managed(
make_metadata("managed-metadata", "managed-metadata"),
&rootfs,
&manifest,
)
.unwrap();
let captured = store.rootfs_path("managed-metadata");
assert!(!captured
.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/'))
.exists());
assert!(!captured
.join(PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/'))
.exists());
let stored: RootfsMetadataManifest = serde_json::from_slice(
&std::fs::read(captured.join(ROOTFS_METADATA_PATH.trim_start_matches('/'))).unwrap(),
)
.unwrap();
assert_eq!(stored, manifest);
}
#[test]
fn new_sweeps_leftover_staging_dirs() {
let tmp = TempDir::new().unwrap();
let base = tmp.path().join("snapshots");
std::fs::create_dir_all(&base).unwrap();
let leaked = base.join(".staging-snap1-12345-0");
std::fs::create_dir_all(leaked.join("rootfs")).unwrap();
std::fs::write(leaked.join("rootfs").join("big"), vec![0u8; 4096]).unwrap();
let real = base.join("snap1");
std::fs::create_dir_all(&real).unwrap();
std::fs::write(real.join("metadata.json"), "{}").unwrap();
let _store = SnapshotStore::new(&base).unwrap();
assert!(
!leaked.exists(),
"leaked .staging-* dir must be swept on open"
);
assert!(real.exists(), "a real snapshot dir must be preserved");
}
}