1use std::cmp::Reverse;
11use std::path::{Path, PathBuf};
12
13use a3s_box_core::error::{BoxError, Result};
14use a3s_box_core::rootfs_metadata::RootfsMetadataManifest;
15#[cfg(test)]
16use a3s_box_core::rootfs_metadata::{IMAGE_ROOTFS_METADATA_PATH, ROOTFS_METADATA_PATH};
17use a3s_box_core::snapshot::SnapshotMetadata;
18use a3s_box_core::SnapshotStoreBackend;
19
20use crate::file_lock::FileLock;
21
22mod copy;
23
24use copy::{copy_dir_recursive, dir_size, install_rootfs_metadata};
25
26pub struct SnapshotStore {
28 base_dir: PathBuf,
30}
31
32impl SnapshotStore {
33 pub(crate) fn acquire_exclusive_lock(&self) -> Result<FileLock> {
34 FileLock::acquire(&self.base_dir.join(".snapshot-store")).map_err(|error| {
35 BoxError::CacheError(format!(
36 "Failed to lock snapshot directory {}: {error}",
37 self.base_dir.display()
38 ))
39 })
40 }
41
42 pub fn new(base_dir: &Path) -> Result<Self> {
44 std::fs::create_dir_all(base_dir).map_err(|e| {
45 BoxError::CacheError(format!(
46 "Failed to create snapshot directory {}: {}",
47 base_dir.display(),
48 e
49 ))
50 })?;
51 let _lock = FileLock::acquire(&base_dir.join(".snapshot-store")).map_err(|e| {
52 BoxError::CacheError(format!(
53 "Failed to lock snapshot directory {}: {}",
54 base_dir.display(),
55 e
56 ))
57 })?;
58 if let Ok(entries) = std::fs::read_dir(base_dir) {
66 for entry in entries.flatten() {
67 if entry.file_name().to_string_lossy().starts_with(".staging-") {
68 let _ = std::fs::remove_dir_all(entry.path());
69 }
70 }
71 }
72 Ok(Self {
73 base_dir: base_dir.to_path_buf(),
74 })
75 }
76
77 pub fn default_path() -> Result<Self> {
79 let home = a3s_box_core::dirs_home();
80 Self::new(&home.join("snapshots"))
81 }
82
83 pub fn save(
88 &self,
89 metadata: SnapshotMetadata,
90 rootfs_source: &Path,
91 ) -> Result<SnapshotMetadata> {
92 self.save_inner(metadata, rootfs_source, None)
93 }
94
95 pub(crate) fn save_managed(
98 &self,
99 metadata: SnapshotMetadata,
100 rootfs_source: &Path,
101 rootfs_metadata: &RootfsMetadataManifest,
102 ) -> Result<SnapshotMetadata> {
103 self.save_inner(metadata, rootfs_source, Some(rootfs_metadata))
104 }
105
106 fn save_inner(
107 &self,
108 mut metadata: SnapshotMetadata,
109 rootfs_source: &Path,
110 rootfs_metadata: Option<&RootfsMetadataManifest>,
111 ) -> Result<SnapshotMetadata> {
112 let _lock = FileLock::acquire(&self.base_dir.join(".snapshot-store")).map_err(|e| {
113 BoxError::CacheError(format!(
114 "Failed to lock snapshot directory {}: {}",
115 self.base_dir.display(),
116 e
117 ))
118 })?;
119 let snap_dir = self.base_dir.join(&metadata.id);
120 if snap_dir.exists() {
121 return Err(BoxError::CacheError(format!(
122 "Snapshot '{}' already exists",
123 metadata.id
124 )));
125 }
126
127 let staging_prefix = format!(".staging-{}-{}-", metadata.id, std::process::id());
133 let staging = tempfile::Builder::new()
134 .prefix(&staging_prefix)
135 .tempdir_in(&self.base_dir)
136 .map_err(|e| {
137 BoxError::CacheError(format!(
138 "Failed to create snapshot staging directory in {}: {}",
139 self.base_dir.display(),
140 e
141 ))
142 })?;
143
144 let rootfs_dest = staging.path().join("rootfs");
146 if rootfs_source.exists() {
147 copy_dir_recursive(rootfs_source, &rootfs_dest)?;
148 } else {
149 std::fs::create_dir_all(&rootfs_dest).map_err(|e| {
150 BoxError::CacheError(format!("Failed to create snapshot rootfs directory: {}", e))
151 })?;
152 }
153 if let Some(rootfs_metadata) = rootfs_metadata {
154 install_rootfs_metadata(&rootfs_dest, rootfs_metadata)?;
155 }
156
157 metadata.size_bytes = dir_size(&rootfs_dest)?;
159
160 let meta_path = staging.path().join("metadata.json");
162 let json = serde_json::to_string_pretty(&metadata).map_err(|e| {
163 BoxError::SerializationError(format!("Failed to serialize snapshot metadata: {}", e))
164 })?;
165 std::fs::write(&meta_path, &json).map_err(|e| {
166 BoxError::CacheError(format!(
167 "Failed to write snapshot metadata {}: {}",
168 meta_path.display(),
169 e
170 ))
171 })?;
172
173 std::fs::rename(staging.path(), &snap_dir).map_err(|e| {
176 BoxError::CacheError(format!(
177 "Failed to publish snapshot {}: {}",
178 snap_dir.display(),
179 e
180 ))
181 })?;
182
183 Ok(metadata)
184 }
185
186 pub fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
188 let meta_path = self.base_dir.join(id).join("metadata.json");
189 if !meta_path.exists() {
190 return Ok(None);
191 }
192
193 let data = std::fs::read_to_string(&meta_path).map_err(|e| {
194 BoxError::CacheError(format!(
195 "Failed to read snapshot metadata {}: {}",
196 meta_path.display(),
197 e
198 ))
199 })?;
200 let metadata: SnapshotMetadata = serde_json::from_str(&data).map_err(|e| {
201 BoxError::SerializationError(format!("Failed to parse snapshot metadata: {}", e))
202 })?;
203 Ok(Some(metadata))
204 }
205
206 pub fn rootfs_path(&self, id: &str) -> PathBuf {
208 self.base_dir.join(id).join("rootfs")
209 }
210
211 pub fn list(&self) -> Result<Vec<SnapshotMetadata>> {
213 let mut snapshots = Vec::new();
214
215 let entries = match std::fs::read_dir(&self.base_dir) {
216 Ok(entries) => entries,
217 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(snapshots),
218 Err(e) => {
219 return Err(BoxError::CacheError(format!(
220 "Failed to read snapshot directory: {}",
221 e
222 )));
223 }
224 };
225
226 for entry in entries {
227 let entry = entry.map_err(|e| {
228 BoxError::CacheError(format!("Failed to read snapshot entry: {}", e))
229 })?;
230 let meta_path = entry.path().join("metadata.json");
231 if meta_path.exists() {
232 match std::fs::read_to_string(&meta_path) {
233 Ok(data) => match serde_json::from_str::<SnapshotMetadata>(&data) {
234 Ok(meta) => snapshots.push(meta),
235 Err(e) => tracing::warn!(
240 path = %meta_path.display(),
241 error = %e,
242 "Skipping snapshot with unparseable metadata.json (orphaned on disk)"
243 ),
244 },
245 Err(e) => tracing::warn!(
246 path = %meta_path.display(),
247 error = %e,
248 "Skipping snapshot with unreadable metadata.json"
249 ),
250 }
251 }
252 }
253
254 snapshots.sort_by_key(|snapshot| Reverse(snapshot.created_at));
256 Ok(snapshots)
257 }
258
259 pub fn delete(&self, id: &str) -> Result<bool> {
261 let _lock = self.acquire_exclusive_lock()?;
262 self.delete_locked(id)
263 }
264
265 pub(crate) fn delete_locked(&self, id: &str) -> Result<bool> {
270 let snap_dir = self.base_dir.join(id);
271 if !snap_dir.exists() {
272 return Ok(false);
273 }
274
275 std::fs::remove_dir_all(&snap_dir).map_err(|e| {
276 BoxError::CacheError(format!("Failed to delete snapshot {}: {}", id, e))
277 })?;
278 Ok(true)
279 }
280
281 pub fn count(&self) -> Result<usize> {
283 Ok(self.list()?.len())
284 }
285
286 pub fn total_size(&self) -> Result<u64> {
288 Ok(self.list()?.iter().map(|s| s.size_bytes).sum())
289 }
290
291 pub fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
296 let protected = self.referenced_rootfs_paths();
301 let mut snapshots = self.list()?; let mut removed = Vec::new();
303
304 loop {
305 let over_count = max_count > 0 && snapshots.len() > max_count;
306 let total: u64 = snapshots.iter().map(|s| s.size_bytes).sum();
307 let over_size = max_bytes > 0 && total > max_bytes;
308 if !over_count && !over_size {
309 break;
310 }
311
312 let idx = snapshots
314 .iter()
315 .rposition(|s| !protected.contains(&self.rootfs_path(&s.id)));
316 match idx {
317 Some(i) => {
318 let snap = snapshots.remove(i);
319 self.delete(&snap.id)?;
320 removed.push(snap.id);
321 }
322 None => {
325 tracing::warn!(
326 in_use = snapshots.len(),
327 "snapshot prune kept {} in-use snapshot(s) (each referenced as a \
328 copy-on-write overlay lower); requested limit not fully met",
329 snapshots.len()
330 );
331 break;
332 }
333 }
334 }
335
336 Ok(removed)
337 }
338
339 fn referenced_rootfs_paths(&self) -> std::collections::HashSet<PathBuf> {
344 let mut set = std::collections::HashSet::new();
345 let boxes = match self.base_dir.parent() {
346 Some(home) => home.join("boxes"),
347 None => return set,
348 };
349 if let Ok(entries) = std::fs::read_dir(&boxes) {
350 for entry in entries.flatten() {
351 if let Ok(content) = std::fs::read_to_string(entry.path().join(".snapshot-lower")) {
352 set.insert(PathBuf::from(content.trim()));
353 }
354 }
355 }
356 set
357 }
358}
359
360impl SnapshotStoreBackend for SnapshotStore {
361 fn save(&self, metadata: SnapshotMetadata, rootfs_source: &Path) -> Result<SnapshotMetadata> {
362 self.save(metadata, rootfs_source)
363 }
364
365 fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
366 self.get(id)
367 }
368
369 fn list(&self) -> Result<Vec<SnapshotMetadata>> {
370 self.list()
371 }
372
373 fn delete(&self, id: &str) -> Result<bool> {
374 self.delete(id)
375 }
376
377 fn count(&self) -> Result<usize> {
378 self.count()
379 }
380
381 fn total_size(&self) -> Result<u64> {
382 self.total_size()
383 }
384
385 fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
386 self.prune(max_count, max_bytes)
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use tempfile::TempDir;
394
395 fn make_metadata(id: &str, name: &str) -> SnapshotMetadata {
396 SnapshotMetadata::new(
397 id.to_string(),
398 name.to_string(),
399 "box-source".to_string(),
400 "alpine:latest".to_string(),
401 )
402 }
403
404 fn make_rootfs(tmp: &TempDir) -> PathBuf {
405 let rootfs = tmp.path().join("rootfs");
406 std::fs::create_dir_all(&rootfs).unwrap();
407 std::fs::write(rootfs.join("bin.sh"), "#!/bin/sh\necho hello").unwrap();
408 std::fs::create_dir_all(rootfs.join("etc")).unwrap();
409 std::fs::write(rootfs.join("etc/config"), "key=value").unwrap();
410 rootfs
411 }
412
413 #[test]
414 fn test_snapshot_store_new() {
415 let tmp = TempDir::new().unwrap();
416 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
417 assert!(store.base_dir.exists());
418 }
419
420 #[test]
421 fn test_snapshot_save_and_get() {
422 let tmp = TempDir::new().unwrap();
423 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
424 let rootfs = make_rootfs(&tmp);
425
426 let meta = make_metadata("snap-1", "first");
427 let saved = store.save(meta, &rootfs).unwrap();
428
429 assert_eq!(saved.id, "snap-1");
430 assert!(saved.size_bytes > 0);
431
432 let loaded = store.get("snap-1").unwrap().unwrap();
433 assert_eq!(loaded.id, "snap-1");
434 assert_eq!(loaded.name, "first");
435 assert_eq!(loaded.image, "alpine:latest");
436 assert_eq!(loaded.size_bytes, saved.size_bytes);
437 }
438
439 #[test]
440 fn test_snapshot_get_nonexistent() {
441 let tmp = TempDir::new().unwrap();
442 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
443 assert!(store.get("nonexistent").unwrap().is_none());
444 }
445
446 #[test]
447 fn test_snapshot_save_duplicate_fails() {
448 let tmp = TempDir::new().unwrap();
449 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
450 let rootfs = make_rootfs(&tmp);
451
452 let meta = make_metadata("snap-dup", "dup");
453 store.save(meta.clone(), &rootfs).unwrap();
454
455 let result = store.save(meta, &rootfs);
456 assert!(result.is_err());
457 assert!(result.unwrap_err().to_string().contains("already exists"));
458 }
459
460 #[test]
461 fn test_snapshot_rootfs_copied() {
462 let tmp = TempDir::new().unwrap();
463 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
464 let rootfs = make_rootfs(&tmp);
465
466 let meta = make_metadata("snap-fs", "fs-test");
467 store.save(meta, &rootfs).unwrap();
468
469 let snap_rootfs = store.rootfs_path("snap-fs");
470 assert!(snap_rootfs.join("bin.sh").exists());
471 assert!(snap_rootfs.join("etc/config").exists());
472 assert_eq!(
473 std::fs::read_to_string(snap_rootfs.join("etc/config")).unwrap(),
474 "key=value"
475 );
476 }
477
478 #[test]
479 fn test_snapshot_list_empty() {
480 let tmp = TempDir::new().unwrap();
481 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
482 let list = store.list().unwrap();
483 assert!(list.is_empty());
484 }
485
486 #[test]
487 fn test_snapshot_list_multiple() {
488 let tmp = TempDir::new().unwrap();
489 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
490 let rootfs = make_rootfs(&tmp);
491
492 for i in 0..3 {
493 let meta = make_metadata(&format!("snap-{}", i), &format!("snap-{}", i));
494 store.save(meta, &rootfs).unwrap();
495 std::thread::sleep(std::time::Duration::from_millis(10));
496 }
497
498 let list = store.list().unwrap();
499 assert_eq!(list.len(), 3);
500 assert_eq!(list[0].id, "snap-2");
502 assert_eq!(list[2].id, "snap-0");
503 }
504
505 #[test]
506 fn test_snapshot_delete() {
507 let tmp = TempDir::new().unwrap();
508 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
509 let rootfs = make_rootfs(&tmp);
510
511 let meta = make_metadata("snap-del", "delete-me");
512 store.save(meta, &rootfs).unwrap();
513
514 assert!(store.delete("snap-del").unwrap());
515 assert!(store.get("snap-del").unwrap().is_none());
516 }
517
518 #[test]
519 fn test_snapshot_delete_nonexistent() {
520 let tmp = TempDir::new().unwrap();
521 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
522 assert!(!store.delete("nope").unwrap());
523 }
524
525 #[test]
526 fn test_snapshot_count() {
527 let tmp = TempDir::new().unwrap();
528 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
529 let rootfs = make_rootfs(&tmp);
530
531 assert_eq!(store.count().unwrap(), 0);
532
533 store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
534 store.save(make_metadata("s2", "s2"), &rootfs).unwrap();
535 assert_eq!(store.count().unwrap(), 2);
536 }
537
538 #[test]
539 fn test_snapshot_total_size() {
540 let tmp = TempDir::new().unwrap();
541 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
542 let rootfs = make_rootfs(&tmp);
543
544 store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
545 let total = store.total_size().unwrap();
546 assert!(total > 0);
547 }
548
549 #[test]
550 fn test_snapshot_prune_by_count() {
551 let tmp = TempDir::new().unwrap();
552 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
553 let rootfs = make_rootfs(&tmp);
554
555 for i in 0..5 {
556 let meta = make_metadata(&format!("s{}", i), &format!("s{}", i));
557 store.save(meta, &rootfs).unwrap();
558 std::thread::sleep(std::time::Duration::from_millis(10));
559 }
560
561 let removed = store.prune(3, 0).unwrap();
562 assert_eq!(removed.len(), 2);
563 assert_eq!(store.count().unwrap(), 3);
564
565 assert!(store.get("s0").unwrap().is_none());
567 assert!(store.get("s1").unwrap().is_none());
568 assert!(store.get("s4").unwrap().is_some());
570 assert!(store.get("s3").unwrap().is_some());
571 assert!(store.get("s2").unwrap().is_some());
572 }
573
574 #[test]
575 fn test_snapshot_prune_no_limits() {
576 let tmp = TempDir::new().unwrap();
577 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
578 let rootfs = make_rootfs(&tmp);
579
580 for i in 0..3 {
581 store
582 .save(
583 make_metadata(&format!("s{}", i), &format!("s{}", i)),
584 &rootfs,
585 )
586 .unwrap();
587 }
588
589 let removed = store.prune(0, 0).unwrap();
590 assert!(removed.is_empty());
591 assert_eq!(store.count().unwrap(), 3);
592 }
593
594 #[test]
595 fn prune_skips_in_use_snapshots() {
596 let tmp = TempDir::new().unwrap();
597 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
598 let rootfs = make_rootfs(&tmp);
599
600 for i in 0..5 {
601 store
602 .save(
603 make_metadata(&format!("s{}", i), &format!("s{}", i)),
604 &rootfs,
605 )
606 .unwrap();
607 std::thread::sleep(std::time::Duration::from_millis(10));
608 }
609
610 let box_dir = tmp.path().join("boxes").join("box1");
612 std::fs::create_dir_all(&box_dir).unwrap();
613 std::fs::write(
614 box_dir.join(".snapshot-lower"),
615 store.rootfs_path("s0").to_string_lossy().as_bytes(),
616 )
617 .unwrap();
618
619 let removed = store.prune(3, 0).unwrap();
622 assert_eq!(removed.len(), 2);
623 assert!(store.get("s0").unwrap().is_some(), "in-use s0 must be kept");
624 assert!(store.get("s1").unwrap().is_none());
625 assert!(store.get("s2").unwrap().is_none());
626 assert!(store.get("s3").unwrap().is_some());
627 assert!(store.get("s4").unwrap().is_some());
628 }
629
630 #[test]
631 fn prune_keeps_everything_when_all_in_use() {
632 let tmp = TempDir::new().unwrap();
633 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
634 let rootfs = make_rootfs(&tmp);
635 store.save(make_metadata("a", "a"), &rootfs).unwrap();
636 store.save(make_metadata("b", "b"), &rootfs).unwrap();
637
638 for (i, id) in ["a", "b"].iter().enumerate() {
640 let bd = tmp.path().join("boxes").join(format!("bx{i}"));
641 std::fs::create_dir_all(&bd).unwrap();
642 std::fs::write(
643 bd.join(".snapshot-lower"),
644 store.rootfs_path(id).to_string_lossy().as_bytes(),
645 )
646 .unwrap();
647 }
648
649 let removed = store.prune(1, 0).unwrap();
651 assert!(removed.is_empty(), "in-use snapshots must never be pruned");
652 assert_eq!(store.count().unwrap(), 2);
653 }
654
655 #[test]
656 fn test_snapshot_save_with_empty_rootfs() {
657 let tmp = TempDir::new().unwrap();
658 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
659 let empty_rootfs = tmp.path().join("nonexistent_rootfs");
660
661 let meta = make_metadata("snap-empty", "empty");
662 let saved = store.save(meta, &empty_rootfs).unwrap();
663 assert_eq!(saved.id, "snap-empty");
664
665 assert!(store.rootfs_path("snap-empty").exists());
667 }
668
669 #[test]
670 fn test_dir_size() {
671 let tmp = TempDir::new().unwrap();
672 let dir = tmp.path().join("sized");
673 std::fs::create_dir_all(&dir).unwrap();
674 std::fs::write(dir.join("a.txt"), "hello").unwrap();
675 std::fs::write(dir.join("b.txt"), "world!").unwrap();
676
677 let size = dir_size(&dir).unwrap();
678 assert_eq!(size, 11); }
680
681 #[test]
682 fn test_dir_size_nested() {
683 let tmp = TempDir::new().unwrap();
684 let dir = tmp.path().join("nested");
685 std::fs::create_dir_all(dir.join("sub")).unwrap();
686 std::fs::write(dir.join("a.txt"), "abc").unwrap();
687 std::fs::write(dir.join("sub/b.txt"), "defgh").unwrap();
688
689 let size = dir_size(&dir).unwrap();
690 assert_eq!(size, 8); }
692
693 #[test]
694 fn test_dir_size_empty() {
695 let tmp = TempDir::new().unwrap();
696 let dir = tmp.path().join("empty");
697 std::fs::create_dir_all(&dir).unwrap();
698 assert_eq!(dir_size(&dir).unwrap(), 0);
699 }
700
701 #[test]
702 fn test_copy_dir_recursive() {
703 let tmp = TempDir::new().unwrap();
704 let src = tmp.path().join("src");
705 let dst = tmp.path().join("dst");
706
707 std::fs::create_dir_all(src.join("sub")).unwrap();
708 std::fs::write(src.join("a.txt"), "hello").unwrap();
709 std::fs::write(src.join("sub/b.txt"), "world").unwrap();
710
711 copy_dir_recursive(&src, &dst).unwrap();
712
713 assert!(dst.join("a.txt").exists());
714 assert!(dst.join("sub/b.txt").exists());
715 assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
716 assert_eq!(
717 std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
718 "world"
719 );
720 }
721
722 #[cfg(unix)]
723 #[test]
724 fn snapshot_size_does_not_follow_absolute_symlinks() {
725 use std::os::unix::ffi::OsStrExt;
726
727 let tmp = TempDir::new().unwrap();
728 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
729 let rootfs = tmp.path().join("rootfs");
730 std::fs::create_dir(&rootfs).unwrap();
731 let outside = tmp.path().join("outside");
732 std::fs::write(&outside, vec![0_u8; 64 * 1024]).unwrap();
733 std::os::unix::fs::symlink(&outside, rootfs.join("outside-link")).unwrap();
734
735 let saved = store
736 .save(make_metadata("symlink-size", "symlink-size"), &rootfs)
737 .unwrap();
738
739 assert_eq!(
740 saved.size_bytes,
741 outside.as_os_str().as_bytes().len() as u64
742 );
743 }
744
745 #[cfg(unix)]
746 #[test]
747 fn snapshot_preserves_hardlinks_and_xattrs() {
748 use std::os::unix::fs::MetadataExt;
749
750 let tmp = TempDir::new().unwrap();
751 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
752 let rootfs = tmp.path().join("rootfs");
753 std::fs::create_dir(&rootfs).unwrap();
754 let first = rootfs.join("first");
755 let second = rootfs.join("second");
756 std::fs::write(&first, b"shared inode").unwrap();
757 std::fs::hard_link(&first, &second).unwrap();
758 xattr::set(&first, "user.a3s.snapshot", b"preserved").unwrap();
759
760 let saved = store
761 .save(make_metadata("hardlink-xattr", "hardlink-xattr"), &rootfs)
762 .unwrap();
763 let captured = store.rootfs_path(&saved.id);
764 let captured_first = std::fs::metadata(captured.join("first")).unwrap();
765 let captured_second = std::fs::metadata(captured.join("second")).unwrap();
766
767 assert_eq!(captured_first.dev(), captured_second.dev());
768 assert_eq!(captured_first.ino(), captured_second.ino());
769 assert_eq!(saved.size_bytes, b"shared inode".len() as u64);
770 assert_eq!(
771 xattr::get(captured.join("first"), "user.a3s.snapshot").unwrap(),
772 Some(b"preserved".to_vec())
773 );
774 }
775
776 #[cfg(unix)]
777 #[test]
778 fn snapshot_rejects_fifo_without_leaking_staging() {
779 use std::os::unix::ffi::OsStrExt;
780
781 let tmp = TempDir::new().unwrap();
782 let snapshots = tmp.path().join("snapshots");
783 let store = SnapshotStore::new(&snapshots).unwrap();
784 let rootfs = tmp.path().join("rootfs");
785 std::fs::create_dir(&rootfs).unwrap();
786 let fifo = rootfs.join("blocking-fifo");
787 let fifo_path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
788 assert_eq!(unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) }, 0);
789
790 let error = store
791 .save(make_metadata("fifo", "fifo"), &rootfs)
792 .unwrap_err();
793
794 assert!(error.to_string().contains("unsupported special file"));
795 assert!(store.get("fifo").unwrap().is_none());
796 assert!(
797 std::fs::read_dir(snapshots)
798 .unwrap()
799 .flatten()
800 .all(|entry| !entry.file_name().to_string_lossy().starts_with(".staging-")),
801 "failed Snapshot must remove its staging tree"
802 );
803 }
804
805 #[test]
806 fn managed_snapshot_installs_only_terminal_rootfs_metadata() {
807 let tmp = TempDir::new().unwrap();
808 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
809 let rootfs = make_rootfs(&tmp);
810 std::fs::write(
811 rootfs.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')),
812 b"stale image metadata",
813 )
814 .unwrap();
815 let manifest = RootfsMetadataManifest::new(Vec::new());
816
817 store
818 .save_managed(
819 make_metadata("managed-metadata", "managed-metadata"),
820 &rootfs,
821 &manifest,
822 )
823 .unwrap();
824
825 let captured = store.rootfs_path("managed-metadata");
826 assert!(!captured
827 .join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/'))
828 .exists());
829 let stored: RootfsMetadataManifest = serde_json::from_slice(
830 &std::fs::read(captured.join(ROOTFS_METADATA_PATH.trim_start_matches('/'))).unwrap(),
831 )
832 .unwrap();
833 assert_eq!(stored, manifest);
834 }
835
836 #[test]
837 fn new_sweeps_leftover_staging_dirs() {
838 let tmp = TempDir::new().unwrap();
839 let base = tmp.path().join("snapshots");
840 std::fs::create_dir_all(&base).unwrap();
841
842 let leaked = base.join(".staging-snap1-12345-0");
844 std::fs::create_dir_all(leaked.join("rootfs")).unwrap();
845 std::fs::write(leaked.join("rootfs").join("big"), vec![0u8; 4096]).unwrap();
846 let real = base.join("snap1");
848 std::fs::create_dir_all(&real).unwrap();
849 std::fs::write(real.join("metadata.json"), "{}").unwrap();
850
851 let _store = SnapshotStore::new(&base).unwrap();
852
853 assert!(
854 !leaked.exists(),
855 "leaked .staging-* dir must be swept on open"
856 );
857 assert!(real.exists(), "a real snapshot dir must be preserved");
858 }
859}