1use std::cmp::Reverse;
14use std::path::{Path, PathBuf};
15
16use a3s_box_core::error::{BoxError, Result};
17use a3s_box_core::rootfs_metadata::RootfsMetadataManifest;
18#[cfg(test)]
19use a3s_box_core::rootfs_metadata::{
20 IMAGE_ROOTFS_METADATA_PATH, PREVIOUS_ROOTFS_METADATA_PATH, ROOTFS_METADATA_PATH,
21};
22use a3s_box_core::snapshot::SnapshotMetadata;
23use a3s_box_core::SnapshotStoreBackend;
24
25use crate::file_lock::FileLock;
26
27mod copy;
28mod rootfs_bundle;
29
30use copy::{copy_dir_recursive, dir_size, install_rootfs_metadata};
31pub use rootfs_bundle::{RestoredSnapshotRootfs, SnapshotRootfsFormat, SNAPSHOT_ROOTFS_SCHEMA};
32
33pub struct SnapshotStore {
35 base_dir: PathBuf,
37}
38
39impl SnapshotStore {
40 pub(crate) fn acquire_exclusive_lock(&self) -> Result<FileLock> {
41 FileLock::acquire(&self.base_dir.join(".snapshot-store")).map_err(|error| {
42 BoxError::CacheError(format!(
43 "Failed to lock snapshot directory {}: {error}",
44 self.base_dir.display()
45 ))
46 })
47 }
48
49 pub fn new(base_dir: &Path) -> Result<Self> {
51 std::fs::create_dir_all(base_dir).map_err(|e| {
52 BoxError::CacheError(format!(
53 "Failed to create snapshot directory {}: {}",
54 base_dir.display(),
55 e
56 ))
57 })?;
58 let _lock = FileLock::acquire(&base_dir.join(".snapshot-store")).map_err(|e| {
59 BoxError::CacheError(format!(
60 "Failed to lock snapshot directory {}: {}",
61 base_dir.display(),
62 e
63 ))
64 })?;
65 if let Ok(entries) = std::fs::read_dir(base_dir) {
73 for entry in entries.flatten() {
74 let is_plain_directory = entry
75 .file_type()
76 .is_ok_and(|file_type| file_type.is_dir() && !file_type.is_symlink());
77 if is_plain_directory
78 && entry.file_name().to_string_lossy().starts_with(".staging-")
79 {
80 let _ = std::fs::remove_dir_all(entry.path());
81 }
82 }
83 }
84 Ok(Self {
85 base_dir: base_dir.to_path_buf(),
86 })
87 }
88
89 pub fn default_path() -> Result<Self> {
91 let home = a3s_box_core::dirs_home();
92 Self::new(&home.join("snapshots"))
93 }
94
95 pub fn save(
100 &self,
101 metadata: SnapshotMetadata,
102 rootfs_source: &Path,
103 ) -> Result<SnapshotMetadata> {
104 self.save_inner(metadata, rootfs_source, None)
105 }
106
107 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
110 pub(crate) fn save_managed(
111 &self,
112 metadata: SnapshotMetadata,
113 rootfs_source: &Path,
114 rootfs_metadata: &RootfsMetadataManifest,
115 ) -> Result<SnapshotMetadata> {
116 self.save_inner(metadata, rootfs_source, Some(rootfs_metadata))
117 }
118
119 fn save_inner(
120 &self,
121 mut metadata: SnapshotMetadata,
122 rootfs_source: &Path,
123 rootfs_metadata: Option<&RootfsMetadataManifest>,
124 ) -> Result<SnapshotMetadata> {
125 rootfs_bundle::validate_snapshot_id(&metadata.id)?;
126 let _lock = FileLock::acquire(&self.base_dir.join(".snapshot-store")).map_err(|e| {
127 BoxError::CacheError(format!(
128 "Failed to lock snapshot directory {}: {}",
129 self.base_dir.display(),
130 e
131 ))
132 })?;
133 let snap_dir = self.base_dir.join(&metadata.id);
134 match std::fs::symlink_metadata(&snap_dir) {
135 Ok(_) => {
136 return Err(BoxError::CacheError(format!(
137 "Snapshot '{}' already exists",
138 metadata.id
139 )))
140 }
141 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
142 Err(error) => return Err(BoxError::IoError(error)),
143 }
144
145 let staging_prefix = format!(".staging-{}-{}-", metadata.id, std::process::id());
151 let staging = tempfile::Builder::new()
152 .prefix(&staging_prefix)
153 .tempdir_in(&self.base_dir)
154 .map_err(|e| {
155 BoxError::CacheError(format!(
156 "Failed to create snapshot staging directory in {}: {}",
157 self.base_dir.display(),
158 e
159 ))
160 })?;
161
162 let rootfs_dest = staging.path().join("rootfs");
164 if rootfs_source.exists() {
165 copy_dir_recursive(rootfs_source, &rootfs_dest)?;
166 } else {
167 std::fs::create_dir_all(&rootfs_dest).map_err(|e| {
168 BoxError::CacheError(format!("Failed to create snapshot rootfs directory: {}", e))
169 })?;
170 }
171 if let Some(rootfs_metadata) = rootfs_metadata {
172 install_rootfs_metadata(&rootfs_dest, rootfs_metadata)?;
173 }
174
175 metadata.size_bytes = dir_size(&rootfs_dest)?;
177
178 rootfs_bundle::write_directory_manifest(staging.path(), &metadata)?;
183
184 let meta_path = staging.path().join("metadata.json");
186 let json = serde_json::to_string_pretty(&metadata).map_err(|e| {
187 BoxError::SerializationError(format!("Failed to serialize snapshot metadata: {}", e))
188 })?;
189 std::fs::write(&meta_path, &json).map_err(|e| {
190 BoxError::CacheError(format!(
191 "Failed to write snapshot metadata {}: {}",
192 meta_path.display(),
193 e
194 ))
195 })?;
196
197 std::fs::rename(staging.path(), &snap_dir).map_err(|e| {
200 BoxError::CacheError(format!(
201 "Failed to publish snapshot {}: {}",
202 snap_dir.display(),
203 e
204 ))
205 })?;
206
207 Ok(metadata)
208 }
209
210 pub fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
212 Ok(
213 rootfs_bundle::load_snapshot_metadata(&self.base_dir, id)?
214 .map(|(_, metadata)| metadata),
215 )
216 }
217
218 pub fn rootfs_path(&self, id: &str) -> PathBuf {
220 self.base_dir.join(id).join("rootfs")
221 }
222
223 pub fn list(&self) -> Result<Vec<SnapshotMetadata>> {
225 let mut snapshots = Vec::new();
226
227 let entries = match std::fs::read_dir(&self.base_dir) {
228 Ok(entries) => entries,
229 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(snapshots),
230 Err(e) => {
231 return Err(BoxError::CacheError(format!(
232 "Failed to read snapshot directory: {}",
233 e
234 )));
235 }
236 };
237
238 for entry in entries {
239 let entry = entry.map_err(|e| {
240 BoxError::CacheError(format!("Failed to read snapshot entry: {}", e))
241 })?;
242 let entry_type = entry.file_type().map_err(BoxError::IoError)?;
243 if !entry_type.is_dir() || entry_type.is_symlink() {
244 continue;
245 }
246 let Some(id) = entry.file_name().to_str().map(str::to_owned) else {
247 tracing::warn!(
248 path = %entry.path().display(),
249 "Skipping snapshot with a non-UTF-8 store identity"
250 );
251 continue;
252 };
253 match self.get(&id) {
254 Ok(Some(metadata)) => snapshots.push(metadata),
255 Ok(None) => {}
256 Err(error) => tracing::warn!(
260 path = %entry.path().display(),
261 %error,
262 "Skipping snapshot with invalid persisted metadata"
263 ),
264 }
265 }
266
267 snapshots.sort_by_key(|snapshot| Reverse(snapshot.created_at));
269 Ok(snapshots)
270 }
271
272 pub fn delete(&self, id: &str) -> Result<bool> {
274 rootfs_bundle::validate_snapshot_id(id)?;
275 let _lock = self.acquire_exclusive_lock()?;
276 let rootfs = normalize_snapshot_reference(self.rootfs_path(id));
277 if self.referenced_rootfs_paths().contains(&rootfs) {
278 return Err(BoxError::StateError(format!(
279 "Snapshot '{id}' is still used as a copy-on-write rootfs lower"
280 )));
281 }
282 self.delete_locked(id)
283 }
284
285 pub fn delete_force(&self, id: &str) -> Result<bool> {
289 rootfs_bundle::validate_snapshot_id(id)?;
290 let _lock = self.acquire_exclusive_lock()?;
291 self.delete_locked(id)
292 }
293
294 pub(crate) fn delete_locked(&self, id: &str) -> Result<bool> {
299 rootfs_bundle::validate_snapshot_id(id)?;
300 let snap_dir = self.base_dir.join(id);
301 let metadata = match std::fs::symlink_metadata(&snap_dir) {
302 Ok(metadata) => metadata,
303 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
304 Err(error) => return Err(BoxError::IoError(error)),
305 };
306 if !metadata.is_dir() || metadata.file_type().is_symlink() {
307 return Err(BoxError::CacheError(format!(
308 "Snapshot bundle is not a plain directory: {}",
309 snap_dir.display()
310 )));
311 }
312
313 std::fs::remove_dir_all(&snap_dir).map_err(|e| {
314 BoxError::CacheError(format!("Failed to delete snapshot {}: {}", id, e))
315 })?;
316 Ok(true)
317 }
318
319 pub fn count(&self) -> Result<usize> {
321 Ok(self.list()?.len())
322 }
323
324 pub fn total_size(&self) -> Result<u64> {
326 Ok(self.list()?.iter().map(|s| s.size_bytes).sum())
327 }
328
329 pub fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
334 let protected = self.referenced_rootfs_paths();
339 let mut snapshots = self.list()?; let mut removed = Vec::new();
341
342 loop {
343 let over_count = max_count > 0 && snapshots.len() > max_count;
344 let total: u64 = snapshots.iter().map(|s| s.size_bytes).sum();
345 let over_size = max_bytes > 0 && total > max_bytes;
346 if !over_count && !over_size {
347 break;
348 }
349
350 let idx = snapshots.iter().rposition(|s| {
352 !protected.contains(&normalize_snapshot_reference(self.rootfs_path(&s.id)))
353 });
354 match idx {
355 Some(i) => {
356 let snap = snapshots.remove(i);
357 self.delete(&snap.id)?;
358 removed.push(snap.id);
359 }
360 None => {
363 tracing::warn!(
364 in_use = snapshots.len(),
365 "snapshot prune kept {} in-use snapshot(s) (each referenced as a \
366 copy-on-write overlay lower); requested limit not fully met",
367 snapshots.len()
368 );
369 break;
370 }
371 }
372 }
373
374 Ok(removed)
375 }
376
377 fn referenced_rootfs_paths(&self) -> std::collections::HashSet<PathBuf> {
382 let mut set = std::collections::HashSet::new();
383 let boxes = match self.base_dir.parent() {
384 Some(home) => home.join("boxes"),
385 None => return set,
386 };
387 if let Ok(entries) = std::fs::read_dir(&boxes) {
388 for entry in entries.flatten() {
389 if let Ok(content) = std::fs::read_to_string(entry.path().join(".snapshot-lower")) {
390 set.insert(normalize_snapshot_reference(PathBuf::from(content.trim())));
391 }
392 }
393 }
394 set
395 }
396}
397
398fn normalize_snapshot_reference(path: PathBuf) -> PathBuf {
399 path.canonicalize().unwrap_or(path)
400}
401
402impl SnapshotStoreBackend for SnapshotStore {
403 fn save(&self, metadata: SnapshotMetadata, rootfs_source: &Path) -> Result<SnapshotMetadata> {
404 self.save(metadata, rootfs_source)
405 }
406
407 fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
408 self.get(id)
409 }
410
411 fn list(&self) -> Result<Vec<SnapshotMetadata>> {
412 self.list()
413 }
414
415 fn delete(&self, id: &str) -> Result<bool> {
416 self.delete(id)
417 }
418
419 fn count(&self) -> Result<usize> {
420 self.count()
421 }
422
423 fn total_size(&self) -> Result<u64> {
424 self.total_size()
425 }
426
427 fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
428 self.prune(max_count, max_bytes)
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use tempfile::TempDir;
436
437 fn make_metadata(id: &str, name: &str) -> SnapshotMetadata {
438 SnapshotMetadata::new(
439 id.to_string(),
440 name.to_string(),
441 "box-source".to_string(),
442 "alpine:latest".to_string(),
443 )
444 }
445
446 fn make_rootfs(tmp: &TempDir) -> PathBuf {
447 let rootfs = tmp.path().join("rootfs");
448 std::fs::create_dir_all(&rootfs).unwrap();
449 std::fs::write(rootfs.join("bin.sh"), "#!/bin/sh\necho hello").unwrap();
450 std::fs::create_dir_all(rootfs.join("etc")).unwrap();
451 std::fs::write(rootfs.join("etc/config"), "key=value").unwrap();
452 rootfs
453 }
454
455 #[test]
456 fn test_snapshot_store_new() {
457 let tmp = TempDir::new().unwrap();
458 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
459 assert!(store.base_dir.exists());
460 }
461
462 #[test]
463 fn test_snapshot_save_and_get() {
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-1", "first");
469 let saved = store.save(meta, &rootfs).unwrap();
470
471 assert_eq!(saved.id, "snap-1");
472 assert!(saved.size_bytes > 0);
473
474 let loaded = store.get("snap-1").unwrap().unwrap();
475 assert_eq!(loaded.id, "snap-1");
476 assert_eq!(loaded.name, "first");
477 assert_eq!(loaded.image, "alpine:latest");
478 assert_eq!(loaded.size_bytes, saved.size_bytes);
479 }
480
481 #[test]
482 fn test_snapshot_get_nonexistent() {
483 let tmp = TempDir::new().unwrap();
484 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
485 assert!(store.get("nonexistent").unwrap().is_none());
486 }
487
488 #[test]
489 fn test_snapshot_save_duplicate_fails() {
490 let tmp = TempDir::new().unwrap();
491 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
492 let rootfs = make_rootfs(&tmp);
493
494 let meta = make_metadata("snap-dup", "dup");
495 store.save(meta.clone(), &rootfs).unwrap();
496
497 let result = store.save(meta, &rootfs);
498 assert!(result.is_err());
499 assert!(result.unwrap_err().to_string().contains("already exists"));
500 }
501
502 #[test]
503 fn test_snapshot_rootfs_copied() {
504 let tmp = TempDir::new().unwrap();
505 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
506 let rootfs = make_rootfs(&tmp);
507
508 let meta = make_metadata("snap-fs", "fs-test");
509 store.save(meta, &rootfs).unwrap();
510
511 let snap_rootfs = store.rootfs_path("snap-fs");
512 assert!(snap_rootfs.join("bin.sh").exists());
513 assert!(snap_rootfs.join("etc/config").exists());
514 assert_eq!(
515 std::fs::read_to_string(snap_rootfs.join("etc/config")).unwrap(),
516 "key=value"
517 );
518 }
519
520 #[test]
521 fn test_snapshot_list_empty() {
522 let tmp = TempDir::new().unwrap();
523 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
524 let list = store.list().unwrap();
525 assert!(list.is_empty());
526 }
527
528 #[test]
529 fn test_snapshot_list_multiple() {
530 let tmp = TempDir::new().unwrap();
531 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
532 let rootfs = make_rootfs(&tmp);
533
534 for i in 0..3 {
535 let meta = make_metadata(&format!("snap-{}", i), &format!("snap-{}", i));
536 store.save(meta, &rootfs).unwrap();
537 std::thread::sleep(std::time::Duration::from_millis(10));
538 }
539
540 let list = store.list().unwrap();
541 assert_eq!(list.len(), 3);
542 assert_eq!(list[0].id, "snap-2");
544 assert_eq!(list[2].id, "snap-0");
545 }
546
547 #[test]
548 fn test_snapshot_delete() {
549 let tmp = TempDir::new().unwrap();
550 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
551 let rootfs = make_rootfs(&tmp);
552
553 let meta = make_metadata("snap-del", "delete-me");
554 store.save(meta, &rootfs).unwrap();
555
556 assert!(store.delete("snap-del").unwrap());
557 assert!(store.get("snap-del").unwrap().is_none());
558 }
559
560 #[test]
561 fn test_snapshot_delete_nonexistent() {
562 let tmp = TempDir::new().unwrap();
563 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
564 assert!(!store.delete("nope").unwrap());
565 }
566
567 #[test]
568 fn snapshot_ids_cannot_escape_the_store() {
569 let tmp = TempDir::new().unwrap();
570 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
571 let rootfs = make_rootfs(&tmp);
572
573 for id in ["", ".", "..", "../outside", "nested/snapshot"] {
574 let save_error = store
575 .save(make_metadata(id, "invalid"), &rootfs)
576 .unwrap_err()
577 .to_string();
578 assert!(save_error.contains("invalid snapshot id"), "{save_error}");
579 let get_error = store.get(id).unwrap_err().to_string();
580 assert!(get_error.contains("invalid snapshot id"), "{get_error}");
581 let delete_error = store.delete_force(id).unwrap_err().to_string();
582 assert!(
583 delete_error.contains("invalid snapshot id"),
584 "{delete_error}"
585 );
586 }
587 }
588
589 #[cfg(unix)]
590 #[test]
591 fn snapshot_delete_never_follows_a_bundle_symlink() {
592 let tmp = TempDir::new().unwrap();
593 let snapshots = tmp.path().join("snapshots");
594 let store = SnapshotStore::new(&snapshots).unwrap();
595 let outside = tmp.path().join("outside");
596 std::fs::create_dir(&outside).unwrap();
597 std::fs::write(outside.join("keep"), b"external").unwrap();
598 std::os::unix::fs::symlink(&outside, snapshots.join("forged")).unwrap();
599
600 let error = store.delete_force("forged").unwrap_err().to_string();
601
602 assert!(error.contains("not a plain directory"), "{error}");
603 assert_eq!(std::fs::read(outside.join("keep")).unwrap(), b"external");
604 assert!(snapshots.join("forged").symlink_metadata().is_ok());
605 }
606
607 #[test]
608 fn delete_refuses_a_referenced_directory_snapshot_without_force() {
609 let tmp = TempDir::new().unwrap();
610 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
611 let rootfs = make_rootfs(&tmp);
612 store
613 .save(make_metadata("snap-used", "used"), &rootfs)
614 .unwrap();
615 let box_dir = tmp.path().join("boxes/restored");
616 std::fs::create_dir_all(&box_dir).unwrap();
617 std::fs::write(
618 box_dir.join(".snapshot-lower"),
619 store
620 .rootfs_path("snap-used")
621 .canonicalize()
622 .unwrap()
623 .to_string_lossy()
624 .as_bytes(),
625 )
626 .unwrap();
627
628 let error = store.delete("snap-used").unwrap_err().to_string();
629 assert!(error.contains("still used"), "{error}");
630 assert!(store.get("snap-used").unwrap().is_some());
631 assert!(store.delete_force("snap-used").unwrap());
632 }
633
634 #[test]
635 fn test_snapshot_count() {
636 let tmp = TempDir::new().unwrap();
637 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
638 let rootfs = make_rootfs(&tmp);
639
640 assert_eq!(store.count().unwrap(), 0);
641
642 store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
643 store.save(make_metadata("s2", "s2"), &rootfs).unwrap();
644 assert_eq!(store.count().unwrap(), 2);
645 }
646
647 #[test]
648 fn test_snapshot_total_size() {
649 let tmp = TempDir::new().unwrap();
650 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
651 let rootfs = make_rootfs(&tmp);
652
653 store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
654 let total = store.total_size().unwrap();
655 assert!(total > 0);
656 }
657
658 #[test]
659 fn test_snapshot_prune_by_count() {
660 let tmp = TempDir::new().unwrap();
661 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
662 let rootfs = make_rootfs(&tmp);
663
664 for i in 0..5 {
665 let meta = make_metadata(&format!("s{}", i), &format!("s{}", i));
666 store.save(meta, &rootfs).unwrap();
667 std::thread::sleep(std::time::Duration::from_millis(10));
668 }
669
670 let removed = store.prune(3, 0).unwrap();
671 assert_eq!(removed.len(), 2);
672 assert_eq!(store.count().unwrap(), 3);
673
674 assert!(store.get("s0").unwrap().is_none());
676 assert!(store.get("s1").unwrap().is_none());
677 assert!(store.get("s4").unwrap().is_some());
679 assert!(store.get("s3").unwrap().is_some());
680 assert!(store.get("s2").unwrap().is_some());
681 }
682
683 #[test]
684 fn test_snapshot_prune_no_limits() {
685 let tmp = TempDir::new().unwrap();
686 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
687 let rootfs = make_rootfs(&tmp);
688
689 for i in 0..3 {
690 store
691 .save(
692 make_metadata(&format!("s{}", i), &format!("s{}", i)),
693 &rootfs,
694 )
695 .unwrap();
696 }
697
698 let removed = store.prune(0, 0).unwrap();
699 assert!(removed.is_empty());
700 assert_eq!(store.count().unwrap(), 3);
701 }
702
703 #[test]
704 fn prune_skips_in_use_snapshots() {
705 let tmp = TempDir::new().unwrap();
706 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
707 let rootfs = make_rootfs(&tmp);
708
709 for i in 0..5 {
710 store
711 .save(
712 make_metadata(&format!("s{}", i), &format!("s{}", i)),
713 &rootfs,
714 )
715 .unwrap();
716 std::thread::sleep(std::time::Duration::from_millis(10));
717 }
718
719 let box_dir = tmp.path().join("boxes").join("box1");
721 std::fs::create_dir_all(&box_dir).unwrap();
722 std::fs::write(
723 box_dir.join(".snapshot-lower"),
724 store.rootfs_path("s0").to_string_lossy().as_bytes(),
725 )
726 .unwrap();
727
728 let removed = store.prune(3, 0).unwrap();
731 assert_eq!(removed.len(), 2);
732 assert!(store.get("s0").unwrap().is_some(), "in-use s0 must be kept");
733 assert!(store.get("s1").unwrap().is_none());
734 assert!(store.get("s2").unwrap().is_none());
735 assert!(store.get("s3").unwrap().is_some());
736 assert!(store.get("s4").unwrap().is_some());
737 }
738
739 #[test]
740 fn prune_keeps_everything_when_all_in_use() {
741 let tmp = TempDir::new().unwrap();
742 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
743 let rootfs = make_rootfs(&tmp);
744 store.save(make_metadata("a", "a"), &rootfs).unwrap();
745 store.save(make_metadata("b", "b"), &rootfs).unwrap();
746
747 for (i, id) in ["a", "b"].iter().enumerate() {
749 let bd = tmp.path().join("boxes").join(format!("bx{i}"));
750 std::fs::create_dir_all(&bd).unwrap();
751 std::fs::write(
752 bd.join(".snapshot-lower"),
753 store.rootfs_path(id).to_string_lossy().as_bytes(),
754 )
755 .unwrap();
756 }
757
758 let removed = store.prune(1, 0).unwrap();
760 assert!(removed.is_empty(), "in-use snapshots must never be pruned");
761 assert_eq!(store.count().unwrap(), 2);
762 }
763
764 #[test]
765 fn test_snapshot_save_with_empty_rootfs() {
766 let tmp = TempDir::new().unwrap();
767 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
768 let empty_rootfs = tmp.path().join("nonexistent_rootfs");
769
770 let meta = make_metadata("snap-empty", "empty");
771 let saved = store.save(meta, &empty_rootfs).unwrap();
772 assert_eq!(saved.id, "snap-empty");
773
774 assert!(store.rootfs_path("snap-empty").exists());
776 }
777
778 #[test]
779 fn test_dir_size() {
780 let tmp = TempDir::new().unwrap();
781 let dir = tmp.path().join("sized");
782 std::fs::create_dir_all(&dir).unwrap();
783 std::fs::write(dir.join("a.txt"), "hello").unwrap();
784 std::fs::write(dir.join("b.txt"), "world!").unwrap();
785
786 let size = dir_size(&dir).unwrap();
787 assert_eq!(size, 11); }
789
790 #[test]
791 fn test_dir_size_nested() {
792 let tmp = TempDir::new().unwrap();
793 let dir = tmp.path().join("nested");
794 std::fs::create_dir_all(dir.join("sub")).unwrap();
795 std::fs::write(dir.join("a.txt"), "abc").unwrap();
796 std::fs::write(dir.join("sub/b.txt"), "defgh").unwrap();
797
798 let size = dir_size(&dir).unwrap();
799 assert_eq!(size, 8); }
801
802 #[test]
803 fn test_dir_size_empty() {
804 let tmp = TempDir::new().unwrap();
805 let dir = tmp.path().join("empty");
806 std::fs::create_dir_all(&dir).unwrap();
807 assert_eq!(dir_size(&dir).unwrap(), 0);
808 }
809
810 #[test]
811 fn test_copy_dir_recursive() {
812 let tmp = TempDir::new().unwrap();
813 let src = tmp.path().join("src");
814 let dst = tmp.path().join("dst");
815
816 std::fs::create_dir_all(src.join("sub")).unwrap();
817 std::fs::write(src.join("a.txt"), "hello").unwrap();
818 std::fs::write(src.join("sub/b.txt"), "world").unwrap();
819
820 copy_dir_recursive(&src, &dst).unwrap();
821
822 assert!(dst.join("a.txt").exists());
823 assert!(dst.join("sub/b.txt").exists());
824 assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
825 assert_eq!(
826 std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
827 "world"
828 );
829 }
830
831 #[cfg(unix)]
832 #[test]
833 fn snapshot_size_does_not_follow_absolute_symlinks() {
834 use std::os::unix::ffi::OsStrExt;
835
836 let tmp = TempDir::new().unwrap();
837 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
838 let rootfs = tmp.path().join("rootfs");
839 std::fs::create_dir(&rootfs).unwrap();
840 let outside = tmp.path().join("outside");
841 std::fs::write(&outside, vec![0_u8; 64 * 1024]).unwrap();
842 std::os::unix::fs::symlink(&outside, rootfs.join("outside-link")).unwrap();
843
844 let saved = store
845 .save(make_metadata("symlink-size", "symlink-size"), &rootfs)
846 .unwrap();
847
848 assert_eq!(
849 saved.size_bytes,
850 outside.as_os_str().as_bytes().len() as u64
851 );
852 }
853
854 #[cfg(unix)]
855 #[test]
856 fn snapshot_preserves_hardlinks_and_xattrs() {
857 use std::os::unix::fs::MetadataExt;
858
859 let tmp = TempDir::new().unwrap();
860 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
861 let rootfs = tmp.path().join("rootfs");
862 std::fs::create_dir(&rootfs).unwrap();
863 let first = rootfs.join("first");
864 let second = rootfs.join("second");
865 std::fs::write(&first, b"shared inode").unwrap();
866 std::fs::hard_link(&first, &second).unwrap();
867 xattr::set(&first, "user.a3s.snapshot", b"preserved").unwrap();
868
869 let saved = store
870 .save(make_metadata("hardlink-xattr", "hardlink-xattr"), &rootfs)
871 .unwrap();
872 let captured = store.rootfs_path(&saved.id);
873 let captured_first = std::fs::metadata(captured.join("first")).unwrap();
874 let captured_second = std::fs::metadata(captured.join("second")).unwrap();
875
876 assert_eq!(captured_first.dev(), captured_second.dev());
877 assert_eq!(captured_first.ino(), captured_second.ino());
878 assert_eq!(saved.size_bytes, b"shared inode".len() as u64);
879 assert_eq!(
880 xattr::get(captured.join("first"), "user.a3s.snapshot").unwrap(),
881 Some(b"preserved".to_vec())
882 );
883 }
884
885 #[cfg(unix)]
886 #[test]
887 fn snapshot_rejects_fifo_without_leaking_staging() {
888 use std::os::unix::ffi::OsStrExt;
889
890 let tmp = TempDir::new().unwrap();
891 let snapshots = tmp.path().join("snapshots");
892 let store = SnapshotStore::new(&snapshots).unwrap();
893 let rootfs = tmp.path().join("rootfs");
894 std::fs::create_dir(&rootfs).unwrap();
895 let fifo = rootfs.join("blocking-fifo");
896 let fifo_path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
897 assert_eq!(unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) }, 0);
898
899 let error = store
900 .save(make_metadata("fifo", "fifo"), &rootfs)
901 .unwrap_err();
902
903 assert!(error.to_string().contains("unsupported special file"));
904 assert!(store.get("fifo").unwrap().is_none());
905 assert!(
906 std::fs::read_dir(snapshots)
907 .unwrap()
908 .flatten()
909 .all(|entry| !entry.file_name().to_string_lossy().starts_with(".staging-")),
910 "failed Snapshot must remove its staging tree"
911 );
912 }
913
914 #[test]
915 fn managed_snapshot_installs_only_terminal_rootfs_metadata() {
916 let tmp = TempDir::new().unwrap();
917 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
918 let rootfs = make_rootfs(&tmp);
919 std::fs::write(
920 rootfs.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')),
921 b"stale image metadata",
922 )
923 .unwrap();
924 std::fs::write(
925 rootfs.join(PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/')),
926 b"stale replay metadata",
927 )
928 .unwrap();
929 let manifest = RootfsMetadataManifest::new(Vec::new());
930
931 store
932 .save_managed(
933 make_metadata("managed-metadata", "managed-metadata"),
934 &rootfs,
935 &manifest,
936 )
937 .unwrap();
938
939 let captured = store.rootfs_path("managed-metadata");
940 assert!(!captured
941 .join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/'))
942 .exists());
943 assert!(!captured
944 .join(PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/'))
945 .exists());
946 let stored: RootfsMetadataManifest = serde_json::from_slice(
947 &std::fs::read(captured.join(ROOTFS_METADATA_PATH.trim_start_matches('/'))).unwrap(),
948 )
949 .unwrap();
950 assert_eq!(stored, manifest);
951 }
952
953 #[test]
954 fn new_sweeps_leftover_staging_dirs() {
955 let tmp = TempDir::new().unwrap();
956 let base = tmp.path().join("snapshots");
957 std::fs::create_dir_all(&base).unwrap();
958
959 let leaked = base.join(".staging-snap1-12345-0");
961 std::fs::create_dir_all(leaked.join("rootfs")).unwrap();
962 std::fs::write(leaked.join("rootfs").join("big"), vec![0u8; 4096]).unwrap();
963 let real = base.join("snap1");
965 std::fs::create_dir_all(&real).unwrap();
966 std::fs::write(real.join("metadata.json"), "{}").unwrap();
967
968 let _store = SnapshotStore::new(&base).unwrap();
969
970 assert!(
971 !leaked.exists(),
972 "leaked .staging-* dir must be swept on open"
973 );
974 assert!(real.exists(), "a real snapshot dir must be preserved");
975 }
976}