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
18mod blob_reuse;
19
20static PUT_SEQ: AtomicU64 = AtomicU64::new(0);
22
23#[derive(Debug, Default, Serialize, Deserialize)]
25struct StoreIndex {
26 images: Vec<StoredImage>,
27}
28
29pub struct ImageStore {
31 store_dir: PathBuf,
33 index: Arc<RwLock<HashMap<String, StoredImage>>>,
35 max_size_bytes: u64,
37}
38
39fn state_dir_hint() -> &'static str {
40 "Set A3S_HOME to a writable directory to change the A3S Box state directory."
41}
42
43impl ImageStore {
44 pub fn new(store_dir: &Path, max_size_bytes: u64) -> Result<Self> {
49 std::fs::create_dir_all(store_dir).map_err(|e| {
50 BoxError::OciImageError(format!(
51 "Failed to create image store directory {}: {}. {}",
52 store_dir.display(),
53 e,
54 state_dir_hint()
55 ))
56 })?;
57
58 let mut store = Self {
59 store_dir: store_dir.to_path_buf(),
60 index: Arc::new(RwLock::new(HashMap::new())),
61 max_size_bytes,
62 };
63
64 store.load_index()?;
65 Ok(store)
66 }
67
68 pub async fn get(&self, reference: &str) -> Option<StoredImage> {
70 let mut index = self.index.write().await;
71 if let Some(image) = index.get_mut(reference) {
72 image.last_used = Utc::now();
73 let updated = image.clone();
74 drop(index);
75 if let Err(e) = self.save_index_inner().await {
77 tracing::warn!(error = %e, "Failed to persist image store index (last_used may be stale)");
78 }
79 Some(updated)
80 } else {
81 None
82 }
83 }
84
85 pub async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
87 let mut index = self.index.write().await;
88 let found = index.values_mut().find(|img| img.digest == digest);
89 if let Some(image) = found {
90 image.last_used = Utc::now();
91 let updated = image.clone();
92 drop(index);
93 if let Err(e) = self.save_index_inner().await {
94 tracing::warn!(error = %e, "Failed to persist image store index (last_used may be stale)");
95 }
96 Some(updated)
97 } else {
98 None
99 }
100 }
101
102 pub async fn resolve(&self, image: &str) -> Option<StoredImage> {
108 if let Some(found) = self.get(image).await {
109 return Some(found);
110 }
111 let digest_part = image.rsplit_once('@').map_or(image, |(_, digest)| digest);
112 if let Some(found) = self.get_by_digest(digest_part).await {
113 return Some(found);
114 }
115 match super::ImageReference::parse(image) {
116 Ok(parsed) => self.get(&parsed.full_reference()).await,
117 Err(_) => None,
118 }
119 }
120
121 pub async fn put(
126 &self,
127 reference: &str,
128 digest: &str,
129 source_dir: &Path,
130 ) -> Result<StoredImage> {
131 let digest_hex = super::registry::validated_digest_hex(digest)?;
134 let digest_root = self.store_dir.join("sha256");
135 std::fs::create_dir_all(&digest_root).map_err(|error| {
136 BoxError::OciImageError(format!(
137 "Failed to create image content directory {}: {error}",
138 digest_root.display()
139 ))
140 })?;
141 require_real_directory(&digest_root).map_err(|error| {
142 BoxError::OciImageError(format!(
143 "Unsafe image content directory {}: {error}",
144 digest_root.display()
145 ))
146 })?;
147 let target_dir = digest_root.join(digest_hex);
148
149 if !real_directory_exists(&target_dir).map_err(|error| {
155 BoxError::OciImageError(format!(
156 "Unsafe existing image directory {}: {error}",
157 target_dir.display()
158 ))
159 })? {
160 let staging = loop {
164 let seq = PUT_SEQ.fetch_add(1, Ordering::Relaxed);
165 let candidate = digest_root.join(format!(
166 ".staging-{}-{}-{}",
167 digest_hex,
168 std::process::id(),
169 seq
170 ));
171 match std::fs::create_dir(&candidate) {
172 Ok(()) => break candidate,
173 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
174 Err(error) => {
175 return Err(BoxError::OciImageError(format!(
176 "Failed to reserve image staging directory {}: {error}",
177 candidate.display()
178 )))
179 }
180 }
181 };
182 copy_dir_contents_no_follow(source_dir, &staging).map_err(|e| {
183 let _ = std::fs::remove_dir_all(&staging);
184 BoxError::OciImageError(format!("Failed to copy image to store: {}", e))
185 })?;
186 if let Err(e) = std::fs::rename(&staging, &target_dir) {
187 let _ = std::fs::remove_dir_all(&staging);
188 if !real_directory_exists(&target_dir).map_err(|error| {
192 BoxError::OciImageError(format!(
193 "Unsafe concurrently published image directory {}: {error}",
194 target_dir.display()
195 ))
196 })? {
197 return Err(BoxError::OciImageError(format!(
198 "Failed to publish image to store: {}",
199 e
200 )));
201 }
202 }
203 }
204
205 let size_bytes = dir_size(&target_dir);
206 let now = Utc::now();
207
208 let stored = StoredImage {
209 reference: reference.to_string(),
210 digest: digest.to_string(),
211 size_bytes,
212 pulled_at: now,
213 last_used: now,
214 path: target_dir,
215 };
216
217 self.with_index_lock(|index| {
218 if let Some(old) = index.get(reference).cloned() {
225 if old.digest != digest {
226 let still_referenced = index
227 .iter()
228 .any(|(key, img)| key.as_str() != reference && img.digest == old.digest);
229 if !still_referenced && !index.contains_key(&old.digest) {
230 let mut dangling = old.clone();
231 dangling.reference = old.digest.clone();
232 index.insert(old.digest.clone(), dangling);
233 }
234 }
235 }
236
237 index.insert(reference.to_string(), stored.clone());
238 Ok(())
239 })
240 .await?;
241
242 Ok(stored)
243 }
244
245 pub async fn remove(&self, image: &str) -> Result<()> {
253 let store_dir = self.store_dir.clone();
254 self.with_index_lock(move |index| {
255 let keys: Vec<String> = if index.contains_key(image) {
258 vec![image.to_string()]
259 } else {
260 index
261 .values()
262 .filter(|img| img.digest == image)
263 .map(|img| img.reference.clone())
264 .collect()
265 };
266
267 if keys.is_empty() {
268 return Err(BoxError::OciImageError(format!(
269 "Image not found: {}",
270 image
271 )));
272 }
273
274 for key in &keys {
277 let img = index.get(key).ok_or_else(|| {
278 BoxError::OciImageError(format!("Image index entry disappeared: {key}"))
279 })?;
280 let digest_hex = super::registry::validated_digest_hex(&img.digest)?;
281 let expected = store_dir.join("sha256").join(digest_hex);
282 if img.path != expected {
283 return Err(BoxError::OciImageError(format!(
284 "Refusing unsafe image path {} for digest {} (expected {})",
285 img.path.display(),
286 img.digest,
287 expected.display()
288 )));
289 }
290 }
291
292 let removed: Vec<StoredImage> = keys.iter().filter_map(|k| index.remove(k)).collect();
293
294 for img in removed {
298 let digest_still_used = index.values().any(|other| other.digest == img.digest);
299 if !digest_still_used
300 && real_directory_exists(&img.path).map_err(|error| {
301 BoxError::OciImageError(format!(
302 "Refusing unsafe image directory {}: {error}",
303 img.path.display()
304 ))
305 })?
306 {
307 std::fs::remove_dir_all(&img.path).map_err(|e| {
308 BoxError::OciImageError(format!(
309 "Failed to remove image directory {}: {}",
310 img.path.display(),
311 e
312 ))
313 })?;
314 }
315 }
316 Ok(())
317 })
318 .await
319 }
320
321 pub async fn list(&self) -> Vec<StoredImage> {
323 let index = self.index.read().await;
324 index.values().cloned().collect()
325 }
326
327 pub async fn evict(&self) -> Result<Vec<String>> {
331 let mut evicted = Vec::new();
332 let mut total = self.total_size().await;
333
334 while total > self.max_size_bytes {
335 let lru_ref = {
337 let index = self.index.read().await;
338 index
339 .values()
340 .min_by_key(|img| img.last_used)
341 .map(|img| img.reference.clone())
342 };
343
344 match lru_ref {
345 Some(reference) => {
346 self.remove(&reference).await?;
347 evicted.push(reference);
348 total = self.total_size().await;
349 }
350 None => break,
351 }
352 }
353
354 Ok(evicted)
355 }
356
357 pub async fn total_size(&self) -> u64 {
359 let index = self.index.read().await;
360 index.values().map(|img| img.size_bytes).sum()
361 }
362
363 fn load_index(&mut self) -> Result<()> {
365 self.index = Arc::new(RwLock::new(self.read_index_from_disk()?));
367 Ok(())
368 }
369
370 fn read_index_from_disk(&self) -> Result<HashMap<String, StoredImage>> {
373 let index_path = self.store_dir.join("index.json");
374 if !index_path.exists() {
375 return Ok(HashMap::new());
376 }
377
378 let data = std::fs::read_to_string(&index_path).map_err(|e| {
379 BoxError::OciImageError(format!(
380 "Failed to read image store index {}: {}",
381 index_path.display(),
382 e
383 ))
384 })?;
385
386 #[derive(serde::Deserialize)]
393 struct RawIndex {
394 #[serde(default)]
395 images: Vec<serde_json::Value>,
396 }
397
398 let raw: RawIndex = match serde_json::from_str(&data) {
399 Ok(raw) => raw,
400 Err(err) => {
401 let preserved = crate::store_io::quarantine_label(&index_path);
402 tracing::warn!(
403 "image store index {} is corrupt ({err}); preserved a copy at \
404 {preserved} and started from an empty catalog (re-pulled images \
405 will repopulate it)",
406 index_path.display(),
407 );
408 return Ok(HashMap::new());
409 }
410 };
411
412 let mut index = HashMap::new();
413 let mut skipped = 0usize;
414 for value in raw.images {
415 match serde_json::from_value::<StoredImage>(value) {
416 Ok(mut image) => {
417 let expected = super::registry::validated_digest_hex(&image.digest)
425 .map(|digest_hex| self.store_dir.join("sha256").join(digest_hex));
426 match expected {
427 Ok(expected) if real_directory_exists(&expected).unwrap_or(false) => {
428 image.path = expected;
429 index.insert(image.reference.clone(), image);
430 }
431 _ => {
432 skipped += 1;
433 tracing::warn!(
434 reference = %image.reference,
435 digest = %image.digest,
436 path = %image.path.display(),
437 "skipping image index entry with malformed digest or unsafe path"
438 );
439 }
440 }
441 }
442 Err(err) => {
443 skipped += 1;
444 tracing::warn!("skipping unreadable image index entry ({err})");
445 }
446 }
447 }
448 if skipped > 0 {
449 let preserved = crate::store_io::quarantine_copy(&index_path)
453 .map(|p| p.display().to_string())
454 .unwrap_or_else(|| "<backup failed>".to_string());
455 tracing::warn!(
456 "{skipped} image index entr{} skipped as unreadable; preserved a copy at \
457 {preserved}; affected images will be re-pulled on demand",
458 if skipped == 1 { "y" } else { "ies" },
459 );
460 }
461 Ok(index)
462 }
463
464 async fn with_index_lock<F, R>(&self, f: F) -> Result<R>
473 where
474 F: FnOnce(&mut HashMap<String, StoredImage>) -> Result<R>,
475 {
476 let index_path = self.store_dir.join("index.json");
477 let _lock = {
478 let p = index_path.clone();
479 tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&p))
480 .await
481 .map_err(|e| BoxError::OciImageError(format!("index lock task failed: {e}")))?
482 .map_err(|e| {
483 BoxError::OciImageError(format!(
484 "failed to lock image index {}: {e}. {}",
485 index_path.display(),
486 state_dir_hint()
487 ))
488 })?
489 };
490 let fresh = self.read_index_from_disk()?;
492 let result = {
493 let mut idx = self.index.write().await;
494 *idx = fresh;
495 f(&mut idx)?
496 };
497 self.save_index_inner().await?;
498 Ok(result)
499 }
500
501 async fn save_index_inner(&self) -> Result<()> {
503 let index = self.index.read().await;
504 let store_index = StoreIndex {
505 images: index.values().cloned().collect(),
506 };
507 drop(index);
508
509 let data = serde_json::to_string_pretty(&store_index)?;
510 let index_path = self.store_dir.join("index.json");
511 let tmp_path = self.store_dir.join("index.json.tmp");
516 tokio::fs::write(&tmp_path, data).await.map_err(|e| {
517 BoxError::OciImageError(format!(
518 "Failed to write image store index {}: {}. {}",
519 tmp_path.display(),
520 e,
521 state_dir_hint()
522 ))
523 })?;
524 tokio::fs::rename(&tmp_path, &index_path)
525 .await
526 .map_err(|e| {
527 BoxError::OciImageError(format!(
528 "Failed to commit image store index {}: {}. {}",
529 index_path.display(),
530 e,
531 state_dir_hint()
532 ))
533 })?;
534
535 Ok(())
536 }
537
538 pub fn store_dir(&self) -> &Path {
540 &self.store_dir
541 }
542}
543
544#[async_trait::async_trait]
545impl ImageStoreBackend for ImageStore {
546 async fn get(&self, reference: &str) -> Option<StoredImage> {
547 self.get(reference).await
548 }
549
550 async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
551 self.get_by_digest(digest).await
552 }
553
554 async fn put(&self, reference: &str, digest: &str, source_dir: &Path) -> Result<StoredImage> {
555 self.put(reference, digest, source_dir).await
556 }
557
558 async fn remove(&self, reference: &str) -> Result<()> {
559 self.remove(reference).await
560 }
561
562 async fn list(&self) -> Vec<StoredImage> {
563 self.list().await
564 }
565
566 async fn evict(&self) -> Result<Vec<String>> {
567 self.evict().await
568 }
569
570 async fn total_size(&self) -> u64 {
571 self.total_size().await
572 }
573}
574
575#[cfg(windows)]
576fn metadata_is_reparse_point(metadata: &std::fs::Metadata) -> bool {
577 use std::os::windows::fs::MetadataExt;
578
579 metadata.file_attributes() & 0x0000_0400 != 0
582}
583
584#[cfg(not(windows))]
585fn metadata_is_reparse_point(_metadata: &std::fs::Metadata) -> bool {
586 false
587}
588
589fn require_real_directory(path: &Path) -> std::io::Result<()> {
590 let metadata = std::fs::symlink_metadata(path)?;
591 if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
592 return Err(std::io::Error::new(
593 std::io::ErrorKind::PermissionDenied,
594 format!(
595 "refusing symbolic link or reparse-point directory {}",
596 path.display()
597 ),
598 ));
599 }
600 if !metadata.is_dir() {
601 return Err(std::io::Error::new(
602 std::io::ErrorKind::InvalidData,
603 format!("expected a directory at {}", path.display()),
604 ));
605 }
606 Ok(())
607}
608
609fn real_directory_exists(path: &Path) -> std::io::Result<bool> {
610 match require_real_directory(path) {
611 Ok(()) => Ok(true),
612 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
613 Err(error) => Err(error),
614 }
615}
616
617#[cfg(unix)]
618fn open_regular_source_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
619 use std::os::unix::fs::OpenOptionsExt;
620
621 let mut options = std::fs::OpenOptions::new();
622 options
623 .read(true)
624 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
625 let file = options.open(path)?;
626 if !file.metadata()?.is_file() {
627 return Err(std::io::Error::new(
628 std::io::ErrorKind::InvalidData,
629 format!("expected a regular file at {}", path.display()),
630 ));
631 }
632 Ok(file)
633}
634
635#[cfg(windows)]
636fn open_regular_source_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
637 a3s_box_core::windows_file::open_regular_file(path, None).map(|(file, _)| file)
638}
639
640#[cfg(not(any(unix, windows)))]
641fn open_regular_source_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
642 let metadata = std::fs::symlink_metadata(path)?;
643 if metadata.file_type().is_symlink() || !metadata.is_file() {
644 return Err(std::io::Error::new(
645 std::io::ErrorKind::InvalidData,
646 format!("expected a regular file at {}", path.display()),
647 ));
648 }
649 std::fs::File::open(path)
650}
651
652fn copy_regular_file_no_follow(src: &Path, dst: &Path) -> std::io::Result<()> {
653 let mut source = open_regular_source_no_follow(src)?;
654 let mut destination = std::fs::OpenOptions::new()
655 .write(true)
656 .create_new(true)
657 .open(dst)?;
658 std::io::copy(&mut source, &mut destination)?;
659 Ok(())
660}
661
662fn copy_dir_contents_no_follow(src: &Path, dst: &Path) -> std::io::Result<()> {
666 require_real_directory(src)?;
667 require_real_directory(dst)?;
668
669 for entry in std::fs::read_dir(src)? {
670 let entry = entry?;
671 let src_path = entry.path();
672 let dst_path = dst.join(entry.file_name());
673 let metadata = std::fs::symlink_metadata(&src_path)?;
674
675 if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
676 return Err(std::io::Error::new(
677 std::io::ErrorKind::PermissionDenied,
678 format!(
679 "refusing symbolic link or reparse point in OCI layout: {}",
680 src_path.display()
681 ),
682 ));
683 }
684 if metadata.is_dir() {
685 std::fs::create_dir(&dst_path)?;
686 copy_dir_contents_no_follow(&src_path, &dst_path)?;
687 } else if metadata.is_file() {
688 copy_regular_file_no_follow(&src_path, &dst_path)?;
689 } else {
690 return Err(std::io::Error::new(
691 std::io::ErrorKind::InvalidData,
692 format!(
693 "refusing special file in OCI layout: {}",
694 src_path.display()
695 ),
696 ));
697 }
698 }
699 Ok(())
700}
701
702#[cfg(test)]
704fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
705 std::fs::create_dir(dst)?;
706 copy_dir_contents_no_follow(src, dst)
707}
708
709fn dir_size(path: &Path) -> u64 {
712 let Ok(metadata) = std::fs::symlink_metadata(path) else {
713 return 0;
714 };
715 if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
716 return 0;
717 }
718 if metadata.is_file() {
719 return metadata.len();
720 }
721 if !metadata.is_dir() {
722 return 0;
723 }
724
725 std::fs::read_dir(path)
726 .map(|entries| entries.flatten().map(|entry| dir_size(&entry.path())).sum())
727 .unwrap_or(0)
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use tempfile::TempDir;
734
735 fn create_test_oci_layout(dir: &Path) {
736 std::fs::create_dir_all(dir.join("blobs/sha256")).unwrap();
737 std::fs::write(dir.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
738 std::fs::write(dir.join("index.json"), r#"{"manifests":[]}"#).unwrap();
739 std::fs::write(dir.join("blobs/sha256/testblob"), "x".repeat(1024)).unwrap();
741 }
742
743 fn stored_image(reference: &str, digest: &str, path: PathBuf) -> StoredImage {
744 let now = Utc::now();
745 StoredImage {
746 reference: reference.to_string(),
747 digest: digest.to_string(),
748 size_bytes: 1024,
749 pulled_at: now,
750 last_used: now,
751 path,
752 }
753 }
754
755 #[tokio::test]
756 async fn test_new_creates_directory() {
757 let tmp = TempDir::new().unwrap();
758 let store_dir = tmp.path().join("images");
759 let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
760 assert!(store_dir.exists());
761 assert_eq!(store.total_size().await, 0);
762 }
763
764 #[tokio::test]
765 async fn test_new_keeps_existing_tmp_dir_for_concurrent_pulls() {
766 let tmp = TempDir::new().unwrap();
767 let store_dir = tmp.path().join("images");
768 let tmp_dir = store_dir.join("tmp");
769 std::fs::create_dir_all(tmp_dir.join("pull-1")).unwrap();
770 std::fs::write(tmp_dir.join("pull-1/layer"), b"partial").unwrap();
771
772 let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
773
774 assert!(
775 tmp_dir.join("pull-1/layer").exists(),
776 "constructing a store must not delete another process' active pull"
777 );
778 assert_eq!(store.total_size().await, 0);
779 }
780
781 #[tokio::test]
782 async fn test_put_and_get() {
783 let tmp = TempDir::new().unwrap();
784 let store_dir = tmp.path().join("store");
785 let source_dir = tmp.path().join("source");
786 create_test_oci_layout(&source_dir);
787
788 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
789
790 let stored = store
791 .put(
792 "nginx:latest",
793 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
794 &source_dir,
795 )
796 .await
797 .unwrap();
798
799 assert_eq!(stored.reference, "nginx:latest");
800 assert_eq!(
801 stored.digest,
802 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
803 );
804 assert!(stored.size_bytes > 0);
805 assert!(stored.path.exists());
806
807 let fetched = store.get("nginx:latest").await.unwrap();
809 assert_eq!(
810 fetched.digest,
811 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
812 );
813
814 let fetched = store
816 .get_by_digest(
817 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
818 )
819 .await
820 .unwrap();
821 assert_eq!(fetched.reference, "nginx:latest");
822 }
823
824 #[tokio::test]
825 async fn test_get_nonexistent() {
826 let tmp = TempDir::new().unwrap();
827 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
828 assert!(store.get("nonexistent").await.is_none());
829 }
830
831 #[tokio::test]
832 async fn test_remove() {
833 let tmp = TempDir::new().unwrap();
834 let store_dir = tmp.path().join("store");
835 let source_dir = tmp.path().join("source");
836 create_test_oci_layout(&source_dir);
837
838 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
839 store
840 .put(
841 "nginx:latest",
842 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
843 &source_dir,
844 )
845 .await
846 .unwrap();
847
848 store.remove("nginx:latest").await.unwrap();
849 assert!(store.get("nginx:latest").await.is_none());
850 }
851
852 #[tokio::test]
853 async fn test_remove_one_tag_keeps_shared_digest_until_last_reference() {
854 let tmp = TempDir::new().unwrap();
855 let store_dir = tmp.path().join("store");
856 let source_dir = tmp.path().join("source");
857 create_test_oci_layout(&source_dir);
858
859 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
860 store
861 .put(
862 "img:v1",
863 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
864 &source_dir,
865 )
866 .await
867 .unwrap();
868 let stored = store
869 .put(
870 "img:latest",
871 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
872 &source_dir,
873 )
874 .await
875 .unwrap();
876 let path = stored.path.clone();
877
878 store.remove("img:v1").await.unwrap();
879 assert!(store.get("img:v1").await.is_none());
880 assert!(store.get("img:latest").await.is_some());
881 assert!(path.exists(), "shared layout should remain in use");
882
883 store.remove("img:latest").await.unwrap();
884 assert!(!path.exists(), "layout should be removed after final tag");
885 }
886
887 #[tokio::test]
888 async fn test_retag_keeps_displaced_image_as_dangling() {
889 let tmp = TempDir::new().unwrap();
892 let store_dir = tmp.path().join("store");
893 let source_dir = tmp.path().join("source");
894 create_test_oci_layout(&source_dir);
895
896 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
897 store
898 .put(
899 "app:latest",
900 "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
901 &source_dir,
902 )
903 .await
904 .unwrap();
905 store
906 .put(
907 "app:latest",
908 "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
909 &source_dir,
910 )
911 .await
912 .unwrap();
913
914 assert_eq!(
916 store.get("app:latest").await.unwrap().digest,
917 "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
918 );
919 let dangling = store
921 .get("sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")
922 .await
923 .unwrap();
924 assert_eq!(
925 dangling.digest,
926 "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
927 );
928 assert_eq!(store.list().await.len(), 2);
929 }
930
931 #[tokio::test]
932 async fn test_reput_same_digest_does_not_create_dangling() {
933 let tmp = TempDir::new().unwrap();
936 let store_dir = tmp.path().join("store");
937 let source_dir = tmp.path().join("source");
938 create_test_oci_layout(&source_dir);
939
940 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
941 store
942 .put(
943 "app:latest",
944 "sha256:1111111111111111111111111111111111111111111111111111111111111111",
945 &source_dir,
946 )
947 .await
948 .unwrap();
949 store
950 .put(
951 "app:latest",
952 "sha256:1111111111111111111111111111111111111111111111111111111111111111",
953 &source_dir,
954 )
955 .await
956 .unwrap();
957
958 assert_eq!(store.list().await.len(), 1);
959 }
960
961 #[tokio::test]
962 async fn test_remove_by_digest() {
963 let tmp = TempDir::new().unwrap();
966 let store_dir = tmp.path().join("store");
967 let source_dir = tmp.path().join("source");
968 create_test_oci_layout(&source_dir);
969
970 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
971 let stored = store
972 .put(
973 "gcr.io/test/img:test",
974 "sha256:2222222222222222222222222222222222222222222222222222222222222222",
975 &source_dir,
976 )
977 .await
978 .unwrap();
979 let path = stored.path.clone();
980
981 store
982 .remove("sha256:2222222222222222222222222222222222222222222222222222222222222222")
983 .await
984 .unwrap();
985 assert!(store.get("gcr.io/test/img:test").await.is_none());
986 assert!(store
987 .get_by_digest(
988 "sha256:2222222222222222222222222222222222222222222222222222222222222222"
989 )
990 .await
991 .is_none());
992 assert!(!path.exists(), "on-disk layout should be deleted");
993 }
994
995 #[tokio::test]
996 async fn test_remove_by_digest_removes_all_tags() {
997 let tmp = TempDir::new().unwrap();
1000 let store_dir = tmp.path().join("store");
1001 let source_dir = tmp.path().join("source");
1002 create_test_oci_layout(&source_dir);
1003
1004 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1005 store
1006 .put(
1007 "img:v1",
1008 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1009 &source_dir,
1010 )
1011 .await
1012 .unwrap();
1013 let stored = store
1014 .put(
1015 "img:latest",
1016 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1017 &source_dir,
1018 )
1019 .await
1020 .unwrap();
1021 let path = stored.path.clone();
1022
1023 store
1024 .remove("sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
1025 .await
1026 .unwrap();
1027 assert!(store.get("img:v1").await.is_none());
1028 assert!(store.get("img:latest").await.is_none());
1029 assert!(!path.exists(), "shared layout should be deleted");
1030 }
1031
1032 #[tokio::test]
1033 async fn test_resolve_by_name_digest_and_normalized() {
1034 let tmp = TempDir::new().unwrap();
1035 let store_dir = tmp.path().join("store");
1036 let source_dir = tmp.path().join("source");
1037 create_test_oci_layout(&source_dir);
1038
1039 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1040 store
1041 .put(
1042 "gcr.io/x/test-image-predefined-group:latest",
1043 "sha256:3333333333333333333333333333333333333333333333333333333333333333",
1044 &source_dir,
1045 )
1046 .await
1047 .unwrap();
1048
1049 assert!(store
1051 .resolve("gcr.io/x/test-image-predefined-group:latest")
1052 .await
1053 .is_some());
1054 assert_eq!(
1056 store
1057 .resolve("gcr.io/x/test-image-predefined-group")
1058 .await
1059 .map(|i| i.digest),
1060 Some(
1061 "sha256:3333333333333333333333333333333333333333333333333333333333333333"
1062 .to_string()
1063 )
1064 );
1065 assert!(store
1067 .resolve("sha256:3333333333333333333333333333333333333333333333333333333333333333")
1068 .await
1069 .is_some());
1070 assert!(store
1071 .resolve("gcr.io/x/test-image-predefined-group@sha256:3333333333333333333333333333333333333333333333333333333333333333")
1072 .await
1073 .is_some());
1074 assert!(store.resolve("nope:latest").await.is_none());
1076 }
1077
1078 #[tokio::test]
1079 async fn test_resolve_invalid_reference_returns_none() {
1080 let tmp = TempDir::new().unwrap();
1081 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
1082
1083 assert!(store.resolve("registry.example.com/").await.is_none());
1084 assert!(store.resolve("busybox@not-a-digest").await.is_none());
1085 }
1086
1087 #[tokio::test]
1088 async fn test_remove_nonexistent() {
1089 let tmp = TempDir::new().unwrap();
1090 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
1091 assert!(store.remove("nonexistent").await.is_err());
1092 }
1093
1094 #[tokio::test]
1095 async fn test_list() {
1096 let tmp = TempDir::new().unwrap();
1097 let store_dir = tmp.path().join("store");
1098 let source_dir = tmp.path().join("source");
1099 create_test_oci_layout(&source_dir);
1100
1101 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1102 store
1103 .put(
1104 "nginx:latest",
1105 "sha256:4444444444444444444444444444444444444444444444444444444444444444",
1106 &source_dir,
1107 )
1108 .await
1109 .unwrap();
1110 store
1111 .put(
1112 "alpine:3.18",
1113 "sha256:5555555555555555555555555555555555555555555555555555555555555555",
1114 &source_dir,
1115 )
1116 .await
1117 .unwrap();
1118
1119 let images = store.list().await;
1120 assert_eq!(images.len(), 2);
1121 }
1122
1123 #[tokio::test]
1124 async fn test_total_size() {
1125 let tmp = TempDir::new().unwrap();
1126 let store_dir = tmp.path().join("store");
1127 let source_dir = tmp.path().join("source");
1128 create_test_oci_layout(&source_dir);
1129
1130 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1131 store
1132 .put(
1133 "nginx:latest",
1134 "sha256:4444444444444444444444444444444444444444444444444444444444444444",
1135 &source_dir,
1136 )
1137 .await
1138 .unwrap();
1139
1140 assert!(store.total_size().await > 0);
1141 }
1142
1143 #[tokio::test]
1144 async fn test_lru_eviction() {
1145 let tmp = TempDir::new().unwrap();
1146 let store_dir = tmp.path().join("store");
1147 let source_dir = tmp.path().join("source");
1148 create_test_oci_layout(&source_dir);
1149
1150 let store = ImageStore::new(&store_dir, 100).unwrap();
1152
1153 store
1154 .put(
1155 "old:v1",
1156 "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
1157 &source_dir,
1158 )
1159 .await
1160 .unwrap();
1161
1162 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1164
1165 store
1166 .put(
1167 "new:v2",
1168 "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1169 &source_dir,
1170 )
1171 .await
1172 .unwrap();
1173
1174 store.get("new:v2").await;
1176
1177 let evicted = store.evict().await.unwrap();
1178 assert!(!evicted.is_empty());
1180 assert!(evicted.contains(&"old:v1".to_string()));
1181 }
1182
1183 #[tokio::test]
1184 async fn test_evict_empty_and_under_limit_returns_empty() {
1185 let tmp = TempDir::new().unwrap();
1186 let store_dir = tmp.path().join("store");
1187 let source_dir = tmp.path().join("source");
1188 create_test_oci_layout(&source_dir);
1189
1190 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1191 assert!(store.evict().await.unwrap().is_empty());
1192
1193 store
1194 .put(
1195 "tiny:latest",
1196 "sha256:6666666666666666666666666666666666666666666666666666666666666666",
1197 &source_dir,
1198 )
1199 .await
1200 .unwrap();
1201 assert!(store.evict().await.unwrap().is_empty());
1202 assert!(store.get("tiny:latest").await.is_some());
1203 }
1204
1205 #[tokio::test]
1206 async fn test_index_persistence() {
1207 let tmp = TempDir::new().unwrap();
1208 let store_dir = tmp.path().join("store");
1209 let source_dir = tmp.path().join("source");
1210 create_test_oci_layout(&source_dir);
1211
1212 {
1214 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1215 store
1216 .put(
1217 "nginx:latest",
1218 "sha256:7777777777777777777777777777777777777777777777777777777777777777",
1219 &source_dir,
1220 )
1221 .await
1222 .unwrap();
1223 }
1224
1225 {
1227 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1228 let image = store.get("nginx:latest").await;
1229 assert!(image.is_some());
1230 assert_eq!(
1231 image.unwrap().digest,
1232 "sha256:7777777777777777777777777777777777777777777777777777777777777777"
1233 );
1234 }
1235 }
1236
1237 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1238 async fn concurrent_cross_instance_puts_persist_both() {
1239 use std::collections::HashSet;
1240 use std::sync::Arc;
1241
1242 let tmp = TempDir::new().unwrap();
1243 let store_dir = tmp.path().join("store");
1244 let source_dir = tmp.path().join("source");
1245 create_test_oci_layout(&source_dir);
1246
1247 let s1 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
1252 let s2 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
1253 let (src1, src2) = (source_dir.clone(), source_dir.clone());
1254 let h1 = {
1255 let s1 = Arc::clone(&s1);
1256 tokio::spawn(async move {
1257 s1.put(
1258 "img:a",
1259 "sha256:8888888888888888888888888888888888888888888888888888888888888888",
1260 &src1,
1261 )
1262 .await
1263 .unwrap()
1264 })
1265 };
1266 let h2 = {
1267 let s2 = Arc::clone(&s2);
1268 tokio::spawn(async move {
1269 s2.put(
1270 "img:b",
1271 "sha256:9999999999999999999999999999999999999999999999999999999999999999",
1272 &src2,
1273 )
1274 .await
1275 .unwrap()
1276 })
1277 };
1278 h1.await.unwrap();
1279 h2.await.unwrap();
1280
1281 let s3 = ImageStore::new(&store_dir, u64::MAX).unwrap();
1283 let refs: HashSet<String> = s3.list().await.into_iter().map(|i| i.reference).collect();
1284 assert!(refs.contains("img:a"), "img:a lost: {refs:?}");
1285 assert!(refs.contains("img:b"), "img:b lost: {refs:?}");
1286 }
1287
1288 #[tokio::test]
1289 async fn load_index_skips_missing_paths_and_unreadable_entries() {
1290 let tmp = tempfile::tempdir().unwrap();
1291 let store_dir = tmp.path().join("images");
1292 let live_digest = format!("sha256:{}", "a".repeat(64));
1293 let missing_digest = format!("sha256:{}", "b".repeat(64));
1294 let live_path = store_dir.join("sha256").join("a".repeat(64));
1295 let missing_path = store_dir.join("sha256").join("b".repeat(64));
1296 create_test_oci_layout(&live_path);
1297
1298 let live = stored_image("live:latest", &live_digest, live_path);
1299 let missing = stored_image("missing:latest", &missing_digest, missing_path);
1300 let index = serde_json::json!({
1301 "images": [
1302 serde_json::to_value(&live).unwrap(),
1303 serde_json::to_value(&missing).unwrap(),
1304 {
1305 "reference": "broken:latest",
1306 "digest": false
1307 }
1308 ]
1309 });
1310 std::fs::write(
1311 store_dir.join("index.json"),
1312 serde_json::to_vec_pretty(&index).unwrap(),
1313 )
1314 .unwrap();
1315
1316 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1317 let images = store.list().await;
1318
1319 assert_eq!(images.len(), 1);
1320 assert_eq!(images[0].reference, "live:latest");
1321 assert!(store.get("missing:latest").await.is_none());
1322 assert!(std::fs::read_dir(&store_dir)
1323 .unwrap()
1324 .filter_map(|entry| entry.ok())
1325 .any(|entry| entry
1326 .file_name()
1327 .to_string_lossy()
1328 .contains("index.json.corrupt-")));
1329 }
1330
1331 #[tokio::test]
1332 async fn corrupt_index_is_quarantined_not_fatal() {
1333 let tmp = tempfile::tempdir().unwrap();
1334 let store_dir = tmp.path().join("images");
1335 std::fs::create_dir_all(&store_dir).unwrap();
1336 std::fs::write(store_dir.join("index.json"), "{ not valid json").unwrap();
1337
1338 let store = ImageStore::new(&store_dir, u64::MAX)
1341 .expect("corrupt index.json must not brick the image store");
1342 assert!(
1343 store.list().await.is_empty(),
1344 "store must start from an empty catalog after quarantine"
1345 );
1346
1347 let quarantined = std::fs::read_dir(&store_dir)
1349 .unwrap()
1350 .filter_map(|e| e.ok())
1351 .any(|e| {
1352 e.file_name()
1353 .to_string_lossy()
1354 .contains("index.json.corrupt-")
1355 });
1356 assert!(
1357 quarantined,
1358 "corrupt index.json must be quarantined to a sibling"
1359 );
1360 }
1361
1362 #[test]
1363 fn copy_dir_recursive_copies_nested_files_and_dir_size_sums() {
1364 let tmp = TempDir::new().unwrap();
1365 let src = tmp.path().join("src");
1366 let dst = tmp.path().join("dst");
1367 std::fs::create_dir_all(src.join("nested")).unwrap();
1368 std::fs::write(src.join("root.txt"), b"abc").unwrap();
1369 std::fs::write(src.join("nested/leaf.txt"), b"hello").unwrap();
1370
1371 copy_dir_recursive(&src, &dst).unwrap();
1372
1373 assert_eq!(std::fs::read(dst.join("root.txt")).unwrap(), b"abc");
1374 assert_eq!(
1375 std::fs::read(dst.join("nested/leaf.txt")).unwrap(),
1376 b"hello"
1377 );
1378 assert_eq!(dir_size(&dst), 8);
1379 assert_eq!(dir_size(&tmp.path().join("missing")), 0);
1380 }
1381
1382 #[test]
1383 fn copy_dir_recursive_fails_for_missing_source() {
1384 let tmp = TempDir::new().unwrap();
1385 let err = copy_dir_recursive(&tmp.path().join("missing"), &tmp.path().join("dst"))
1386 .expect_err("missing source should fail");
1387
1388 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1389 }
1390
1391 #[tokio::test]
1392 async fn put_rejects_path_shaped_digest_without_touching_host_path() {
1393 let tmp = TempDir::new().unwrap();
1394 let store_dir = tmp.path().join("store");
1395 let source_dir = tmp.path().join("source");
1396 let host_dir = tmp.path().join("host-target");
1397 create_test_oci_layout(&source_dir);
1398 std::fs::create_dir_all(&host_dir).unwrap();
1399 std::fs::write(host_dir.join("keep.txt"), b"host data").unwrap();
1400 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1401
1402 let error = store
1403 .put("evil:latest", "sha256:../../host-target", &source_dir)
1404 .await
1405 .unwrap_err();
1406
1407 assert!(error.to_string().contains("malformed content digest"));
1408 assert_eq!(
1409 std::fs::read(host_dir.join("keep.txt")).unwrap(),
1410 b"host data"
1411 );
1412 assert!(store.list().await.is_empty());
1413 }
1414
1415 #[cfg(unix)]
1416 #[tokio::test]
1417 async fn put_rejects_source_symlink_without_copying_target() {
1418 use std::os::unix::fs::symlink;
1419
1420 let tmp = TempDir::new().unwrap();
1421 let store_dir = tmp.path().join("store");
1422 let real_source = tmp.path().join("real-source");
1423 let source_link = tmp.path().join("source-link");
1424 create_test_oci_layout(&real_source);
1425 symlink(&real_source, &source_link).unwrap();
1426 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1427
1428 let error = store
1429 .put(
1430 "evil:latest",
1431 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1432 &source_link,
1433 )
1434 .await
1435 .unwrap_err();
1436
1437 assert!(error.to_string().contains("symbolic link"));
1438 assert!(store.list().await.is_empty());
1439 assert!(real_source.join("index.json").is_file());
1440 }
1441
1442 #[cfg(unix)]
1443 #[tokio::test]
1444 async fn put_rejects_extra_symlink_and_preserves_its_target() {
1445 use std::os::unix::fs::symlink;
1446
1447 let tmp = TempDir::new().unwrap();
1448 let store_dir = tmp.path().join("store");
1449 let source_dir = tmp.path().join("source");
1450 let host_file = tmp.path().join("host-secret.txt");
1451 create_test_oci_layout(&source_dir);
1452 std::fs::write(&host_file, b"secret").unwrap();
1453 symlink(&host_file, source_dir.join("extra-blob")).unwrap();
1454 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1455
1456 assert!(store
1457 .put(
1458 "evil:latest",
1459 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1460 &source_dir,
1461 )
1462 .await
1463 .is_err());
1464 assert_eq!(std::fs::read(&host_file).unwrap(), b"secret");
1465 assert!(store.list().await.is_empty());
1466 }
1467
1468 #[cfg(windows)]
1469 #[tokio::test]
1470 async fn put_rejects_windows_source_reparse_point() {
1471 let tmp = TempDir::new().unwrap();
1472 let store_dir = tmp.path().join("store");
1473 let source_dir = tmp.path().join("source");
1474 let host_file = tmp.path().join("host-secret.txt");
1475 let link = source_dir.join("extra-blob");
1476 create_test_oci_layout(&source_dir);
1477 std::fs::write(&host_file, b"secret").unwrap();
1478 match std::os::windows::fs::symlink_file(&host_file, &link) {
1479 Ok(()) => {}
1480 Err(error) if error.raw_os_error() == Some(1314) => return,
1481 Err(error) => panic!("failed to create Windows test symlink: {error}"),
1482 }
1483 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1484
1485 assert!(store
1486 .put(
1487 "evil:latest",
1488 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1489 &source_dir,
1490 )
1491 .await
1492 .is_err());
1493 assert_eq!(std::fs::read(&host_file).unwrap(), b"secret");
1494 assert!(store.list().await.is_empty());
1495 }
1496
1497 #[tokio::test]
1498 async fn load_index_rejects_forged_deletion_path() {
1499 let tmp = TempDir::new().unwrap();
1500 let store_dir = tmp.path().join("store");
1501 let victim = tmp.path().join("host-victim");
1502 std::fs::create_dir_all(&store_dir).unwrap();
1503 std::fs::create_dir_all(&victim).unwrap();
1504 std::fs::write(victim.join("keep.txt"), b"keep").unwrap();
1505
1506 let forged = stored_image(
1507 "evil:latest",
1508 "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
1509 victim.clone(),
1510 );
1511 let index = StoreIndex {
1512 images: vec![forged],
1513 };
1514 std::fs::write(
1515 store_dir.join("index.json"),
1516 serde_json::to_vec_pretty(&index).unwrap(),
1517 )
1518 .unwrap();
1519
1520 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1521 assert!(store.list().await.is_empty());
1522 assert!(store.remove("evil:latest").await.is_err());
1523 assert_eq!(std::fs::read(victim.join("keep.txt")).unwrap(), b"keep");
1524 }
1525
1526 #[tokio::test]
1527 async fn load_index_rederives_path_and_never_uses_forged_spelling() {
1528 let tmp = TempDir::new().unwrap();
1529 let store_dir = tmp.path().join("store");
1530 let digest_hex = "d".repeat(64);
1531 let digest = format!("sha256:{digest_hex}");
1532 let expected = store_dir.join("sha256").join(&digest_hex);
1533 let victim = tmp.path().join("host-victim");
1534 create_test_oci_layout(&expected);
1535 std::fs::create_dir_all(&victim).unwrap();
1536 std::fs::write(victim.join("keep.txt"), b"keep").unwrap();
1537
1538 let forged = stored_image("safe:latest", &digest, victim.clone());
1542 let index = StoreIndex {
1543 images: vec![forged],
1544 };
1545 std::fs::write(
1546 store_dir.join("index.json"),
1547 serde_json::to_vec_pretty(&index).unwrap(),
1548 )
1549 .unwrap();
1550
1551 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1552 let loaded = store.get("safe:latest").await.unwrap();
1553 assert_eq!(loaded.path, expected);
1554
1555 store.remove("safe:latest").await.unwrap();
1556 assert!(!expected.exists());
1557 assert_eq!(std::fs::read(victim.join("keep.txt")).unwrap(), b"keep");
1558 }
1559}