1use std::collections::{HashMap, HashSet};
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 self.put_with_digest_lock(
178 reference.to_string(),
179 digest.to_string(),
180 digest_root,
181 digest_hex.to_string(),
182 target_dir,
183 source_dir.to_path_buf(),
184 )
185 .await
186 }
187
188 pub async fn remove(&self, image: &str) -> Result<()> {
196 let mut locked_digests = self.remove_candidate_digests(image).await?;
202 loop {
203 let mut content_locks = Vec::with_capacity(locked_digests.len());
204 for digest_hex in &locked_digests {
205 content_locks.push(self.acquire_content_lock(digest_hex).await?);
206 }
207
208 let index_lock = self.acquire_index_lock("remove").await?;
209 let fresh = self.read_index_from_disk()?;
210 let keys = image_keys_for_request(&fresh, image);
211 if keys.is_empty() {
212 return Err(BoxError::OciImageError(format!(
213 "Image not found: {}",
214 image
215 )));
216 }
217 let current_digests = image_digests_for_keys(&fresh, &keys)?;
218 if current_digests
219 .iter()
220 .any(|digest_hex| !locked_digests.contains(digest_hex))
221 {
222 drop(index_lock);
225 drop(content_locks);
226 locked_digests = current_digests;
227 continue;
228 }
229
230 let store_dir = self.store_dir.clone();
231 {
232 let mut index = self.index.write().await;
233 *index = fresh;
234
235 for key in &keys {
239 let img = index.get(key).ok_or_else(|| {
240 BoxError::OciImageError(format!("Image index entry disappeared: {key}"))
241 })?;
242 let digest_hex = super::registry::validated_digest_hex(&img.digest)?;
243 let expected = store_dir.join("sha256").join(digest_hex);
244 if img.path != expected {
245 return Err(BoxError::OciImageError(format!(
246 "Refusing unsafe image path {} for digest {} (expected {})",
247 img.path.display(),
248 img.digest,
249 expected.display()
250 )));
251 }
252 }
253
254 let removed: Vec<StoredImage> =
255 keys.iter().filter_map(|key| index.remove(key)).collect();
256
257 for img in removed {
262 let digest_still_used = index.values().any(|other| other.digest == img.digest);
263 if !digest_still_used
264 && real_directory_exists(&img.path).map_err(|error| {
265 BoxError::OciImageError(format!(
266 "Refusing unsafe image directory {}: {error}",
267 img.path.display()
268 ))
269 })?
270 {
271 std::fs::remove_dir_all(&img.path).map_err(|error| {
272 BoxError::OciImageError(format!(
273 "Failed to remove image directory {}: {error}",
274 img.path.display()
275 ))
276 })?;
277 }
278 }
279 }
280 self.save_index_inner().await?;
281 drop(index_lock);
282 drop(content_locks);
283 return Ok(());
284 }
285 }
286
287 pub async fn list(&self) -> Vec<StoredImage> {
289 if let Err(error) = self.refresh_index().await {
290 tracing::warn!(
291 %error,
292 "Failed to refresh the authoritative image store index before listing"
293 );
294 }
295 let index = self.index.read().await;
296 index.values().cloned().collect()
297 }
298
299 pub async fn evict(&self) -> Result<Vec<String>> {
303 let mut evicted = Vec::new();
304 let mut total = self.total_size().await;
305
306 while total > self.max_size_bytes {
307 let lru_ref = {
309 let index = self.index.read().await;
310 index
311 .values()
312 .min_by_key(|img| img.last_used)
313 .map(|img| img.reference.clone())
314 };
315
316 match lru_ref {
317 Some(reference) => {
318 self.remove(&reference).await?;
319 evicted.push(reference);
320 total = self.total_size().await;
321 }
322 None => break,
323 }
324 }
325
326 Ok(evicted)
327 }
328
329 pub async fn total_size(&self) -> u64 {
331 if let Err(error) = self.refresh_index().await {
332 tracing::warn!(
333 %error,
334 "Failed to refresh the authoritative image store index before sizing"
335 );
336 }
337 let index = self.index.read().await;
338 let mut seen_digests = HashSet::new();
343 index
344 .values()
345 .filter(|image| seen_digests.insert(image.digest.as_str()))
346 .map(|image| image.size_bytes)
347 .sum()
348 }
349
350 fn load_index(&mut self) -> Result<()> {
352 self.index = Arc::new(RwLock::new(self.read_index_from_disk()?));
354 Ok(())
355 }
356
357 async fn put_with_digest_lock(
365 &self,
366 reference: String,
367 digest: String,
368 digest_root: PathBuf,
369 digest_hex: String,
370 target_dir: PathBuf,
371 source_dir: PathBuf,
372 ) -> Result<StoredImage> {
373 let _content_lock = self.acquire_content_lock(&digest_hex).await?;
374
375 let target_for_worker = target_dir.clone();
376 let digest_root_for_worker = digest_root;
377 let digest_hex_for_worker = digest_hex;
378 let size_bytes = tokio::task::spawn_blocking(move || {
379 publish_image_content_if_missing(
380 &digest_root_for_worker,
381 &digest_hex_for_worker,
382 &target_for_worker,
383 &source_dir,
384 )?;
385 Ok::<u64, BoxError>(dir_size(&target_for_worker))
386 })
387 .await
388 .map_err(|error| {
389 BoxError::OciImageError(format!("Image content publication task failed: {error}"))
390 })??;
391
392 let now = Utc::now();
393 let stored = StoredImage {
394 reference: reference.clone(),
395 digest: digest.clone(),
396 size_bytes,
397 pulled_at: now,
398 last_used: now,
399 path: target_dir,
400 };
401
402 self.with_index_lock(|index| {
403 if let Some(old) = index.get(&reference).cloned() {
410 if old.digest != digest {
411 let still_referenced = index.iter().any(|(key, image)| {
412 key.as_str() != reference && image.digest == old.digest
413 });
414 if !still_referenced && !index.contains_key(&old.digest) {
415 let mut dangling = old.clone();
416 dangling.reference = old.digest.clone();
417 index.insert(old.digest.clone(), dangling);
418 }
419 }
420 }
421
422 index.insert(reference, stored.clone());
423 Ok(())
424 })
425 .await?;
426 Ok(stored)
427 }
428
429 async fn refresh_index(&self) -> Result<()> {
436 let _lock = self.acquire_index_lock("refresh").await?;
437
438 let fresh = self.read_index_from_disk()?;
439 let mut index = self.index.write().await;
440 *index = fresh;
441 Ok(())
442 }
443
444 async fn acquire_index_lock(&self, operation: &str) -> Result<crate::file_lock::FileLock> {
446 let index_path = self.store_dir.join("index.json");
447 let lock_path = index_path.clone();
448 tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&lock_path))
449 .await
450 .map_err(|error| {
451 BoxError::OciImageError(format!("index {operation} lock task failed: {error}"))
452 })?
453 .map_err(|error| {
454 BoxError::OciImageError(format!(
455 "failed to lock image index {} for {operation}: {error}. {}",
456 index_path.display(),
457 state_dir_hint()
458 ))
459 })
460 }
461
462 async fn remove_candidate_digests(&self, image: &str) -> Result<Vec<String>> {
466 let _lock = self.acquire_index_lock("remove lookup").await?;
467 let fresh = self.read_index_from_disk()?;
468 let keys = image_keys_for_request(&fresh, image);
469 if keys.is_empty() {
470 return Err(BoxError::OciImageError(format!(
471 "Image not found: {}",
472 image
473 )));
474 }
475 image_digests_for_keys(&fresh, &keys)
476 }
477
478 async fn acquire_content_lock(&self, digest_hex: &str) -> Result<crate::file_lock::FileLock> {
486 let digest_root = self.store_dir.join("sha256");
487 let target = digest_root.join(format!(".content-{digest_hex}"));
488 let lock_target = target.clone();
489 tokio::task::spawn_blocking(move || {
490 std::fs::create_dir_all(&digest_root)?;
491 require_real_directory(&digest_root)?;
492 crate::file_lock::FileLock::acquire(&lock_target)
493 })
494 .await
495 .map_err(|error| {
496 BoxError::OciImageError(format!("image content lock task failed: {error}"))
497 })?
498 .map_err(|error| {
499 BoxError::OciImageError(format!(
500 "failed to lock image content {}: {error}. {}",
501 target.display(),
502 state_dir_hint()
503 ))
504 })
505 }
506
507 fn read_index_from_disk(&self) -> Result<HashMap<String, StoredImage>> {
510 let index_path = self.store_dir.join("index.json");
511 if !index_path.exists() {
512 return Ok(HashMap::new());
513 }
514
515 let data = std::fs::read_to_string(&index_path).map_err(|e| {
516 BoxError::OciImageError(format!(
517 "Failed to read image store index {}: {}",
518 index_path.display(),
519 e
520 ))
521 })?;
522
523 #[derive(serde::Deserialize)]
530 struct RawIndex {
531 #[serde(default)]
532 images: Vec<serde_json::Value>,
533 }
534
535 let raw: RawIndex = match serde_json::from_str(&data) {
536 Ok(raw) => raw,
537 Err(err) => {
538 let preserved = crate::store_io::quarantine_label(&index_path);
539 tracing::warn!(
540 "image store index {} is corrupt ({err}); preserved a copy at \
541 {preserved} and started from an empty catalog (re-pulled images \
542 will repopulate it)",
543 index_path.display(),
544 );
545 return Ok(HashMap::new());
546 }
547 };
548
549 let mut index = HashMap::new();
550 let mut skipped = 0usize;
551 for value in raw.images {
552 match serde_json::from_value::<StoredImage>(value) {
553 Ok(mut image) => {
554 let expected = super::registry::validated_digest_hex(&image.digest)
562 .map(|digest_hex| self.store_dir.join("sha256").join(digest_hex));
563 match expected {
564 Ok(expected) if real_directory_exists(&expected).unwrap_or(false) => {
565 image.path = expected;
566 index.insert(image.reference.clone(), image);
567 }
568 _ => {
569 skipped += 1;
570 tracing::warn!(
571 reference = %image.reference,
572 digest = %image.digest,
573 path = %image.path.display(),
574 "skipping image index entry with malformed digest or unsafe path"
575 );
576 }
577 }
578 }
579 Err(err) => {
580 skipped += 1;
581 tracing::warn!("skipping unreadable image index entry ({err})");
582 }
583 }
584 }
585 if skipped > 0 {
586 let preserved = crate::store_io::quarantine_copy(&index_path)
590 .map(|p| p.display().to_string())
591 .unwrap_or_else(|| "<backup failed>".to_string());
592 tracing::warn!(
593 "{skipped} image index entr{} skipped as unreadable; preserved a copy at \
594 {preserved}; affected images will be re-pulled on demand",
595 if skipped == 1 { "y" } else { "ies" },
596 );
597 }
598 Ok(index)
599 }
600
601 async fn with_index_lock<F, R>(&self, f: F) -> Result<R>
610 where
611 F: FnOnce(&mut HashMap<String, StoredImage>) -> Result<R>,
612 {
613 let _lock = self.acquire_index_lock("write").await?;
614 let fresh = self.read_index_from_disk()?;
616 let result = {
617 let mut idx = self.index.write().await;
618 *idx = fresh;
619 f(&mut idx)?
620 };
621 self.save_index_inner().await?;
622 Ok(result)
623 }
624
625 async fn save_index_inner(&self) -> Result<()> {
627 let index = self.index.read().await;
628 let store_index = StoreIndex {
629 images: index.values().cloned().collect(),
630 };
631 drop(index);
632
633 let data = serde_json::to_vec_pretty(&store_index)?;
634 let index_path = self.store_dir.join("index.json");
635 let tmp_path = self.store_dir.join("index.json.tmp");
636 let display_path = index_path.clone();
637 tokio::task::spawn_blocking(move || {
638 a3s_box_core::fs_atomic::write_durable(&tmp_path, &index_path, &data)
639 })
640 .await
641 .map_err(|error| {
642 BoxError::OciImageError(format!(
643 "Image store index persistence task failed for {}: {error}",
644 display_path.display()
645 ))
646 })?
647 .map_err(|error| {
648 BoxError::OciImageError(format!(
649 "Failed to durably commit image store index {}: {error}. {}",
650 display_path.display(),
651 state_dir_hint()
652 ))
653 })?;
654
655 Ok(())
656 }
657
658 pub fn store_dir(&self) -> &Path {
660 &self.store_dir
661 }
662}
663
664#[async_trait::async_trait]
665impl ImageStoreBackend for ImageStore {
666 async fn get(&self, reference: &str) -> Option<StoredImage> {
667 self.get(reference).await
668 }
669
670 async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
671 self.get_by_digest(digest).await
672 }
673
674 async fn put(&self, reference: &str, digest: &str, source_dir: &Path) -> Result<StoredImage> {
675 self.put(reference, digest, source_dir).await
676 }
677
678 async fn remove(&self, reference: &str) -> Result<()> {
679 self.remove(reference).await
680 }
681
682 async fn list(&self) -> Vec<StoredImage> {
683 self.list().await
684 }
685
686 async fn evict(&self) -> Result<Vec<String>> {
687 self.evict().await
688 }
689
690 async fn total_size(&self) -> u64 {
691 self.total_size().await
692 }
693}
694
695#[cfg(windows)]
696fn metadata_is_reparse_point(metadata: &std::fs::Metadata) -> bool {
697 use std::os::windows::fs::MetadataExt;
698
699 metadata.file_attributes() & 0x0000_0400 != 0
702}
703
704#[cfg(not(windows))]
705fn metadata_is_reparse_point(_metadata: &std::fs::Metadata) -> bool {
706 false
707}
708
709fn require_real_directory(path: &Path) -> std::io::Result<()> {
710 let metadata = std::fs::symlink_metadata(path)?;
711 if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
712 return Err(std::io::Error::new(
713 std::io::ErrorKind::PermissionDenied,
714 format!(
715 "refusing symbolic link or reparse-point directory {}",
716 path.display()
717 ),
718 ));
719 }
720 if !metadata.is_dir() {
721 return Err(std::io::Error::new(
722 std::io::ErrorKind::InvalidData,
723 format!("expected a directory at {}", path.display()),
724 ));
725 }
726 Ok(())
727}
728
729fn real_directory_exists(path: &Path) -> std::io::Result<bool> {
730 match require_real_directory(path) {
731 Ok(()) => Ok(true),
732 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
733 Err(error) => Err(error),
734 }
735}
736
737#[cfg(unix)]
738fn open_regular_source_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
739 use std::os::unix::fs::OpenOptionsExt;
740
741 let mut options = std::fs::OpenOptions::new();
742 options
743 .read(true)
744 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
745 let file = options.open(path)?;
746 if !file.metadata()?.is_file() {
747 return Err(std::io::Error::new(
748 std::io::ErrorKind::InvalidData,
749 format!("expected a regular file at {}", path.display()),
750 ));
751 }
752 Ok(file)
753}
754
755#[cfg(windows)]
756fn open_regular_source_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
757 a3s_box_core::windows_file::open_regular_file(path, None).map(|(file, _)| file)
758}
759
760#[cfg(not(any(unix, windows)))]
761fn open_regular_source_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
762 let metadata = std::fs::symlink_metadata(path)?;
763 if metadata.file_type().is_symlink() || !metadata.is_file() {
764 return Err(std::io::Error::new(
765 std::io::ErrorKind::InvalidData,
766 format!("expected a regular file at {}", path.display()),
767 ));
768 }
769 std::fs::File::open(path)
770}
771
772fn copy_regular_file_no_follow(src: &Path, dst: &Path) -> std::io::Result<()> {
773 let mut source = open_regular_source_no_follow(src)?;
774 let mut destination = std::fs::OpenOptions::new()
775 .write(true)
776 .create_new(true)
777 .open(dst)?;
778 std::io::copy(&mut source, &mut destination)?;
779 Ok(())
780}
781
782fn copy_dir_contents_no_follow(src: &Path, dst: &Path) -> std::io::Result<()> {
786 require_real_directory(src)?;
787 require_real_directory(dst)?;
788
789 for entry in std::fs::read_dir(src)? {
790 let entry = entry?;
791 let src_path = entry.path();
792 let dst_path = dst.join(entry.file_name());
793 let metadata = std::fs::symlink_metadata(&src_path)?;
794
795 if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
796 return Err(std::io::Error::new(
797 std::io::ErrorKind::PermissionDenied,
798 format!(
799 "refusing symbolic link or reparse point in OCI layout: {}",
800 src_path.display()
801 ),
802 ));
803 }
804 if metadata.is_dir() {
805 std::fs::create_dir(&dst_path)?;
806 copy_dir_contents_no_follow(&src_path, &dst_path)?;
807 } else if metadata.is_file() {
808 copy_regular_file_no_follow(&src_path, &dst_path)?;
809 } else {
810 return Err(std::io::Error::new(
811 std::io::ErrorKind::InvalidData,
812 format!(
813 "refusing special file in OCI layout: {}",
814 src_path.display()
815 ),
816 ));
817 }
818 }
819 Ok(())
820}
821
822fn publish_image_content_if_missing(
828 digest_root: &Path,
829 digest_hex: &str,
830 target_dir: &Path,
831 source_dir: &Path,
832) -> Result<()> {
833 require_real_directory(digest_root).map_err(|error| {
834 BoxError::OciImageError(format!(
835 "Unsafe image content directory {}: {error}",
836 digest_root.display()
837 ))
838 })?;
839 if real_directory_exists(target_dir).map_err(|error| {
840 BoxError::OciImageError(format!(
841 "Unsafe existing image directory {}: {error}",
842 target_dir.display()
843 ))
844 })? {
845 return Ok(());
846 }
847
848 let staging = loop {
852 let seq = PUT_SEQ.fetch_add(1, Ordering::Relaxed);
853 let candidate = digest_root.join(format!(
854 ".staging-{}-{}-{}",
855 digest_hex,
856 std::process::id(),
857 seq
858 ));
859 match std::fs::create_dir(&candidate) {
860 Ok(()) => break candidate,
861 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
862 Err(error) => {
863 return Err(BoxError::OciImageError(format!(
864 "Failed to reserve image staging directory {}: {error}",
865 candidate.display()
866 )))
867 }
868 }
869 };
870
871 if let Err(error) = copy_dir_contents_no_follow(source_dir, &staging) {
872 let _ = std::fs::remove_dir_all(&staging);
873 return Err(BoxError::OciImageError(format!(
874 "Failed to copy image to store: {error}"
875 )));
876 }
877
878 if let Err(error) = std::fs::rename(&staging, target_dir) {
879 let _ = std::fs::remove_dir_all(&staging);
880 if !real_directory_exists(target_dir).map_err(|check_error| {
884 BoxError::OciImageError(format!(
885 "Unsafe concurrently published image directory {}: {check_error}",
886 target_dir.display()
887 ))
888 })? {
889 return Err(BoxError::OciImageError(format!(
890 "Failed to publish image to store: {error}"
891 )));
892 }
893 }
894
895 if !real_directory_exists(target_dir).map_err(|error| {
896 BoxError::OciImageError(format!(
897 "Unsafe published image directory {}: {error}",
898 target_dir.display()
899 ))
900 })? {
901 return Err(BoxError::OciImageError(format!(
902 "Image content directory {} is missing after publication",
903 target_dir.display()
904 )));
905 }
906 Ok(())
907}
908
909fn image_keys_for_request(index: &HashMap<String, StoredImage>, image: &str) -> Vec<String> {
911 if index.contains_key(image) {
912 vec![image.to_string()]
913 } else {
914 index
915 .values()
916 .filter(|entry| entry.digest == image)
917 .map(|entry| entry.reference.clone())
918 .collect()
919 }
920}
921
922fn image_digests_for_keys(
924 index: &HashMap<String, StoredImage>,
925 keys: &[String],
926) -> Result<Vec<String>> {
927 let mut digests: Vec<String> = keys
928 .iter()
929 .map(|key| {
930 index
931 .get(key)
932 .ok_or_else(|| {
933 BoxError::OciImageError(format!("Image index entry disappeared: {key}"))
934 })
935 .and_then(|entry| {
936 super::registry::validated_digest_hex(&entry.digest).map(str::to_owned)
937 })
938 })
939 .collect::<Result<Vec<_>>>()?;
940 digests.sort_unstable();
941 digests.dedup();
942 Ok(digests)
943}
944
945#[cfg(test)]
947fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
948 std::fs::create_dir(dst)?;
949 copy_dir_contents_no_follow(src, dst)
950}
951
952fn dir_size(path: &Path) -> u64 {
955 let Ok(metadata) = std::fs::symlink_metadata(path) else {
956 return 0;
957 };
958 if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
959 return 0;
960 }
961 if metadata.is_file() {
962 return metadata.len();
963 }
964 if !metadata.is_dir() {
965 return 0;
966 }
967
968 std::fs::read_dir(path)
969 .map(|entries| entries.flatten().map(|entry| dir_size(&entry.path())).sum())
970 .unwrap_or(0)
971}
972
973#[cfg(test)]
974mod tests {
975 use super::*;
976 use tempfile::TempDir;
977
978 fn create_test_oci_layout(dir: &Path) {
979 std::fs::create_dir_all(dir.join("blobs/sha256")).unwrap();
980 std::fs::write(dir.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
981 std::fs::write(dir.join("index.json"), r#"{"manifests":[]}"#).unwrap();
982 std::fs::write(dir.join("blobs/sha256/testblob"), "x".repeat(1024)).unwrap();
984 }
985
986 fn stored_image(reference: &str, digest: &str, path: PathBuf) -> StoredImage {
987 let now = Utc::now();
988 StoredImage {
989 reference: reference.to_string(),
990 digest: digest.to_string(),
991 size_bytes: 1024,
992 pulled_at: now,
993 last_used: now,
994 path,
995 }
996 }
997
998 #[tokio::test]
999 async fn test_new_creates_directory() {
1000 let tmp = TempDir::new().unwrap();
1001 let store_dir = tmp.path().join("images");
1002 let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
1003 assert!(store_dir.exists());
1004 assert_eq!(store.total_size().await, 0);
1005 }
1006
1007 #[tokio::test]
1008 async fn test_new_keeps_existing_tmp_dir_for_concurrent_pulls() {
1009 let tmp = TempDir::new().unwrap();
1010 let store_dir = tmp.path().join("images");
1011 let tmp_dir = store_dir.join("tmp");
1012 std::fs::create_dir_all(tmp_dir.join("pull-1")).unwrap();
1013 std::fs::write(tmp_dir.join("pull-1/layer"), b"partial").unwrap();
1014
1015 let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
1016
1017 assert!(
1018 tmp_dir.join("pull-1/layer").exists(),
1019 "constructing a store must not delete another process' active pull"
1020 );
1021 assert_eq!(store.total_size().await, 0);
1022 }
1023
1024 #[tokio::test]
1025 async fn test_put_and_get() {
1026 let tmp = TempDir::new().unwrap();
1027 let store_dir = tmp.path().join("store");
1028 let source_dir = tmp.path().join("source");
1029 create_test_oci_layout(&source_dir);
1030
1031 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1032
1033 let stored = store
1034 .put(
1035 "nginx:latest",
1036 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1037 &source_dir,
1038 )
1039 .await
1040 .unwrap();
1041
1042 assert_eq!(stored.reference, "nginx:latest");
1043 assert_eq!(
1044 stored.digest,
1045 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1046 );
1047 assert!(stored.size_bytes > 0);
1048 assert!(stored.path.exists());
1049
1050 let fetched = store.get("nginx:latest").await.unwrap();
1052 assert_eq!(
1053 fetched.digest,
1054 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1055 );
1056
1057 let fetched = store
1059 .get_by_digest(
1060 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1061 )
1062 .await
1063 .unwrap();
1064 assert_eq!(fetched.reference, "nginx:latest");
1065 }
1066
1067 #[tokio::test]
1068 async fn test_get_nonexistent() {
1069 let tmp = TempDir::new().unwrap();
1070 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
1071 assert!(store.get("nonexistent").await.is_none());
1072 }
1073
1074 #[tokio::test]
1075 async fn test_remove() {
1076 let tmp = TempDir::new().unwrap();
1077 let store_dir = tmp.path().join("store");
1078 let source_dir = tmp.path().join("source");
1079 create_test_oci_layout(&source_dir);
1080
1081 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1082 store
1083 .put(
1084 "nginx:latest",
1085 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1086 &source_dir,
1087 )
1088 .await
1089 .unwrap();
1090
1091 store.remove("nginx:latest").await.unwrap();
1092 assert!(store.get("nginx:latest").await.is_none());
1093 }
1094
1095 #[tokio::test]
1096 async fn test_remove_one_tag_keeps_shared_digest_until_last_reference() {
1097 let tmp = TempDir::new().unwrap();
1098 let store_dir = tmp.path().join("store");
1099 let source_dir = tmp.path().join("source");
1100 create_test_oci_layout(&source_dir);
1101
1102 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1103 store
1104 .put(
1105 "img:v1",
1106 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1107 &source_dir,
1108 )
1109 .await
1110 .unwrap();
1111 let stored = store
1112 .put(
1113 "img:latest",
1114 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1115 &source_dir,
1116 )
1117 .await
1118 .unwrap();
1119 let path = stored.path.clone();
1120
1121 store.remove("img:v1").await.unwrap();
1122 assert!(store.get("img:v1").await.is_none());
1123 assert!(store.get("img:latest").await.is_some());
1124 assert!(path.exists(), "shared layout should remain in use");
1125
1126 store.remove("img:latest").await.unwrap();
1127 assert!(!path.exists(), "layout should be removed after final tag");
1128 }
1129
1130 #[tokio::test]
1131 async fn test_total_size_counts_shared_digest_once() {
1132 let tmp = TempDir::new().unwrap();
1133 let store_dir = tmp.path().join("store");
1134 let source_dir = tmp.path().join("source");
1135 create_test_oci_layout(&source_dir);
1136
1137 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1138 let first = store
1139 .put(
1140 "img:v1",
1141 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1142 &source_dir,
1143 )
1144 .await
1145 .unwrap();
1146 store
1147 .put(
1148 "img:latest",
1149 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1150 &source_dir,
1151 )
1152 .await
1153 .unwrap();
1154
1155 assert_eq!(
1156 store.total_size().await,
1157 first.size_bytes,
1158 "shared content must contribute one directory to disk usage"
1159 );
1160 }
1161
1162 #[tokio::test]
1163 async fn test_retag_keeps_displaced_image_as_dangling() {
1164 let tmp = TempDir::new().unwrap();
1167 let store_dir = tmp.path().join("store");
1168 let source_dir = tmp.path().join("source");
1169 create_test_oci_layout(&source_dir);
1170
1171 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1172 store
1173 .put(
1174 "app:latest",
1175 "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
1176 &source_dir,
1177 )
1178 .await
1179 .unwrap();
1180 store
1181 .put(
1182 "app:latest",
1183 "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
1184 &source_dir,
1185 )
1186 .await
1187 .unwrap();
1188
1189 assert_eq!(
1191 store.get("app:latest").await.unwrap().digest,
1192 "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
1193 );
1194 let dangling = store
1196 .get("sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")
1197 .await
1198 .unwrap();
1199 assert_eq!(
1200 dangling.digest,
1201 "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
1202 );
1203 assert_eq!(store.list().await.len(), 2);
1204 }
1205
1206 #[tokio::test]
1207 async fn test_reput_same_digest_does_not_create_dangling() {
1208 let tmp = TempDir::new().unwrap();
1211 let store_dir = tmp.path().join("store");
1212 let source_dir = tmp.path().join("source");
1213 create_test_oci_layout(&source_dir);
1214
1215 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1216 store
1217 .put(
1218 "app:latest",
1219 "sha256:1111111111111111111111111111111111111111111111111111111111111111",
1220 &source_dir,
1221 )
1222 .await
1223 .unwrap();
1224 store
1225 .put(
1226 "app:latest",
1227 "sha256:1111111111111111111111111111111111111111111111111111111111111111",
1228 &source_dir,
1229 )
1230 .await
1231 .unwrap();
1232
1233 assert_eq!(store.list().await.len(), 1);
1234 }
1235
1236 #[tokio::test]
1237 async fn test_remove_by_digest() {
1238 let tmp = TempDir::new().unwrap();
1241 let store_dir = tmp.path().join("store");
1242 let source_dir = tmp.path().join("source");
1243 create_test_oci_layout(&source_dir);
1244
1245 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1246 let stored = store
1247 .put(
1248 "gcr.io/test/img:test",
1249 "sha256:2222222222222222222222222222222222222222222222222222222222222222",
1250 &source_dir,
1251 )
1252 .await
1253 .unwrap();
1254 let path = stored.path.clone();
1255
1256 store
1257 .remove("sha256:2222222222222222222222222222222222222222222222222222222222222222")
1258 .await
1259 .unwrap();
1260 assert!(store.get("gcr.io/test/img:test").await.is_none());
1261 assert!(store
1262 .get_by_digest(
1263 "sha256:2222222222222222222222222222222222222222222222222222222222222222"
1264 )
1265 .await
1266 .is_none());
1267 assert!(!path.exists(), "on-disk layout should be deleted");
1268 }
1269
1270 #[tokio::test]
1271 async fn test_remove_by_digest_removes_all_tags() {
1272 let tmp = TempDir::new().unwrap();
1275 let store_dir = tmp.path().join("store");
1276 let source_dir = tmp.path().join("source");
1277 create_test_oci_layout(&source_dir);
1278
1279 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1280 store
1281 .put(
1282 "img:v1",
1283 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1284 &source_dir,
1285 )
1286 .await
1287 .unwrap();
1288 let stored = store
1289 .put(
1290 "img:latest",
1291 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1292 &source_dir,
1293 )
1294 .await
1295 .unwrap();
1296 let path = stored.path.clone();
1297
1298 store
1299 .remove("sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
1300 .await
1301 .unwrap();
1302 assert!(store.get("img:v1").await.is_none());
1303 assert!(store.get("img:latest").await.is_none());
1304 assert!(!path.exists(), "shared layout should be deleted");
1305 }
1306
1307 #[tokio::test]
1308 async fn test_resolve_by_name_digest_and_normalized() {
1309 let tmp = TempDir::new().unwrap();
1310 let store_dir = tmp.path().join("store");
1311 let source_dir = tmp.path().join("source");
1312 create_test_oci_layout(&source_dir);
1313
1314 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1315 store
1316 .put(
1317 "gcr.io/x/test-image-predefined-group:latest",
1318 "sha256:3333333333333333333333333333333333333333333333333333333333333333",
1319 &source_dir,
1320 )
1321 .await
1322 .unwrap();
1323
1324 assert!(store
1326 .resolve("gcr.io/x/test-image-predefined-group:latest")
1327 .await
1328 .is_some());
1329 assert_eq!(
1331 store
1332 .resolve("gcr.io/x/test-image-predefined-group")
1333 .await
1334 .map(|i| i.digest),
1335 Some(
1336 "sha256:3333333333333333333333333333333333333333333333333333333333333333"
1337 .to_string()
1338 )
1339 );
1340 assert!(store
1342 .resolve("sha256:3333333333333333333333333333333333333333333333333333333333333333")
1343 .await
1344 .is_some());
1345 assert!(store
1346 .resolve("gcr.io/x/test-image-predefined-group@sha256:3333333333333333333333333333333333333333333333333333333333333333")
1347 .await
1348 .is_some());
1349 assert!(store.resolve("nope:latest").await.is_none());
1351 }
1352
1353 #[tokio::test]
1354 async fn test_resolve_invalid_reference_returns_none() {
1355 let tmp = TempDir::new().unwrap();
1356 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
1357
1358 assert!(store.resolve("registry.example.com/").await.is_none());
1359 assert!(store.resolve("busybox@not-a-digest").await.is_none());
1360 }
1361
1362 #[tokio::test]
1363 async fn test_remove_nonexistent() {
1364 let tmp = TempDir::new().unwrap();
1365 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
1366 assert!(store.remove("nonexistent").await.is_err());
1367 }
1368
1369 #[tokio::test]
1370 async fn test_list() {
1371 let tmp = TempDir::new().unwrap();
1372 let store_dir = tmp.path().join("store");
1373 let source_dir = tmp.path().join("source");
1374 create_test_oci_layout(&source_dir);
1375
1376 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1377 store
1378 .put(
1379 "nginx:latest",
1380 "sha256:4444444444444444444444444444444444444444444444444444444444444444",
1381 &source_dir,
1382 )
1383 .await
1384 .unwrap();
1385 store
1386 .put(
1387 "alpine:3.18",
1388 "sha256:5555555555555555555555555555555555555555555555555555555555555555",
1389 &source_dir,
1390 )
1391 .await
1392 .unwrap();
1393
1394 let images = store.list().await;
1395 assert_eq!(images.len(), 2);
1396 }
1397
1398 #[tokio::test]
1399 async fn test_total_size() {
1400 let tmp = TempDir::new().unwrap();
1401 let store_dir = tmp.path().join("store");
1402 let source_dir = tmp.path().join("source");
1403 create_test_oci_layout(&source_dir);
1404
1405 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1406 store
1407 .put(
1408 "nginx:latest",
1409 "sha256:4444444444444444444444444444444444444444444444444444444444444444",
1410 &source_dir,
1411 )
1412 .await
1413 .unwrap();
1414
1415 assert!(store.total_size().await > 0);
1416 }
1417
1418 #[tokio::test]
1419 async fn test_lru_eviction() {
1420 let tmp = TempDir::new().unwrap();
1421 let store_dir = tmp.path().join("store");
1422 let source_dir = tmp.path().join("source");
1423 create_test_oci_layout(&source_dir);
1424
1425 let store = ImageStore::new(&store_dir, 100).unwrap();
1427
1428 store
1429 .put(
1430 "old:v1",
1431 "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
1432 &source_dir,
1433 )
1434 .await
1435 .unwrap();
1436
1437 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1439
1440 store
1441 .put(
1442 "new:v2",
1443 "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1444 &source_dir,
1445 )
1446 .await
1447 .unwrap();
1448
1449 store.get("new:v2").await;
1451
1452 let evicted = store.evict().await.unwrap();
1453 assert!(!evicted.is_empty());
1455 assert!(evicted.contains(&"old:v1".to_string()));
1456 }
1457
1458 #[tokio::test]
1459 async fn test_evict_empty_and_under_limit_returns_empty() {
1460 let tmp = TempDir::new().unwrap();
1461 let store_dir = tmp.path().join("store");
1462 let source_dir = tmp.path().join("source");
1463 create_test_oci_layout(&source_dir);
1464
1465 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1466 assert!(store.evict().await.unwrap().is_empty());
1467
1468 store
1469 .put(
1470 "tiny:latest",
1471 "sha256:6666666666666666666666666666666666666666666666666666666666666666",
1472 &source_dir,
1473 )
1474 .await
1475 .unwrap();
1476 assert!(store.evict().await.unwrap().is_empty());
1477 assert!(store.get("tiny:latest").await.is_some());
1478 }
1479
1480 #[tokio::test]
1481 async fn test_index_persistence() {
1482 let tmp = TempDir::new().unwrap();
1483 let store_dir = tmp.path().join("store");
1484 let source_dir = tmp.path().join("source");
1485 create_test_oci_layout(&source_dir);
1486
1487 {
1489 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1490 store
1491 .put(
1492 "nginx:latest",
1493 "sha256:7777777777777777777777777777777777777777777777777777777777777777",
1494 &source_dir,
1495 )
1496 .await
1497 .unwrap();
1498 }
1499
1500 {
1502 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1503 let image = store.get("nginx:latest").await;
1504 assert!(image.is_some());
1505 assert_eq!(
1506 image.unwrap().digest,
1507 "sha256:7777777777777777777777777777777777777777777777777777777777777777"
1508 );
1509 }
1510 }
1511
1512 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1513 async fn concurrent_cross_instance_puts_persist_both() {
1514 use std::collections::HashSet;
1515 use std::sync::Arc;
1516
1517 let tmp = TempDir::new().unwrap();
1518 let store_dir = tmp.path().join("store");
1519 let source_dir = tmp.path().join("source");
1520 create_test_oci_layout(&source_dir);
1521
1522 let s1 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
1527 let s2 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
1528 let (src1, src2) = (source_dir.clone(), source_dir.clone());
1529 let h1 = {
1530 let s1 = Arc::clone(&s1);
1531 tokio::spawn(async move {
1532 s1.put(
1533 "img:a",
1534 "sha256:8888888888888888888888888888888888888888888888888888888888888888",
1535 &src1,
1536 )
1537 .await
1538 .unwrap()
1539 })
1540 };
1541 let h2 = {
1542 let s2 = Arc::clone(&s2);
1543 tokio::spawn(async move {
1544 s2.put(
1545 "img:b",
1546 "sha256:9999999999999999999999999999999999999999999999999999999999999999",
1547 &src2,
1548 )
1549 .await
1550 .unwrap()
1551 })
1552 };
1553 h1.await.unwrap();
1554 h2.await.unwrap();
1555
1556 let s3 = ImageStore::new(&store_dir, u64::MAX).unwrap();
1558 let refs: HashSet<String> = s3.list().await.into_iter().map(|i| i.reference).collect();
1559 assert!(refs.contains("img:a"), "img:a lost: {refs:?}");
1560 assert!(refs.contains("img:b"), "img:b lost: {refs:?}");
1561 }
1562
1563 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1564 async fn concurrent_put_and_remove_keep_published_content() {
1565 use std::sync::Arc;
1566
1567 let tmp = TempDir::new().unwrap();
1568 let store_dir = tmp.path().join("store");
1569 let source_dir = tmp.path().join("source");
1570 create_test_oci_layout(&source_dir);
1571
1572 let writer = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
1573 let remover = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
1574
1575 for i in 0..16u64 {
1580 let digest = format!("sha256:{:064x}", i + 1000);
1581 let old_reference = format!("app:old-{i}");
1582 let new_reference = format!("app:new-{i}");
1583 writer
1584 .put(&old_reference, &digest, &source_dir)
1585 .await
1586 .unwrap();
1587
1588 let put_writer = Arc::clone(&writer);
1589 let put_source = source_dir.clone();
1590 let put_digest = digest.clone();
1591 let put_reference = new_reference.clone();
1592 let put_task = tokio::spawn(async move {
1593 put_writer
1594 .put(&put_reference, &put_digest, &put_source)
1595 .await
1596 });
1597
1598 let remove_remover = Arc::clone(&remover);
1599 let remove_reference = old_reference.clone();
1600 let remove_task =
1601 tokio::spawn(async move { remove_remover.remove(&remove_reference).await });
1602
1603 put_task.await.unwrap().unwrap();
1604 remove_task.await.unwrap().unwrap();
1605
1606 let fresh = ImageStore::new(&store_dir, u64::MAX).unwrap();
1607 let image = fresh
1608 .get(&new_reference)
1609 .await
1610 .expect("new tag must survive the concurrent removal");
1611 assert!(
1612 image.path.is_dir(),
1613 "surviving index entry must point at published content: {}",
1614 image.path.display()
1615 );
1616 }
1617 }
1618
1619 #[tokio::test]
1620 async fn cross_instance_get_refreshes_the_authoritative_index() {
1621 let tmp = TempDir::new().unwrap();
1622 let store_dir = tmp.path().join("store");
1623 let source_dir = tmp.path().join("source");
1624 create_test_oci_layout(&source_dir);
1625
1626 let writer = ImageStore::new(&store_dir, u64::MAX).unwrap();
1627 let reader = ImageStore::new(&store_dir, u64::MAX).unwrap();
1628 writer
1629 .put(
1630 "img:fresh",
1631 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1632 &source_dir,
1633 )
1634 .await
1635 .unwrap();
1636
1637 let observed = reader
1638 .get("img:fresh")
1639 .await
1640 .expect("reader created before put must refresh the shared index");
1641 assert_eq!(
1642 observed.digest,
1643 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1644 );
1645
1646 let reopened = ImageStore::new(&store_dir, u64::MAX).unwrap();
1647 assert!(reopened.get("img:fresh").await.is_some());
1648 }
1649
1650 #[tokio::test]
1651 async fn cross_instance_list_refreshes_the_authoritative_index() {
1652 let tmp = TempDir::new().unwrap();
1653 let store_dir = tmp.path().join("store");
1654 let source_dir = tmp.path().join("source");
1655 create_test_oci_layout(&source_dir);
1656
1657 let writer = ImageStore::new(&store_dir, u64::MAX).unwrap();
1658 let reader = ImageStore::new(&store_dir, u64::MAX).unwrap();
1659 writer
1660 .put(
1661 "img:list",
1662 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1663 &source_dir,
1664 )
1665 .await
1666 .unwrap();
1667
1668 let images = reader.list().await;
1669 assert_eq!(images.len(), 1);
1670 assert_eq!(images[0].reference, "img:list");
1671 }
1672
1673 #[tokio::test]
1674 async fn load_index_skips_missing_paths_and_unreadable_entries() {
1675 let tmp = tempfile::tempdir().unwrap();
1676 let store_dir = tmp.path().join("images");
1677 let live_digest = format!("sha256:{}", "a".repeat(64));
1678 let missing_digest = format!("sha256:{}", "b".repeat(64));
1679 let live_path = store_dir.join("sha256").join("a".repeat(64));
1680 let missing_path = store_dir.join("sha256").join("b".repeat(64));
1681 create_test_oci_layout(&live_path);
1682
1683 let live = stored_image("live:latest", &live_digest, live_path);
1684 let missing = stored_image("missing:latest", &missing_digest, missing_path);
1685 let index = serde_json::json!({
1686 "images": [
1687 serde_json::to_value(&live).unwrap(),
1688 serde_json::to_value(&missing).unwrap(),
1689 {
1690 "reference": "broken:latest",
1691 "digest": false
1692 }
1693 ]
1694 });
1695 std::fs::write(
1696 store_dir.join("index.json"),
1697 serde_json::to_vec_pretty(&index).unwrap(),
1698 )
1699 .unwrap();
1700
1701 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1702 let images = store.list().await;
1703
1704 assert_eq!(images.len(), 1);
1705 assert_eq!(images[0].reference, "live:latest");
1706 assert!(store.get("missing:latest").await.is_none());
1707 assert!(std::fs::read_dir(&store_dir)
1708 .unwrap()
1709 .filter_map(|entry| entry.ok())
1710 .any(|entry| entry
1711 .file_name()
1712 .to_string_lossy()
1713 .contains("index.json.corrupt-")));
1714 }
1715
1716 #[tokio::test]
1717 async fn corrupt_index_is_quarantined_not_fatal() {
1718 let tmp = tempfile::tempdir().unwrap();
1719 let store_dir = tmp.path().join("images");
1720 std::fs::create_dir_all(&store_dir).unwrap();
1721 std::fs::write(store_dir.join("index.json"), "{ not valid json").unwrap();
1722
1723 let store = ImageStore::new(&store_dir, u64::MAX)
1726 .expect("corrupt index.json must not brick the image store");
1727 assert!(
1728 store.list().await.is_empty(),
1729 "store must start from an empty catalog after quarantine"
1730 );
1731
1732 let quarantined = std::fs::read_dir(&store_dir)
1734 .unwrap()
1735 .filter_map(|e| e.ok())
1736 .any(|e| {
1737 e.file_name()
1738 .to_string_lossy()
1739 .contains("index.json.corrupt-")
1740 });
1741 assert!(
1742 quarantined,
1743 "corrupt index.json must be quarantined to a sibling"
1744 );
1745 }
1746
1747 #[test]
1748 fn copy_dir_recursive_copies_nested_files_and_dir_size_sums() {
1749 let tmp = TempDir::new().unwrap();
1750 let src = tmp.path().join("src");
1751 let dst = tmp.path().join("dst");
1752 std::fs::create_dir_all(src.join("nested")).unwrap();
1753 std::fs::write(src.join("root.txt"), b"abc").unwrap();
1754 std::fs::write(src.join("nested/leaf.txt"), b"hello").unwrap();
1755
1756 copy_dir_recursive(&src, &dst).unwrap();
1757
1758 assert_eq!(std::fs::read(dst.join("root.txt")).unwrap(), b"abc");
1759 assert_eq!(
1760 std::fs::read(dst.join("nested/leaf.txt")).unwrap(),
1761 b"hello"
1762 );
1763 assert_eq!(dir_size(&dst), 8);
1764 assert_eq!(dir_size(&tmp.path().join("missing")), 0);
1765 }
1766
1767 #[test]
1768 fn copy_dir_recursive_fails_for_missing_source() {
1769 let tmp = TempDir::new().unwrap();
1770 let err = copy_dir_recursive(&tmp.path().join("missing"), &tmp.path().join("dst"))
1771 .expect_err("missing source should fail");
1772
1773 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1774 }
1775
1776 #[tokio::test]
1777 async fn put_rejects_path_shaped_digest_without_touching_host_path() {
1778 let tmp = TempDir::new().unwrap();
1779 let store_dir = tmp.path().join("store");
1780 let source_dir = tmp.path().join("source");
1781 let host_dir = tmp.path().join("host-target");
1782 create_test_oci_layout(&source_dir);
1783 std::fs::create_dir_all(&host_dir).unwrap();
1784 std::fs::write(host_dir.join("keep.txt"), b"host data").unwrap();
1785 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1786
1787 let error = store
1788 .put("evil:latest", "sha256:../../host-target", &source_dir)
1789 .await
1790 .unwrap_err();
1791
1792 assert!(error.to_string().contains("malformed content digest"));
1793 assert_eq!(
1794 std::fs::read(host_dir.join("keep.txt")).unwrap(),
1795 b"host data"
1796 );
1797 assert!(store.list().await.is_empty());
1798 }
1799
1800 #[cfg(unix)]
1801 #[tokio::test]
1802 async fn put_rejects_source_symlink_without_copying_target() {
1803 use std::os::unix::fs::symlink;
1804
1805 let tmp = TempDir::new().unwrap();
1806 let store_dir = tmp.path().join("store");
1807 let real_source = tmp.path().join("real-source");
1808 let source_link = tmp.path().join("source-link");
1809 create_test_oci_layout(&real_source);
1810 symlink(&real_source, &source_link).unwrap();
1811 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1812
1813 let error = store
1814 .put(
1815 "evil:latest",
1816 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1817 &source_link,
1818 )
1819 .await
1820 .unwrap_err();
1821
1822 assert!(error.to_string().contains("symbolic link"));
1823 assert!(store.list().await.is_empty());
1824 assert!(real_source.join("index.json").is_file());
1825 }
1826
1827 #[cfg(unix)]
1828 #[tokio::test]
1829 async fn put_rejects_extra_symlink_and_preserves_its_target() {
1830 use std::os::unix::fs::symlink;
1831
1832 let tmp = TempDir::new().unwrap();
1833 let store_dir = tmp.path().join("store");
1834 let source_dir = tmp.path().join("source");
1835 let host_file = tmp.path().join("host-secret.txt");
1836 create_test_oci_layout(&source_dir);
1837 std::fs::write(&host_file, b"secret").unwrap();
1838 symlink(&host_file, source_dir.join("extra-blob")).unwrap();
1839 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1840
1841 assert!(store
1842 .put(
1843 "evil:latest",
1844 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1845 &source_dir,
1846 )
1847 .await
1848 .is_err());
1849 assert_eq!(std::fs::read(&host_file).unwrap(), b"secret");
1850 assert!(store.list().await.is_empty());
1851 }
1852
1853 #[cfg(windows)]
1854 #[tokio::test]
1855 async fn put_rejects_windows_source_reparse_point() {
1856 let tmp = TempDir::new().unwrap();
1857 let store_dir = tmp.path().join("store");
1858 let source_dir = tmp.path().join("source");
1859 let host_file = tmp.path().join("host-secret.txt");
1860 let link = source_dir.join("extra-blob");
1861 create_test_oci_layout(&source_dir);
1862 std::fs::write(&host_file, b"secret").unwrap();
1863 let guard = a3s_box_core::windows_symlink::WindowsSymlinkPrivilegeGuard::acquire();
1864 let assigned_privilege_enabled = guard.assigned_privilege_enabled();
1865 match std::os::windows::fs::symlink_file(&host_file, &link) {
1866 Ok(()) => {}
1867 Err(error)
1868 if a3s_box_core::windows_symlink::is_capability_denial(
1869 &error,
1870 assigned_privilege_enabled,
1871 ) =>
1872 {
1873 return
1874 }
1875 Err(error) => panic!("failed to create Windows test symlink: {error}"),
1876 }
1877 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1878
1879 assert!(store
1880 .put(
1881 "evil:latest",
1882 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1883 &source_dir,
1884 )
1885 .await
1886 .is_err());
1887 assert_eq!(std::fs::read(&host_file).unwrap(), b"secret");
1888 assert!(store.list().await.is_empty());
1889 }
1890
1891 #[tokio::test]
1892 async fn load_index_rejects_forged_deletion_path() {
1893 let tmp = TempDir::new().unwrap();
1894 let store_dir = tmp.path().join("store");
1895 let victim = tmp.path().join("host-victim");
1896 std::fs::create_dir_all(&store_dir).unwrap();
1897 std::fs::create_dir_all(&victim).unwrap();
1898 std::fs::write(victim.join("keep.txt"), b"keep").unwrap();
1899
1900 let forged = stored_image(
1901 "evil:latest",
1902 "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
1903 victim.clone(),
1904 );
1905 let index = StoreIndex {
1906 images: vec![forged],
1907 };
1908 std::fs::write(
1909 store_dir.join("index.json"),
1910 serde_json::to_vec_pretty(&index).unwrap(),
1911 )
1912 .unwrap();
1913
1914 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1915 assert!(store.list().await.is_empty());
1916 assert!(store.remove("evil:latest").await.is_err());
1917 assert_eq!(std::fs::read(victim.join("keep.txt")).unwrap(), b"keep");
1918 }
1919
1920 #[tokio::test]
1921 async fn load_index_rederives_path_and_never_uses_forged_spelling() {
1922 let tmp = TempDir::new().unwrap();
1923 let store_dir = tmp.path().join("store");
1924 let digest_hex = "d".repeat(64);
1925 let digest = format!("sha256:{digest_hex}");
1926 let expected = store_dir.join("sha256").join(&digest_hex);
1927 let victim = tmp.path().join("host-victim");
1928 create_test_oci_layout(&expected);
1929 std::fs::create_dir_all(&victim).unwrap();
1930 std::fs::write(victim.join("keep.txt"), b"keep").unwrap();
1931
1932 let forged = stored_image("safe:latest", &digest, victim.clone());
1936 let index = StoreIndex {
1937 images: vec![forged],
1938 };
1939 std::fs::write(
1940 store_dir.join("index.json"),
1941 serde_json::to_vec_pretty(&index).unwrap(),
1942 )
1943 .unwrap();
1944
1945 let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1946 let loaded = store.get("safe:latest").await.unwrap();
1947 assert_eq!(loaded.path, expected);
1948
1949 store.remove("safe:latest").await.unwrap();
1950 assert!(!expected.exists());
1951 assert_eq!(std::fs::read(victim.join("keep.txt")).unwrap(), b"keep");
1952 }
1953}