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