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