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, HashSet};
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        // Content publication and index mutation must be one cross-process
171        // critical section. Publishing the directory first and locking only
172        // the index leaves a remove() window: remove can delete the last
173        // reference (and the content directory), after which put() inserts an
174        // index entry pointing at missing content. `put_with_digest_lock` keeps
175        // the blocking copy on a worker thread while fencing both operations;
176        // different digests retain parallel pull throughput.
177        self.put_with_digest_lock(
178            reference.to_string(),
179            digest.to_string(),
180            digest_root,
181            digest_hex.to_string(),
182            target_dir,
183            source_dir.to_path_buf(),
184        )
185        .await
186    }
187
188    /// Remove an image by reference or by image ID (digest).
189    ///
190    /// The CRI `RemoveImage` may identify an image either by a repo
191    /// reference/tag or by its image ID (`sha256:<digest>`, as returned in
192    /// `ImageStatus`). When `image` does not match a stored reference key,
193    /// fall back to removing every reference that points at the matching
194    /// digest.
195    pub async fn remove(&self, image: &str) -> Result<()> {
196        // Both put and remove acquire content → index.  The initial index read
197        // only discovers which digest locks are needed; the locked pass below
198        // re-reads and retries when a concurrent tag update introduces another
199        // digest, so no content directory can be deleted while a put is about
200        // to publish a reference to it.
201        let mut locked_digests = self.remove_candidate_digests(image).await?;
202        loop {
203            let mut content_locks = Vec::with_capacity(locked_digests.len());
204            for digest_hex in &locked_digests {
205                content_locks.push(self.acquire_content_lock(digest_hex).await?);
206            }
207
208            let index_lock = self.acquire_index_lock("remove").await?;
209            let fresh = self.read_index_from_disk()?;
210            let keys = image_keys_for_request(&fresh, image);
211            if keys.is_empty() {
212                return Err(BoxError::OciImageError(format!(
213                    "Image not found: {}",
214                    image
215                )));
216            }
217            let current_digests = image_digests_for_keys(&fresh, &keys)?;
218            if current_digests
219                .iter()
220                .any(|digest_hex| !locked_digests.contains(digest_hex))
221            {
222                // Release both lock classes before acquiring the newly needed
223                // digest lock; all writers use the same content → index order.
224                drop(index_lock);
225                drop(content_locks);
226                locked_digests = current_digests;
227                continue;
228            }
229
230            let store_dir = self.store_dir.clone();
231            {
232                let mut index = self.index.write().await;
233                *index = fresh;
234
235                // Stored paths are persisted data. Re-derive every deletion
236                // target from a validated digest instead of trusting a
237                // serialized path.
238                for key in &keys {
239                    let img = index.get(key).ok_or_else(|| {
240                        BoxError::OciImageError(format!("Image index entry disappeared: {key}"))
241                    })?;
242                    let digest_hex = super::registry::validated_digest_hex(&img.digest)?;
243                    let expected = store_dir.join("sha256").join(digest_hex);
244                    if img.path != expected {
245                        return Err(BoxError::OciImageError(format!(
246                            "Refusing unsafe image path {} for digest {} (expected {})",
247                            img.path.display(),
248                            img.digest,
249                            expected.display()
250                        )));
251                    }
252                }
253
254                let removed: Vec<StoredImage> =
255                    keys.iter().filter_map(|key| index.remove(key)).collect();
256
257                // Delete each image's on-disk layout once no remaining
258                // reference points at the same digest. References sharing a
259                // digest share the same directory, and the digest lock fences
260                // concurrent puts for that content.
261                for img in removed {
262                    let digest_still_used = index.values().any(|other| other.digest == img.digest);
263                    if !digest_still_used
264                        && real_directory_exists(&img.path).map_err(|error| {
265                            BoxError::OciImageError(format!(
266                                "Refusing unsafe image directory {}: {error}",
267                                img.path.display()
268                            ))
269                        })?
270                    {
271                        std::fs::remove_dir_all(&img.path).map_err(|error| {
272                            BoxError::OciImageError(format!(
273                                "Failed to remove image directory {}: {error}",
274                                img.path.display()
275                            ))
276                        })?;
277                    }
278                }
279            }
280            self.save_index_inner().await?;
281            drop(index_lock);
282            drop(content_locks);
283            return Ok(());
284        }
285    }
286
287    /// List all stored images.
288    pub async fn list(&self) -> Vec<StoredImage> {
289        if let Err(error) = self.refresh_index().await {
290            tracing::warn!(
291                %error,
292                "Failed to refresh the authoritative image store index before listing"
293            );
294        }
295        let index = self.index.read().await;
296        index.values().cloned().collect()
297    }
298
299    /// Evict least-recently-used images until total size is under the limit.
300    ///
301    /// Returns the references of evicted images.
302    pub async fn evict(&self) -> Result<Vec<String>> {
303        let mut evicted = Vec::new();
304        let mut total = self.total_size().await;
305
306        while total > self.max_size_bytes {
307            // Find the least recently used image
308            let lru_ref = {
309                let index = self.index.read().await;
310                index
311                    .values()
312                    .min_by_key(|img| img.last_used)
313                    .map(|img| img.reference.clone())
314            };
315
316            match lru_ref {
317                Some(reference) => {
318                    self.remove(&reference).await?;
319                    evicted.push(reference);
320                    total = self.total_size().await;
321                }
322                None => break,
323            }
324        }
325
326        Ok(evicted)
327    }
328
329    /// Get total size of all stored images in bytes.
330    pub async fn total_size(&self) -> u64 {
331        if let Err(error) = self.refresh_index().await {
332            tracing::warn!(
333                %error,
334                "Failed to refresh the authoritative image store index before sizing"
335            );
336        }
337        let index = self.index.read().await;
338        // Multiple tags (and digest-pinned aliases) can point at the same
339        // content-addressed directory.  Count each digest once; summing index
340        // entries reports a fictitious disk usage and makes LRU eviction fire
341        // early as soon as an image has more than one reference.
342        let mut seen_digests = HashSet::new();
343        index
344            .values()
345            .filter(|image| seen_digests.insert(image.digest.as_str()))
346            .map(|image| image.size_bytes)
347            .sum()
348    }
349
350    /// Load index from disk.
351    fn load_index(&mut self) -> Result<()> {
352        // Construction-time load; reuse the shared disk reader.
353        self.index = Arc::new(RwLock::new(self.read_index_from_disk()?));
354        Ok(())
355    }
356
357    /// Publish image content and its index entry while holding its digest lock.
358    ///
359    /// Keeping a digest-specific content lock across the worker-thread copy and
360    /// the short index transaction closes the only interval in which `remove` could
361    /// observe the old index, delete a shared content directory, and leave the
362    /// new `put` entry pointing at a missing path. Read-only index operations do
363    /// not take this lock and can continue while a pull copies content.
364    async fn put_with_digest_lock(
365        &self,
366        reference: String,
367        digest: String,
368        digest_root: PathBuf,
369        digest_hex: String,
370        target_dir: PathBuf,
371        source_dir: PathBuf,
372    ) -> Result<StoredImage> {
373        let _content_lock = self.acquire_content_lock(&digest_hex).await?;
374
375        let target_for_worker = target_dir.clone();
376        let digest_root_for_worker = digest_root;
377        let digest_hex_for_worker = digest_hex;
378        let size_bytes = tokio::task::spawn_blocking(move || {
379            publish_image_content_if_missing(
380                &digest_root_for_worker,
381                &digest_hex_for_worker,
382                &target_for_worker,
383                &source_dir,
384            )?;
385            Ok::<u64, BoxError>(dir_size(&target_for_worker))
386        })
387        .await
388        .map_err(|error| {
389            BoxError::OciImageError(format!("Image content publication task failed: {error}"))
390        })??;
391
392        let now = Utc::now();
393        let stored = StoredImage {
394            reference: reference.clone(),
395            digest: digest.clone(),
396            size_bytes,
397            pulled_at: now,
398            last_used: now,
399            path: target_dir,
400        };
401
402        self.with_index_lock(|index| {
403            // Docker parity: if this reference already points at a DIFFERENT
404            // digest and that old digest is about to lose its last reference,
405            // keep the displaced image as a dangling entry (keyed by its
406            // digest) instead of dropping it. This makes a rebuilt/re-tagged
407            // image show up as `<none>` in `images`, be removable by
408            // `image prune`, and prevents silently orphaning its layout.
409            if let Some(old) = index.get(&reference).cloned() {
410                if old.digest != digest {
411                    let still_referenced = index.iter().any(|(key, image)| {
412                        key.as_str() != reference && image.digest == old.digest
413                    });
414                    if !still_referenced && !index.contains_key(&old.digest) {
415                        let mut dangling = old.clone();
416                        dangling.reference = old.digest.clone();
417                        index.insert(old.digest.clone(), dangling);
418                    }
419                }
420            }
421
422            index.insert(reference, stored.clone());
423            Ok(())
424        })
425        .await?;
426        Ok(stored)
427    }
428
429    /// Refresh the in-memory index from disk under the cross-process lock.
430    ///
431    /// Read-only callers such as ListImages and ImageFsInfo must observe pulls
432    /// and removals made by another Box process.  They must not use the stale
433    /// constructor snapshot, but also must not rewrite the index merely to
434    /// perform a read.
435    async fn refresh_index(&self) -> Result<()> {
436        let _lock = self.acquire_index_lock("refresh").await?;
437
438        let fresh = self.read_index_from_disk()?;
439        let mut index = self.index.write().await;
440        *index = fresh;
441        Ok(())
442    }
443
444    /// Acquire the image-index lock without blocking a Tokio worker thread.
445    async fn acquire_index_lock(&self, operation: &str) -> Result<crate::file_lock::FileLock> {
446        let index_path = self.store_dir.join("index.json");
447        let lock_path = index_path.clone();
448        tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&lock_path))
449            .await
450            .map_err(|error| {
451                BoxError::OciImageError(format!("index {operation} lock task failed: {error}"))
452            })?
453            .map_err(|error| {
454                BoxError::OciImageError(format!(
455                    "failed to lock image index {} for {operation}: {error}. {}",
456                    index_path.display(),
457                    state_dir_hint()
458                ))
459            })
460    }
461
462    /// Read the authoritative index once to discover the digest lock(s) needed
463    /// by a remove request. The locked removal pass rechecks this snapshot
464    /// before mutating anything, so this lookup is only a lock-planning step.
465    async fn remove_candidate_digests(&self, image: &str) -> Result<Vec<String>> {
466        let _lock = self.acquire_index_lock("remove lookup").await?;
467        let fresh = self.read_index_from_disk()?;
468        let keys = image_keys_for_request(&fresh, image);
469        if keys.is_empty() {
470            return Err(BoxError::OciImageError(format!(
471                "Image not found: {}",
472                image
473            )));
474        }
475        image_digests_for_keys(&fresh, &keys)
476    }
477
478    /// Acquire one digest's content lock without blocking a Tokio worker.
479    ///
480    /// The lock file lives beside the content-addressed directories and is
481    /// keyed only by a validated digest component. `put` and `remove` both
482    /// acquire digest lock(s) before the index lock, allowing unrelated image
483    /// digests to be copied concurrently without reopening the publication /
484    /// deletion race.
485    async fn acquire_content_lock(&self, digest_hex: &str) -> Result<crate::file_lock::FileLock> {
486        let digest_root = self.store_dir.join("sha256");
487        let target = digest_root.join(format!(".content-{digest_hex}"));
488        let lock_target = target.clone();
489        tokio::task::spawn_blocking(move || {
490            std::fs::create_dir_all(&digest_root)?;
491            require_real_directory(&digest_root)?;
492            crate::file_lock::FileLock::acquire(&lock_target)
493        })
494        .await
495        .map_err(|error| {
496            BoxError::OciImageError(format!("image content lock task failed: {error}"))
497        })?
498        .map_err(|error| {
499            BoxError::OciImageError(format!(
500                "failed to lock image content {}: {error}. {}",
501                target.display(),
502                state_dir_hint()
503            ))
504        })
505    }
506
507    /// Read and parse `index.json` from disk into a fresh map (entries whose
508    /// content dir vanished are dropped). Does NOT touch `self.index`.
509    fn read_index_from_disk(&self) -> Result<HashMap<String, StoredImage>> {
510        let index_path = self.store_dir.join("index.json");
511        if !index_path.exists() {
512            return Ok(HashMap::new());
513        }
514
515        let data = std::fs::read_to_string(&index_path).map_err(|e| {
516            BoxError::OciImageError(format!(
517                "Failed to read image store index {}: {}",
518                index_path.display(),
519                e
520            ))
521        })?;
522
523        // Parse resiliently so a corrupt/old-schema index never bricks the whole
524        // catalog or blocks CRI/CLI startup. First read the `{ images: [...] }`
525        // envelope leniently (entries as raw values); if even that fails the file
526        // is unusable, so quarantine it and start from an empty catalog that
527        // re-pulls repopulate. Then deserialize each entry independently, skipping
528        // (not failing on) any one corrupt/incompatible record.
529        #[derive(serde::Deserialize)]
530        struct RawIndex {
531            #[serde(default)]
532            images: Vec<serde_json::Value>,
533        }
534
535        let raw: RawIndex = match serde_json::from_str(&data) {
536            Ok(raw) => raw,
537            Err(err) => {
538                let preserved = crate::store_io::quarantine_label(&index_path);
539                tracing::warn!(
540                    "image store index {} is corrupt ({err}); preserved a copy at \
541                     {preserved} and started from an empty catalog (re-pulled images \
542                     will repopulate it)",
543                    index_path.display(),
544                );
545                return Ok(HashMap::new());
546            }
547        };
548
549        let mut index = HashMap::new();
550        let mut skipped = 0usize;
551        for value in raw.images {
552            match serde_json::from_value::<StoredImage>(value) {
553                Ok(mut image) => {
554                    // The serialized path is compatibility metadata, not an
555                    // authority for filesystem access. Windows can persist an
556                    // equivalent 8.3 spelling (for example `WODEDI~1`) and then
557                    // reopen the store through the long spelling. Lexical path
558                    // equality rejects that valid entry. Re-derive the only
559                    // permitted location from the validated digest and replace
560                    // the persisted spelling before exposing the record.
561                    let expected = super::registry::validated_digest_hex(&image.digest)
562                        .map(|digest_hex| self.store_dir.join("sha256").join(digest_hex));
563                    match expected {
564                        Ok(expected) if real_directory_exists(&expected).unwrap_or(false) => {
565                            image.path = expected;
566                            index.insert(image.reference.clone(), image);
567                        }
568                        _ => {
569                            skipped += 1;
570                            tracing::warn!(
571                                reference = %image.reference,
572                                digest = %image.digest,
573                                path = %image.path.display(),
574                                "skipping image index entry with malformed digest or unsafe path"
575                            );
576                        }
577                    }
578                }
579                Err(err) => {
580                    skipped += 1;
581                    tracing::warn!("skipping unreadable image index entry ({err})");
582                }
583            }
584        }
585        if skipped > 0 {
586            // Preserve the original (with the un-deserializable entries) before the
587            // next save rewrites index.json with only the survivors — otherwise the
588            // skipped records are erased with no backup, unlike the whole-file path.
589            let preserved = crate::store_io::quarantine_copy(&index_path)
590                .map(|p| p.display().to_string())
591                .unwrap_or_else(|| "<backup failed>".to_string());
592            tracing::warn!(
593                "{skipped} image index entr{} skipped as unreadable; preserved a copy at \
594                 {preserved}; affected images will be re-pulled on demand",
595                if skipped == 1 { "y" } else { "ies" },
596            );
597        }
598        Ok(index)
599    }
600
601    /// Apply `f` to the image index under the **cross-process write lock**:
602    /// reload `index.json` from disk (so this process observes other processes'
603    /// pulls/removes), let `f` mutate the map, then save. Without this, two
604    /// processes pulling concurrently each load their own snapshot and the
605    /// second `save` drops the first's entry (and leaks its content dir).
606    ///
607    /// The blocking `flock` is acquired off the runtime via `spawn_blocking`;
608    /// `save_index_inner` is lock-free, so there is no re-entrant `flock`.
609    async fn with_index_lock<F, R>(&self, f: F) -> Result<R>
610    where
611        F: FnOnce(&mut HashMap<String, StoredImage>) -> Result<R>,
612    {
613        let _lock = self.acquire_index_lock("write").await?;
614        // Sync the in-memory index with disk (pick up other processes' writes).
615        let fresh = self.read_index_from_disk()?;
616        let result = {
617            let mut idx = self.index.write().await;
618            *idx = fresh;
619            f(&mut idx)?
620        };
621        self.save_index_inner().await?;
622        Ok(result)
623    }
624
625    /// Save index to disk (async inner helper).
626    async fn save_index_inner(&self) -> Result<()> {
627        let index = self.index.read().await;
628        let store_index = StoreIndex {
629            images: index.values().cloned().collect(),
630        };
631        drop(index);
632
633        let data = serde_json::to_vec_pretty(&store_index)?;
634        let index_path = self.store_dir.join("index.json");
635        let tmp_path = self.store_dir.join("index.json.tmp");
636        let display_path = index_path.clone();
637        tokio::task::spawn_blocking(move || {
638            a3s_box_core::fs_atomic::write_durable(&tmp_path, &index_path, &data)
639        })
640        .await
641        .map_err(|error| {
642            BoxError::OciImageError(format!(
643                "Image store index persistence task failed for {}: {error}",
644                display_path.display()
645            ))
646        })?
647        .map_err(|error| {
648            BoxError::OciImageError(format!(
649                "Failed to durably commit image store index {}: {error}. {}",
650                display_path.display(),
651                state_dir_hint()
652            ))
653        })?;
654
655        Ok(())
656    }
657
658    /// Get the store directory path.
659    pub fn store_dir(&self) -> &Path {
660        &self.store_dir
661    }
662}
663
664#[async_trait::async_trait]
665impl ImageStoreBackend for ImageStore {
666    async fn get(&self, reference: &str) -> Option<StoredImage> {
667        self.get(reference).await
668    }
669
670    async fn get_by_digest(&self, digest: &str) -> Option<StoredImage> {
671        self.get_by_digest(digest).await
672    }
673
674    async fn put(&self, reference: &str, digest: &str, source_dir: &Path) -> Result<StoredImage> {
675        self.put(reference, digest, source_dir).await
676    }
677
678    async fn remove(&self, reference: &str) -> Result<()> {
679        self.remove(reference).await
680    }
681
682    async fn list(&self) -> Vec<StoredImage> {
683        self.list().await
684    }
685
686    async fn evict(&self) -> Result<Vec<String>> {
687        self.evict().await
688    }
689
690    async fn total_size(&self) -> u64 {
691        self.total_size().await
692    }
693}
694
695#[cfg(windows)]
696fn metadata_is_reparse_point(metadata: &std::fs::Metadata) -> bool {
697    use std::os::windows::fs::MetadataExt;
698
699    // FILE_ATTRIBUTE_REPARSE_POINT. Keep the value local so runtime does not
700    // need another windows-sys feature solely to classify metadata.
701    metadata.file_attributes() & 0x0000_0400 != 0
702}
703
704#[cfg(not(windows))]
705fn metadata_is_reparse_point(_metadata: &std::fs::Metadata) -> bool {
706    false
707}
708
709fn require_real_directory(path: &Path) -> std::io::Result<()> {
710    let metadata = std::fs::symlink_metadata(path)?;
711    if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
712        return Err(std::io::Error::new(
713            std::io::ErrorKind::PermissionDenied,
714            format!(
715                "refusing symbolic link or reparse-point directory {}",
716                path.display()
717            ),
718        ));
719    }
720    if !metadata.is_dir() {
721        return Err(std::io::Error::new(
722            std::io::ErrorKind::InvalidData,
723            format!("expected a directory at {}", path.display()),
724        ));
725    }
726    Ok(())
727}
728
729fn real_directory_exists(path: &Path) -> std::io::Result<bool> {
730    match require_real_directory(path) {
731        Ok(()) => Ok(true),
732        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
733        Err(error) => Err(error),
734    }
735}
736
737#[cfg(unix)]
738fn open_regular_source_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
739    use std::os::unix::fs::OpenOptionsExt;
740
741    let mut options = std::fs::OpenOptions::new();
742    options
743        .read(true)
744        .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
745    let file = options.open(path)?;
746    if !file.metadata()?.is_file() {
747        return Err(std::io::Error::new(
748            std::io::ErrorKind::InvalidData,
749            format!("expected a regular file at {}", path.display()),
750        ));
751    }
752    Ok(file)
753}
754
755#[cfg(windows)]
756fn open_regular_source_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
757    a3s_box_core::windows_file::open_regular_file(path, None).map(|(file, _)| file)
758}
759
760#[cfg(not(any(unix, windows)))]
761fn open_regular_source_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
762    let metadata = std::fs::symlink_metadata(path)?;
763    if metadata.file_type().is_symlink() || !metadata.is_file() {
764        return Err(std::io::Error::new(
765            std::io::ErrorKind::InvalidData,
766            format!("expected a regular file at {}", path.display()),
767        ));
768    }
769    std::fs::File::open(path)
770}
771
772fn copy_regular_file_no_follow(src: &Path, dst: &Path) -> std::io::Result<()> {
773    let mut source = open_regular_source_no_follow(src)?;
774    let mut destination = std::fs::OpenOptions::new()
775        .write(true)
776        .create_new(true)
777        .open(dst)?;
778    std::io::copy(&mut source, &mut destination)?;
779    Ok(())
780}
781
782/// Copy directory contents while rejecting every source link, junction,
783/// reparse point, and special file. OCI layout symlinks are never required:
784/// guest symlinks live inside opaque layer tar blobs instead.
785fn copy_dir_contents_no_follow(src: &Path, dst: &Path) -> std::io::Result<()> {
786    require_real_directory(src)?;
787    require_real_directory(dst)?;
788
789    for entry in std::fs::read_dir(src)? {
790        let entry = entry?;
791        let src_path = entry.path();
792        let dst_path = dst.join(entry.file_name());
793        let metadata = std::fs::symlink_metadata(&src_path)?;
794
795        if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
796            return Err(std::io::Error::new(
797                std::io::ErrorKind::PermissionDenied,
798                format!(
799                    "refusing symbolic link or reparse point in OCI layout: {}",
800                    src_path.display()
801                ),
802            ));
803        }
804        if metadata.is_dir() {
805            std::fs::create_dir(&dst_path)?;
806            copy_dir_contents_no_follow(&src_path, &dst_path)?;
807        } else if metadata.is_file() {
808            copy_regular_file_no_follow(&src_path, &dst_path)?;
809        } else {
810            return Err(std::io::Error::new(
811                std::io::ErrorKind::InvalidData,
812                format!(
813                    "refusing special file in OCI layout: {}",
814                    src_path.display()
815                ),
816            ));
817        }
818    }
819    Ok(())
820}
821
822/// Publish one content-addressed image directory if it is not already present.
823///
824/// The caller holds the digest-specific content lock for the complete
825/// publication. The lock is deliberately separate from the image-index lock so
826/// that the potentially large copy does not serialize unrelated digests.
827fn publish_image_content_if_missing(
828    digest_root: &Path,
829    digest_hex: &str,
830    target_dir: &Path,
831    source_dir: &Path,
832) -> Result<()> {
833    require_real_directory(digest_root).map_err(|error| {
834        BoxError::OciImageError(format!(
835            "Unsafe image content directory {}: {error}",
836            digest_root.display()
837        ))
838    })?;
839    if real_directory_exists(target_dir).map_err(|error| {
840        BoxError::OciImageError(format!(
841            "Unsafe existing image directory {}: {error}",
842            target_dir.display()
843        ))
844    })? {
845        return Ok(());
846    }
847
848    // Reserve the staging directory atomically. Never remove a guessed path
849    // first: a local reparse point at that name must not turn cleanup into
850    // traversal outside the store.
851    let staging = loop {
852        let seq = PUT_SEQ.fetch_add(1, Ordering::Relaxed);
853        let candidate = digest_root.join(format!(
854            ".staging-{}-{}-{}",
855            digest_hex,
856            std::process::id(),
857            seq
858        ));
859        match std::fs::create_dir(&candidate) {
860            Ok(()) => break candidate,
861            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
862            Err(error) => {
863                return Err(BoxError::OciImageError(format!(
864                    "Failed to reserve image staging directory {}: {error}",
865                    candidate.display()
866                )))
867            }
868        }
869    };
870
871    if let Err(error) = copy_dir_contents_no_follow(source_dir, &staging) {
872        let _ = std::fs::remove_dir_all(&staging);
873        return Err(BoxError::OciImageError(format!(
874            "Failed to copy image to store: {error}"
875        )));
876    }
877
878    if let Err(error) = std::fs::rename(&staging, target_dir) {
879        let _ = std::fs::remove_dir_all(&staging);
880        // A legacy/concurrent writer may have populated target_dir first. Keep
881        // the winner only when it is a real directory; never accept a link or
882        // another unsafe entry as a successful publication.
883        if !real_directory_exists(target_dir).map_err(|check_error| {
884            BoxError::OciImageError(format!(
885                "Unsafe concurrently published image directory {}: {check_error}",
886                target_dir.display()
887            ))
888        })? {
889            return Err(BoxError::OciImageError(format!(
890                "Failed to publish image to store: {error}"
891            )));
892        }
893    }
894
895    if !real_directory_exists(target_dir).map_err(|error| {
896        BoxError::OciImageError(format!(
897            "Unsafe published image directory {}: {error}",
898            target_dir.display()
899        ))
900    })? {
901        return Err(BoxError::OciImageError(format!(
902            "Image content directory {} is missing after publication",
903            target_dir.display()
904        )));
905    }
906    Ok(())
907}
908
909/// Resolve an image removal request against an already-loaded index.
910fn image_keys_for_request(index: &HashMap<String, StoredImage>, image: &str) -> Vec<String> {
911    if index.contains_key(image) {
912        vec![image.to_string()]
913    } else {
914        index
915            .values()
916            .filter(|entry| entry.digest == image)
917            .map(|entry| entry.reference.clone())
918            .collect()
919    }
920}
921
922/// Return sorted, de-duplicated validated digest components for index entries.
923fn image_digests_for_keys(
924    index: &HashMap<String, StoredImage>,
925    keys: &[String],
926) -> Result<Vec<String>> {
927    let mut digests: Vec<String> = keys
928        .iter()
929        .map(|key| {
930            index
931                .get(key)
932                .ok_or_else(|| {
933                    BoxError::OciImageError(format!("Image index entry disappeared: {key}"))
934                })
935                .and_then(|entry| {
936                    super::registry::validated_digest_hex(&entry.digest).map(str::to_owned)
937                })
938        })
939        .collect::<Result<Vec<_>>>()?;
940    digests.sort_unstable();
941    digests.dedup();
942    Ok(digests)
943}
944
945/// Recursively copy a directory into a newly created destination.
946#[cfg(test)]
947fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
948    std::fs::create_dir(dst)?;
949    copy_dir_contents_no_follow(src, dst)
950}
951
952/// Calculate total size without following links that may have appeared in a
953/// corrupted or externally modified store.
954fn dir_size(path: &Path) -> u64 {
955    let Ok(metadata) = std::fs::symlink_metadata(path) else {
956        return 0;
957    };
958    if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
959        return 0;
960    }
961    if metadata.is_file() {
962        return metadata.len();
963    }
964    if !metadata.is_dir() {
965        return 0;
966    }
967
968    std::fs::read_dir(path)
969        .map(|entries| entries.flatten().map(|entry| dir_size(&entry.path())).sum())
970        .unwrap_or(0)
971}
972
973#[cfg(test)]
974mod tests {
975    use super::*;
976    use tempfile::TempDir;
977
978    fn create_test_oci_layout(dir: &Path) {
979        std::fs::create_dir_all(dir.join("blobs/sha256")).unwrap();
980        std::fs::write(dir.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
981        std::fs::write(dir.join("index.json"), r#"{"manifests":[]}"#).unwrap();
982        // Write some blob data to have measurable size
983        std::fs::write(dir.join("blobs/sha256/testblob"), "x".repeat(1024)).unwrap();
984    }
985
986    fn stored_image(reference: &str, digest: &str, path: PathBuf) -> StoredImage {
987        let now = Utc::now();
988        StoredImage {
989            reference: reference.to_string(),
990            digest: digest.to_string(),
991            size_bytes: 1024,
992            pulled_at: now,
993            last_used: now,
994            path,
995        }
996    }
997
998    #[tokio::test]
999    async fn test_new_creates_directory() {
1000        let tmp = TempDir::new().unwrap();
1001        let store_dir = tmp.path().join("images");
1002        let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
1003        assert!(store_dir.exists());
1004        assert_eq!(store.total_size().await, 0);
1005    }
1006
1007    #[tokio::test]
1008    async fn test_new_keeps_existing_tmp_dir_for_concurrent_pulls() {
1009        let tmp = TempDir::new().unwrap();
1010        let store_dir = tmp.path().join("images");
1011        let tmp_dir = store_dir.join("tmp");
1012        std::fs::create_dir_all(tmp_dir.join("pull-1")).unwrap();
1013        std::fs::write(tmp_dir.join("pull-1/layer"), b"partial").unwrap();
1014
1015        let store = ImageStore::new(&store_dir, 1024 * 1024).unwrap();
1016
1017        assert!(
1018            tmp_dir.join("pull-1/layer").exists(),
1019            "constructing a store must not delete another process' active pull"
1020        );
1021        assert_eq!(store.total_size().await, 0);
1022    }
1023
1024    #[tokio::test]
1025    async fn test_put_and_get() {
1026        let tmp = TempDir::new().unwrap();
1027        let store_dir = tmp.path().join("store");
1028        let source_dir = tmp.path().join("source");
1029        create_test_oci_layout(&source_dir);
1030
1031        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1032
1033        let stored = store
1034            .put(
1035                "nginx:latest",
1036                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1037                &source_dir,
1038            )
1039            .await
1040            .unwrap();
1041
1042        assert_eq!(stored.reference, "nginx:latest");
1043        assert_eq!(
1044            stored.digest,
1045            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1046        );
1047        assert!(stored.size_bytes > 0);
1048        assert!(stored.path.exists());
1049
1050        // Get by reference
1051        let fetched = store.get("nginx:latest").await.unwrap();
1052        assert_eq!(
1053            fetched.digest,
1054            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1055        );
1056
1057        // Get by digest
1058        let fetched = store
1059            .get_by_digest(
1060                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1061            )
1062            .await
1063            .unwrap();
1064        assert_eq!(fetched.reference, "nginx:latest");
1065    }
1066
1067    #[tokio::test]
1068    async fn test_get_nonexistent() {
1069        let tmp = TempDir::new().unwrap();
1070        let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
1071        assert!(store.get("nonexistent").await.is_none());
1072    }
1073
1074    #[tokio::test]
1075    async fn test_remove() {
1076        let tmp = TempDir::new().unwrap();
1077        let store_dir = tmp.path().join("store");
1078        let source_dir = tmp.path().join("source");
1079        create_test_oci_layout(&source_dir);
1080
1081        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1082        store
1083            .put(
1084                "nginx:latest",
1085                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1086                &source_dir,
1087            )
1088            .await
1089            .unwrap();
1090
1091        store.remove("nginx:latest").await.unwrap();
1092        assert!(store.get("nginx:latest").await.is_none());
1093    }
1094
1095    #[tokio::test]
1096    async fn test_remove_one_tag_keeps_shared_digest_until_last_reference() {
1097        let tmp = TempDir::new().unwrap();
1098        let store_dir = tmp.path().join("store");
1099        let source_dir = tmp.path().join("source");
1100        create_test_oci_layout(&source_dir);
1101
1102        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1103        store
1104            .put(
1105                "img:v1",
1106                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1107                &source_dir,
1108            )
1109            .await
1110            .unwrap();
1111        let stored = store
1112            .put(
1113                "img:latest",
1114                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1115                &source_dir,
1116            )
1117            .await
1118            .unwrap();
1119        let path = stored.path.clone();
1120
1121        store.remove("img:v1").await.unwrap();
1122        assert!(store.get("img:v1").await.is_none());
1123        assert!(store.get("img:latest").await.is_some());
1124        assert!(path.exists(), "shared layout should remain in use");
1125
1126        store.remove("img:latest").await.unwrap();
1127        assert!(!path.exists(), "layout should be removed after final tag");
1128    }
1129
1130    #[tokio::test]
1131    async fn test_total_size_counts_shared_digest_once() {
1132        let tmp = TempDir::new().unwrap();
1133        let store_dir = tmp.path().join("store");
1134        let source_dir = tmp.path().join("source");
1135        create_test_oci_layout(&source_dir);
1136
1137        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1138        let first = store
1139            .put(
1140                "img:v1",
1141                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1142                &source_dir,
1143            )
1144            .await
1145            .unwrap();
1146        store
1147            .put(
1148                "img:latest",
1149                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1150                &source_dir,
1151            )
1152            .await
1153            .unwrap();
1154
1155        assert_eq!(
1156            store.total_size().await,
1157            first.size_bytes,
1158            "shared content must contribute one directory to disk usage"
1159        );
1160    }
1161
1162    #[tokio::test]
1163    async fn test_retag_keeps_displaced_image_as_dangling() {
1164        // Docker parity: re-pointing a tag at a new digest leaves the old image
1165        // as a dangling entry (keyed by its digest), not silently dropped.
1166        let tmp = TempDir::new().unwrap();
1167        let store_dir = tmp.path().join("store");
1168        let source_dir = tmp.path().join("source");
1169        create_test_oci_layout(&source_dir);
1170
1171        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1172        store
1173            .put(
1174                "app:latest",
1175                "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
1176                &source_dir,
1177            )
1178            .await
1179            .unwrap();
1180        store
1181            .put(
1182                "app:latest",
1183                "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
1184                &source_dir,
1185            )
1186            .await
1187            .unwrap();
1188
1189        // The tag now resolves to the new digest...
1190        assert_eq!(
1191            store.get("app:latest").await.unwrap().digest,
1192            "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
1193        );
1194        // ...and the displaced image survives as a digest-keyed dangling entry.
1195        let dangling = store
1196            .get("sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")
1197            .await
1198            .unwrap();
1199        assert_eq!(
1200            dangling.digest,
1201            "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
1202        );
1203        assert_eq!(store.list().await.len(), 2);
1204    }
1205
1206    #[tokio::test]
1207    async fn test_reput_same_digest_does_not_create_dangling() {
1208        // Re-putting the same reference at the SAME digest (e.g. pulling latest
1209        // when content is unchanged) must not spawn a spurious dangling entry.
1210        let tmp = TempDir::new().unwrap();
1211        let store_dir = tmp.path().join("store");
1212        let source_dir = tmp.path().join("source");
1213        create_test_oci_layout(&source_dir);
1214
1215        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1216        store
1217            .put(
1218                "app:latest",
1219                "sha256:1111111111111111111111111111111111111111111111111111111111111111",
1220                &source_dir,
1221            )
1222            .await
1223            .unwrap();
1224        store
1225            .put(
1226                "app:latest",
1227                "sha256:1111111111111111111111111111111111111111111111111111111111111111",
1228                &source_dir,
1229            )
1230            .await
1231            .unwrap();
1232
1233        assert_eq!(store.list().await.len(), 1);
1234    }
1235
1236    #[tokio::test]
1237    async fn test_remove_by_digest() {
1238        // CRI RemoveImage identifies the image by its ID (sha256 digest),
1239        // not its tag. Removing by digest must drop the reference + layout.
1240        let tmp = TempDir::new().unwrap();
1241        let store_dir = tmp.path().join("store");
1242        let source_dir = tmp.path().join("source");
1243        create_test_oci_layout(&source_dir);
1244
1245        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1246        let stored = store
1247            .put(
1248                "gcr.io/test/img:test",
1249                "sha256:2222222222222222222222222222222222222222222222222222222222222222",
1250                &source_dir,
1251            )
1252            .await
1253            .unwrap();
1254        let path = stored.path.clone();
1255
1256        store
1257            .remove("sha256:2222222222222222222222222222222222222222222222222222222222222222")
1258            .await
1259            .unwrap();
1260        assert!(store.get("gcr.io/test/img:test").await.is_none());
1261        assert!(store
1262            .get_by_digest(
1263                "sha256:2222222222222222222222222222222222222222222222222222222222222222"
1264            )
1265            .await
1266            .is_none());
1267        assert!(!path.exists(), "on-disk layout should be deleted");
1268    }
1269
1270    #[tokio::test]
1271    async fn test_remove_by_digest_removes_all_tags() {
1272        // Two tags sharing one digest: removing by digest drops both and
1273        // deletes the shared layout exactly once.
1274        let tmp = TempDir::new().unwrap();
1275        let store_dir = tmp.path().join("store");
1276        let source_dir = tmp.path().join("source");
1277        create_test_oci_layout(&source_dir);
1278
1279        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1280        store
1281            .put(
1282                "img:v1",
1283                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1284                &source_dir,
1285            )
1286            .await
1287            .unwrap();
1288        let stored = store
1289            .put(
1290                "img:latest",
1291                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1292                &source_dir,
1293            )
1294            .await
1295            .unwrap();
1296        let path = stored.path.clone();
1297
1298        store
1299            .remove("sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
1300            .await
1301            .unwrap();
1302        assert!(store.get("img:v1").await.is_none());
1303        assert!(store.get("img:latest").await.is_none());
1304        assert!(!path.exists(), "shared layout should be deleted");
1305    }
1306
1307    #[tokio::test]
1308    async fn test_resolve_by_name_digest_and_normalized() {
1309        let tmp = TempDir::new().unwrap();
1310        let store_dir = tmp.path().join("store");
1311        let source_dir = tmp.path().join("source");
1312        create_test_oci_layout(&source_dir);
1313
1314        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1315        store
1316            .put(
1317                "gcr.io/x/test-image-predefined-group:latest",
1318                "sha256:3333333333333333333333333333333333333333333333333333333333333333",
1319                &source_dir,
1320            )
1321            .await
1322            .unwrap();
1323
1324        // Exact reference.
1325        assert!(store
1326            .resolve("gcr.io/x/test-image-predefined-group:latest")
1327            .await
1328            .is_some());
1329        // Unnormalized name (no tag -> :latest) — the CreateContainer case.
1330        assert_eq!(
1331            store
1332                .resolve("gcr.io/x/test-image-predefined-group")
1333                .await
1334                .map(|i| i.digest),
1335            Some(
1336                "sha256:3333333333333333333333333333333333333333333333333333333333333333"
1337                    .to_string()
1338            )
1339        );
1340        // Image id (bare digest) and a name@digest pin.
1341        assert!(store
1342            .resolve("sha256:3333333333333333333333333333333333333333333333333333333333333333")
1343            .await
1344            .is_some());
1345        assert!(store
1346            .resolve("gcr.io/x/test-image-predefined-group@sha256:3333333333333333333333333333333333333333333333333333333333333333")
1347            .await
1348            .is_some());
1349        // Unknown.
1350        assert!(store.resolve("nope:latest").await.is_none());
1351    }
1352
1353    #[tokio::test]
1354    async fn test_resolve_invalid_reference_returns_none() {
1355        let tmp = TempDir::new().unwrap();
1356        let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
1357
1358        assert!(store.resolve("registry.example.com/").await.is_none());
1359        assert!(store.resolve("busybox@not-a-digest").await.is_none());
1360    }
1361
1362    #[tokio::test]
1363    async fn test_remove_nonexistent() {
1364        let tmp = TempDir::new().unwrap();
1365        let store = ImageStore::new(tmp.path(), 1024 * 1024).unwrap();
1366        assert!(store.remove("nonexistent").await.is_err());
1367    }
1368
1369    #[tokio::test]
1370    async fn test_list() {
1371        let tmp = TempDir::new().unwrap();
1372        let store_dir = tmp.path().join("store");
1373        let source_dir = tmp.path().join("source");
1374        create_test_oci_layout(&source_dir);
1375
1376        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1377        store
1378            .put(
1379                "nginx:latest",
1380                "sha256:4444444444444444444444444444444444444444444444444444444444444444",
1381                &source_dir,
1382            )
1383            .await
1384            .unwrap();
1385        store
1386            .put(
1387                "alpine:3.18",
1388                "sha256:5555555555555555555555555555555555555555555555555555555555555555",
1389                &source_dir,
1390            )
1391            .await
1392            .unwrap();
1393
1394        let images = store.list().await;
1395        assert_eq!(images.len(), 2);
1396    }
1397
1398    #[tokio::test]
1399    async fn test_total_size() {
1400        let tmp = TempDir::new().unwrap();
1401        let store_dir = tmp.path().join("store");
1402        let source_dir = tmp.path().join("source");
1403        create_test_oci_layout(&source_dir);
1404
1405        let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1406        store
1407            .put(
1408                "nginx:latest",
1409                "sha256:4444444444444444444444444444444444444444444444444444444444444444",
1410                &source_dir,
1411            )
1412            .await
1413            .unwrap();
1414
1415        assert!(store.total_size().await > 0);
1416    }
1417
1418    #[tokio::test]
1419    async fn test_lru_eviction() {
1420        let tmp = TempDir::new().unwrap();
1421        let store_dir = tmp.path().join("store");
1422        let source_dir = tmp.path().join("source");
1423        create_test_oci_layout(&source_dir);
1424
1425        // Set max size very small to trigger eviction
1426        let store = ImageStore::new(&store_dir, 100).unwrap();
1427
1428        store
1429            .put(
1430                "old:v1",
1431                "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
1432                &source_dir,
1433            )
1434            .await
1435            .unwrap();
1436
1437        // Sleep briefly so timestamps differ
1438        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1439
1440        store
1441            .put(
1442                "new:v2",
1443                "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1444                &source_dir,
1445            )
1446            .await
1447            .unwrap();
1448
1449        // Access the newer one to update its last_used
1450        store.get("new:v2").await;
1451
1452        let evicted = store.evict().await.unwrap();
1453        // At least one image should be evicted (the older one first)
1454        assert!(!evicted.is_empty());
1455        assert!(evicted.contains(&"old:v1".to_string()));
1456    }
1457
1458    #[tokio::test]
1459    async fn test_evict_empty_and_under_limit_returns_empty() {
1460        let tmp = TempDir::new().unwrap();
1461        let store_dir = tmp.path().join("store");
1462        let source_dir = tmp.path().join("source");
1463        create_test_oci_layout(&source_dir);
1464
1465        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1466        assert!(store.evict().await.unwrap().is_empty());
1467
1468        store
1469            .put(
1470                "tiny:latest",
1471                "sha256:6666666666666666666666666666666666666666666666666666666666666666",
1472                &source_dir,
1473            )
1474            .await
1475            .unwrap();
1476        assert!(store.evict().await.unwrap().is_empty());
1477        assert!(store.get("tiny:latest").await.is_some());
1478    }
1479
1480    #[tokio::test]
1481    async fn test_index_persistence() {
1482        let tmp = TempDir::new().unwrap();
1483        let store_dir = tmp.path().join("store");
1484        let source_dir = tmp.path().join("source");
1485        create_test_oci_layout(&source_dir);
1486
1487        // Create store and add image
1488        {
1489            let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1490            store
1491                .put(
1492                    "nginx:latest",
1493                    "sha256:7777777777777777777777777777777777777777777777777777777777777777",
1494                    &source_dir,
1495                )
1496                .await
1497                .unwrap();
1498        }
1499
1500        // Create new store from same directory — should load persisted index
1501        {
1502            let store = ImageStore::new(&store_dir, 10 * 1024 * 1024).unwrap();
1503            let image = store.get("nginx:latest").await;
1504            assert!(image.is_some());
1505            assert_eq!(
1506                image.unwrap().digest,
1507                "sha256:7777777777777777777777777777777777777777777777777777777777777777"
1508            );
1509        }
1510    }
1511
1512    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1513    async fn concurrent_cross_instance_puts_persist_both() {
1514        use std::collections::HashSet;
1515        use std::sync::Arc;
1516
1517        let tmp = TempDir::new().unwrap();
1518        let store_dir = tmp.path().join("store");
1519        let source_dir = tmp.path().join("source");
1520        create_test_oci_layout(&source_dir);
1521
1522        // Two ImageStore instances on the SAME dir simulate two processes, each
1523        // with its own in-memory index. Concurrent puts of distinct images must
1524        // BOTH persist — the lost-update bug dropped one. with_index_lock
1525        // reloads under the cross-process lock, so neither overwrites the other.
1526        let s1 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
1527        let s2 = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
1528        let (src1, src2) = (source_dir.clone(), source_dir.clone());
1529        let h1 = {
1530            let s1 = Arc::clone(&s1);
1531            tokio::spawn(async move {
1532                s1.put(
1533                    "img:a",
1534                    "sha256:8888888888888888888888888888888888888888888888888888888888888888",
1535                    &src1,
1536                )
1537                .await
1538                .unwrap()
1539            })
1540        };
1541        let h2 = {
1542            let s2 = Arc::clone(&s2);
1543            tokio::spawn(async move {
1544                s2.put(
1545                    "img:b",
1546                    "sha256:9999999999999999999999999999999999999999999999999999999999999999",
1547                    &src2,
1548                )
1549                .await
1550                .unwrap()
1551            })
1552        };
1553        h1.await.unwrap();
1554        h2.await.unwrap();
1555
1556        // A fresh instance reads index.json from disk: both must be there.
1557        let s3 = ImageStore::new(&store_dir, u64::MAX).unwrap();
1558        let refs: HashSet<String> = s3.list().await.into_iter().map(|i| i.reference).collect();
1559        assert!(refs.contains("img:a"), "img:a lost: {refs:?}");
1560        assert!(refs.contains("img:b"), "img:b lost: {refs:?}");
1561    }
1562
1563    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1564    async fn concurrent_put_and_remove_keep_published_content() {
1565        use std::sync::Arc;
1566
1567        let tmp = TempDir::new().unwrap();
1568        let store_dir = tmp.path().join("store");
1569        let source_dir = tmp.path().join("source");
1570        create_test_oci_layout(&source_dir);
1571
1572        let writer = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
1573        let remover = Arc::new(ImageStore::new(&store_dir, u64::MAX).unwrap());
1574
1575        // Each iteration removes the old tag while publishing a second tag for
1576        // the same content.  The content lock must linearize those operations;
1577        // after either ordering the surviving tag must have a real directory,
1578        // never an index entry whose content was deleted by the remover.
1579        for i in 0..16u64 {
1580            let digest = format!("sha256:{:064x}", i + 1000);
1581            let old_reference = format!("app:old-{i}");
1582            let new_reference = format!("app:new-{i}");
1583            writer
1584                .put(&old_reference, &digest, &source_dir)
1585                .await
1586                .unwrap();
1587
1588            let put_writer = Arc::clone(&writer);
1589            let put_source = source_dir.clone();
1590            let put_digest = digest.clone();
1591            let put_reference = new_reference.clone();
1592            let put_task = tokio::spawn(async move {
1593                put_writer
1594                    .put(&put_reference, &put_digest, &put_source)
1595                    .await
1596            });
1597
1598            let remove_remover = Arc::clone(&remover);
1599            let remove_reference = old_reference.clone();
1600            let remove_task =
1601                tokio::spawn(async move { remove_remover.remove(&remove_reference).await });
1602
1603            put_task.await.unwrap().unwrap();
1604            remove_task.await.unwrap().unwrap();
1605
1606            let fresh = ImageStore::new(&store_dir, u64::MAX).unwrap();
1607            let image = fresh
1608                .get(&new_reference)
1609                .await
1610                .expect("new tag must survive the concurrent removal");
1611            assert!(
1612                image.path.is_dir(),
1613                "surviving index entry must point at published content: {}",
1614                image.path.display()
1615            );
1616        }
1617    }
1618
1619    #[tokio::test]
1620    async fn cross_instance_get_refreshes_the_authoritative_index() {
1621        let tmp = TempDir::new().unwrap();
1622        let store_dir = tmp.path().join("store");
1623        let source_dir = tmp.path().join("source");
1624        create_test_oci_layout(&source_dir);
1625
1626        let writer = ImageStore::new(&store_dir, u64::MAX).unwrap();
1627        let reader = ImageStore::new(&store_dir, u64::MAX).unwrap();
1628        writer
1629            .put(
1630                "img:fresh",
1631                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1632                &source_dir,
1633            )
1634            .await
1635            .unwrap();
1636
1637        let observed = reader
1638            .get("img:fresh")
1639            .await
1640            .expect("reader created before put must refresh the shared index");
1641        assert_eq!(
1642            observed.digest,
1643            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1644        );
1645
1646        let reopened = ImageStore::new(&store_dir, u64::MAX).unwrap();
1647        assert!(reopened.get("img:fresh").await.is_some());
1648    }
1649
1650    #[tokio::test]
1651    async fn cross_instance_list_refreshes_the_authoritative_index() {
1652        let tmp = TempDir::new().unwrap();
1653        let store_dir = tmp.path().join("store");
1654        let source_dir = tmp.path().join("source");
1655        create_test_oci_layout(&source_dir);
1656
1657        let writer = ImageStore::new(&store_dir, u64::MAX).unwrap();
1658        let reader = ImageStore::new(&store_dir, u64::MAX).unwrap();
1659        writer
1660            .put(
1661                "img:list",
1662                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1663                &source_dir,
1664            )
1665            .await
1666            .unwrap();
1667
1668        let images = reader.list().await;
1669        assert_eq!(images.len(), 1);
1670        assert_eq!(images[0].reference, "img:list");
1671    }
1672
1673    #[tokio::test]
1674    async fn load_index_skips_missing_paths_and_unreadable_entries() {
1675        let tmp = tempfile::tempdir().unwrap();
1676        let store_dir = tmp.path().join("images");
1677        let live_digest = format!("sha256:{}", "a".repeat(64));
1678        let missing_digest = format!("sha256:{}", "b".repeat(64));
1679        let live_path = store_dir.join("sha256").join("a".repeat(64));
1680        let missing_path = store_dir.join("sha256").join("b".repeat(64));
1681        create_test_oci_layout(&live_path);
1682
1683        let live = stored_image("live:latest", &live_digest, live_path);
1684        let missing = stored_image("missing:latest", &missing_digest, missing_path);
1685        let index = serde_json::json!({
1686            "images": [
1687                serde_json::to_value(&live).unwrap(),
1688                serde_json::to_value(&missing).unwrap(),
1689                {
1690                    "reference": "broken:latest",
1691                    "digest": false
1692                }
1693            ]
1694        });
1695        std::fs::write(
1696            store_dir.join("index.json"),
1697            serde_json::to_vec_pretty(&index).unwrap(),
1698        )
1699        .unwrap();
1700
1701        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1702        let images = store.list().await;
1703
1704        assert_eq!(images.len(), 1);
1705        assert_eq!(images[0].reference, "live:latest");
1706        assert!(store.get("missing:latest").await.is_none());
1707        assert!(std::fs::read_dir(&store_dir)
1708            .unwrap()
1709            .filter_map(|entry| entry.ok())
1710            .any(|entry| entry
1711                .file_name()
1712                .to_string_lossy()
1713                .contains("index.json.corrupt-")));
1714    }
1715
1716    #[tokio::test]
1717    async fn corrupt_index_is_quarantined_not_fatal() {
1718        let tmp = tempfile::tempdir().unwrap();
1719        let store_dir = tmp.path().join("images");
1720        std::fs::create_dir_all(&store_dir).unwrap();
1721        std::fs::write(store_dir.join("index.json"), "{ not valid json").unwrap();
1722
1723        // Construction must SUCCEED (start from an empty catalog) rather than
1724        // erroring and blocking CRI/CLI startup on a corrupt/old-schema index.
1725        let store = ImageStore::new(&store_dir, u64::MAX)
1726            .expect("corrupt index.json must not brick the image store");
1727        assert!(
1728            store.list().await.is_empty(),
1729            "store must start from an empty catalog after quarantine"
1730        );
1731
1732        // The corrupt index is preserved as a timestamped sibling, not lost.
1733        let quarantined = std::fs::read_dir(&store_dir)
1734            .unwrap()
1735            .filter_map(|e| e.ok())
1736            .any(|e| {
1737                e.file_name()
1738                    .to_string_lossy()
1739                    .contains("index.json.corrupt-")
1740            });
1741        assert!(
1742            quarantined,
1743            "corrupt index.json must be quarantined to a sibling"
1744        );
1745    }
1746
1747    #[test]
1748    fn copy_dir_recursive_copies_nested_files_and_dir_size_sums() {
1749        let tmp = TempDir::new().unwrap();
1750        let src = tmp.path().join("src");
1751        let dst = tmp.path().join("dst");
1752        std::fs::create_dir_all(src.join("nested")).unwrap();
1753        std::fs::write(src.join("root.txt"), b"abc").unwrap();
1754        std::fs::write(src.join("nested/leaf.txt"), b"hello").unwrap();
1755
1756        copy_dir_recursive(&src, &dst).unwrap();
1757
1758        assert_eq!(std::fs::read(dst.join("root.txt")).unwrap(), b"abc");
1759        assert_eq!(
1760            std::fs::read(dst.join("nested/leaf.txt")).unwrap(),
1761            b"hello"
1762        );
1763        assert_eq!(dir_size(&dst), 8);
1764        assert_eq!(dir_size(&tmp.path().join("missing")), 0);
1765    }
1766
1767    #[test]
1768    fn copy_dir_recursive_fails_for_missing_source() {
1769        let tmp = TempDir::new().unwrap();
1770        let err = copy_dir_recursive(&tmp.path().join("missing"), &tmp.path().join("dst"))
1771            .expect_err("missing source should fail");
1772
1773        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1774    }
1775
1776    #[tokio::test]
1777    async fn put_rejects_path_shaped_digest_without_touching_host_path() {
1778        let tmp = TempDir::new().unwrap();
1779        let store_dir = tmp.path().join("store");
1780        let source_dir = tmp.path().join("source");
1781        let host_dir = tmp.path().join("host-target");
1782        create_test_oci_layout(&source_dir);
1783        std::fs::create_dir_all(&host_dir).unwrap();
1784        std::fs::write(host_dir.join("keep.txt"), b"host data").unwrap();
1785        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1786
1787        let error = store
1788            .put("evil:latest", "sha256:../../host-target", &source_dir)
1789            .await
1790            .unwrap_err();
1791
1792        assert!(error.to_string().contains("malformed content digest"));
1793        assert_eq!(
1794            std::fs::read(host_dir.join("keep.txt")).unwrap(),
1795            b"host data"
1796        );
1797        assert!(store.list().await.is_empty());
1798    }
1799
1800    #[cfg(unix)]
1801    #[tokio::test]
1802    async fn put_rejects_source_symlink_without_copying_target() {
1803        use std::os::unix::fs::symlink;
1804
1805        let tmp = TempDir::new().unwrap();
1806        let store_dir = tmp.path().join("store");
1807        let real_source = tmp.path().join("real-source");
1808        let source_link = tmp.path().join("source-link");
1809        create_test_oci_layout(&real_source);
1810        symlink(&real_source, &source_link).unwrap();
1811        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1812
1813        let error = store
1814            .put(
1815                "evil:latest",
1816                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1817                &source_link,
1818            )
1819            .await
1820            .unwrap_err();
1821
1822        assert!(error.to_string().contains("symbolic link"));
1823        assert!(store.list().await.is_empty());
1824        assert!(real_source.join("index.json").is_file());
1825    }
1826
1827    #[cfg(unix)]
1828    #[tokio::test]
1829    async fn put_rejects_extra_symlink_and_preserves_its_target() {
1830        use std::os::unix::fs::symlink;
1831
1832        let tmp = TempDir::new().unwrap();
1833        let store_dir = tmp.path().join("store");
1834        let source_dir = tmp.path().join("source");
1835        let host_file = tmp.path().join("host-secret.txt");
1836        create_test_oci_layout(&source_dir);
1837        std::fs::write(&host_file, b"secret").unwrap();
1838        symlink(&host_file, source_dir.join("extra-blob")).unwrap();
1839        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1840
1841        assert!(store
1842            .put(
1843                "evil:latest",
1844                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1845                &source_dir,
1846            )
1847            .await
1848            .is_err());
1849        assert_eq!(std::fs::read(&host_file).unwrap(), b"secret");
1850        assert!(store.list().await.is_empty());
1851    }
1852
1853    #[cfg(windows)]
1854    #[tokio::test]
1855    async fn put_rejects_windows_source_reparse_point() {
1856        let tmp = TempDir::new().unwrap();
1857        let store_dir = tmp.path().join("store");
1858        let source_dir = tmp.path().join("source");
1859        let host_file = tmp.path().join("host-secret.txt");
1860        let link = source_dir.join("extra-blob");
1861        create_test_oci_layout(&source_dir);
1862        std::fs::write(&host_file, b"secret").unwrap();
1863        let guard = a3s_box_core::windows_symlink::WindowsSymlinkPrivilegeGuard::acquire();
1864        let assigned_privilege_enabled = guard.assigned_privilege_enabled();
1865        match std::os::windows::fs::symlink_file(&host_file, &link) {
1866            Ok(()) => {}
1867            Err(error)
1868                if a3s_box_core::windows_symlink::is_capability_denial(
1869                    &error,
1870                    assigned_privilege_enabled,
1871                ) =>
1872            {
1873                return
1874            }
1875            Err(error) => panic!("failed to create Windows test symlink: {error}"),
1876        }
1877        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1878
1879        assert!(store
1880            .put(
1881                "evil:latest",
1882                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1883                &source_dir,
1884            )
1885            .await
1886            .is_err());
1887        assert_eq!(std::fs::read(&host_file).unwrap(), b"secret");
1888        assert!(store.list().await.is_empty());
1889    }
1890
1891    #[tokio::test]
1892    async fn load_index_rejects_forged_deletion_path() {
1893        let tmp = TempDir::new().unwrap();
1894        let store_dir = tmp.path().join("store");
1895        let victim = tmp.path().join("host-victim");
1896        std::fs::create_dir_all(&store_dir).unwrap();
1897        std::fs::create_dir_all(&victim).unwrap();
1898        std::fs::write(victim.join("keep.txt"), b"keep").unwrap();
1899
1900        let forged = stored_image(
1901            "evil:latest",
1902            "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
1903            victim.clone(),
1904        );
1905        let index = StoreIndex {
1906            images: vec![forged],
1907        };
1908        std::fs::write(
1909            store_dir.join("index.json"),
1910            serde_json::to_vec_pretty(&index).unwrap(),
1911        )
1912        .unwrap();
1913
1914        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1915        assert!(store.list().await.is_empty());
1916        assert!(store.remove("evil:latest").await.is_err());
1917        assert_eq!(std::fs::read(victim.join("keep.txt")).unwrap(), b"keep");
1918    }
1919
1920    #[tokio::test]
1921    async fn load_index_rederives_path_and_never_uses_forged_spelling() {
1922        let tmp = TempDir::new().unwrap();
1923        let store_dir = tmp.path().join("store");
1924        let digest_hex = "d".repeat(64);
1925        let digest = format!("sha256:{digest_hex}");
1926        let expected = store_dir.join("sha256").join(&digest_hex);
1927        let victim = tmp.path().join("host-victim");
1928        create_test_oci_layout(&expected);
1929        std::fs::create_dir_all(&victim).unwrap();
1930        std::fs::write(victim.join("keep.txt"), b"keep").unwrap();
1931
1932        // A persisted path may be an equivalent legacy spelling (notably an
1933        // 8.3 path on Windows), or it may be attacker-controlled. In either
1934        // case the digest-derived content directory is the only path to use.
1935        let forged = stored_image("safe:latest", &digest, victim.clone());
1936        let index = StoreIndex {
1937            images: vec![forged],
1938        };
1939        std::fs::write(
1940            store_dir.join("index.json"),
1941            serde_json::to_vec_pretty(&index).unwrap(),
1942        )
1943        .unwrap();
1944
1945        let store = ImageStore::new(&store_dir, u64::MAX).unwrap();
1946        let loaded = store.get("safe:latest").await.unwrap();
1947        assert_eq!(loaded.path, expected);
1948
1949        store.remove("safe:latest").await.unwrap();
1950        assert!(!expected.exists());
1951        assert_eq!(std::fs::read(victim.join("keep.txt")).unwrap(), b"keep");
1952    }
1953}