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        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    /// Apply `f` to the image index under the **cross-process write lock**:
338    /// reload `index.json` from disk (so this process observes other processes'
339    /// pulls/removes), let `f` mutate the map, then save. Without this, two
340    /// processes pulling concurrently each load their own snapshot and the
341    /// second `save` drops the first's entry (and leaks its content dir).
342    ///
343    /// The blocking `flock` is acquired off the runtime via `spawn_blocking`;
344    /// `save_index_inner` is lock-free, so there is no re-entrant `flock`.
345    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        // Sync the in-memory index with disk (pick up other processes' writes).
363        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    /// Save index to disk (async inner helper).
374    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        // Write atomically (tmp + rename) so a concurrent reader (e.g. another
384        // process running `create`/`run`) never observes a truncated/empty
385        // index.json mid-write — which previously surfaced as
386        // "Failed to parse image store index: EOF".
387        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    /// Get the store directory path.
409    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
445/// Recursively copy a directory.
446fn 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
461/// Calculate total size of a directory recursively.
462fn 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        // Write some blob data to have measurable size
487        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        // Get by reference
519        let fetched = store.get("nginx:latest").await.unwrap();
520        assert_eq!(fetched.digest, "sha256:abc123");
521
522        // Get by digest
523        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        // Docker parity: re-pointing a tag at a new digest leaves the old image
554        // as a dangling entry (keyed by its digest), not silently dropped.
555        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        // The tag now resolves to the new digest...
571        assert_eq!(store.get("app:latest").await.unwrap().digest, "sha256:new");
572        // ...and the displaced image survives as a digest-keyed dangling entry.
573        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        // Re-putting the same reference at the SAME digest (e.g. pulling latest
581        // when content is unchanged) must not spawn a spurious dangling entry.
582        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        // CRI RemoveImage identifies the image by its ID (sha256 digest),
603        // not its tag. Removing by digest must drop the reference + layout.
604        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        // Two tags sharing one digest: removing by digest drops both and
625        // deletes the shared layout exactly once.
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("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        // Exact reference.
666        assert!(store
667            .resolve("gcr.io/x/test-image-predefined-group:latest")
668            .await
669            .is_some());
670        // Unnormalized name (no tag -> :latest) — the CreateContainer case.
671        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        // Image id (bare digest) and a name@digest pin.
679        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        // Unknown.
685        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        // Set max size very small to trigger eviction
740        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        // Sleep briefly so timestamps differ
748        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        // Access the newer one to update its last_used
756        store.get("new:v2").await;
757
758        let evicted = store.evict().await.unwrap();
759        // At least one image should be evicted (the older one first)
760        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        // Create store and add image
772        {
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        // Create new store from same directory — should load persisted index
781        {
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        // Two ImageStore instances on the SAME dir simulate two processes, each
800        // with its own in-memory index. Concurrent puts of distinct images must
801        // BOTH persist — the lost-update bug dropped one. with_index_lock
802        // reloads under the cross-process lock, so neither overwrites the other.
803        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        // A fresh instance reads index.json from disk: both must be there.
818        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}