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 tmp_dir = store_dir.join("tmp");
54 if tmp_dir.is_dir() {
55 if let Err(e) = std::fs::remove_dir_all(&tmp_dir) {
56 tracing::debug!(path = %tmp_dir.display(), error = %e, "Failed to sweep image store tmp dir");
57 }
58 }
59
60 let mut store = Self {
61 store_dir: store_dir.to_path_buf(),
62 index: Arc::new(RwLock::new(HashMap::new())),
63 max_size_bytes,
64 };
65
66 store.load_index()?;
67 Ok(store)
68 }
69
70 pub async fn get(&self, reference: &str) -> Option<StoredImage> {
72 let mut index = self.index.write().await;
73 if let Some(image) = index.get_mut(reference) {
74 image.last_used = Utc::now();
75 let updated = image.clone();
76 drop(index);
77 if let Err(e) = self.save_index_inner().await {
79 tracing::warn!(error = %e, "Failed to persist image store index (last_used may be stale)");
80 }
81 Some(updated)
82 } else {
83 None
84 }
85 }
86
87 pub async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
89 let mut index = self.index.write().await;
90 let found = index.values_mut().find(|img| img.digest == digest);
91 if let Some(image) = found {
92 image.last_used = Utc::now();
93 let updated = image.clone();
94 drop(index);
95 if let Err(e) = self.save_index_inner().await {
96 tracing::warn!(error = %e, "Failed to persist image store index (last_used may be stale)");
97 }
98 Some(updated)
99 } else {
100 None
101 }
102 }
103
104 pub async fn resolve(&self, image: &str) -> Option<StoredImage> {
110 if let Some(found) = self.get(image).await {
111 return Some(found);
112 }
113 let digest_part = image.rsplit_once('@').map_or(image, |(_, digest)| digest);
114 if let Some(found) = self.get_by_digest(digest_part).await {
115 return Some(found);
116 }
117 match super::ImageReference::parse(image) {
118 Ok(parsed) => self.get(&parsed.full_reference()).await,
119 Err(_) => None,
120 }
121 }
122
123 pub async fn put(
128 &self,
129 reference: &str,
130 digest: &str,
131 source_dir: &Path,
132 ) -> Result<StoredImage> {
133 let digest_hex = digest.strip_prefix("sha256:").unwrap_or(digest);
135 let target_dir = self.store_dir.join("sha256").join(digest_hex);
136
137 if !target_dir.exists() {
143 let seq = PUT_SEQ.fetch_add(1, Ordering::Relaxed);
144 let staging = self.store_dir.join("sha256").join(format!(
145 ".staging-{}-{}-{}",
146 digest_hex,
147 std::process::id(),
148 seq
149 ));
150 let _ = std::fs::remove_dir_all(&staging);
151 copy_dir_recursive(source_dir, &staging).map_err(|e| {
152 let _ = std::fs::remove_dir_all(&staging);
153 BoxError::OciImageError(format!("Failed to copy image to store: {}", e))
154 })?;
155 if let Err(e) = std::fs::rename(&staging, &target_dir) {
156 let _ = std::fs::remove_dir_all(&staging);
157 if !target_dir.exists() {
161 return Err(BoxError::OciImageError(format!(
162 "Failed to publish image to store: {}",
163 e
164 )));
165 }
166 }
167 }
168
169 let size_bytes = dir_size(&target_dir);
170 let now = Utc::now();
171
172 let stored = StoredImage {
173 reference: reference.to_string(),
174 digest: digest.to_string(),
175 size_bytes,
176 pulled_at: now,
177 last_used: now,
178 path: target_dir,
179 };
180
181 self.with_index_lock(|index| {
182 if let Some(old) = index.get(reference).cloned() {
189 if old.digest != digest {
190 let still_referenced = index
191 .iter()
192 .any(|(key, img)| key.as_str() != reference && img.digest == old.digest);
193 if !still_referenced && !index.contains_key(&old.digest) {
194 let mut dangling = old.clone();
195 dangling.reference = old.digest.clone();
196 index.insert(old.digest.clone(), dangling);
197 }
198 }
199 }
200
201 index.insert(reference.to_string(), stored.clone());
202 Ok(())
203 })
204 .await?;
205
206 Ok(stored)
207 }
208
209 pub async fn remove(&self, image: &str) -> Result<()> {
217 self.with_index_lock(|index| {
218 let keys: Vec<String> = if index.contains_key(image) {
221 vec![image.to_string()]
222 } else {
223 index
224 .values()
225 .filter(|img| img.digest == image)
226 .map(|img| img.reference.clone())
227 .collect()
228 };
229
230 if keys.is_empty() {
231 return Err(BoxError::OciImageError(format!(
232 "Image not found: {}",
233 image
234 )));
235 }
236
237 let removed: Vec<StoredImage> = keys.iter().filter_map(|k| index.remove(k)).collect();
238
239 for img in removed {
243 let digest_still_used = index.values().any(|other| other.digest == img.digest);
244 if !digest_still_used && img.path.exists() {
245 std::fs::remove_dir_all(&img.path).map_err(|e| {
246 BoxError::OciImageError(format!(
247 "Failed to remove image directory {}: {}",
248 img.path.display(),
249 e
250 ))
251 })?;
252 }
253 }
254 Ok(())
255 })
256 .await
257 }
258
259 pub async fn list(&self) -> Vec<StoredImage> {
261 let index = self.index.read().await;
262 index.values().cloned().collect()
263 }
264
265 pub async fn evict(&self) -> Result<Vec<String>> {
269 let mut evicted = Vec::new();
270 let mut total = self.total_size().await;
271
272 while total > self.max_size_bytes {
273 let lru_ref = {
275 let index = self.index.read().await;
276 index
277 .values()
278 .min_by_key(|img| img.last_used)
279 .map(|img| img.reference.clone())
280 };
281
282 match lru_ref {
283 Some(reference) => {
284 self.remove(&reference).await?;
285 evicted.push(reference);
286 total = self.total_size().await;
287 }
288 None => break,
289 }
290 }
291
292 Ok(evicted)
293 }
294
295 pub async fn total_size(&self) -> u64 {
297 let index = self.index.read().await;
298 index.values().map(|img| img.size_bytes).sum()
299 }
300
301 fn load_index(&mut self) -> Result<()> {
303 self.index = Arc::new(RwLock::new(self.read_index_from_disk()?));
305 Ok(())
306 }
307
308 fn read_index_from_disk(&self) -> Result<HashMap<String, StoredImage>> {
311 let index_path = self.store_dir.join("index.json");
312 if !index_path.exists() {
313 return Ok(HashMap::new());
314 }
315
316 let data = std::fs::read_to_string(&index_path).map_err(|e| {
317 BoxError::OciImageError(format!(
318 "Failed to read image store index {}: {}",
319 index_path.display(),
320 e
321 ))
322 })?;
323
324 #[derive(serde::Deserialize)]
331 struct RawIndex {
332 #[serde(default)]
333 images: Vec<serde_json::Value>,
334 }
335
336 let raw: RawIndex = match serde_json::from_str(&data) {
337 Ok(raw) => raw,
338 Err(err) => {
339 let preserved = crate::store_io::quarantine_label(&index_path);
340 tracing::warn!(
341 "image store index {} is corrupt ({err}); preserved a copy at \
342 {preserved} and started from an empty catalog (re-pulled images \
343 will repopulate it)",
344 index_path.display(),
345 );
346 return Ok(HashMap::new());
347 }
348 };
349
350 let mut index = HashMap::new();
351 let mut skipped = 0usize;
352 for value in raw.images {
353 match serde_json::from_value::<StoredImage>(value) {
354 Ok(image) => {
355 if image.path.exists() {
356 index.insert(image.reference.clone(), image);
357 }
358 }
359 Err(err) => {
360 skipped += 1;
361 tracing::warn!("skipping unreadable image index entry ({err})");
362 }
363 }
364 }
365 if skipped > 0 {
366 let preserved = crate::store_io::quarantine_copy(&index_path)
370 .map(|p| p.display().to_string())
371 .unwrap_or_else(|| "<backup failed>".to_string());
372 tracing::warn!(
373 "{skipped} image index entr{} skipped as unreadable; preserved a copy at \
374 {preserved}; affected images will be re-pulled on demand",
375 if skipped == 1 { "y" } else { "ies" },
376 );
377 }
378 Ok(index)
379 }
380
381 async fn with_index_lock<F, R>(&self, f: F) -> Result<R>
390 where
391 F: FnOnce(&mut HashMap<String, StoredImage>) -> Result<R>,
392 {
393 let index_path = self.store_dir.join("index.json");
394 let _lock = {
395 let p = index_path.clone();
396 tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&p))
397 .await
398 .map_err(|e| BoxError::OciImageError(format!("index lock task failed: {e}")))?
399 .map_err(|e| {
400 BoxError::OciImageError(format!(
401 "failed to lock image index {}: {e}",
402 index_path.display()
403 ))
404 })?
405 };
406 let fresh = self.read_index_from_disk()?;
408 let result = {
409 let mut idx = self.index.write().await;
410 *idx = fresh;
411 f(&mut idx)?
412 };
413 self.save_index_inner().await?;
414 Ok(result)
415 }
416
417 async fn save_index_inner(&self) -> Result<()> {
419 let index = self.index.read().await;
420 let store_index = StoreIndex {
421 images: index.values().cloned().collect(),
422 };
423 drop(index);
424
425 let data = serde_json::to_string_pretty(&store_index)?;
426 let index_path = self.store_dir.join("index.json");
427 let tmp_path = self.store_dir.join("index.json.tmp");
432 tokio::fs::write(&tmp_path, data).await.map_err(|e| {
433 BoxError::OciImageError(format!(
434 "Failed to write image store index {}: {}",
435 tmp_path.display(),
436 e
437 ))
438 })?;
439 tokio::fs::rename(&tmp_path, &index_path)
440 .await
441 .map_err(|e| {
442 BoxError::OciImageError(format!(
443 "Failed to commit image store index {}: {}",
444 index_path.display(),
445 e
446 ))
447 })?;
448
449 Ok(())
450 }
451
452 pub fn store_dir(&self) -> &Path {
454 &self.store_dir
455 }
456}
457
458#[async_trait::async_trait]
459impl ImageStoreBackend for ImageStore {
460 async fn get(&self, reference: &str) -> Option<StoredImage> {
461 self.get(reference).await
462 }
463
464 async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
465 self.get_by_digest(digest).await
466 }
467
468 async fn put(&self, reference: &str, digest: &str, source_dir: &Path) -> Result<StoredImage> {
469 self.put(reference, digest, source_dir).await
470 }
471
472 async fn remove(&self, reference: &str) -> Result<()> {
473 self.remove(reference).await
474 }
475
476 async fn list(&self) -> Vec<StoredImage> {
477 self.list().await
478 }
479
480 async fn evict(&self) -> Result<Vec<String>> {
481 self.evict().await
482 }
483
484 async fn total_size(&self) -> u64 {
485 self.total_size().await
486 }
487}
488
489fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
491 std::fs::create_dir_all(dst)?;
492 for entry in std::fs::read_dir(src)? {
493 let entry = entry?;
494 let src_path = entry.path();
495 let dst_path = dst.join(entry.file_name());
496 if src_path.is_dir() {
497 copy_dir_recursive(&src_path, &dst_path)?;
498 } else {
499 std::fs::copy(&src_path, &dst_path)?;
500 }
501 }
502 Ok(())
503}
504
505fn dir_size(path: &Path) -> u64 {
507 let mut total = 0;
508 if let Ok(entries) = std::fs::read_dir(path) {
509 for entry in entries.flatten() {
510 let path = entry.path();
511 if path.is_dir() {
512 total += dir_size(&path);
513 } else if let Ok(meta) = path.metadata() {
514 total += meta.len();
515 }
516 }
517 }
518 total
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use tempfile::TempDir;
525
526 fn create_test_oci_layout(dir: &Path) {
527 std::fs::create_dir_all(dir.join("blobs/sha256")).unwrap();
528 std::fs::write(dir.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
529 std::fs::write(dir.join("index.json"), r#"{"manifests":[]}"#).unwrap();
530 std::fs::write(dir.join("blobs/sha256/testblob"), "x".repeat(1024)).unwrap();
532 }
533
534 #[tokio::test]
535 async fn test_new_creates_directory() {
536 let tmp = TempDir::new().unwrap();
537 let store_dir = tmp.path().join("images");
538 let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
539 assert!(store_dir.exists());
540 assert_eq!(store.total_size().await, 0);
541 }
542
543 #[tokio::test]
544 async fn test_put_and_get() {
545 let tmp = TempDir::new().unwrap();
546 let store_dir = tmp.path().join("store");
547 let source_dir = tmp.path().join("source");
548 create_test_oci_layout(&source_dir);
549
550 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
551
552 let stored = store
553 .put("nginx:latest", "sha256:abc123", &source_dir)
554 .await
555 .unwrap();
556
557 assert_eq!(stored.reference, "nginx:latest");
558 assert_eq!(stored.digest, "sha256:abc123");
559 assert!(stored.size_bytes > 0);
560 assert!(stored.path.exists());
561
562 let fetched = store.get("nginx:latest").await.unwrap();
564 assert_eq!(fetched.digest, "sha256:abc123");
565
566 let fetched = store.get_by_digest("sha256:abc123").await.unwrap();
568 assert_eq!(fetched.reference, "nginx:latest");
569 }
570
571 #[tokio::test]
572 async fn test_get_nonexistent() {
573 let tmp = TempDir::new().unwrap();
574 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
575 assert!(store.get("nonexistent").await.is_none());
576 }
577
578 #[tokio::test]
579 async fn test_remove() {
580 let tmp = TempDir::new().unwrap();
581 let store_dir = tmp.path().join("store");
582 let source_dir = tmp.path().join("source");
583 create_test_oci_layout(&source_dir);
584
585 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
586 store
587 .put("nginx:latest", "sha256:abc123", &source_dir)
588 .await
589 .unwrap();
590
591 store.remove("nginx:latest").await.unwrap();
592 assert!(store.get("nginx:latest").await.is_none());
593 }
594
595 #[tokio::test]
596 async fn test_retag_keeps_displaced_image_as_dangling() {
597 let tmp = TempDir::new().unwrap();
600 let store_dir = tmp.path().join("store");
601 let source_dir = tmp.path().join("source");
602 create_test_oci_layout(&source_dir);
603
604 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
605 store
606 .put("app:latest", "sha256:old", &source_dir)
607 .await
608 .unwrap();
609 store
610 .put("app:latest", "sha256:new", &source_dir)
611 .await
612 .unwrap();
613
614 assert_eq!(store.get("app:latest").await.unwrap().digest, "sha256:new");
616 let dangling = store.get("sha256:old").await.unwrap();
618 assert_eq!(dangling.digest, "sha256:old");
619 assert_eq!(store.list().await.len(), 2);
620 }
621
622 #[tokio::test]
623 async fn test_reput_same_digest_does_not_create_dangling() {
624 let tmp = TempDir::new().unwrap();
627 let store_dir = tmp.path().join("store");
628 let source_dir = tmp.path().join("source");
629 create_test_oci_layout(&source_dir);
630
631 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
632 store
633 .put("app:latest", "sha256:same", &source_dir)
634 .await
635 .unwrap();
636 store
637 .put("app:latest", "sha256:same", &source_dir)
638 .await
639 .unwrap();
640
641 assert_eq!(store.list().await.len(), 1);
642 }
643
644 #[tokio::test]
645 async fn test_remove_by_digest() {
646 let tmp = TempDir::new().unwrap();
649 let store_dir = tmp.path().join("store");
650 let source_dir = tmp.path().join("source");
651 create_test_oci_layout(&source_dir);
652
653 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
654 let stored = store
655 .put("gcr.io/test/img:test", "sha256:deadbeef", &source_dir)
656 .await
657 .unwrap();
658 let path = stored.path.clone();
659
660 store.remove("sha256:deadbeef").await.unwrap();
661 assert!(store.get("gcr.io/test/img:test").await.is_none());
662 assert!(store.get_by_digest("sha256:deadbeef").await.is_none());
663 assert!(!path.exists(), "on-disk layout should be deleted");
664 }
665
666 #[tokio::test]
667 async fn test_remove_by_digest_removes_all_tags() {
668 let tmp = TempDir::new().unwrap();
671 let store_dir = tmp.path().join("store");
672 let source_dir = tmp.path().join("source");
673 create_test_oci_layout(&source_dir);
674
675 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
676 store
677 .put("img:v1", "sha256:shared", &source_dir)
678 .await
679 .unwrap();
680 let stored = store
681 .put("img:latest", "sha256:shared", &source_dir)
682 .await
683 .unwrap();
684 let path = stored.path.clone();
685
686 store.remove("sha256:shared").await.unwrap();
687 assert!(store.get("img:v1").await.is_none());
688 assert!(store.get("img:latest").await.is_none());
689 assert!(!path.exists(), "shared layout should be deleted");
690 }
691
692 #[tokio::test]
693 async fn test_resolve_by_name_digest_and_normalized() {
694 let tmp = TempDir::new().unwrap();
695 let store_dir = tmp.path().join("store");
696 let source_dir = tmp.path().join("source");
697 create_test_oci_layout(&source_dir);
698
699 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
700 store
701 .put(
702 "gcr.io/x/test-image-predefined-group:latest",
703 "sha256:grp",
704 &source_dir,
705 )
706 .await
707 .unwrap();
708
709 assert!(store
711 .resolve("gcr.io/x/test-image-predefined-group:latest")
712 .await
713 .is_some());
714 assert_eq!(
716 store
717 .resolve("gcr.io/x/test-image-predefined-group")
718 .await
719 .map(|i| i.digest),
720 Some("sha256:grp".to_string())
721 );
722 assert!(store.resolve("sha256:grp").await.is_some());
724 assert!(store
725 .resolve("gcr.io/x/test-image-predefined-group@sha256:grp")
726 .await
727 .is_some());
728 assert!(store.resolve("nope:latest").await.is_none());
730 }
731
732 #[tokio::test]
733 async fn test_remove_nonexistent() {
734 let tmp = TempDir::new().unwrap();
735 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
736 assert!(store.remove("nonexistent").await.is_err());
737 }
738
739 #[tokio::test]
740 async fn test_list() {
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("nginx:latest", "sha256:aaa", &source_dir)
749 .await
750 .unwrap();
751 store
752 .put("alpine:3.18", "sha256:bbb", &source_dir)
753 .await
754 .unwrap();
755
756 let images = store.list().await;
757 assert_eq!(images.len(), 2);
758 }
759
760 #[tokio::test]
761 async fn test_total_size() {
762 let tmp = TempDir::new().unwrap();
763 let store_dir = tmp.path().join("store");
764 let source_dir = tmp.path().join("source");
765 create_test_oci_layout(&source_dir);
766
767 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
768 store
769 .put("nginx:latest", "sha256:aaa", &source_dir)
770 .await
771 .unwrap();
772
773 assert!(store.total_size().await > 0);
774 }
775
776 #[tokio::test]
777 async fn test_lru_eviction() {
778 let tmp = TempDir::new().unwrap();
779 let store_dir = tmp.path().join("store");
780 let source_dir = tmp.path().join("source");
781 create_test_oci_layout(&source_dir);
782
783 let store = ImageStore::new(&store_dir, 100).unwrap();
785
786 store
787 .put("old:v1", "sha256:old1", &source_dir)
788 .await
789 .unwrap();
790
791 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
793
794 store
795 .put("new:v2", "sha256:new2", &source_dir)
796 .await
797 .unwrap();
798
799 store.get("new:v2").await;
801
802 let evicted = store.evict().await.unwrap();
803 assert!(!evicted.is_empty());
805 assert!(evicted.contains(&"old:v1".to_string()));
806 }
807
808 #[tokio::test]
809 async fn test_index_persistence() {
810 let tmp = TempDir::new().unwrap();
811 let store_dir = tmp.path().join("store");
812 let source_dir = tmp.path().join("source");
813 create_test_oci_layout(&source_dir);
814
815 {
817 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
818 store
819 .put("nginx:latest", "sha256:persist", &source_dir)
820 .await
821 .unwrap();
822 }
823
824 {
826 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
827 let image = store.get("nginx:latest").await;
828 assert!(image.is_some());
829 assert_eq!(image.unwrap().digest, "sha256:persist");
830 }
831 }
832
833 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
834 async fn concurrent_cross_instance_puts_persist_both() {
835 use std::collections::HashSet;
836 use std::sync::Arc;
837
838 let tmp = TempDir::new().unwrap();
839 let store_dir = tmp.path().join("store");
840 let source_dir = tmp.path().join("source");
841 create_test_oci_layout(&source_dir);
842
843 let s1 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
848 let s2 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
849 let (src1, src2) = (source_dir.clone(), source_dir.clone());
850 let h1 = {
851 let s1 = Arc::clone(&s1);
852 tokio::spawn(async move { s1.put("img:a", "sha256:aaaa", &src1).await.unwrap() })
853 };
854 let h2 = {
855 let s2 = Arc::clone(&s2);
856 tokio::spawn(async move { s2.put("img:b", "sha256:bbbb", &src2).await.unwrap() })
857 };
858 h1.await.unwrap();
859 h2.await.unwrap();
860
861 let s3 = ImageStore::new(&store_dir, u64::MAX).unwrap();
863 let refs: HashSet<String> = s3.list().await.into_iter().map(|i| i.reference).collect();
864 assert!(refs.contains("img:a"), "img:a lost: {refs:?}");
865 assert!(refs.contains("img:b"), "img:b lost: {refs:?}");
866 }
867
868 #[tokio::test]
869 async fn corrupt_index_is_quarantined_not_fatal() {
870 let tmp = tempfile::tempdir().unwrap();
871 let store_dir = tmp.path().join("images");
872 std::fs::create_dir_all(&store_dir).unwrap();
873 std::fs::write(store_dir.join("index.json"), "{ not valid json").unwrap();
874
875 let store = ImageStore::new(&store_dir, u64::MAX)
878 .expect("corrupt index.json must not brick the image store");
879 assert!(
880 store.list().await.is_empty(),
881 "store must start from an empty catalog after quarantine"
882 );
883
884 let quarantined = std::fs::read_dir(&store_dir)
886 .unwrap()
887 .filter_map(|e| e.ok())
888 .any(|e| {
889 e.file_name()
890 .to_string_lossy()
891 .contains("index.json.corrupt-")
892 });
893 assert!(
894 quarantined,
895 "corrupt index.json must be quarantined to a sibling"
896 );
897 }
898}