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