1use std::{
6 collections::{HashMap, HashSet},
7 io,
8 path::{Path, PathBuf},
9 pin::Pin,
10 sync::Arc,
11 task::{Context, Poll},
12 time::Instant,
13};
14
15use oci_client::{Client, manifest::ImageIndexEntry};
16use tokio::{
17 io::{AsyncRead, ReadBuf},
18 sync::Semaphore,
19 task::JoinHandle,
20};
21
22use crate::{
23 cache::{
24 self, CachedImageMetadata, CachedLayerMetadata, GlobalCache,
25 lock::{flock_unlock, lock_exclusive, open_lock_file},
26 },
27 config::ImageConfig,
28 digest::Digest,
29 erofs,
30 error::{ImageError, ImageResult},
31 layer::Layer,
32 platform::Platform,
33 progress::{self, PullProgress, PullProgressHandle, PullProgressSender},
34 pull::{PullOptions, PullPolicy, PullResult},
35 tar::{self, Compression},
36 tree::{FileTree, ResourceLimits},
37};
38
39use super::{RegistryBuilder, manifest::OciManifest};
40
41const MATERIALIZE_PROGRESS_EMIT_BYTES: u64 = 256 * 1024;
47
48const MAX_LAYER_PIPELINE_CONCURRENCY: usize = 16;
50
51pub struct Registry {
57 pub(super) client: Client,
58 pub(super) auth: oci_client::secrets::RegistryAuth,
59 pub(super) platform: Platform,
60 pub(super) cache: GlobalCache,
61}
62
63#[derive(Debug, Clone)]
65struct LayerDescriptor {
66 digest: Digest,
67 media_type: Option<String>,
68 size: Option<u64>,
69}
70
71struct CachedPullInfo {
72 result: PullResult,
73 metadata: CachedImageMetadata,
74}
75
76struct LayerPipelineFailure {
77 error: ImageError,
78}
79
80struct MaterializeLayersRequest<'a> {
81 oci_ref: &'a oci_client::Reference,
82 manifest_digest: &'a Digest,
83 layer_descriptors: &'a [LayerDescriptor],
84 diff_ids: &'a [String],
85 force: bool,
86 progress: Option<PullProgressSender>,
87 staged_layers: Option<Arc<HashMap<String, PathBuf>>>,
88}
89
90struct LayerPipelineTreeSuccess {
93 layer_index: usize,
94 tree: Option<FileTree>,
95 data_map: Option<erofs::ErofsDataMap>,
96}
97
98struct MaterializeProgressReader<R> {
105 inner: R,
106 progress: Option<PullProgressSender>,
107 layer_index: usize,
108 total_bytes: u64,
109 bytes_read: u64,
110 last_emitted_bytes: u64,
111}
112
113impl<R> MaterializeProgressReader<R> {
118 fn new(
119 inner: R,
120 progress: Option<PullProgressSender>,
121 layer_index: usize,
122 total_bytes: u64,
123 ) -> Self {
124 Self {
125 inner,
126 progress,
127 layer_index,
128 total_bytes: total_bytes.max(1),
129 bytes_read: 0,
130 last_emitted_bytes: 0,
131 }
132 }
133}
134
135impl Registry {
136 pub fn new(platform: Platform, cache: GlobalCache) -> ImageResult<Self> {
138 Self::builder(platform, cache).build()
139 }
140
141 pub fn builder(platform: Platform, cache: GlobalCache) -> RegistryBuilder {
143 RegistryBuilder::new(platform, cache)
144 }
145
146 pub fn pull_cached(
148 cache: &GlobalCache,
149 reference: &oci_client::Reference,
150 options: &PullOptions,
151 ) -> ImageResult<Option<(PullResult, CachedImageMetadata)>> {
152 Ok(resolve_cached_pull_result(cache, reference, options)?
153 .map(|cached| (cached.result, cached.metadata)))
154 }
155
156 pub async fn pull_cached_by_manifest_digest(
162 cache: &GlobalCache,
163 manifest_digest: &Digest,
164 ) -> ImageResult<Option<(PullResult, CachedImageMetadata)>> {
165 Ok(
166 resolve_cached_pull_result_by_manifest_digest_async(cache, manifest_digest)
167 .await?
168 .map(|cached| (cached.result, cached.metadata)),
169 )
170 }
171
172 pub async fn pull(
174 &self,
175 reference: &oci_client::Reference,
176 options: &PullOptions,
177 ) -> ImageResult<PullResult> {
178 self.pull_inner(reference, options, None).await
179 }
180
181 pub async fn materialize_cached_layers(
187 &self,
188 reference: &oci_client::Reference,
189 metadata: &CachedImageMetadata,
190 force: bool,
191 ) -> ImageResult<PullResult> {
192 self.materialize_cached_layers_inner(reference, metadata, force, None, None)
193 .await
194 }
195
196 pub(crate) async fn materialize_cached_layers_from_paths(
197 &self,
198 reference: &oci_client::Reference,
199 metadata: &CachedImageMetadata,
200 force: bool,
201 staged_layers: Arc<HashMap<String, PathBuf>>,
202 progress: Option<PullProgressSender>,
203 ) -> ImageResult<PullResult> {
204 self.materialize_cached_layers_inner(
205 reference,
206 metadata,
207 force,
208 Some(staged_layers),
209 progress,
210 )
211 .await
212 }
213
214 async fn materialize_cached_layers_inner(
215 &self,
216 reference: &oci_client::Reference,
217 metadata: &CachedImageMetadata,
218 force: bool,
219 staged_layers: Option<Arc<HashMap<String, PathBuf>>>,
220 progress: Option<PullProgressSender>,
221 ) -> ImageResult<PullResult> {
222 let manifest_digest: Digest = metadata.manifest_digest.parse()?;
223 let layer_descriptors = metadata
224 .layers
225 .iter()
226 .map(|layer| {
227 Ok(LayerDescriptor {
228 digest: layer.digest.parse()?,
229 media_type: layer.media_type.clone(),
230 size: layer.size_bytes,
231 })
232 })
233 .collect::<ImageResult<Vec<_>>>()?;
234 let diff_ids = metadata
235 .layers
236 .iter()
237 .map(|layer| layer.diff_id.clone())
238 .collect::<Vec<_>>();
239
240 self.materialize_layers_and_fsmeta(MaterializeLayersRequest {
241 oci_ref: reference,
242 manifest_digest: &manifest_digest,
243 layer_descriptors: &layer_descriptors,
244 diff_ids: &diff_ids,
245 force,
246 progress,
247 staged_layers,
248 })
249 .await?;
250
251 let layer_diff_ids = diff_ids
252 .iter()
253 .map(|diff_id| diff_id.parse())
254 .collect::<ImageResult<Vec<Digest>>>()?;
255
256 Ok(PullResult {
257 layer_diff_ids,
258 config: metadata.config.clone(),
259 manifest_digest,
260 cached: false,
261 })
262 }
263
264 pub fn pull_with_progress(
269 &self,
270 reference: &oci_client::Reference,
271 options: &PullOptions,
272 ) -> (PullProgressHandle, JoinHandle<ImageResult<PullResult>>)
273 where
274 Self: Send + Sync + 'static,
275 {
276 let (handle, sender) = progress::progress_channel();
277 let task = self.spawn_pull_task(reference, options, sender);
278 (handle, task)
279 }
280
281 pub fn pull_with_sender(
287 &self,
288 reference: &oci_client::Reference,
289 options: &PullOptions,
290 sender: PullProgressSender,
291 ) -> JoinHandle<ImageResult<PullResult>>
292 where
293 Self: Send + Sync + 'static,
294 {
295 self.spawn_pull_task(reference, options, sender)
296 }
297
298 fn spawn_pull_task(
300 &self,
301 reference: &oci_client::Reference,
302 options: &PullOptions,
303 sender: PullProgressSender,
304 ) -> JoinHandle<ImageResult<PullResult>>
305 where
306 Self: Send + Sync + 'static,
307 {
308 let reference = reference.clone();
309 let options = options.clone();
310 let client = self.client.clone();
311 let auth = self.auth.clone();
312 let platform = self.platform.clone();
313
314 let layers_dir = self.cache.layers_dir().to_path_buf();
315 let cache_parent = layers_dir.parent().unwrap_or(&layers_dir).to_path_buf();
316
317 tokio::spawn(async move {
318 let cache = GlobalCache::new_async(&cache_parent).await?;
319 let registry = Self {
320 client,
321 auth,
322 platform,
323 cache,
324 };
325 registry
326 .pull_inner(&reference, &options, Some(sender))
327 .await
328 })
329 }
330
331 async fn pull_inner(
333 &self,
334 reference: &oci_client::Reference,
335 options: &PullOptions,
336 progress: Option<PullProgressSender>,
337 ) -> ImageResult<PullResult> {
338 let pull_started_at = Instant::now();
339 let ref_str: Arc<str> = reference.to_string().into();
340 let oci_ref = reference;
341 let image_lock_path = self.cache.image_lock_path(reference);
342 let image_lock_file = open_lock_file(&image_lock_path)?;
343 let image_lock_file = tokio::task::spawn_blocking(move || {
344 lock_exclusive(&image_lock_file)?;
345 Ok::<_, ImageError>(image_lock_file)
346 })
347 .await
348 .map_err(|e| ImageError::Io(io::Error::other(e)))??;
349 let _image_lock_guard = scopeguard::guard(image_lock_file, |file| {
352 let _ = flock_unlock(&file);
353 });
354
355 if let Some(cached) =
357 resolve_cached_pull_result_async(&self.cache, reference, options).await?
358 {
359 tracing::debug!(
360 reference = %reference,
361 elapsed_ms = pull_started_at.elapsed().as_millis(),
362 "pull resolved entirely from cached image metadata"
363 );
364
365 if let Some(ref p) = progress {
366 p.send(PullProgress::Resolving {
367 reference: ref_str.clone(),
368 });
369 p.send(PullProgress::Resolved {
370 reference: ref_str.clone(),
371 manifest_digest: cached.metadata.manifest_digest.clone().into(),
372 layer_count: cached.metadata.layers.len(),
373 total_download_bytes: cached
374 .metadata
375 .layers
376 .iter()
377 .filter_map(|layer| layer.size_bytes)
378 .reduce(|a, b| a + b),
379 });
380 p.send(PullProgress::Complete {
381 reference: ref_str,
382 layer_count: cached.metadata.layers.len(),
383 });
384 }
385
386 return Ok(cached.result);
387 }
388
389 if options.pull_policy == PullPolicy::Never {
390 return Err(ImageError::NotCached {
391 reference: reference.to_string(),
392 });
393 }
394
395 if let Some(ref p) = progress {
397 p.send(PullProgress::Resolving {
398 reference: ref_str.clone(),
399 });
400 }
401
402 let resolve_started_at = Instant::now();
403 let (manifest_bytes, manifest_digest, config_bytes) =
404 self.fetch_manifest_and_config(oci_ref).await?;
405
406 let manifest_digest: Digest = manifest_digest.parse()?;
407
408 let (manifest, config_bytes, resolved_manifest_bytes) = self
411 .parse_and_resolve_manifest(&manifest_bytes, config_bytes, oci_ref)
412 .await?;
413
414 let (image_config, diff_ids) = ImageConfig::parse(&config_bytes)?;
416
417 let layer_descriptors = self.extract_layer_digests(&manifest)?;
419
420 if diff_ids.len() != layer_descriptors.len() {
422 return Err(ImageError::ManifestParse(format!(
423 "layer count mismatch: config has {} diff_ids but manifest has {} layers",
424 diff_ids.len(),
425 layer_descriptors.len()
426 )));
427 }
428
429 let layer_count = layer_descriptors.len();
430 let total_bytes: Option<u64> = {
431 let sum: u64 = layer_descriptors
432 .iter()
433 .filter_map(|layer| layer.size)
434 .sum();
435 if sum > 0 { Some(sum) } else { None }
436 };
437
438 tracing::debug!(
439 reference = %reference,
440 layer_count,
441 elapsed_ms = resolve_started_at.elapsed().as_millis(),
442 "pull resolved manifest and layer descriptors"
443 );
444
445 if let Some(ref p) = progress {
446 p.send(PullProgress::Resolved {
447 reference: ref_str.clone(),
448 manifest_digest: manifest_digest.to_string().into(),
449 layer_count,
450 total_download_bytes: total_bytes,
451 });
452 }
453
454 tokio::task::yield_now().await;
457
458 {
460 let mut seen = std::collections::HashSet::new();
461 for desc in &layer_descriptors {
462 if !seen.insert(&desc.digest) {
463 tracing::warn!(
464 digest = %desc.digest,
465 "manifest contains duplicate layer digest; \
466 per-layer processing will be serialized for this digest"
467 );
468 }
469 }
470 }
471
472 self.materialize_layers_and_fsmeta(MaterializeLayersRequest {
474 oci_ref,
475 manifest_digest: &manifest_digest,
476 layer_descriptors: &layer_descriptors,
477 diff_ids: &diff_ids,
478 force: options.force,
479 progress: progress.clone(),
480 staged_layers: None,
481 })
482 .await?;
483
484 for layer_desc in &layer_descriptors {
487 let layer = Layer::new(layer_desc.digest.clone(), &self.cache);
488 let _ = tokio::fs::remove_file(&layer.tar_path_ref()).await;
489 }
490
491 let layer_diff_ids: Vec<Digest> = diff_ids
492 .iter()
493 .map(|diff_id| diff_id.parse())
494 .collect::<ImageResult<Vec<Digest>>>()?;
495
496 let cached_image = CachedImageMetadata {
498 manifest_digest: manifest_digest.to_string(),
499 config_digest: manifest.config_digest().unwrap_or_default(),
500 raw_manifest_json: json_bytes_to_string(&resolved_manifest_bytes, "resolved manifest")?,
501 raw_config_json: json_bytes_to_string(&config_bytes, "image config")?,
502 config: image_config.clone(),
503 layers: layer_descriptors
504 .iter()
505 .enumerate()
506 .map(|(i, layer)| CachedLayerMetadata {
507 digest: layer.digest.to_string(),
508 media_type: layer.media_type.clone(),
509 size_bytes: layer.size,
510 diff_id: diff_ids.get(i).cloned().unwrap_or_default(),
511 })
512 .collect(),
513 };
514 self.cache
515 .write_image_metadata_async(reference, &cached_image)
516 .await?;
517
518 tracing::debug!(
519 reference = %reference,
520 layer_count,
521 elapsed_ms = pull_started_at.elapsed().as_millis(),
522 "pull completed and cached image metadata was persisted"
523 );
524
525 if let Some(ref p) = progress {
526 p.send(PullProgress::Complete {
527 reference: ref_str,
528 layer_count,
529 });
530 }
531
532 Ok(PullResult {
533 layer_diff_ids,
534 config: image_config,
535 manifest_digest,
536 cached: false,
537 })
538 }
539
540 async fn fetch_manifest_and_config(
542 &self,
543 reference: &oci_client::Reference,
544 ) -> ImageResult<(Vec<u8>, String, Vec<u8>)> {
545 let (manifest, manifest_digest, config) = self
546 .client
547 .pull_manifest_and_config(reference, &self.auth)
548 .await?;
549
550 let manifest_bytes = serde_json::to_vec(&manifest)
551 .map_err(|e| ImageError::ManifestParse(format!("failed to serialize manifest: {e}")))?;
552
553 Ok((manifest_bytes, manifest_digest, config.into_bytes()))
554 }
555
556 async fn parse_and_resolve_manifest(
562 &self,
563 manifest_bytes: &[u8],
564 config_bytes: Vec<u8>,
565 reference: &oci_client::Reference,
566 ) -> ImageResult<(OciManifest, Vec<u8>, Vec<u8>)> {
567 let media_type = detect_manifest_media_type(manifest_bytes);
569
570 let manifest = OciManifest::parse(manifest_bytes, &media_type)?;
571
572 if manifest.is_index() {
573 self.resolve_platform_manifest(manifest_bytes, reference)
575 .await
576 } else {
577 Ok((manifest, config_bytes, manifest_bytes.to_vec()))
578 }
579 }
580
581 async fn resolve_platform_manifest(
585 &self,
586 index_bytes: &[u8],
587 reference: &oci_client::Reference,
588 ) -> ImageResult<(OciManifest, Vec<u8>, Vec<u8>)> {
589 let index: oci_spec::image::ImageIndex = serde_json::from_slice(index_bytes)
590 .map_err(|e| ImageError::ManifestParse(format!("failed to parse index: {e}")))?;
591
592 let manifests = index.manifests();
593
594 let mut best_match: Option<&oci_spec::image::Descriptor> = None;
596 let mut exact_variant = false;
597
598 for entry in manifests {
599 if entry.media_type().to_string().contains("attestation") {
601 continue;
602 }
603
604 let platform = match entry.platform().as_ref() {
605 Some(p) => p,
606 None => continue,
607 };
608
609 if *platform.os() != self.platform.os {
611 continue;
612 }
613
614 if *platform.architecture() != self.platform.arch {
616 continue;
617 }
618
619 if let Some(ref target_variant) = self.platform.variant {
621 if let Some(entry_variant) = platform.variant().as_ref()
622 && entry_variant == target_variant
623 {
624 best_match = Some(entry);
625 exact_variant = true;
626 continue;
627 }
628 if !exact_variant {
629 best_match = Some(entry);
630 }
631 } else {
632 best_match = Some(entry);
633 }
634 }
635
636 let entry = best_match.ok_or_else(|| ImageError::PlatformNotFound {
637 reference: reference.to_string(),
638 os: self.platform.os.clone(),
639 arch: self.platform.arch.clone(),
640 })?;
641
642 let digest = entry.digest();
643
644 let platform_ref = format!(
646 "{}/{}@{}",
647 reference.registry(),
648 reference.repository(),
649 digest
650 );
651 let platform_ref: oci_client::Reference = platform_ref.parse().map_err(|e| {
652 ImageError::ManifestParse(format!("failed to parse platform reference: {e}"))
653 })?;
654
655 let (manifest_bytes, _digest, config_bytes) =
656 self.fetch_manifest_and_config(&platform_ref).await?;
657
658 let media_type = detect_manifest_media_type(&manifest_bytes);
659 let manifest = OciManifest::parse(&manifest_bytes, &media_type)?;
660 Ok((manifest, config_bytes, manifest_bytes))
661 }
662
663 fn extract_layer_digests(&self, manifest: &OciManifest) -> ImageResult<Vec<LayerDescriptor>> {
665 match manifest {
666 OciManifest::Image(m) => {
667 let layers: Vec<LayerDescriptor> = m
668 .layers()
669 .iter()
670 .map(|desc| {
671 let digest: Digest = desc.digest().to_string().parse().map_err(|_| {
672 ImageError::ManifestParse(format!(
673 "invalid layer digest: {}",
674 desc.digest()
675 ))
676 })?;
677 let size = if desc.size() > 0 {
678 Some(desc.size())
679 } else {
680 None
681 };
682 Ok(LayerDescriptor {
683 digest,
684 media_type: Some(desc.media_type().to_string()),
685 size,
686 })
687 })
688 .collect::<ImageResult<Vec<_>>>()?;
689 Ok(layers)
690 }
691 OciManifest::Index(_) => Err(ImageError::ManifestParse(
692 "cannot extract layers from an index — resolve platform first".to_string(),
693 )),
694 }
695 }
696
697 async fn materialize_layers_and_fsmeta(
699 &self,
700 request: MaterializeLayersRequest<'_>,
701 ) -> ImageResult<()> {
702 let MaterializeLayersRequest {
703 oci_ref,
704 manifest_digest,
705 layer_descriptors,
706 diff_ids,
707 force,
708 progress,
709 staged_layers,
710 } = request;
711
712 let validated_diff_ids: Vec<Digest> = diff_ids
715 .iter()
716 .enumerate()
717 .map(|(i, id)| {
718 id.parse::<Digest>().map_err(|_| {
719 ImageError::ManifestParse(format!("invalid diff_id at layer {i}: {id}"))
720 })
721 })
722 .collect::<ImageResult<Vec<_>>>()?;
723
724 let fsmeta_path = self.cache.fsmeta_erofs_path(manifest_digest);
734 let vmdk_path = self.cache.vmdk_path(manifest_digest);
735 let fsmeta_valid = cache::is_valid_erofs_artifact_async(&fsmeta_path).await;
736 let vmdk_valid = path_exists_async(&vmdk_path).await;
737 let all_layers_valid =
738 all_layers_materialized_async(&self.cache, &validated_diff_ids).await;
739
740 if all_layers_valid && fsmeta_valid && vmdk_valid && !force {
741 return Ok(());
742 }
743
744 if all_layers_valid && fsmeta_valid && !vmdk_valid && !force {
745 return self
746 .regenerate_vmdk_only(manifest_digest, &validated_diff_ids, progress.as_ref())
747 .await;
748 }
749
750 let layer_force = force || !fsmeta_valid;
760 let has_duplicate_diff_ids = has_duplicate_entries(diff_ids);
761 let layer_concurrency = layer_pipeline_concurrency(layer_descriptors.len());
762 let semaphore = Arc::new(Semaphore::new(layer_concurrency));
763
764 let layer_tasks: Vec<_> = layer_descriptors
765 .iter()
766 .enumerate()
767 .map(|(i, layer_desc)| {
768 let layer = Layer::new(layer_desc.digest.clone(), &self.cache);
769 let client = self.client.clone();
770 let oci_ref = oci_ref.clone();
771 let size = layer_desc.size;
772 let progress = progress.clone();
773 let media_type = layer_desc.media_type.clone();
774 let diff_id = diff_ids[i].clone();
775 let staged_tar_path = staged_layers
776 .as_ref()
777 .and_then(|layers| layers.get(&layer_desc.digest.to_string()).cloned());
778
779 let diff_id_digest: Digest = validated_diff_ids[i].clone();
780 let erofs_path = self.cache.layer_erofs_path(&diff_id_digest);
781 let lock_path = self.cache.layer_erofs_lock_path(&diff_id_digest);
782 let tmp_dir = self.cache.tmp_dir().to_path_buf();
783 let semaphore = Arc::clone(&semaphore);
784
785 tokio::spawn(async move {
786 let _permit =
787 semaphore
788 .acquire_owned()
789 .await
790 .map_err(|e| LayerPipelineFailure {
791 error: ImageError::Io(io::Error::other(format!(
792 "layer pipeline semaphore closed: {e}"
793 ))),
794 })?;
795 let layer_started_at = Instant::now();
796
797 if cache::is_valid_erofs_artifact_async(&erofs_path).await && !layer_force {
798 if let Some(ref p) = progress {
799 p.send(PullProgress::LayerMaterializeComplete {
800 layer_index: i,
801 diff_id: diff_id.clone().into(),
802 });
803 }
804
805 tracing::debug!(
806 layer_index = i,
807 diff_id = %diff_id,
808 elapsed_ms = layer_started_at.elapsed().as_millis(),
809 "layer reused existing EROFS image"
810 );
811
812 return Ok::<_, LayerPipelineFailure>(LayerPipelineTreeSuccess {
813 layer_index: i,
814 tree: None,
815 data_map: None,
816 });
817 }
818
819 if staged_tar_path.is_none()
820 && let Err(error) = layer
821 .download(&client, &oci_ref, size, force, progress.as_ref(), i)
822 .await
823 {
824 return Err(LayerPipelineFailure { error });
825 }
826
827 let lock_file = open_lock_file(&lock_path)
829 .map_err(|e| LayerPipelineFailure { error: e })?;
830 let lock_file = tokio::task::spawn_blocking(move || {
831 lock_exclusive(&lock_file)?;
832 Ok::<_, ImageError>(lock_file)
833 })
834 .await
835 .map_err(|e| LayerPipelineFailure {
836 error: ImageError::Io(io::Error::other(e)),
837 })?
838 .map_err(|e| LayerPipelineFailure { error: e })?;
839 let _lock_guard = scopeguard::guard(lock_file, |file| {
840 let _ = flock_unlock(&file);
841 });
842
843 if cache::is_valid_erofs_artifact_async(&erofs_path).await && !layer_force {
845 if let Some(ref p) = progress {
846 p.send(PullProgress::LayerMaterializeComplete {
847 layer_index: i,
848 diff_id: diff_id.clone().into(),
849 });
850 }
851 return Ok::<_, LayerPipelineFailure>(LayerPipelineTreeSuccess {
852 layer_index: i,
853 tree: None,
854 data_map: None,
855 });
856 }
857
858 if let Some(ref p) = progress {
859 p.send(PullProgress::LayerMaterializeStarted {
860 layer_index: i,
861 diff_id: diff_id.clone().into(),
862 });
863 }
864
865 let tar_path = staged_tar_path
866 .clone()
867 .unwrap_or_else(|| layer.tar_path_ref());
868 let tar_size =
869 tokio::fs::metadata(&tar_path)
870 .await
871 .map_err(|e| LayerPipelineFailure {
872 error: ImageError::Cache {
873 path: tar_path.clone(),
874 source: e,
875 },
876 })?;
877 let tar_file = tokio::fs::File::open(&tar_path).await.map_err(|e| {
878 LayerPipelineFailure {
879 error: ImageError::Cache {
880 path: tar_path.clone(),
881 source: e,
882 },
883 }
884 })?;
885
886 let compression =
887 Compression::from_media_type(media_type.as_deref().unwrap_or(""));
888 let limits = ResourceLimits::default();
889 let spool_path = layer_work_path(&tmp_dir, &diff_id_digest, "spool");
890 let ingest_started_at = Instant::now();
891 let ingest_result = tar::ingest_compressed_tar(
892 MaterializeProgressReader::new(
893 tar_file,
894 progress.clone(),
895 i,
896 tar_size.len(),
897 ),
898 compression,
899 &limits,
900 Some(&spool_path),
901 )
902 .await
903 .map_err(|e| LayerPipelineFailure {
904 error: ImageError::Materialize {
905 digest: diff_id.clone(),
906 message: format!("tar ingestion failed: {e}"),
907 source: None,
908 },
909 })?;
910
911 let expected_diff_hex = diff_id_digest.hex();
915 if ingest_result.uncompressed_digest != expected_diff_hex {
916 return Err(LayerPipelineFailure {
917 error: ImageError::DigestMismatch {
918 digest: diff_id.clone(),
919 expected: format!("sha256:{expected_diff_hex}"),
920 actual: format!("sha256:{}", ingest_result.uncompressed_digest),
921 },
922 });
923 }
924 let tree = ingest_result.tree;
925
926 tracing::debug!(
927 layer_index = i,
928 diff_id = %diff_id,
929 tar_bytes = tar_size.len(),
930 elapsed_ms = ingest_started_at.elapsed().as_millis(),
931 "layer tar ingestion completed (diff_id verified)"
932 );
933
934 if let Some(ref p) = progress {
935 p.send(PullProgress::LayerMaterializeWriting { layer_index: i });
936 }
937
938 let temp_path = layer_work_path(&tmp_dir, &diff_id_digest, "erofs.part");
941 let erofs_final = erofs_path.clone();
942 let diff_id_for_join = diff_id.clone();
943 let write_started_at = Instant::now();
944 let (data_map, mut tree) = tokio::task::spawn_blocking(move || {
945 let data_map = erofs::write_erofs(&tree, &temp_path)?;
946 std::fs::rename(&temp_path, &erofs_final).map_err(erofs::ErofsError::Io)?;
947 Ok::<(erofs::ErofsDataMap, FileTree), erofs::ErofsError>((data_map, tree))
948 })
949 .await
950 .map_err(|e| LayerPipelineFailure {
951 error: ImageError::Materialize {
952 digest: diff_id_for_join.clone(),
953 message: format!("EROFS write task failed: {e}"),
954 source: None,
955 },
956 })?
957 .map_err(|e| LayerPipelineFailure {
958 error: ImageError::Materialize {
959 digest: diff_id.clone(),
960 message: format!("EROFS write failed: {e}"),
961 source: None,
962 },
963 })?;
964
965 tree.strip_file_data();
968
969 tracing::debug!(
970 layer_index = i,
971 diff_id = %diff_id,
972 elapsed_ms = write_started_at.elapsed().as_millis(),
973 total_elapsed_ms = layer_started_at.elapsed().as_millis(),
974 "layer EROFS image write completed"
975 );
976
977 let _ = tokio::fs::remove_file(&spool_path).await;
981
982 if let Some(ref p) = progress {
983 p.send(PullProgress::LayerMaterializeComplete {
984 layer_index: i,
985 diff_id: diff_id.clone().into(),
986 });
987 }
988
989 Ok::<_, LayerPipelineFailure>(LayerPipelineTreeSuccess {
990 layer_index: i,
991 tree: Some(tree),
992 data_map: Some(data_map),
993 })
994 })
995 })
996 .collect();
997
998 let mut layer_results = wait_for_layer_tree_pipeline(layer_tasks).await?;
1000 layer_results.sort_by_key(|r| r.layer_index);
1001
1002 let fsmeta_path = self.cache.fsmeta_erofs_path(manifest_digest);
1004 let vmdk_path = self.cache.vmdk_path(manifest_digest);
1005
1006 if cache::is_valid_erofs_artifact_async(&fsmeta_path).await
1007 && path_exists_async(&vmdk_path).await
1008 && !force
1009 {
1010 tracing::debug!(
1011 manifest_digest = %manifest_digest,
1012 "fsmeta + VMDK already cached, skipping generation"
1013 );
1014 return Ok(());
1015 }
1016
1017 let fsmeta_lock_path = self.cache.fsmeta_erofs_lock_path(manifest_digest);
1019 let fsmeta_lock_file = open_lock_file(&fsmeta_lock_path)?;
1020 let fsmeta_lock_file = tokio::task::spawn_blocking(move || {
1021 lock_exclusive(&fsmeta_lock_file)?;
1022 Ok::<_, ImageError>(fsmeta_lock_file)
1023 })
1024 .await
1025 .map_err(|e| ImageError::Io(io::Error::other(e)))??;
1026 let _fsmeta_lock_guard = scopeguard::guard(fsmeta_lock_file, |file| {
1027 let _ = flock_unlock(&file);
1028 });
1029
1030 if cache::is_valid_erofs_artifact_async(&fsmeta_path).await
1032 && path_exists_async(&vmdk_path).await
1033 && !force
1034 {
1035 return Ok(());
1036 }
1037
1038 let (layer_trees, layer_data_maps) = if has_duplicate_diff_ids {
1051 let mut tree_by_diff_id: HashMap<String, (FileTree, erofs::ErofsDataMap)> =
1052 HashMap::new();
1053 for result in &mut layer_results {
1054 if let (Some(tree), Some(data_map)) = (result.tree.take(), result.data_map.take()) {
1055 let diff_id = diff_ids[result.layer_index].clone();
1056 tree_by_diff_id.entry(diff_id).or_insert((tree, data_map));
1057 }
1058 }
1059
1060 let mut layer_trees: Vec<FileTree> = Vec::with_capacity(layer_results.len());
1061 let mut layer_data_maps: Vec<erofs::ErofsDataMap> =
1062 Vec::with_capacity(layer_results.len());
1063 for result in &layer_results {
1064 let diff_id = &diff_ids[result.layer_index];
1065 match tree_by_diff_id.get(diff_id) {
1066 Some((tree, data_map)) => {
1067 layer_trees.push(tree.clone());
1068 layer_data_maps.push(data_map.clone());
1069 }
1070 None => {
1071 return Err(ImageError::Materialize {
1072 digest: manifest_digest.to_string(),
1073 message: "fsmeta cache evicted but layer EROFS cached — \
1074 re-pull with force to regenerate"
1075 .into(),
1076 source: None,
1077 });
1078 }
1079 }
1080 }
1081
1082 (layer_trees, layer_data_maps)
1083 } else {
1084 let mut layer_trees: Vec<FileTree> = Vec::with_capacity(layer_results.len());
1085 let mut layer_data_maps: Vec<erofs::ErofsDataMap> =
1086 Vec::with_capacity(layer_results.len());
1087 for result in layer_results {
1088 let tree = result.tree.ok_or_else(|| ImageError::Materialize {
1089 digest: manifest_digest.to_string(),
1090 message: "fsmeta generation expected uncached layer tree but found none".into(),
1091 source: None,
1092 })?;
1093 let data_map = result.data_map.ok_or_else(|| ImageError::Materialize {
1094 digest: manifest_digest.to_string(),
1095 message: "fsmeta generation expected uncached layer data map but found none"
1096 .into(),
1097 source: None,
1098 })?;
1099 layer_trees.push(tree);
1100 layer_data_maps.push(data_map);
1101 }
1102
1103 (layer_trees, layer_data_maps)
1104 };
1105
1106 if let Some(ref p) = progress {
1108 p.send(PullProgress::StitchMergingTrees {
1109 layer_count: layer_trees.len(),
1110 });
1111 }
1112 let (merged_tree, provenance) = crate::tree::merge_layers_with_provenance(layer_trees);
1113
1114 let fsmeta_path_for_write = fsmeta_path.clone();
1116 let vmdk_path_for_write = vmdk_path.clone();
1117 let work_dir = self.cache.work_dir(manifest_digest);
1118 let manifest_digest_str = manifest_digest.to_string();
1119
1120 let layer_erofs_paths: Vec<std::path::PathBuf> = validated_diff_ids
1122 .iter()
1123 .map(|d| self.cache.layer_erofs_path(d))
1124 .collect();
1125
1126 let stitch_progress = progress.clone();
1127 tokio::task::spawn_blocking(move || {
1128 std::fs::create_dir_all(&work_dir).map_err(|e| ImageError::Cache {
1129 path: work_dir.clone(),
1130 source: e,
1131 })?;
1132 let _work_guard = scopeguard::guard((), |_| {
1133 let _ = std::fs::remove_dir_all(&work_dir);
1134 });
1135
1136 if let Some(ref p) = stitch_progress {
1138 p.send(PullProgress::StitchWritingFsmeta);
1139 }
1140 let temp_fsmeta = work_dir.join("fsmeta.erofs");
1141 erofs::fsmeta::write_fsmeta(&merged_tree, &provenance, &layer_data_maps, &temp_fsmeta)
1142 .map_err(|e| ImageError::Materialize {
1143 digest: manifest_digest_str.clone(),
1144 message: format!("fsmeta write failed: {e}"),
1145 source: None,
1146 })?;
1147
1148 std::fs::rename(&temp_fsmeta, &fsmeta_path_for_write).map_err(|e| {
1149 ImageError::Cache {
1150 path: fsmeta_path_for_write.clone(),
1151 source: e,
1152 }
1153 })?;
1154
1155 if let Some(ref p) = stitch_progress {
1157 p.send(PullProgress::StitchWritingVmdk);
1158 }
1159 let temp_vmdk = work_dir.join("rootfs.vmdk");
1160 let mut extents: Vec<&std::path::Path> = vec![&fsmeta_path_for_write];
1161 extents.extend(layer_erofs_paths.iter().map(|p| p.as_path()));
1162
1163 crate::stitch::write_vmdk_descriptor(&temp_vmdk, &extents).map_err(|e| {
1164 ImageError::Materialize {
1165 digest: manifest_digest_str.clone(),
1166 message: format!("VMDK write failed: {e}"),
1167 source: None,
1168 }
1169 })?;
1170
1171 std::fs::rename(&temp_vmdk, &vmdk_path_for_write).map_err(|e| ImageError::Cache {
1172 path: vmdk_path_for_write.clone(),
1173 source: e,
1174 })?;
1175
1176 Ok::<(), ImageError>(())
1177 })
1178 .await
1179 .map_err(|e| ImageError::Io(io::Error::other(e)))??;
1180
1181 if let Some(ref p) = progress {
1182 p.send(PullProgress::StitchComplete);
1183 }
1184
1185 Ok(())
1186 }
1187
1188 async fn regenerate_vmdk_only(
1194 &self,
1195 manifest_digest: &Digest,
1196 validated_diff_ids: &[Digest],
1197 progress: Option<&PullProgressSender>,
1198 ) -> ImageResult<()> {
1199 let fsmeta_path = self.cache.fsmeta_erofs_path(manifest_digest);
1200 let vmdk_path = self.cache.vmdk_path(manifest_digest);
1201
1202 let fsmeta_lock_path = self.cache.fsmeta_erofs_lock_path(manifest_digest);
1203 let fsmeta_lock_file = open_lock_file(&fsmeta_lock_path)?;
1204 let fsmeta_lock_file = tokio::task::spawn_blocking(move || {
1205 lock_exclusive(&fsmeta_lock_file)?;
1206 Ok::<_, ImageError>(fsmeta_lock_file)
1207 })
1208 .await
1209 .map_err(|e| ImageError::Io(io::Error::other(e)))??;
1210 let _fsmeta_lock_guard = scopeguard::guard(fsmeta_lock_file, |file| {
1211 let _ = flock_unlock(&file);
1212 });
1213
1214 if path_exists_async(&vmdk_path).await {
1217 return Ok(());
1218 }
1219 if !cache::is_valid_erofs_artifact_async(&fsmeta_path).await {
1220 return Err(ImageError::Materialize {
1221 digest: manifest_digest.to_string(),
1222 message: "fsmeta vanished while waiting for VMDK regen lock".into(),
1223 source: None,
1224 });
1225 }
1226
1227 let layer_erofs_paths: Vec<std::path::PathBuf> = validated_diff_ids
1228 .iter()
1229 .map(|d| self.cache.layer_erofs_path(d))
1230 .collect();
1231 let work_dir = self.cache.work_dir(manifest_digest);
1232 let manifest_digest_str = manifest_digest.to_string();
1233
1234 let stitch_progress = progress.cloned();
1235 tokio::task::spawn_blocking(move || {
1236 std::fs::create_dir_all(&work_dir).map_err(|e| ImageError::Cache {
1237 path: work_dir.clone(),
1238 source: e,
1239 })?;
1240 let _work_guard = scopeguard::guard((), |_| {
1241 let _ = std::fs::remove_dir_all(&work_dir);
1242 });
1243
1244 if let Some(ref p) = stitch_progress {
1245 p.send(PullProgress::StitchWritingVmdk);
1246 }
1247 let temp_vmdk = work_dir.join("rootfs.vmdk");
1248 let mut extents: Vec<&std::path::Path> = vec![&fsmeta_path];
1249 extents.extend(layer_erofs_paths.iter().map(|p| p.as_path()));
1250
1251 crate::stitch::write_vmdk_descriptor(&temp_vmdk, &extents).map_err(|e| {
1252 ImageError::Materialize {
1253 digest: manifest_digest_str.clone(),
1254 message: format!("VMDK write failed: {e}"),
1255 source: None,
1256 }
1257 })?;
1258
1259 std::fs::rename(&temp_vmdk, &vmdk_path).map_err(|e| ImageError::Cache {
1260 path: vmdk_path.clone(),
1261 source: e,
1262 })?;
1263
1264 Ok::<(), ImageError>(())
1265 })
1266 .await
1267 .map_err(|e| ImageError::Io(io::Error::other(e)))??;
1268
1269 if let Some(p) = progress {
1270 p.send(PullProgress::StitchComplete);
1271 }
1272
1273 Ok(())
1274 }
1275
1276 }
1279
1280impl<R: AsyncRead + Unpin> AsyncRead for MaterializeProgressReader<R> {
1285 fn poll_read(
1286 mut self: Pin<&mut Self>,
1287 cx: &mut Context<'_>,
1288 buf: &mut ReadBuf<'_>,
1289 ) -> Poll<io::Result<()>> {
1290 let before = buf.filled().len();
1291 match Pin::new(&mut self.inner).poll_read(cx, buf) {
1292 Poll::Ready(Ok(())) => {
1293 let bytes_read = (buf.filled().len() - before) as u64;
1294 if bytes_read > 0 {
1295 self.bytes_read += bytes_read;
1296 let should_emit_progress =
1297 self.bytes_read.saturating_sub(self.last_emitted_bytes)
1298 >= MATERIALIZE_PROGRESS_EMIT_BYTES
1299 || self.bytes_read >= self.total_bytes;
1300
1301 if should_emit_progress {
1302 if let Some(progress) = &self.progress {
1303 progress.send(PullProgress::LayerMaterializeProgress {
1304 layer_index: self.layer_index,
1305 bytes_read: self.bytes_read.min(self.total_bytes),
1306 total_bytes: self.total_bytes,
1307 });
1308 }
1309 self.last_emitted_bytes = self.bytes_read;
1310 }
1311 }
1312
1313 Poll::Ready(Ok(()))
1314 }
1315 Poll::Ready(Err(error)) => Poll::Ready(Err(error)),
1316 Poll::Pending => Poll::Pending,
1317 }
1318 }
1319}
1320
1321fn detect_manifest_media_type(bytes: &[u8]) -> String {
1327 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
1329 if let Some(mt) = v.get("mediaType").and_then(|v| v.as_str()) {
1330 return mt.to_string();
1331 }
1332
1333 if v.get("manifests").is_some() {
1335 return "application/vnd.oci.image.index.v1+json".to_string();
1336 }
1337
1338 if v.get("layers").is_some() {
1340 return "application/vnd.oci.image.manifest.v1+json".to_string();
1341 }
1342 }
1343
1344 "application/vnd.oci.image.manifest.v1+json".to_string()
1346}
1347
1348pub(super) fn resolve_platform_digest(
1350 manifests: &[ImageIndexEntry],
1351 target: &Platform,
1352) -> Option<String> {
1353 let mut arch_only_match: Option<String> = None;
1354 let target_os = target.os.to_string();
1355 let target_arch = target.arch.to_string();
1356
1357 for entry in manifests {
1358 if entry.media_type.contains("attestation") {
1359 continue;
1360 }
1361
1362 let Some(platform) = entry.platform.as_ref() else {
1363 continue;
1364 };
1365 if platform.os.to_string() != target_os || platform.architecture.to_string() != target_arch
1366 {
1367 continue;
1368 }
1369
1370 match target.variant.as_deref() {
1371 Some(target_variant) if platform.variant.as_deref() == Some(target_variant) => {
1372 return Some(entry.digest.clone());
1373 }
1374 Some(_) => {
1375 if arch_only_match.is_none() {
1376 arch_only_match = Some(entry.digest.clone());
1377 }
1378 }
1379 None => return Some(entry.digest.clone()),
1380 }
1381 }
1382
1383 arch_only_match
1384}
1385
1386fn cached_pull_result(metadata: &CachedImageMetadata) -> ImageResult<PullResult> {
1388 let manifest_digest: Digest = metadata.manifest_digest.parse()?;
1389 let layer_diff_ids = metadata
1390 .layers
1391 .iter()
1392 .map(|layer| layer.diff_id.parse())
1393 .collect::<ImageResult<Vec<Digest>>>()?;
1394
1395 Ok(PullResult {
1396 layer_diff_ids,
1397 config: metadata.config.clone(),
1398 manifest_digest,
1399 cached: true,
1400 })
1401}
1402
1403fn resolve_cached_pull_result(
1404 cache: &GlobalCache,
1405 reference: &oci_client::Reference,
1406 options: &PullOptions,
1407) -> ImageResult<Option<CachedPullInfo>> {
1408 if options.force || options.pull_policy == PullPolicy::Always {
1409 return Ok(None);
1410 }
1411
1412 let Some(metadata) = cache.read_image_metadata(reference)? else {
1413 return Ok(None);
1414 };
1415
1416 let cached_diff_ids = match metadata
1418 .layers
1419 .iter()
1420 .map(|layer| layer.diff_id.parse())
1421 .collect::<ImageResult<Vec<Digest>>>()
1422 {
1423 Ok(digests) => digests,
1424 Err(_) => return Ok(None),
1425 };
1426 if !cache.all_layers_materialized(&cached_diff_ids) {
1427 return Ok(None);
1428 }
1429
1430 let manifest_digest = match metadata.manifest_digest.parse::<Digest>() {
1432 Ok(digest) => digest,
1433 Err(_) => return Ok(None),
1434 };
1435 if !cache.is_fsmeta_materialized(&manifest_digest)
1436 || !cache.is_vmdk_materialized(&manifest_digest)
1437 {
1438 return Ok(None);
1439 }
1440
1441 let result = match cached_pull_result(&metadata) {
1442 Ok(result) => result,
1443 Err(_) => return Ok(None),
1444 };
1445
1446 Ok(Some(CachedPullInfo { result, metadata }))
1447}
1448
1449async fn wait_for_layer_tree_pipeline(
1450 layer_tasks: Vec<JoinHandle<Result<LayerPipelineTreeSuccess, LayerPipelineFailure>>>,
1451) -> ImageResult<Vec<LayerPipelineTreeSuccess>> {
1452 let outcomes = futures::future::join_all(layer_tasks).await;
1453 let mut results = Vec::new();
1454 let mut first_error: Option<ImageError> = None;
1455
1456 for outcome in outcomes {
1457 match outcome {
1458 Ok(Ok(result)) => results.push(result),
1459 Ok(Err(failure)) => {
1460 if first_error.is_none() {
1461 first_error = Some(failure.error);
1462 }
1463 }
1464 Err(error) => {
1465 if first_error.is_none() {
1466 first_error = Some(ImageError::Io(io::Error::other(format!(
1467 "layer task failed: {error}"
1468 ))));
1469 }
1470 }
1471 }
1472 }
1473
1474 if let Some(error) = first_error {
1475 return Err(error);
1476 }
1477
1478 Ok(results)
1479}
1480
1481async fn resolve_cached_pull_result_async(
1482 cache: &GlobalCache,
1483 reference: &oci_client::Reference,
1484 options: &PullOptions,
1485) -> ImageResult<Option<CachedPullInfo>> {
1486 if options.force || options.pull_policy == PullPolicy::Always {
1487 return Ok(None);
1488 }
1489
1490 let Some(metadata) = cache.read_image_metadata_async(reference).await? else {
1491 return Ok(None);
1492 };
1493
1494 resolve_cached_metadata_pull_result_async(cache, metadata).await
1495}
1496
1497async fn resolve_cached_pull_result_by_manifest_digest_async(
1498 cache: &GlobalCache,
1499 manifest_digest: &Digest,
1500) -> ImageResult<Option<CachedPullInfo>> {
1501 let expected = manifest_digest.to_string();
1502 let mut entries = tokio::fs::read_dir(cache.manifests_dir())
1503 .await
1504 .map_err(|e| ImageError::Cache {
1505 path: cache.manifests_dir().to_path_buf(),
1506 source: e,
1507 })?;
1508
1509 while let Some(entry) = entries.next_entry().await.map_err(|e| ImageError::Cache {
1510 path: cache.manifests_dir().to_path_buf(),
1511 source: e,
1512 })? {
1513 let path = entry.path();
1514 if path.extension().and_then(|s| s.to_str()) != Some("json") {
1515 continue;
1516 }
1517
1518 let data = match tokio::fs::read_to_string(&path).await {
1519 Ok(data) => data,
1520 Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
1521 Err(e) => return Err(ImageError::Cache { path, source: e }),
1522 };
1523 let Some(metadata) = cache::parse_cached_image_metadata(&path, &data)? else {
1524 continue;
1525 };
1526 if metadata.manifest_digest != expected {
1527 continue;
1528 }
1529
1530 if let Some(cached) = resolve_cached_metadata_pull_result_async(cache, metadata).await? {
1531 return Ok(Some(cached));
1532 }
1533 }
1534
1535 Ok(None)
1536}
1537
1538async fn resolve_cached_metadata_pull_result_async(
1539 cache: &GlobalCache,
1540 metadata: CachedImageMetadata,
1541) -> ImageResult<Option<CachedPullInfo>> {
1542 let cached_diff_ids = match metadata
1543 .layers
1544 .iter()
1545 .map(|layer| layer.diff_id.parse())
1546 .collect::<ImageResult<Vec<Digest>>>()
1547 {
1548 Ok(digests) => digests,
1549 Err(_) => return Ok(None),
1550 };
1551 if !all_layers_materialized_async(cache, &cached_diff_ids).await {
1552 return Ok(None);
1553 }
1554
1555 let manifest_digest = match metadata.manifest_digest.parse::<Digest>() {
1556 Ok(digest) => digest,
1557 Err(_) => return Ok(None),
1558 };
1559 if !cache::is_valid_erofs_artifact_async(&cache.fsmeta_erofs_path(&manifest_digest)).await
1560 || !path_exists_async(&cache.vmdk_path(&manifest_digest)).await
1561 {
1562 return Ok(None);
1563 }
1564
1565 let result = match cached_pull_result(&metadata) {
1566 Ok(result) => result,
1567 Err(_) => return Ok(None),
1568 };
1569
1570 Ok(Some(CachedPullInfo { result, metadata }))
1571}
1572
1573async fn all_layers_materialized_async(cache: &GlobalCache, diff_ids: &[Digest]) -> bool {
1574 for diff_id in diff_ids {
1575 if !cache::is_valid_erofs_artifact_async(&cache.layer_erofs_path(diff_id)).await {
1576 return false;
1577 }
1578 }
1579
1580 true
1581}
1582
1583async fn path_exists_async(path: &Path) -> bool {
1584 tokio::fs::metadata(path).await.is_ok()
1585}
1586
1587fn has_duplicate_entries(entries: &[String]) -> bool {
1588 let mut seen = HashSet::with_capacity(entries.len());
1589 for entry in entries {
1590 if !seen.insert(entry.as_str()) {
1591 return true;
1592 }
1593 }
1594
1595 false
1596}
1597
1598fn layer_pipeline_concurrency(layer_count: usize) -> usize {
1599 let host_limit = std::thread::available_parallelism()
1600 .map(|n| n.get().saturating_mul(2))
1601 .unwrap_or(8)
1602 .clamp(4, MAX_LAYER_PIPELINE_CONCURRENCY);
1603
1604 host_limit.min(layer_count.max(1))
1605}
1606
1607fn layer_work_path(tmp_dir: &Path, diff_id: &Digest, suffix: &str) -> PathBuf {
1608 tmp_dir.join(format!("{}.{}", diff_id.to_path_safe(), suffix))
1609}
1610
1611fn json_bytes_to_string(bytes: &[u8], context: &str) -> ImageResult<String> {
1612 std::str::from_utf8(bytes)
1613 .map(str::to_owned)
1614 .map_err(|e| ImageError::ConfigParse(format!("{context} is not UTF-8 JSON: {e}")))
1615}
1616
1617#[cfg(test)]
1622mod tests {
1623 use tempfile::tempdir;
1624
1625 use oci_client::manifest::{ImageIndexEntry, Platform as OciPlatform};
1626
1627 use super::{Platform, layer_work_path, resolve_cached_pull_result, resolve_platform_digest};
1628 use crate::{
1629 cache::{CachedImageMetadata, CachedLayerMetadata, GlobalCache},
1630 config::ImageConfig,
1631 digest::Digest,
1632 error::ImageError,
1633 pull::{PullOptions, PullPolicy},
1634 };
1635
1636 #[test]
1637 fn test_platform_resolver_prefers_exact_variant() {
1638 let manifests = vec![
1639 ImageIndexEntry {
1640 media_type: "application/vnd.oci.image.manifest.v1+json".into(),
1641 digest: "sha256:arch-only".into(),
1642 size: 1,
1643 platform: Some(OciPlatform {
1644 architecture: "arm".into(),
1645 os: "linux".into(),
1646 os_version: None,
1647 os_features: None,
1648 variant: None,
1649 features: None,
1650 }),
1651 annotations: None,
1652 artifact_type: None,
1653 },
1654 ImageIndexEntry {
1655 media_type: "application/vnd.oci.image.manifest.v1+json".into(),
1656 digest: "sha256:exact".into(),
1657 size: 1,
1658 platform: Some(OciPlatform {
1659 architecture: "arm".into(),
1660 os: "linux".into(),
1661 os_version: None,
1662 os_features: None,
1663 variant: Some("v7".into()),
1664 features: None,
1665 }),
1666 annotations: None,
1667 artifact_type: None,
1668 },
1669 ];
1670
1671 let digest =
1672 resolve_platform_digest(&manifests, &Platform::with_variant("linux", "arm", "v7"));
1673 assert_eq!(digest.as_deref(), Some("sha256:exact"));
1674 }
1675
1676 #[test]
1677 fn test_layer_work_path_uses_path_safe_digest() {
1678 let temp = tempdir().unwrap();
1679 let digest = Digest::new("sha256", "abc123");
1680
1681 let path = layer_work_path(temp.path(), &digest, "erofs.part");
1682 let file_name = path.file_name().unwrap().to_string_lossy();
1683
1684 assert_eq!(file_name, "sha256_abc123.erofs.part");
1685 assert!(!file_name.contains(':'));
1686 }
1687
1688 #[test]
1689 fn test_resolve_cached_pull_result_if_missing_uses_complete_cache() {
1690 let temp = tempdir().unwrap();
1691 let cache = GlobalCache::new(temp.path()).unwrap();
1692 let reference: oci_client::Reference = "docker.io/library/alpine".parse().unwrap();
1693 let metadata = write_cached_image_fixture(&cache, &reference, &[true, true]);
1694
1695 let cached = resolve_cached_pull_result(
1696 &cache,
1697 &reference,
1698 &PullOptions {
1699 pull_policy: PullPolicy::IfMissing,
1700 force: false,
1701 },
1702 )
1703 .unwrap()
1704 .expect("expected cached pull result");
1705
1706 assert!(cached.result.cached);
1707 assert_eq!(cached.result.layer_diff_ids.len(), 2);
1708 assert_eq!(
1709 cached.result.manifest_digest.to_string(),
1710 metadata.manifest_digest
1711 );
1712 assert_eq!(cached.result.config.env, metadata.config.env);
1713 assert_eq!(
1714 cached.result.layer_diff_ids[0].to_string(),
1715 metadata.layers[0].diff_id
1716 );
1717 assert_eq!(
1718 cached.result.layer_diff_ids[1].to_string(),
1719 metadata.layers[1].diff_id
1720 );
1721 }
1722
1723 #[test]
1724 fn test_resolve_cached_pull_result_never_uses_complete_cache() {
1725 let temp = tempdir().unwrap();
1726 let cache = GlobalCache::new(temp.path()).unwrap();
1727 let reference: oci_client::Reference = "docker.io/library/busybox:latest".parse().unwrap();
1728 write_cached_image_fixture(&cache, &reference, &[true]);
1729
1730 let cached = resolve_cached_pull_result(
1731 &cache,
1732 &reference,
1733 &PullOptions {
1734 pull_policy: PullPolicy::Never,
1735 force: false,
1736 },
1737 )
1738 .unwrap();
1739
1740 assert!(cached.is_some());
1741 assert!(cached.unwrap().result.cached);
1742 }
1743
1744 #[test]
1745 fn test_pull_cached_uses_complete_cache() {
1746 let temp = tempdir().unwrap();
1747 let cache = GlobalCache::new(temp.path()).unwrap();
1748 let reference: oci_client::Reference = "docker.io/library/alpine".parse().unwrap();
1749 let metadata = write_cached_image_fixture(&cache, &reference, &[true]);
1750
1751 let cached = super::Registry::pull_cached(
1752 &cache,
1753 &reference,
1754 &PullOptions {
1755 pull_policy: PullPolicy::IfMissing,
1756 force: false,
1757 },
1758 )
1759 .unwrap()
1760 .expect("expected cached pull result");
1761
1762 assert!(cached.0.cached);
1763 assert_eq!(
1764 cached.0.manifest_digest.to_string(),
1765 metadata.manifest_digest
1766 );
1767 assert_eq!(cached.1.manifest_digest, metadata.manifest_digest);
1768 }
1769
1770 #[tokio::test]
1771 async fn test_pull_cached_by_manifest_digest_finds_tag_keyed_metadata() {
1772 let temp = tempdir().unwrap();
1773 let cache = GlobalCache::new(temp.path()).unwrap();
1774 let reference: oci_client::Reference = "docker.io/library/python:3.12".parse().unwrap();
1775 let metadata = write_cached_image_fixture(&cache, &reference, &[true, true]);
1776 let manifest_digest = parse_digest(&metadata.manifest_digest);
1777
1778 let cached = super::Registry::pull_cached_by_manifest_digest(&cache, &manifest_digest)
1779 .await
1780 .unwrap()
1781 .expect("expected cached pull result by manifest digest");
1782
1783 assert!(cached.0.cached);
1784 assert_eq!(
1785 cached.0.manifest_digest.to_string(),
1786 metadata.manifest_digest
1787 );
1788 assert_eq!(cached.1.manifest_digest, metadata.manifest_digest);
1789 }
1790
1791 #[tokio::test]
1792 async fn test_pull_cached_by_manifest_digest_requires_complete_artifacts() {
1793 let temp = tempdir().unwrap();
1794 let cache = GlobalCache::new(temp.path()).unwrap();
1795 let reference: oci_client::Reference = "docker.io/library/python:3.12".parse().unwrap();
1796 let metadata = write_cached_image_fixture(&cache, &reference, &[true, false]);
1797 let manifest_digest = parse_digest(&metadata.manifest_digest);
1798
1799 let cached = super::Registry::pull_cached_by_manifest_digest(&cache, &manifest_digest)
1800 .await
1801 .unwrap();
1802
1803 assert!(cached.is_none());
1804 }
1805
1806 #[tokio::test]
1807 async fn test_pull_never_returns_not_cached_when_any_layer_is_missing() {
1808 let temp = tempdir().unwrap();
1809 let cache = GlobalCache::new(temp.path()).unwrap();
1810 let reference: oci_client::Reference = "docker.io/library/debian:stable".parse().unwrap();
1811 write_cached_image_fixture(&cache, &reference, &[true, false]);
1812
1813 let cached = resolve_cached_pull_result(
1814 &cache,
1815 &reference,
1816 &PullOptions {
1817 pull_policy: PullPolicy::Never,
1818 force: false,
1819 },
1820 )
1821 .unwrap();
1822 assert!(cached.is_none());
1823
1824 let registry = super::Registry::new(Platform::default(), cache).unwrap();
1825 let err = registry
1826 .pull(
1827 &reference,
1828 &PullOptions {
1829 pull_policy: PullPolicy::Never,
1830 force: false,
1831 },
1832 )
1833 .await;
1834
1835 assert!(matches!(err, Err(ImageError::NotCached { .. })));
1836 }
1837
1838 #[test]
1839 fn test_resolve_cached_pull_result_ignores_corrupt_metadata_file() {
1840 let temp = tempdir().unwrap();
1841 let cache = GlobalCache::new(temp.path()).unwrap();
1842 let reference: oci_client::Reference = "docker.io/library/ubuntu:latest".parse().unwrap();
1843 let metadata_path = image_metadata_path(temp.path(), &reference);
1844 std::fs::write(&metadata_path, b"{ definitely not json").unwrap();
1845
1846 let cached = resolve_cached_pull_result(
1847 &cache,
1848 &reference,
1849 &PullOptions {
1850 pull_policy: PullPolicy::IfMissing,
1851 force: false,
1852 },
1853 )
1854 .unwrap();
1855
1856 assert!(cached.is_none());
1857 }
1858
1859 #[test]
1860 fn test_resolve_cached_pull_result_skips_cache_for_force_and_always() {
1861 let temp = tempdir().unwrap();
1862 let cache = GlobalCache::new(temp.path()).unwrap();
1863 let reference: oci_client::Reference = "docker.io/library/fedora:latest".parse().unwrap();
1864 write_cached_image_fixture(&cache, &reference, &[true]);
1865
1866 let forced = resolve_cached_pull_result(
1867 &cache,
1868 &reference,
1869 &PullOptions {
1870 pull_policy: PullPolicy::IfMissing,
1871 force: true,
1872 },
1873 )
1874 .unwrap();
1875 assert!(forced.is_none());
1876
1877 let always = resolve_cached_pull_result(
1878 &cache,
1879 &reference,
1880 &PullOptions {
1881 pull_policy: PullPolicy::Always,
1882 force: false,
1883 },
1884 )
1885 .unwrap();
1886 assert!(always.is_none());
1887 }
1888
1889 #[test]
1890 fn test_resolve_cached_pull_result_ignores_invalid_digest_metadata() {
1891 let temp = tempdir().unwrap();
1892 let cache = GlobalCache::new(temp.path()).unwrap();
1893 let reference: oci_client::Reference = "docker.io/library/redis:latest".parse().unwrap();
1894 let mut metadata = write_cached_image_fixture(&cache, &reference, &[true]);
1895 metadata.layers[0].diff_id = "not-a-digest".into();
1896 cache.write_image_metadata(&reference, &metadata).unwrap();
1897
1898 let cached = resolve_cached_pull_result(
1899 &cache,
1900 &reference,
1901 &PullOptions {
1902 pull_policy: PullPolicy::IfMissing,
1903 force: false,
1904 },
1905 )
1906 .unwrap();
1907
1908 assert!(cached.is_none());
1909 }
1910
1911 #[test]
1912 fn test_resolve_cached_pull_result_requires_fsmeta_and_vmdk() {
1913 let temp = tempdir().unwrap();
1914 let cache = GlobalCache::new(temp.path()).unwrap();
1915 let reference: oci_client::Reference = "docker.io/library/alpine:latest".parse().unwrap();
1916 let metadata = write_cached_image_fixture(&cache, &reference, &[false, false]);
1918 let manifest_digest = parse_digest(&metadata.manifest_digest);
1919 for (index, _) in metadata.layers.iter().enumerate() {
1921 let diff_id = parse_digest(&format!("sha256:{:064x}", index as u64 + 1000));
1922 std::fs::write(cache.layer_erofs_path(&diff_id), vec![0u8; 4096]).unwrap();
1923 }
1924 let _ = std::fs::remove_file(cache.fsmeta_erofs_path(&manifest_digest));
1926 let _ = std::fs::remove_file(cache.vmdk_path(&manifest_digest));
1927
1928 let cached = resolve_cached_pull_result(
1929 &cache,
1930 &reference,
1931 &PullOptions {
1932 pull_policy: PullPolicy::IfMissing,
1933 force: false,
1934 },
1935 )
1936 .unwrap();
1937
1938 assert!(cached.is_none(), "should not be cached without fsmeta+VMDK");
1939 }
1940
1941 #[tokio::test]
1942 async fn test_pull_never_treats_invalid_digest_metadata_as_not_cached() {
1943 let temp = tempdir().unwrap();
1944 let cache = GlobalCache::new(temp.path()).unwrap();
1945 let reference: oci_client::Reference = "docker.io/library/httpd:latest".parse().unwrap();
1946 let mut metadata = write_cached_image_fixture(&cache, &reference, &[true]);
1947 metadata.layers[0].diff_id = "not-a-digest".into();
1948 cache.write_image_metadata(&reference, &metadata).unwrap();
1949
1950 let registry = super::Registry::new(Platform::default(), cache).unwrap();
1951 let result = registry
1952 .pull(
1953 &reference,
1954 &PullOptions {
1955 pull_policy: PullPolicy::Never,
1956 force: false,
1957 },
1958 )
1959 .await;
1960
1961 assert!(matches!(result, Err(ImageError::NotCached { .. })));
1962 }
1963
1964 #[tokio::test]
1965 async fn test_pull_with_progress_cached_if_missing_emits_only_summary_events() {
1966 let temp = tempdir().unwrap();
1967 let cache = GlobalCache::new(temp.path()).unwrap();
1968 let reference: oci_client::Reference = "docker.io/library/nginx:latest".parse().unwrap();
1969 write_cached_image_fixture(&cache, &reference, &[true, true]);
1970 let registry = super::Registry::new(Platform::default(), cache).unwrap();
1971
1972 let (mut handle, task) = registry.pull_with_progress(
1973 &reference,
1974 &PullOptions {
1975 pull_policy: PullPolicy::IfMissing,
1976 force: false,
1977 },
1978 );
1979
1980 let result = task.await.unwrap().unwrap();
1981 let mut events = Vec::new();
1982 while let Some(event) = handle.recv().await {
1983 events.push(event);
1984 }
1985
1986 assert!(result.cached);
1987 assert_eq!(events.len(), 3);
1988 assert!(matches!(
1989 &events[0],
1990 crate::progress::PullProgress::Resolving { reference: event_ref }
1991 if event_ref.as_ref() == reference.to_string()
1992 ));
1993 assert!(matches!(
1994 &events[1],
1995 crate::progress::PullProgress::Resolved {
1996 reference: event_ref,
1997 layer_count: 2,
1998 ..
1999 } if event_ref.as_ref() == reference.to_string()
2000 ));
2001 assert!(matches!(
2002 &events[2],
2003 crate::progress::PullProgress::Complete {
2004 reference: event_ref,
2005 layer_count: 2,
2006 } if event_ref.as_ref() == reference.to_string()
2007 ));
2008 }
2009
2010 fn write_cached_image_fixture(
2011 cache: &GlobalCache,
2012 reference: &oci_client::Reference,
2013 materialized_layers: &[bool],
2014 ) -> CachedImageMetadata {
2015 let metadata = CachedImageMetadata {
2016 manifest_digest:
2017 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
2018 .to_string(),
2019 config_digest:
2020 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
2021 .to_string(),
2022 raw_manifest_json: r#"{"schemaVersion":2,"layers":[]}"#.to_string(),
2023 raw_config_json:
2024 r#"{"architecture":"amd64","os":"linux","rootfs":{"type":"layers","diff_ids":[]}}"#
2025 .to_string(),
2026 config: ImageConfig {
2027 env: vec!["PATH=/usr/bin".into()],
2028 ..Default::default()
2029 },
2030 layers: materialized_layers
2031 .iter()
2032 .enumerate()
2033 .map(|(index, _)| CachedLayerMetadata {
2034 digest: layer_digest(index),
2035 media_type: Some("application/vnd.oci.image.layer.v1.tar+gzip".into()),
2036 size_bytes: Some((index as u64 + 1) * 100),
2037 diff_id: format!("sha256:{:064x}", index as u64 + 1000),
2038 })
2039 .collect(),
2040 };
2041
2042 cache.write_image_metadata(reference, &metadata).unwrap();
2043
2044 let all_materialized = materialized_layers.iter().all(|m| *m);
2046 for (index, materialized) in materialized_layers.iter().copied().enumerate() {
2047 let diff_id = parse_digest(&format!("sha256:{:064x}", index as u64 + 1000));
2048 let erofs_path = cache.layer_erofs_path(&diff_id);
2049 if materialized {
2050 std::fs::write(&erofs_path, vec![0u8; 4096]).unwrap();
2051 }
2052 }
2053
2054 if all_materialized && !materialized_layers.is_empty() {
2056 let manifest_digest = parse_digest(&metadata.manifest_digest);
2057 std::fs::write(cache.fsmeta_erofs_path(&manifest_digest), vec![0u8; 4096]).unwrap();
2058 std::fs::write(cache.vmdk_path(&manifest_digest), b"# VMDK fixture").unwrap();
2059 }
2060
2061 metadata
2062 }
2063
2064 fn layer_digest(index: usize) -> String {
2065 format!("sha256:{:064x}", index as u64 + 1)
2066 }
2067
2068 fn parse_digest(digest: &str) -> crate::digest::Digest {
2069 digest.parse().unwrap()
2070 }
2071
2072 fn image_metadata_path(
2073 cache_root: &std::path::Path,
2074 reference: &oci_client::Reference,
2075 ) -> std::path::PathBuf {
2076 use sha2::{Digest as Sha2Digest, Sha256};
2077
2078 let mut hasher = Sha256::new();
2079 hasher.update(reference.to_string().as_bytes());
2080 cache_root
2081 .join("manifests")
2082 .join(format!("{}.json", hex::encode(hasher.finalize())))
2083 }
2084
2085 #[test]
2086 fn test_registry_builder_default() {
2087 let temp = tempdir().unwrap();
2088 let cache = GlobalCache::new(temp.path()).unwrap();
2089 let registry = super::Registry::builder(Platform::default(), cache)
2090 .build()
2091 .unwrap();
2092
2093 assert!(matches!(
2094 registry.auth,
2095 oci_client::secrets::RegistryAuth::Anonymous
2096 ));
2097 }
2098
2099 #[test]
2100 fn test_registry_builder_with_auth() {
2101 let temp = tempdir().unwrap();
2102 let cache = GlobalCache::new(temp.path()).unwrap();
2103 let registry = super::Registry::builder(Platform::default(), cache)
2104 .auth(crate::RegistryAuth::Basic {
2105 username: "user".into(),
2106 password: "pass".into(),
2107 })
2108 .build()
2109 .unwrap();
2110
2111 assert!(matches!(
2112 registry.auth,
2113 oci_client::secrets::RegistryAuth::Basic(_, _)
2114 ));
2115 }
2116
2117 #[test]
2118 fn test_registry_builder_with_insecure_registries() {
2119 let temp = tempdir().unwrap();
2120 let cache = GlobalCache::new(temp.path()).unwrap();
2121 super::Registry::builder(Platform::default(), cache)
2124 .add_insecure_registries(vec!["localhost:5000".into()])
2125 .build()
2126 .unwrap();
2127 }
2128
2129 fn generate_test_ca_pem() -> Vec<u8> {
2131 let key_pair = rcgen::KeyPair::generate().unwrap();
2132 let mut params = rcgen::CertificateParams::default();
2133 params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
2134 let cert = params.self_signed(&key_pair).unwrap();
2135 cert.pem().into_bytes()
2136 }
2137
2138 #[test]
2139 fn test_registry_builder_with_valid_ca_cert() {
2140 let temp = tempdir().unwrap();
2141 let cache = GlobalCache::new(temp.path()).unwrap();
2142 let pem = generate_test_ca_pem();
2143 super::Registry::builder(Platform::default(), cache)
2144 .extra_ca_certs(vec![pem])
2145 .build()
2146 .unwrap();
2147 }
2148
2149 fn build_err(result: Result<super::Registry, crate::ImageError>) -> crate::ImageError {
2151 match result {
2152 Err(e) => e,
2153 Ok(_) => panic!("expected build to fail"),
2154 }
2155 }
2156
2157 #[test]
2158 fn test_registry_builder_rejects_invalid_pem() {
2159 let temp = tempdir().unwrap();
2160 let cache = GlobalCache::new(temp.path()).unwrap();
2161 let bad_pem = b"not valid PEM data".to_vec();
2162 let err = build_err(
2163 super::Registry::builder(Platform::default(), cache)
2164 .extra_ca_certs(vec![bad_pem])
2165 .build(),
2166 );
2167
2168 assert!(
2169 err.to_string().contains("no certificates found"),
2170 "expected 'no certificates found', got: {err}"
2171 );
2172 }
2173
2174 #[test]
2175 fn test_registry_builder_rejects_empty_pem() {
2176 let temp = tempdir().unwrap();
2177 let cache = GlobalCache::new(temp.path()).unwrap();
2178 let err = build_err(
2179 super::Registry::builder(Platform::default(), cache)
2180 .extra_ca_certs(vec![Vec::new()])
2181 .build(),
2182 );
2183
2184 assert!(
2185 err.to_string().contains("no certificates found"),
2186 "expected 'no certificates found', got: {err}"
2187 );
2188 }
2189
2190 #[test]
2191 fn test_registry_builder_all_options() {
2192 let temp = tempdir().unwrap();
2193 let cache = GlobalCache::new(temp.path()).unwrap();
2194 let pem = generate_test_ca_pem();
2195 super::Registry::builder(Platform::default(), cache)
2196 .auth(crate::RegistryAuth::Basic {
2197 username: "user".into(),
2198 password: "pass".into(),
2199 })
2200 .add_insecure_registries(vec!["localhost:5000".into()])
2201 .extra_ca_certs(vec![pem])
2202 .build()
2203 .unwrap();
2204 }
2205
2206 #[test]
2207 fn test_registry_new_equals_builder_default() {
2208 let temp = tempdir().unwrap();
2209 let cache = GlobalCache::new(temp.path()).unwrap();
2210 super::Registry::new(Platform::default(), cache).unwrap();
2212 }
2213}