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