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