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::rootfs_metadata::RootfsMetadataManifest;
15#[cfg(test)]
16use a3s_box_core::rootfs_metadata::{IMAGE_ROOTFS_METADATA_PATH, ROOTFS_METADATA_PATH};
17use a3s_box_core::snapshot::SnapshotMetadata;
18use a3s_box_core::SnapshotStoreBackend;
19
20use crate::file_lock::FileLock;
21
22mod copy;
23
24use copy::{copy_dir_recursive, dir_size, install_rootfs_metadata};
25
26/// Persistent store for VM snapshots.
27pub struct SnapshotStore {
28    /// Root directory for all snapshots
29    base_dir: PathBuf,
30}
31
32impl SnapshotStore {
33    pub(crate) fn acquire_exclusive_lock(&self) -> Result<FileLock> {
34        FileLock::acquire(&self.base_dir.join(".snapshot-store")).map_err(|error| {
35            BoxError::CacheError(format!(
36                "Failed to lock snapshot directory {}: {error}",
37                self.base_dir.display()
38            ))
39        })
40    }
41
42    /// Create a new snapshot store at the given directory.
43    pub fn new(base_dir: &Path) -> Result<Self> {
44        std::fs::create_dir_all(base_dir).map_err(|e| {
45            BoxError::CacheError(format!(
46                "Failed to create snapshot directory {}: {}",
47                base_dir.display(),
48                e
49            ))
50        })?;
51        let _lock = FileLock::acquire(&base_dir.join(".snapshot-store")).map_err(|e| {
52            BoxError::CacheError(format!(
53                "Failed to lock snapshot directory {}: {}",
54                base_dir.display(),
55                e
56            ))
57        })?;
58        // Sweep leftover `.staging-*` dirs from a prior crashed/aborted save
59        // (mirrors ImageStore::new). `save` builds the whole snapshot in
60        // `.staging-<id>-<pid>-<seq>` and only renames it into `<id>/` after
61        // metadata.json; a SIGKILL/OOM/power-loss mid-copy leaves a full
62        // rootfs-sized staging dir that list/count/prune never see (they key on
63        // metadata.json) and that embeds the dead writer's pid+seq, so no later
64        // process can match it — it would leak permanently without this sweep.
65        if let Ok(entries) = std::fs::read_dir(base_dir) {
66            for entry in entries.flatten() {
67                if entry.file_name().to_string_lossy().starts_with(".staging-") {
68                    let _ = std::fs::remove_dir_all(entry.path());
69                }
70            }
71        }
72        Ok(Self {
73            base_dir: base_dir.to_path_buf(),
74        })
75    }
76
77    /// Open the default snapshot store at `~/.a3s/snapshots`.
78    pub fn default_path() -> Result<Self> {
79        let home = a3s_box_core::dirs_home();
80        Self::new(&home.join("snapshots"))
81    }
82
83    /// Save a snapshot with the given metadata and rootfs source.
84    ///
85    /// Copies the rootfs directory into the snapshot bundle.
86    /// Returns the updated metadata with `size_bytes` populated.
87    pub fn save(
88        &self,
89        metadata: SnapshotMetadata,
90        rootfs_source: &Path,
91    ) -> Result<SnapshotMetadata> {
92        self.save_inner(metadata, rootfs_source, None)
93    }
94
95    /// Save a managed Sandbox snapshot with an authoritative terminal rootfs
96    /// metadata manifest captured while the source execution is quiesced.
97    pub(crate) fn save_managed(
98        &self,
99        metadata: SnapshotMetadata,
100        rootfs_source: &Path,
101        rootfs_metadata: &RootfsMetadataManifest,
102    ) -> Result<SnapshotMetadata> {
103        self.save_inner(metadata, rootfs_source, Some(rootfs_metadata))
104    }
105
106    fn save_inner(
107        &self,
108        mut metadata: SnapshotMetadata,
109        rootfs_source: &Path,
110        rootfs_metadata: Option<&RootfsMetadataManifest>,
111    ) -> Result<SnapshotMetadata> {
112        let _lock = FileLock::acquire(&self.base_dir.join(".snapshot-store")).map_err(|e| {
113            BoxError::CacheError(format!(
114                "Failed to lock snapshot directory {}: {}",
115                self.base_dir.display(),
116                e
117            ))
118        })?;
119        let snap_dir = self.base_dir.join(&metadata.id);
120        if snap_dir.exists() {
121            return Err(BoxError::CacheError(format!(
122                "Snapshot '{}' already exists",
123                metadata.id
124            )));
125        }
126
127        // Build the whole snapshot in a staging dir, then atomically rename it to
128        // `<id>/` only after metadata.json is written. A crash mid-save then
129        // leaves at most a `<id>.staging-*` dir (GC-able), never a partial
130        // `<id>/` that get/list ignore (they key on metadata.json) yet that
131        // blocks re-create and never prunes.
132        let staging_prefix = format!(".staging-{}-{}-", metadata.id, std::process::id());
133        let staging = tempfile::Builder::new()
134            .prefix(&staging_prefix)
135            .tempdir_in(&self.base_dir)
136            .map_err(|e| {
137                BoxError::CacheError(format!(
138                    "Failed to create snapshot staging directory in {}: {}",
139                    self.base_dir.display(),
140                    e
141                ))
142            })?;
143
144        // Copy rootfs if source exists
145        let rootfs_dest = staging.path().join("rootfs");
146        if rootfs_source.exists() {
147            copy_dir_recursive(rootfs_source, &rootfs_dest)?;
148        } else {
149            std::fs::create_dir_all(&rootfs_dest).map_err(|e| {
150                BoxError::CacheError(format!("Failed to create snapshot rootfs directory: {}", e))
151            })?;
152        }
153        if let Some(rootfs_metadata) = rootfs_metadata {
154            install_rootfs_metadata(&rootfs_dest, rootfs_metadata)?;
155        }
156
157        // Calculate size
158        metadata.size_bytes = dir_size(&rootfs_dest)?;
159
160        // Write metadata into the staging dir.
161        let meta_path = staging.path().join("metadata.json");
162        let json = serde_json::to_string_pretty(&metadata).map_err(|e| {
163            BoxError::SerializationError(format!("Failed to serialize snapshot metadata: {}", e))
164        })?;
165        std::fs::write(&meta_path, &json).map_err(|e| {
166            BoxError::CacheError(format!(
167                "Failed to write snapshot metadata {}: {}",
168                meta_path.display(),
169                e
170            ))
171        })?;
172
173        // Atomic publish: the snapshot becomes visible (with its metadata) in one
174        // step, or not at all.
175        std::fs::rename(staging.path(), &snap_dir).map_err(|e| {
176            BoxError::CacheError(format!(
177                "Failed to publish snapshot {}: {}",
178                snap_dir.display(),
179                e
180            ))
181        })?;
182
183        Ok(metadata)
184    }
185
186    /// Load snapshot metadata by ID.
187    pub fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
188        let meta_path = self.base_dir.join(id).join("metadata.json");
189        if !meta_path.exists() {
190            return Ok(None);
191        }
192
193        let data = std::fs::read_to_string(&meta_path).map_err(|e| {
194            BoxError::CacheError(format!(
195                "Failed to read snapshot metadata {}: {}",
196                meta_path.display(),
197                e
198            ))
199        })?;
200        let metadata: SnapshotMetadata = serde_json::from_str(&data).map_err(|e| {
201            BoxError::SerializationError(format!("Failed to parse snapshot metadata: {}", e))
202        })?;
203        Ok(Some(metadata))
204    }
205
206    /// Get the rootfs path for a snapshot.
207    pub fn rootfs_path(&self, id: &str) -> PathBuf {
208        self.base_dir.join(id).join("rootfs")
209    }
210
211    /// List all snapshots, sorted by creation time (newest first).
212    pub fn list(&self) -> Result<Vec<SnapshotMetadata>> {
213        let mut snapshots = Vec::new();
214
215        let entries = match std::fs::read_dir(&self.base_dir) {
216            Ok(entries) => entries,
217            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(snapshots),
218            Err(e) => {
219                return Err(BoxError::CacheError(format!(
220                    "Failed to read snapshot directory: {}",
221                    e
222                )));
223            }
224        };
225
226        for entry in entries {
227            let entry = entry.map_err(|e| {
228                BoxError::CacheError(format!("Failed to read snapshot entry: {}", e))
229            })?;
230            let meta_path = entry.path().join("metadata.json");
231            if meta_path.exists() {
232                match std::fs::read_to_string(&meta_path) {
233                    Ok(data) => match serde_json::from_str::<SnapshotMetadata>(&data) {
234                        Ok(meta) => snapshots.push(meta),
235                        // Don't silently skip: a metadata file that won't parse
236                        // makes the snapshot invisible to count/total_size/prune
237                        // while its rootfs keeps consuming disk. Surface it so it
238                        // can be diagnosed and cleaned up.
239                        Err(e) => tracing::warn!(
240                            path = %meta_path.display(),
241                            error = %e,
242                            "Skipping snapshot with unparseable metadata.json (orphaned on disk)"
243                        ),
244                    },
245                    Err(e) => tracing::warn!(
246                        path = %meta_path.display(),
247                        error = %e,
248                        "Skipping snapshot with unreadable metadata.json"
249                    ),
250                }
251            }
252        }
253
254        // Sort newest first
255        snapshots.sort_by_key(|snapshot| Reverse(snapshot.created_at));
256        Ok(snapshots)
257    }
258
259    /// Delete a snapshot by ID.
260    pub fn delete(&self, id: &str) -> Result<bool> {
261        let _lock = self.acquire_exclusive_lock()?;
262        self.delete_locked(id)
263    }
264
265    /// Delete while the caller holds [`Self::acquire_exclusive_lock`].
266    ///
267    /// This is crate-visible so managed execution reservation can validate and
268    /// persist a Snapshot reference under the same lock used by deletion.
269    pub(crate) fn delete_locked(&self, id: &str) -> Result<bool> {
270        let snap_dir = self.base_dir.join(id);
271        if !snap_dir.exists() {
272            return Ok(false);
273        }
274
275        std::fs::remove_dir_all(&snap_dir).map_err(|e| {
276            BoxError::CacheError(format!("Failed to delete snapshot {}: {}", id, e))
277        })?;
278        Ok(true)
279    }
280
281    /// Count the number of snapshots.
282    pub fn count(&self) -> Result<usize> {
283        Ok(self.list()?.len())
284    }
285
286    /// Calculate total size of all snapshots in bytes.
287    pub fn total_size(&self) -> Result<u64> {
288        Ok(self.list()?.iter().map(|s| s.size_bytes).sum())
289    }
290
291    /// Prune old snapshots to stay within limits.
292    ///
293    /// Removes oldest snapshots first until both `max_count` and `max_bytes`
294    /// constraints are satisfied. A value of 0 means unlimited.
295    pub fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
296        // A snapshot a restored box shares as its copy-on-write overlay lower must
297        // never be evicted — deleting a live lower breaks the box (ESTALE) or stops
298        // it from re-starting. Read which are in use from the boxes' `.snapshot-lower`
299        // markers and skip them, evicting the oldest *evictable* snapshot instead.
300        let protected = self.referenced_rootfs_paths();
301        let mut snapshots = self.list()?; // newest-first
302        let mut removed = Vec::new();
303
304        loop {
305            let over_count = max_count > 0 && snapshots.len() > max_count;
306            let total: u64 = snapshots.iter().map(|s| s.size_bytes).sum();
307            let over_size = max_bytes > 0 && total > max_bytes;
308            if !over_count && !over_size {
309                break;
310            }
311
312            // Oldest (last) snapshot that is not an in-use CoW lower.
313            let idx = snapshots
314                .iter()
315                .rposition(|s| !protected.contains(&self.rootfs_path(&s.id)));
316            match idx {
317                Some(i) => {
318                    let snap = snapshots.remove(i);
319                    self.delete(&snap.id)?;
320                    removed.push(snap.id);
321                }
322                // Everything left is protected; can't prune further without
323                // breaking a live box, so stop (caller stays over the cap).
324                None => {
325                    tracing::warn!(
326                        in_use = snapshots.len(),
327                        "snapshot prune kept {} in-use snapshot(s) (each referenced as a \
328                         copy-on-write overlay lower); requested limit not fully met",
329                        snapshots.len()
330                    );
331                    break;
332                }
333            }
334        }
335
336        Ok(removed)
337    }
338
339    /// Rootfs paths currently referenced by a restored box as its CoW overlay lower
340    /// (`<box_dir>/.snapshot-lower`, written by `snapshot restore`). These snapshots
341    /// must not be pruned. Boxes live next to snapshots under the a3s home
342    /// (`base_dir` = `<home>/snapshots`).
343    fn referenced_rootfs_paths(&self) -> std::collections::HashSet<PathBuf> {
344        let mut set = std::collections::HashSet::new();
345        let boxes = match self.base_dir.parent() {
346            Some(home) => home.join("boxes"),
347            None => return set,
348        };
349        if let Ok(entries) = std::fs::read_dir(&boxes) {
350            for entry in entries.flatten() {
351                if let Ok(content) = std::fs::read_to_string(entry.path().join(".snapshot-lower")) {
352                    set.insert(PathBuf::from(content.trim()));
353                }
354            }
355        }
356        set
357    }
358}
359
360impl SnapshotStoreBackend for SnapshotStore {
361    fn save(&self, metadata: SnapshotMetadata, rootfs_source: &Path) -> Result<SnapshotMetadata> {
362        self.save(metadata, rootfs_source)
363    }
364
365    fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
366        self.get(id)
367    }
368
369    fn list(&self) -> Result<Vec<SnapshotMetadata>> {
370        self.list()
371    }
372
373    fn delete(&self, id: &str) -> Result<bool> {
374        self.delete(id)
375    }
376
377    fn count(&self) -> Result<usize> {
378        self.count()
379    }
380
381    fn total_size(&self) -> Result<u64> {
382        self.total_size()
383    }
384
385    fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
386        self.prune(max_count, max_bytes)
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use tempfile::TempDir;
394
395    fn make_metadata(id: &str, name: &str) -> SnapshotMetadata {
396        SnapshotMetadata::new(
397            id.to_string(),
398            name.to_string(),
399            "box-source".to_string(),
400            "alpine:latest".to_string(),
401        )
402    }
403
404    fn make_rootfs(tmp: &TempDir) -> PathBuf {
405        let rootfs = tmp.path().join("rootfs");
406        std::fs::create_dir_all(&rootfs).unwrap();
407        std::fs::write(rootfs.join("bin.sh"), "#!/bin/sh\necho hello").unwrap();
408        std::fs::create_dir_all(rootfs.join("etc")).unwrap();
409        std::fs::write(rootfs.join("etc/config"), "key=value").unwrap();
410        rootfs
411    }
412
413    #[test]
414    fn test_snapshot_store_new() {
415        let tmp = TempDir::new().unwrap();
416        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
417        assert!(store.base_dir.exists());
418    }
419
420    #[test]
421    fn test_snapshot_save_and_get() {
422        let tmp = TempDir::new().unwrap();
423        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
424        let rootfs = make_rootfs(&tmp);
425
426        let meta = make_metadata("snap-1", "first");
427        let saved = store.save(meta, &rootfs).unwrap();
428
429        assert_eq!(saved.id, "snap-1");
430        assert!(saved.size_bytes > 0);
431
432        let loaded = store.get("snap-1").unwrap().unwrap();
433        assert_eq!(loaded.id, "snap-1");
434        assert_eq!(loaded.name, "first");
435        assert_eq!(loaded.image, "alpine:latest");
436        assert_eq!(loaded.size_bytes, saved.size_bytes);
437    }
438
439    #[test]
440    fn test_snapshot_get_nonexistent() {
441        let tmp = TempDir::new().unwrap();
442        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
443        assert!(store.get("nonexistent").unwrap().is_none());
444    }
445
446    #[test]
447    fn test_snapshot_save_duplicate_fails() {
448        let tmp = TempDir::new().unwrap();
449        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
450        let rootfs = make_rootfs(&tmp);
451
452        let meta = make_metadata("snap-dup", "dup");
453        store.save(meta.clone(), &rootfs).unwrap();
454
455        let result = store.save(meta, &rootfs);
456        assert!(result.is_err());
457        assert!(result.unwrap_err().to_string().contains("already exists"));
458    }
459
460    #[test]
461    fn test_snapshot_rootfs_copied() {
462        let tmp = TempDir::new().unwrap();
463        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
464        let rootfs = make_rootfs(&tmp);
465
466        let meta = make_metadata("snap-fs", "fs-test");
467        store.save(meta, &rootfs).unwrap();
468
469        let snap_rootfs = store.rootfs_path("snap-fs");
470        assert!(snap_rootfs.join("bin.sh").exists());
471        assert!(snap_rootfs.join("etc/config").exists());
472        assert_eq!(
473            std::fs::read_to_string(snap_rootfs.join("etc/config")).unwrap(),
474            "key=value"
475        );
476    }
477
478    #[test]
479    fn test_snapshot_list_empty() {
480        let tmp = TempDir::new().unwrap();
481        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
482        let list = store.list().unwrap();
483        assert!(list.is_empty());
484    }
485
486    #[test]
487    fn test_snapshot_list_multiple() {
488        let tmp = TempDir::new().unwrap();
489        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
490        let rootfs = make_rootfs(&tmp);
491
492        for i in 0..3 {
493            let meta = make_metadata(&format!("snap-{}", i), &format!("snap-{}", i));
494            store.save(meta, &rootfs).unwrap();
495            std::thread::sleep(std::time::Duration::from_millis(10));
496        }
497
498        let list = store.list().unwrap();
499        assert_eq!(list.len(), 3);
500        // Newest first
501        assert_eq!(list[0].id, "snap-2");
502        assert_eq!(list[2].id, "snap-0");
503    }
504
505    #[test]
506    fn test_snapshot_delete() {
507        let tmp = TempDir::new().unwrap();
508        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
509        let rootfs = make_rootfs(&tmp);
510
511        let meta = make_metadata("snap-del", "delete-me");
512        store.save(meta, &rootfs).unwrap();
513
514        assert!(store.delete("snap-del").unwrap());
515        assert!(store.get("snap-del").unwrap().is_none());
516    }
517
518    #[test]
519    fn test_snapshot_delete_nonexistent() {
520        let tmp = TempDir::new().unwrap();
521        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
522        assert!(!store.delete("nope").unwrap());
523    }
524
525    #[test]
526    fn test_snapshot_count() {
527        let tmp = TempDir::new().unwrap();
528        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
529        let rootfs = make_rootfs(&tmp);
530
531        assert_eq!(store.count().unwrap(), 0);
532
533        store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
534        store.save(make_metadata("s2", "s2"), &rootfs).unwrap();
535        assert_eq!(store.count().unwrap(), 2);
536    }
537
538    #[test]
539    fn test_snapshot_total_size() {
540        let tmp = TempDir::new().unwrap();
541        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
542        let rootfs = make_rootfs(&tmp);
543
544        store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
545        let total = store.total_size().unwrap();
546        assert!(total > 0);
547    }
548
549    #[test]
550    fn test_snapshot_prune_by_count() {
551        let tmp = TempDir::new().unwrap();
552        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
553        let rootfs = make_rootfs(&tmp);
554
555        for i in 0..5 {
556            let meta = make_metadata(&format!("s{}", i), &format!("s{}", i));
557            store.save(meta, &rootfs).unwrap();
558            std::thread::sleep(std::time::Duration::from_millis(10));
559        }
560
561        let removed = store.prune(3, 0).unwrap();
562        assert_eq!(removed.len(), 2);
563        assert_eq!(store.count().unwrap(), 3);
564
565        // Oldest should be removed
566        assert!(store.get("s0").unwrap().is_none());
567        assert!(store.get("s1").unwrap().is_none());
568        // Newest should remain
569        assert!(store.get("s4").unwrap().is_some());
570        assert!(store.get("s3").unwrap().is_some());
571        assert!(store.get("s2").unwrap().is_some());
572    }
573
574    #[test]
575    fn test_snapshot_prune_no_limits() {
576        let tmp = TempDir::new().unwrap();
577        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
578        let rootfs = make_rootfs(&tmp);
579
580        for i in 0..3 {
581            store
582                .save(
583                    make_metadata(&format!("s{}", i), &format!("s{}", i)),
584                    &rootfs,
585                )
586                .unwrap();
587        }
588
589        let removed = store.prune(0, 0).unwrap();
590        assert!(removed.is_empty());
591        assert_eq!(store.count().unwrap(), 3);
592    }
593
594    #[test]
595    fn prune_skips_in_use_snapshots() {
596        let tmp = TempDir::new().unwrap();
597        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
598        let rootfs = make_rootfs(&tmp);
599
600        for i in 0..5 {
601            store
602                .save(
603                    make_metadata(&format!("s{}", i), &format!("s{}", i)),
604                    &rootfs,
605                )
606                .unwrap();
607            std::thread::sleep(std::time::Duration::from_millis(10));
608        }
609
610        // Mark the OLDEST (s0) as a box's in-use CoW overlay lower.
611        let box_dir = tmp.path().join("boxes").join("box1");
612        std::fs::create_dir_all(&box_dir).unwrap();
613        std::fs::write(
614            box_dir.join(".snapshot-lower"),
615            store.rootfs_path("s0").to_string_lossy().as_bytes(),
616        )
617        .unwrap();
618
619        // keep=3 would normally evict the oldest two (s0, s1); s0 is protected, so
620        // it survives and s1 + s2 are evicted instead.
621        let removed = store.prune(3, 0).unwrap();
622        assert_eq!(removed.len(), 2);
623        assert!(store.get("s0").unwrap().is_some(), "in-use s0 must be kept");
624        assert!(store.get("s1").unwrap().is_none());
625        assert!(store.get("s2").unwrap().is_none());
626        assert!(store.get("s3").unwrap().is_some());
627        assert!(store.get("s4").unwrap().is_some());
628    }
629
630    #[test]
631    fn prune_keeps_everything_when_all_in_use() {
632        let tmp = TempDir::new().unwrap();
633        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
634        let rootfs = make_rootfs(&tmp);
635        store.save(make_metadata("a", "a"), &rootfs).unwrap();
636        store.save(make_metadata("b", "b"), &rootfs).unwrap();
637
638        // Both snapshots are in use as a box's CoW overlay lower.
639        for (i, id) in ["a", "b"].iter().enumerate() {
640            let bd = tmp.path().join("boxes").join(format!("bx{i}"));
641            std::fs::create_dir_all(&bd).unwrap();
642            std::fs::write(
643                bd.join(".snapshot-lower"),
644                store.rootfs_path(id).to_string_lossy().as_bytes(),
645            )
646            .unwrap();
647        }
648
649        // Even asked to keep only 1, prune evicts nothing — both are protected.
650        let removed = store.prune(1, 0).unwrap();
651        assert!(removed.is_empty(), "in-use snapshots must never be pruned");
652        assert_eq!(store.count().unwrap(), 2);
653    }
654
655    #[test]
656    fn test_snapshot_save_with_empty_rootfs() {
657        let tmp = TempDir::new().unwrap();
658        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
659        let empty_rootfs = tmp.path().join("nonexistent_rootfs");
660
661        let meta = make_metadata("snap-empty", "empty");
662        let saved = store.save(meta, &empty_rootfs).unwrap();
663        assert_eq!(saved.id, "snap-empty");
664
665        // Rootfs dir should still be created (empty)
666        assert!(store.rootfs_path("snap-empty").exists());
667    }
668
669    #[test]
670    fn test_dir_size() {
671        let tmp = TempDir::new().unwrap();
672        let dir = tmp.path().join("sized");
673        std::fs::create_dir_all(&dir).unwrap();
674        std::fs::write(dir.join("a.txt"), "hello").unwrap();
675        std::fs::write(dir.join("b.txt"), "world!").unwrap();
676
677        let size = dir_size(&dir).unwrap();
678        assert_eq!(size, 11); // 5 + 6
679    }
680
681    #[test]
682    fn test_dir_size_nested() {
683        let tmp = TempDir::new().unwrap();
684        let dir = tmp.path().join("nested");
685        std::fs::create_dir_all(dir.join("sub")).unwrap();
686        std::fs::write(dir.join("a.txt"), "abc").unwrap();
687        std::fs::write(dir.join("sub/b.txt"), "defgh").unwrap();
688
689        let size = dir_size(&dir).unwrap();
690        assert_eq!(size, 8); // 3 + 5
691    }
692
693    #[test]
694    fn test_dir_size_empty() {
695        let tmp = TempDir::new().unwrap();
696        let dir = tmp.path().join("empty");
697        std::fs::create_dir_all(&dir).unwrap();
698        assert_eq!(dir_size(&dir).unwrap(), 0);
699    }
700
701    #[test]
702    fn test_copy_dir_recursive() {
703        let tmp = TempDir::new().unwrap();
704        let src = tmp.path().join("src");
705        let dst = tmp.path().join("dst");
706
707        std::fs::create_dir_all(src.join("sub")).unwrap();
708        std::fs::write(src.join("a.txt"), "hello").unwrap();
709        std::fs::write(src.join("sub/b.txt"), "world").unwrap();
710
711        copy_dir_recursive(&src, &dst).unwrap();
712
713        assert!(dst.join("a.txt").exists());
714        assert!(dst.join("sub/b.txt").exists());
715        assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
716        assert_eq!(
717            std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
718            "world"
719        );
720    }
721
722    #[cfg(unix)]
723    #[test]
724    fn snapshot_size_does_not_follow_absolute_symlinks() {
725        use std::os::unix::ffi::OsStrExt;
726
727        let tmp = TempDir::new().unwrap();
728        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
729        let rootfs = tmp.path().join("rootfs");
730        std::fs::create_dir(&rootfs).unwrap();
731        let outside = tmp.path().join("outside");
732        std::fs::write(&outside, vec![0_u8; 64 * 1024]).unwrap();
733        std::os::unix::fs::symlink(&outside, rootfs.join("outside-link")).unwrap();
734
735        let saved = store
736            .save(make_metadata("symlink-size", "symlink-size"), &rootfs)
737            .unwrap();
738
739        assert_eq!(
740            saved.size_bytes,
741            outside.as_os_str().as_bytes().len() as u64
742        );
743    }
744
745    #[cfg(unix)]
746    #[test]
747    fn snapshot_preserves_hardlinks_and_xattrs() {
748        use std::os::unix::fs::MetadataExt;
749
750        let tmp = TempDir::new().unwrap();
751        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
752        let rootfs = tmp.path().join("rootfs");
753        std::fs::create_dir(&rootfs).unwrap();
754        let first = rootfs.join("first");
755        let second = rootfs.join("second");
756        std::fs::write(&first, b"shared inode").unwrap();
757        std::fs::hard_link(&first, &second).unwrap();
758        xattr::set(&first, "user.a3s.snapshot", b"preserved").unwrap();
759
760        let saved = store
761            .save(make_metadata("hardlink-xattr", "hardlink-xattr"), &rootfs)
762            .unwrap();
763        let captured = store.rootfs_path(&saved.id);
764        let captured_first = std::fs::metadata(captured.join("first")).unwrap();
765        let captured_second = std::fs::metadata(captured.join("second")).unwrap();
766
767        assert_eq!(captured_first.dev(), captured_second.dev());
768        assert_eq!(captured_first.ino(), captured_second.ino());
769        assert_eq!(saved.size_bytes, b"shared inode".len() as u64);
770        assert_eq!(
771            xattr::get(captured.join("first"), "user.a3s.snapshot").unwrap(),
772            Some(b"preserved".to_vec())
773        );
774    }
775
776    #[cfg(unix)]
777    #[test]
778    fn snapshot_rejects_fifo_without_leaking_staging() {
779        use std::os::unix::ffi::OsStrExt;
780
781        let tmp = TempDir::new().unwrap();
782        let snapshots = tmp.path().join("snapshots");
783        let store = SnapshotStore::new(&snapshots).unwrap();
784        let rootfs = tmp.path().join("rootfs");
785        std::fs::create_dir(&rootfs).unwrap();
786        let fifo = rootfs.join("blocking-fifo");
787        let fifo_path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
788        assert_eq!(unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) }, 0);
789
790        let error = store
791            .save(make_metadata("fifo", "fifo"), &rootfs)
792            .unwrap_err();
793
794        assert!(error.to_string().contains("unsupported special file"));
795        assert!(store.get("fifo").unwrap().is_none());
796        assert!(
797            std::fs::read_dir(snapshots)
798                .unwrap()
799                .flatten()
800                .all(|entry| !entry.file_name().to_string_lossy().starts_with(".staging-")),
801            "failed Snapshot must remove its staging tree"
802        );
803    }
804
805    #[test]
806    fn managed_snapshot_installs_only_terminal_rootfs_metadata() {
807        let tmp = TempDir::new().unwrap();
808        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
809        let rootfs = make_rootfs(&tmp);
810        std::fs::write(
811            rootfs.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')),
812            b"stale image metadata",
813        )
814        .unwrap();
815        let manifest = RootfsMetadataManifest::new(Vec::new());
816
817        store
818            .save_managed(
819                make_metadata("managed-metadata", "managed-metadata"),
820                &rootfs,
821                &manifest,
822            )
823            .unwrap();
824
825        let captured = store.rootfs_path("managed-metadata");
826        assert!(!captured
827            .join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/'))
828            .exists());
829        let stored: RootfsMetadataManifest = serde_json::from_slice(
830            &std::fs::read(captured.join(ROOTFS_METADATA_PATH.trim_start_matches('/'))).unwrap(),
831        )
832        .unwrap();
833        assert_eq!(stored, manifest);
834    }
835
836    #[test]
837    fn new_sweeps_leftover_staging_dirs() {
838        let tmp = TempDir::new().unwrap();
839        let base = tmp.path().join("snapshots");
840        std::fs::create_dir_all(&base).unwrap();
841
842        // A full rootfs-sized staging dir leaked by a crashed save...
843        let leaked = base.join(".staging-snap1-12345-0");
844        std::fs::create_dir_all(leaked.join("rootfs")).unwrap();
845        std::fs::write(leaked.join("rootfs").join("big"), vec![0u8; 4096]).unwrap();
846        // ...alongside a real snapshot (keyed by metadata.json).
847        let real = base.join("snap1");
848        std::fs::create_dir_all(&real).unwrap();
849        std::fs::write(real.join("metadata.json"), "{}").unwrap();
850
851        let _store = SnapshotStore::new(&base).unwrap();
852
853        assert!(
854            !leaked.exists(),
855            "leaked .staging-* dir must be swept on open"
856        );
857        assert!(real.exists(), "a real snapshot dir must be preserved");
858    }
859}