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::{PullProgressEventFn, RegistryAuth, RegistryPullPolicy, 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
24#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
26pub struct PullTempPruneResult {
27 pub directories_removed: usize,
28 pub bytes_freed: u64,
29}
30
31pub struct ImagePuller {
33 store: Arc<ImageStore>,
34 puller: RegistryPuller,
35 metrics: Option<crate::prom::RuntimeMetrics>,
36 mirrors: std::collections::HashMap<String, String>,
42}
43
44fn 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 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 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 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 pub fn set_metrics(mut self, metrics: crate::prom::RuntimeMetrics) -> Self {
111 self.metrics = Some(metrics);
112 self
113 }
114
115 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 pub fn with_progress_fn(mut self, f: PullProgressFn) -> Self {
123 self.puller = self.puller.with_progress_fn(f);
124 self
125 }
126
127 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 pub fn with_pull_policy(mut self, policy: RegistryPullPolicy) -> Self {
135 self.puller = self.puller.with_pull_policy(policy);
136 self
137 }
138
139 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 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 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 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 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 async fn pull_and_store(&self, reference: &ImageReference) -> Result<OciImage> {
246 let full_ref = reference.full_reference();
247
248 let fetch = self.mirror_reference(reference);
251
252 let digest = self.puller.pull_manifest_digest(&fetch).await?;
258 super::registry::validated_digest_hex(&digest)?;
259
260 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 self.store.put(&full_ref, &digest, &stored.path).await?;
269 return OciImage::from_path(&stored.path);
270 }
271
272 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 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 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 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 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
442pub 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 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}