Skip to main content

a3s_box_runtime/
snapshot.rs

1//! VM Snapshot Store — Save and restore VM configuration snapshots.
2//!
3//! Snapshots are stored as directories under `~/.a3s/snapshots/<id>/`:
4//! - `metadata.json` — SnapshotMetadata (config, resources, env, etc.)
5//! - `rootfs/` — Copy of the box's rootfs (or symlink to cache)
6//!
7//! Restore creates a new box from the saved configuration, leveraging
8//! rootfs caching for sub-500ms cold start.
9
10use std::cmp::Reverse;
11use std::path::{Path, PathBuf};
12
13use a3s_box_core::error::{BoxError, Result};
14use a3s_box_core::snapshot::SnapshotMetadata;
15use a3s_box_core::SnapshotStoreBackend;
16
17/// Persistent store for VM snapshots.
18pub struct SnapshotStore {
19    /// Root directory for all snapshots
20    base_dir: PathBuf,
21}
22
23impl SnapshotStore {
24    /// Create a new snapshot store at the given directory.
25    pub fn new(base_dir: &Path) -> Result<Self> {
26        std::fs::create_dir_all(base_dir).map_err(|e| {
27            BoxError::CacheError(format!(
28                "Failed to create snapshot directory {}: {}",
29                base_dir.display(),
30                e
31            ))
32        })?;
33        // Sweep leftover `.staging-*` dirs from a prior crashed/aborted save
34        // (mirrors ImageStore::new). `save` builds the whole snapshot in
35        // `.staging-<id>-<pid>-<seq>` and only renames it into `<id>/` after
36        // metadata.json; a SIGKILL/OOM/power-loss mid-copy leaves a full
37        // rootfs-sized staging dir that list/count/prune never see (they key on
38        // metadata.json) and that embeds the dead writer's pid+seq, so no later
39        // process can match it — it would leak permanently without this sweep.
40        if let Ok(entries) = std::fs::read_dir(base_dir) {
41            for entry in entries.flatten() {
42                if entry.file_name().to_string_lossy().starts_with(".staging-") {
43                    let _ = std::fs::remove_dir_all(entry.path());
44                }
45            }
46        }
47        Ok(Self {
48            base_dir: base_dir.to_path_buf(),
49        })
50    }
51
52    /// Open the default snapshot store at `~/.a3s/snapshots`.
53    pub fn default_path() -> Result<Self> {
54        let home = a3s_box_core::dirs_home();
55        Self::new(&home.join("snapshots"))
56    }
57
58    /// Save a snapshot with the given metadata and rootfs source.
59    ///
60    /// Copies the rootfs directory into the snapshot bundle.
61    /// Returns the updated metadata with `size_bytes` populated.
62    pub fn save(
63        &self,
64        mut metadata: SnapshotMetadata,
65        rootfs_source: &Path,
66    ) -> Result<SnapshotMetadata> {
67        let snap_dir = self.base_dir.join(&metadata.id);
68        if snap_dir.exists() {
69            return Err(BoxError::CacheError(format!(
70                "Snapshot '{}' already exists",
71                metadata.id
72            )));
73        }
74
75        // Build the whole snapshot in a staging dir, then atomically rename it to
76        // `<id>/` only after metadata.json is written. A crash mid-save then
77        // leaves at most a `<id>.staging-*` dir (GC-able), never a partial
78        // `<id>/` that get/list ignore (they key on metadata.json) yet that
79        // blocks re-create and never prunes.
80        use std::sync::atomic::{AtomicU64, Ordering};
81        static STAGE_SEQ: AtomicU64 = AtomicU64::new(0);
82        let staging = self.base_dir.join(format!(
83            ".staging-{}-{}-{}",
84            metadata.id,
85            std::process::id(),
86            STAGE_SEQ.fetch_add(1, Ordering::Relaxed)
87        ));
88        let _ = std::fs::remove_dir_all(&staging);
89        std::fs::create_dir_all(&staging).map_err(|e| {
90            BoxError::CacheError(format!(
91                "Failed to create snapshot staging directory {}: {}",
92                staging.display(),
93                e
94            ))
95        })?;
96
97        // Copy rootfs if source exists
98        let rootfs_dest = staging.join("rootfs");
99        if rootfs_source.exists() {
100            copy_dir_recursive(rootfs_source, &rootfs_dest)?;
101        } else {
102            std::fs::create_dir_all(&rootfs_dest).map_err(|e| {
103                BoxError::CacheError(format!("Failed to create snapshot rootfs directory: {}", e))
104            })?;
105        }
106
107        // Calculate size
108        metadata.size_bytes = dir_size(&staging);
109
110        // Write metadata into the staging dir.
111        let meta_path = staging.join("metadata.json");
112        let json = serde_json::to_string_pretty(&metadata).map_err(|e| {
113            BoxError::SerializationError(format!("Failed to serialize snapshot metadata: {}", e))
114        })?;
115        std::fs::write(&meta_path, &json).map_err(|e| {
116            BoxError::CacheError(format!(
117                "Failed to write snapshot metadata {}: {}",
118                meta_path.display(),
119                e
120            ))
121        })?;
122
123        // Atomic publish: the snapshot becomes visible (with its metadata) in one
124        // step, or not at all.
125        std::fs::rename(&staging, &snap_dir).map_err(|e| {
126            let _ = std::fs::remove_dir_all(&staging);
127            BoxError::CacheError(format!(
128                "Failed to publish snapshot {}: {}",
129                snap_dir.display(),
130                e
131            ))
132        })?;
133
134        Ok(metadata)
135    }
136
137    /// Load snapshot metadata by ID.
138    pub fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
139        let meta_path = self.base_dir.join(id).join("metadata.json");
140        if !meta_path.exists() {
141            return Ok(None);
142        }
143
144        let data = std::fs::read_to_string(&meta_path).map_err(|e| {
145            BoxError::CacheError(format!(
146                "Failed to read snapshot metadata {}: {}",
147                meta_path.display(),
148                e
149            ))
150        })?;
151        let metadata: SnapshotMetadata = serde_json::from_str(&data).map_err(|e| {
152            BoxError::SerializationError(format!("Failed to parse snapshot metadata: {}", e))
153        })?;
154        Ok(Some(metadata))
155    }
156
157    /// Get the rootfs path for a snapshot.
158    pub fn rootfs_path(&self, id: &str) -> PathBuf {
159        self.base_dir.join(id).join("rootfs")
160    }
161
162    /// List all snapshots, sorted by creation time (newest first).
163    pub fn list(&self) -> Result<Vec<SnapshotMetadata>> {
164        let mut snapshots = Vec::new();
165
166        let entries = match std::fs::read_dir(&self.base_dir) {
167            Ok(entries) => entries,
168            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(snapshots),
169            Err(e) => {
170                return Err(BoxError::CacheError(format!(
171                    "Failed to read snapshot directory: {}",
172                    e
173                )));
174            }
175        };
176
177        for entry in entries {
178            let entry = entry.map_err(|e| {
179                BoxError::CacheError(format!("Failed to read snapshot entry: {}", e))
180            })?;
181            let meta_path = entry.path().join("metadata.json");
182            if meta_path.exists() {
183                match std::fs::read_to_string(&meta_path) {
184                    Ok(data) => match serde_json::from_str::<SnapshotMetadata>(&data) {
185                        Ok(meta) => snapshots.push(meta),
186                        // Don't silently skip: a metadata file that won't parse
187                        // makes the snapshot invisible to count/total_size/prune
188                        // while its rootfs keeps consuming disk. Surface it so it
189                        // can be diagnosed and cleaned up.
190                        Err(e) => tracing::warn!(
191                            path = %meta_path.display(),
192                            error = %e,
193                            "Skipping snapshot with unparseable metadata.json (orphaned on disk)"
194                        ),
195                    },
196                    Err(e) => tracing::warn!(
197                        path = %meta_path.display(),
198                        error = %e,
199                        "Skipping snapshot with unreadable metadata.json"
200                    ),
201                }
202            }
203        }
204
205        // Sort newest first
206        snapshots.sort_by_key(|snapshot| Reverse(snapshot.created_at));
207        Ok(snapshots)
208    }
209
210    /// Delete a snapshot by ID.
211    pub fn delete(&self, id: &str) -> Result<bool> {
212        let snap_dir = self.base_dir.join(id);
213        if !snap_dir.exists() {
214            return Ok(false);
215        }
216
217        std::fs::remove_dir_all(&snap_dir).map_err(|e| {
218            BoxError::CacheError(format!("Failed to delete snapshot {}: {}", id, e))
219        })?;
220        Ok(true)
221    }
222
223    /// Count the number of snapshots.
224    pub fn count(&self) -> Result<usize> {
225        Ok(self.list()?.len())
226    }
227
228    /// Calculate total size of all snapshots in bytes.
229    pub fn total_size(&self) -> Result<u64> {
230        Ok(self.list()?.iter().map(|s| s.size_bytes).sum())
231    }
232
233    /// Prune old snapshots to stay within limits.
234    ///
235    /// Removes oldest snapshots first until both `max_count` and `max_bytes`
236    /// constraints are satisfied. A value of 0 means unlimited.
237    pub fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
238        let mut snapshots = self.list()?;
239        let mut removed = Vec::new();
240
241        // Snapshots are sorted newest-first; remove from the end (oldest)
242        while !snapshots.is_empty() {
243            let over_count = max_count > 0 && snapshots.len() > max_count;
244            let total: u64 = snapshots.iter().map(|s| s.size_bytes).sum();
245            let over_size = max_bytes > 0 && total > max_bytes;
246
247            if !over_count && !over_size {
248                break;
249            }
250
251            // Remove the oldest (last in the list)
252            if let Some(oldest) = snapshots.pop() {
253                self.delete(&oldest.id)?;
254                removed.push(oldest.id);
255            }
256        }
257
258        Ok(removed)
259    }
260}
261
262/// Recursively copy a directory.
263fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
264    std::fs::create_dir_all(dst).map_err(|e| {
265        BoxError::CacheError(format!(
266            "Failed to create directory {}: {}",
267            dst.display(),
268            e
269        ))
270    })?;
271
272    for entry in std::fs::read_dir(src).map_err(|e| {
273        BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
274    })? {
275        let entry = entry
276            .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
277        let src_path = entry.path();
278        let dst_path = dst.join(entry.file_name());
279        let file_type = entry.file_type().map_err(|e| {
280            BoxError::CacheError(format!(
281                "Failed to read file type for {}: {}",
282                src_path.display(),
283                e
284            ))
285        })?;
286
287        if file_type.is_symlink() {
288            copy_symlink(&src_path, &dst_path)?;
289        } else if file_type.is_dir() {
290            copy_dir_recursive(&src_path, &dst_path)?;
291        } else {
292            std::fs::copy(&src_path, &dst_path).map_err(|e| {
293                BoxError::CacheError(format!(
294                    "Failed to copy {} → {}: {}",
295                    src_path.display(),
296                    dst_path.display(),
297                    e
298                ))
299            })?;
300        }
301    }
302
303    Ok(())
304}
305
306fn copy_symlink(src: &Path, dst: &Path) -> Result<()> {
307    let target = std::fs::read_link(src).map_err(|e| {
308        BoxError::CacheError(format!("Failed to read symlink {}: {}", src.display(), e))
309    })?;
310
311    #[cfg(unix)]
312    {
313        std::os::unix::fs::symlink(&target, dst).map_err(|e| {
314            BoxError::CacheError(format!(
315                "Failed to create symlink {} → {}: {}",
316                dst.display(),
317                target.display(),
318                e
319            ))
320        })?;
321    }
322
323    #[cfg(windows)]
324    {
325        let is_dir = src.metadata().map(|m| m.is_dir()).unwrap_or(false);
326        let result = if is_dir {
327            std::os::windows::fs::symlink_dir(&target, dst)
328        } else {
329            std::os::windows::fs::symlink_file(&target, dst)
330        };
331        result.map_err(|e| {
332            BoxError::CacheError(format!(
333                "Failed to create symlink {} → {}: {}",
334                dst.display(),
335                target.display(),
336                e
337            ))
338        })?;
339    }
340
341    #[cfg(not(any(unix, windows)))]
342    {
343        let _ = target;
344        return Err(BoxError::CacheError(format!(
345            "Symlink copy is not supported on this platform: {}",
346            src.display()
347        )));
348    }
349
350    Ok(())
351}
352
353/// Calculate the total size of a directory recursively.
354fn dir_size(path: &Path) -> u64 {
355    let mut total = 0u64;
356    if let Ok(entries) = std::fs::read_dir(path) {
357        for entry in entries.flatten() {
358            let p = entry.path();
359            if p.is_dir() {
360                total += dir_size(&p);
361            } else if let Ok(meta) = p.metadata() {
362                total += meta.len();
363            }
364        }
365    }
366    total
367}
368
369impl SnapshotStoreBackend for SnapshotStore {
370    fn save(&self, metadata: SnapshotMetadata, rootfs_source: &Path) -> Result<SnapshotMetadata> {
371        self.save(metadata, rootfs_source)
372    }
373
374    fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
375        self.get(id)
376    }
377
378    fn list(&self) -> Result<Vec<SnapshotMetadata>> {
379        self.list()
380    }
381
382    fn delete(&self, id: &str) -> Result<bool> {
383        self.delete(id)
384    }
385
386    fn count(&self) -> Result<usize> {
387        self.count()
388    }
389
390    fn total_size(&self) -> Result<u64> {
391        self.total_size()
392    }
393
394    fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
395        self.prune(max_count, max_bytes)
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use tempfile::TempDir;
403
404    fn make_metadata(id: &str, name: &str) -> SnapshotMetadata {
405        SnapshotMetadata::new(
406            id.to_string(),
407            name.to_string(),
408            "box-source".to_string(),
409            "alpine:latest".to_string(),
410        )
411    }
412
413    fn make_rootfs(tmp: &TempDir) -> PathBuf {
414        let rootfs = tmp.path().join("rootfs");
415        std::fs::create_dir_all(&rootfs).unwrap();
416        std::fs::write(rootfs.join("bin.sh"), "#!/bin/sh\necho hello").unwrap();
417        std::fs::create_dir_all(rootfs.join("etc")).unwrap();
418        std::fs::write(rootfs.join("etc/config"), "key=value").unwrap();
419        rootfs
420    }
421
422    #[test]
423    fn test_snapshot_store_new() {
424        let tmp = TempDir::new().unwrap();
425        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
426        assert!(store.base_dir.exists());
427    }
428
429    #[test]
430    fn test_snapshot_save_and_get() {
431        let tmp = TempDir::new().unwrap();
432        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
433        let rootfs = make_rootfs(&tmp);
434
435        let meta = make_metadata("snap-1", "first");
436        let saved = store.save(meta, &rootfs).unwrap();
437
438        assert_eq!(saved.id, "snap-1");
439        assert!(saved.size_bytes > 0);
440
441        let loaded = store.get("snap-1").unwrap().unwrap();
442        assert_eq!(loaded.id, "snap-1");
443        assert_eq!(loaded.name, "first");
444        assert_eq!(loaded.image, "alpine:latest");
445        assert_eq!(loaded.size_bytes, saved.size_bytes);
446    }
447
448    #[test]
449    fn test_snapshot_get_nonexistent() {
450        let tmp = TempDir::new().unwrap();
451        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
452        assert!(store.get("nonexistent").unwrap().is_none());
453    }
454
455    #[test]
456    fn test_snapshot_save_duplicate_fails() {
457        let tmp = TempDir::new().unwrap();
458        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
459        let rootfs = make_rootfs(&tmp);
460
461        let meta = make_metadata("snap-dup", "dup");
462        store.save(meta.clone(), &rootfs).unwrap();
463
464        let result = store.save(meta, &rootfs);
465        assert!(result.is_err());
466        assert!(result.unwrap_err().to_string().contains("already exists"));
467    }
468
469    #[test]
470    fn test_snapshot_rootfs_copied() {
471        let tmp = TempDir::new().unwrap();
472        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
473        let rootfs = make_rootfs(&tmp);
474
475        let meta = make_metadata("snap-fs", "fs-test");
476        store.save(meta, &rootfs).unwrap();
477
478        let snap_rootfs = store.rootfs_path("snap-fs");
479        assert!(snap_rootfs.join("bin.sh").exists());
480        assert!(snap_rootfs.join("etc/config").exists());
481        assert_eq!(
482            std::fs::read_to_string(snap_rootfs.join("etc/config")).unwrap(),
483            "key=value"
484        );
485    }
486
487    #[test]
488    fn test_snapshot_list_empty() {
489        let tmp = TempDir::new().unwrap();
490        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
491        let list = store.list().unwrap();
492        assert!(list.is_empty());
493    }
494
495    #[test]
496    fn test_snapshot_list_multiple() {
497        let tmp = TempDir::new().unwrap();
498        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
499        let rootfs = make_rootfs(&tmp);
500
501        for i in 0..3 {
502            let meta = make_metadata(&format!("snap-{}", i), &format!("snap-{}", i));
503            store.save(meta, &rootfs).unwrap();
504            std::thread::sleep(std::time::Duration::from_millis(10));
505        }
506
507        let list = store.list().unwrap();
508        assert_eq!(list.len(), 3);
509        // Newest first
510        assert_eq!(list[0].id, "snap-2");
511        assert_eq!(list[2].id, "snap-0");
512    }
513
514    #[test]
515    fn test_snapshot_delete() {
516        let tmp = TempDir::new().unwrap();
517        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
518        let rootfs = make_rootfs(&tmp);
519
520        let meta = make_metadata("snap-del", "delete-me");
521        store.save(meta, &rootfs).unwrap();
522
523        assert!(store.delete("snap-del").unwrap());
524        assert!(store.get("snap-del").unwrap().is_none());
525    }
526
527    #[test]
528    fn test_snapshot_delete_nonexistent() {
529        let tmp = TempDir::new().unwrap();
530        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
531        assert!(!store.delete("nope").unwrap());
532    }
533
534    #[test]
535    fn test_snapshot_count() {
536        let tmp = TempDir::new().unwrap();
537        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
538        let rootfs = make_rootfs(&tmp);
539
540        assert_eq!(store.count().unwrap(), 0);
541
542        store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
543        store.save(make_metadata("s2", "s2"), &rootfs).unwrap();
544        assert_eq!(store.count().unwrap(), 2);
545    }
546
547    #[test]
548    fn test_snapshot_total_size() {
549        let tmp = TempDir::new().unwrap();
550        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
551        let rootfs = make_rootfs(&tmp);
552
553        store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
554        let total = store.total_size().unwrap();
555        assert!(total > 0);
556    }
557
558    #[test]
559    fn test_snapshot_prune_by_count() {
560        let tmp = TempDir::new().unwrap();
561        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
562        let rootfs = make_rootfs(&tmp);
563
564        for i in 0..5 {
565            let meta = make_metadata(&format!("s{}", i), &format!("s{}", i));
566            store.save(meta, &rootfs).unwrap();
567            std::thread::sleep(std::time::Duration::from_millis(10));
568        }
569
570        let removed = store.prune(3, 0).unwrap();
571        assert_eq!(removed.len(), 2);
572        assert_eq!(store.count().unwrap(), 3);
573
574        // Oldest should be removed
575        assert!(store.get("s0").unwrap().is_none());
576        assert!(store.get("s1").unwrap().is_none());
577        // Newest should remain
578        assert!(store.get("s4").unwrap().is_some());
579        assert!(store.get("s3").unwrap().is_some());
580        assert!(store.get("s2").unwrap().is_some());
581    }
582
583    #[test]
584    fn test_snapshot_prune_no_limits() {
585        let tmp = TempDir::new().unwrap();
586        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
587        let rootfs = make_rootfs(&tmp);
588
589        for i in 0..3 {
590            store
591                .save(
592                    make_metadata(&format!("s{}", i), &format!("s{}", i)),
593                    &rootfs,
594                )
595                .unwrap();
596        }
597
598        let removed = store.prune(0, 0).unwrap();
599        assert!(removed.is_empty());
600        assert_eq!(store.count().unwrap(), 3);
601    }
602
603    #[test]
604    fn test_snapshot_save_with_empty_rootfs() {
605        let tmp = TempDir::new().unwrap();
606        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
607        let empty_rootfs = tmp.path().join("nonexistent_rootfs");
608
609        let meta = make_metadata("snap-empty", "empty");
610        let saved = store.save(meta, &empty_rootfs).unwrap();
611        assert_eq!(saved.id, "snap-empty");
612
613        // Rootfs dir should still be created (empty)
614        assert!(store.rootfs_path("snap-empty").exists());
615    }
616
617    #[test]
618    fn test_dir_size() {
619        let tmp = TempDir::new().unwrap();
620        let dir = tmp.path().join("sized");
621        std::fs::create_dir_all(&dir).unwrap();
622        std::fs::write(dir.join("a.txt"), "hello").unwrap();
623        std::fs::write(dir.join("b.txt"), "world!").unwrap();
624
625        let size = dir_size(&dir);
626        assert_eq!(size, 11); // 5 + 6
627    }
628
629    #[test]
630    fn test_dir_size_nested() {
631        let tmp = TempDir::new().unwrap();
632        let dir = tmp.path().join("nested");
633        std::fs::create_dir_all(dir.join("sub")).unwrap();
634        std::fs::write(dir.join("a.txt"), "abc").unwrap();
635        std::fs::write(dir.join("sub/b.txt"), "defgh").unwrap();
636
637        let size = dir_size(&dir);
638        assert_eq!(size, 8); // 3 + 5
639    }
640
641    #[test]
642    fn test_dir_size_empty() {
643        let tmp = TempDir::new().unwrap();
644        let dir = tmp.path().join("empty");
645        std::fs::create_dir_all(&dir).unwrap();
646        assert_eq!(dir_size(&dir), 0);
647    }
648
649    #[test]
650    fn test_copy_dir_recursive() {
651        let tmp = TempDir::new().unwrap();
652        let src = tmp.path().join("src");
653        let dst = tmp.path().join("dst");
654
655        std::fs::create_dir_all(src.join("sub")).unwrap();
656        std::fs::write(src.join("a.txt"), "hello").unwrap();
657        std::fs::write(src.join("sub/b.txt"), "world").unwrap();
658
659        copy_dir_recursive(&src, &dst).unwrap();
660
661        assert!(dst.join("a.txt").exists());
662        assert!(dst.join("sub/b.txt").exists());
663        assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
664        assert_eq!(
665            std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
666            "world"
667        );
668    }
669
670    #[test]
671    fn new_sweeps_leftover_staging_dirs() {
672        let tmp = TempDir::new().unwrap();
673        let base = tmp.path().join("snapshots");
674        std::fs::create_dir_all(&base).unwrap();
675
676        // A full rootfs-sized staging dir leaked by a crashed save...
677        let leaked = base.join(".staging-snap1-12345-0");
678        std::fs::create_dir_all(leaked.join("rootfs")).unwrap();
679        std::fs::write(leaked.join("rootfs").join("big"), vec![0u8; 4096]).unwrap();
680        // ...alongside a real snapshot (keyed by metadata.json).
681        let real = base.join("snap1");
682        std::fs::create_dir_all(&real).unwrap();
683        std::fs::write(real.join("metadata.json"), "{}").unwrap();
684
685        let _store = SnapshotStore::new(&base).unwrap();
686
687        assert!(
688            !leaked.exists(),
689            "leaked .staging-* dir must be swept on open"
690        );
691        assert!(real.exists(), "a real snapshot dir must be preserved");
692    }
693}