Skip to main content

arcbox_snapshot/
snapshot.rs

1use std::collections::{BTreeSet, HashMap};
2use std::io::Write;
3use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
4use std::path::{Path, PathBuf};
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use tracing::{info, warn};
9use uuid::Uuid;
10
11use crate::error::{Result, SnapshotError};
12
13/// Snapshot type — mirrors Firecracker terminology.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub enum SnapshotType {
16    /// Capture full memory + VM state.
17    Full,
18    /// Capture only dirty pages since the last snapshot.
19    Diff,
20}
21
22/// VM geometry a memory snapshot was captured with.
23///
24/// A Firecracker memory snapshot restores only onto identical
25/// vcpus/memory, so anything that restores a snapshot onto a caller-shaped
26/// VM (template promotion, CORE-107) needs the capture-time geometry.
27/// Absent on metas written before this field existed.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29pub struct SnapshotGeometry {
30    pub vcpus: u32,
31    pub memory_mib: u64,
32}
33
34/// Metadata stored alongside each snapshot on disk.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SnapshotMeta {
37    /// Unique snapshot identifier.
38    pub id: String,
39    /// VM identifier this snapshot belongs to.
40    pub vm_id: String,
41    /// Optional human-readable label.
42    pub name: Option<String>,
43    /// Arbitrary key-value metadata, used for filtering in ListSnapshots.
44    ///
45    /// `serde(default)` so snapshots catalogued before labels existed still
46    /// load (they read back as unlabelled).
47    #[serde(default)]
48    pub labels: HashMap<String, String>,
49    pub snapshot_type: SnapshotType,
50    /// Absolute path to the `vmstate` file.
51    pub vmstate_path: PathBuf,
52    /// Absolute path to the memory file (full snapshots only).
53    pub mem_path: Option<PathBuf>,
54    /// When the snapshot was created.
55    pub created_at: DateTime<Utc>,
56    /// Parent snapshot ID (diff chain).
57    pub parent_id: Option<String>,
58    /// Host-absolute kernel path (required for jailer-mode restore staging).
59    #[serde(default)]
60    pub kernel_path: Option<String>,
61    /// Host-absolute rootfs path (required for jailer-mode restore staging).
62    #[serde(default)]
63    pub rootfs_path: Option<String>,
64    /// Whether the origin guest ran the fixed invariant network identity
65    /// (CORE-81): eth0 on the constant link-local address with the constant
66    /// gateway, external identity applied host-side per TAP. A restore of such
67    /// a snapshot needs zero guest-side network work. `serde(default)` so
68    /// legacy snapshots read back `false` and keep the reconfig-RPC path.
69    #[serde(default)]
70    pub net_invariant: bool,
71    /// Capture-time VM geometry. `serde(default)` so legacy metas read back
72    /// `None` (consumers that need it reject with an actionable error).
73    #[serde(default)]
74    pub geometry: Option<SnapshotGeometry>,
75    /// The checkpoint image's on-disk format, named by the driver that
76    /// wrote it — a restore hands the image only to a driver that names the
77    /// same format. Defaults to [`LEGACY_FORMAT`] for entries catalogued
78    /// before the field existed.
79    #[serde(default = "legacy_format")]
80    pub format: String,
81}
82
83/// The image format of every entry catalogued before
84/// [`SnapshotMeta::format`] was recorded.
85///
86/// Firecracker was the only VMM writing checkpoints then, so a `meta.json`
87/// without the field is a Firecracker `vmstate` + `mem` pair.
88pub const LEGACY_FORMAT: &str = "firecracker/v1";
89
90fn legacy_format() -> String {
91    LEGACY_FORMAT.to_owned()
92}
93
94/// Info returned to callers / gRPC layer.
95#[derive(Debug, Clone)]
96pub struct SnapshotInfo {
97    pub id: String,
98    pub vm_id: String,
99    pub name: Option<String>,
100    /// Arbitrary key-value metadata recorded at checkpoint time.
101    pub labels: HashMap<String, String>,
102    pub snapshot_type: SnapshotType,
103    pub vmstate_path: PathBuf,
104    pub mem_path: Option<PathBuf>,
105    pub created_at: DateTime<Utc>,
106}
107
108impl From<&SnapshotMeta> for SnapshotInfo {
109    fn from(m: &SnapshotMeta) -> Self {
110        Self {
111            id: m.id.clone(),
112            vm_id: m.vm_id.clone(),
113            name: m.name.clone(),
114            labels: m.labels.clone(),
115            snapshot_type: m.snapshot_type,
116            vmstate_path: m.vmstate_path.clone(),
117            mem_path: m.mem_path.clone(),
118            created_at: m.created_at,
119        }
120    }
121}
122
123/// Name of the VM-state file inside a snapshot directory.
124const VMSTATE_FILE: &str = "vmstate";
125
126/// Name of the memory file inside a snapshot directory.
127const MEM_FILE: &str = "mem";
128
129/// Suffix marking a staging directory, i.e. a snapshot still being written.
130///
131/// The leading dot keeps it out of the catalog's `{snapshot_id}` namespace:
132/// snapshot IDs are UUIDs, so no published entry can collide with one.
133const STAGING_SUFFIX: &str = ".partial";
134
135fn secure_dir(path: &Path) -> Result<()> {
136    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
137        .map_err(SnapshotError::from)
138}
139
140fn sync_private_file(path: &Path) -> Result<()> {
141    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
142        .map_err(SnapshotError::from)?;
143    std::fs::File::open(path)
144        .and_then(|file| file.sync_all())
145        .map_err(SnapshotError::from)
146}
147
148fn write_private_file(path: &Path, bytes: &[u8]) -> Result<()> {
149    let mut file = std::fs::OpenOptions::new()
150        .write(true)
151        .create(true)
152        .truncate(true)
153        .mode(0o600)
154        .open(path)
155        .map_err(SnapshotError::from)?;
156    file.set_permissions(std::fs::Permissions::from_mode(0o600))
157        .map_err(SnapshotError::from)?;
158    file.write_all(bytes).map_err(SnapshotError::from)?;
159    file.sync_all().map_err(SnapshotError::from)
160}
161
162fn sync_dir(path: &Path) -> Result<()> {
163    std::fs::File::open(path)
164        .and_then(|dir| dir.sync_all())
165        .map_err(SnapshotError::from)
166}
167
168/// True when `path` is a published snapshot directory.
169///
170/// Staging directories are named `.{snapshot_id}.partial` and are not catalog
171/// members until [`PendingSnapshot::commit`] renames them.
172fn is_catalog_entry(path: &Path) -> bool {
173    path.is_dir()
174        && path
175            .file_name()
176            .and_then(|n| n.to_str())
177            .is_some_and(|name| !name.starts_with('.'))
178}
179
180/// What a caller knows about a snapshot before it is published.
181///
182/// The file layout (`vmstate`, `mem`) is the catalog's business, so it is not
183/// part of this: [`PendingSnapshot::commit`] fills those paths in.
184#[derive(Debug)]
185pub struct SnapshotDraft {
186    /// Optional human-readable label.
187    pub name: Option<String>,
188    /// Arbitrary key-value metadata carried onto the snapshot.
189    pub labels: HashMap<String, String>,
190    pub snapshot_type: SnapshotType,
191    /// Parent snapshot ID (diff chain).
192    pub parent_id: Option<String>,
193    /// Host-absolute kernel path (required for jailer-mode restore staging).
194    pub kernel_path: Option<String>,
195    /// Host-absolute rootfs path (required for restore to rebuild the
196    /// dm-snapshot origin).
197    pub rootfs_path: Option<String>,
198    /// Whether the origin guest ran the invariant network identity (CORE-81).
199    pub net_invariant: bool,
200    /// Capture-time VM geometry, when the producer knows it.
201    pub geometry: Option<SnapshotGeometry>,
202    /// The image format the producer wrote (see [`SnapshotMeta::format`]).
203    pub format: String,
204}
205
206/// A snapshot being written, not yet part of the catalog.
207///
208/// Firecracker writes into [`Self::dir`], a staging directory that is
209/// deliberately outside the catalog's namespace, and [`Self::commit`] writes
210/// `meta.json` there and renames the whole directory into place. That single
211/// atomic publish is what lets every reader assume a catalogued directory has
212/// a readable record: a snapshot is either complete or not there at all.
213///
214/// Dropping without committing removes the staging directory, so a failed
215/// checkpoint leaves neither a record-less entry nor a partial `vmstate`/`mem`
216/// behind. A crash leaves the staging directory, which
217/// [`SnapshotCatalog::sweep_incomplete`] reclaims on the next start.
218pub struct PendingSnapshot<'a> {
219    catalog: &'a SnapshotCatalog,
220    vm_id: String,
221    id: String,
222    published: bool,
223}
224
225impl PendingSnapshot<'_> {
226    /// Identifier the snapshot will carry once published.
227    pub fn id(&self) -> &str {
228        &self.id
229    }
230
231    /// Staging directory to write `vmstate` and `mem` into.
232    pub fn dir(&self) -> PathBuf {
233        self.catalog.staging_dir(&self.vm_id, &self.id)
234    }
235
236    /// Publish the snapshot: write the record, then rename it into the catalog.
237    ///
238    /// The `mem` file is recorded when the staging directory actually holds one
239    /// (diff snapshots and jailer moves may not produce it).
240    pub fn commit(mut self, draft: SnapshotDraft) -> Result<SnapshotMeta> {
241        let staging = self.dir();
242        let published = self.catalog.snapshot_dir(&self.vm_id, &self.id);
243        let has_mem = staging.join(MEM_FILE).exists();
244
245        let meta = SnapshotMeta {
246            id: self.id.clone(),
247            vm_id: self.vm_id.clone(),
248            name: draft.name,
249            labels: draft.labels,
250            snapshot_type: draft.snapshot_type,
251            vmstate_path: published.join(VMSTATE_FILE),
252            mem_path: has_mem.then(|| published.join(MEM_FILE)),
253            created_at: Utc::now(),
254            parent_id: draft.parent_id,
255            kernel_path: draft.kernel_path,
256            rootfs_path: draft.rootfs_path,
257            net_invariant: draft.net_invariant,
258            geometry: draft.geometry,
259            format: draft.format,
260        };
261
262        sync_private_file(&staging.join(VMSTATE_FILE))?;
263        if has_mem {
264            sync_private_file(&staging.join(MEM_FILE))?;
265        }
266        let json = serde_json::to_vec_pretty(&meta)?;
267        write_private_file(&SnapshotCatalog::meta_path(&staging), &json)?;
268        secure_dir(&staging)?;
269        sync_dir(&staging)?;
270        std::fs::rename(&staging, &published).map_err(SnapshotError::from)?;
271        self.published = true;
272        let owner = published.parent().expect("snapshot directory has an owner");
273        sync_dir(owner).map_err(|error| {
274            SnapshotError::Unavailable(format!(
275                "snapshot {} is visible, but publish durability is unconfirmed: {error}",
276                meta.id
277            ))
278        })?;
279
280        info!(snapshot_id = %meta.id, vm_id = %meta.vm_id, "snapshot registered");
281        Ok(meta)
282    }
283}
284
285impl Drop for PendingSnapshot<'_> {
286    fn drop(&mut self) {
287        if self.published {
288            return;
289        }
290        let dir = self.dir();
291        match std::fs::remove_dir_all(&dir) {
292            Ok(()) => {
293                info!(snapshot_id = %self.id, vm_id = %self.vm_id, "discarded incomplete snapshot");
294            }
295            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
296            Err(e) => {
297                warn!(path = %dir.display(), error = %e, "failed to discard incomplete snapshot");
298            }
299        }
300    }
301}
302
303/// Manages the on-disk snapshot catalog for all VMs.
304///
305/// Layout:
306/// ```text
307/// {data_dir}/snapshots/{vm_id}/{snapshot_id}/
308///     vmstate
309///     mem          (full only)
310///     meta.json
311/// {data_dir}/snapshots/{vm_id}/.{snapshot_id}.partial/   (being written)
312/// ```
313///
314/// Every catalogued directory carries a readable `meta.json`, because a
315/// snapshot is written into a `.partial` staging directory and renamed in as a
316/// whole (see [`PendingSnapshot`]). Readers therefore treat a missing or
317/// unparseable record as corruption rather than as a normal lifecycle state.
318pub struct SnapshotCatalog {
319    root: PathBuf,
320}
321
322impl SnapshotCatalog {
323    /// Create a new catalog rooted at `{data_dir}/snapshots`.
324    pub fn new(data_dir: &str) -> Self {
325        Self {
326            root: PathBuf::from(data_dir).join("snapshots"),
327        }
328    }
329
330    /// Start a new snapshot for `vm_id`.
331    ///
332    /// Creates the staging directory; the snapshot enters the catalog only on
333    /// [`PendingSnapshot::commit`].
334    pub fn begin(&self, vm_id: &str) -> Result<PendingSnapshot<'_>> {
335        let pending = PendingSnapshot {
336            catalog: self,
337            vm_id: vm_id.to_owned(),
338            id: Uuid::new_v4().to_string(),
339            published: false,
340        };
341        std::fs::create_dir_all(pending.dir()).map_err(SnapshotError::from)?;
342        secure_dir(&self.root)?;
343        secure_dir(&self.vm_dir(vm_id))?;
344        secure_dir(&pending.dir())?;
345        sync_dir(&self.vm_dir(vm_id))?;
346        sync_dir(&self.root)?;
347        if let Some(data_dir) = self.root.parent() {
348            sync_dir(data_dir)?;
349        }
350        Ok(pending)
351    }
352
353    /// List all snapshots for a VM, sorted by creation time (oldest first).
354    pub fn list(&self, vm_id: &str) -> Result<Vec<SnapshotInfo>> {
355        let dir = self.vm_dir(vm_id);
356        if !dir.exists() {
357            return Ok(vec![]);
358        }
359        let mut entries: Vec<SnapshotMeta> = std::fs::read_dir(&dir)
360            .map_err(SnapshotError::from)?
361            .filter_map(|e| e.ok())
362            .filter(|e| is_catalog_entry(&e.path()))
363            .map(|e| self.read_meta(&e.path()))
364            .collect::<Result<_>>()?;
365        entries.sort_by_key(|m| m.created_at);
366        Ok(entries.iter().map(SnapshotInfo::from).collect())
367    }
368
369    /// Look up a single snapshot by ID.
370    pub fn get(&self, vm_id: &str, snapshot_id: &str) -> Result<SnapshotMeta> {
371        let path = self.snapshot_dir(vm_id, snapshot_id);
372        self.read_meta(&path)
373    }
374
375    /// Delete a snapshot directory from disk.
376    pub fn delete(&self, vm_id: &str, snapshot_id: &str) -> Result<()> {
377        let path = self.snapshot_dir(vm_id, snapshot_id);
378        if !path.exists() {
379            return Err(SnapshotError::Snapshot(format!(
380                "snapshot {snapshot_id} not found for VM {vm_id}"
381            )));
382        }
383        std::fs::remove_dir_all(&path).map_err(SnapshotError::from)?;
384        sync_dir(&self.vm_dir(vm_id))?;
385        info!(snapshot_id, vm_id, "snapshot deleted");
386        Ok(())
387    }
388
389    /// Remove staging directories left behind by a crash mid-checkpoint.
390    ///
391    /// A committed snapshot is renamed into place atomically, so anything still
392    /// staged when the process died is unfinished by definition — and can be
393    /// holding a full memory dump.
394    pub fn sweep_incomplete(&self) {
395        let Ok(vms) = std::fs::read_dir(&self.root) else {
396            return;
397        };
398        for vm in vms.flatten() {
399            let Ok(entries) = std::fs::read_dir(vm.path()) else {
400                continue;
401            };
402            for entry in entries.flatten() {
403                let path = entry.path();
404                let staged = path
405                    .file_name()
406                    .and_then(|n| n.to_str())
407                    .is_some_and(|name| name.starts_with('.') && name.ends_with(STAGING_SUFFIX));
408                if staged && path.is_dir() {
409                    match std::fs::remove_dir_all(&path) {
410                        Ok(()) => info!(path = %path.display(), "removed incomplete snapshot"),
411                        Err(e) => {
412                            warn!(path = %path.display(), error = %e, "failed to remove incomplete snapshot");
413                        }
414                    }
415                }
416            }
417        }
418    }
419
420    /// Find a snapshot by ID alone, searching across all owner directories.
421    pub fn find_by_id(&self, snapshot_id: &str) -> Result<SnapshotMeta> {
422        if !self.root.exists() {
423            return Err(SnapshotError::Snapshot(format!(
424                "snapshot {snapshot_id} not found"
425            )));
426        }
427        for entry in std::fs::read_dir(&self.root).map_err(SnapshotError::from)? {
428            let entry = entry.map_err(SnapshotError::from)?;
429            if entry.path().is_dir() {
430                let snap_path = entry.path().join(snapshot_id);
431                if is_catalog_entry(&snap_path) {
432                    return self.read_meta(&snap_path);
433                }
434            }
435        }
436        Err(SnapshotError::Snapshot(format!(
437            "snapshot {snapshot_id} not found"
438        )))
439    }
440
441    /// List all snapshots across every owner directory, sorted by creation time.
442    pub fn list_all(&self) -> Result<Vec<SnapshotInfo>> {
443        if !self.root.exists() {
444            return Ok(vec![]);
445        }
446        let mut all: Vec<SnapshotInfo> = vec![];
447        for entry in std::fs::read_dir(&self.root).map_err(SnapshotError::from)? {
448            let entry = entry.map_err(SnapshotError::from)?;
449            if entry.path().is_dir() {
450                let owner_id = entry.file_name().to_string_lossy().into_owned();
451                let mut infos = self.list(&owner_id)?;
452                all.append(&mut infos);
453            }
454        }
455        all.sort_by_key(|s| s.created_at);
456        Ok(all)
457    }
458
459    /// Rootfs images that catalogued snapshots depend on.
460    ///
461    /// Restore rebuilds the dm-snapshot origin from
462    /// [`SnapshotMeta::rootfs_path`], and the guest memory in the snapshot was
463    /// captured against those exact bytes — re-converting the same image with a
464    /// different `vm-agent` produces a different file and is not a substitute.
465    /// Anything that garbage-collects rootfs images must treat these as pinned.
466    pub fn referenced_rootfs_paths(&self) -> Result<BTreeSet<PathBuf>> {
467        if !self.root.exists() {
468            return Ok(BTreeSet::new());
469        }
470        let mut pinned = BTreeSet::new();
471        for vm in std::fs::read_dir(&self.root).map_err(SnapshotError::from)? {
472            let vm_dir = vm.map_err(SnapshotError::from)?.path();
473            if !vm_dir.is_dir() {
474                continue;
475            }
476            for snapshot in std::fs::read_dir(&vm_dir).map_err(SnapshotError::from)? {
477                let snapshot_dir = snapshot.map_err(SnapshotError::from)?.path();
478                if !is_catalog_entry(&snapshot_dir) {
479                    continue;
480                }
481                // Strict by design: a catalogued directory always carries a
482                // readable record (see [`PendingSnapshot`]), so a failure here
483                // is real corruption — and treating unknown references as "none"
484                // is what would delete an image a restore still needs.
485                if let Some(rootfs) = self.read_meta(&snapshot_dir)?.rootfs_path {
486                    pinned.insert(PathBuf::from(rootfs));
487                }
488            }
489        }
490        Ok(pinned)
491    }
492
493    /// Delete a snapshot knowing only its ID (searches across all owner directories).
494    pub fn delete_by_id(&self, snapshot_id: &str) -> Result<()> {
495        let meta = self.find_by_id(snapshot_id)?;
496        self.delete(&meta.vm_id, snapshot_id)
497    }
498
499    // -------------------------------------------------------------------------
500    // Private helpers
501    // -------------------------------------------------------------------------
502
503    fn vm_dir(&self, vm_id: &str) -> PathBuf {
504        self.root.join(vm_id)
505    }
506
507    fn snapshot_dir(&self, vm_id: &str, snapshot_id: &str) -> PathBuf {
508        self.vm_dir(vm_id).join(snapshot_id)
509    }
510
511    fn staging_dir(&self, vm_id: &str, snapshot_id: &str) -> PathBuf {
512        self.vm_dir(vm_id)
513            .join(format!(".{snapshot_id}{STAGING_SUFFIX}"))
514    }
515
516    fn meta_path(dir: &Path) -> PathBuf {
517        dir.join("meta.json")
518    }
519
520    fn read_meta(&self, dir: &Path) -> Result<SnapshotMeta> {
521        let json = std::fs::read_to_string(Self::meta_path(dir)).map_err(SnapshotError::from)?;
522        let meta = serde_json::from_str(&json)?;
523        Ok(meta)
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    /// Publish a snapshot the way a producer does: stage, write a `vmstate`,
532    /// then commit.
533    fn publish(catalog: &SnapshotCatalog, vm_id: &str, draft: SnapshotDraft) -> SnapshotMeta {
534        let pending = catalog.begin(vm_id).unwrap();
535        std::fs::write(pending.dir().join(VMSTATE_FILE), b"vmstate").unwrap();
536        pending.commit(draft).unwrap()
537    }
538
539    fn draft() -> SnapshotDraft {
540        SnapshotDraft {
541            labels: HashMap::new(),
542            name: None,
543            snapshot_type: SnapshotType::Full,
544            parent_id: None,
545            kernel_path: None,
546            rootfs_path: None,
547            net_invariant: false,
548            geometry: None,
549            format: "fake/v1".into(),
550        }
551    }
552
553    fn register_one(catalog: &SnapshotCatalog, vm_id: &str) -> SnapshotMeta {
554        publish(catalog, vm_id, draft())
555    }
556
557    #[test]
558    fn commit_publishes_atomically_and_records_catalog_paths() {
559        let dir = tempfile::tempdir().unwrap();
560        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
561
562        let pending = catalog.begin("vm-1").unwrap();
563        let staging = pending.dir();
564        let id = pending.id().to_owned();
565        std::fs::write(staging.join(VMSTATE_FILE), b"vmstate").unwrap();
566        std::fs::write(staging.join(MEM_FILE), b"mem").unwrap();
567        // Nothing is catalogued while the snapshot is still being written.
568        assert!(catalog.list("vm-1").unwrap().is_empty());
569
570        let meta = pending.commit(draft()).unwrap();
571
572        assert!(!staging.exists());
573        let published = catalog.snapshot_dir("vm-1", &id);
574        assert_eq!(meta.vmstate_path, published.join(VMSTATE_FILE));
575        assert_eq!(meta.mem_path, Some(published.join(MEM_FILE)));
576        assert_eq!(catalog.list("vm-1").unwrap().len(), 1);
577        assert_eq!(
578            std::fs::metadata(&published).unwrap().permissions().mode() & 0o777,
579            0o700
580        );
581        for file in [VMSTATE_FILE, MEM_FILE, "meta.json"] {
582            assert_eq!(
583                std::fs::metadata(published.join(file))
584                    .unwrap()
585                    .permissions()
586                    .mode()
587                    & 0o777,
588                0o600
589            );
590        }
591    }
592
593    #[test]
594    fn commit_records_no_mem_when_the_snapshot_has_none() {
595        let dir = tempfile::tempdir().unwrap();
596        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
597        let meta = register_one(&catalog, "vm-1");
598        assert_eq!(meta.mem_path, None);
599    }
600
601    #[test]
602    fn labels_survive_the_catalog_round_trip() {
603        let dir = tempfile::tempdir().unwrap();
604        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
605
606        let pending = catalog.begin("vm-1").unwrap();
607        std::fs::write(pending.dir().join(VMSTATE_FILE), b"vmstate").unwrap();
608        let labels = HashMap::from([("env".to_owned(), "prod".to_owned())]);
609        pending
610            .commit(SnapshotDraft {
611                labels: labels.clone(),
612                ..draft()
613            })
614            .unwrap();
615
616        // Read back through the catalog (i.e. off disk), not from the meta
617        // the commit returned — labels are only useful if they persist.
618        let listed = catalog.list("vm-1").unwrap();
619        assert_eq!(listed.len(), 1);
620        assert_eq!(listed[0].labels, labels);
621    }
622
623    #[test]
624    fn snapshots_catalogued_before_labels_existed_still_load() {
625        let dir = tempfile::tempdir().unwrap();
626        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
627        let snap_dir = catalog.snapshot_dir("vm-1", "old-snap");
628        std::fs::create_dir_all(&snap_dir).unwrap();
629        // A meta.json written before the labels field existed.
630        std::fs::write(
631            SnapshotCatalog::meta_path(&snap_dir),
632            r#"{"id":"old-snap","vm_id":"vm-1","name":null,"snapshot_type":"Full",
633                "vmstate_path":"/tmp/vmstate","mem_path":null,
634                "created_at":"2026-01-01T00:00:00Z","parent_id":null,
635                "kernel_path":null,"rootfs_path":null}"#,
636        )
637        .unwrap();
638
639        let listed = catalog.list("vm-1").unwrap();
640        assert_eq!(listed.len(), 1, "pre-labels snapshot must still load");
641        assert!(listed[0].labels.is_empty());
642        let meta = catalog.get("vm-1", "old-snap").unwrap();
643        // Field absent on disk → legacy addressing → the reconfig-RPC restore
644        // path must stay selected.
645        assert!(!meta.net_invariant);
646        // Field absent on disk → written by the only VMM there was.
647        assert_eq!(meta.format, LEGACY_FORMAT);
648    }
649
650    #[test]
651    fn format_survives_the_catalog_round_trip() {
652        let dir = tempfile::tempdir().unwrap();
653        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
654        let meta = publish(
655            &catalog,
656            "vm-1",
657            SnapshotDraft {
658                format: "cloud-hypervisor/v3".into(),
659                ..draft()
660            },
661        );
662        assert_eq!(
663            catalog.get("vm-1", &meta.id).unwrap().format,
664            "cloud-hypervisor/v3"
665        );
666    }
667
668    #[test]
669    fn net_invariant_survives_the_catalog_round_trip() {
670        let dir = tempfile::tempdir().unwrap();
671        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
672        let meta = publish(
673            &catalog,
674            "vm-1",
675            SnapshotDraft {
676                net_invariant: true,
677                ..draft()
678            },
679        );
680        assert!(catalog.get("vm-1", &meta.id).unwrap().net_invariant);
681    }
682
683    #[test]
684    fn dropping_without_commit_leaves_no_trace() {
685        let dir = tempfile::tempdir().unwrap();
686        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
687
688        let staging = {
689            let pending = catalog.begin("vm-1").unwrap();
690            let staging = pending.dir();
691            // A failed checkpoint can leave a partial memory dump behind.
692            std::fs::write(staging.join(MEM_FILE), b"partial").unwrap();
693            staging
694        };
695
696        assert!(!staging.exists());
697        assert!(catalog.list("vm-1").unwrap().is_empty());
698        // No record-less directory to trip readers up.
699        assert!(catalog.referenced_rootfs_paths().unwrap().is_empty());
700    }
701
702    #[test]
703    fn sweep_incomplete_reclaims_staging_left_by_a_crash() {
704        let dir = tempfile::tempdir().unwrap();
705        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
706        let kept = register_one(&catalog, "vm-1");
707
708        // Simulate a process death between `begin` and `commit`: the staging
709        // directory survives because `Drop` never ran.
710        let orphan = catalog.staging_dir("vm-1", "22222222-dead-beef");
711        std::fs::create_dir_all(&orphan).unwrap();
712        std::fs::write(orphan.join(MEM_FILE), b"partial").unwrap();
713
714        catalog.sweep_incomplete();
715
716        assert!(!orphan.exists());
717        assert!(catalog.get("vm-1", &kept.id).is_ok());
718    }
719
720    #[test]
721    fn referenced_rootfs_paths_spans_every_vm_and_skips_snapshots_without_one() {
722        let dir = tempfile::tempdir().unwrap();
723        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
724        assert!(catalog.referenced_rootfs_paths().unwrap().is_empty());
725
726        let with_rootfs = |vm_id: &str, rootfs: Option<String>| {
727            publish(
728                &catalog,
729                vm_id,
730                SnapshotDraft {
731                    rootfs_path: rootfs,
732                    ..draft()
733                },
734            )
735        };
736        with_rootfs("vm-1", Some("/var/lib/arcbox/sandbox/rootfs-a.ext4".into()));
737        with_rootfs("vm-2", Some("/var/lib/arcbox/sandbox/rootfs-b.ext4".into()));
738        with_rootfs("vm-2", None);
739
740        assert_eq!(
741            catalog.referenced_rootfs_paths().unwrap(),
742            BTreeSet::from([
743                PathBuf::from("/var/lib/arcbox/sandbox/rootfs-a.ext4"),
744                PathBuf::from("/var/lib/arcbox/sandbox/rootfs-b.ext4"),
745            ])
746        );
747    }
748
749    #[test]
750    fn referenced_rootfs_paths_fails_on_an_unreadable_record() {
751        let dir = tempfile::tempdir().unwrap();
752        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
753        let meta = register_one(&catalog, "vm-1");
754        std::fs::write(
755            SnapshotCatalog::meta_path(&catalog.snapshot_dir("vm-1", &meta.id)),
756            "{ truncated",
757        )
758        .unwrap();
759        // Guessing "no references" here would delete an image a restore needs.
760        assert!(catalog.referenced_rootfs_paths().is_err());
761    }
762
763    #[test]
764    fn test_list_empty() {
765        let dir = tempfile::tempdir().unwrap();
766        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
767        assert!(catalog.list("vm-1").unwrap().is_empty());
768        assert!(catalog.list_all().unwrap().is_empty());
769    }
770
771    #[test]
772    fn test_register_and_list() {
773        let dir = tempfile::tempdir().unwrap();
774        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
775        let meta = register_one(&catalog, "vm-1");
776        let snapshots = catalog.list("vm-1").unwrap();
777        assert_eq!(snapshots.len(), 1);
778        assert_eq!(snapshots[0].id, meta.id);
779        assert_eq!(snapshots[0].vm_id, "vm-1");
780    }
781
782    #[test]
783    fn test_register_and_get() {
784        let dir = tempfile::tempdir().unwrap();
785        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
786        let meta = publish(
787            &catalog,
788            "vm-2",
789            SnapshotDraft {
790                name: Some("my-snap".into()),
791                snapshot_type: SnapshotType::Diff,
792                ..draft()
793            },
794        );
795        let loaded = catalog.get("vm-2", &meta.id).unwrap();
796        assert_eq!(loaded.id, meta.id);
797        assert_eq!(loaded.snapshot_type, SnapshotType::Diff);
798        assert_eq!(loaded.name.as_deref(), Some("my-snap"));
799    }
800
801    #[test]
802    fn test_delete_removes_snapshot() {
803        let dir = tempfile::tempdir().unwrap();
804        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
805        let meta = register_one(&catalog, "vm-1");
806        catalog.delete("vm-1", &meta.id).unwrap();
807        assert!(catalog.list("vm-1").unwrap().is_empty());
808    }
809
810    #[test]
811    fn test_find_by_id_across_vms() {
812        let dir = tempfile::tempdir().unwrap();
813        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
814        let meta = register_one(&catalog, "vm-42");
815        let found = catalog.find_by_id(&meta.id).unwrap();
816        assert_eq!(found.vm_id, "vm-42");
817    }
818
819    #[test]
820    fn test_list_all_across_multiple_vms() {
821        let dir = tempfile::tempdir().unwrap();
822        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
823        register_one(&catalog, "vm-a");
824        register_one(&catalog, "vm-a");
825        register_one(&catalog, "vm-b");
826        let all = catalog.list_all().unwrap();
827        assert_eq!(all.len(), 3);
828    }
829
830    #[test]
831    fn test_delete_by_id() {
832        let dir = tempfile::tempdir().unwrap();
833        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
834        let meta = register_one(&catalog, "vm-1");
835        catalog.delete_by_id(&meta.id).unwrap();
836        assert!(catalog.list_all().unwrap().is_empty());
837    }
838}