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        // A snapshot a restored box shares as its copy-on-write overlay lower must
239        // never be evicted — deleting a live lower breaks the box (ESTALE) or stops
240        // it from re-starting. Read which are in use from the boxes' `.snapshot-lower`
241        // markers and skip them, evicting the oldest *evictable* snapshot instead.
242        let protected = self.referenced_rootfs_paths();
243        let mut snapshots = self.list()?; // newest-first
244        let mut removed = Vec::new();
245
246        loop {
247            let over_count = max_count > 0 && snapshots.len() > max_count;
248            let total: u64 = snapshots.iter().map(|s| s.size_bytes).sum();
249            let over_size = max_bytes > 0 && total > max_bytes;
250            if !over_count && !over_size {
251                break;
252            }
253
254            // Oldest (last) snapshot that is not an in-use CoW lower.
255            let idx = snapshots
256                .iter()
257                .rposition(|s| !protected.contains(&self.rootfs_path(&s.id)));
258            match idx {
259                Some(i) => {
260                    let snap = snapshots.remove(i);
261                    self.delete(&snap.id)?;
262                    removed.push(snap.id);
263                }
264                // Everything left is protected; can't prune further without
265                // breaking a live box, so stop (caller stays over the cap).
266                None => {
267                    tracing::warn!(
268                        in_use = snapshots.len(),
269                        "snapshot prune kept {} in-use snapshot(s) (each referenced as a \
270                         copy-on-write overlay lower); requested limit not fully met",
271                        snapshots.len()
272                    );
273                    break;
274                }
275            }
276        }
277
278        Ok(removed)
279    }
280
281    /// Rootfs paths currently referenced by a restored box as its CoW overlay lower
282    /// (`<box_dir>/.snapshot-lower`, written by `snapshot restore`). These snapshots
283    /// must not be pruned. Boxes live next to snapshots under the a3s home
284    /// (`base_dir` = `<home>/snapshots`).
285    fn referenced_rootfs_paths(&self) -> std::collections::HashSet<PathBuf> {
286        let mut set = std::collections::HashSet::new();
287        let boxes = match self.base_dir.parent() {
288            Some(home) => home.join("boxes"),
289            None => return set,
290        };
291        if let Ok(entries) = std::fs::read_dir(&boxes) {
292            for entry in entries.flatten() {
293                if let Ok(content) = std::fs::read_to_string(entry.path().join(".snapshot-lower")) {
294                    set.insert(PathBuf::from(content.trim()));
295                }
296            }
297        }
298        set
299    }
300}
301
302/// Recursively copy a directory.
303fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
304    std::fs::create_dir_all(dst).map_err(|e| {
305        BoxError::CacheError(format!(
306            "Failed to create directory {}: {}",
307            dst.display(),
308            e
309        ))
310    })?;
311
312    for entry in std::fs::read_dir(src).map_err(|e| {
313        BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
314    })? {
315        let entry = entry
316            .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
317        let src_path = entry.path();
318        let dst_path = dst.join(entry.file_name());
319        let file_type = entry.file_type().map_err(|e| {
320            BoxError::CacheError(format!(
321                "Failed to read file type for {}: {}",
322                src_path.display(),
323                e
324            ))
325        })?;
326
327        if file_type.is_symlink() {
328            copy_symlink(&src_path, &dst_path)?;
329        } else if file_type.is_dir() {
330            copy_dir_recursive(&src_path, &dst_path)?;
331        } else {
332            std::fs::copy(&src_path, &dst_path).map_err(|e| {
333                BoxError::CacheError(format!(
334                    "Failed to copy {} → {}: {}",
335                    src_path.display(),
336                    dst_path.display(),
337                    e
338                ))
339            })?;
340        }
341    }
342
343    Ok(())
344}
345
346fn copy_symlink(src: &Path, dst: &Path) -> Result<()> {
347    let target = std::fs::read_link(src).map_err(|e| {
348        BoxError::CacheError(format!("Failed to read symlink {}: {}", src.display(), e))
349    })?;
350
351    #[cfg(unix)]
352    {
353        std::os::unix::fs::symlink(&target, dst).map_err(|e| {
354            BoxError::CacheError(format!(
355                "Failed to create symlink {} → {}: {}",
356                dst.display(),
357                target.display(),
358                e
359            ))
360        })?;
361    }
362
363    #[cfg(windows)]
364    {
365        let is_dir = src.metadata().map(|m| m.is_dir()).unwrap_or(false);
366        let result = if is_dir {
367            std::os::windows::fs::symlink_dir(&target, dst)
368        } else {
369            std::os::windows::fs::symlink_file(&target, dst)
370        };
371        result.map_err(|e| {
372            BoxError::CacheError(format!(
373                "Failed to create symlink {} → {}: {}",
374                dst.display(),
375                target.display(),
376                e
377            ))
378        })?;
379    }
380
381    #[cfg(not(any(unix, windows)))]
382    {
383        let _ = target;
384        return Err(BoxError::CacheError(format!(
385            "Symlink copy is not supported on this platform: {}",
386            src.display()
387        )));
388    }
389
390    Ok(())
391}
392
393/// Calculate the total size of a directory recursively.
394fn dir_size(path: &Path) -> u64 {
395    let mut total = 0u64;
396    if let Ok(entries) = std::fs::read_dir(path) {
397        for entry in entries.flatten() {
398            let p = entry.path();
399            if p.is_dir() {
400                total += dir_size(&p);
401            } else if let Ok(meta) = p.metadata() {
402                total += meta.len();
403            }
404        }
405    }
406    total
407}
408
409impl SnapshotStoreBackend for SnapshotStore {
410    fn save(&self, metadata: SnapshotMetadata, rootfs_source: &Path) -> Result<SnapshotMetadata> {
411        self.save(metadata, rootfs_source)
412    }
413
414    fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
415        self.get(id)
416    }
417
418    fn list(&self) -> Result<Vec<SnapshotMetadata>> {
419        self.list()
420    }
421
422    fn delete(&self, id: &str) -> Result<bool> {
423        self.delete(id)
424    }
425
426    fn count(&self) -> Result<usize> {
427        self.count()
428    }
429
430    fn total_size(&self) -> Result<u64> {
431        self.total_size()
432    }
433
434    fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
435        self.prune(max_count, max_bytes)
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use tempfile::TempDir;
443
444    fn make_metadata(id: &str, name: &str) -> SnapshotMetadata {
445        SnapshotMetadata::new(
446            id.to_string(),
447            name.to_string(),
448            "box-source".to_string(),
449            "alpine:latest".to_string(),
450        )
451    }
452
453    fn make_rootfs(tmp: &TempDir) -> PathBuf {
454        let rootfs = tmp.path().join("rootfs");
455        std::fs::create_dir_all(&rootfs).unwrap();
456        std::fs::write(rootfs.join("bin.sh"), "#!/bin/sh\necho hello").unwrap();
457        std::fs::create_dir_all(rootfs.join("etc")).unwrap();
458        std::fs::write(rootfs.join("etc/config"), "key=value").unwrap();
459        rootfs
460    }
461
462    #[test]
463    fn test_snapshot_store_new() {
464        let tmp = TempDir::new().unwrap();
465        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
466        assert!(store.base_dir.exists());
467    }
468
469    #[test]
470    fn test_snapshot_save_and_get() {
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-1", "first");
476        let saved = store.save(meta, &rootfs).unwrap();
477
478        assert_eq!(saved.id, "snap-1");
479        assert!(saved.size_bytes > 0);
480
481        let loaded = store.get("snap-1").unwrap().unwrap();
482        assert_eq!(loaded.id, "snap-1");
483        assert_eq!(loaded.name, "first");
484        assert_eq!(loaded.image, "alpine:latest");
485        assert_eq!(loaded.size_bytes, saved.size_bytes);
486    }
487
488    #[test]
489    fn test_snapshot_get_nonexistent() {
490        let tmp = TempDir::new().unwrap();
491        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
492        assert!(store.get("nonexistent").unwrap().is_none());
493    }
494
495    #[test]
496    fn test_snapshot_save_duplicate_fails() {
497        let tmp = TempDir::new().unwrap();
498        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
499        let rootfs = make_rootfs(&tmp);
500
501        let meta = make_metadata("snap-dup", "dup");
502        store.save(meta.clone(), &rootfs).unwrap();
503
504        let result = store.save(meta, &rootfs);
505        assert!(result.is_err());
506        assert!(result.unwrap_err().to_string().contains("already exists"));
507    }
508
509    #[test]
510    fn test_snapshot_rootfs_copied() {
511        let tmp = TempDir::new().unwrap();
512        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
513        let rootfs = make_rootfs(&tmp);
514
515        let meta = make_metadata("snap-fs", "fs-test");
516        store.save(meta, &rootfs).unwrap();
517
518        let snap_rootfs = store.rootfs_path("snap-fs");
519        assert!(snap_rootfs.join("bin.sh").exists());
520        assert!(snap_rootfs.join("etc/config").exists());
521        assert_eq!(
522            std::fs::read_to_string(snap_rootfs.join("etc/config")).unwrap(),
523            "key=value"
524        );
525    }
526
527    #[test]
528    fn test_snapshot_list_empty() {
529        let tmp = TempDir::new().unwrap();
530        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
531        let list = store.list().unwrap();
532        assert!(list.is_empty());
533    }
534
535    #[test]
536    fn test_snapshot_list_multiple() {
537        let tmp = TempDir::new().unwrap();
538        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
539        let rootfs = make_rootfs(&tmp);
540
541        for i in 0..3 {
542            let meta = make_metadata(&format!("snap-{}", i), &format!("snap-{}", i));
543            store.save(meta, &rootfs).unwrap();
544            std::thread::sleep(std::time::Duration::from_millis(10));
545        }
546
547        let list = store.list().unwrap();
548        assert_eq!(list.len(), 3);
549        // Newest first
550        assert_eq!(list[0].id, "snap-2");
551        assert_eq!(list[2].id, "snap-0");
552    }
553
554    #[test]
555    fn test_snapshot_delete() {
556        let tmp = TempDir::new().unwrap();
557        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
558        let rootfs = make_rootfs(&tmp);
559
560        let meta = make_metadata("snap-del", "delete-me");
561        store.save(meta, &rootfs).unwrap();
562
563        assert!(store.delete("snap-del").unwrap());
564        assert!(store.get("snap-del").unwrap().is_none());
565    }
566
567    #[test]
568    fn test_snapshot_delete_nonexistent() {
569        let tmp = TempDir::new().unwrap();
570        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
571        assert!(!store.delete("nope").unwrap());
572    }
573
574    #[test]
575    fn test_snapshot_count() {
576        let tmp = TempDir::new().unwrap();
577        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
578        let rootfs = make_rootfs(&tmp);
579
580        assert_eq!(store.count().unwrap(), 0);
581
582        store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
583        store.save(make_metadata("s2", "s2"), &rootfs).unwrap();
584        assert_eq!(store.count().unwrap(), 2);
585    }
586
587    #[test]
588    fn test_snapshot_total_size() {
589        let tmp = TempDir::new().unwrap();
590        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
591        let rootfs = make_rootfs(&tmp);
592
593        store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
594        let total = store.total_size().unwrap();
595        assert!(total > 0);
596    }
597
598    #[test]
599    fn test_snapshot_prune_by_count() {
600        let tmp = TempDir::new().unwrap();
601        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
602        let rootfs = make_rootfs(&tmp);
603
604        for i in 0..5 {
605            let meta = make_metadata(&format!("s{}", i), &format!("s{}", i));
606            store.save(meta, &rootfs).unwrap();
607            std::thread::sleep(std::time::Duration::from_millis(10));
608        }
609
610        let removed = store.prune(3, 0).unwrap();
611        assert_eq!(removed.len(), 2);
612        assert_eq!(store.count().unwrap(), 3);
613
614        // Oldest should be removed
615        assert!(store.get("s0").unwrap().is_none());
616        assert!(store.get("s1").unwrap().is_none());
617        // Newest should remain
618        assert!(store.get("s4").unwrap().is_some());
619        assert!(store.get("s3").unwrap().is_some());
620        assert!(store.get("s2").unwrap().is_some());
621    }
622
623    #[test]
624    fn test_snapshot_prune_no_limits() {
625        let tmp = TempDir::new().unwrap();
626        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
627        let rootfs = make_rootfs(&tmp);
628
629        for i in 0..3 {
630            store
631                .save(
632                    make_metadata(&format!("s{}", i), &format!("s{}", i)),
633                    &rootfs,
634                )
635                .unwrap();
636        }
637
638        let removed = store.prune(0, 0).unwrap();
639        assert!(removed.is_empty());
640        assert_eq!(store.count().unwrap(), 3);
641    }
642
643    #[test]
644    fn prune_skips_in_use_snapshots() {
645        let tmp = TempDir::new().unwrap();
646        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
647        let rootfs = make_rootfs(&tmp);
648
649        for i in 0..5 {
650            store
651                .save(
652                    make_metadata(&format!("s{}", i), &format!("s{}", i)),
653                    &rootfs,
654                )
655                .unwrap();
656            std::thread::sleep(std::time::Duration::from_millis(10));
657        }
658
659        // Mark the OLDEST (s0) as a box's in-use CoW overlay lower.
660        let box_dir = tmp.path().join("boxes").join("box1");
661        std::fs::create_dir_all(&box_dir).unwrap();
662        std::fs::write(
663            box_dir.join(".snapshot-lower"),
664            store.rootfs_path("s0").to_string_lossy().as_bytes(),
665        )
666        .unwrap();
667
668        // keep=3 would normally evict the oldest two (s0, s1); s0 is protected, so
669        // it survives and s1 + s2 are evicted instead.
670        let removed = store.prune(3, 0).unwrap();
671        assert_eq!(removed.len(), 2);
672        assert!(store.get("s0").unwrap().is_some(), "in-use s0 must be kept");
673        assert!(store.get("s1").unwrap().is_none());
674        assert!(store.get("s2").unwrap().is_none());
675        assert!(store.get("s3").unwrap().is_some());
676        assert!(store.get("s4").unwrap().is_some());
677    }
678
679    #[test]
680    fn prune_keeps_everything_when_all_in_use() {
681        let tmp = TempDir::new().unwrap();
682        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
683        let rootfs = make_rootfs(&tmp);
684        store.save(make_metadata("a", "a"), &rootfs).unwrap();
685        store.save(make_metadata("b", "b"), &rootfs).unwrap();
686
687        // Both snapshots are in use as a box's CoW overlay lower.
688        for (i, id) in ["a", "b"].iter().enumerate() {
689            let bd = tmp.path().join("boxes").join(format!("bx{i}"));
690            std::fs::create_dir_all(&bd).unwrap();
691            std::fs::write(
692                bd.join(".snapshot-lower"),
693                store.rootfs_path(id).to_string_lossy().as_bytes(),
694            )
695            .unwrap();
696        }
697
698        // Even asked to keep only 1, prune evicts nothing — both are protected.
699        let removed = store.prune(1, 0).unwrap();
700        assert!(removed.is_empty(), "in-use snapshots must never be pruned");
701        assert_eq!(store.count().unwrap(), 2);
702    }
703
704    #[test]
705    fn test_snapshot_save_with_empty_rootfs() {
706        let tmp = TempDir::new().unwrap();
707        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
708        let empty_rootfs = tmp.path().join("nonexistent_rootfs");
709
710        let meta = make_metadata("snap-empty", "empty");
711        let saved = store.save(meta, &empty_rootfs).unwrap();
712        assert_eq!(saved.id, "snap-empty");
713
714        // Rootfs dir should still be created (empty)
715        assert!(store.rootfs_path("snap-empty").exists());
716    }
717
718    #[test]
719    fn test_dir_size() {
720        let tmp = TempDir::new().unwrap();
721        let dir = tmp.path().join("sized");
722        std::fs::create_dir_all(&dir).unwrap();
723        std::fs::write(dir.join("a.txt"), "hello").unwrap();
724        std::fs::write(dir.join("b.txt"), "world!").unwrap();
725
726        let size = dir_size(&dir);
727        assert_eq!(size, 11); // 5 + 6
728    }
729
730    #[test]
731    fn test_dir_size_nested() {
732        let tmp = TempDir::new().unwrap();
733        let dir = tmp.path().join("nested");
734        std::fs::create_dir_all(dir.join("sub")).unwrap();
735        std::fs::write(dir.join("a.txt"), "abc").unwrap();
736        std::fs::write(dir.join("sub/b.txt"), "defgh").unwrap();
737
738        let size = dir_size(&dir);
739        assert_eq!(size, 8); // 3 + 5
740    }
741
742    #[test]
743    fn test_dir_size_empty() {
744        let tmp = TempDir::new().unwrap();
745        let dir = tmp.path().join("empty");
746        std::fs::create_dir_all(&dir).unwrap();
747        assert_eq!(dir_size(&dir), 0);
748    }
749
750    #[test]
751    fn test_copy_dir_recursive() {
752        let tmp = TempDir::new().unwrap();
753        let src = tmp.path().join("src");
754        let dst = tmp.path().join("dst");
755
756        std::fs::create_dir_all(src.join("sub")).unwrap();
757        std::fs::write(src.join("a.txt"), "hello").unwrap();
758        std::fs::write(src.join("sub/b.txt"), "world").unwrap();
759
760        copy_dir_recursive(&src, &dst).unwrap();
761
762        assert!(dst.join("a.txt").exists());
763        assert!(dst.join("sub/b.txt").exists());
764        assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
765        assert_eq!(
766            std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
767            "world"
768        );
769    }
770
771    #[test]
772    fn new_sweeps_leftover_staging_dirs() {
773        let tmp = TempDir::new().unwrap();
774        let base = tmp.path().join("snapshots");
775        std::fs::create_dir_all(&base).unwrap();
776
777        // A full rootfs-sized staging dir leaked by a crashed save...
778        let leaked = base.join(".staging-snap1-12345-0");
779        std::fs::create_dir_all(leaked.join("rootfs")).unwrap();
780        std::fs::write(leaked.join("rootfs").join("big"), vec![0u8; 4096]).unwrap();
781        // ...alongside a real snapshot (keyed by metadata.json).
782        let real = base.join("snap1");
783        std::fs::create_dir_all(&real).unwrap();
784        std::fs::write(real.join("metadata.json"), "{}").unwrap();
785
786        let _store = SnapshotStore::new(&base).unwrap();
787
788        assert!(
789            !leaked.exists(),
790            "leaked .staging-* dir must be swept on open"
791        );
792        assert!(real.exists(), "a real snapshot dir must be preserved");
793    }
794}