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