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 mut snapshots = self.list()?;
239 let mut removed = Vec::new();
240
241 while !snapshots.is_empty() {
243 let over_count = max_count > 0 && snapshots.len() > max_count;
244 let total: u64 = snapshots.iter().map(|s| s.size_bytes).sum();
245 let over_size = max_bytes > 0 && total > max_bytes;
246
247 if !over_count && !over_size {
248 break;
249 }
250
251 if let Some(oldest) = snapshots.pop() {
253 self.delete(&oldest.id)?;
254 removed.push(oldest.id);
255 }
256 }
257
258 Ok(removed)
259 }
260}
261
262fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
264 std::fs::create_dir_all(dst).map_err(|e| {
265 BoxError::CacheError(format!(
266 "Failed to create directory {}: {}",
267 dst.display(),
268 e
269 ))
270 })?;
271
272 for entry in std::fs::read_dir(src).map_err(|e| {
273 BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
274 })? {
275 let entry = entry
276 .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
277 let src_path = entry.path();
278 let dst_path = dst.join(entry.file_name());
279 let file_type = entry.file_type().map_err(|e| {
280 BoxError::CacheError(format!(
281 "Failed to read file type for {}: {}",
282 src_path.display(),
283 e
284 ))
285 })?;
286
287 if file_type.is_symlink() {
288 copy_symlink(&src_path, &dst_path)?;
289 } else if file_type.is_dir() {
290 copy_dir_recursive(&src_path, &dst_path)?;
291 } else {
292 std::fs::copy(&src_path, &dst_path).map_err(|e| {
293 BoxError::CacheError(format!(
294 "Failed to copy {} → {}: {}",
295 src_path.display(),
296 dst_path.display(),
297 e
298 ))
299 })?;
300 }
301 }
302
303 Ok(())
304}
305
306fn copy_symlink(src: &Path, dst: &Path) -> Result<()> {
307 let target = std::fs::read_link(src).map_err(|e| {
308 BoxError::CacheError(format!("Failed to read symlink {}: {}", src.display(), e))
309 })?;
310
311 #[cfg(unix)]
312 {
313 std::os::unix::fs::symlink(&target, dst).map_err(|e| {
314 BoxError::CacheError(format!(
315 "Failed to create symlink {} → {}: {}",
316 dst.display(),
317 target.display(),
318 e
319 ))
320 })?;
321 }
322
323 #[cfg(windows)]
324 {
325 let is_dir = src.metadata().map(|m| m.is_dir()).unwrap_or(false);
326 let result = if is_dir {
327 std::os::windows::fs::symlink_dir(&target, dst)
328 } else {
329 std::os::windows::fs::symlink_file(&target, dst)
330 };
331 result.map_err(|e| {
332 BoxError::CacheError(format!(
333 "Failed to create symlink {} → {}: {}",
334 dst.display(),
335 target.display(),
336 e
337 ))
338 })?;
339 }
340
341 #[cfg(not(any(unix, windows)))]
342 {
343 let _ = target;
344 return Err(BoxError::CacheError(format!(
345 "Symlink copy is not supported on this platform: {}",
346 src.display()
347 )));
348 }
349
350 Ok(())
351}
352
353fn dir_size(path: &Path) -> u64 {
355 let mut total = 0u64;
356 if let Ok(entries) = std::fs::read_dir(path) {
357 for entry in entries.flatten() {
358 let p = entry.path();
359 if p.is_dir() {
360 total += dir_size(&p);
361 } else if let Ok(meta) = p.metadata() {
362 total += meta.len();
363 }
364 }
365 }
366 total
367}
368
369impl SnapshotStoreBackend for SnapshotStore {
370 fn save(&self, metadata: SnapshotMetadata, rootfs_source: &Path) -> Result<SnapshotMetadata> {
371 self.save(metadata, rootfs_source)
372 }
373
374 fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
375 self.get(id)
376 }
377
378 fn list(&self) -> Result<Vec<SnapshotMetadata>> {
379 self.list()
380 }
381
382 fn delete(&self, id: &str) -> Result<bool> {
383 self.delete(id)
384 }
385
386 fn count(&self) -> Result<usize> {
387 self.count()
388 }
389
390 fn total_size(&self) -> Result<u64> {
391 self.total_size()
392 }
393
394 fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
395 self.prune(max_count, max_bytes)
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use tempfile::TempDir;
403
404 fn make_metadata(id: &str, name: &str) -> SnapshotMetadata {
405 SnapshotMetadata::new(
406 id.to_string(),
407 name.to_string(),
408 "box-source".to_string(),
409 "alpine:latest".to_string(),
410 )
411 }
412
413 fn make_rootfs(tmp: &TempDir) -> PathBuf {
414 let rootfs = tmp.path().join("rootfs");
415 std::fs::create_dir_all(&rootfs).unwrap();
416 std::fs::write(rootfs.join("bin.sh"), "#!/bin/sh\necho hello").unwrap();
417 std::fs::create_dir_all(rootfs.join("etc")).unwrap();
418 std::fs::write(rootfs.join("etc/config"), "key=value").unwrap();
419 rootfs
420 }
421
422 #[test]
423 fn test_snapshot_store_new() {
424 let tmp = TempDir::new().unwrap();
425 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
426 assert!(store.base_dir.exists());
427 }
428
429 #[test]
430 fn test_snapshot_save_and_get() {
431 let tmp = TempDir::new().unwrap();
432 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
433 let rootfs = make_rootfs(&tmp);
434
435 let meta = make_metadata("snap-1", "first");
436 let saved = store.save(meta, &rootfs).unwrap();
437
438 assert_eq!(saved.id, "snap-1");
439 assert!(saved.size_bytes > 0);
440
441 let loaded = store.get("snap-1").unwrap().unwrap();
442 assert_eq!(loaded.id, "snap-1");
443 assert_eq!(loaded.name, "first");
444 assert_eq!(loaded.image, "alpine:latest");
445 assert_eq!(loaded.size_bytes, saved.size_bytes);
446 }
447
448 #[test]
449 fn test_snapshot_get_nonexistent() {
450 let tmp = TempDir::new().unwrap();
451 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
452 assert!(store.get("nonexistent").unwrap().is_none());
453 }
454
455 #[test]
456 fn test_snapshot_save_duplicate_fails() {
457 let tmp = TempDir::new().unwrap();
458 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
459 let rootfs = make_rootfs(&tmp);
460
461 let meta = make_metadata("snap-dup", "dup");
462 store.save(meta.clone(), &rootfs).unwrap();
463
464 let result = store.save(meta, &rootfs);
465 assert!(result.is_err());
466 assert!(result.unwrap_err().to_string().contains("already exists"));
467 }
468
469 #[test]
470 fn test_snapshot_rootfs_copied() {
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-fs", "fs-test");
476 store.save(meta, &rootfs).unwrap();
477
478 let snap_rootfs = store.rootfs_path("snap-fs");
479 assert!(snap_rootfs.join("bin.sh").exists());
480 assert!(snap_rootfs.join("etc/config").exists());
481 assert_eq!(
482 std::fs::read_to_string(snap_rootfs.join("etc/config")).unwrap(),
483 "key=value"
484 );
485 }
486
487 #[test]
488 fn test_snapshot_list_empty() {
489 let tmp = TempDir::new().unwrap();
490 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
491 let list = store.list().unwrap();
492 assert!(list.is_empty());
493 }
494
495 #[test]
496 fn test_snapshot_list_multiple() {
497 let tmp = TempDir::new().unwrap();
498 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
499 let rootfs = make_rootfs(&tmp);
500
501 for i in 0..3 {
502 let meta = make_metadata(&format!("snap-{}", i), &format!("snap-{}", i));
503 store.save(meta, &rootfs).unwrap();
504 std::thread::sleep(std::time::Duration::from_millis(10));
505 }
506
507 let list = store.list().unwrap();
508 assert_eq!(list.len(), 3);
509 assert_eq!(list[0].id, "snap-2");
511 assert_eq!(list[2].id, "snap-0");
512 }
513
514 #[test]
515 fn test_snapshot_delete() {
516 let tmp = TempDir::new().unwrap();
517 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
518 let rootfs = make_rootfs(&tmp);
519
520 let meta = make_metadata("snap-del", "delete-me");
521 store.save(meta, &rootfs).unwrap();
522
523 assert!(store.delete("snap-del").unwrap());
524 assert!(store.get("snap-del").unwrap().is_none());
525 }
526
527 #[test]
528 fn test_snapshot_delete_nonexistent() {
529 let tmp = TempDir::new().unwrap();
530 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
531 assert!(!store.delete("nope").unwrap());
532 }
533
534 #[test]
535 fn test_snapshot_count() {
536 let tmp = TempDir::new().unwrap();
537 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
538 let rootfs = make_rootfs(&tmp);
539
540 assert_eq!(store.count().unwrap(), 0);
541
542 store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
543 store.save(make_metadata("s2", "s2"), &rootfs).unwrap();
544 assert_eq!(store.count().unwrap(), 2);
545 }
546
547 #[test]
548 fn test_snapshot_total_size() {
549 let tmp = TempDir::new().unwrap();
550 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
551 let rootfs = make_rootfs(&tmp);
552
553 store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
554 let total = store.total_size().unwrap();
555 assert!(total > 0);
556 }
557
558 #[test]
559 fn test_snapshot_prune_by_count() {
560 let tmp = TempDir::new().unwrap();
561 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
562 let rootfs = make_rootfs(&tmp);
563
564 for i in 0..5 {
565 let meta = make_metadata(&format!("s{}", i), &format!("s{}", i));
566 store.save(meta, &rootfs).unwrap();
567 std::thread::sleep(std::time::Duration::from_millis(10));
568 }
569
570 let removed = store.prune(3, 0).unwrap();
571 assert_eq!(removed.len(), 2);
572 assert_eq!(store.count().unwrap(), 3);
573
574 assert!(store.get("s0").unwrap().is_none());
576 assert!(store.get("s1").unwrap().is_none());
577 assert!(store.get("s4").unwrap().is_some());
579 assert!(store.get("s3").unwrap().is_some());
580 assert!(store.get("s2").unwrap().is_some());
581 }
582
583 #[test]
584 fn test_snapshot_prune_no_limits() {
585 let tmp = TempDir::new().unwrap();
586 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
587 let rootfs = make_rootfs(&tmp);
588
589 for i in 0..3 {
590 store
591 .save(
592 make_metadata(&format!("s{}", i), &format!("s{}", i)),
593 &rootfs,
594 )
595 .unwrap();
596 }
597
598 let removed = store.prune(0, 0).unwrap();
599 assert!(removed.is_empty());
600 assert_eq!(store.count().unwrap(), 3);
601 }
602
603 #[test]
604 fn test_snapshot_save_with_empty_rootfs() {
605 let tmp = TempDir::new().unwrap();
606 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
607 let empty_rootfs = tmp.path().join("nonexistent_rootfs");
608
609 let meta = make_metadata("snap-empty", "empty");
610 let saved = store.save(meta, &empty_rootfs).unwrap();
611 assert_eq!(saved.id, "snap-empty");
612
613 assert!(store.rootfs_path("snap-empty").exists());
615 }
616
617 #[test]
618 fn test_dir_size() {
619 let tmp = TempDir::new().unwrap();
620 let dir = tmp.path().join("sized");
621 std::fs::create_dir_all(&dir).unwrap();
622 std::fs::write(dir.join("a.txt"), "hello").unwrap();
623 std::fs::write(dir.join("b.txt"), "world!").unwrap();
624
625 let size = dir_size(&dir);
626 assert_eq!(size, 11); }
628
629 #[test]
630 fn test_dir_size_nested() {
631 let tmp = TempDir::new().unwrap();
632 let dir = tmp.path().join("nested");
633 std::fs::create_dir_all(dir.join("sub")).unwrap();
634 std::fs::write(dir.join("a.txt"), "abc").unwrap();
635 std::fs::write(dir.join("sub/b.txt"), "defgh").unwrap();
636
637 let size = dir_size(&dir);
638 assert_eq!(size, 8); }
640
641 #[test]
642 fn test_dir_size_empty() {
643 let tmp = TempDir::new().unwrap();
644 let dir = tmp.path().join("empty");
645 std::fs::create_dir_all(&dir).unwrap();
646 assert_eq!(dir_size(&dir), 0);
647 }
648
649 #[test]
650 fn test_copy_dir_recursive() {
651 let tmp = TempDir::new().unwrap();
652 let src = tmp.path().join("src");
653 let dst = tmp.path().join("dst");
654
655 std::fs::create_dir_all(src.join("sub")).unwrap();
656 std::fs::write(src.join("a.txt"), "hello").unwrap();
657 std::fs::write(src.join("sub/b.txt"), "world").unwrap();
658
659 copy_dir_recursive(&src, &dst).unwrap();
660
661 assert!(dst.join("a.txt").exists());
662 assert!(dst.join("sub/b.txt").exists());
663 assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
664 assert_eq!(
665 std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
666 "world"
667 );
668 }
669
670 #[test]
671 fn new_sweeps_leftover_staging_dirs() {
672 let tmp = TempDir::new().unwrap();
673 let base = tmp.path().join("snapshots");
674 std::fs::create_dir_all(&base).unwrap();
675
676 let leaked = base.join(".staging-snap1-12345-0");
678 std::fs::create_dir_all(leaked.join("rootfs")).unwrap();
679 std::fs::write(leaked.join("rootfs").join("big"), vec![0u8; 4096]).unwrap();
680 let real = base.join("snap1");
682 std::fs::create_dir_all(&real).unwrap();
683 std::fs::write(real.join("metadata.json"), "{}").unwrap();
684
685 let _store = SnapshotStore::new(&base).unwrap();
686
687 assert!(
688 !leaked.exists(),
689 "leaked .staging-* dir must be swept on open"
690 );
691 assert!(real.exists(), "a real snapshot dir must be preserved");
692 }
693}