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        // Sweep leftover pull staging directories from prior crashed/aborted
52        // pulls so they don't linger under store_dir/tmp forever.
53        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    /// Get a stored image by reference.
71    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            // Best-effort save of updated last_used; log on failure so staleness is visible.
78            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    /// Get a stored image by digest.
88    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    /// Resolve an image reference to a stored image.
105    ///
106    /// CRI callers may address an image by an exact stored reference, by its
107    /// image id (a bare `sha256:...` or a `name@sha256:...` digest pin), or by
108    /// an unnormalized name (e.g. a tagless name that defaults to `:latest`).
109    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    /// Store an image from a source directory.
124    ///
125    /// Copies the OCI image layout from `source_dir` into the store
126    /// under `sha256/<digest>/`.
127    pub async fn put(
128        &self,
129        reference: &str,
130        digest: &str,
131        source_dir: &Path,
132    ) -> Result<StoredImage> {
133        // Compute target path from digest
134        let digest_hex = digest.strip_prefix("sha256:").unwrap_or(digest);
135        let target_dir = self.store_dir.join("sha256").join(digest_hex);
136
137        // Copy source to target if not already present. Stage into a unique temp
138        // dir then atomically rename into place, so a concurrent put() for the
139        // same digest — or a copy that fails partway — can never leave a
140        // half-populated content-addressed dir that a later caller mistakes for a
141        // complete image (the bare check-then-copy raced on both counts).
142        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                // A concurrent put() may have populated target_dir first (rename
158                // onto a non-empty dir fails); that's fine — only propagate if the
159                // image still isn't there.
160                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            // Docker parity: if this reference already points at a DIFFERENT
183            // digest and that old digest is about to lose its last reference,
184            // keep the displaced image as a dangling entry (keyed by its digest)
185            // instead of dropping it. This makes a rebuilt/re-tagged image show
186            // up as `<none>` in `images`, be removable by `image prune`, and
187            // prevents silently orphaning its on-disk layout.
188            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    /// Remove an image by reference or by image ID (digest).
210    ///
211    /// The CRI `RemoveImage` may identify an image either by a repo
212    /// reference/tag or by its image ID (`sha256:<digest>`, as returned in
213    /// `ImageStatus`). When `image` does not match a stored reference key,
214    /// fall back to removing every reference that points at the matching
215    /// digest.
216    pub async fn remove(&self, image: &str) -> Result<()> {
217        self.with_index_lock(|index| {
218            // Resolve the reference keys to remove: the exact reference if it is
219            // a known key, otherwise every key sharing the requested digest.
220            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            // Delete each image's on-disk layout once no remaining reference
240            // points at the same digest. References sharing a digest share the
241            // same directory, so the `path.exists()` guard makes this idempotent.
242            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    /// List all stored images.
260    pub async fn list(&self) -> Vec<StoredImage> {
261        let index = self.index.read().await;
262        index.values().cloned().collect()
263    }
264
265    /// Evict least-recently-used images until total size is under the limit.
266    ///
267    /// Returns the references of evicted images.
268    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            // Find the least recently used image
274            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    /// Get total size of all stored images in bytes.
296    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    /// Load index from disk.
302    fn load_index(&mut self) -> Result<()> {
303        // Construction-time load; reuse the shared disk reader.
304        self.index = Arc::new(RwLock::new(self.read_index_from_disk()?));
305        Ok(())
306    }
307
308    /// Read and parse `index.json` from disk into a fresh map (entries whose
309    /// content dir vanished are dropped). Does NOT touch `self.index`.
310    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        // Parse resiliently so a corrupt/old-schema index never bricks the whole
325        // catalog or blocks CRI/CLI startup. First read the `{ images: [...] }`
326        // envelope leniently (entries as raw values); if even that fails the file
327        // is unusable, so quarantine it and start from an empty catalog that
328        // re-pulls repopulate. Then deserialize each entry independently, skipping
329        // (not failing on) any one corrupt/incompatible record.
330        #[derive(serde::Deserialize)]
331        struct RawIndex {
332            #[serde(default)]
333            images: Vec<serde_json::Value>,
334        }
335
336        let raw: RawIndex = match serde_json::from_str(&data) {
337            Ok(raw) => raw,
338            Err(err) => {
339                let preserved = crate::store_io::quarantine_label(&index_path);
340                tracing::warn!(
341                    "image store index {} is corrupt ({err}); preserved a copy at \
342                     {preserved} and started from an empty catalog (re-pulled images \
343                     will repopulate it)",
344                    index_path.display(),
345                );
346                return Ok(HashMap::new());
347            }
348        };
349
350        let mut index = HashMap::new();
351        let mut skipped = 0usize;
352        for value in raw.images {
353            match serde_json::from_value::<StoredImage>(value) {
354                Ok(image) => {
355                    if image.path.exists() {
356                        index.insert(image.reference.clone(), image);
357                    }
358                }
359                Err(err) => {
360                    skipped += 1;
361                    tracing::warn!("skipping unreadable image index entry ({err})");
362                }
363            }
364        }
365        if skipped > 0 {
366            // Preserve the original (with the un-deserializable entries) before the
367            // next save rewrites index.json with only the survivors — otherwise the
368            // skipped records are erased with no backup, unlike the whole-file path.
369            let preserved = crate::store_io::quarantine_copy(&index_path)
370                .map(|p| p.display().to_string())
371                .unwrap_or_else(|| "<backup failed>".to_string());
372            tracing::warn!(
373                "{skipped} image index entr{} skipped as unreadable; preserved a copy at \
374                 {preserved}; affected images will be re-pulled on demand",
375                if skipped == 1 { "y" } else { "ies" },
376            );
377        }
378        Ok(index)
379    }
380
381    /// Apply `f` to the image index under the **cross-process write lock**:
382    /// reload `index.json` from disk (so this process observes other processes'
383    /// pulls/removes), let `f` mutate the map, then save. Without this, two
384    /// processes pulling concurrently each load their own snapshot and the
385    /// second `save` drops the first's entry (and leaks its content dir).
386    ///
387    /// The blocking `flock` is acquired off the runtime via `spawn_blocking`;
388    /// `save_index_inner` is lock-free, so there is no re-entrant `flock`.
389    async fn with_index_lock<F, R>(&self, f: F) -> Result<R>
390    where
391        F: FnOnce(&mut HashMap<String, StoredImage>) -> Result<R>,
392    {
393        let index_path = self.store_dir.join("index.json");
394        let _lock = {
395            let p = index_path.clone();
396            tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&p))
397                .await
398                .map_err(|e| BoxError::OciImageError(format!("index lock task failed: {e}")))?
399                .map_err(|e| {
400                    BoxError::OciImageError(format!(
401                        "failed to lock image index {}: {e}",
402                        index_path.display()
403                    ))
404                })?
405        };
406        // Sync the in-memory index with disk (pick up other processes' writes).
407        let fresh = self.read_index_from_disk()?;
408        let result = {
409            let mut idx = self.index.write().await;
410            *idx = fresh;
411            f(&mut idx)?
412        };
413        self.save_index_inner().await?;
414        Ok(result)
415    }
416
417    /// Save index to disk (async inner helper).
418    async fn save_index_inner(&self) -> Result<()> {
419        let index = self.index.read().await;
420        let store_index = StoreIndex {
421            images: index.values().cloned().collect(),
422        };
423        drop(index);
424
425        let data = serde_json::to_string_pretty(&store_index)?;
426        let index_path = self.store_dir.join("index.json");
427        // Write atomically (tmp + rename) so a concurrent reader (e.g. another
428        // process running `create`/`run`) never observes a truncated/empty
429        // index.json mid-write — which previously surfaced as
430        // "Failed to parse image store index: EOF".
431        let tmp_path = self.store_dir.join("index.json.tmp");
432        tokio::fs::write(&tmp_path, data).await.map_err(|e| {
433            BoxError::OciImageError(format!(
434                "Failed to write image store index {}: {}",
435                tmp_path.display(),
436                e
437            ))
438        })?;
439        tokio::fs::rename(&tmp_path, &index_path)
440            .await
441            .map_err(|e| {
442                BoxError::OciImageError(format!(
443                    "Failed to commit image store index {}: {}",
444                    index_path.display(),
445                    e
446                ))
447            })?;
448
449        Ok(())
450    }
451
452    /// Get the store directory path.
453    pub fn store_dir(&self) -> &Path {
454        &self.store_dir
455    }
456}
457
458#[async_trait::async_trait]
459impl ImageStoreBackend for ImageStore {
460    async fn get(&self, reference: &str) -> Option<StoredImage> {
461        self.get(reference).await
462    }
463
464    async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
465        self.get_by_digest(digest).await
466    }
467
468    async fn put(&self, reference: &str, digest: &str, source_dir: &Path) -> Result<StoredImage> {
469        self.put(reference, digest, source_dir).await
470    }
471
472    async fn remove(&self, reference: &str) -> Result<()> {
473        self.remove(reference).await
474    }
475
476    async fn list(&self) -> Vec<StoredImage> {
477        self.list().await
478    }
479
480    async fn evict(&self) -> Result<Vec<String>> {
481        self.evict().await
482    }
483
484    async fn total_size(&self) -> u64 {
485        self.total_size().await
486    }
487}
488
489/// Recursively copy a directory.
490fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
491    std::fs::create_dir_all(dst)?;
492    for entry in std::fs::read_dir(src)? {
493        let entry = entry?;
494        let src_path = entry.path();
495        let dst_path = dst.join(entry.file_name());
496        if src_path.is_dir() {
497            copy_dir_recursive(&src_path, &dst_path)?;
498        } else {
499            std::fs::copy(&src_path, &dst_path)?;
500        }
501    }
502    Ok(())
503}
504
505/// Calculate total size of a directory recursively.
506fn dir_size(path: &Path) -> u64 {
507    let mut total = 0;
508    if let Ok(entries) = std::fs::read_dir(path) {
509        for entry in entries.flatten() {
510            let path = entry.path();
511            if path.is_dir() {
512                total += dir_size(&path);
513            } else if let Ok(meta) = path.metadata() {
514                total += meta.len();
515            }
516        }
517    }
518    total
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use tempfile::TempDir;
525
526    fn create_test_oci_layout(dir: &Path) {
527        std::fs::create_dir_all(dir.join("blobs/sha256")).unwrap();
528        std::fs::write(dir.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
529        std::fs::write(dir.join("index.json"), r#"{"manifests":[]}"#).unwrap();
530        // Write some blob data to have measurable size
531        std::fs::write(dir.join("blobs/sha256/testblob"), "x".repeat(1024)).unwrap();
532    }
533
534    #[tokio::test]
535    async fn test_new_creates_directory() {
536        let tmp = TempDir::new().unwrap();
537        let store_dir = tmp.path().join("images");
538        let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
539        assert!(store_dir.exists());
540        assert_eq!(store.total_size().await, 0);
541    }
542
543    #[tokio::test]
544    async fn test_put_and_get() {
545        let tmp = TempDir::new().unwrap();
546        let store_dir = tmp.path().join("store");
547        let source_dir = tmp.path().join("source");
548        create_test_oci_layout(&source_dir);
549
550        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
551
552        let stored = store
553            .put("nginx:latest", "sha256:abc123", &source_dir)
554            .await
555            .unwrap();
556
557        assert_eq!(stored.reference, "nginx:latest");
558        assert_eq!(stored.digest, "sha256:abc123");
559        assert!(stored.size_bytes > 0);
560        assert!(stored.path.exists());
561
562        // Get by reference
563        let fetched = store.get("nginx:latest").await.unwrap();
564        assert_eq!(fetched.digest, "sha256:abc123");
565
566        // Get by digest
567        let fetched = store.get_by_digest("sha256:abc123").await.unwrap();
568        assert_eq!(fetched.reference, "nginx:latest");
569    }
570
571    #[tokio::test]
572    async fn test_get_nonexistent() {
573        let tmp = TempDir::new().unwrap();
574        let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
575        assert!(store.get("nonexistent").await.is_none());
576    }
577
578    #[tokio::test]
579    async fn test_remove() {
580        let tmp = TempDir::new().unwrap();
581        let store_dir = tmp.path().join("store");
582        let source_dir = tmp.path().join("source");
583        create_test_oci_layout(&source_dir);
584
585        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
586        store
587            .put("nginx:latest", "sha256:abc123", &source_dir)
588            .await
589            .unwrap();
590
591        store.remove("nginx:latest").await.unwrap();
592        assert!(store.get("nginx:latest").await.is_none());
593    }
594
595    #[tokio::test]
596    async fn test_retag_keeps_displaced_image_as_dangling() {
597        // Docker parity: re-pointing a tag at a new digest leaves the old image
598        // as a dangling entry (keyed by its digest), not silently dropped.
599        let tmp = TempDir::new().unwrap();
600        let store_dir = tmp.path().join("store");
601        let source_dir = tmp.path().join("source");
602        create_test_oci_layout(&source_dir);
603
604        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
605        store
606            .put("app:latest", "sha256:old", &source_dir)
607            .await
608            .unwrap();
609        store
610            .put("app:latest", "sha256:new", &source_dir)
611            .await
612            .unwrap();
613
614        // The tag now resolves to the new digest...
615        assert_eq!(store.get("app:latest").await.unwrap().digest, "sha256:new");
616        // ...and the displaced image survives as a digest-keyed dangling entry.
617        let dangling = store.get("sha256:old").await.unwrap();
618        assert_eq!(dangling.digest, "sha256:old");
619        assert_eq!(store.list().await.len(), 2);
620    }
621
622    #[tokio::test]
623    async fn test_reput_same_digest_does_not_create_dangling() {
624        // Re-putting the same reference at the SAME digest (e.g. pulling latest
625        // when content is unchanged) must not spawn a spurious dangling entry.
626        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("app:latest", "sha256:same", &source_dir)
634            .await
635            .unwrap();
636        store
637            .put("app:latest", "sha256:same", &source_dir)
638            .await
639            .unwrap();
640
641        assert_eq!(store.list().await.len(), 1);
642    }
643
644    #[tokio::test]
645    async fn test_remove_by_digest() {
646        // CRI RemoveImage identifies the image by its ID (sha256 digest),
647        // not its tag. Removing by digest must drop the reference + layout.
648        let tmp = TempDir::new().unwrap();
649        let store_dir = tmp.path().join("store");
650        let source_dir = tmp.path().join("source");
651        create_test_oci_layout(&source_dir);
652
653        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
654        let stored = store
655            .put("gcr.io/test/img:test", "sha256:deadbeef", &source_dir)
656            .await
657            .unwrap();
658        let path = stored.path.clone();
659
660        store.remove("sha256:deadbeef").await.unwrap();
661        assert!(store.get("gcr.io/test/img:test").await.is_none());
662        assert!(store.get_by_digest("sha256:deadbeef").await.is_none());
663        assert!(!path.exists(), "on-disk layout should be deleted");
664    }
665
666    #[tokio::test]
667    async fn test_remove_by_digest_removes_all_tags() {
668        // Two tags sharing one digest: removing by digest drops both and
669        // deletes the shared layout exactly once.
670        let tmp = TempDir::new().unwrap();
671        let store_dir = tmp.path().join("store");
672        let source_dir = tmp.path().join("source");
673        create_test_oci_layout(&source_dir);
674
675        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
676        store
677            .put("img:v1", "sha256:shared", &source_dir)
678            .await
679            .unwrap();
680        let stored = store
681            .put("img:latest", "sha256:shared", &source_dir)
682            .await
683            .unwrap();
684        let path = stored.path.clone();
685
686        store.remove("sha256:shared").await.unwrap();
687        assert!(store.get("img:v1").await.is_none());
688        assert!(store.get("img:latest").await.is_none());
689        assert!(!path.exists(), "shared layout should be deleted");
690    }
691
692    #[tokio::test]
693    async fn test_resolve_by_name_digest_and_normalized() {
694        let tmp = TempDir::new().unwrap();
695        let store_dir = tmp.path().join("store");
696        let source_dir = tmp.path().join("source");
697        create_test_oci_layout(&source_dir);
698
699        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
700        store
701            .put(
702                "gcr.io/x/test-image-predefined-group:latest",
703                "sha256:grp",
704                &source_dir,
705            )
706            .await
707            .unwrap();
708
709        // Exact reference.
710        assert!(store
711            .resolve("gcr.io/x/test-image-predefined-group:latest")
712            .await
713            .is_some());
714        // Unnormalized name (no tag -> :latest) — the CreateContainer case.
715        assert_eq!(
716            store
717                .resolve("gcr.io/x/test-image-predefined-group")
718                .await
719                .map(|i| i.digest),
720            Some("sha256:grp".to_string())
721        );
722        // Image id (bare digest) and a name@digest pin.
723        assert!(store.resolve("sha256:grp").await.is_some());
724        assert!(store
725            .resolve("gcr.io/x/test-image-predefined-group@sha256:grp")
726            .await
727            .is_some());
728        // Unknown.
729        assert!(store.resolve("nope:latest").await.is_none());
730    }
731
732    #[tokio::test]
733    async fn test_remove_nonexistent() {
734        let tmp = TempDir::new().unwrap();
735        let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
736        assert!(store.remove("nonexistent").await.is_err());
737    }
738
739    #[tokio::test]
740    async fn test_list() {
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("nginx:latest", "sha256:aaa", &source_dir)
749            .await
750            .unwrap();
751        store
752            .put("alpine:3.18", "sha256:bbb", &source_dir)
753            .await
754            .unwrap();
755
756        let images = store.list().await;
757        assert_eq!(images.len(), 2);
758    }
759
760    #[tokio::test]
761    async fn test_total_size() {
762        let tmp = TempDir::new().unwrap();
763        let store_dir = tmp.path().join("store");
764        let source_dir = tmp.path().join("source");
765        create_test_oci_layout(&source_dir);
766
767        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
768        store
769            .put("nginx:latest", "sha256:aaa", &source_dir)
770            .await
771            .unwrap();
772
773        assert!(store.total_size().await > 0);
774    }
775
776    #[tokio::test]
777    async fn test_lru_eviction() {
778        let tmp = TempDir::new().unwrap();
779        let store_dir = tmp.path().join("store");
780        let source_dir = tmp.path().join("source");
781        create_test_oci_layout(&source_dir);
782
783        // Set max size very small to trigger eviction
784        let store = ImageStore::new(&store_dir, 100).unwrap();
785
786        store
787            .put("old:v1", "sha256:old1", &source_dir)
788            .await
789            .unwrap();
790
791        // Sleep briefly so timestamps differ
792        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
793
794        store
795            .put("new:v2", "sha256:new2", &source_dir)
796            .await
797            .unwrap();
798
799        // Access the newer one to update its last_used
800        store.get("new:v2").await;
801
802        let evicted = store.evict().await.unwrap();
803        // At least one image should be evicted (the older one first)
804        assert!(!evicted.is_empty());
805        assert!(evicted.contains(&"old:v1".to_string()));
806    }
807
808    #[tokio::test]
809    async fn test_index_persistence() {
810        let tmp = TempDir::new().unwrap();
811        let store_dir = tmp.path().join("store");
812        let source_dir = tmp.path().join("source");
813        create_test_oci_layout(&source_dir);
814
815        // Create store and add image
816        {
817            let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
818            store
819                .put("nginx:latest", "sha256:persist", &source_dir)
820                .await
821                .unwrap();
822        }
823
824        // Create new store from same directory — should load persisted index
825        {
826            let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
827            let image = store.get("nginx:latest").await;
828            assert!(image.is_some());
829            assert_eq!(image.unwrap().digest, "sha256:persist");
830        }
831    }
832
833    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
834    async fn concurrent_cross_instance_puts_persist_both() {
835        use std::collections::HashSet;
836        use std::sync::Arc;
837
838        let tmp = TempDir::new().unwrap();
839        let store_dir = tmp.path().join("store");
840        let source_dir = tmp.path().join("source");
841        create_test_oci_layout(&source_dir);
842
843        // Two ImageStore instances on the SAME dir simulate two processes, each
844        // with its own in-memory index. Concurrent puts of distinct images must
845        // BOTH persist — the lost-update bug dropped one. with_index_lock
846        // reloads under the cross-process lock, so neither overwrites the other.
847        let s1 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
848        let s2 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
849        let (src1, src2) = (source_dir.clone(), source_dir.clone());
850        let h1 = {
851            let s1 = Arc::clone(&s1);
852            tokio::spawn(async move { s1.put("img:a", "sha256:aaaa", &src1).await.unwrap() })
853        };
854        let h2 = {
855            let s2 = Arc::clone(&s2);
856            tokio::spawn(async move { s2.put("img:b", "sha256:bbbb", &src2).await.unwrap() })
857        };
858        h1.await.unwrap();
859        h2.await.unwrap();
860
861        // A fresh instance reads index.json from disk: both must be there.
862        let s3 = ImageStore::new(&store_dir, u64::MAX).unwrap();
863        let refs: HashSet<String> = s3.list().await.into_iter().map(|i| i.reference).collect();
864        assert!(refs.contains("img:a"), "img:a lost: {refs:?}");
865        assert!(refs.contains("img:b"), "img:b lost: {refs:?}");
866    }
867
868    #[tokio::test]
869    async fn corrupt_index_is_quarantined_not_fatal() {
870        let tmp = tempfile::tempdir().unwrap();
871        let store_dir = tmp.path().join("images");
872        std::fs::create_dir_all(&store_dir).unwrap();
873        std::fs::write(store_dir.join("index.json"), "{ not valid json").unwrap();
874
875        // Construction must SUCCEED (start from an empty catalog) rather than
876        // erroring and blocking CRI/CLI startup on a corrupt/old-schema index.
877        let store = ImageStore::new(&store_dir, u64::MAX)
878            .expect("corrupt index.json must not brick the image store");
879        assert!(
880            store.list().await.is_empty(),
881            "store must start from an empty catalog after quarantine"
882        );
883
884        // The corrupt index is preserved as a timestamped sibling, not lost.
885        let quarantined = std::fs::read_dir(&store_dir)
886            .unwrap()
887            .filter_map(|e| e.ok())
888            .any(|e| {
889                e.file_name()
890                    .to_string_lossy()
891                    .contains("index.json.corrupt-")
892            });
893        assert!(
894            quarantined,
895            "corrupt index.json must be quarantined to a sibling"
896        );
897    }
898}