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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub enum SnapshotType {
16 Full,
18 Diff,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29pub struct SnapshotGeometry {
30 pub vcpus: u32,
31 pub memory_mib: u64,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SnapshotMeta {
37 pub id: String,
39 pub vm_id: String,
41 pub name: Option<String>,
43 #[serde(default)]
48 pub labels: HashMap<String, String>,
49 pub snapshot_type: SnapshotType,
50 pub vmstate_path: PathBuf,
52 pub mem_path: Option<PathBuf>,
54 pub created_at: DateTime<Utc>,
56 pub parent_id: Option<String>,
58 #[serde(default)]
60 pub kernel_path: Option<String>,
61 #[serde(default)]
63 pub rootfs_path: Option<String>,
64 #[serde(default)]
70 pub net_invariant: bool,
71 #[serde(default)]
74 pub geometry: Option<SnapshotGeometry>,
75 #[serde(default = "legacy_format")]
80 pub format: String,
81}
82
83pub const LEGACY_FORMAT: &str = "firecracker/v1";
89
90fn legacy_format() -> String {
91 LEGACY_FORMAT.to_owned()
92}
93
94#[derive(Debug, Clone)]
96pub struct SnapshotInfo {
97 pub id: String,
98 pub vm_id: String,
99 pub name: Option<String>,
100 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
123const VMSTATE_FILE: &str = "vmstate";
125
126const MEM_FILE: &str = "mem";
128
129const 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
168fn 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#[derive(Debug)]
185pub struct SnapshotDraft {
186 pub name: Option<String>,
188 pub labels: HashMap<String, String>,
190 pub snapshot_type: SnapshotType,
191 pub parent_id: Option<String>,
193 pub kernel_path: Option<String>,
195 pub rootfs_path: Option<String>,
198 pub net_invariant: bool,
200 pub geometry: Option<SnapshotGeometry>,
202 pub format: String,
204}
205
206pub struct PendingSnapshot<'a> {
219 catalog: &'a SnapshotCatalog,
220 vm_id: String,
221 id: String,
222 published: bool,
223}
224
225impl PendingSnapshot<'_> {
226 pub fn id(&self) -> &str {
228 &self.id
229 }
230
231 pub fn dir(&self) -> PathBuf {
233 self.catalog.staging_dir(&self.vm_id, &self.id)
234 }
235
236 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
303pub struct SnapshotCatalog {
319 root: PathBuf,
320}
321
322impl SnapshotCatalog {
323 pub fn new(data_dir: &str) -> Self {
325 Self {
326 root: PathBuf::from(data_dir).join("snapshots"),
327 }
328 }
329
330 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(!meta.net_invariant);
646 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 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 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 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 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}