1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::Arc;
11
12use a3s_box_core::error::{BoxError, Result};
13use a3s_box_core::{ImageStoreBackend, StoredImage};
14use chrono::Utc;
15use serde::{Deserialize, Serialize};
16use tokio::sync::RwLock;
17
18static PUT_SEQ: AtomicU64 = AtomicU64::new(0);
20
21#[derive(Debug, Default, Serialize, Deserialize)]
23struct StoreIndex {
24 images: Vec<StoredImage>,
25}
26
27pub struct ImageStore {
29 store_dir: PathBuf,
31 index: Arc<RwLock<HashMap<String, StoredImage>>>,
33 max_size_bytes: u64,
35}
36
37impl ImageStore {
38 pub fn new(store_dir: &Path, max_size_bytes: u64) -> Result<Self> {
43 std::fs::create_dir_all(store_dir).map_err(|e| {
44 BoxError::OciImageError(format!(
45 "Failed to create image store directory {}: {}",
46 store_dir.display(),
47 e
48 ))
49 })?;
50
51 let mut store = Self {
52 store_dir: store_dir.to_path_buf(),
53 index: Arc::new(RwLock::new(HashMap::new())),
54 max_size_bytes,
55 };
56
57 store.load_index()?;
58 Ok(store)
59 }
60
61 pub async fn get(&self, reference: &str) -> Option<StoredImage> {
63 let mut index = self.index.write().await;
64 if let Some(image) = index.get_mut(reference) {
65 image.last_used = Utc::now();
66 let updated = image.clone();
67 drop(index);
68 if let Err(e) = self.save_index_inner().await {
70 tracing::warn!(error = %e, "Failed to persist image store index (last_used may be stale)");
71 }
72 Some(updated)
73 } else {
74 None
75 }
76 }
77
78 pub async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
80 let mut index = self.index.write().await;
81 let found = index.values_mut().find(|img| img.digest == digest);
82 if let Some(image) = found {
83 image.last_used = Utc::now();
84 let updated = image.clone();
85 drop(index);
86 if let Err(e) = self.save_index_inner().await {
87 tracing::warn!(error = %e, "Failed to persist image store index (last_used may be stale)");
88 }
89 Some(updated)
90 } else {
91 None
92 }
93 }
94
95 pub async fn resolve(&self, image: &str) -> Option<StoredImage> {
101 if let Some(found) = self.get(image).await {
102 return Some(found);
103 }
104 let digest_part = image.rsplit_once('@').map_or(image, |(_, digest)| digest);
105 if let Some(found) = self.get_by_digest(digest_part).await {
106 return Some(found);
107 }
108 match super::ImageReference::parse(image) {
109 Ok(parsed) => self.get(&parsed.full_reference()).await,
110 Err(_) => None,
111 }
112 }
113
114 pub async fn put(
119 &self,
120 reference: &str,
121 digest: &str,
122 source_dir: &Path,
123 ) -> Result<StoredImage> {
124 let digest_hex = digest.strip_prefix("sha256:").unwrap_or(digest);
126 let target_dir = self.store_dir.join("sha256").join(digest_hex);
127
128 if !target_dir.exists() {
134 let seq = PUT_SEQ.fetch_add(1, Ordering::Relaxed);
135 let staging = self.store_dir.join("sha256").join(format!(
136 ".staging-{}-{}-{}",
137 digest_hex,
138 std::process::id(),
139 seq
140 ));
141 let _ = std::fs::remove_dir_all(&staging);
142 copy_dir_recursive(source_dir, &staging).map_err(|e| {
143 let _ = std::fs::remove_dir_all(&staging);
144 BoxError::OciImageError(format!("Failed to copy image to store: {}", e))
145 })?;
146 if let Err(e) = std::fs::rename(&staging, &target_dir) {
147 let _ = std::fs::remove_dir_all(&staging);
148 if !target_dir.exists() {
152 return Err(BoxError::OciImageError(format!(
153 "Failed to publish image to store: {}",
154 e
155 )));
156 }
157 }
158 }
159
160 let size_bytes = dir_size(&target_dir);
161 let now = Utc::now();
162
163 let stored = StoredImage {
164 reference: reference.to_string(),
165 digest: digest.to_string(),
166 size_bytes,
167 pulled_at: now,
168 last_used: now,
169 path: target_dir,
170 };
171
172 self.with_index_lock(|index| {
173 if let Some(old) = index.get(reference).cloned() {
180 if old.digest != digest {
181 let still_referenced = index
182 .iter()
183 .any(|(key, img)| key.as_str() != reference && img.digest == old.digest);
184 if !still_referenced && !index.contains_key(&old.digest) {
185 let mut dangling = old.clone();
186 dangling.reference = old.digest.clone();
187 index.insert(old.digest.clone(), dangling);
188 }
189 }
190 }
191
192 index.insert(reference.to_string(), stored.clone());
193 Ok(())
194 })
195 .await?;
196
197 Ok(stored)
198 }
199
200 pub async fn remove(&self, image: &str) -> Result<()> {
208 self.with_index_lock(|index| {
209 let keys: Vec<String> = if index.contains_key(image) {
212 vec![image.to_string()]
213 } else {
214 index
215 .values()
216 .filter(|img| img.digest == image)
217 .map(|img| img.reference.clone())
218 .collect()
219 };
220
221 if keys.is_empty() {
222 return Err(BoxError::OciImageError(format!(
223 "Image not found: {}",
224 image
225 )));
226 }
227
228 let removed: Vec<StoredImage> = keys.iter().filter_map(|k| index.remove(k)).collect();
229
230 for img in removed {
234 let digest_still_used = index.values().any(|other| other.digest == img.digest);
235 if !digest_still_used && img.path.exists() {
236 std::fs::remove_dir_all(&img.path).map_err(|e| {
237 BoxError::OciImageError(format!(
238 "Failed to remove image directory {}: {}",
239 img.path.display(),
240 e
241 ))
242 })?;
243 }
244 }
245 Ok(())
246 })
247 .await
248 }
249
250 pub async fn list(&self) -> Vec<StoredImage> {
252 let index = self.index.read().await;
253 index.values().cloned().collect()
254 }
255
256 pub async fn evict(&self) -> Result<Vec<String>> {
260 let mut evicted = Vec::new();
261 let mut total = self.total_size().await;
262
263 while total > self.max_size_bytes {
264 let lru_ref = {
266 let index = self.index.read().await;
267 index
268 .values()
269 .min_by_key(|img| img.last_used)
270 .map(|img| img.reference.clone())
271 };
272
273 match lru_ref {
274 Some(reference) => {
275 self.remove(&reference).await?;
276 evicted.push(reference);
277 total = self.total_size().await;
278 }
279 None => break,
280 }
281 }
282
283 Ok(evicted)
284 }
285
286 pub async fn total_size(&self) -> u64 {
288 let index = self.index.read().await;
289 index.values().map(|img| img.size_bytes).sum()
290 }
291
292 fn load_index(&mut self) -> Result<()> {
294 self.index = Arc::new(RwLock::new(self.read_index_from_disk()?));
296 Ok(())
297 }
298
299 fn read_index_from_disk(&self) -> Result<HashMap<String, StoredImage>> {
302 let index_path = self.store_dir.join("index.json");
303 if !index_path.exists() {
304 return Ok(HashMap::new());
305 }
306
307 let data = std::fs::read_to_string(&index_path).map_err(|e| {
308 BoxError::OciImageError(format!(
309 "Failed to read image store index {}: {}",
310 index_path.display(),
311 e
312 ))
313 })?;
314
315 #[derive(serde::Deserialize)]
322 struct RawIndex {
323 #[serde(default)]
324 images: Vec<serde_json::Value>,
325 }
326
327 let raw: RawIndex = match serde_json::from_str(&data) {
328 Ok(raw) => raw,
329 Err(err) => {
330 let preserved = crate::store_io::quarantine_label(&index_path);
331 tracing::warn!(
332 "image store index {} is corrupt ({err}); preserved a copy at \
333 {preserved} and started from an empty catalog (re-pulled images \
334 will repopulate it)",
335 index_path.display(),
336 );
337 return Ok(HashMap::new());
338 }
339 };
340
341 let mut index = HashMap::new();
342 let mut skipped = 0usize;
343 for value in raw.images {
344 match serde_json::from_value::<StoredImage>(value) {
345 Ok(image) => {
346 if image.path.exists() {
347 index.insert(image.reference.clone(), image);
348 }
349 }
350 Err(err) => {
351 skipped += 1;
352 tracing::warn!("skipping unreadable image index entry ({err})");
353 }
354 }
355 }
356 if skipped > 0 {
357 let preserved = crate::store_io::quarantine_copy(&index_path)
361 .map(|p| p.display().to_string())
362 .unwrap_or_else(|| "<backup failed>".to_string());
363 tracing::warn!(
364 "{skipped} image index entr{} skipped as unreadable; preserved a copy at \
365 {preserved}; affected images will be re-pulled on demand",
366 if skipped == 1 { "y" } else { "ies" },
367 );
368 }
369 Ok(index)
370 }
371
372 async fn with_index_lock<F, R>(&self, f: F) -> Result<R>
381 where
382 F: FnOnce(&mut HashMap<String, StoredImage>) -> Result<R>,
383 {
384 let index_path = self.store_dir.join("index.json");
385 let _lock = {
386 let p = index_path.clone();
387 tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&p))
388 .await
389 .map_err(|e| BoxError::OciImageError(format!("index lock task failed: {e}")))?
390 .map_err(|e| {
391 BoxError::OciImageError(format!(
392 "failed to lock image index {}: {e}",
393 index_path.display()
394 ))
395 })?
396 };
397 let fresh = self.read_index_from_disk()?;
399 let result = {
400 let mut idx = self.index.write().await;
401 *idx = fresh;
402 f(&mut idx)?
403 };
404 self.save_index_inner().await?;
405 Ok(result)
406 }
407
408 async fn save_index_inner(&self) -> Result<()> {
410 let index = self.index.read().await;
411 let store_index = StoreIndex {
412 images: index.values().cloned().collect(),
413 };
414 drop(index);
415
416 let data = serde_json::to_string_pretty(&store_index)?;
417 let index_path = self.store_dir.join("index.json");
418 let tmp_path = self.store_dir.join("index.json.tmp");
423 tokio::fs::write(&tmp_path, data).await.map_err(|e| {
424 BoxError::OciImageError(format!(
425 "Failed to write image store index {}: {}",
426 tmp_path.display(),
427 e
428 ))
429 })?;
430 tokio::fs::rename(&tmp_path, &index_path)
431 .await
432 .map_err(|e| {
433 BoxError::OciImageError(format!(
434 "Failed to commit image store index {}: {}",
435 index_path.display(),
436 e
437 ))
438 })?;
439
440 Ok(())
441 }
442
443 pub fn store_dir(&self) -> &Path {
445 &self.store_dir
446 }
447}
448
449#[async_trait::async_trait]
450impl ImageStoreBackend for ImageStore {
451 async fn get(&self, reference: &str) -> Option<StoredImage> {
452 self.get(reference).await
453 }
454
455 async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
456 self.get_by_digest(digest).await
457 }
458
459 async fn put(&self, reference: &str, digest: &str, source_dir: &Path) -> Result<StoredImage> {
460 self.put(reference, digest, source_dir).await
461 }
462
463 async fn remove(&self, reference: &str) -> Result<()> {
464 self.remove(reference).await
465 }
466
467 async fn list(&self) -> Vec<StoredImage> {
468 self.list().await
469 }
470
471 async fn evict(&self) -> Result<Vec<String>> {
472 self.evict().await
473 }
474
475 async fn total_size(&self) -> u64 {
476 self.total_size().await
477 }
478}
479
480fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
482 std::fs::create_dir_all(dst)?;
483 for entry in std::fs::read_dir(src)? {
484 let entry = entry?;
485 let src_path = entry.path();
486 let dst_path = dst.join(entry.file_name());
487 if src_path.is_dir() {
488 copy_dir_recursive(&src_path, &dst_path)?;
489 } else {
490 std::fs::copy(&src_path, &dst_path)?;
491 }
492 }
493 Ok(())
494}
495
496fn dir_size(path: &Path) -> u64 {
498 let mut total = 0;
499 if let Ok(entries) = std::fs::read_dir(path) {
500 for entry in entries.flatten() {
501 let path = entry.path();
502 if path.is_dir() {
503 total += dir_size(&path);
504 } else if let Ok(meta) = path.metadata() {
505 total += meta.len();
506 }
507 }
508 }
509 total
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use tempfile::TempDir;
516
517 fn create_test_oci_layout(dir: &Path) {
518 std::fs::create_dir_all(dir.join("blobs/sha256")).unwrap();
519 std::fs::write(dir.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
520 std::fs::write(dir.join("index.json"), r#"{"manifests":[]}"#).unwrap();
521 std::fs::write(dir.join("blobs/sha256/testblob"), "x".repeat(1024)).unwrap();
523 }
524
525 fn stored_image(reference: &str, digest: &str, path: PathBuf) -> StoredImage {
526 let now = Utc::now();
527 StoredImage {
528 reference: reference.to_string(),
529 digest: digest.to_string(),
530 size_bytes: 1024,
531 pulled_at: now,
532 last_used: now,
533 path,
534 }
535 }
536
537 #[tokio::test]
538 async fn test_new_creates_directory() {
539 let tmp = TempDir::new().unwrap();
540 let store_dir = tmp.path().join("images");
541 let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
542 assert!(store_dir.exists());
543 assert_eq!(store.total_size().await, 0);
544 }
545
546 #[tokio::test]
547 async fn test_new_keeps_existing_tmp_dir_for_concurrent_pulls() {
548 let tmp = TempDir::new().unwrap();
549 let store_dir = tmp.path().join("images");
550 let tmp_dir = store_dir.join("tmp");
551 std::fs::create_dir_all(tmp_dir.join("pull-1")).unwrap();
552 std::fs::write(tmp_dir.join("pull-1/layer"), b"partial").unwrap();
553
554 let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
555
556 assert!(
557 tmp_dir.join("pull-1/layer").exists(),
558 "constructing a store must not delete another process' active pull"
559 );
560 assert_eq!(store.total_size().await, 0);
561 }
562
563 #[tokio::test]
564 async fn test_put_and_get() {
565 let tmp = TempDir::new().unwrap();
566 let store_dir = tmp.path().join("store");
567 let source_dir = tmp.path().join("source");
568 create_test_oci_layout(&source_dir);
569
570 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
571
572 let stored = store
573 .put("nginx:latest", "sha256:abc123", &source_dir)
574 .await
575 .unwrap();
576
577 assert_eq!(stored.reference, "nginx:latest");
578 assert_eq!(stored.digest, "sha256:abc123");
579 assert!(stored.size_bytes > 0);
580 assert!(stored.path.exists());
581
582 let fetched = store.get("nginx:latest").await.unwrap();
584 assert_eq!(fetched.digest, "sha256:abc123");
585
586 let fetched = store.get_by_digest("sha256:abc123").await.unwrap();
588 assert_eq!(fetched.reference, "nginx:latest");
589 }
590
591 #[tokio::test]
592 async fn test_get_nonexistent() {
593 let tmp = TempDir::new().unwrap();
594 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
595 assert!(store.get("nonexistent").await.is_none());
596 }
597
598 #[tokio::test]
599 async fn test_remove() {
600 let tmp = TempDir::new().unwrap();
601 let store_dir = tmp.path().join("store");
602 let source_dir = tmp.path().join("source");
603 create_test_oci_layout(&source_dir);
604
605 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
606 store
607 .put("nginx:latest", "sha256:abc123", &source_dir)
608 .await
609 .unwrap();
610
611 store.remove("nginx:latest").await.unwrap();
612 assert!(store.get("nginx:latest").await.is_none());
613 }
614
615 #[tokio::test]
616 async fn test_remove_one_tag_keeps_shared_digest_until_last_reference() {
617 let tmp = TempDir::new().unwrap();
618 let store_dir = tmp.path().join("store");
619 let source_dir = tmp.path().join("source");
620 create_test_oci_layout(&source_dir);
621
622 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
623 store
624 .put("img:v1", "sha256:shared", &source_dir)
625 .await
626 .unwrap();
627 let stored = store
628 .put("img:latest", "sha256:shared", &source_dir)
629 .await
630 .unwrap();
631 let path = stored.path.clone();
632
633 store.remove("img:v1").await.unwrap();
634 assert!(store.get("img:v1").await.is_none());
635 assert!(store.get("img:latest").await.is_some());
636 assert!(path.exists(), "shared layout should remain in use");
637
638 store.remove("img:latest").await.unwrap();
639 assert!(!path.exists(), "layout should be removed after final tag");
640 }
641
642 #[tokio::test]
643 async fn test_retag_keeps_displaced_image_as_dangling() {
644 let tmp = TempDir::new().unwrap();
647 let store_dir = tmp.path().join("store");
648 let source_dir = tmp.path().join("source");
649 create_test_oci_layout(&source_dir);
650
651 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
652 store
653 .put("app:latest", "sha256:old", &source_dir)
654 .await
655 .unwrap();
656 store
657 .put("app:latest", "sha256:new", &source_dir)
658 .await
659 .unwrap();
660
661 assert_eq!(store.get("app:latest").await.unwrap().digest, "sha256:new");
663 let dangling = store.get("sha256:old").await.unwrap();
665 assert_eq!(dangling.digest, "sha256:old");
666 assert_eq!(store.list().await.len(), 2);
667 }
668
669 #[tokio::test]
670 async fn test_reput_same_digest_does_not_create_dangling() {
671 let tmp = TempDir::new().unwrap();
674 let store_dir = tmp.path().join("store");
675 let source_dir = tmp.path().join("source");
676 create_test_oci_layout(&source_dir);
677
678 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
679 store
680 .put("app:latest", "sha256:same", &source_dir)
681 .await
682 .unwrap();
683 store
684 .put("app:latest", "sha256:same", &source_dir)
685 .await
686 .unwrap();
687
688 assert_eq!(store.list().await.len(), 1);
689 }
690
691 #[tokio::test]
692 async fn test_remove_by_digest() {
693 let tmp = TempDir::new().unwrap();
696 let store_dir = tmp.path().join("store");
697 let source_dir = tmp.path().join("source");
698 create_test_oci_layout(&source_dir);
699
700 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
701 let stored = store
702 .put("gcr.io/test/img:test", "sha256:deadbeef", &source_dir)
703 .await
704 .unwrap();
705 let path = stored.path.clone();
706
707 store.remove("sha256:deadbeef").await.unwrap();
708 assert!(store.get("gcr.io/test/img:test").await.is_none());
709 assert!(store.get_by_digest("sha256:deadbeef").await.is_none());
710 assert!(!path.exists(), "on-disk layout should be deleted");
711 }
712
713 #[tokio::test]
714 async fn test_remove_by_digest_removes_all_tags() {
715 let tmp = TempDir::new().unwrap();
718 let store_dir = tmp.path().join("store");
719 let source_dir = tmp.path().join("source");
720 create_test_oci_layout(&source_dir);
721
722 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
723 store
724 .put("img:v1", "sha256:shared", &source_dir)
725 .await
726 .unwrap();
727 let stored = store
728 .put("img:latest", "sha256:shared", &source_dir)
729 .await
730 .unwrap();
731 let path = stored.path.clone();
732
733 store.remove("sha256:shared").await.unwrap();
734 assert!(store.get("img:v1").await.is_none());
735 assert!(store.get("img:latest").await.is_none());
736 assert!(!path.exists(), "shared layout should be deleted");
737 }
738
739 #[tokio::test]
740 async fn test_resolve_by_name_digest_and_normalized() {
741 let tmp = TempDir::new().unwrap();
742 let store_dir = tmp.path().join("store");
743 let source_dir = tmp.path().join("source");
744 create_test_oci_layout(&source_dir);
745
746 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
747 store
748 .put(
749 "gcr.io/x/test-image-predefined-group:latest",
750 "sha256:grp",
751 &source_dir,
752 )
753 .await
754 .unwrap();
755
756 assert!(store
758 .resolve("gcr.io/x/test-image-predefined-group:latest")
759 .await
760 .is_some());
761 assert_eq!(
763 store
764 .resolve("gcr.io/x/test-image-predefined-group")
765 .await
766 .map(|i| i.digest),
767 Some("sha256:grp".to_string())
768 );
769 assert!(store.resolve("sha256:grp").await.is_some());
771 assert!(store
772 .resolve("gcr.io/x/test-image-predefined-group@sha256:grp")
773 .await
774 .is_some());
775 assert!(store.resolve("nope:latest").await.is_none());
777 }
778
779 #[tokio::test]
780 async fn test_resolve_invalid_reference_returns_none() {
781 let tmp = TempDir::new().unwrap();
782 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
783
784 assert!(store.resolve("registry.example.com/").await.is_none());
785 assert!(store.resolve("busybox@not-a-digest").await.is_none());
786 }
787
788 #[tokio::test]
789 async fn test_remove_nonexistent() {
790 let tmp = TempDir::new().unwrap();
791 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
792 assert!(store.remove("nonexistent").await.is_err());
793 }
794
795 #[tokio::test]
796 async fn test_list() {
797 let tmp = TempDir::new().unwrap();
798 let store_dir = tmp.path().join("store");
799 let source_dir = tmp.path().join("source");
800 create_test_oci_layout(&source_dir);
801
802 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
803 store
804 .put("nginx:latest", "sha256:aaa", &source_dir)
805 .await
806 .unwrap();
807 store
808 .put("alpine:3.18", "sha256:bbb", &source_dir)
809 .await
810 .unwrap();
811
812 let images = store.list().await;
813 assert_eq!(images.len(), 2);
814 }
815
816 #[tokio::test]
817 async fn test_total_size() {
818 let tmp = TempDir::new().unwrap();
819 let store_dir = tmp.path().join("store");
820 let source_dir = tmp.path().join("source");
821 create_test_oci_layout(&source_dir);
822
823 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
824 store
825 .put("nginx:latest", "sha256:aaa", &source_dir)
826 .await
827 .unwrap();
828
829 assert!(store.total_size().await > 0);
830 }
831
832 #[tokio::test]
833 async fn test_lru_eviction() {
834 let tmp = TempDir::new().unwrap();
835 let store_dir = tmp.path().join("store");
836 let source_dir = tmp.path().join("source");
837 create_test_oci_layout(&source_dir);
838
839 let store = ImageStore::new(&store_dir, 100).unwrap();
841
842 store
843 .put("old:v1", "sha256:old1", &source_dir)
844 .await
845 .unwrap();
846
847 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
849
850 store
851 .put("new:v2", "sha256:new2", &source_dir)
852 .await
853 .unwrap();
854
855 store.get("new:v2").await;
857
858 let evicted = store.evict().await.unwrap();
859 assert!(!evicted.is_empty());
861 assert!(evicted.contains(&"old:v1".to_string()));
862 }
863
864 #[tokio::test]
865 async fn test_evict_empty_and_under_limit_returns_empty() {
866 let tmp = TempDir::new().unwrap();
867 let store_dir = tmp.path().join("store");
868 let source_dir = tmp.path().join("source");
869 create_test_oci_layout(&source_dir);
870
871 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
872 assert!(store.evict().await.unwrap().is_empty());
873
874 store
875 .put("tiny:latest", "sha256:tiny", &source_dir)
876 .await
877 .unwrap();
878 assert!(store.evict().await.unwrap().is_empty());
879 assert!(store.get("tiny:latest").await.is_some());
880 }
881
882 #[tokio::test]
883 async fn test_index_persistence() {
884 let tmp = TempDir::new().unwrap();
885 let store_dir = tmp.path().join("store");
886 let source_dir = tmp.path().join("source");
887 create_test_oci_layout(&source_dir);
888
889 {
891 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
892 store
893 .put("nginx:latest", "sha256:persist", &source_dir)
894 .await
895 .unwrap();
896 }
897
898 {
900 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
901 let image = store.get("nginx:latest").await;
902 assert!(image.is_some());
903 assert_eq!(image.unwrap().digest, "sha256:persist");
904 }
905 }
906
907 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
908 async fn concurrent_cross_instance_puts_persist_both() {
909 use std::collections::HashSet;
910 use std::sync::Arc;
911
912 let tmp = TempDir::new().unwrap();
913 let store_dir = tmp.path().join("store");
914 let source_dir = tmp.path().join("source");
915 create_test_oci_layout(&source_dir);
916
917 let s1 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
922 let s2 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
923 let (src1, src2) = (source_dir.clone(), source_dir.clone());
924 let h1 = {
925 let s1 = Arc::clone(&s1);
926 tokio::spawn(async move { s1.put("img:a", "sha256:aaaa", &src1).await.unwrap() })
927 };
928 let h2 = {
929 let s2 = Arc::clone(&s2);
930 tokio::spawn(async move { s2.put("img:b", "sha256:bbbb", &src2).await.unwrap() })
931 };
932 h1.await.unwrap();
933 h2.await.unwrap();
934
935 let s3 = ImageStore::new(&store_dir, u64::MAX).unwrap();
937 let refs: HashSet<String> = s3.list().await.into_iter().map(|i| i.reference).collect();
938 assert!(refs.contains("img:a"), "img:a lost: {refs:?}");
939 assert!(refs.contains("img:b"), "img:b lost: {refs:?}");
940 }
941
942 #[tokio::test]
943 async fn load_index_skips_missing_paths_and_unreadable_entries() {
944 let tmp = tempfile::tempdir().unwrap();
945 let store_dir = tmp.path().join("images");
946 let live_path = store_dir.join("sha256/live");
947 let missing_path = store_dir.join("sha256/missing");
948 create_test_oci_layout(&live_path);
949
950 let live = stored_image("live:latest", "sha256:live", live_path);
951 let missing = stored_image("missing:latest", "sha256:missing", missing_path);
952 let index = serde_json::json!({
953 "images": [
954 serde_json::to_value(&live).unwrap(),
955 serde_json::to_value(&missing).unwrap(),
956 {
957 "reference": "broken:latest",
958 "digest": false
959 }
960 ]
961 });
962 std::fs::write(
963 store_dir.join("index.json"),
964 serde_json::to_vec_pretty(&index).unwrap(),
965 )
966 .unwrap();
967
968 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
969 let images = store.list().await;
970
971 assert_eq!(images.len(), 1);
972 assert_eq!(images[0].reference, "live:latest");
973 assert!(store.get("missing:latest").await.is_none());
974 assert!(std::fs::read_dir(&store_dir)
975 .unwrap()
976 .filter_map(|entry| entry.ok())
977 .any(|entry| entry
978 .file_name()
979 .to_string_lossy()
980 .contains("index.json.corrupt-")));
981 }
982
983 #[tokio::test]
984 async fn corrupt_index_is_quarantined_not_fatal() {
985 let tmp = tempfile::tempdir().unwrap();
986 let store_dir = tmp.path().join("images");
987 std::fs::create_dir_all(&store_dir).unwrap();
988 std::fs::write(store_dir.join("index.json"), "{ not valid json").unwrap();
989
990 let store = ImageStore::new(&store_dir, u64::MAX)
993 .expect("corrupt index.json must not brick the image store");
994 assert!(
995 store.list().await.is_empty(),
996 "store must start from an empty catalog after quarantine"
997 );
998
999 let quarantined = std::fs::read_dir(&store_dir)
1001 .unwrap()
1002 .filter_map(|e| e.ok())
1003 .any(|e| {
1004 e.file_name()
1005 .to_string_lossy()
1006 .contains("index.json.corrupt-")
1007 });
1008 assert!(
1009 quarantined,
1010 "corrupt index.json must be quarantined to a sibling"
1011 );
1012 }
1013
1014 #[test]
1015 fn copy_dir_recursive_copies_nested_files_and_dir_size_sums() {
1016 let tmp = TempDir::new().unwrap();
1017 let src = tmp.path().join("src");
1018 let dst = tmp.path().join("dst");
1019 std::fs::create_dir_all(src.join("nested")).unwrap();
1020 std::fs::write(src.join("root.txt"), b"abc").unwrap();
1021 std::fs::write(src.join("nested/leaf.txt"), b"hello").unwrap();
1022
1023 copy_dir_recursive(&src, &dst).unwrap();
1024
1025 assert_eq!(std::fs::read(dst.join("root.txt")).unwrap(), b"abc");
1026 assert_eq!(
1027 std::fs::read(dst.join("nested/leaf.txt")).unwrap(),
1028 b"hello"
1029 );
1030 assert_eq!(dir_size(&dst), 8);
1031 assert_eq!(dir_size(&tmp.path().join("missing")), 0);
1032 }
1033
1034 #[test]
1035 fn copy_dir_recursive_fails_for_missing_source() {
1036 let tmp = TempDir::new().unwrap();
1037 let err = copy_dir_recursive(&tmp.path().join("missing"), &tmp.path().join("dst"))
1038 .expect_err("missing source should fail");
1039
1040 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1041 }
1042}