Skip to main content

microsandbox_image/registry/
client.rs

1//! OCI registry client.
2//!
3//! Wraps `oci-client` with platform resolution, caching, and progress reporting.
4
5use 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
41//--------------------------------------------------------------------------------------------------
42// Constants
43//--------------------------------------------------------------------------------------------------
44
45/// Minimum byte delta between per-layer materialization progress updates.
46const MATERIALIZE_PROGRESS_EMIT_BYTES: u64 = 256 * 1024;
47
48/// Upper bound for concurrently active layer download/materialize tasks.
49const MAX_LAYER_PIPELINE_CONCURRENCY: usize = 16;
50
51//--------------------------------------------------------------------------------------------------
52// Types
53//--------------------------------------------------------------------------------------------------
54
55/// OCI registry client with platform resolution, caching, and progress reporting.
56pub 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/// Resolved manifest layer descriptor used during download/materialization.
64#[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
90/// Per-layer pipeline success: EROFS image written, data-stripped tree + data map retained.
91/// `tree` and `data_map` are `None` when the EROFS was already cached.
92struct LayerPipelineTreeSuccess {
93    layer_index: usize,
94    tree: Option<FileTree>,
95    data_map: Option<erofs::ErofsDataMap>,
96}
97
98/// Wraps an `AsyncRead` to emit `LayerMaterializeProgress` events as the
99/// tar stream is read during EROFS materialization.
100///
101/// Progress events are throttled to avoid flooding the channel — an update
102/// is sent only after at least `MATERIALIZE_PROGRESS_EMIT_BYTES` (256 KiB)
103/// have been read since the last event.
104struct 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
113//--------------------------------------------------------------------------------------------------
114// Methods
115//--------------------------------------------------------------------------------------------------
116
117impl<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    /// Create a registry client with anonymous authentication and default TLS settings.
137    pub fn new(platform: Platform, cache: GlobalCache) -> ImageResult<Self> {
138        Self::builder(platform, cache).build()
139    }
140
141    /// Create a builder for configuring auth, TLS, and other registry options.
142    pub fn builder(platform: Platform, cache: GlobalCache) -> RegistryBuilder {
143        RegistryBuilder::new(platform, cache)
144    }
145
146    /// Resolve a pull directly from the on-disk cache without building a registry client.
147    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    /// Resolve a pull from any complete cached image metadata matching a manifest digest.
157    ///
158    /// Snapshot restores use this before contacting a registry because the
159    /// snapshot already records the immutable base image digest, while cached
160    /// metadata may still be keyed by the mutable tag that produced it.
161    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    /// Pull an image. Downloads blobs and materializes EROFS layers concurrently.
173    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    /// Materialize layers that have already been staged into the cache.
182    ///
183    /// This is used by archive import: layer blobs are written to the cache at
184    /// their descriptor digest first, then the normal EROFS/fsmeta/VMDK
185    /// materialization path can run without contacting a registry.
186    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    /// Pull with progress reporting.
265    ///
266    /// Creates a progress channel internally and returns both the receiver
267    /// handle and the spawned pull task.
268    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    /// Pull with an externally-provided progress sender.
282    ///
283    /// Use [`progress_channel()`](crate::progress_channel) to create the
284    /// channel, keep the [`PullProgressHandle`] receiver, and pass the
285    /// [`PullProgressSender`] here.
286    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    /// Spawn the pull task with a progress sender.
299    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    /// Core pull implementation.
332    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        // Lock files are intentionally never deleted — stable inodes prevent
350        // TOCTOU races where two processes flock different inodes at the same path.
351        let _image_lock_guard = scopeguard::guard(image_lock_file, |file| {
352            let _ = flock_unlock(&file);
353        });
354
355        // Step 1: Early cache check using persisted image metadata.
356        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        // Step 2: Resolve manifest.
396        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        // Determine media type from manifest bytes. For multi-platform images,
409        // this also fetches the platform-specific config bytes.
410        let (manifest, config_bytes, resolved_manifest_bytes) = self
411            .parse_and_resolve_manifest(&manifest_bytes, config_bytes, oci_ref)
412            .await?;
413
414        // Step 3: Parse config.
415        let (image_config, diff_ids) = ImageConfig::parse(&config_bytes)?;
416
417        // Step 4: Get layer descriptors.
418        let layer_descriptors = self.extract_layer_digests(&manifest)?;
419
420        // OCI spec requires diff_ids and layer descriptors to have the same count.
421        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        // Give the receiver a chance to render the resolved state before the
455        // layer tasks begin flooding download events.
456        tokio::task::yield_now().await;
457
458        // Warn about duplicate layer digests — they can cause contention.
459        {
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        // Materialize per-layer EROFS images, then generate fsmeta + VMDK.
473        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        // Clean up compressed tarballs after all layer tasks complete.
485        // Deferred from per-task cleanup to avoid races with duplicate layer digests.
486        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        // Persist cached image metadata.
497        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    /// Fetch manifest and config from the registry.
541    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    /// Parse manifest, resolving multi-platform index if needed.
557    ///
558    /// Returns the manifest and the correct config bytes. For single-platform
559    /// manifests, the config bytes are passed through unchanged. For multi-platform
560    /// indexes, the platform-specific config bytes are fetched and returned.
561    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        // Try to detect media type from the JSON.
568        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            // Resolve platform-specific manifest and fetch its config.
574            self.resolve_platform_manifest(manifest_bytes, reference)
575                .await
576        } else {
577            Ok((manifest, config_bytes, manifest_bytes.to_vec()))
578        }
579    }
580
581    /// Resolve a platform-specific manifest from an OCI index.
582    ///
583    /// Returns the resolved manifest and its platform-specific config bytes.
584    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        // Find matching platform.
595        let mut best_match: Option<&oci_spec::image::Descriptor> = None;
596        let mut exact_variant = false;
597
598        for entry in manifests {
599            // Skip attestation manifests.
600            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            // OS must match.
610            if *platform.os() != self.platform.os {
611                continue;
612            }
613
614            // Architecture must match.
615            if *platform.architecture() != self.platform.arch {
616                continue;
617            }
618
619            // Check variant.
620            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        // Fetch the platform-specific manifest and config.
645        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    /// Extract layer digests and sizes from a parsed manifest.
664    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    /// Materialize per-layer EROFS images, then generate fsmeta + VMDK.
698    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        // Validate all diff_ids parse as digests before spawning layer tasks.
713        // diff_ids come from the remote config blob (untrusted input).
714        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        // Phase-level idempotency: decide what actually needs regen based on
725        // which artifacts already exist.
726        //
727        // - layers + fsmeta + VMDK all valid, not force: no-op.
728        // - layers + fsmeta valid, only VMDK missing: re-stitch VMDK alone.
729        // - fsmeta missing (regardless of VMDK): force layers to re-materialize
730        //   so the pipeline produces fresh trees for fsmeta generation.
731        // - layers missing (any subset): let the per-layer tasks re-materialize
732        //   the missing ones; fsmeta/VMDK regen follows if needed.
733        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        // fsmeta missing or force=true → layers must produce trees. The per-
751        // layer cache check in the task body would otherwise short-circuit
752        // with tree=None for cached layer EROFSes.
753        //
754        // This is scoped to MATERIALIZATION only. Downloads are already
755        // idempotent (content-addressed, size-gated) and sharing the same
756        // blob digest across duplicate layers means forcing re-download
757        // would race: one task's `rm tar.gz` can run while another task has
758        // finished its download and is about to read the tar.
759        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                    // Acquire per-layer flock to coordinate with concurrent pulls.
828                    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                    // Re-check after lock — another process may have materialized it.
844                    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                    // Verify the uncompressed digest matches the config's diff_id.
912                    // This is the OCI content trust check — the diff_id is signed
913                    // as part of the image config, so a tampered layer would be caught.
914                    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                    // Write to a temp file, then atomic rename to the final path.
939                    // This prevents partial files from being visible to concurrent readers.
940                    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                    // Strip file data from the retained tree to reduce memory.
966                    // Only directory structure and metadata are needed for fsmeta merge.
967                    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                    // Tarball cleanup is deferred — with duplicate layer digests,
978                    // another task may still need the same blob. Tarballs are cleaned
979                    // up after all layer tasks complete.
980                    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        // Wait for all layer tasks to complete. Collect trees + data maps.
999        let mut layer_results = wait_for_layer_tree_pipeline(layer_tasks).await?;
1000        layer_results.sort_by_key(|r| r.layer_index);
1001
1002        // Generate fsmeta + VMDK if not already cached.
1003        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        // Acquire flock for fsmeta/VMDK generation.
1018        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        // Re-check after lock acquisition.
1031        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        // Extract trees and data maps from results.
1039        //
1040        // When an image contains duplicate layers (same diff_id at multiple
1041        // positions), only the first task actually builds the EROFS — the
1042        // others find the cached artifact and return tree=None. We handle
1043        // this by collecting produced trees keyed by diff_id, then cloning
1044        // for duplicate positions.
1045        //
1046        // If a diff_id has NO produced tree at all (every layer was already
1047        // cached from a prior pull), fsmeta generation was expected to be
1048        // cached too — but we checked above and it wasn't. This can happen
1049        // if the fsmeta cache was evicted while layer caches were kept.
1050        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        // Merge layer trees with provenance tracking.
1107        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        // Generate fsmeta and VMDK.
1115        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        // Collect per-layer EROFS paths for the VMDK extents.
1121        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            // Write fsmeta.
1137            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            // Write VMDK descriptor.
1156            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    /// Re-stitch the VMDK descriptor from an existing fsmeta + layer EROFS files.
1189    ///
1190    /// Called when fsmeta and all layer EROFSes are present but only the VMDK
1191    /// descriptor is missing (e.g. the user deleted it manually, or a previous
1192    /// pull was interrupted between fsmeta rename and VMDK rename).
1193    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        // Re-check under lock: a concurrent pull may have regenerated VMDK,
1215        // or the fsmeta may have been evicted while we waited.
1216        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    // NOTE: materialize_flat_image was removed — replaced by fsmeta + VMDK generation
1277    // in materialize_layers_and_fsmeta().
1278}
1279
1280//--------------------------------------------------------------------------------------------------
1281// Trait Implementations
1282//--------------------------------------------------------------------------------------------------
1283
1284impl<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
1321//--------------------------------------------------------------------------------------------------
1322// Functions: Helpers
1323//--------------------------------------------------------------------------------------------------
1324
1325/// Detect the media type of a manifest from its JSON content.
1326fn detect_manifest_media_type(bytes: &[u8]) -> String {
1327    // Try to parse the mediaType field from JSON.
1328    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        // Heuristic: if it has "manifests" array, it's an index.
1334        if v.get("manifests").is_some() {
1335            return "application/vnd.oci.image.index.v1+json".to_string();
1336        }
1337
1338        // If it has "layers" array, it's an image manifest.
1339        if v.get("layers").is_some() {
1340            return "application/vnd.oci.image.manifest.v1+json".to_string();
1341        }
1342    }
1343
1344    // Default to OCI image manifest.
1345    "application/vnd.oci.image.manifest.v1+json".to_string()
1346}
1347
1348/// Resolve the best matching platform-specific manifest digest.
1349pub(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
1386/// Build a pull result from cached image metadata.
1387fn 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    // Check that all per-layer EROFS images exist.
1417    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    // Check that fsmeta + VMDK exist.
1431    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//--------------------------------------------------------------------------------------------------
1618// Tests
1619//--------------------------------------------------------------------------------------------------
1620
1621#[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        // Create layers but no fsmeta/VMDK.
1917        let metadata = write_cached_image_fixture(&cache, &reference, &[false, false]);
1918        let manifest_digest = parse_digest(&metadata.manifest_digest);
1919        // Manually create layer files without fsmeta/VMDK.
1920        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        // Delete fsmeta/VMDK if they were created by the fixture.
1925        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        // Create EROFS files keyed by diff_id for cache hit detection.
2045        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        // Create fsmeta + VMDK when all layers are present (fsmerge pipeline).
2055        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        // Should build without error — we can't inspect ClientConfig directly,
2122        // but we verify it doesn't panic or fail.
2123        super::Registry::builder(Platform::default(), cache)
2124            .add_insecure_registries(vec!["localhost:5000".into()])
2125            .build()
2126            .unwrap();
2127    }
2128
2129    /// Generate a self-signed CA certificate and return PEM bytes.
2130    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    /// Helper to extract the error from a builder result.
2150    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        // Registry::new() should succeed just like builder().build()
2211        super::Registry::new(Platform::default(), cache).unwrap();
2212    }
2213}