1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::Arc;
11
12use a3s_box_core::error::{BoxError, Result};
13use a3s_box_core::{ImageStoreBackend, StoredImage};
14use chrono::Utc;
15use serde::{Deserialize, Serialize};
16use tokio::sync::RwLock;
17
18static PUT_SEQ: AtomicU64 = AtomicU64::new(0);
20
21#[derive(Debug, Default, Serialize, Deserialize)]
23struct StoreIndex {
24 images: Vec<StoredImage>,
25}
26
27pub struct ImageStore {
29 store_dir: PathBuf,
31 index: Arc<RwLock<HashMap<String, StoredImage>>>,
33 max_size_bytes: u64,
35}
36
37impl ImageStore {
38 pub fn new(store_dir: &Path, max_size_bytes: u64) -> Result<Self> {
43 std::fs::create_dir_all(store_dir).map_err(|e| {
44 BoxError::OciImageError(format!(
45 "Failed to create image store directory {}: {}",
46 store_dir.display(),
47 e
48 ))
49 })?;
50
51 let tmp_dir = store_dir.join("tmp");
54 if tmp_dir.is_dir() {
55 if let Err(e) = std::fs::remove_dir_all(&tmp_dir) {
56 tracing::debug!(path = %tmp_dir.display(), error = %e, "Failed to sweep image store tmp dir");
57 }
58 }
59
60 let mut store = Self {
61 store_dir: store_dir.to_path_buf(),
62 index: Arc::new(RwLock::new(HashMap::new())),
63 max_size_bytes,
64 };
65
66 store.load_index()?;
67 Ok(store)
68 }
69
70 pub async fn get(&self, reference: &str) -> Option<StoredImage> {
72 let mut index = self.index.write().await;
73 if let Some(image) = index.get_mut(reference) {
74 image.last_used = Utc::now();
75 let updated = image.clone();
76 drop(index);
77 if let Err(e) = self.save_index_inner().await {
79 tracing::warn!(error = %e, "Failed to persist image store index (last_used may be stale)");
80 }
81 Some(updated)
82 } else {
83 None
84 }
85 }
86
87 pub async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
89 let mut index = self.index.write().await;
90 let found = index.values_mut().find(|img| img.digest == digest);
91 if let Some(image) = found {
92 image.last_used = Utc::now();
93 let updated = image.clone();
94 drop(index);
95 if let Err(e) = self.save_index_inner().await {
96 tracing::warn!(error = %e, "Failed to persist image store index (last_used may be stale)");
97 }
98 Some(updated)
99 } else {
100 None
101 }
102 }
103
104 pub async fn resolve(&self, image: &str) -> Option<StoredImage> {
110 if let Some(found) = self.get(image).await {
111 return Some(found);
112 }
113 let digest_part = image.rsplit_once('@').map_or(image, |(_, digest)| digest);
114 if let Some(found) = self.get_by_digest(digest_part).await {
115 return Some(found);
116 }
117 match super::ImageReference::parse(image) {
118 Ok(parsed) => self.get(&parsed.full_reference()).await,
119 Err(_) => None,
120 }
121 }
122
123 pub async fn put(
128 &self,
129 reference: &str,
130 digest: &str,
131 source_dir: &Path,
132 ) -> Result<StoredImage> {
133 let digest_hex = digest.strip_prefix("sha256:").unwrap_or(digest);
135 let target_dir = self.store_dir.join("sha256").join(digest_hex);
136
137 if !target_dir.exists() {
143 let seq = PUT_SEQ.fetch_add(1, Ordering::Relaxed);
144 let staging = self.store_dir.join("sha256").join(format!(
145 ".staging-{}-{}-{}",
146 digest_hex,
147 std::process::id(),
148 seq
149 ));
150 let _ = std::fs::remove_dir_all(&staging);
151 copy_dir_recursive(source_dir, &staging).map_err(|e| {
152 let _ = std::fs::remove_dir_all(&staging);
153 BoxError::OciImageError(format!("Failed to copy image to store: {}", e))
154 })?;
155 if let Err(e) = std::fs::rename(&staging, &target_dir) {
156 let _ = std::fs::remove_dir_all(&staging);
157 if !target_dir.exists() {
161 return Err(BoxError::OciImageError(format!(
162 "Failed to publish image to store: {}",
163 e
164 )));
165 }
166 }
167 }
168
169 let size_bytes = dir_size(&target_dir);
170 let now = Utc::now();
171
172 let stored = StoredImage {
173 reference: reference.to_string(),
174 digest: digest.to_string(),
175 size_bytes,
176 pulled_at: now,
177 last_used: now,
178 path: target_dir,
179 };
180
181 self.with_index_lock(|index| {
182 if let Some(old) = index.get(reference).cloned() {
189 if old.digest != digest {
190 let still_referenced = index
191 .iter()
192 .any(|(key, img)| key.as_str() != reference && img.digest == old.digest);
193 if !still_referenced && !index.contains_key(&old.digest) {
194 let mut dangling = old.clone();
195 dangling.reference = old.digest.clone();
196 index.insert(old.digest.clone(), dangling);
197 }
198 }
199 }
200
201 index.insert(reference.to_string(), stored.clone());
202 Ok(())
203 })
204 .await?;
205
206 Ok(stored)
207 }
208
209 pub async fn remove(&self, image: &str) -> Result<()> {
217 self.with_index_lock(|index| {
218 let keys: Vec<String> = if index.contains_key(image) {
221 vec![image.to_string()]
222 } else {
223 index
224 .values()
225 .filter(|img| img.digest == image)
226 .map(|img| img.reference.clone())
227 .collect()
228 };
229
230 if keys.is_empty() {
231 return Err(BoxError::OciImageError(format!(
232 "Image not found: {}",
233 image
234 )));
235 }
236
237 let removed: Vec<StoredImage> = keys.iter().filter_map(|k| index.remove(k)).collect();
238
239 for img in removed {
243 let digest_still_used = index.values().any(|other| other.digest == img.digest);
244 if !digest_still_used && img.path.exists() {
245 std::fs::remove_dir_all(&img.path).map_err(|e| {
246 BoxError::OciImageError(format!(
247 "Failed to remove image directory {}: {}",
248 img.path.display(),
249 e
250 ))
251 })?;
252 }
253 }
254 Ok(())
255 })
256 .await
257 }
258
259 pub async fn list(&self) -> Vec<StoredImage> {
261 let index = self.index.read().await;
262 index.values().cloned().collect()
263 }
264
265 pub async fn evict(&self) -> Result<Vec<String>> {
269 let mut evicted = Vec::new();
270 let mut total = self.total_size().await;
271
272 while total > self.max_size_bytes {
273 let lru_ref = {
275 let index = self.index.read().await;
276 index
277 .values()
278 .min_by_key(|img| img.last_used)
279 .map(|img| img.reference.clone())
280 };
281
282 match lru_ref {
283 Some(reference) => {
284 self.remove(&reference).await?;
285 evicted.push(reference);
286 total = self.total_size().await;
287 }
288 None => break,
289 }
290 }
291
292 Ok(evicted)
293 }
294
295 pub async fn total_size(&self) -> u64 {
297 let index = self.index.read().await;
298 index.values().map(|img| img.size_bytes).sum()
299 }
300
301 fn load_index(&mut self) -> Result<()> {
303 self.index = Arc::new(RwLock::new(self.read_index_from_disk()?));
305 Ok(())
306 }
307
308 fn read_index_from_disk(&self) -> Result<HashMap<String, StoredImage>> {
311 let index_path = self.store_dir.join("index.json");
312 if !index_path.exists() {
313 return Ok(HashMap::new());
314 }
315
316 let data = std::fs::read_to_string(&index_path).map_err(|e| {
317 BoxError::OciImageError(format!(
318 "Failed to read image store index {}: {}",
319 index_path.display(),
320 e
321 ))
322 })?;
323
324 let store_index: StoreIndex = serde_json::from_str(&data).map_err(|e| {
325 BoxError::OciImageError(format!("Failed to parse image store index: {}", e))
326 })?;
327
328 let mut index = HashMap::new();
329 for image in store_index.images {
330 if image.path.exists() {
331 index.insert(image.reference.clone(), image);
332 }
333 }
334 Ok(index)
335 }
336
337 async fn with_index_lock<F, R>(&self, f: F) -> Result<R>
346 where
347 F: FnOnce(&mut HashMap<String, StoredImage>) -> Result<R>,
348 {
349 let index_path = self.store_dir.join("index.json");
350 let _lock = {
351 let p = index_path.clone();
352 tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&p))
353 .await
354 .map_err(|e| BoxError::OciImageError(format!("index lock task failed: {e}")))?
355 .map_err(|e| {
356 BoxError::OciImageError(format!(
357 "failed to lock image index {}: {e}",
358 index_path.display()
359 ))
360 })?
361 };
362 let fresh = self.read_index_from_disk()?;
364 let result = {
365 let mut idx = self.index.write().await;
366 *idx = fresh;
367 f(&mut idx)?
368 };
369 self.save_index_inner().await?;
370 Ok(result)
371 }
372
373 async fn save_index_inner(&self) -> Result<()> {
375 let index = self.index.read().await;
376 let store_index = StoreIndex {
377 images: index.values().cloned().collect(),
378 };
379 drop(index);
380
381 let data = serde_json::to_string_pretty(&store_index)?;
382 let index_path = self.store_dir.join("index.json");
383 let tmp_path = self.store_dir.join("index.json.tmp");
388 tokio::fs::write(&tmp_path, data).await.map_err(|e| {
389 BoxError::OciImageError(format!(
390 "Failed to write image store index {}: {}",
391 tmp_path.display(),
392 e
393 ))
394 })?;
395 tokio::fs::rename(&tmp_path, &index_path)
396 .await
397 .map_err(|e| {
398 BoxError::OciImageError(format!(
399 "Failed to commit image store index {}: {}",
400 index_path.display(),
401 e
402 ))
403 })?;
404
405 Ok(())
406 }
407
408 pub fn store_dir(&self) -> &Path {
410 &self.store_dir
411 }
412}
413
414#[async_trait::async_trait]
415impl ImageStoreBackend for ImageStore {
416 async fn get(&self, reference: &str) -> Option<StoredImage> {
417 self.get(reference).await
418 }
419
420 async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
421 self.get_by_digest(digest).await
422 }
423
424 async fn put(&self, reference: &str, digest: &str, source_dir: &Path) -> Result<StoredImage> {
425 self.put(reference, digest, source_dir).await
426 }
427
428 async fn remove(&self, reference: &str) -> Result<()> {
429 self.remove(reference).await
430 }
431
432 async fn list(&self) -> Vec<StoredImage> {
433 self.list().await
434 }
435
436 async fn evict(&self) -> Result<Vec<String>> {
437 self.evict().await
438 }
439
440 async fn total_size(&self) -> u64 {
441 self.total_size().await
442 }
443}
444
445fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
447 std::fs::create_dir_all(dst)?;
448 for entry in std::fs::read_dir(src)? {
449 let entry = entry?;
450 let src_path = entry.path();
451 let dst_path = dst.join(entry.file_name());
452 if src_path.is_dir() {
453 copy_dir_recursive(&src_path, &dst_path)?;
454 } else {
455 std::fs::copy(&src_path, &dst_path)?;
456 }
457 }
458 Ok(())
459}
460
461fn dir_size(path: &Path) -> u64 {
463 let mut total = 0;
464 if let Ok(entries) = std::fs::read_dir(path) {
465 for entry in entries.flatten() {
466 let path = entry.path();
467 if path.is_dir() {
468 total += dir_size(&path);
469 } else if let Ok(meta) = path.metadata() {
470 total += meta.len();
471 }
472 }
473 }
474 total
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use tempfile::TempDir;
481
482 fn create_test_oci_layout(dir: &Path) {
483 std::fs::create_dir_all(dir.join("blobs/sha256")).unwrap();
484 std::fs::write(dir.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
485 std::fs::write(dir.join("index.json"), r#"{"manifests":[]}"#).unwrap();
486 std::fs::write(dir.join("blobs/sha256/testblob"), "x".repeat(1024)).unwrap();
488 }
489
490 #[tokio::test]
491 async fn test_new_creates_directory() {
492 let tmp = TempDir::new().unwrap();
493 let store_dir = tmp.path().join("images");
494 let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
495 assert!(store_dir.exists());
496 assert_eq!(store.total_size().await, 0);
497 }
498
499 #[tokio::test]
500 async fn test_put_and_get() {
501 let tmp = TempDir::new().unwrap();
502 let store_dir = tmp.path().join("store");
503 let source_dir = tmp.path().join("source");
504 create_test_oci_layout(&source_dir);
505
506 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
507
508 let stored = store
509 .put("nginx:latest", "sha256:abc123", &source_dir)
510 .await
511 .unwrap();
512
513 assert_eq!(stored.reference, "nginx:latest");
514 assert_eq!(stored.digest, "sha256:abc123");
515 assert!(stored.size_bytes > 0);
516 assert!(stored.path.exists());
517
518 let fetched = store.get("nginx:latest").await.unwrap();
520 assert_eq!(fetched.digest, "sha256:abc123");
521
522 let fetched = store.get_by_digest("sha256:abc123").await.unwrap();
524 assert_eq!(fetched.reference, "nginx:latest");
525 }
526
527 #[tokio::test]
528 async fn test_get_nonexistent() {
529 let tmp = TempDir::new().unwrap();
530 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
531 assert!(store.get("nonexistent").await.is_none());
532 }
533
534 #[tokio::test]
535 async fn test_remove() {
536 let tmp = TempDir::new().unwrap();
537 let store_dir = tmp.path().join("store");
538 let source_dir = tmp.path().join("source");
539 create_test_oci_layout(&source_dir);
540
541 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
542 store
543 .put("nginx:latest", "sha256:abc123", &source_dir)
544 .await
545 .unwrap();
546
547 store.remove("nginx:latest").await.unwrap();
548 assert!(store.get("nginx:latest").await.is_none());
549 }
550
551 #[tokio::test]
552 async fn test_retag_keeps_displaced_image_as_dangling() {
553 let tmp = TempDir::new().unwrap();
556 let store_dir = tmp.path().join("store");
557 let source_dir = tmp.path().join("source");
558 create_test_oci_layout(&source_dir);
559
560 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
561 store
562 .put("app:latest", "sha256:old", &source_dir)
563 .await
564 .unwrap();
565 store
566 .put("app:latest", "sha256:new", &source_dir)
567 .await
568 .unwrap();
569
570 assert_eq!(store.get("app:latest").await.unwrap().digest, "sha256:new");
572 let dangling = store.get("sha256:old").await.unwrap();
574 assert_eq!(dangling.digest, "sha256:old");
575 assert_eq!(store.list().await.len(), 2);
576 }
577
578 #[tokio::test]
579 async fn test_reput_same_digest_does_not_create_dangling() {
580 let tmp = TempDir::new().unwrap();
583 let store_dir = tmp.path().join("store");
584 let source_dir = tmp.path().join("source");
585 create_test_oci_layout(&source_dir);
586
587 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
588 store
589 .put("app:latest", "sha256:same", &source_dir)
590 .await
591 .unwrap();
592 store
593 .put("app:latest", "sha256:same", &source_dir)
594 .await
595 .unwrap();
596
597 assert_eq!(store.list().await.len(), 1);
598 }
599
600 #[tokio::test]
601 async fn test_remove_by_digest() {
602 let tmp = TempDir::new().unwrap();
605 let store_dir = tmp.path().join("store");
606 let source_dir = tmp.path().join("source");
607 create_test_oci_layout(&source_dir);
608
609 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
610 let stored = store
611 .put("gcr.io/test/img:test", "sha256:deadbeef", &source_dir)
612 .await
613 .unwrap();
614 let path = stored.path.clone();
615
616 store.remove("sha256:deadbeef").await.unwrap();
617 assert!(store.get("gcr.io/test/img:test").await.is_none());
618 assert!(store.get_by_digest("sha256:deadbeef").await.is_none());
619 assert!(!path.exists(), "on-disk layout should be deleted");
620 }
621
622 #[tokio::test]
623 async fn test_remove_by_digest_removes_all_tags() {
624 let tmp = TempDir::new().unwrap();
627 let store_dir = tmp.path().join("store");
628 let source_dir = tmp.path().join("source");
629 create_test_oci_layout(&source_dir);
630
631 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
632 store
633 .put("img:v1", "sha256:shared", &source_dir)
634 .await
635 .unwrap();
636 let stored = store
637 .put("img:latest", "sha256:shared", &source_dir)
638 .await
639 .unwrap();
640 let path = stored.path.clone();
641
642 store.remove("sha256:shared").await.unwrap();
643 assert!(store.get("img:v1").await.is_none());
644 assert!(store.get("img:latest").await.is_none());
645 assert!(!path.exists(), "shared layout should be deleted");
646 }
647
648 #[tokio::test]
649 async fn test_resolve_by_name_digest_and_normalized() {
650 let tmp = TempDir::new().unwrap();
651 let store_dir = tmp.path().join("store");
652 let source_dir = tmp.path().join("source");
653 create_test_oci_layout(&source_dir);
654
655 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
656 store
657 .put(
658 "gcr.io/x/test-image-predefined-group:latest",
659 "sha256:grp",
660 &source_dir,
661 )
662 .await
663 .unwrap();
664
665 assert!(store
667 .resolve("gcr.io/x/test-image-predefined-group:latest")
668 .await
669 .is_some());
670 assert_eq!(
672 store
673 .resolve("gcr.io/x/test-image-predefined-group")
674 .await
675 .map(|i| i.digest),
676 Some("sha256:grp".to_string())
677 );
678 assert!(store.resolve("sha256:grp").await.is_some());
680 assert!(store
681 .resolve("gcr.io/x/test-image-predefined-group@sha256:grp")
682 .await
683 .is_some());
684 assert!(store.resolve("nope:latest").await.is_none());
686 }
687
688 #[tokio::test]
689 async fn test_remove_nonexistent() {
690 let tmp = TempDir::new().unwrap();
691 let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
692 assert!(store.remove("nonexistent").await.is_err());
693 }
694
695 #[tokio::test]
696 async fn test_list() {
697 let tmp = TempDir::new().unwrap();
698 let store_dir = tmp.path().join("store");
699 let source_dir = tmp.path().join("source");
700 create_test_oci_layout(&source_dir);
701
702 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
703 store
704 .put("nginx:latest", "sha256:aaa", &source_dir)
705 .await
706 .unwrap();
707 store
708 .put("alpine:3.18", "sha256:bbb", &source_dir)
709 .await
710 .unwrap();
711
712 let images = store.list().await;
713 assert_eq!(images.len(), 2);
714 }
715
716 #[tokio::test]
717 async fn test_total_size() {
718 let tmp = TempDir::new().unwrap();
719 let store_dir = tmp.path().join("store");
720 let source_dir = tmp.path().join("source");
721 create_test_oci_layout(&source_dir);
722
723 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
724 store
725 .put("nginx:latest", "sha256:aaa", &source_dir)
726 .await
727 .unwrap();
728
729 assert!(store.total_size().await > 0);
730 }
731
732 #[tokio::test]
733 async fn test_lru_eviction() {
734 let tmp = TempDir::new().unwrap();
735 let store_dir = tmp.path().join("store");
736 let source_dir = tmp.path().join("source");
737 create_test_oci_layout(&source_dir);
738
739 let store = ImageStore::new(&store_dir, 100).unwrap();
741
742 store
743 .put("old:v1", "sha256:old1", &source_dir)
744 .await
745 .unwrap();
746
747 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
749
750 store
751 .put("new:v2", "sha256:new2", &source_dir)
752 .await
753 .unwrap();
754
755 store.get("new:v2").await;
757
758 let evicted = store.evict().await.unwrap();
759 assert!(!evicted.is_empty());
761 assert!(evicted.contains(&"old:v1".to_string()));
762 }
763
764 #[tokio::test]
765 async fn test_index_persistence() {
766 let tmp = TempDir::new().unwrap();
767 let store_dir = tmp.path().join("store");
768 let source_dir = tmp.path().join("source");
769 create_test_oci_layout(&source_dir);
770
771 {
773 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
774 store
775 .put("nginx:latest", "sha256:persist", &source_dir)
776 .await
777 .unwrap();
778 }
779
780 {
782 let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
783 let image = store.get("nginx:latest").await;
784 assert!(image.is_some());
785 assert_eq!(image.unwrap().digest, "sha256:persist");
786 }
787 }
788
789 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
790 async fn concurrent_cross_instance_puts_persist_both() {
791 use std::collections::HashSet;
792 use std::sync::Arc;
793
794 let tmp = TempDir::new().unwrap();
795 let store_dir = tmp.path().join("store");
796 let source_dir = tmp.path().join("source");
797 create_test_oci_layout(&source_dir);
798
799 let s1 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
804 let s2 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
805 let (src1, src2) = (source_dir.clone(), source_dir.clone());
806 let h1 = {
807 let s1 = Arc::clone(&s1);
808 tokio::spawn(async move { s1.put("img:a", "sha256:aaaa", &src1).await.unwrap() })
809 };
810 let h2 = {
811 let s2 = Arc::clone(&s2);
812 tokio::spawn(async move { s2.put("img:b", "sha256:bbbb", &src2).await.unwrap() })
813 };
814 h1.await.unwrap();
815 h2.await.unwrap();
816
817 let s3 = ImageStore::new(&store_dir, u64::MAX).unwrap();
819 let refs: HashSet<String> = s3.list().await.into_iter().map(|i| i.reference).collect();
820 assert!(refs.contains("img:a"), "img:a lost: {refs:?}");
821 assert!(refs.contains("img:b"), "img:b lost: {refs:?}");
822 }
823}