1use 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::{RegistryAuth, RegistryPuller};
16use super::store::ImageStore;
17
18type PullProgressFn = Arc<dyn Fn(usize, usize, &str, i64) + Send + Sync>;
20
21static PULL_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
23
24pub struct ImagePuller {
26 store: Arc<ImageStore>,
27 puller: RegistryPuller,
28 metrics: Option<crate::prom::RuntimeMetrics>,
29 mirrors: std::collections::HashMap<String, String>,
35}
36
37fn 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 pub fn new(store: Arc<ImageStore>, auth: RegistryAuth) -> Self {
54 Self::with_platform(store, auth, None)
55 }
56
57 pub fn with_platform(
61 store: Arc<ImageStore>,
62 auth: RegistryAuth,
63 platform: Option<String>,
64 ) -> Self {
65 Self {
66 store,
67 puller: RegistryPuller::with_auth_and_platform(auth, platform),
68 metrics: None,
69 mirrors: parse_registry_mirrors(),
70 }
71 }
72
73 fn mirror_reference(&self, reference: &ImageReference) -> ImageReference {
77 match self.mirrors.get(&reference.registry) {
78 Some(mirror) if !mirror.is_empty() => {
79 tracing::info!(
80 registry = %reference.registry,
81 mirror = %mirror,
82 "Pulling via configured registry mirror"
83 );
84 let mut mirrored = reference.clone();
85 mirror.clone_into(&mut mirrored.registry);
86 mirrored
87 }
88 _ => reference.clone(),
89 }
90 }
91
92 pub fn set_metrics(mut self, metrics: crate::prom::RuntimeMetrics) -> Self {
94 self.metrics = Some(metrics);
95 self
96 }
97
98 pub fn with_signature_policy(mut self, policy: super::signing::SignaturePolicy) -> Self {
100 self.puller = self.puller.with_signature_policy(policy);
101 self
102 }
103
104 pub fn with_progress_fn(mut self, f: PullProgressFn) -> Self {
106 self.puller = self.puller.with_progress_fn(f);
107 self
108 }
109
110 pub async fn pull(&self, reference: &str) -> Result<OciImage> {
114 self.pull_resolved(reference).await.map(|(image, _)| image)
115 }
116
117 async fn pull_resolved(&self, reference: &str) -> Result<(OciImage, String)> {
118 let reference = reference.trim();
119 if is_digest_reference(reference) {
120 let Some((matched_reference, stored)) = self.cached_digest_image(reference).await?
121 else {
122 return Err(BoxError::OciImageError(format!(
123 "Image digest not found in local cache: {reference}"
124 )));
125 };
126 tracing::info!(
127 requested_reference = %reference,
128 matched_reference = %matched_reference,
129 digest = %stored.digest,
130 "Using cached image by digest"
131 );
132 return Ok((OciImage::from_path(&stored.path)?, matched_reference));
133 }
134
135 let parsed = ImageReference::parse(reference)?;
136
137 if let Some((matched_reference, stored)) = self.cached_image(reference, &parsed).await? {
138 tracing::info!(
139 requested_reference = %reference,
140 matched_reference = %matched_reference,
141 digest = %stored.digest,
142 "Using cached image"
143 );
144 return Ok((OciImage::from_path(&stored.path)?, matched_reference));
145 }
146
147 Ok((self.pull_and_store(&parsed).await?, parsed.full_reference()))
148 }
149
150 pub async fn force_pull(&self, reference: &str) -> Result<OciImage> {
152 let reference = reference.trim();
153 if is_digest_reference(reference) {
154 return Err(BoxError::OciImageError(format!(
155 "Cannot force-pull digest-only reference {reference}; use a tagged registry reference"
156 )));
157 }
158
159 let parsed = ImageReference::parse(reference)?;
160
161 for candidate in cache_reference_candidates(reference, &parsed) {
162 if self.store.get(&candidate).await.is_some() {
163 let _ = self.store.remove(&candidate).await;
164 }
165 }
166
167 self.pull_and_store(&parsed).await
168 }
169
170 pub async fn is_cached(&self, reference: &str) -> bool {
172 let reference = reference.trim();
173 if is_digest_reference(reference) {
174 return matches!(self.cached_digest_image(reference).await, Ok(Some(_)));
175 }
176
177 let parsed = match ImageReference::parse(reference) {
178 Ok(p) => p,
179 Err(_) => return false,
180 };
181 matches!(self.cached_image(reference, &parsed).await, Ok(Some(_)))
182 }
183
184 pub async fn remove_cached(&self, reference: &str) -> Result<bool> {
186 let reference = reference.trim();
187 if is_digest_reference(reference) {
188 if let Some((matched_reference, _)) = self.cached_digest_image(reference).await? {
189 self.store.remove(&matched_reference).await?;
190 return Ok(true);
191 }
192 return Ok(false);
193 }
194
195 let parsed = ImageReference::parse(reference)?;
196 if let Some((matched_reference, _)) = self.cached_image(reference, &parsed).await? {
197 self.store.remove(&matched_reference).await?;
198 Ok(true)
199 } else {
200 Ok(false)
201 }
202 }
203
204 pub async fn list_cached(&self) -> Result<Vec<String>> {
206 Ok(self
207 .store
208 .list()
209 .await
210 .into_iter()
211 .map(|img| img.reference)
212 .collect())
213 }
214
215 async fn pull_and_store(&self, reference: &ImageReference) -> Result<OciImage> {
217 let full_ref = reference.full_reference();
218
219 let fetch = self.mirror_reference(reference);
222
223 let digest = self.puller.pull_manifest_digest(&fetch).await?;
229 super::registry::validated_digest_hex(&digest)?;
230
231 if let Some(stored) = self.store.get_by_digest(&digest).await {
233 tracing::info!(
234 reference = %full_ref,
235 digest = %digest,
236 "Image content already cached under different reference"
237 );
238 self.store.put(&full_ref, &digest, &stored.path).await?;
240 return OciImage::from_path(&stored.path);
241 }
242
243 let digest_hex = digest.strip_prefix("sha256:").unwrap_or(&digest);
247 let tmp_dir = unique_pull_tmp_dir(self.store.store_dir(), digest_hex);
248
249 let pull_start = std::time::Instant::now();
250 if let Err(e) = self.puller.pull(&fetch, &tmp_dir).await {
255 let _ = std::fs::remove_dir_all(&tmp_dir);
256 return Err(e);
257 }
258 if let Some(ref m) = self.metrics {
259 m.image_pull_total.inc();
260 m.image_pull_duration
261 .observe(pull_start.elapsed().as_secs_f64());
262 }
263
264 let stored = match self.store.put(&full_ref, &digest, &tmp_dir).await {
266 Ok(stored) => stored,
267 Err(e) => {
268 let _ = std::fs::remove_dir_all(&tmp_dir);
269 return Err(e);
270 }
271 };
272
273 if let Err(e) = std::fs::remove_dir_all(&tmp_dir) {
275 tracing::warn!(path = %tmp_dir.display(), error = %e, "Failed to remove temp dir after pull");
276 }
277
278 let evicted = self.store.evict().await?;
280 if !evicted.is_empty() {
281 tracing::info!(
282 count = evicted.len(),
283 references = ?evicted,
284 "Evicted images from cache"
285 );
286 }
287
288 OciImage::from_path(&stored.path)
289 }
290
291 async fn cached_image(
292 &self,
293 reference: &str,
294 parsed: &ImageReference,
295 ) -> Result<Option<(String, StoredImage)>> {
296 for candidate in cache_reference_candidates(reference, parsed) {
297 if let Some(stored) = self.store.get(&candidate).await {
298 return Ok(Some((candidate, stored)));
299 }
300 }
301 if let Some(digest) = parsed.digest.as_deref() {
302 return self.cached_digest_image(digest).await;
303 }
304 Ok(None)
305 }
306
307 async fn cached_digest_image(&self, digest: &str) -> Result<Option<(String, StoredImage)>> {
308 let images = self.store.list().await;
309 let mut matches = Vec::new();
310
311 for image in images {
312 let already_matched = matches
313 .iter()
314 .any(|matched: &StoredImage| matched.reference == image.reference);
315 if (digest_matches(&image.digest, digest) || image.reference == digest)
316 && !already_matched
317 {
318 matches.push(image);
319 }
320 }
321
322 match matches.len() {
323 0 => Ok(None),
324 1 => {
325 let stored = matches.pop().expect("checked one match");
326 let stored = self.store.get(&stored.reference).await.unwrap_or(stored);
327 Ok(Some((stored.reference.clone(), stored)))
328 }
329 _ => Err(BoxError::OciImageError(ambiguous_digest_error(
330 digest, &matches,
331 ))),
332 }
333 }
334}
335
336fn ambiguous_digest_error(query: &str, matches: &[StoredImage]) -> String {
337 let mut references: Vec<_> = matches
338 .iter()
339 .map(|image| image.reference.as_str())
340 .collect();
341 references.sort_unstable();
342 format!(
343 "Image digest '{query}' is ambiguous; it matches: {}",
344 references.join(", ")
345 )
346}
347
348fn is_digest_reference(reference: &str) -> bool {
349 reference.starts_with("sha256:")
350}
351
352fn digest_matches(stored_digest: &str, query: &str) -> bool {
353 if stored_digest == query {
354 return true;
355 }
356 let Some(query_hex) = query.strip_prefix("sha256:") else {
357 return false;
358 };
359 !query_hex.is_empty() && stored_digest.starts_with(query)
360}
361
362fn cache_reference_candidates(reference: &str, parsed: &ImageReference) -> Vec<String> {
363 let mut candidates = Vec::new();
364 push_unique(&mut candidates, reference.trim().to_string());
365 push_unique(&mut candidates, parsed.full_reference());
366
367 if parsed.registry == "docker.io" {
368 let repository = parsed
369 .repository
370 .strip_prefix("library/")
371 .unwrap_or(&parsed.repository);
372 push_unique(
373 &mut candidates,
374 reference_from_repository(repository, parsed.tag.as_deref(), parsed.digest.as_deref()),
375 );
376
377 if parsed.digest.is_none() && parsed.tag.as_deref() == Some("latest") {
378 push_unique(&mut candidates, repository.to_string());
379 }
380 }
381
382 if let Some(digest) = &parsed.digest {
383 push_unique(&mut candidates, digest.clone());
384 }
385
386 candidates
387}
388
389fn reference_from_repository(repository: &str, tag: Option<&str>, digest: Option<&str>) -> String {
390 let mut reference = repository.to_string();
391 if let Some(tag) = tag {
392 reference.push(':');
393 reference.push_str(tag);
394 }
395 if let Some(digest) = digest {
396 reference.push('@');
397 reference.push_str(digest);
398 }
399 reference
400}
401
402fn unique_pull_tmp_dir(store_dir: &std::path::Path, digest_hex: &str) -> std::path::PathBuf {
403 let seq = PULL_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
404 store_dir
405 .join("tmp")
406 .join(format!("pull-{digest_hex}-{}-{seq}", std::process::id()))
407}
408
409fn push_unique(values: &mut Vec<String>, value: String) {
410 if !value.is_empty() && !values.iter().any(|existing| existing == &value) {
411 values.push(value);
412 }
413}
414
415#[async_trait::async_trait]
416impl a3s_box_core::traits::ImageRegistry for ImagePuller {
417 async fn pull(&self, reference: &str) -> Result<a3s_box_core::traits::PulledImage> {
418 let (image, resolved_reference) = self.pull_resolved(reference).await?;
419 Ok(a3s_box_core::traits::PulledImage {
420 path: image.root_dir().to_path_buf(),
421 digest: image.manifest_digest().to_string(),
422 reference: resolved_reference,
423 })
424 }
425
426 async fn force_pull(&self, reference: &str) -> Result<a3s_box_core::traits::PulledImage> {
427 let image = self.force_pull(reference).await?;
428 let parsed = ImageReference::parse(reference)?;
429 Ok(a3s_box_core::traits::PulledImage {
430 path: image.root_dir().to_path_buf(),
431 digest: image.manifest_digest().to_string(),
432 reference: parsed.full_reference(),
433 })
434 }
435
436 async fn is_cached(&self, reference: &str) -> bool {
437 self.is_cached(reference).await
438 }
439
440 async fn remove(&self, reference: &str) -> Result<bool> {
441 self.remove_cached(reference).await
442 }
443
444 async fn list_cached(&self) -> Result<Vec<String>> {
445 self.list_cached().await
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::oci::store::ImageStore;
453 use std::path::Path;
454 use tempfile::TempDir;
455
456 #[test]
457 fn test_image_puller_creation() {
458 let tmp = TempDir::new().unwrap();
459 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
460 let _puller = ImagePuller::new(store, RegistryAuth::anonymous());
461 }
462
463 #[tokio::test]
464 async fn test_is_cached_empty_store() {
465 let tmp = TempDir::new().unwrap();
466 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
467 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
468 assert!(!puller.is_cached("nginx:latest").await);
469 }
470
471 #[tokio::test]
472 async fn test_is_cached_invalid_reference() {
473 let tmp = TempDir::new().unwrap();
474 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
475 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
476 assert!(!puller.is_cached("").await);
477 }
478
479 #[tokio::test]
480 async fn test_is_cached_matches_docker_hub_aliases() {
481 let tmp = TempDir::new().unwrap();
482 let source = TempDir::new().unwrap();
483 create_complete_oci_image(source.path());
484 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
485 store
486 .put("alpine:latest", "sha256:manifestxyz789", source.path())
487 .await
488 .unwrap();
489
490 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
491
492 assert!(puller.is_cached("alpine:latest").await);
493 assert!(puller.is_cached("docker.io/library/alpine:latest").await);
494 assert!(puller.is_cached("alpine").await);
495 }
496
497 #[tokio::test]
498 async fn test_pull_uses_cached_short_alias_for_full_reference() {
499 let tmp = TempDir::new().unwrap();
500 let source = TempDir::new().unwrap();
501 create_complete_oci_image(source.path());
502 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
503 store
504 .put("alpine:latest", "sha256:manifestxyz789", source.path())
505 .await
506 .unwrap();
507 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
508
509 let image = puller
510 .pull("docker.io/library/alpine:latest")
511 .await
512 .unwrap();
513
514 assert_eq!(image.manifest_digest(), "sha256:manifestxyz789");
515 }
516
517 #[tokio::test]
518 async fn test_pull_uses_cached_digest_reference_without_registry_parse() {
519 let tmp = TempDir::new().unwrap();
520 let source = TempDir::new().unwrap();
521 create_complete_oci_image(source.path());
522 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
523 store
524 .put("alpine:latest", "sha256:manifestxyz789", source.path())
525 .await
526 .unwrap();
527 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
528
529 let image = puller.pull("sha256:manifestxyz789").await.unwrap();
530
531 assert_eq!(image.manifest_digest(), "sha256:manifestxyz789");
532 }
533
534 #[tokio::test]
535 async fn test_is_cached_matches_digest_prefix() {
536 let tmp = TempDir::new().unwrap();
537 let source = TempDir::new().unwrap();
538 create_complete_oci_image(source.path());
539 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
540 store
541 .put("alpine:latest", "sha256:manifestxyz789", source.path())
542 .await
543 .unwrap();
544 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
545
546 assert!(puller.is_cached("sha256:manifest").await);
547 }
548
549 #[tokio::test]
550 async fn test_pull_reports_missing_digest_reference_as_local_cache_miss() {
551 let tmp = TempDir::new().unwrap();
552 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
553 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
554
555 let error = puller.pull("sha256:notfound").await.unwrap_err();
556
557 assert!(error.to_string().contains("not found in local cache"));
558 }
559
560 #[tokio::test]
561 async fn test_pull_reports_ambiguous_digest_prefix() {
562 let tmp = TempDir::new().unwrap();
563 let source = TempDir::new().unwrap();
564 create_complete_oci_image(source.path());
565 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
566 store
567 .put("alpine:latest", "sha256:manifestaaa", source.path())
568 .await
569 .unwrap();
570 store
571 .put("busybox:latest", "sha256:manifestbbb", source.path())
572 .await
573 .unwrap();
574 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
575
576 let error = puller.pull("sha256:manifest").await.unwrap_err();
577
578 assert!(error.to_string().contains("ambiguous"));
579 assert!(error.to_string().contains("alpine:latest"));
580 assert!(error.to_string().contains("busybox:latest"));
581 }
582
583 #[tokio::test]
584 async fn test_remove_cached_matches_docker_hub_alias() {
585 let tmp = TempDir::new().unwrap();
586 let source = TempDir::new().unwrap();
587 create_complete_oci_image(source.path());
588 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
589 store
590 .put("alpine:latest", "sha256:manifestxyz789", source.path())
591 .await
592 .unwrap();
593 let puller = ImagePuller::new(store.clone(), RegistryAuth::anonymous());
594
595 assert!(puller
596 .remove_cached("docker.io/library/alpine:latest")
597 .await
598 .unwrap());
599 assert!(store.get("alpine:latest").await.is_none());
600 }
601
602 #[test]
603 fn test_set_metrics_attaches_to_puller() {
604 let tmp = TempDir::new().unwrap();
605 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
606 let metrics = crate::prom::RuntimeMetrics::new();
607 let puller =
609 ImagePuller::new(store, RegistryAuth::anonymous()).set_metrics(metrics.clone());
610 assert!(puller.metrics.is_some());
611 assert_eq!(metrics.image_pull_total.get(), 0);
612 assert_eq!(metrics.image_pull_duration.get_sample_count(), 0);
613 }
614
615 #[test]
616 fn test_cache_reference_candidates_include_short_docker_hub_aliases() {
617 let parsed = ImageReference::parse("docker.io/library/alpine:latest").unwrap();
618 let candidates = cache_reference_candidates("docker.io/library/alpine:latest", &parsed);
619
620 assert_eq!(
621 candidates,
622 vec![
623 "docker.io/library/alpine:latest".to_string(),
624 "alpine:latest".to_string(),
625 "alpine".to_string(),
626 ]
627 );
628 }
629
630 #[test]
631 fn test_digest_matches_exact_and_prefix_queries() {
632 assert!(digest_matches("sha256:abcdef123456", "sha256:abcdef123456"));
633 assert!(digest_matches("sha256:abcdef123456", "sha256:abcdef"));
634 assert!(!digest_matches("sha256:abcdef123456", "sha256:"));
635 assert!(!digest_matches("sha256:abcdef123456", "abcdef"));
636 }
637
638 #[test]
639 fn test_unique_pull_tmp_dir_does_not_reuse_digest_path() {
640 let tmp = TempDir::new().unwrap();
641 let first = unique_pull_tmp_dir(tmp.path(), "abc123");
642 let second = unique_pull_tmp_dir(tmp.path(), "abc123");
643
644 assert_ne!(first, second);
645 assert_eq!(first.parent().unwrap(), tmp.path().join("tmp"));
646 assert_eq!(second.parent().unwrap(), tmp.path().join("tmp"));
647 assert!(first
648 .file_name()
649 .unwrap()
650 .to_string_lossy()
651 .contains("abc123"));
652 }
653
654 fn create_complete_oci_image(path: &Path) {
655 std::fs::create_dir_all(path.join("blobs/sha256")).unwrap();
656 std::fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
657
658 let config_content = r#"{
659 "architecture": "amd64",
660 "os": "linux",
661 "config": {
662 "Entrypoint": ["/bin/sh"],
663 "Cmd": ["-c", "true"],
664 "Env": ["PATH=/usr/bin:/bin"],
665 "WorkingDir": "/"
666 },
667 "rootfs": {
668 "type": "layers",
669 "diff_ids": ["sha256:layerdiff"]
670 },
671 "history": []
672 }"#;
673 let config_hash = "configabc123";
674 std::fs::write(path.join("blobs/sha256").join(config_hash), config_content).unwrap();
675
676 let layer_hash = "layerdef456";
677 std::fs::write(path.join("blobs/sha256").join(layer_hash), b"layer").unwrap();
678
679 let manifest_content = format!(
680 r#"{{
681 "schemaVersion": 2,
682 "mediaType": "application/vnd.oci.image.manifest.v1+json",
683 "config": {{
684 "mediaType": "application/vnd.oci.image.config.v1+json",
685 "digest": "sha256:{}",
686 "size": {}
687 }},
688 "layers": [
689 {{
690 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
691 "digest": "sha256:{}",
692 "size": 5
693 }}
694 ]
695 }}"#,
696 config_hash,
697 config_content.len(),
698 layer_hash
699 );
700 let manifest_hash = "manifestxyz789";
701 std::fs::write(
702 path.join("blobs/sha256").join(manifest_hash),
703 &manifest_content,
704 )
705 .unwrap();
706
707 let index_content = format!(
708 r#"{{
709 "schemaVersion": 2,
710 "mediaType": "application/vnd.oci.image.index.v1+json",
711 "manifests": [
712 {{
713 "mediaType": "application/vnd.oci.image.manifest.v1+json",
714 "digest": "sha256:{}",
715 "size": {}
716 }}
717 ]
718 }}"#,
719 manifest_hash,
720 manifest_content.len()
721 );
722 std::fs::write(path.join("index.json"), index_content).unwrap();
723 }
724}