1use std::cmp::Reverse;
11use std::path::{Path, PathBuf};
12
13use a3s_box_core::error::{BoxError, Result};
14use a3s_box_core::snapshot::SnapshotMetadata;
15use a3s_box_core::SnapshotStoreBackend;
16
17pub struct SnapshotStore {
19 base_dir: PathBuf,
21}
22
23impl SnapshotStore {
24 pub fn new(base_dir: &Path) -> Result<Self> {
26 std::fs::create_dir_all(base_dir).map_err(|e| {
27 BoxError::CacheError(format!(
28 "Failed to create snapshot directory {}: {}",
29 base_dir.display(),
30 e
31 ))
32 })?;
33 if let Ok(entries) = std::fs::read_dir(base_dir) {
41 for entry in entries.flatten() {
42 if entry.file_name().to_string_lossy().starts_with(".staging-") {
43 let _ = std::fs::remove_dir_all(entry.path());
44 }
45 }
46 }
47 Ok(Self {
48 base_dir: base_dir.to_path_buf(),
49 })
50 }
51
52 pub fn default_path() -> Result<Self> {
54 let home = a3s_box_core::dirs_home();
55 Self::new(&home.join("snapshots"))
56 }
57
58 pub fn save(
63 &self,
64 mut metadata: SnapshotMetadata,
65 rootfs_source: &Path,
66 ) -> Result<SnapshotMetadata> {
67 let snap_dir = self.base_dir.join(&metadata.id);
68 if snap_dir.exists() {
69 return Err(BoxError::CacheError(format!(
70 "Snapshot '{}' already exists",
71 metadata.id
72 )));
73 }
74
75 use std::sync::atomic::{AtomicU64, Ordering};
81 static STAGE_SEQ: AtomicU64 = AtomicU64::new(0);
82 let staging = self.base_dir.join(format!(
83 ".staging-{}-{}-{}",
84 metadata.id,
85 std::process::id(),
86 STAGE_SEQ.fetch_add(1, Ordering::Relaxed)
87 ));
88 let _ = std::fs::remove_dir_all(&staging);
89 std::fs::create_dir_all(&staging).map_err(|e| {
90 BoxError::CacheError(format!(
91 "Failed to create snapshot staging directory {}: {}",
92 staging.display(),
93 e
94 ))
95 })?;
96
97 let rootfs_dest = staging.join("rootfs");
99 if rootfs_source.exists() {
100 copy_dir_recursive(rootfs_source, &rootfs_dest)?;
101 } else {
102 std::fs::create_dir_all(&rootfs_dest).map_err(|e| {
103 BoxError::CacheError(format!("Failed to create snapshot rootfs directory: {}", e))
104 })?;
105 }
106
107 metadata.size_bytes = dir_size(&staging);
109
110 let meta_path = staging.join("metadata.json");
112 let json = serde_json::to_string_pretty(&metadata).map_err(|e| {
113 BoxError::SerializationError(format!("Failed to serialize snapshot metadata: {}", e))
114 })?;
115 std::fs::write(&meta_path, &json).map_err(|e| {
116 BoxError::CacheError(format!(
117 "Failed to write snapshot metadata {}: {}",
118 meta_path.display(),
119 e
120 ))
121 })?;
122
123 std::fs::rename(&staging, &snap_dir).map_err(|e| {
126 let _ = std::fs::remove_dir_all(&staging);
127 BoxError::CacheError(format!(
128 "Failed to publish snapshot {}: {}",
129 snap_dir.display(),
130 e
131 ))
132 })?;
133
134 Ok(metadata)
135 }
136
137 pub fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
139 let meta_path = self.base_dir.join(id).join("metadata.json");
140 if !meta_path.exists() {
141 return Ok(None);
142 }
143
144 let data = std::fs::read_to_string(&meta_path).map_err(|e| {
145 BoxError::CacheError(format!(
146 "Failed to read snapshot metadata {}: {}",
147 meta_path.display(),
148 e
149 ))
150 })?;
151 let metadata: SnapshotMetadata = serde_json::from_str(&data).map_err(|e| {
152 BoxError::SerializationError(format!("Failed to parse snapshot metadata: {}", e))
153 })?;
154 Ok(Some(metadata))
155 }
156
157 pub fn rootfs_path(&self, id: &str) -> PathBuf {
159 self.base_dir.join(id).join("rootfs")
160 }
161
162 pub fn list(&self) -> Result<Vec<SnapshotMetadata>> {
164 let mut snapshots = Vec::new();
165
166 let entries = match std::fs::read_dir(&self.base_dir) {
167 Ok(entries) => entries,
168 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(snapshots),
169 Err(e) => {
170 return Err(BoxError::CacheError(format!(
171 "Failed to read snapshot directory: {}",
172 e
173 )));
174 }
175 };
176
177 for entry in entries {
178 let entry = entry.map_err(|e| {
179 BoxError::CacheError(format!("Failed to read snapshot entry: {}", e))
180 })?;
181 let meta_path = entry.path().join("metadata.json");
182 if meta_path.exists() {
183 match std::fs::read_to_string(&meta_path) {
184 Ok(data) => match serde_json::from_str::<SnapshotMetadata>(&data) {
185 Ok(meta) => snapshots.push(meta),
186 Err(e) => tracing::warn!(
191 path = %meta_path.display(),
192 error = %e,
193 "Skipping snapshot with unparseable metadata.json (orphaned on disk)"
194 ),
195 },
196 Err(e) => tracing::warn!(
197 path = %meta_path.display(),
198 error = %e,
199 "Skipping snapshot with unreadable metadata.json"
200 ),
201 }
202 }
203 }
204
205 snapshots.sort_by_key(|snapshot| Reverse(snapshot.created_at));
207 Ok(snapshots)
208 }
209
210 pub fn delete(&self, id: &str) -> Result<bool> {
212 let snap_dir = self.base_dir.join(id);
213 if !snap_dir.exists() {
214 return Ok(false);
215 }
216
217 std::fs::remove_dir_all(&snap_dir).map_err(|e| {
218 BoxError::CacheError(format!("Failed to delete snapshot {}: {}", id, e))
219 })?;
220 Ok(true)
221 }
222
223 pub fn count(&self) -> Result<usize> {
225 Ok(self.list()?.len())
226 }
227
228 pub fn total_size(&self) -> Result<u64> {
230 Ok(self.list()?.iter().map(|s| s.size_bytes).sum())
231 }
232
233 pub fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
238 let protected = self.referenced_rootfs_paths();
243 let mut snapshots = self.list()?; let mut removed = Vec::new();
245
246 loop {
247 let over_count = max_count > 0 && snapshots.len() > max_count;
248 let total: u64 = snapshots.iter().map(|s| s.size_bytes).sum();
249 let over_size = max_bytes > 0 && total > max_bytes;
250 if !over_count && !over_size {
251 break;
252 }
253
254 let idx = snapshots
256 .iter()
257 .rposition(|s| !protected.contains(&self.rootfs_path(&s.id)));
258 match idx {
259 Some(i) => {
260 let snap = snapshots.remove(i);
261 self.delete(&snap.id)?;
262 removed.push(snap.id);
263 }
264 None => {
267 tracing::warn!(
268 in_use = snapshots.len(),
269 "snapshot prune kept {} in-use snapshot(s) (each referenced as a \
270 copy-on-write overlay lower); requested limit not fully met",
271 snapshots.len()
272 );
273 break;
274 }
275 }
276 }
277
278 Ok(removed)
279 }
280
281 fn referenced_rootfs_paths(&self) -> std::collections::HashSet<PathBuf> {
286 let mut set = std::collections::HashSet::new();
287 let boxes = match self.base_dir.parent() {
288 Some(home) => home.join("boxes"),
289 None => return set,
290 };
291 if let Ok(entries) = std::fs::read_dir(&boxes) {
292 for entry in entries.flatten() {
293 if let Ok(content) = std::fs::read_to_string(entry.path().join(".snapshot-lower")) {
294 set.insert(PathBuf::from(content.trim()));
295 }
296 }
297 }
298 set
299 }
300}
301
302fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
304 std::fs::create_dir_all(dst).map_err(|e| {
305 BoxError::CacheError(format!(
306 "Failed to create directory {}: {}",
307 dst.display(),
308 e
309 ))
310 })?;
311
312 for entry in std::fs::read_dir(src).map_err(|e| {
313 BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
314 })? {
315 let entry = entry
316 .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
317 let src_path = entry.path();
318 let dst_path = dst.join(entry.file_name());
319 let file_type = entry.file_type().map_err(|e| {
320 BoxError::CacheError(format!(
321 "Failed to read file type for {}: {}",
322 src_path.display(),
323 e
324 ))
325 })?;
326
327 if file_type.is_symlink() {
328 copy_symlink(&src_path, &dst_path)?;
329 } else if file_type.is_dir() {
330 copy_dir_recursive(&src_path, &dst_path)?;
331 } else {
332 std::fs::copy(&src_path, &dst_path).map_err(|e| {
333 BoxError::CacheError(format!(
334 "Failed to copy {} → {}: {}",
335 src_path.display(),
336 dst_path.display(),
337 e
338 ))
339 })?;
340 }
341 }
342
343 Ok(())
344}
345
346fn copy_symlink(src: &Path, dst: &Path) -> Result<()> {
347 let target = std::fs::read_link(src).map_err(|e| {
348 BoxError::CacheError(format!("Failed to read symlink {}: {}", src.display(), e))
349 })?;
350
351 #[cfg(unix)]
352 {
353 std::os::unix::fs::symlink(&target, dst).map_err(|e| {
354 BoxError::CacheError(format!(
355 "Failed to create symlink {} → {}: {}",
356 dst.display(),
357 target.display(),
358 e
359 ))
360 })?;
361 }
362
363 #[cfg(windows)]
364 {
365 let is_dir = src.metadata().map(|m| m.is_dir()).unwrap_or(false);
366 let result = if is_dir {
367 std::os::windows::fs::symlink_dir(&target, dst)
368 } else {
369 std::os::windows::fs::symlink_file(&target, dst)
370 };
371 result.map_err(|e| {
372 BoxError::CacheError(format!(
373 "Failed to create symlink {} → {}: {}",
374 dst.display(),
375 target.display(),
376 e
377 ))
378 })?;
379 }
380
381 #[cfg(not(any(unix, windows)))]
382 {
383 let _ = target;
384 return Err(BoxError::CacheError(format!(
385 "Symlink copy is not supported on this platform: {}",
386 src.display()
387 )));
388 }
389
390 Ok(())
391}
392
393fn dir_size(path: &Path) -> u64 {
395 let mut total = 0u64;
396 if let Ok(entries) = std::fs::read_dir(path) {
397 for entry in entries.flatten() {
398 let p = entry.path();
399 if p.is_dir() {
400 total += dir_size(&p);
401 } else if let Ok(meta) = p.metadata() {
402 total += meta.len();
403 }
404 }
405 }
406 total
407}
408
409impl SnapshotStoreBackend for SnapshotStore {
410 fn save(&self, metadata: SnapshotMetadata, rootfs_source: &Path) -> Result<SnapshotMetadata> {
411 self.save(metadata, rootfs_source)
412 }
413
414 fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
415 self.get(id)
416 }
417
418 fn list(&self) -> Result<Vec<SnapshotMetadata>> {
419 self.list()
420 }
421
422 fn delete(&self, id: &str) -> Result<bool> {
423 self.delete(id)
424 }
425
426 fn count(&self) -> Result<usize> {
427 self.count()
428 }
429
430 fn total_size(&self) -> Result<u64> {
431 self.total_size()
432 }
433
434 fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
435 self.prune(max_count, max_bytes)
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use tempfile::TempDir;
443
444 fn make_metadata(id: &str, name: &str) -> SnapshotMetadata {
445 SnapshotMetadata::new(
446 id.to_string(),
447 name.to_string(),
448 "box-source".to_string(),
449 "alpine:latest".to_string(),
450 )
451 }
452
453 fn make_rootfs(tmp: &TempDir) -> PathBuf {
454 let rootfs = tmp.path().join("rootfs");
455 std::fs::create_dir_all(&rootfs).unwrap();
456 std::fs::write(rootfs.join("bin.sh"), "#!/bin/sh\necho hello").unwrap();
457 std::fs::create_dir_all(rootfs.join("etc")).unwrap();
458 std::fs::write(rootfs.join("etc/config"), "key=value").unwrap();
459 rootfs
460 }
461
462 #[test]
463 fn test_snapshot_store_new() {
464 let tmp = TempDir::new().unwrap();
465 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
466 assert!(store.base_dir.exists());
467 }
468
469 #[test]
470 fn test_snapshot_save_and_get() {
471 let tmp = TempDir::new().unwrap();
472 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
473 let rootfs = make_rootfs(&tmp);
474
475 let meta = make_metadata("snap-1", "first");
476 let saved = store.save(meta, &rootfs).unwrap();
477
478 assert_eq!(saved.id, "snap-1");
479 assert!(saved.size_bytes > 0);
480
481 let loaded = store.get("snap-1").unwrap().unwrap();
482 assert_eq!(loaded.id, "snap-1");
483 assert_eq!(loaded.name, "first");
484 assert_eq!(loaded.image, "alpine:latest");
485 assert_eq!(loaded.size_bytes, saved.size_bytes);
486 }
487
488 #[test]
489 fn test_snapshot_get_nonexistent() {
490 let tmp = TempDir::new().unwrap();
491 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
492 assert!(store.get("nonexistent").unwrap().is_none());
493 }
494
495 #[test]
496 fn test_snapshot_save_duplicate_fails() {
497 let tmp = TempDir::new().unwrap();
498 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
499 let rootfs = make_rootfs(&tmp);
500
501 let meta = make_metadata("snap-dup", "dup");
502 store.save(meta.clone(), &rootfs).unwrap();
503
504 let result = store.save(meta, &rootfs);
505 assert!(result.is_err());
506 assert!(result.unwrap_err().to_string().contains("already exists"));
507 }
508
509 #[test]
510 fn test_snapshot_rootfs_copied() {
511 let tmp = TempDir::new().unwrap();
512 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
513 let rootfs = make_rootfs(&tmp);
514
515 let meta = make_metadata("snap-fs", "fs-test");
516 store.save(meta, &rootfs).unwrap();
517
518 let snap_rootfs = store.rootfs_path("snap-fs");
519 assert!(snap_rootfs.join("bin.sh").exists());
520 assert!(snap_rootfs.join("etc/config").exists());
521 assert_eq!(
522 std::fs::read_to_string(snap_rootfs.join("etc/config")).unwrap(),
523 "key=value"
524 );
525 }
526
527 #[test]
528 fn test_snapshot_list_empty() {
529 let tmp = TempDir::new().unwrap();
530 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
531 let list = store.list().unwrap();
532 assert!(list.is_empty());
533 }
534
535 #[test]
536 fn test_snapshot_list_multiple() {
537 let tmp = TempDir::new().unwrap();
538 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
539 let rootfs = make_rootfs(&tmp);
540
541 for i in 0..3 {
542 let meta = make_metadata(&format!("snap-{}", i), &format!("snap-{}", i));
543 store.save(meta, &rootfs).unwrap();
544 std::thread::sleep(std::time::Duration::from_millis(10));
545 }
546
547 let list = store.list().unwrap();
548 assert_eq!(list.len(), 3);
549 assert_eq!(list[0].id, "snap-2");
551 assert_eq!(list[2].id, "snap-0");
552 }
553
554 #[test]
555 fn test_snapshot_delete() {
556 let tmp = TempDir::new().unwrap();
557 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
558 let rootfs = make_rootfs(&tmp);
559
560 let meta = make_metadata("snap-del", "delete-me");
561 store.save(meta, &rootfs).unwrap();
562
563 assert!(store.delete("snap-del").unwrap());
564 assert!(store.get("snap-del").unwrap().is_none());
565 }
566
567 #[test]
568 fn test_snapshot_delete_nonexistent() {
569 let tmp = TempDir::new().unwrap();
570 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
571 assert!(!store.delete("nope").unwrap());
572 }
573
574 #[test]
575 fn test_snapshot_count() {
576 let tmp = TempDir::new().unwrap();
577 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
578 let rootfs = make_rootfs(&tmp);
579
580 assert_eq!(store.count().unwrap(), 0);
581
582 store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
583 store.save(make_metadata("s2", "s2"), &rootfs).unwrap();
584 assert_eq!(store.count().unwrap(), 2);
585 }
586
587 #[test]
588 fn test_snapshot_total_size() {
589 let tmp = TempDir::new().unwrap();
590 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
591 let rootfs = make_rootfs(&tmp);
592
593 store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
594 let total = store.total_size().unwrap();
595 assert!(total > 0);
596 }
597
598 #[test]
599 fn test_snapshot_prune_by_count() {
600 let tmp = TempDir::new().unwrap();
601 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
602 let rootfs = make_rootfs(&tmp);
603
604 for i in 0..5 {
605 let meta = make_metadata(&format!("s{}", i), &format!("s{}", i));
606 store.save(meta, &rootfs).unwrap();
607 std::thread::sleep(std::time::Duration::from_millis(10));
608 }
609
610 let removed = store.prune(3, 0).unwrap();
611 assert_eq!(removed.len(), 2);
612 assert_eq!(store.count().unwrap(), 3);
613
614 assert!(store.get("s0").unwrap().is_none());
616 assert!(store.get("s1").unwrap().is_none());
617 assert!(store.get("s4").unwrap().is_some());
619 assert!(store.get("s3").unwrap().is_some());
620 assert!(store.get("s2").unwrap().is_some());
621 }
622
623 #[test]
624 fn test_snapshot_prune_no_limits() {
625 let tmp = TempDir::new().unwrap();
626 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
627 let rootfs = make_rootfs(&tmp);
628
629 for i in 0..3 {
630 store
631 .save(
632 make_metadata(&format!("s{}", i), &format!("s{}", i)),
633 &rootfs,
634 )
635 .unwrap();
636 }
637
638 let removed = store.prune(0, 0).unwrap();
639 assert!(removed.is_empty());
640 assert_eq!(store.count().unwrap(), 3);
641 }
642
643 #[test]
644 fn prune_skips_in_use_snapshots() {
645 let tmp = TempDir::new().unwrap();
646 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
647 let rootfs = make_rootfs(&tmp);
648
649 for i in 0..5 {
650 store
651 .save(
652 make_metadata(&format!("s{}", i), &format!("s{}", i)),
653 &rootfs,
654 )
655 .unwrap();
656 std::thread::sleep(std::time::Duration::from_millis(10));
657 }
658
659 let box_dir = tmp.path().join("boxes").join("box1");
661 std::fs::create_dir_all(&box_dir).unwrap();
662 std::fs::write(
663 box_dir.join(".snapshot-lower"),
664 store.rootfs_path("s0").to_string_lossy().as_bytes(),
665 )
666 .unwrap();
667
668 let removed = store.prune(3, 0).unwrap();
671 assert_eq!(removed.len(), 2);
672 assert!(store.get("s0").unwrap().is_some(), "in-use s0 must be kept");
673 assert!(store.get("s1").unwrap().is_none());
674 assert!(store.get("s2").unwrap().is_none());
675 assert!(store.get("s3").unwrap().is_some());
676 assert!(store.get("s4").unwrap().is_some());
677 }
678
679 #[test]
680 fn prune_keeps_everything_when_all_in_use() {
681 let tmp = TempDir::new().unwrap();
682 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
683 let rootfs = make_rootfs(&tmp);
684 store.save(make_metadata("a", "a"), &rootfs).unwrap();
685 store.save(make_metadata("b", "b"), &rootfs).unwrap();
686
687 for (i, id) in ["a", "b"].iter().enumerate() {
689 let bd = tmp.path().join("boxes").join(format!("bx{i}"));
690 std::fs::create_dir_all(&bd).unwrap();
691 std::fs::write(
692 bd.join(".snapshot-lower"),
693 store.rootfs_path(id).to_string_lossy().as_bytes(),
694 )
695 .unwrap();
696 }
697
698 let removed = store.prune(1, 0).unwrap();
700 assert!(removed.is_empty(), "in-use snapshots must never be pruned");
701 assert_eq!(store.count().unwrap(), 2);
702 }
703
704 #[test]
705 fn test_snapshot_save_with_empty_rootfs() {
706 let tmp = TempDir::new().unwrap();
707 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
708 let empty_rootfs = tmp.path().join("nonexistent_rootfs");
709
710 let meta = make_metadata("snap-empty", "empty");
711 let saved = store.save(meta, &empty_rootfs).unwrap();
712 assert_eq!(saved.id, "snap-empty");
713
714 assert!(store.rootfs_path("snap-empty").exists());
716 }
717
718 #[test]
719 fn test_dir_size() {
720 let tmp = TempDir::new().unwrap();
721 let dir = tmp.path().join("sized");
722 std::fs::create_dir_all(&dir).unwrap();
723 std::fs::write(dir.join("a.txt"), "hello").unwrap();
724 std::fs::write(dir.join("b.txt"), "world!").unwrap();
725
726 let size = dir_size(&dir);
727 assert_eq!(size, 11); }
729
730 #[test]
731 fn test_dir_size_nested() {
732 let tmp = TempDir::new().unwrap();
733 let dir = tmp.path().join("nested");
734 std::fs::create_dir_all(dir.join("sub")).unwrap();
735 std::fs::write(dir.join("a.txt"), "abc").unwrap();
736 std::fs::write(dir.join("sub/b.txt"), "defgh").unwrap();
737
738 let size = dir_size(&dir);
739 assert_eq!(size, 8); }
741
742 #[test]
743 fn test_dir_size_empty() {
744 let tmp = TempDir::new().unwrap();
745 let dir = tmp.path().join("empty");
746 std::fs::create_dir_all(&dir).unwrap();
747 assert_eq!(dir_size(&dir), 0);
748 }
749
750 #[test]
751 fn test_copy_dir_recursive() {
752 let tmp = TempDir::new().unwrap();
753 let src = tmp.path().join("src");
754 let dst = tmp.path().join("dst");
755
756 std::fs::create_dir_all(src.join("sub")).unwrap();
757 std::fs::write(src.join("a.txt"), "hello").unwrap();
758 std::fs::write(src.join("sub/b.txt"), "world").unwrap();
759
760 copy_dir_recursive(&src, &dst).unwrap();
761
762 assert!(dst.join("a.txt").exists());
763 assert!(dst.join("sub/b.txt").exists());
764 assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
765 assert_eq!(
766 std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
767 "world"
768 );
769 }
770
771 #[test]
772 fn new_sweeps_leftover_staging_dirs() {
773 let tmp = TempDir::new().unwrap();
774 let base = tmp.path().join("snapshots");
775 std::fs::create_dir_all(&base).unwrap();
776
777 let leaked = base.join(".staging-snap1-12345-0");
779 std::fs::create_dir_all(leaked.join("rootfs")).unwrap();
780 std::fs::write(leaked.join("rootfs").join("big"), vec![0u8; 4096]).unwrap();
781 let real = base.join("snap1");
783 std::fs::create_dir_all(&real).unwrap();
784 std::fs::write(real.join("metadata.json"), "{}").unwrap();
785
786 let _store = SnapshotStore::new(&base).unwrap();
787
788 assert!(
789 !leaked.exists(),
790 "leaked .staging-* dir must be swept on open"
791 );
792 assert!(real.exists(), "a real snapshot dir must be preserved");
793 }
794}