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
18mod blob_reuse;
19
20/// Per-process counter for unique staging-dir names in `put`.
21static PUT_SEQ: AtomicU64 = AtomicU64::new(0);
22
23/// Persistent index stored as JSON on disk.
24#[derive(Debug, Default, Serialize, Deserialize)]
25struct StoreIndex {
26    images: Vec<StoredImage>,
27}
28
29/// Disk-based image store with in-memory index and LRU eviction.
30pub struct ImageStore {
31    /// Root directory for image storage
32    store_dir: PathBuf,
33    /// In-memory index: reference → StoredImage
34    index: Arc<RwLock<HashMap<String, StoredImage>>>,
35    /// Maximum total size in bytes
36    max_size_bytes: u64,
37}
38
39fn state_dir_hint() -> &'static str {
40    "Set A3S_HOME to a writable directory to change the A3S Box state directory."
41}
42
43impl ImageStore {
44    /// Create a new image store.
45    ///
46    /// Creates the store directory if it doesn't exist and loads
47    /// any existing index from disk.
48    pub fn new(store_dir: &Path, max_size_bytes: u64) -> Result<Self> {
49        std::fs::create_dir_all(store_dir).map_err(|e| {
50            BoxError::OciImageError(format!(
51                "Failed to create image store directory {}: {}. {}",
52                store_dir.display(),
53                e,
54                state_dir_hint()
55            ))
56        })?;
57
58        let mut store = Self {
59            store_dir: store_dir.to_path_buf(),
60            index: Arc::new(RwLock::new(HashMap::new())),
61            max_size_bytes,
62        };
63
64        store.load_index()?;
65        Ok(store)
66    }
67
68    /// Get a stored image by reference.
69    pub async fn get(&self, reference: &str) -> Option<StoredImage> {
70        match self.get_checked(reference).await {
71            Ok(image) => image,
72            Err(error) => {
73                tracing::warn!(
74                    reference,
75                    %error,
76                    "Failed to read the authoritative image store index"
77                );
78                None
79            }
80        }
81    }
82
83    /// Get a stored image through the authoritative cross-process index.
84    pub(crate) async fn get_checked(&self, reference: &str) -> Result<Option<StoredImage>> {
85        let reference = reference.to_string();
86        self.with_index_lock(move |index| {
87            let Some(image) = index.get_mut(&reference) else {
88                return Ok(None);
89            };
90            image.last_used = Utc::now();
91            Ok(Some(image.clone()))
92        })
93        .await
94    }
95
96    /// Get a stored image by digest.
97    pub async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
98        match self.get_by_digest_checked(digest).await {
99            Ok(image) => image,
100            Err(error) => {
101                tracing::warn!(
102                    digest,
103                    %error,
104                    "Failed to read the authoritative image store index"
105                );
106                None
107            }
108        }
109    }
110
111    async fn get_by_digest_checked(&self, digest: &str) -> Result<Option<StoredImage>> {
112        let digest = digest.to_string();
113        self.with_index_lock(move |index| {
114            let Some(image) = index.values_mut().find(|image| image.digest == digest) else {
115                return Ok(None);
116            };
117            image.last_used = Utc::now();
118            Ok(Some(image.clone()))
119        })
120        .await
121    }
122
123    /// Resolve an image reference to a stored image.
124    ///
125    /// CRI callers may address an image by an exact stored reference, by its
126    /// image id (a bare `sha256:...` or a `name@sha256:...` digest pin), or by
127    /// an unnormalized name (e.g. a tagless name that defaults to `:latest`).
128    pub async fn resolve(&self, image: &str) -> Option<StoredImage> {
129        if let Some(found) = self.get(image).await {
130            return Some(found);
131        }
132        let digest_part = image.rsplit_once('@').map_or(image, |(_, digest)| digest);
133        if let Some(found) = self.get_by_digest(digest_part).await {
134            return Some(found);
135        }
136        match super::ImageReference::parse(image) {
137            Ok(parsed) => self.get(&parsed.full_reference()).await,
138            Err(_) => None,
139        }
140    }
141
142    /// Store an image from a source directory.
143    ///
144    /// Copies the OCI image layout from `source_dir` into the store
145    /// under `sha256/<digest>/`.
146    pub async fn put(
147        &self,
148        reference: &str,
149        digest: &str,
150        source_dir: &Path,
151    ) -> Result<StoredImage> {
152        // A digest becomes both a directory name and part of the staging name.
153        // Accept only canonical content digests before either path is built.
154        let digest_hex = super::registry::validated_digest_hex(digest)?;
155        let digest_root = self.store_dir.join("sha256");
156        std::fs::create_dir_all(&digest_root).map_err(|error| {
157            BoxError::OciImageError(format!(
158                "Failed to create image content directory {}: {error}",
159                digest_root.display()
160            ))
161        })?;
162        require_real_directory(&digest_root).map_err(|error| {
163            BoxError::OciImageError(format!(
164                "Unsafe image content directory {}: {error}",
165                digest_root.display()
166            ))
167        })?;
168        let target_dir = digest_root.join(digest_hex);
169
170        // Copy source to target if not already present. Stage into a unique temp
171        // dir then atomically rename into place, so a concurrent put() for the
172        // same digest — or a copy that fails partway — can never leave a
173        // half-populated content-addressed dir that a later caller mistakes for a
174        // complete image (the bare check-then-copy raced on both counts).
175        if !real_directory_exists(&target_dir).map_err(|error| {
176            BoxError::OciImageError(format!(
177                "Unsafe existing image directory {}: {error}",
178                target_dir.display()
179            ))
180        })? {
181            // Reserve the staging directory atomically. Never remove a guessed
182            // path first: a local reparse point at that name must not turn
183            // cleanup into traversal outside the store.
184            let staging = loop {
185                let seq = PUT_SEQ.fetch_add(1, Ordering::Relaxed);
186                let candidate = digest_root.join(format!(
187                    ".staging-{}-{}-{}",
188                    digest_hex,
189                    std::process::id(),
190                    seq
191                ));
192                match std::fs::create_dir(&candidate) {
193                    Ok(()) => break candidate,
194                    Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
195                    Err(error) => {
196                        return Err(BoxError::OciImageError(format!(
197                            "Failed to reserve image staging directory {}: {error}",
198                            candidate.display()
199                        )))
200                    }
201                }
202            };
203            copy_dir_contents_no_follow(source_dir, &staging).map_err(|e| {
204                let _ = std::fs::remove_dir_all(&staging);
205                BoxError::OciImageError(format!("Failed to copy image to store: {}", e))
206            })?;
207            if let Err(e) = std::fs::rename(&staging, &target_dir) {
208                let _ = std::fs::remove_dir_all(&staging);
209                // A concurrent put() may have populated target_dir first (rename
210                // onto a non-empty dir fails); that's fine — only propagate if the
211                // image still isn't there.
212                if !real_directory_exists(&target_dir).map_err(|error| {
213                    BoxError::OciImageError(format!(
214                        "Unsafe concurrently published image directory {}: {error}",
215                        target_dir.display()
216                    ))
217                })? {
218                    return Err(BoxError::OciImageError(format!(
219                        "Failed to publish image to store: {}",
220                        e
221                    )));
222                }
223            }
224        }
225
226        let size_bytes = dir_size(&target_dir);
227        let now = Utc::now();
228
229        let stored = StoredImage {
230            reference: reference.to_string(),
231            digest: digest.to_string(),
232            size_bytes,
233            pulled_at: now,
234            last_used: now,
235            path: target_dir,
236        };
237
238        self.with_index_lock(|index| {
239            // Docker parity: if this reference already points at a DIFFERENT
240            // digest and that old digest is about to lose its last reference,
241            // keep the displaced image as a dangling entry (keyed by its digest)
242            // instead of dropping it. This makes a rebuilt/re-tagged image show
243            // up as `<none>` in `images`, be removable by `image prune`, and
244            // prevents silently orphaning its on-disk layout.
245            if let Some(old) = index.get(reference).cloned() {
246                if old.digest != digest {
247                    let still_referenced = index
248                        .iter()
249                        .any(|(key, img)| key.as_str() != reference && img.digest == old.digest);
250                    if !still_referenced && !index.contains_key(&old.digest) {
251                        let mut dangling = old.clone();
252                        dangling.reference = old.digest.clone();
253                        index.insert(old.digest.clone(), dangling);
254                    }
255                }
256            }
257
258            index.insert(reference.to_string(), stored.clone());
259            Ok(())
260        })
261        .await?;
262
263        Ok(stored)
264    }
265
266    /// Remove an image by reference or by image ID (digest).
267    ///
268    /// The CRI `RemoveImage` may identify an image either by a repo
269    /// reference/tag or by its image ID (`sha256:<digest>`, as returned in
270    /// `ImageStatus`). When `image` does not match a stored reference key,
271    /// fall back to removing every reference that points at the matching
272    /// digest.
273    pub async fn remove(&self, image: &str) -> Result<()> {
274        let store_dir = self.store_dir.clone();
275        self.with_index_lock(move |index| {
276            // Resolve the reference keys to remove: the exact reference if it is
277            // a known key, otherwise every key sharing the requested digest.
278            let keys: Vec<String> = if index.contains_key(image) {
279                vec![image.to_string()]
280            } else {
281                index
282                    .values()
283                    .filter(|img| img.digest == image)
284                    .map(|img| img.reference.clone())
285                    .collect()
286            };
287
288            if keys.is_empty() {
289                return Err(BoxError::OciImageError(format!(
290                    "Image not found: {}",
291                    image
292                )));
293            }
294
295            // Stored paths are persisted data. Re-derive every deletion target
296            // from a validated digest instead of trusting a serialized path.
297            for key in &keys {
298                let img = index.get(key).ok_or_else(|| {
299                    BoxError::OciImageError(format!("Image index entry disappeared: {key}"))
300                })?;
301                let digest_hex = super::registry::validated_digest_hex(&img.digest)?;
302                let expected = store_dir.join("sha256").join(digest_hex);
303                if img.path != expected {
304                    return Err(BoxError::OciImageError(format!(
305                        "Refusing unsafe image path {} for digest {} (expected {})",
306                        img.path.display(),
307                        img.digest,
308                        expected.display()
309                    )));
310                }
311            }
312
313            let removed: Vec<StoredImage> = keys.iter().filter_map(|k| index.remove(k)).collect();
314
315            // Delete each image's on-disk layout once no remaining reference
316            // points at the same digest. References sharing a digest share the
317            // same directory, so the `path.exists()` guard makes this idempotent.
318            for img in removed {
319                let digest_still_used = index.values().any(|other| other.digest == img.digest);
320                if !digest_still_used
321                    && real_directory_exists(&img.path).map_err(|error| {
322                        BoxError::OciImageError(format!(
323                            "Refusing unsafe image directory {}: {error}",
324                            img.path.display()
325                        ))
326                    })?
327                {
328                    std::fs::remove_dir_all(&img.path).map_err(|e| {
329                        BoxError::OciImageError(format!(
330                            "Failed to remove image directory {}: {}",
331                            img.path.display(),
332                            e
333                        ))
334                    })?;
335                }
336            }
337            Ok(())
338        })
339        .await
340    }
341
342    /// List all stored images.
343    pub async fn list(&self) -> Vec<StoredImage> {
344        let index = self.index.read().await;
345        index.values().cloned().collect()
346    }
347
348    /// Evict least-recently-used images until total size is under the limit.
349    ///
350    /// Returns the references of evicted images.
351    pub async fn evict(&self) -> Result<Vec<String>> {
352        let mut evicted = Vec::new();
353        let mut total = self.total_size().await;
354
355        while total > self.max_size_bytes {
356            // Find the least recently used image
357            let lru_ref = {
358                let index = self.index.read().await;
359                index
360                    .values()
361                    .min_by_key(|img| img.last_used)
362                    .map(|img| img.reference.clone())
363            };
364
365            match lru_ref {
366                Some(reference) => {
367                    self.remove(&reference).await?;
368                    evicted.push(reference);
369                    total = self.total_size().await;
370                }
371                None => break,
372            }
373        }
374
375        Ok(evicted)
376    }
377
378    /// Get total size of all stored images in bytes.
379    pub async fn total_size(&self) -> u64 {
380        let index = self.index.read().await;
381        index.values().map(|img| img.size_bytes).sum()
382    }
383
384    /// Load index from disk.
385    fn load_index(&mut self) -> Result<()> {
386        // Construction-time load; reuse the shared disk reader.
387        self.index = Arc::new(RwLock::new(self.read_index_from_disk()?));
388        Ok(())
389    }
390
391    /// Read and parse `index.json` from disk into a fresh map (entries whose
392    /// content dir vanished are dropped). Does NOT touch `self.index`.
393    fn read_index_from_disk(&self) -> Result<HashMap<String, StoredImage>> {
394        let index_path = self.store_dir.join("index.json");
395        if !index_path.exists() {
396            return Ok(HashMap::new());
397        }
398
399        let data = std::fs::read_to_string(&index_path).map_err(|e| {
400            BoxError::OciImageError(format!(
401                "Failed to read image store index {}: {}",
402                index_path.display(),
403                e
404            ))
405        })?;
406
407        // Parse resiliently so a corrupt/old-schema index never bricks the whole
408        // catalog or blocks CRI/CLI startup. First read the `{ images: [...] }`
409        // envelope leniently (entries as raw values); if even that fails the file
410        // is unusable, so quarantine it and start from an empty catalog that
411        // re-pulls repopulate. Then deserialize each entry independently, skipping
412        // (not failing on) any one corrupt/incompatible record.
413        #[derive(serde::Deserialize)]
414        struct RawIndex {
415            #[serde(default)]
416            images: Vec<serde_json::Value>,
417        }
418
419        let raw: RawIndex = match serde_json::from_str(&data) {
420            Ok(raw) => raw,
421            Err(err) => {
422                let preserved = crate::store_io::quarantine_label(&index_path);
423                tracing::warn!(
424                    "image store index {} is corrupt ({err}); preserved a copy at \
425                     {preserved} and started from an empty catalog (re-pulled images \
426                     will repopulate it)",
427                    index_path.display(),
428                );
429                return Ok(HashMap::new());
430            }
431        };
432
433        let mut index = HashMap::new();
434        let mut skipped = 0usize;
435        for value in raw.images {
436            match serde_json::from_value::<StoredImage>(value) {
437                Ok(mut image) => {
438                    // The serialized path is compatibility metadata, not an
439                    // authority for filesystem access. Windows can persist an
440                    // equivalent 8.3 spelling (for example `WODEDI~1`) and then
441                    // reopen the store through the long spelling. Lexical path
442                    // equality rejects that valid entry. Re-derive the only
443                    // permitted location from the validated digest and replace
444                    // the persisted spelling before exposing the record.
445                    let expected = super::registry::validated_digest_hex(&image.digest)
446                        .map(|digest_hex| self.store_dir.join("sha256").join(digest_hex));
447                    match expected {
448                        Ok(expected) if real_directory_exists(&expected).unwrap_or(false) => {
449                            image.path = expected;
450                            index.insert(image.reference.clone(), image);
451                        }
452                        _ => {
453                            skipped += 1;
454                            tracing::warn!(
455                                reference = %image.reference,
456                                digest = %image.digest,
457                                path = %image.path.display(),
458                                "skipping image index entry with malformed digest or unsafe path"
459                            );
460                        }
461                    }
462                }
463                Err(err) => {
464                    skipped += 1;
465                    tracing::warn!("skipping unreadable image index entry ({err})");
466                }
467            }
468        }
469        if skipped > 0 {
470            // Preserve the original (with the un-deserializable entries) before the
471            // next save rewrites index.json with only the survivors — otherwise the
472            // skipped records are erased with no backup, unlike the whole-file path.
473            let preserved = crate::store_io::quarantine_copy(&index_path)
474                .map(|p| p.display().to_string())
475                .unwrap_or_else(|| "<backup failed>".to_string());
476            tracing::warn!(
477                "{skipped} image index entr{} skipped as unreadable; preserved a copy at \
478                 {preserved}; affected images will be re-pulled on demand",
479                if skipped == 1 { "y" } else { "ies" },
480            );
481        }
482        Ok(index)
483    }
484
485    /// Apply `f` to the image index under the **cross-process write lock**:
486    /// reload `index.json` from disk (so this process observes other processes'
487    /// pulls/removes), let `f` mutate the map, then save. Without this, two
488    /// processes pulling concurrently each load their own snapshot and the
489    /// second `save` drops the first's entry (and leaks its content dir).
490    ///
491    /// The blocking `flock` is acquired off the runtime via `spawn_blocking`;
492    /// `save_index_inner` is lock-free, so there is no re-entrant `flock`.
493    async fn with_index_lock<F, R>(&self, f: F) -> Result<R>
494    where
495        F: FnOnce(&mut HashMap<String, StoredImage>) -> Result<R>,
496    {
497        let index_path = self.store_dir.join("index.json");
498        let _lock = {
499            let p = index_path.clone();
500            tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&p))
501                .await
502                .map_err(|e| BoxError::OciImageError(format!("index lock task failed: {e}")))?
503                .map_err(|e| {
504                    BoxError::OciImageError(format!(
505                        "failed to lock image index {}: {e}. {}",
506                        index_path.display(),
507                        state_dir_hint()
508                    ))
509                })?
510        };
511        // Sync the in-memory index with disk (pick up other processes' writes).
512        let fresh = self.read_index_from_disk()?;
513        let result = {
514            let mut idx = self.index.write().await;
515            *idx = fresh;
516            f(&mut idx)?
517        };
518        self.save_index_inner().await?;
519        Ok(result)
520    }
521
522    /// Save index to disk (async inner helper).
523    async fn save_index_inner(&self) -> Result<()> {
524        let index = self.index.read().await;
525        let store_index = StoreIndex {
526            images: index.values().cloned().collect(),
527        };
528        drop(index);
529
530        let data = serde_json::to_vec_pretty(&store_index)?;
531        let index_path = self.store_dir.join("index.json");
532        let tmp_path = self.store_dir.join("index.json.tmp");
533        let display_path = index_path.clone();
534        tokio::task::spawn_blocking(move || {
535            a3s_box_core::fs_atomic::write_durable(&tmp_path, &index_path, &data)
536        })
537        .await
538        .map_err(|error| {
539            BoxError::OciImageError(format!(
540                "Image store index persistence task failed for {}: {error}",
541                display_path.display()
542            ))
543        })?
544        .map_err(|error| {
545            BoxError::OciImageError(format!(
546                "Failed to durably commit image store index {}: {error}. {}",
547                display_path.display(),
548                state_dir_hint()
549            ))
550        })?;
551
552        Ok(())
553    }
554
555    /// Get the store directory path.
556    pub fn store_dir(&self) -> &Path {
557        &self.store_dir
558    }
559}
560
561#[async_trait::async_trait]
562impl ImageStoreBackend for ImageStore {
563    async fn get(&self, reference: &str) -> Option<StoredImage> {
564        self.get(reference).await
565    }
566
567    async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
568        self.get_by_digest(digest).await
569    }
570
571    async fn put(&self, reference: &str, digest: &str, source_dir: &Path) -> Result<StoredImage> {
572        self.put(reference, digest, source_dir).await
573    }
574
575    async fn remove(&self, reference: &str) -> Result<()> {
576        self.remove(reference).await
577    }
578
579    async fn list(&self) -> Vec<StoredImage> {
580        self.list().await
581    }
582
583    async fn evict(&self) -> Result<Vec<String>> {
584        self.evict().await
585    }
586
587    async fn total_size(&self) -> u64 {
588        self.total_size().await
589    }
590}
591
592#[cfg(windows)]
593fn metadata_is_reparse_point(metadata: &std::fs::Metadata) -> bool {
594    use std::os::windows::fs::MetadataExt;
595
596    // FILE_ATTRIBUTE_REPARSE_POINT. Keep the value local so runtime does not
597    // need another windows-sys feature solely to classify metadata.
598    metadata.file_attributes() & 0x0000_0400 != 0
599}
600
601#[cfg(not(windows))]
602fn metadata_is_reparse_point(_metadata: &std::fs::Metadata) -> bool {
603    false
604}
605
606fn require_real_directory(path: &Path) -> std::io::Result<()> {
607    let metadata = std::fs::symlink_metadata(path)?;
608    if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
609        return Err(std::io::Error::new(
610            std::io::ErrorKind::PermissionDenied,
611            format!(
612                "refusing symbolic link or reparse-point directory {}",
613                path.display()
614            ),
615        ));
616    }
617    if !metadata.is_dir() {
618        return Err(std::io::Error::new(
619            std::io::ErrorKind::InvalidData,
620            format!("expected a directory at {}", path.display()),
621        ));
622    }
623    Ok(())
624}
625
626fn real_directory_exists(path: &Path) -> std::io::Result<bool> {
627    match require_real_directory(path) {
628        Ok(()) => Ok(true),
629        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
630        Err(error) => Err(error),
631    }
632}
633
634#[cfg(unix)]
635fn open_regular_source_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
636    use std::os::unix::fs::OpenOptionsExt;
637
638    let mut options = std::fs::OpenOptions::new();
639    options
640        .read(true)
641        .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
642    let file = options.open(path)?;
643    if !file.metadata()?.is_file() {
644        return Err(std::io::Error::new(
645            std::io::ErrorKind::InvalidData,
646            format!("expected a regular file at {}", path.display()),
647        ));
648    }
649    Ok(file)
650}
651
652#[cfg(windows)]
653fn open_regular_source_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
654    a3s_box_core::windows_file::open_regular_file(path, None).map(|(file, _)| file)
655}
656
657#[cfg(not(any(unix, windows)))]
658fn open_regular_source_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
659    let metadata = std::fs::symlink_metadata(path)?;
660    if metadata.file_type().is_symlink() || !metadata.is_file() {
661        return Err(std::io::Error::new(
662            std::io::ErrorKind::InvalidData,
663            format!("expected a regular file at {}", path.display()),
664        ));
665    }
666    std::fs::File::open(path)
667}
668
669fn copy_regular_file_no_follow(src: &Path, dst: &Path) -> std::io::Result<()> {
670    let mut source = open_regular_source_no_follow(src)?;
671    let mut destination = std::fs::OpenOptions::new()
672        .write(true)
673        .create_new(true)
674        .open(dst)?;
675    std::io::copy(&mut source, &mut destination)?;
676    Ok(())
677}
678
679/// Copy directory contents while rejecting every source link, junction,
680/// reparse point, and special file. OCI layout symlinks are never required:
681/// guest symlinks live inside opaque layer tar blobs instead.
682fn copy_dir_contents_no_follow(src: &Path, dst: &Path) -> std::io::Result<()> {
683    require_real_directory(src)?;
684    require_real_directory(dst)?;
685
686    for entry in std::fs::read_dir(src)? {
687        let entry = entry?;
688        let src_path = entry.path();
689        let dst_path = dst.join(entry.file_name());
690        let metadata = std::fs::symlink_metadata(&src_path)?;
691
692        if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
693            return Err(std::io::Error::new(
694                std::io::ErrorKind::PermissionDenied,
695                format!(
696                    "refusing symbolic link or reparse point in OCI layout: {}",
697                    src_path.display()
698                ),
699            ));
700        }
701        if metadata.is_dir() {
702            std::fs::create_dir(&dst_path)?;
703            copy_dir_contents_no_follow(&src_path, &dst_path)?;
704        } else if metadata.is_file() {
705            copy_regular_file_no_follow(&src_path, &dst_path)?;
706        } else {
707            return Err(std::io::Error::new(
708                std::io::ErrorKind::InvalidData,
709                format!(
710                    "refusing special file in OCI layout: {}",
711                    src_path.display()
712                ),
713            ));
714        }
715    }
716    Ok(())
717}
718
719/// Recursively copy a directory into a newly created destination.
720#[cfg(test)]
721fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
722    std::fs::create_dir(dst)?;
723    copy_dir_contents_no_follow(src, dst)
724}
725
726/// Calculate total size without following links that may have appeared in a
727/// corrupted or externally modified store.
728fn dir_size(path: &Path) -> u64 {
729    let Ok(metadata) = std::fs::symlink_metadata(path) else {
730        return 0;
731    };
732    if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
733        return 0;
734    }
735    if metadata.is_file() {
736        return metadata.len();
737    }
738    if !metadata.is_dir() {
739        return 0;
740    }
741
742    std::fs::read_dir(path)
743        .map(|entries| entries.flatten().map(|entry| dir_size(&entry.path())).sum())
744        .unwrap_or(0)
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use tempfile::TempDir;
751
752    fn create_test_oci_layout(dir: &Path) {
753        std::fs::create_dir_all(dir.join("blobs/sha256")).unwrap();
754        std::fs::write(dir.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
755        std::fs::write(dir.join("index.json"), r#"{"manifests":[]}"#).unwrap();
756        // Write some blob data to have measurable size
757        std::fs::write(dir.join("blobs/sha256/testblob"), "x".repeat(1024)).unwrap();
758    }
759
760    fn stored_image(reference: &str, digest: &str, path: PathBuf) -> StoredImage {
761        let now = Utc::now();
762        StoredImage {
763            reference: reference.to_string(),
764            digest: digest.to_string(),
765            size_bytes: 1024,
766            pulled_at: now,
767            last_used: now,
768            path,
769        }
770    }
771
772    #[tokio::test]
773    async fn test_new_creates_directory() {
774        let tmp = TempDir::new().unwrap();
775        let store_dir = tmp.path().join("images");
776        let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
777        assert!(store_dir.exists());
778        assert_eq!(store.total_size().await, 0);
779    }
780
781    #[tokio::test]
782    async fn test_new_keeps_existing_tmp_dir_for_concurrent_pulls() {
783        let tmp = TempDir::new().unwrap();
784        let store_dir = tmp.path().join("images");
785        let tmp_dir = store_dir.join("tmp");
786        std::fs::create_dir_all(tmp_dir.join("pull-1")).unwrap();
787        std::fs::write(tmp_dir.join("pull-1/layer"), b"partial").unwrap();
788
789        let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
790
791        assert!(
792            tmp_dir.join("pull-1/layer").exists(),
793            "constructing a store must not delete another process' active pull"
794        );
795        assert_eq!(store.total_size().await, 0);
796    }
797
798    #[tokio::test]
799    async fn test_put_and_get() {
800        let tmp = TempDir::new().unwrap();
801        let store_dir = tmp.path().join("store");
802        let source_dir = tmp.path().join("source");
803        create_test_oci_layout(&source_dir);
804
805        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
806
807        let stored = store
808            .put(
809                "nginx:latest",
810                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
811                &source_dir,
812            )
813            .await
814            .unwrap();
815
816        assert_eq!(stored.reference, "nginx:latest");
817        assert_eq!(
818            stored.digest,
819            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
820        );
821        assert!(stored.size_bytes > 0);
822        assert!(stored.path.exists());
823
824        // Get by reference
825        let fetched = store.get("nginx:latest").await.unwrap();
826        assert_eq!(
827            fetched.digest,
828            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
829        );
830
831        // Get by digest
832        let fetched = store
833            .get_by_digest(
834                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
835            )
836            .await
837            .unwrap();
838        assert_eq!(fetched.reference, "nginx:latest");
839    }
840
841    #[tokio::test]
842    async fn test_get_nonexistent() {
843        let tmp = TempDir::new().unwrap();
844        let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
845        assert!(store.get("nonexistent").await.is_none());
846    }
847
848    #[tokio::test]
849    async fn test_remove() {
850        let tmp = TempDir::new().unwrap();
851        let store_dir = tmp.path().join("store");
852        let source_dir = tmp.path().join("source");
853        create_test_oci_layout(&source_dir);
854
855        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
856        store
857            .put(
858                "nginx:latest",
859                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
860                &source_dir,
861            )
862            .await
863            .unwrap();
864
865        store.remove("nginx:latest").await.unwrap();
866        assert!(store.get("nginx:latest").await.is_none());
867    }
868
869    #[tokio::test]
870    async fn test_remove_one_tag_keeps_shared_digest_until_last_reference() {
871        let tmp = TempDir::new().unwrap();
872        let store_dir = tmp.path().join("store");
873        let source_dir = tmp.path().join("source");
874        create_test_oci_layout(&source_dir);
875
876        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
877        store
878            .put(
879                "img:v1",
880                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
881                &source_dir,
882            )
883            .await
884            .unwrap();
885        let stored = store
886            .put(
887                "img:latest",
888                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
889                &source_dir,
890            )
891            .await
892            .unwrap();
893        let path = stored.path.clone();
894
895        store.remove("img:v1").await.unwrap();
896        assert!(store.get("img:v1").await.is_none());
897        assert!(store.get("img:latest").await.is_some());
898        assert!(path.exists(), "shared layout should remain in use");
899
900        store.remove("img:latest").await.unwrap();
901        assert!(!path.exists(), "layout should be removed after final tag");
902    }
903
904    #[tokio::test]
905    async fn test_retag_keeps_displaced_image_as_dangling() {
906        // Docker parity: re-pointing a tag at a new digest leaves the old image
907        // as a dangling entry (keyed by its digest), not silently dropped.
908        let tmp = TempDir::new().unwrap();
909        let store_dir = tmp.path().join("store");
910        let source_dir = tmp.path().join("source");
911        create_test_oci_layout(&source_dir);
912
913        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
914        store
915            .put(
916                "app:latest",
917                "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
918                &source_dir,
919            )
920            .await
921            .unwrap();
922        store
923            .put(
924                "app:latest",
925                "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
926                &source_dir,
927            )
928            .await
929            .unwrap();
930
931        // The tag now resolves to the new digest...
932        assert_eq!(
933            store.get("app:latest").await.unwrap().digest,
934            "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
935        );
936        // ...and the displaced image survives as a digest-keyed dangling entry.
937        let dangling = store
938            .get("sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")
939            .await
940            .unwrap();
941        assert_eq!(
942            dangling.digest,
943            "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
944        );
945        assert_eq!(store.list().await.len(), 2);
946    }
947
948    #[tokio::test]
949    async fn test_reput_same_digest_does_not_create_dangling() {
950        // Re-putting the same reference at the SAME digest (e.g. pulling latest
951        // when content is unchanged) must not spawn a spurious dangling entry.
952        let tmp = TempDir::new().unwrap();
953        let store_dir = tmp.path().join("store");
954        let source_dir = tmp.path().join("source");
955        create_test_oci_layout(&source_dir);
956
957        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
958        store
959            .put(
960                "app:latest",
961                "sha256:1111111111111111111111111111111111111111111111111111111111111111",
962                &source_dir,
963            )
964            .await
965            .unwrap();
966        store
967            .put(
968                "app:latest",
969                "sha256:1111111111111111111111111111111111111111111111111111111111111111",
970                &source_dir,
971            )
972            .await
973            .unwrap();
974
975        assert_eq!(store.list().await.len(), 1);
976    }
977
978    #[tokio::test]
979    async fn test_remove_by_digest() {
980        // CRI RemoveImage identifies the image by its ID (sha256 digest),
981        // not its tag. Removing by digest must drop the reference + layout.
982        let tmp = TempDir::new().unwrap();
983        let store_dir = tmp.path().join("store");
984        let source_dir = tmp.path().join("source");
985        create_test_oci_layout(&source_dir);
986
987        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
988        let stored = store
989            .put(
990                "gcr.io/test/img:test",
991                "sha256:2222222222222222222222222222222222222222222222222222222222222222",
992                &source_dir,
993            )
994            .await
995            .unwrap();
996        let path = stored.path.clone();
997
998        store
999            .remove("sha256:2222222222222222222222222222222222222222222222222222222222222222")
1000            .await
1001            .unwrap();
1002        assert!(store.get("gcr.io/test/img:test").await.is_none());
1003        assert!(store
1004            .get_by_digest(
1005                "sha256:2222222222222222222222222222222222222222222222222222222222222222"
1006            )
1007            .await
1008            .is_none());
1009        assert!(!path.exists(), "on-disk layout should be deleted");
1010    }
1011
1012    #[tokio::test]
1013    async fn test_remove_by_digest_removes_all_tags() {
1014        // Two tags sharing one digest: removing by digest drops both and
1015        // deletes the shared layout exactly once.
1016        let tmp = TempDir::new().unwrap();
1017        let store_dir = tmp.path().join("store");
1018        let source_dir = tmp.path().join("source");
1019        create_test_oci_layout(&source_dir);
1020
1021        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1022        store
1023            .put(
1024                "img:v1",
1025                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1026                &source_dir,
1027            )
1028            .await
1029            .unwrap();
1030        let stored = store
1031            .put(
1032                "img:latest",
1033                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1034                &source_dir,
1035            )
1036            .await
1037            .unwrap();
1038        let path = stored.path.clone();
1039
1040        store
1041            .remove("sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
1042            .await
1043            .unwrap();
1044        assert!(store.get("img:v1").await.is_none());
1045        assert!(store.get("img:latest").await.is_none());
1046        assert!(!path.exists(), "shared layout should be deleted");
1047    }
1048
1049    #[tokio::test]
1050    async fn test_resolve_by_name_digest_and_normalized() {
1051        let tmp = TempDir::new().unwrap();
1052        let store_dir = tmp.path().join("store");
1053        let source_dir = tmp.path().join("source");
1054        create_test_oci_layout(&source_dir);
1055
1056        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1057        store
1058            .put(
1059                "gcr.io/x/test-image-predefined-group:latest",
1060                "sha256:3333333333333333333333333333333333333333333333333333333333333333",
1061                &source_dir,
1062            )
1063            .await
1064            .unwrap();
1065
1066        // Exact reference.
1067        assert!(store
1068            .resolve("gcr.io/x/test-image-predefined-group:latest")
1069            .await
1070            .is_some());
1071        // Unnormalized name (no tag -> :latest) — the CreateContainer case.
1072        assert_eq!(
1073            store
1074                .resolve("gcr.io/x/test-image-predefined-group")
1075                .await
1076                .map(|i| i.digest),
1077            Some(
1078                "sha256:3333333333333333333333333333333333333333333333333333333333333333"
1079                    .to_string()
1080            )
1081        );
1082        // Image id (bare digest) and a name@digest pin.
1083        assert!(store
1084            .resolve("sha256:3333333333333333333333333333333333333333333333333333333333333333")
1085            .await
1086            .is_some());
1087        assert!(store
1088            .resolve("gcr.io/x/test-image-predefined-group@sha256:3333333333333333333333333333333333333333333333333333333333333333")
1089            .await
1090            .is_some());
1091        // Unknown.
1092        assert!(store.resolve("nope:latest").await.is_none());
1093    }
1094
1095    #[tokio::test]
1096    async fn test_resolve_invalid_reference_returns_none() {
1097        let tmp = TempDir::new().unwrap();
1098        let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
1099
1100        assert!(store.resolve("registry.example.com/").await.is_none());
1101        assert!(store.resolve("busybox@not-a-digest").await.is_none());
1102    }
1103
1104    #[tokio::test]
1105    async fn test_remove_nonexistent() {
1106        let tmp = TempDir::new().unwrap();
1107        let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
1108        assert!(store.remove("nonexistent").await.is_err());
1109    }
1110
1111    #[tokio::test]
1112    async fn test_list() {
1113        let tmp = TempDir::new().unwrap();
1114        let store_dir = tmp.path().join("store");
1115        let source_dir = tmp.path().join("source");
1116        create_test_oci_layout(&source_dir);
1117
1118        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1119        store
1120            .put(
1121                "nginx:latest",
1122                "sha256:4444444444444444444444444444444444444444444444444444444444444444",
1123                &source_dir,
1124            )
1125            .await
1126            .unwrap();
1127        store
1128            .put(
1129                "alpine:3.18",
1130                "sha256:5555555555555555555555555555555555555555555555555555555555555555",
1131                &source_dir,
1132            )
1133            .await
1134            .unwrap();
1135
1136        let images = store.list().await;
1137        assert_eq!(images.len(), 2);
1138    }
1139
1140    #[tokio::test]
1141    async fn test_total_size() {
1142        let tmp = TempDir::new().unwrap();
1143        let store_dir = tmp.path().join("store");
1144        let source_dir = tmp.path().join("source");
1145        create_test_oci_layout(&source_dir);
1146
1147        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1148        store
1149            .put(
1150                "nginx:latest",
1151                "sha256:4444444444444444444444444444444444444444444444444444444444444444",
1152                &source_dir,
1153            )
1154            .await
1155            .unwrap();
1156
1157        assert!(store.total_size().await > 0);
1158    }
1159
1160    #[tokio::test]
1161    async fn test_lru_eviction() {
1162        let tmp = TempDir::new().unwrap();
1163        let store_dir = tmp.path().join("store");
1164        let source_dir = tmp.path().join("source");
1165        create_test_oci_layout(&source_dir);
1166
1167        // Set max size very small to trigger eviction
1168        let store = ImageStore::new(&store_dir, 100).unwrap();
1169
1170        store
1171            .put(
1172                "old:v1",
1173                "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
1174                &source_dir,
1175            )
1176            .await
1177            .unwrap();
1178
1179        // Sleep briefly so timestamps differ
1180        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1181
1182        store
1183            .put(
1184                "new:v2",
1185                "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1186                &source_dir,
1187            )
1188            .await
1189            .unwrap();
1190
1191        // Access the newer one to update its last_used
1192        store.get("new:v2").await;
1193
1194        let evicted = store.evict().await.unwrap();
1195        // At least one image should be evicted (the older one first)
1196        assert!(!evicted.is_empty());
1197        assert!(evicted.contains(&"old:v1".to_string()));
1198    }
1199
1200    #[tokio::test]
1201    async fn test_evict_empty_and_under_limit_returns_empty() {
1202        let tmp = TempDir::new().unwrap();
1203        let store_dir = tmp.path().join("store");
1204        let source_dir = tmp.path().join("source");
1205        create_test_oci_layout(&source_dir);
1206
1207        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1208        assert!(store.evict().await.unwrap().is_empty());
1209
1210        store
1211            .put(
1212                "tiny:latest",
1213                "sha256:6666666666666666666666666666666666666666666666666666666666666666",
1214                &source_dir,
1215            )
1216            .await
1217            .unwrap();
1218        assert!(store.evict().await.unwrap().is_empty());
1219        assert!(store.get("tiny:latest").await.is_some());
1220    }
1221
1222    #[tokio::test]
1223    async fn test_index_persistence() {
1224        let tmp = TempDir::new().unwrap();
1225        let store_dir = tmp.path().join("store");
1226        let source_dir = tmp.path().join("source");
1227        create_test_oci_layout(&source_dir);
1228
1229        // Create store and add image
1230        {
1231            let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1232            store
1233                .put(
1234                    "nginx:latest",
1235                    "sha256:7777777777777777777777777777777777777777777777777777777777777777",
1236                    &source_dir,
1237                )
1238                .await
1239                .unwrap();
1240        }
1241
1242        // Create new store from same directory — should load persisted index
1243        {
1244            let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1245            let image = store.get("nginx:latest").await;
1246            assert!(image.is_some());
1247            assert_eq!(
1248                image.unwrap().digest,
1249                "sha256:7777777777777777777777777777777777777777777777777777777777777777"
1250            );
1251        }
1252    }
1253
1254    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1255    async fn concurrent_cross_instance_puts_persist_both() {
1256        use std::collections::HashSet;
1257        use std::sync::Arc;
1258
1259        let tmp = TempDir::new().unwrap();
1260        let store_dir = tmp.path().join("store");
1261        let source_dir = tmp.path().join("source");
1262        create_test_oci_layout(&source_dir);
1263
1264        // Two ImageStore instances on the SAME dir simulate two processes, each
1265        // with its own in-memory index. Concurrent puts of distinct images must
1266        // BOTH persist — the lost-update bug dropped one. with_index_lock
1267        // reloads under the cross-process lock, so neither overwrites the other.
1268        let s1 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
1269        let s2 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
1270        let (src1, src2) = (source_dir.clone(), source_dir.clone());
1271        let h1 = {
1272            let s1 = Arc::clone(&s1);
1273            tokio::spawn(async move {
1274                s1.put(
1275                    "img:a",
1276                    "sha256:8888888888888888888888888888888888888888888888888888888888888888",
1277                    &src1,
1278                )
1279                .await
1280                .unwrap()
1281            })
1282        };
1283        let h2 = {
1284            let s2 = Arc::clone(&s2);
1285            tokio::spawn(async move {
1286                s2.put(
1287                    "img:b",
1288                    "sha256:9999999999999999999999999999999999999999999999999999999999999999",
1289                    &src2,
1290                )
1291                .await
1292                .unwrap()
1293            })
1294        };
1295        h1.await.unwrap();
1296        h2.await.unwrap();
1297
1298        // A fresh instance reads index.json from disk: both must be there.
1299        let s3 = ImageStore::new(&store_dir, u64::MAX).unwrap();
1300        let refs: HashSet<String> = s3.list().await.into_iter().map(|i| i.reference).collect();
1301        assert!(refs.contains("img:a"), "img:a lost: {refs:?}");
1302        assert!(refs.contains("img:b"), "img:b lost: {refs:?}");
1303    }
1304
1305    #[tokio::test]
1306    async fn cross_instance_get_refreshes_the_authoritative_index() {
1307        let tmp = TempDir::new().unwrap();
1308        let store_dir = tmp.path().join("store");
1309        let source_dir = tmp.path().join("source");
1310        create_test_oci_layout(&source_dir);
1311
1312        let writer = ImageStore::new(&store_dir, u64::MAX).unwrap();
1313        let reader = ImageStore::new(&store_dir, u64::MAX).unwrap();
1314        writer
1315            .put(
1316                "img:fresh",
1317                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1318                &source_dir,
1319            )
1320            .await
1321            .unwrap();
1322
1323        let observed = reader
1324            .get("img:fresh")
1325            .await
1326            .expect("reader created before put must refresh the shared index");
1327        assert_eq!(
1328            observed.digest,
1329            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1330        );
1331
1332        let reopened = ImageStore::new(&store_dir, u64::MAX).unwrap();
1333        assert!(reopened.get("img:fresh").await.is_some());
1334    }
1335
1336    #[tokio::test]
1337    async fn load_index_skips_missing_paths_and_unreadable_entries() {
1338        let tmp = tempfile::tempdir().unwrap();
1339        let store_dir = tmp.path().join("images");
1340        let live_digest = format!("sha256:{}", "a".repeat(64));
1341        let missing_digest = format!("sha256:{}", "b".repeat(64));
1342        let live_path = store_dir.join("sha256").join("a".repeat(64));
1343        let missing_path = store_dir.join("sha256").join("b".repeat(64));
1344        create_test_oci_layout(&live_path);
1345
1346        let live = stored_image("live:latest", &live_digest, live_path);
1347        let missing = stored_image("missing:latest", &missing_digest, missing_path);
1348        let index = serde_json::json!({
1349            "images": [
1350                serde_json::to_value(&live).unwrap(),
1351                serde_json::to_value(&missing).unwrap(),
1352                {
1353                    "reference": "broken:latest",
1354                    "digest": false
1355                }
1356            ]
1357        });
1358        std::fs::write(
1359            store_dir.join("index.json"),
1360            serde_json::to_vec_pretty(&index).unwrap(),
1361        )
1362        .unwrap();
1363
1364        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1365        let images = store.list().await;
1366
1367        assert_eq!(images.len(), 1);
1368        assert_eq!(images[0].reference, "live:latest");
1369        assert!(store.get("missing:latest").await.is_none());
1370        assert!(std::fs::read_dir(&store_dir)
1371            .unwrap()
1372            .filter_map(|entry| entry.ok())
1373            .any(|entry| entry
1374                .file_name()
1375                .to_string_lossy()
1376                .contains("index.json.corrupt-")));
1377    }
1378
1379    #[tokio::test]
1380    async fn corrupt_index_is_quarantined_not_fatal() {
1381        let tmp = tempfile::tempdir().unwrap();
1382        let store_dir = tmp.path().join("images");
1383        std::fs::create_dir_all(&store_dir).unwrap();
1384        std::fs::write(store_dir.join("index.json"), "{ not valid json").unwrap();
1385
1386        // Construction must SUCCEED (start from an empty catalog) rather than
1387        // erroring and blocking CRI/CLI startup on a corrupt/old-schema index.
1388        let store = ImageStore::new(&store_dir, u64::MAX)
1389            .expect("corrupt index.json must not brick the image store");
1390        assert!(
1391            store.list().await.is_empty(),
1392            "store must start from an empty catalog after quarantine"
1393        );
1394
1395        // The corrupt index is preserved as a timestamped sibling, not lost.
1396        let quarantined = std::fs::read_dir(&store_dir)
1397            .unwrap()
1398            .filter_map(|e| e.ok())
1399            .any(|e| {
1400                e.file_name()
1401                    .to_string_lossy()
1402                    .contains("index.json.corrupt-")
1403            });
1404        assert!(
1405            quarantined,
1406            "corrupt index.json must be quarantined to a sibling"
1407        );
1408    }
1409
1410    #[test]
1411    fn copy_dir_recursive_copies_nested_files_and_dir_size_sums() {
1412        let tmp = TempDir::new().unwrap();
1413        let src = tmp.path().join("src");
1414        let dst = tmp.path().join("dst");
1415        std::fs::create_dir_all(src.join("nested")).unwrap();
1416        std::fs::write(src.join("root.txt"), b"abc").unwrap();
1417        std::fs::write(src.join("nested/leaf.txt"), b"hello").unwrap();
1418
1419        copy_dir_recursive(&src, &dst).unwrap();
1420
1421        assert_eq!(std::fs::read(dst.join("root.txt")).unwrap(), b"abc");
1422        assert_eq!(
1423            std::fs::read(dst.join("nested/leaf.txt")).unwrap(),
1424            b"hello"
1425        );
1426        assert_eq!(dir_size(&dst), 8);
1427        assert_eq!(dir_size(&tmp.path().join("missing")), 0);
1428    }
1429
1430    #[test]
1431    fn copy_dir_recursive_fails_for_missing_source() {
1432        let tmp = TempDir::new().unwrap();
1433        let err = copy_dir_recursive(&tmp.path().join("missing"), &tmp.path().join("dst"))
1434            .expect_err("missing source should fail");
1435
1436        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1437    }
1438
1439    #[tokio::test]
1440    async fn put_rejects_path_shaped_digest_without_touching_host_path() {
1441        let tmp = TempDir::new().unwrap();
1442        let store_dir = tmp.path().join("store");
1443        let source_dir = tmp.path().join("source");
1444        let host_dir = tmp.path().join("host-target");
1445        create_test_oci_layout(&source_dir);
1446        std::fs::create_dir_all(&host_dir).unwrap();
1447        std::fs::write(host_dir.join("keep.txt"), b"host data").unwrap();
1448        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1449
1450        let error = store
1451            .put("evil:latest", "sha256:../../host-target", &source_dir)
1452            .await
1453            .unwrap_err();
1454
1455        assert!(error.to_string().contains("malformed content digest"));
1456        assert_eq!(
1457            std::fs::read(host_dir.join("keep.txt")).unwrap(),
1458            b"host data"
1459        );
1460        assert!(store.list().await.is_empty());
1461    }
1462
1463    #[cfg(unix)]
1464    #[tokio::test]
1465    async fn put_rejects_source_symlink_without_copying_target() {
1466        use std::os::unix::fs::symlink;
1467
1468        let tmp = TempDir::new().unwrap();
1469        let store_dir = tmp.path().join("store");
1470        let real_source = tmp.path().join("real-source");
1471        let source_link = tmp.path().join("source-link");
1472        create_test_oci_layout(&real_source);
1473        symlink(&real_source, &source_link).unwrap();
1474        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1475
1476        let error = store
1477            .put(
1478                "evil:latest",
1479                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1480                &source_link,
1481            )
1482            .await
1483            .unwrap_err();
1484
1485        assert!(error.to_string().contains("symbolic link"));
1486        assert!(store.list().await.is_empty());
1487        assert!(real_source.join("index.json").is_file());
1488    }
1489
1490    #[cfg(unix)]
1491    #[tokio::test]
1492    async fn put_rejects_extra_symlink_and_preserves_its_target() {
1493        use std::os::unix::fs::symlink;
1494
1495        let tmp = TempDir::new().unwrap();
1496        let store_dir = tmp.path().join("store");
1497        let source_dir = tmp.path().join("source");
1498        let host_file = tmp.path().join("host-secret.txt");
1499        create_test_oci_layout(&source_dir);
1500        std::fs::write(&host_file, b"secret").unwrap();
1501        symlink(&host_file, source_dir.join("extra-blob")).unwrap();
1502        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1503
1504        assert!(store
1505            .put(
1506                "evil:latest",
1507                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1508                &source_dir,
1509            )
1510            .await
1511            .is_err());
1512        assert_eq!(std::fs::read(&host_file).unwrap(), b"secret");
1513        assert!(store.list().await.is_empty());
1514    }
1515
1516    #[cfg(windows)]
1517    #[tokio::test]
1518    async fn put_rejects_windows_source_reparse_point() {
1519        let tmp = TempDir::new().unwrap();
1520        let store_dir = tmp.path().join("store");
1521        let source_dir = tmp.path().join("source");
1522        let host_file = tmp.path().join("host-secret.txt");
1523        let link = source_dir.join("extra-blob");
1524        create_test_oci_layout(&source_dir);
1525        std::fs::write(&host_file, b"secret").unwrap();
1526        match std::os::windows::fs::symlink_file(&host_file, &link) {
1527            Ok(()) => {}
1528            Err(error) if error.raw_os_error() == Some(1314) => return,
1529            Err(error) => panic!("failed to create Windows test symlink: {error}"),
1530        }
1531        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1532
1533        assert!(store
1534            .put(
1535                "evil:latest",
1536                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1537                &source_dir,
1538            )
1539            .await
1540            .is_err());
1541        assert_eq!(std::fs::read(&host_file).unwrap(), b"secret");
1542        assert!(store.list().await.is_empty());
1543    }
1544
1545    #[tokio::test]
1546    async fn load_index_rejects_forged_deletion_path() {
1547        let tmp = TempDir::new().unwrap();
1548        let store_dir = tmp.path().join("store");
1549        let victim = tmp.path().join("host-victim");
1550        std::fs::create_dir_all(&store_dir).unwrap();
1551        std::fs::create_dir_all(&victim).unwrap();
1552        std::fs::write(victim.join("keep.txt"), b"keep").unwrap();
1553
1554        let forged = stored_image(
1555            "evil:latest",
1556            "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
1557            victim.clone(),
1558        );
1559        let index = StoreIndex {
1560            images: vec![forged],
1561        };
1562        std::fs::write(
1563            store_dir.join("index.json"),
1564            serde_json::to_vec_pretty(&index).unwrap(),
1565        )
1566        .unwrap();
1567
1568        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1569        assert!(store.list().await.is_empty());
1570        assert!(store.remove("evil:latest").await.is_err());
1571        assert_eq!(std::fs::read(victim.join("keep.txt")).unwrap(), b"keep");
1572    }
1573
1574    #[tokio::test]
1575    async fn load_index_rederives_path_and_never_uses_forged_spelling() {
1576        let tmp = TempDir::new().unwrap();
1577        let store_dir = tmp.path().join("store");
1578        let digest_hex = "d".repeat(64);
1579        let digest = format!("sha256:{digest_hex}");
1580        let expected = store_dir.join("sha256").join(&digest_hex);
1581        let victim = tmp.path().join("host-victim");
1582        create_test_oci_layout(&expected);
1583        std::fs::create_dir_all(&victim).unwrap();
1584        std::fs::write(victim.join("keep.txt"), b"keep").unwrap();
1585
1586        // A persisted path may be an equivalent legacy spelling (notably an
1587        // 8.3 path on Windows), or it may be attacker-controlled. In either
1588        // case the digest-derived content directory is the only path to use.
1589        let forged = stored_image("safe:latest", &digest, victim.clone());
1590        let index = StoreIndex {
1591            images: vec![forged],
1592        };
1593        std::fs::write(
1594            store_dir.join("index.json"),
1595            serde_json::to_vec_pretty(&index).unwrap(),
1596        )
1597        .unwrap();
1598
1599        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1600        let loaded = store.get("safe:latest").await.unwrap();
1601        assert_eq!(loaded.path, expected);
1602
1603        store.remove("safe:latest").await.unwrap();
1604        assert!(!expected.exists());
1605        assert_eq!(std::fs::read(victim.join("keep.txt")).unwrap(), b"keep");
1606    }
1607}