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 Ok(Self {
34 base_dir: base_dir.to_path_buf(),
35 })
36 }
37
38 pub fn default_path() -> Result<Self> {
40 let home = a3s_box_core::dirs_home();
41 Self::new(&home.join("snapshots"))
42 }
43
44 pub fn save(
49 &self,
50 mut metadata: SnapshotMetadata,
51 rootfs_source: &Path,
52 ) -> Result<SnapshotMetadata> {
53 let snap_dir = self.base_dir.join(&metadata.id);
54 if snap_dir.exists() {
55 return Err(BoxError::CacheError(format!(
56 "Snapshot '{}' already exists",
57 metadata.id
58 )));
59 }
60
61 use std::sync::atomic::{AtomicU64, Ordering};
67 static STAGE_SEQ: AtomicU64 = AtomicU64::new(0);
68 let staging = self.base_dir.join(format!(
69 ".staging-{}-{}-{}",
70 metadata.id,
71 std::process::id(),
72 STAGE_SEQ.fetch_add(1, Ordering::Relaxed)
73 ));
74 let _ = std::fs::remove_dir_all(&staging);
75 std::fs::create_dir_all(&staging).map_err(|e| {
76 BoxError::CacheError(format!(
77 "Failed to create snapshot staging directory {}: {}",
78 staging.display(),
79 e
80 ))
81 })?;
82
83 let rootfs_dest = staging.join("rootfs");
85 if rootfs_source.exists() {
86 copy_dir_recursive(rootfs_source, &rootfs_dest)?;
87 } else {
88 std::fs::create_dir_all(&rootfs_dest).map_err(|e| {
89 BoxError::CacheError(format!("Failed to create snapshot rootfs directory: {}", e))
90 })?;
91 }
92
93 metadata.size_bytes = dir_size(&staging);
95
96 let meta_path = staging.join("metadata.json");
98 let json = serde_json::to_string_pretty(&metadata).map_err(|e| {
99 BoxError::SerializationError(format!("Failed to serialize snapshot metadata: {}", e))
100 })?;
101 std::fs::write(&meta_path, &json).map_err(|e| {
102 BoxError::CacheError(format!(
103 "Failed to write snapshot metadata {}: {}",
104 meta_path.display(),
105 e
106 ))
107 })?;
108
109 std::fs::rename(&staging, &snap_dir).map_err(|e| {
112 let _ = std::fs::remove_dir_all(&staging);
113 BoxError::CacheError(format!(
114 "Failed to publish snapshot {}: {}",
115 snap_dir.display(),
116 e
117 ))
118 })?;
119
120 Ok(metadata)
121 }
122
123 pub fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
125 let meta_path = self.base_dir.join(id).join("metadata.json");
126 if !meta_path.exists() {
127 return Ok(None);
128 }
129
130 let data = std::fs::read_to_string(&meta_path).map_err(|e| {
131 BoxError::CacheError(format!(
132 "Failed to read snapshot metadata {}: {}",
133 meta_path.display(),
134 e
135 ))
136 })?;
137 let metadata: SnapshotMetadata = serde_json::from_str(&data).map_err(|e| {
138 BoxError::SerializationError(format!("Failed to parse snapshot metadata: {}", e))
139 })?;
140 Ok(Some(metadata))
141 }
142
143 pub fn rootfs_path(&self, id: &str) -> PathBuf {
145 self.base_dir.join(id).join("rootfs")
146 }
147
148 pub fn list(&self) -> Result<Vec<SnapshotMetadata>> {
150 let mut snapshots = Vec::new();
151
152 let entries = match std::fs::read_dir(&self.base_dir) {
153 Ok(entries) => entries,
154 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(snapshots),
155 Err(e) => {
156 return Err(BoxError::CacheError(format!(
157 "Failed to read snapshot directory: {}",
158 e
159 )));
160 }
161 };
162
163 for entry in entries {
164 let entry = entry.map_err(|e| {
165 BoxError::CacheError(format!("Failed to read snapshot entry: {}", e))
166 })?;
167 let meta_path = entry.path().join("metadata.json");
168 if meta_path.exists() {
169 match std::fs::read_to_string(&meta_path) {
170 Ok(data) => match serde_json::from_str::<SnapshotMetadata>(&data) {
171 Ok(meta) => snapshots.push(meta),
172 Err(e) => tracing::warn!(
177 path = %meta_path.display(),
178 error = %e,
179 "Skipping snapshot with unparseable metadata.json (orphaned on disk)"
180 ),
181 },
182 Err(e) => tracing::warn!(
183 path = %meta_path.display(),
184 error = %e,
185 "Skipping snapshot with unreadable metadata.json"
186 ),
187 }
188 }
189 }
190
191 snapshots.sort_by_key(|snapshot| Reverse(snapshot.created_at));
193 Ok(snapshots)
194 }
195
196 pub fn delete(&self, id: &str) -> Result<bool> {
198 let snap_dir = self.base_dir.join(id);
199 if !snap_dir.exists() {
200 return Ok(false);
201 }
202
203 std::fs::remove_dir_all(&snap_dir).map_err(|e| {
204 BoxError::CacheError(format!("Failed to delete snapshot {}: {}", id, e))
205 })?;
206 Ok(true)
207 }
208
209 pub fn count(&self) -> Result<usize> {
211 Ok(self.list()?.len())
212 }
213
214 pub fn total_size(&self) -> Result<u64> {
216 Ok(self.list()?.iter().map(|s| s.size_bytes).sum())
217 }
218
219 pub fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
224 let mut snapshots = self.list()?;
225 let mut removed = Vec::new();
226
227 while !snapshots.is_empty() {
229 let over_count = max_count > 0 && snapshots.len() > max_count;
230 let total: u64 = snapshots.iter().map(|s| s.size_bytes).sum();
231 let over_size = max_bytes > 0 && total > max_bytes;
232
233 if !over_count && !over_size {
234 break;
235 }
236
237 if let Some(oldest) = snapshots.pop() {
239 self.delete(&oldest.id)?;
240 removed.push(oldest.id);
241 }
242 }
243
244 Ok(removed)
245 }
246}
247
248fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
250 std::fs::create_dir_all(dst).map_err(|e| {
251 BoxError::CacheError(format!(
252 "Failed to create directory {}: {}",
253 dst.display(),
254 e
255 ))
256 })?;
257
258 for entry in std::fs::read_dir(src).map_err(|e| {
259 BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
260 })? {
261 let entry = entry
262 .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
263 let src_path = entry.path();
264 let dst_path = dst.join(entry.file_name());
265 let file_type = entry.file_type().map_err(|e| {
266 BoxError::CacheError(format!(
267 "Failed to read file type for {}: {}",
268 src_path.display(),
269 e
270 ))
271 })?;
272
273 if file_type.is_symlink() {
274 copy_symlink(&src_path, &dst_path)?;
275 } else if file_type.is_dir() {
276 copy_dir_recursive(&src_path, &dst_path)?;
277 } else {
278 std::fs::copy(&src_path, &dst_path).map_err(|e| {
279 BoxError::CacheError(format!(
280 "Failed to copy {} → {}: {}",
281 src_path.display(),
282 dst_path.display(),
283 e
284 ))
285 })?;
286 }
287 }
288
289 Ok(())
290}
291
292fn copy_symlink(src: &Path, dst: &Path) -> Result<()> {
293 let target = std::fs::read_link(src).map_err(|e| {
294 BoxError::CacheError(format!("Failed to read symlink {}: {}", src.display(), e))
295 })?;
296
297 #[cfg(unix)]
298 {
299 std::os::unix::fs::symlink(&target, dst).map_err(|e| {
300 BoxError::CacheError(format!(
301 "Failed to create symlink {} → {}: {}",
302 dst.display(),
303 target.display(),
304 e
305 ))
306 })?;
307 }
308
309 #[cfg(windows)]
310 {
311 let is_dir = src.metadata().map(|m| m.is_dir()).unwrap_or(false);
312 let result = if is_dir {
313 std::os::windows::fs::symlink_dir(&target, dst)
314 } else {
315 std::os::windows::fs::symlink_file(&target, dst)
316 };
317 result.map_err(|e| {
318 BoxError::CacheError(format!(
319 "Failed to create symlink {} → {}: {}",
320 dst.display(),
321 target.display(),
322 e
323 ))
324 })?;
325 }
326
327 #[cfg(not(any(unix, windows)))]
328 {
329 let _ = target;
330 return Err(BoxError::CacheError(format!(
331 "Symlink copy is not supported on this platform: {}",
332 src.display()
333 )));
334 }
335
336 Ok(())
337}
338
339fn dir_size(path: &Path) -> u64 {
341 let mut total = 0u64;
342 if let Ok(entries) = std::fs::read_dir(path) {
343 for entry in entries.flatten() {
344 let p = entry.path();
345 if p.is_dir() {
346 total += dir_size(&p);
347 } else if let Ok(meta) = p.metadata() {
348 total += meta.len();
349 }
350 }
351 }
352 total
353}
354
355impl SnapshotStoreBackend for SnapshotStore {
356 fn save(&self, metadata: SnapshotMetadata, rootfs_source: &Path) -> Result<SnapshotMetadata> {
357 self.save(metadata, rootfs_source)
358 }
359
360 fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
361 self.get(id)
362 }
363
364 fn list(&self) -> Result<Vec<SnapshotMetadata>> {
365 self.list()
366 }
367
368 fn delete(&self, id: &str) -> Result<bool> {
369 self.delete(id)
370 }
371
372 fn count(&self) -> Result<usize> {
373 self.count()
374 }
375
376 fn total_size(&self) -> Result<u64> {
377 self.total_size()
378 }
379
380 fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
381 self.prune(max_count, max_bytes)
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use tempfile::TempDir;
389
390 fn make_metadata(id: &str, name: &str) -> SnapshotMetadata {
391 SnapshotMetadata::new(
392 id.to_string(),
393 name.to_string(),
394 "box-source".to_string(),
395 "alpine:latest".to_string(),
396 )
397 }
398
399 fn make_rootfs(tmp: &TempDir) -> PathBuf {
400 let rootfs = tmp.path().join("rootfs");
401 std::fs::create_dir_all(&rootfs).unwrap();
402 std::fs::write(rootfs.join("bin.sh"), "#!/bin/sh\necho hello").unwrap();
403 std::fs::create_dir_all(rootfs.join("etc")).unwrap();
404 std::fs::write(rootfs.join("etc/config"), "key=value").unwrap();
405 rootfs
406 }
407
408 #[test]
409 fn test_snapshot_store_new() {
410 let tmp = TempDir::new().unwrap();
411 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
412 assert!(store.base_dir.exists());
413 }
414
415 #[test]
416 fn test_snapshot_save_and_get() {
417 let tmp = TempDir::new().unwrap();
418 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
419 let rootfs = make_rootfs(&tmp);
420
421 let meta = make_metadata("snap-1", "first");
422 let saved = store.save(meta, &rootfs).unwrap();
423
424 assert_eq!(saved.id, "snap-1");
425 assert!(saved.size_bytes > 0);
426
427 let loaded = store.get("snap-1").unwrap().unwrap();
428 assert_eq!(loaded.id, "snap-1");
429 assert_eq!(loaded.name, "first");
430 assert_eq!(loaded.image, "alpine:latest");
431 assert_eq!(loaded.size_bytes, saved.size_bytes);
432 }
433
434 #[test]
435 fn test_snapshot_get_nonexistent() {
436 let tmp = TempDir::new().unwrap();
437 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
438 assert!(store.get("nonexistent").unwrap().is_none());
439 }
440
441 #[test]
442 fn test_snapshot_save_duplicate_fails() {
443 let tmp = TempDir::new().unwrap();
444 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
445 let rootfs = make_rootfs(&tmp);
446
447 let meta = make_metadata("snap-dup", "dup");
448 store.save(meta.clone(), &rootfs).unwrap();
449
450 let result = store.save(meta, &rootfs);
451 assert!(result.is_err());
452 assert!(result.unwrap_err().to_string().contains("already exists"));
453 }
454
455 #[test]
456 fn test_snapshot_rootfs_copied() {
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-fs", "fs-test");
462 store.save(meta, &rootfs).unwrap();
463
464 let snap_rootfs = store.rootfs_path("snap-fs");
465 assert!(snap_rootfs.join("bin.sh").exists());
466 assert!(snap_rootfs.join("etc/config").exists());
467 assert_eq!(
468 std::fs::read_to_string(snap_rootfs.join("etc/config")).unwrap(),
469 "key=value"
470 );
471 }
472
473 #[test]
474 fn test_snapshot_list_empty() {
475 let tmp = TempDir::new().unwrap();
476 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
477 let list = store.list().unwrap();
478 assert!(list.is_empty());
479 }
480
481 #[test]
482 fn test_snapshot_list_multiple() {
483 let tmp = TempDir::new().unwrap();
484 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
485 let rootfs = make_rootfs(&tmp);
486
487 for i in 0..3 {
488 let meta = make_metadata(&format!("snap-{}", i), &format!("snap-{}", i));
489 store.save(meta, &rootfs).unwrap();
490 std::thread::sleep(std::time::Duration::from_millis(10));
491 }
492
493 let list = store.list().unwrap();
494 assert_eq!(list.len(), 3);
495 assert_eq!(list[0].id, "snap-2");
497 assert_eq!(list[2].id, "snap-0");
498 }
499
500 #[test]
501 fn test_snapshot_delete() {
502 let tmp = TempDir::new().unwrap();
503 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
504 let rootfs = make_rootfs(&tmp);
505
506 let meta = make_metadata("snap-del", "delete-me");
507 store.save(meta, &rootfs).unwrap();
508
509 assert!(store.delete("snap-del").unwrap());
510 assert!(store.get("snap-del").unwrap().is_none());
511 }
512
513 #[test]
514 fn test_snapshot_delete_nonexistent() {
515 let tmp = TempDir::new().unwrap();
516 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
517 assert!(!store.delete("nope").unwrap());
518 }
519
520 #[test]
521 fn test_snapshot_count() {
522 let tmp = TempDir::new().unwrap();
523 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
524 let rootfs = make_rootfs(&tmp);
525
526 assert_eq!(store.count().unwrap(), 0);
527
528 store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
529 store.save(make_metadata("s2", "s2"), &rootfs).unwrap();
530 assert_eq!(store.count().unwrap(), 2);
531 }
532
533 #[test]
534 fn test_snapshot_total_size() {
535 let tmp = TempDir::new().unwrap();
536 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
537 let rootfs = make_rootfs(&tmp);
538
539 store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
540 let total = store.total_size().unwrap();
541 assert!(total > 0);
542 }
543
544 #[test]
545 fn test_snapshot_prune_by_count() {
546 let tmp = TempDir::new().unwrap();
547 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
548 let rootfs = make_rootfs(&tmp);
549
550 for i in 0..5 {
551 let meta = make_metadata(&format!("s{}", i), &format!("s{}", i));
552 store.save(meta, &rootfs).unwrap();
553 std::thread::sleep(std::time::Duration::from_millis(10));
554 }
555
556 let removed = store.prune(3, 0).unwrap();
557 assert_eq!(removed.len(), 2);
558 assert_eq!(store.count().unwrap(), 3);
559
560 assert!(store.get("s0").unwrap().is_none());
562 assert!(store.get("s1").unwrap().is_none());
563 assert!(store.get("s4").unwrap().is_some());
565 assert!(store.get("s3").unwrap().is_some());
566 assert!(store.get("s2").unwrap().is_some());
567 }
568
569 #[test]
570 fn test_snapshot_prune_no_limits() {
571 let tmp = TempDir::new().unwrap();
572 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
573 let rootfs = make_rootfs(&tmp);
574
575 for i in 0..3 {
576 store
577 .save(
578 make_metadata(&format!("s{}", i), &format!("s{}", i)),
579 &rootfs,
580 )
581 .unwrap();
582 }
583
584 let removed = store.prune(0, 0).unwrap();
585 assert!(removed.is_empty());
586 assert_eq!(store.count().unwrap(), 3);
587 }
588
589 #[test]
590 fn test_snapshot_save_with_empty_rootfs() {
591 let tmp = TempDir::new().unwrap();
592 let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
593 let empty_rootfs = tmp.path().join("nonexistent_rootfs");
594
595 let meta = make_metadata("snap-empty", "empty");
596 let saved = store.save(meta, &empty_rootfs).unwrap();
597 assert_eq!(saved.id, "snap-empty");
598
599 assert!(store.rootfs_path("snap-empty").exists());
601 }
602
603 #[test]
604 fn test_dir_size() {
605 let tmp = TempDir::new().unwrap();
606 let dir = tmp.path().join("sized");
607 std::fs::create_dir_all(&dir).unwrap();
608 std::fs::write(dir.join("a.txt"), "hello").unwrap();
609 std::fs::write(dir.join("b.txt"), "world!").unwrap();
610
611 let size = dir_size(&dir);
612 assert_eq!(size, 11); }
614
615 #[test]
616 fn test_dir_size_nested() {
617 let tmp = TempDir::new().unwrap();
618 let dir = tmp.path().join("nested");
619 std::fs::create_dir_all(dir.join("sub")).unwrap();
620 std::fs::write(dir.join("a.txt"), "abc").unwrap();
621 std::fs::write(dir.join("sub/b.txt"), "defgh").unwrap();
622
623 let size = dir_size(&dir);
624 assert_eq!(size, 8); }
626
627 #[test]
628 fn test_dir_size_empty() {
629 let tmp = TempDir::new().unwrap();
630 let dir = tmp.path().join("empty");
631 std::fs::create_dir_all(&dir).unwrap();
632 assert_eq!(dir_size(&dir), 0);
633 }
634
635 #[test]
636 fn test_copy_dir_recursive() {
637 let tmp = TempDir::new().unwrap();
638 let src = tmp.path().join("src");
639 let dst = tmp.path().join("dst");
640
641 std::fs::create_dir_all(src.join("sub")).unwrap();
642 std::fs::write(src.join("a.txt"), "hello").unwrap();
643 std::fs::write(src.join("sub/b.txt"), "world").unwrap();
644
645 copy_dir_recursive(&src, &dst).unwrap();
646
647 assert!(dst.join("a.txt").exists());
648 assert!(dst.join("sub/b.txt").exists());
649 assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
650 assert_eq!(
651 std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
652 "world"
653 );
654 }
655}