Skip to main content

a3s_box_runtime/oci/
store.rs

1//! Disk-based OCI image store with LRU eviction.
2//!
3//! Stores pulled OCI images on disk with an in-memory index backed by
4//! a persistent `index.json` file. Supports LRU eviction when the store
5//! exceeds a configured maximum size.
6
7use 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
18/// Per-process counter for unique staging-dir names in `put`.
19static PUT_SEQ: AtomicU64 = AtomicU64::new(0);
20
21/// Persistent index stored as JSON on disk.
22#[derive(Debug, Default, Serialize, Deserialize)]
23struct StoreIndex {
24    images: Vec<StoredImage>,
25}
26
27/// Disk-based image store with in-memory index and LRU eviction.
28pub struct ImageStore {
29    /// Root directory for image storage
30    store_dir: PathBuf,
31    /// In-memory index: reference → StoredImage
32    index: Arc<RwLock<HashMap<String, StoredImage>>>,
33    /// Maximum total size in bytes
34    max_size_bytes: u64,
35}
36
37impl ImageStore {
38    /// Create a new image store.
39    ///
40    /// Creates the store directory if it doesn't exist and loads
41    /// any existing index from disk.
42    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 mut store = Self {
52            store_dir: store_dir.to_path_buf(),
53            index: Arc::new(RwLock::new(HashMap::new())),
54            max_size_bytes,
55        };
56
57        store.load_index()?;
58        Ok(store)
59    }
60
61    /// Get a stored image by reference.
62    pub async fn get(&self, reference: &str) -> Option<StoredImage> {
63        let mut index = self.index.write().await;
64        if let Some(image) = index.get_mut(reference) {
65            image.last_used = Utc::now();
66            let updated = image.clone();
67            drop(index);
68            // Best-effort save of updated last_used; log on failure so staleness is visible.
69            if let Err(e) = self.save_index_inner().await {
70                tracing::warn!(error = %e, "Failed to persist image store index (last_used may be stale)");
71            }
72            Some(updated)
73        } else {
74            None
75        }
76    }
77
78    /// Get a stored image by digest.
79    pub async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
80        let mut index = self.index.write().await;
81        let found = index.values_mut().find(|img| img.digest == digest);
82        if let Some(image) = found {
83            image.last_used = Utc::now();
84            let updated = image.clone();
85            drop(index);
86            if let Err(e) = self.save_index_inner().await {
87                tracing::warn!(error = %e, "Failed to persist image store index (last_used may be stale)");
88            }
89            Some(updated)
90        } else {
91            None
92        }
93    }
94
95    /// Resolve an image reference to a stored image.
96    ///
97    /// CRI callers may address an image by an exact stored reference, by its
98    /// image id (a bare `sha256:...` or a `name@sha256:...` digest pin), or by
99    /// an unnormalized name (e.g. a tagless name that defaults to `:latest`).
100    pub async fn resolve(&self, image: &str) -> Option<StoredImage> {
101        if let Some(found) = self.get(image).await {
102            return Some(found);
103        }
104        let digest_part = image.rsplit_once('@').map_or(image, |(_, digest)| digest);
105        if let Some(found) = self.get_by_digest(digest_part).await {
106            return Some(found);
107        }
108        match super::ImageReference::parse(image) {
109            Ok(parsed) => self.get(&parsed.full_reference()).await,
110            Err(_) => None,
111        }
112    }
113
114    /// Store an image from a source directory.
115    ///
116    /// Copies the OCI image layout from `source_dir` into the store
117    /// under `sha256/<digest>/`.
118    pub async fn put(
119        &self,
120        reference: &str,
121        digest: &str,
122        source_dir: &Path,
123    ) -> Result<StoredImage> {
124        // Compute target path from digest
125        let digest_hex = digest.strip_prefix("sha256:").unwrap_or(digest);
126        let target_dir = self.store_dir.join("sha256").join(digest_hex);
127
128        // Copy source to target if not already present. Stage into a unique temp
129        // dir then atomically rename into place, so a concurrent put() for the
130        // same digest — or a copy that fails partway — can never leave a
131        // half-populated content-addressed dir that a later caller mistakes for a
132        // complete image (the bare check-then-copy raced on both counts).
133        if !target_dir.exists() {
134            let seq = PUT_SEQ.fetch_add(1, Ordering::Relaxed);
135            let staging = self.store_dir.join("sha256").join(format!(
136                ".staging-{}-{}-{}",
137                digest_hex,
138                std::process::id(),
139                seq
140            ));
141            let _ = std::fs::remove_dir_all(&staging);
142            copy_dir_recursive(source_dir, &staging).map_err(|e| {
143                let _ = std::fs::remove_dir_all(&staging);
144                BoxError::OciImageError(format!("Failed to copy image to store: {}", e))
145            })?;
146            if let Err(e) = std::fs::rename(&staging, &target_dir) {
147                let _ = std::fs::remove_dir_all(&staging);
148                // A concurrent put() may have populated target_dir first (rename
149                // onto a non-empty dir fails); that's fine — only propagate if the
150                // image still isn't there.
151                if !target_dir.exists() {
152                    return Err(BoxError::OciImageError(format!(
153                        "Failed to publish image to store: {}",
154                        e
155                    )));
156                }
157            }
158        }
159
160        let size_bytes = dir_size(&target_dir);
161        let now = Utc::now();
162
163        let stored = StoredImage {
164            reference: reference.to_string(),
165            digest: digest.to_string(),
166            size_bytes,
167            pulled_at: now,
168            last_used: now,
169            path: target_dir,
170        };
171
172        self.with_index_lock(|index| {
173            // Docker parity: if this reference already points at a DIFFERENT
174            // digest and that old digest is about to lose its last reference,
175            // keep the displaced image as a dangling entry (keyed by its digest)
176            // instead of dropping it. This makes a rebuilt/re-tagged image show
177            // up as `<none>` in `images`, be removable by `image prune`, and
178            // prevents silently orphaning its on-disk layout.
179            if let Some(old) = index.get(reference).cloned() {
180                if old.digest != digest {
181                    let still_referenced = index
182                        .iter()
183                        .any(|(key, img)| key.as_str() != reference && img.digest == old.digest);
184                    if !still_referenced && !index.contains_key(&old.digest) {
185                        let mut dangling = old.clone();
186                        dangling.reference = old.digest.clone();
187                        index.insert(old.digest.clone(), dangling);
188                    }
189                }
190            }
191
192            index.insert(reference.to_string(), stored.clone());
193            Ok(())
194        })
195        .await?;
196
197        Ok(stored)
198    }
199
200    /// Remove an image by reference or by image ID (digest).
201    ///
202    /// The CRI `RemoveImage` may identify an image either by a repo
203    /// reference/tag or by its image ID (`sha256:<digest>`, as returned in
204    /// `ImageStatus`). When `image` does not match a stored reference key,
205    /// fall back to removing every reference that points at the matching
206    /// digest.
207    pub async fn remove(&self, image: &str) -> Result<()> {
208        self.with_index_lock(|index| {
209            // Resolve the reference keys to remove: the exact reference if it is
210            // a known key, otherwise every key sharing the requested digest.
211            let keys: Vec<String> = if index.contains_key(image) {
212                vec![image.to_string()]
213            } else {
214                index
215                    .values()
216                    .filter(|img| img.digest == image)
217                    .map(|img| img.reference.clone())
218                    .collect()
219            };
220
221            if keys.is_empty() {
222                return Err(BoxError::OciImageError(format!(
223                    "Image not found: {}",
224                    image
225                )));
226            }
227
228            let removed: Vec<StoredImage> = keys.iter().filter_map(|k| index.remove(k)).collect();
229
230            // Delete each image's on-disk layout once no remaining reference
231            // points at the same digest. References sharing a digest share the
232            // same directory, so the `path.exists()` guard makes this idempotent.
233            for img in removed {
234                let digest_still_used = index.values().any(|other| other.digest == img.digest);
235                if !digest_still_used && img.path.exists() {
236                    std::fs::remove_dir_all(&img.path).map_err(|e| {
237                        BoxError::OciImageError(format!(
238                            "Failed to remove image directory {}: {}",
239                            img.path.display(),
240                            e
241                        ))
242                    })?;
243                }
244            }
245            Ok(())
246        })
247        .await
248    }
249
250    /// List all stored images.
251    pub async fn list(&self) -> Vec<StoredImage> {
252        let index = self.index.read().await;
253        index.values().cloned().collect()
254    }
255
256    /// Evict least-recently-used images until total size is under the limit.
257    ///
258    /// Returns the references of evicted images.
259    pub async fn evict(&self) -> Result<Vec<String>> {
260        let mut evicted = Vec::new();
261        let mut total = self.total_size().await;
262
263        while total > self.max_size_bytes {
264            // Find the least recently used image
265            let lru_ref = {
266                let index = self.index.read().await;
267                index
268                    .values()
269                    .min_by_key(|img| img.last_used)
270                    .map(|img| img.reference.clone())
271            };
272
273            match lru_ref {
274                Some(reference) => {
275                    self.remove(&reference).await?;
276                    evicted.push(reference);
277                    total = self.total_size().await;
278                }
279                None => break,
280            }
281        }
282
283        Ok(evicted)
284    }
285
286    /// Get total size of all stored images in bytes.
287    pub async fn total_size(&self) -> u64 {
288        let index = self.index.read().await;
289        index.values().map(|img| img.size_bytes).sum()
290    }
291
292    /// Load index from disk.
293    fn load_index(&mut self) -> Result<()> {
294        // Construction-time load; reuse the shared disk reader.
295        self.index = Arc::new(RwLock::new(self.read_index_from_disk()?));
296        Ok(())
297    }
298
299    /// Read and parse `index.json` from disk into a fresh map (entries whose
300    /// content dir vanished are dropped). Does NOT touch `self.index`.
301    fn read_index_from_disk(&self) -> Result<HashMap<String, StoredImage>> {
302        let index_path = self.store_dir.join("index.json");
303        if !index_path.exists() {
304            return Ok(HashMap::new());
305        }
306
307        let data = std::fs::read_to_string(&index_path).map_err(|e| {
308            BoxError::OciImageError(format!(
309                "Failed to read image store index {}: {}",
310                index_path.display(),
311                e
312            ))
313        })?;
314
315        // Parse resiliently so a corrupt/old-schema index never bricks the whole
316        // catalog or blocks CRI/CLI startup. First read the `{ images: [...] }`
317        // envelope leniently (entries as raw values); if even that fails the file
318        // is unusable, so quarantine it and start from an empty catalog that
319        // re-pulls repopulate. Then deserialize each entry independently, skipping
320        // (not failing on) any one corrupt/incompatible record.
321        #[derive(serde::Deserialize)]
322        struct RawIndex {
323            #[serde(default)]
324            images: Vec<serde_json::Value>,
325        }
326
327        let raw: RawIndex = match serde_json::from_str(&data) {
328            Ok(raw) => raw,
329            Err(err) => {
330                let preserved = crate::store_io::quarantine_label(&index_path);
331                tracing::warn!(
332                    "image store index {} is corrupt ({err}); preserved a copy at \
333                     {preserved} and started from an empty catalog (re-pulled images \
334                     will repopulate it)",
335                    index_path.display(),
336                );
337                return Ok(HashMap::new());
338            }
339        };
340
341        let mut index = HashMap::new();
342        let mut skipped = 0usize;
343        for value in raw.images {
344            match serde_json::from_value::<StoredImage>(value) {
345                Ok(image) => {
346                    if image.path.exists() {
347                        index.insert(image.reference.clone(), image);
348                    }
349                }
350                Err(err) => {
351                    skipped += 1;
352                    tracing::warn!("skipping unreadable image index entry ({err})");
353                }
354            }
355        }
356        if skipped > 0 {
357            // Preserve the original (with the un-deserializable entries) before the
358            // next save rewrites index.json with only the survivors — otherwise the
359            // skipped records are erased with no backup, unlike the whole-file path.
360            let preserved = crate::store_io::quarantine_copy(&index_path)
361                .map(|p| p.display().to_string())
362                .unwrap_or_else(|| "<backup failed>".to_string());
363            tracing::warn!(
364                "{skipped} image index entr{} skipped as unreadable; preserved a copy at \
365                 {preserved}; affected images will be re-pulled on demand",
366                if skipped == 1 { "y" } else { "ies" },
367            );
368        }
369        Ok(index)
370    }
371
372    /// Apply `f` to the image index under the **cross-process write lock**:
373    /// reload `index.json` from disk (so this process observes other processes'
374    /// pulls/removes), let `f` mutate the map, then save. Without this, two
375    /// processes pulling concurrently each load their own snapshot and the
376    /// second `save` drops the first's entry (and leaks its content dir).
377    ///
378    /// The blocking `flock` is acquired off the runtime via `spawn_blocking`;
379    /// `save_index_inner` is lock-free, so there is no re-entrant `flock`.
380    async fn with_index_lock<F, R>(&self, f: F) -> Result<R>
381    where
382        F: FnOnce(&mut HashMap<String, StoredImage>) -> Result<R>,
383    {
384        let index_path = self.store_dir.join("index.json");
385        let _lock = {
386            let p = index_path.clone();
387            tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&p))
388                .await
389                .map_err(|e| BoxError::OciImageError(format!("index lock task failed: {e}")))?
390                .map_err(|e| {
391                    BoxError::OciImageError(format!(
392                        "failed to lock image index {}: {e}",
393                        index_path.display()
394                    ))
395                })?
396        };
397        // Sync the in-memory index with disk (pick up other processes' writes).
398        let fresh = self.read_index_from_disk()?;
399        let result = {
400            let mut idx = self.index.write().await;
401            *idx = fresh;
402            f(&mut idx)?
403        };
404        self.save_index_inner().await?;
405        Ok(result)
406    }
407
408    /// Save index to disk (async inner helper).
409    async fn save_index_inner(&self) -> Result<()> {
410        let index = self.index.read().await;
411        let store_index = StoreIndex {
412            images: index.values().cloned().collect(),
413        };
414        drop(index);
415
416        let data = serde_json::to_string_pretty(&store_index)?;
417        let index_path = self.store_dir.join("index.json");
418        // Write atomically (tmp + rename) so a concurrent reader (e.g. another
419        // process running `create`/`run`) never observes a truncated/empty
420        // index.json mid-write — which previously surfaced as
421        // "Failed to parse image store index: EOF".
422        let tmp_path = self.store_dir.join("index.json.tmp");
423        tokio::fs::write(&tmp_path, data).await.map_err(|e| {
424            BoxError::OciImageError(format!(
425                "Failed to write image store index {}: {}",
426                tmp_path.display(),
427                e
428            ))
429        })?;
430        tokio::fs::rename(&tmp_path, &index_path)
431            .await
432            .map_err(|e| {
433                BoxError::OciImageError(format!(
434                    "Failed to commit image store index {}: {}",
435                    index_path.display(),
436                    e
437                ))
438            })?;
439
440        Ok(())
441    }
442
443    /// Get the store directory path.
444    pub fn store_dir(&self) -> &Path {
445        &self.store_dir
446    }
447}
448
449#[async_trait::async_trait]
450impl ImageStoreBackend for ImageStore {
451    async fn get(&self, reference: &str) -> Option<StoredImage> {
452        self.get(reference).await
453    }
454
455    async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
456        self.get_by_digest(digest).await
457    }
458
459    async fn put(&self, reference: &str, digest: &str, source_dir: &Path) -> Result<StoredImage> {
460        self.put(reference, digest, source_dir).await
461    }
462
463    async fn remove(&self, reference: &str) -> Result<()> {
464        self.remove(reference).await
465    }
466
467    async fn list(&self) -> Vec<StoredImage> {
468        self.list().await
469    }
470
471    async fn evict(&self) -> Result<Vec<String>> {
472        self.evict().await
473    }
474
475    async fn total_size(&self) -> u64 {
476        self.total_size().await
477    }
478}
479
480/// Recursively copy a directory.
481fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
482    std::fs::create_dir_all(dst)?;
483    for entry in std::fs::read_dir(src)? {
484        let entry = entry?;
485        let src_path = entry.path();
486        let dst_path = dst.join(entry.file_name());
487        if src_path.is_dir() {
488            copy_dir_recursive(&src_path, &dst_path)?;
489        } else {
490            std::fs::copy(&src_path, &dst_path)?;
491        }
492    }
493    Ok(())
494}
495
496/// Calculate total size of a directory recursively.
497fn dir_size(path: &Path) -> u64 {
498    let mut total = 0;
499    if let Ok(entries) = std::fs::read_dir(path) {
500        for entry in entries.flatten() {
501            let path = entry.path();
502            if path.is_dir() {
503                total += dir_size(&path);
504            } else if let Ok(meta) = path.metadata() {
505                total += meta.len();
506            }
507        }
508    }
509    total
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use tempfile::TempDir;
516
517    fn create_test_oci_layout(dir: &Path) {
518        std::fs::create_dir_all(dir.join("blobs/sha256")).unwrap();
519        std::fs::write(dir.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
520        std::fs::write(dir.join("index.json"), r#"{"manifests":[]}"#).unwrap();
521        // Write some blob data to have measurable size
522        std::fs::write(dir.join("blobs/sha256/testblob"), "x".repeat(1024)).unwrap();
523    }
524
525    fn stored_image(reference: &str, digest: &str, path: PathBuf) -> StoredImage {
526        let now = Utc::now();
527        StoredImage {
528            reference: reference.to_string(),
529            digest: digest.to_string(),
530            size_bytes: 1024,
531            pulled_at: now,
532            last_used: now,
533            path,
534        }
535    }
536
537    #[tokio::test]
538    async fn test_new_creates_directory() {
539        let tmp = TempDir::new().unwrap();
540        let store_dir = tmp.path().join("images");
541        let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
542        assert!(store_dir.exists());
543        assert_eq!(store.total_size().await, 0);
544    }
545
546    #[tokio::test]
547    async fn test_new_keeps_existing_tmp_dir_for_concurrent_pulls() {
548        let tmp = TempDir::new().unwrap();
549        let store_dir = tmp.path().join("images");
550        let tmp_dir = store_dir.join("tmp");
551        std::fs::create_dir_all(tmp_dir.join("pull-1")).unwrap();
552        std::fs::write(tmp_dir.join("pull-1/layer"), b"partial").unwrap();
553
554        let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
555
556        assert!(
557            tmp_dir.join("pull-1/layer").exists(),
558            "constructing a store must not delete another process' active pull"
559        );
560        assert_eq!(store.total_size().await, 0);
561    }
562
563    #[tokio::test]
564    async fn test_put_and_get() {
565        let tmp = TempDir::new().unwrap();
566        let store_dir = tmp.path().join("store");
567        let source_dir = tmp.path().join("source");
568        create_test_oci_layout(&source_dir);
569
570        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
571
572        let stored = store
573            .put("nginx:latest", "sha256:abc123", &source_dir)
574            .await
575            .unwrap();
576
577        assert_eq!(stored.reference, "nginx:latest");
578        assert_eq!(stored.digest, "sha256:abc123");
579        assert!(stored.size_bytes > 0);
580        assert!(stored.path.exists());
581
582        // Get by reference
583        let fetched = store.get("nginx:latest").await.unwrap();
584        assert_eq!(fetched.digest, "sha256:abc123");
585
586        // Get by digest
587        let fetched = store.get_by_digest("sha256:abc123").await.unwrap();
588        assert_eq!(fetched.reference, "nginx:latest");
589    }
590
591    #[tokio::test]
592    async fn test_get_nonexistent() {
593        let tmp = TempDir::new().unwrap();
594        let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
595        assert!(store.get("nonexistent").await.is_none());
596    }
597
598    #[tokio::test]
599    async fn test_remove() {
600        let tmp = TempDir::new().unwrap();
601        let store_dir = tmp.path().join("store");
602        let source_dir = tmp.path().join("source");
603        create_test_oci_layout(&source_dir);
604
605        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
606        store
607            .put("nginx:latest", "sha256:abc123", &source_dir)
608            .await
609            .unwrap();
610
611        store.remove("nginx:latest").await.unwrap();
612        assert!(store.get("nginx:latest").await.is_none());
613    }
614
615    #[tokio::test]
616    async fn test_remove_one_tag_keeps_shared_digest_until_last_reference() {
617        let tmp = TempDir::new().unwrap();
618        let store_dir = tmp.path().join("store");
619        let source_dir = tmp.path().join("source");
620        create_test_oci_layout(&source_dir);
621
622        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
623        store
624            .put("img:v1", "sha256:shared", &source_dir)
625            .await
626            .unwrap();
627        let stored = store
628            .put("img:latest", "sha256:shared", &source_dir)
629            .await
630            .unwrap();
631        let path = stored.path.clone();
632
633        store.remove("img:v1").await.unwrap();
634        assert!(store.get("img:v1").await.is_none());
635        assert!(store.get("img:latest").await.is_some());
636        assert!(path.exists(), "shared layout should remain in use");
637
638        store.remove("img:latest").await.unwrap();
639        assert!(!path.exists(), "layout should be removed after final tag");
640    }
641
642    #[tokio::test]
643    async fn test_retag_keeps_displaced_image_as_dangling() {
644        // Docker parity: re-pointing a tag at a new digest leaves the old image
645        // as a dangling entry (keyed by its digest), not silently dropped.
646        let tmp = TempDir::new().unwrap();
647        let store_dir = tmp.path().join("store");
648        let source_dir = tmp.path().join("source");
649        create_test_oci_layout(&source_dir);
650
651        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
652        store
653            .put("app:latest", "sha256:old", &source_dir)
654            .await
655            .unwrap();
656        store
657            .put("app:latest", "sha256:new", &source_dir)
658            .await
659            .unwrap();
660
661        // The tag now resolves to the new digest...
662        assert_eq!(store.get("app:latest").await.unwrap().digest, "sha256:new");
663        // ...and the displaced image survives as a digest-keyed dangling entry.
664        let dangling = store.get("sha256:old").await.unwrap();
665        assert_eq!(dangling.digest, "sha256:old");
666        assert_eq!(store.list().await.len(), 2);
667    }
668
669    #[tokio::test]
670    async fn test_reput_same_digest_does_not_create_dangling() {
671        // Re-putting the same reference at the SAME digest (e.g. pulling latest
672        // when content is unchanged) must not spawn a spurious dangling entry.
673        let tmp = TempDir::new().unwrap();
674        let store_dir = tmp.path().join("store");
675        let source_dir = tmp.path().join("source");
676        create_test_oci_layout(&source_dir);
677
678        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
679        store
680            .put("app:latest", "sha256:same", &source_dir)
681            .await
682            .unwrap();
683        store
684            .put("app:latest", "sha256:same", &source_dir)
685            .await
686            .unwrap();
687
688        assert_eq!(store.list().await.len(), 1);
689    }
690
691    #[tokio::test]
692    async fn test_remove_by_digest() {
693        // CRI RemoveImage identifies the image by its ID (sha256 digest),
694        // not its tag. Removing by digest must drop the reference + layout.
695        let tmp = TempDir::new().unwrap();
696        let store_dir = tmp.path().join("store");
697        let source_dir = tmp.path().join("source");
698        create_test_oci_layout(&source_dir);
699
700        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
701        let stored = store
702            .put("gcr.io/test/img:test", "sha256:deadbeef", &source_dir)
703            .await
704            .unwrap();
705        let path = stored.path.clone();
706
707        store.remove("sha256:deadbeef").await.unwrap();
708        assert!(store.get("gcr.io/test/img:test").await.is_none());
709        assert!(store.get_by_digest("sha256:deadbeef").await.is_none());
710        assert!(!path.exists(), "on-disk layout should be deleted");
711    }
712
713    #[tokio::test]
714    async fn test_remove_by_digest_removes_all_tags() {
715        // Two tags sharing one digest: removing by digest drops both and
716        // deletes the shared layout exactly once.
717        let tmp = TempDir::new().unwrap();
718        let store_dir = tmp.path().join("store");
719        let source_dir = tmp.path().join("source");
720        create_test_oci_layout(&source_dir);
721
722        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
723        store
724            .put("img:v1", "sha256:shared", &source_dir)
725            .await
726            .unwrap();
727        let stored = store
728            .put("img:latest", "sha256:shared", &source_dir)
729            .await
730            .unwrap();
731        let path = stored.path.clone();
732
733        store.remove("sha256:shared").await.unwrap();
734        assert!(store.get("img:v1").await.is_none());
735        assert!(store.get("img:latest").await.is_none());
736        assert!(!path.exists(), "shared layout should be deleted");
737    }
738
739    #[tokio::test]
740    async fn test_resolve_by_name_digest_and_normalized() {
741        let tmp = TempDir::new().unwrap();
742        let store_dir = tmp.path().join("store");
743        let source_dir = tmp.path().join("source");
744        create_test_oci_layout(&source_dir);
745
746        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
747        store
748            .put(
749                "gcr.io/x/test-image-predefined-group:latest",
750                "sha256:grp",
751                &source_dir,
752            )
753            .await
754            .unwrap();
755
756        // Exact reference.
757        assert!(store
758            .resolve("gcr.io/x/test-image-predefined-group:latest")
759            .await
760            .is_some());
761        // Unnormalized name (no tag -> :latest) — the CreateContainer case.
762        assert_eq!(
763            store
764                .resolve("gcr.io/x/test-image-predefined-group")
765                .await
766                .map(|i| i.digest),
767            Some("sha256:grp".to_string())
768        );
769        // Image id (bare digest) and a name@digest pin.
770        assert!(store.resolve("sha256:grp").await.is_some());
771        assert!(store
772            .resolve("gcr.io/x/test-image-predefined-group@sha256:grp")
773            .await
774            .is_some());
775        // Unknown.
776        assert!(store.resolve("nope:latest").await.is_none());
777    }
778
779    #[tokio::test]
780    async fn test_resolve_invalid_reference_returns_none() {
781        let tmp = TempDir::new().unwrap();
782        let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
783
784        assert!(store.resolve("registry.example.com/").await.is_none());
785        assert!(store.resolve("busybox@not-a-digest").await.is_none());
786    }
787
788    #[tokio::test]
789    async fn test_remove_nonexistent() {
790        let tmp = TempDir::new().unwrap();
791        let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
792        assert!(store.remove("nonexistent").await.is_err());
793    }
794
795    #[tokio::test]
796    async fn test_list() {
797        let tmp = TempDir::new().unwrap();
798        let store_dir = tmp.path().join("store");
799        let source_dir = tmp.path().join("source");
800        create_test_oci_layout(&source_dir);
801
802        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
803        store
804            .put("nginx:latest", "sha256:aaa", &source_dir)
805            .await
806            .unwrap();
807        store
808            .put("alpine:3.18", "sha256:bbb", &source_dir)
809            .await
810            .unwrap();
811
812        let images = store.list().await;
813        assert_eq!(images.len(), 2);
814    }
815
816    #[tokio::test]
817    async fn test_total_size() {
818        let tmp = TempDir::new().unwrap();
819        let store_dir = tmp.path().join("store");
820        let source_dir = tmp.path().join("source");
821        create_test_oci_layout(&source_dir);
822
823        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
824        store
825            .put("nginx:latest", "sha256:aaa", &source_dir)
826            .await
827            .unwrap();
828
829        assert!(store.total_size().await > 0);
830    }
831
832    #[tokio::test]
833    async fn test_lru_eviction() {
834        let tmp = TempDir::new().unwrap();
835        let store_dir = tmp.path().join("store");
836        let source_dir = tmp.path().join("source");
837        create_test_oci_layout(&source_dir);
838
839        // Set max size very small to trigger eviction
840        let store = ImageStore::new(&store_dir, 100).unwrap();
841
842        store
843            .put("old:v1", "sha256:old1", &source_dir)
844            .await
845            .unwrap();
846
847        // Sleep briefly so timestamps differ
848        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
849
850        store
851            .put("new:v2", "sha256:new2", &source_dir)
852            .await
853            .unwrap();
854
855        // Access the newer one to update its last_used
856        store.get("new:v2").await;
857
858        let evicted = store.evict().await.unwrap();
859        // At least one image should be evicted (the older one first)
860        assert!(!evicted.is_empty());
861        assert!(evicted.contains(&"old:v1".to_string()));
862    }
863
864    #[tokio::test]
865    async fn test_evict_empty_and_under_limit_returns_empty() {
866        let tmp = TempDir::new().unwrap();
867        let store_dir = tmp.path().join("store");
868        let source_dir = tmp.path().join("source");
869        create_test_oci_layout(&source_dir);
870
871        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
872        assert!(store.evict().await.unwrap().is_empty());
873
874        store
875            .put("tiny:latest", "sha256:tiny", &source_dir)
876            .await
877            .unwrap();
878        assert!(store.evict().await.unwrap().is_empty());
879        assert!(store.get("tiny:latest").await.is_some());
880    }
881
882    #[tokio::test]
883    async fn test_index_persistence() {
884        let tmp = TempDir::new().unwrap();
885        let store_dir = tmp.path().join("store");
886        let source_dir = tmp.path().join("source");
887        create_test_oci_layout(&source_dir);
888
889        // Create store and add image
890        {
891            let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
892            store
893                .put("nginx:latest", "sha256:persist", &source_dir)
894                .await
895                .unwrap();
896        }
897
898        // Create new store from same directory — should load persisted index
899        {
900            let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
901            let image = store.get("nginx:latest").await;
902            assert!(image.is_some());
903            assert_eq!(image.unwrap().digest, "sha256:persist");
904        }
905    }
906
907    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
908    async fn concurrent_cross_instance_puts_persist_both() {
909        use std::collections::HashSet;
910        use std::sync::Arc;
911
912        let tmp = TempDir::new().unwrap();
913        let store_dir = tmp.path().join("store");
914        let source_dir = tmp.path().join("source");
915        create_test_oci_layout(&source_dir);
916
917        // Two ImageStore instances on the SAME dir simulate two processes, each
918        // with its own in-memory index. Concurrent puts of distinct images must
919        // BOTH persist — the lost-update bug dropped one. with_index_lock
920        // reloads under the cross-process lock, so neither overwrites the other.
921        let s1 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
922        let s2 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
923        let (src1, src2) = (source_dir.clone(), source_dir.clone());
924        let h1 = {
925            let s1 = Arc::clone(&s1);
926            tokio::spawn(async move { s1.put("img:a", "sha256:aaaa", &src1).await.unwrap() })
927        };
928        let h2 = {
929            let s2 = Arc::clone(&s2);
930            tokio::spawn(async move { s2.put("img:b", "sha256:bbbb", &src2).await.unwrap() })
931        };
932        h1.await.unwrap();
933        h2.await.unwrap();
934
935        // A fresh instance reads index.json from disk: both must be there.
936        let s3 = ImageStore::new(&store_dir, u64::MAX).unwrap();
937        let refs: HashSet<String> = s3.list().await.into_iter().map(|i| i.reference).collect();
938        assert!(refs.contains("img:a"), "img:a lost: {refs:?}");
939        assert!(refs.contains("img:b"), "img:b lost: {refs:?}");
940    }
941
942    #[tokio::test]
943    async fn load_index_skips_missing_paths_and_unreadable_entries() {
944        let tmp = tempfile::tempdir().unwrap();
945        let store_dir = tmp.path().join("images");
946        let live_path = store_dir.join("sha256/live");
947        let missing_path = store_dir.join("sha256/missing");
948        create_test_oci_layout(&live_path);
949
950        let live = stored_image("live:latest", "sha256:live", live_path);
951        let missing = stored_image("missing:latest", "sha256:missing", missing_path);
952        let index = serde_json::json!({
953            "images": [
954                serde_json::to_value(&live).unwrap(),
955                serde_json::to_value(&missing).unwrap(),
956                {
957                    "reference": "broken:latest",
958                    "digest": false
959                }
960            ]
961        });
962        std::fs::write(
963            store_dir.join("index.json"),
964            serde_json::to_vec_pretty(&index).unwrap(),
965        )
966        .unwrap();
967
968        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
969        let images = store.list().await;
970
971        assert_eq!(images.len(), 1);
972        assert_eq!(images[0].reference, "live:latest");
973        assert!(store.get("missing:latest").await.is_none());
974        assert!(std::fs::read_dir(&store_dir)
975            .unwrap()
976            .filter_map(|entry| entry.ok())
977            .any(|entry| entry
978                .file_name()
979                .to_string_lossy()
980                .contains("index.json.corrupt-")));
981    }
982
983    #[tokio::test]
984    async fn corrupt_index_is_quarantined_not_fatal() {
985        let tmp = tempfile::tempdir().unwrap();
986        let store_dir = tmp.path().join("images");
987        std::fs::create_dir_all(&store_dir).unwrap();
988        std::fs::write(store_dir.join("index.json"), "{ not valid json").unwrap();
989
990        // Construction must SUCCEED (start from an empty catalog) rather than
991        // erroring and blocking CRI/CLI startup on a corrupt/old-schema index.
992        let store = ImageStore::new(&store_dir, u64::MAX)
993            .expect("corrupt index.json must not brick the image store");
994        assert!(
995            store.list().await.is_empty(),
996            "store must start from an empty catalog after quarantine"
997        );
998
999        // The corrupt index is preserved as a timestamped sibling, not lost.
1000        let quarantined = std::fs::read_dir(&store_dir)
1001            .unwrap()
1002            .filter_map(|e| e.ok())
1003            .any(|e| {
1004                e.file_name()
1005                    .to_string_lossy()
1006                    .contains("index.json.corrupt-")
1007            });
1008        assert!(
1009            quarantined,
1010            "corrupt index.json must be quarantined to a sibling"
1011        );
1012    }
1013
1014    #[test]
1015    fn copy_dir_recursive_copies_nested_files_and_dir_size_sums() {
1016        let tmp = TempDir::new().unwrap();
1017        let src = tmp.path().join("src");
1018        let dst = tmp.path().join("dst");
1019        std::fs::create_dir_all(src.join("nested")).unwrap();
1020        std::fs::write(src.join("root.txt"), b"abc").unwrap();
1021        std::fs::write(src.join("nested/leaf.txt"), b"hello").unwrap();
1022
1023        copy_dir_recursive(&src, &dst).unwrap();
1024
1025        assert_eq!(std::fs::read(dst.join("root.txt")).unwrap(), b"abc");
1026        assert_eq!(
1027            std::fs::read(dst.join("nested/leaf.txt")).unwrap(),
1028            b"hello"
1029        );
1030        assert_eq!(dir_size(&dst), 8);
1031        assert_eq!(dir_size(&tmp.path().join("missing")), 0);
1032    }
1033
1034    #[test]
1035    fn copy_dir_recursive_fails_for_missing_source() {
1036        let tmp = TempDir::new().unwrap();
1037        let err = copy_dir_recursive(&tmp.path().join("missing"), &tmp.path().join("dst"))
1038            .expect_err("missing source should fail");
1039
1040        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1041    }
1042}