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