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