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