Skip to main content

a3s_box_runtime/oci/
pull.rs

1//! High-level OCI image pull orchestrator.
2//!
3//! Combines the registry puller and image store to provide a cache-first
4//! pull workflow. Images are checked in the local store first; if not found,
5//! they are pulled from the registry and stored locally.
6
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10use a3s_box_core::error::{BoxError, Result};
11use a3s_box_core::StoredImage;
12
13use super::image::OciImage;
14use super::reference::ImageReference;
15use super::registry::{PullProgressEventFn, RegistryAuth, RegistryPullPolicy, RegistryPuller};
16use super::store::ImageStore;
17
18/// Callback type for layer pull progress: `(current, total, digest, size_bytes)`.
19type PullProgressFn = Arc<dyn Fn(usize, usize, &str, i64) + Send + Sync>;
20
21/// Per-process counter for unique pull temp dirs.
22static PULL_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
23
24/// High-level image puller with caching.
25pub struct ImagePuller {
26    store: Arc<ImageStore>,
27    puller: RegistryPuller,
28    metrics: Option<crate::prom::RuntimeMetrics>,
29    /// Registry mirror map (original host → mirror host). Pulls fetch layers and
30    /// manifests from the mirror while the image keeps its canonical reference
31    /// for storage and identity. Parsed from `A3S_REGISTRY_MIRRORS`
32    /// (`host=mirror,host=mirror`) — lets a3s-box pull in registry-restricted
33    /// environments, like containerd's registry mirrors.
34    mirrors: std::collections::HashMap<String, String>,
35}
36
37/// Parse `A3S_REGISTRY_MIRRORS=host=mirror,host=mirror` into a map.
38fn parse_registry_mirrors() -> std::collections::HashMap<String, String> {
39    std::env::var("A3S_REGISTRY_MIRRORS")
40        .ok()
41        .map(|raw| {
42            raw.split(',')
43                .filter_map(|pair| pair.split_once('='))
44                .map(|(host, mirror)| (host.trim().to_string(), mirror.trim().to_string()))
45                .filter(|(host, mirror)| !host.is_empty() && !mirror.is_empty())
46                .collect()
47        })
48        .unwrap_or_default()
49}
50
51impl ImagePuller {
52    /// Create a new image puller.
53    pub fn new(store: Arc<ImageStore>, auth: RegistryAuth) -> Self {
54        Self::with_platform(store, auth, None)
55    }
56
57    #[cfg(test)]
58    pub(crate) fn with_registry_puller(store: Arc<ImageStore>, puller: RegistryPuller) -> Self {
59        Self {
60            store,
61            puller,
62            metrics: None,
63            mirrors: std::collections::HashMap::new(),
64        }
65    }
66
67    /// Create an image puller that resolves multi-arch images to an explicit
68    /// platform (e.g. "linux/arm64") instead of the host architecture. `None`
69    /// keeps the host-architecture default.
70    pub fn with_platform(
71        store: Arc<ImageStore>,
72        auth: RegistryAuth,
73        platform: Option<String>,
74    ) -> Self {
75        Self {
76            store,
77            puller: RegistryPuller::with_auth_and_platform(auth, platform),
78            metrics: None,
79            mirrors: parse_registry_mirrors(),
80        }
81    }
82
83    /// Resolve a reference to its fetch target, applying a configured registry
84    /// mirror. The returned reference is used only for the registry HTTP
85    /// fetch — storage and identity always use the original canonical reference.
86    fn mirror_reference(&self, reference: &ImageReference) -> ImageReference {
87        match self.mirrors.get(&reference.registry) {
88            Some(mirror) if !mirror.is_empty() => {
89                tracing::info!(
90                    registry = %reference.registry,
91                    mirror = %mirror,
92                    "Pulling via configured registry mirror"
93                );
94                let mut mirrored = reference.clone();
95                mirror.clone_into(&mut mirrored.registry);
96                mirrored
97            }
98            _ => reference.clone(),
99        }
100    }
101
102    /// Attach Prometheus metrics to this puller.
103    pub fn set_metrics(mut self, metrics: crate::prom::RuntimeMetrics) -> Self {
104        self.metrics = Some(metrics);
105        self
106    }
107
108    /// Set the signature verification policy for image pulls.
109    pub fn with_signature_policy(mut self, policy: super::signing::SignaturePolicy) -> Self {
110        self.puller = self.puller.with_signature_policy(policy);
111        self
112    }
113
114    /// Set a layer progress callback: `(current, total, digest, size_bytes)`.
115    pub fn with_progress_fn(mut self, f: PullProgressFn) -> Self {
116        self.puller = self.puller.with_progress_fn(f);
117        self
118    }
119
120    /// Set a structured layer progress callback with actual downloaded bytes.
121    pub fn with_progress_event_fn(mut self, f: PullProgressEventFn) -> Self {
122        self.puller = self.puller.with_progress_event_fn(f);
123        self
124    }
125
126    /// Override bounded registry retry, timeout, and concurrency settings.
127    pub fn with_pull_policy(mut self, policy: RegistryPullPolicy) -> Self {
128        self.puller = self.puller.with_pull_policy(policy);
129        self
130    }
131
132    /// Pull an image, using the local cache if available.
133    ///
134    /// Returns the loaded OCI image from the store.
135    pub async fn pull(&self, reference: &str) -> Result<OciImage> {
136        self.pull_resolved(reference).await.map(|(image, _)| image)
137    }
138
139    async fn pull_resolved(&self, reference: &str) -> Result<(OciImage, String)> {
140        let reference = reference.trim();
141        if is_digest_reference(reference) {
142            let Some((matched_reference, stored)) = self.cached_digest_image(reference).await?
143            else {
144                return Err(BoxError::OciImageError(format!(
145                    "Image digest not found in local cache: {reference}"
146                )));
147            };
148            tracing::info!(
149                requested_reference = %reference,
150                matched_reference = %matched_reference,
151                digest = %stored.digest,
152                "Using cached image by digest"
153            );
154            return Ok((OciImage::from_path(&stored.path)?, matched_reference));
155        }
156
157        let parsed = ImageReference::parse(reference)?;
158
159        if let Some((matched_reference, stored)) = self.cached_image(reference, &parsed).await? {
160            tracing::info!(
161                requested_reference = %reference,
162                matched_reference = %matched_reference,
163                digest = %stored.digest,
164                "Using cached image"
165            );
166            return Ok((OciImage::from_path(&stored.path)?, matched_reference));
167        }
168
169        Ok((self.pull_and_store(&parsed).await?, parsed.full_reference()))
170    }
171
172    /// Pull an image, bypassing the local cache.
173    pub async fn force_pull(&self, reference: &str) -> Result<OciImage> {
174        let reference = reference.trim();
175        if is_digest_reference(reference) {
176            return Err(BoxError::OciImageError(format!(
177                "Cannot force-pull digest-only reference {reference}; use a tagged registry reference"
178            )));
179        }
180
181        let parsed = ImageReference::parse(reference)?;
182
183        for candidate in cache_reference_candidates(reference, &parsed) {
184            if self.store.get(&candidate).await.is_some() {
185                let _ = self.store.remove(&candidate).await;
186            }
187        }
188
189        self.pull_and_store(&parsed).await
190    }
191
192    /// Check if an image is already cached.
193    pub async fn is_cached(&self, reference: &str) -> bool {
194        let reference = reference.trim();
195        if is_digest_reference(reference) {
196            return matches!(self.cached_digest_image(reference).await, Ok(Some(_)));
197        }
198
199        let parsed = match ImageReference::parse(reference) {
200            Ok(p) => p,
201            Err(_) => return false,
202        };
203        matches!(self.cached_image(reference, &parsed).await, Ok(Some(_)))
204    }
205
206    /// Remove a cached image by reference.
207    pub async fn remove_cached(&self, reference: &str) -> Result<bool> {
208        let reference = reference.trim();
209        if is_digest_reference(reference) {
210            if let Some((matched_reference, _)) = self.cached_digest_image(reference).await? {
211                self.store.remove(&matched_reference).await?;
212                return Ok(true);
213            }
214            return Ok(false);
215        }
216
217        let parsed = ImageReference::parse(reference)?;
218        if let Some((matched_reference, _)) = self.cached_image(reference, &parsed).await? {
219            self.store.remove(&matched_reference).await?;
220            Ok(true)
221        } else {
222            Ok(false)
223        }
224    }
225
226    /// List all cached image references.
227    pub async fn list_cached(&self) -> Result<Vec<String>> {
228        Ok(self
229            .store
230            .list()
231            .await
232            .into_iter()
233            .map(|img| img.reference)
234            .collect())
235    }
236
237    /// Pull from registry and store locally.
238    async fn pull_and_store(&self, reference: &ImageReference) -> Result<OciImage> {
239        let full_ref = reference.full_reference();
240
241        // Fetch from a configured registry mirror when one applies; the image
242        // keeps its canonical reference (full_ref) for storage and identity.
243        let fetch = self.mirror_reference(reference);
244
245        // Get the manifest digest for storage key. The registry returns it
246        // verbatim (the Docker-Content-Digest header), so validate it before it is
247        // used to build any on-disk path below (the temp dir that gets
248        // remove_dir_all'd, the store key) — a `sha256:../../x` value would
249        // otherwise be a path-traversal arbitrary-dir-delete / store-escape.
250        let digest = self.puller.pull_manifest_digest(&fetch).await?;
251        super::registry::validated_digest_hex(&digest)?;
252
253        // Check if we already have this digest (different tag, same content)
254        if let Some(stored) = self.store.get_by_digest(&digest).await {
255            tracing::info!(
256                reference = %full_ref,
257                digest = %digest,
258                "Image content already cached under different reference"
259            );
260            // Store under the new reference too
261            self.store.put(&full_ref, &digest, &stored.path).await?;
262            return OciImage::from_path(&stored.path);
263        }
264
265        // Pull to a temporary directory first.
266        // Strip the "sha256:" prefix so the directory name is pure hex,
267        // which is valid on all platforms (Windows forbids ':' in filenames).
268        let digest_hex = digest.strip_prefix("sha256:").unwrap_or(&digest);
269        let tmp_dir = unique_pull_tmp_dir(self.store.store_dir(), digest_hex);
270
271        let pull_start = std::time::Instant::now();
272        // Remove the partially-written temp directory on ANY failure so aborted
273        // pulls (network error, signature failure, disk error) don't accumulate
274        // under store_dir/tmp — each can be hundreds of MB and is never counted
275        // toward the cache size or evicted by the LRU.
276        if let Err(e) = self
277            .puller
278            .pull_with_store(&fetch, &tmp_dir, Some(&self.store))
279            .await
280        {
281            let _ = std::fs::remove_dir_all(&tmp_dir);
282            return Err(e);
283        }
284        if let Some(ref m) = self.metrics {
285            m.image_pull_total.inc();
286            m.image_pull_duration
287                .observe(pull_start.elapsed().as_secs_f64());
288        }
289
290        // Store in the image store
291        let stored = match self.store.put(&full_ref, &digest, &tmp_dir).await {
292            Ok(stored) => stored,
293            Err(e) => {
294                let _ = std::fs::remove_dir_all(&tmp_dir);
295                return Err(e);
296            }
297        };
298
299        // Clean up temp directory
300        if let Err(e) = std::fs::remove_dir_all(&tmp_dir) {
301            tracing::warn!(path = %tmp_dir.display(), error = %e, "Failed to remove temp dir after pull");
302        }
303
304        // Evict old images if over capacity
305        let evicted = self.store.evict().await?;
306        if !evicted.is_empty() {
307            tracing::info!(
308                count = evicted.len(),
309                references = ?evicted,
310                "Evicted images from cache"
311            );
312        }
313
314        OciImage::from_path(&stored.path)
315    }
316
317    async fn cached_image(
318        &self,
319        reference: &str,
320        parsed: &ImageReference,
321    ) -> Result<Option<(String, StoredImage)>> {
322        for candidate in cache_reference_candidates(reference, parsed) {
323            if let Some(stored) = self.store.get(&candidate).await {
324                return Ok(Some((candidate, stored)));
325            }
326        }
327        if let Some(digest) = parsed.digest.as_deref() {
328            return self.cached_digest_image(digest).await;
329        }
330        Ok(None)
331    }
332
333    async fn cached_digest_image(&self, digest: &str) -> Result<Option<(String, StoredImage)>> {
334        let images = self.store.list().await;
335        let mut matches = Vec::new();
336
337        for image in images {
338            let already_matched = matches
339                .iter()
340                .any(|matched: &StoredImage| matched.reference == image.reference);
341            if (digest_matches(&image.digest, digest) || image.reference == digest)
342                && !already_matched
343            {
344                matches.push(image);
345            }
346        }
347
348        match matches.len() {
349            0 => Ok(None),
350            1 => {
351                let stored = matches.pop().expect("checked one match");
352                let stored = self.store.get(&stored.reference).await.unwrap_or(stored);
353                Ok(Some((stored.reference.clone(), stored)))
354            }
355            _ => Err(BoxError::OciImageError(ambiguous_digest_error(
356                digest, &matches,
357            ))),
358        }
359    }
360}
361
362fn ambiguous_digest_error(query: &str, matches: &[StoredImage]) -> String {
363    let mut references: Vec<_> = matches
364        .iter()
365        .map(|image| image.reference.as_str())
366        .collect();
367    references.sort_unstable();
368    format!(
369        "Image digest '{query}' is ambiguous; it matches: {}",
370        references.join(", ")
371    )
372}
373
374fn is_digest_reference(reference: &str) -> bool {
375    reference.starts_with("sha256:")
376}
377
378fn digest_matches(stored_digest: &str, query: &str) -> bool {
379    if stored_digest == query {
380        return true;
381    }
382    let Some(query_hex) = query.strip_prefix("sha256:") else {
383        return false;
384    };
385    !query_hex.is_empty() && stored_digest.starts_with(query)
386}
387
388fn cache_reference_candidates(reference: &str, parsed: &ImageReference) -> Vec<String> {
389    let mut candidates = Vec::new();
390    push_unique(&mut candidates, reference.trim().to_string());
391    push_unique(&mut candidates, parsed.full_reference());
392
393    if parsed.registry == "docker.io" {
394        let repository = parsed
395            .repository
396            .strip_prefix("library/")
397            .unwrap_or(&parsed.repository);
398        push_unique(
399            &mut candidates,
400            reference_from_repository(repository, parsed.tag.as_deref(), parsed.digest.as_deref()),
401        );
402
403        if parsed.digest.is_none() && parsed.tag.as_deref() == Some("latest") {
404            push_unique(&mut candidates, repository.to_string());
405        }
406    }
407
408    if let Some(digest) = &parsed.digest {
409        push_unique(&mut candidates, digest.clone());
410    }
411
412    candidates
413}
414
415fn reference_from_repository(repository: &str, tag: Option<&str>, digest: Option<&str>) -> String {
416    let mut reference = repository.to_string();
417    if let Some(tag) = tag {
418        reference.push(':');
419        reference.push_str(tag);
420    }
421    if let Some(digest) = digest {
422        reference.push('@');
423        reference.push_str(digest);
424    }
425    reference
426}
427
428fn unique_pull_tmp_dir(store_dir: &std::path::Path, digest_hex: &str) -> std::path::PathBuf {
429    let seq = PULL_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
430    store_dir
431        .join("tmp")
432        .join(format!("pull-{digest_hex}-{}-{seq}", std::process::id()))
433}
434
435fn push_unique(values: &mut Vec<String>, value: String) {
436    if !value.is_empty() && !values.iter().any(|existing| existing == &value) {
437        values.push(value);
438    }
439}
440
441#[async_trait::async_trait]
442impl a3s_box_core::traits::ImageRegistry for ImagePuller {
443    async fn pull(&self, reference: &str) -> Result<a3s_box_core::traits::PulledImage> {
444        let (image, resolved_reference) = self.pull_resolved(reference).await?;
445        Ok(a3s_box_core::traits::PulledImage {
446            path: image.root_dir().to_path_buf(),
447            digest: image.manifest_digest().to_string(),
448            reference: resolved_reference,
449        })
450    }
451
452    async fn force_pull(&self, reference: &str) -> Result<a3s_box_core::traits::PulledImage> {
453        let image = self.force_pull(reference).await?;
454        let parsed = ImageReference::parse(reference)?;
455        Ok(a3s_box_core::traits::PulledImage {
456            path: image.root_dir().to_path_buf(),
457            digest: image.manifest_digest().to_string(),
458            reference: parsed.full_reference(),
459        })
460    }
461
462    async fn is_cached(&self, reference: &str) -> bool {
463        self.is_cached(reference).await
464    }
465
466    async fn remove(&self, reference: &str) -> Result<bool> {
467        self.remove_cached(reference).await
468    }
469
470    async fn list_cached(&self) -> Result<Vec<String>> {
471        self.list_cached().await
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::oci::store::ImageStore;
479    use std::path::Path;
480    use tempfile::TempDir;
481
482    #[test]
483    fn test_image_puller_creation() {
484        let tmp = TempDir::new().unwrap();
485        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
486        let _puller = ImagePuller::new(store, RegistryAuth::anonymous());
487    }
488
489    #[tokio::test]
490    async fn test_is_cached_empty_store() {
491        let tmp = TempDir::new().unwrap();
492        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
493        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
494        assert!(!puller.is_cached("nginx:latest").await);
495    }
496
497    #[tokio::test]
498    async fn test_is_cached_invalid_reference() {
499        let tmp = TempDir::new().unwrap();
500        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
501        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
502        assert!(!puller.is_cached("").await);
503    }
504
505    #[tokio::test]
506    async fn test_is_cached_matches_docker_hub_aliases() {
507        let tmp = TempDir::new().unwrap();
508        let source = TempDir::new().unwrap();
509        create_complete_oci_image(source.path());
510        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
511        store
512            .put(
513                "alpine:latest",
514                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
515                source.path(),
516            )
517            .await
518            .unwrap();
519
520        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
521
522        assert!(puller.is_cached("alpine:latest").await);
523        assert!(puller.is_cached("docker.io/library/alpine:latest").await);
524        assert!(puller.is_cached("alpine").await);
525    }
526
527    #[tokio::test]
528    async fn test_pull_uses_cached_short_alias_for_full_reference() {
529        let tmp = TempDir::new().unwrap();
530        let source = TempDir::new().unwrap();
531        let manifest_digest = create_complete_oci_image(source.path());
532        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
533        store
534            .put(
535                "alpine:latest",
536                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
537                source.path(),
538            )
539            .await
540            .unwrap();
541        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
542
543        let image = puller
544            .pull("docker.io/library/alpine:latest")
545            .await
546            .unwrap();
547
548        assert_eq!(image.manifest_digest(), manifest_digest);
549    }
550
551    #[tokio::test]
552    async fn test_pull_uses_cached_digest_reference_without_registry_parse() {
553        let tmp = TempDir::new().unwrap();
554        let source = TempDir::new().unwrap();
555        let manifest_digest = create_complete_oci_image(source.path());
556        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
557        store
558            .put(
559                "alpine:latest",
560                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
561                source.path(),
562            )
563            .await
564            .unwrap();
565        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
566
567        let image = puller
568            .pull("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
569            .await
570            .unwrap();
571
572        assert_eq!(image.manifest_digest(), manifest_digest);
573    }
574
575    #[tokio::test]
576    async fn test_is_cached_matches_digest_prefix() {
577        let tmp = TempDir::new().unwrap();
578        let source = TempDir::new().unwrap();
579        create_complete_oci_image(source.path());
580        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
581        store
582            .put(
583                "alpine:latest",
584                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
585                source.path(),
586            )
587            .await
588            .unwrap();
589        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
590
591        assert!(puller.is_cached("sha256:aaaaaaaa").await);
592    }
593
594    #[tokio::test]
595    async fn test_pull_reports_missing_digest_reference_as_local_cache_miss() {
596        let tmp = TempDir::new().unwrap();
597        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
598        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
599
600        let error = puller.pull("sha256:notfound").await.unwrap_err();
601
602        assert!(error.to_string().contains("not found in local cache"));
603    }
604
605    #[tokio::test]
606    async fn test_pull_reports_ambiguous_digest_prefix() {
607        let tmp = TempDir::new().unwrap();
608        let source = TempDir::new().unwrap();
609        create_complete_oci_image(source.path());
610        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
611        store
612            .put(
613                "alpine:latest",
614                "sha256:ccccccccaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
615                source.path(),
616            )
617            .await
618            .unwrap();
619        store
620            .put(
621                "busybox:latest",
622                "sha256:ccccccccbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
623                source.path(),
624            )
625            .await
626            .unwrap();
627        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
628
629        let error = puller.pull("sha256:cccccccc").await.unwrap_err();
630
631        assert!(error.to_string().contains("ambiguous"));
632        assert!(error.to_string().contains("alpine:latest"));
633        assert!(error.to_string().contains("busybox:latest"));
634    }
635
636    #[tokio::test]
637    async fn test_remove_cached_matches_docker_hub_alias() {
638        let tmp = TempDir::new().unwrap();
639        let source = TempDir::new().unwrap();
640        create_complete_oci_image(source.path());
641        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
642        store
643            .put(
644                "alpine:latest",
645                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
646                source.path(),
647            )
648            .await
649            .unwrap();
650        let puller = ImagePuller::new(store.clone(), RegistryAuth::anonymous());
651
652        assert!(puller
653            .remove_cached("docker.io/library/alpine:latest")
654            .await
655            .unwrap());
656        assert!(store.get("alpine:latest").await.is_none());
657    }
658
659    #[test]
660    fn test_set_metrics_attaches_to_puller() {
661        let tmp = TempDir::new().unwrap();
662        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
663        let metrics = crate::prom::RuntimeMetrics::new();
664        // Verify set_metrics() returns Self (builder pattern) and metrics start at zero
665        let puller =
666            ImagePuller::new(store, RegistryAuth::anonymous()).set_metrics(metrics.clone());
667        assert!(puller.metrics.is_some());
668        assert_eq!(metrics.image_pull_total.get(), 0);
669        assert_eq!(metrics.image_pull_duration.get_sample_count(), 0);
670    }
671
672    #[test]
673    fn test_cache_reference_candidates_include_short_docker_hub_aliases() {
674        let parsed = ImageReference::parse("docker.io/library/alpine:latest").unwrap();
675        let candidates = cache_reference_candidates("docker.io/library/alpine:latest", &parsed);
676
677        assert_eq!(
678            candidates,
679            vec![
680                "docker.io/library/alpine:latest".to_string(),
681                "alpine:latest".to_string(),
682                "alpine".to_string(),
683            ]
684        );
685    }
686
687    #[test]
688    fn test_digest_matches_exact_and_prefix_queries() {
689        assert!(digest_matches("sha256:abcdef123456", "sha256:abcdef123456"));
690        assert!(digest_matches("sha256:abcdef123456", "sha256:abcdef"));
691        assert!(!digest_matches("sha256:abcdef123456", "sha256:"));
692        assert!(!digest_matches("sha256:abcdef123456", "abcdef"));
693    }
694
695    #[test]
696    fn test_unique_pull_tmp_dir_does_not_reuse_digest_path() {
697        let tmp = TempDir::new().unwrap();
698        let first = unique_pull_tmp_dir(tmp.path(), "abc123");
699        let second = unique_pull_tmp_dir(tmp.path(), "abc123");
700
701        assert_ne!(first, second);
702        assert_eq!(first.parent().unwrap(), tmp.path().join("tmp"));
703        assert_eq!(second.parent().unwrap(), tmp.path().join("tmp"));
704        assert!(first
705            .file_name()
706            .unwrap()
707            .to_string_lossy()
708            .contains("abc123"));
709    }
710
711    fn test_sha256_digest(bytes: &[u8]) -> String {
712        use sha2::{Digest, Sha256};
713
714        format!("sha256:{:x}", Sha256::digest(bytes))
715    }
716
717    fn write_test_blob(path: &Path, bytes: &[u8]) -> String {
718        let digest = test_sha256_digest(bytes);
719        std::fs::write(
720            path.join("blobs/sha256")
721                .join(digest.strip_prefix("sha256:").unwrap()),
722            bytes,
723        )
724        .unwrap();
725        digest
726    }
727
728    fn create_complete_oci_image(path: &Path) -> String {
729        std::fs::create_dir_all(path.join("blobs/sha256")).unwrap();
730        std::fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
731
732        let config_content = r#"{
733            "architecture": "amd64",
734            "os": "linux",
735            "config": {
736                "Entrypoint": ["/bin/sh"],
737                "Cmd": ["-c", "true"],
738                "Env": ["PATH=/usr/bin:/bin"],
739                "WorkingDir": "/"
740            },
741            "rootfs": {
742                "type": "layers",
743                "diff_ids": ["sha256:0000000000000000000000000000000000000000000000000000000000000000"]
744            },
745            "history": []
746        }"#;
747        let config_digest = write_test_blob(path, config_content.as_bytes());
748
749        let layer_content = b"layer";
750        let layer_digest = write_test_blob(path, layer_content);
751
752        let manifest_content = format!(
753            r#"{{
754            "schemaVersion": 2,
755            "mediaType": "application/vnd.oci.image.manifest.v1+json",
756            "config": {{
757                "mediaType": "application/vnd.oci.image.config.v1+json",
758                "digest": "{}",
759                "size": {}
760            }},
761            "layers": [
762                {{
763                    "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
764                    "digest": "{}",
765                    "size": {}
766                }}
767            ]
768        }}"#,
769            config_digest,
770            config_content.len(),
771            layer_digest,
772            layer_content.len()
773        );
774        let manifest_digest = write_test_blob(path, manifest_content.as_bytes());
775
776        let index_content = format!(
777            r#"{{
778            "schemaVersion": 2,
779            "mediaType": "application/vnd.oci.image.index.v1+json",
780            "manifests": [
781                {{
782                    "mediaType": "application/vnd.oci.image.manifest.v1+json",
783                    "digest": "{}",
784                    "size": {}
785                }}
786            ]
787        }}"#,
788            manifest_digest,
789            manifest_content.len()
790        );
791        std::fs::write(path.join("index.json"), index_content).unwrap();
792        manifest_digest
793    }
794}