Skip to main content

oci_client/
client.rs

1//! OCI distribution client for fetching oci images from an OCI compliant remote store
2use std::collections::{BTreeMap, HashMap};
3use std::convert::TryFrom;
4use std::hash::Hash;
5use std::pin::pin;
6use std::sync::Arc;
7use std::time::Duration;
8
9use futures_util::stream::{self, BoxStream, StreamExt, TryStreamExt};
10use futures_util::{future, Stream};
11use http::header::RANGE;
12use http::{HeaderValue, StatusCode};
13use http_auth::{parser::ChallengeParser, ChallengeRef};
14use oci_spec::image::{Arch, Os};
15use olpc_cjson::CanonicalFormatter;
16use reqwest::header::HeaderMap;
17use reqwest::{NoProxy, Proxy, RequestBuilder, Response, Url};
18use serde::{Deserialize, Deserializer, Serialize};
19use sha2::Digest as _;
20use tokio::io::{AsyncWrite, AsyncWriteExt};
21use tokio::sync::RwLock;
22use tracing::{debug, trace, warn};
23
24pub use crate::blob::*;
25use crate::config::ConfigFile;
26use crate::digest::{digest_header_value, validate_digest, Digest, Digester};
27use crate::errors::*;
28use crate::manifest::{
29    ImageIndexEntry, OciDescriptor, OciImageIndex, OciImageManifest, OciManifest, Versioned,
30    IMAGE_CONFIG_MEDIA_TYPE, IMAGE_LAYER_GZIP_MEDIA_TYPE, IMAGE_LAYER_MEDIA_TYPE,
31    IMAGE_MANIFEST_LIST_MEDIA_TYPE, IMAGE_MANIFEST_MEDIA_TYPE, OCI_IMAGE_INDEX_MEDIA_TYPE,
32    OCI_IMAGE_MEDIA_TYPE,
33};
34use crate::secrets::RegistryAuth;
35use crate::secrets::*;
36use crate::sha256_digest;
37use crate::token_cache::{RegistryOperation, RegistryToken, RegistryTokenType, TokenCache};
38use crate::Reference;
39
40const MIME_TYPES_DISTRIBUTION_MANIFEST: &[&str] = &[
41    IMAGE_MANIFEST_MEDIA_TYPE,
42    IMAGE_MANIFEST_LIST_MEDIA_TYPE,
43    OCI_IMAGE_MEDIA_TYPE,
44    OCI_IMAGE_INDEX_MEDIA_TYPE,
45];
46
47const PUSH_CHUNK_MAX_SIZE: usize = 4096 * 1024;
48
49/// Default value for `ClientConfig::max_concurrent_upload`
50pub const DEFAULT_MAX_CONCURRENT_UPLOAD: usize = 16;
51
52/// Default value for `ClientConfig::max_concurrent_download`
53pub const DEFAULT_MAX_CONCURRENT_DOWNLOAD: usize = 16;
54
55/// Default value for `ClientConfig:default_token_expiration_secs`
56pub const DEFAULT_TOKEN_EXPIRATION_SECS: usize = 60;
57
58static DEFAULT_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
59
60/// The data for an image or module.
61#[derive(Clone)]
62pub struct ImageData {
63    /// The layers of the image or module.
64    pub layers: Vec<ImageLayer>,
65    /// The digest of the image or module.
66    pub digest: Option<String>,
67    /// The Configuration object of the image or module.
68    pub config: Config,
69    /// The manifest of the image or module.
70    pub manifest: Option<OciImageManifest>,
71}
72
73/// The data returned by an OCI registry after a successful push
74/// operation is completed
75pub struct PushResponse {
76    /// Pullable url for the config
77    pub config_url: String,
78    /// Pullable url for the manifest
79    pub manifest_url: String,
80}
81
82/// The data returned by [`Client::push_blob_stream_chunked`].
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct PushBlobStreamChunkedResponse {
85    /// Pullable url for the uploaded blob.
86    pub blob_url: String,
87    /// Computed digest of the uploaded blob.
88    pub blob_digest: String,
89    /// Total uploaded blob size in bytes.
90    pub size: u64,
91}
92
93/// The data returned by a successful tags/list Request
94#[derive(Deserialize, Debug)]
95pub struct TagResponse {
96    /// Repository Name
97    pub name: String,
98    /// List of existing Tags
99    #[serde(deserialize_with = "null_as_default")]
100    pub tags: Vec<String>,
101}
102
103/// Helper to deserialize an empty value from a JSON `null`.
104fn null_as_default<'de, D, T>(d: D) -> std::result::Result<T, D::Error>
105where
106    D: Deserializer<'de>,
107    T: Default + Deserialize<'de>,
108{
109    let res = <Option<T>>::deserialize(d)?.unwrap_or_default();
110    Ok(res)
111}
112
113/// The data returned by a successful catalog request.
114#[derive(Deserialize, Debug)]
115pub struct CatalogResponse {
116    /// List of available repositories in the registry.
117    pub repositories: Vec<String>,
118}
119
120/// Layer descriptor required to pull a layer
121pub struct LayerDescriptor<'a> {
122    /// The digest of the layer
123    pub digest: &'a str,
124    /// Optional list of additional URIs to pull the layer from
125    pub urls: &'a Option<Vec<String>>,
126}
127
128/// A trait for converting any type into a [`LayerDescriptor`]
129pub trait AsLayerDescriptor {
130    /// Convert the type to a LayerDescriptor reference
131    fn as_layer_descriptor(&self) -> LayerDescriptor<'_>;
132}
133
134impl<T: AsLayerDescriptor> AsLayerDescriptor for &T {
135    fn as_layer_descriptor(&self) -> LayerDescriptor<'_> {
136        (*self).as_layer_descriptor()
137    }
138}
139
140impl AsLayerDescriptor for &str {
141    fn as_layer_descriptor(&self) -> LayerDescriptor<'_> {
142        LayerDescriptor {
143            digest: self,
144            urls: &None,
145        }
146    }
147}
148
149impl AsLayerDescriptor for &OciDescriptor {
150    fn as_layer_descriptor(&self) -> LayerDescriptor<'_> {
151        LayerDescriptor {
152            digest: &self.digest,
153            urls: &self.urls,
154        }
155    }
156}
157
158impl AsLayerDescriptor for &LayerDescriptor<'_> {
159    fn as_layer_descriptor(&self) -> LayerDescriptor<'_> {
160        LayerDescriptor {
161            digest: self.digest,
162            urls: self.urls,
163        }
164    }
165}
166
167/// The data and media type for an image layer
168#[derive(Clone, Debug, Eq, Hash, PartialEq)]
169pub struct ImageLayer {
170    /// The data of this layer
171    pub data: bytes::Bytes,
172    /// The media type of this layer
173    pub media_type: String,
174    /// This OPTIONAL property contains arbitrary metadata for this descriptor.
175    /// This OPTIONAL property MUST use the [annotation rules](https://github.com/opencontainers/image-spec/blob/main/annotations.md#rules)
176    pub annotations: Option<BTreeMap<String, String>>,
177}
178
179impl ImageLayer {
180    /// Constructs a new ImageLayer struct with provided data and media type
181    pub fn new(
182        data: impl Into<bytes::Bytes>,
183        media_type: String,
184        annotations: Option<BTreeMap<String, String>>,
185    ) -> Self {
186        ImageLayer {
187            data: data.into(),
188            media_type,
189            annotations,
190        }
191    }
192
193    /// Constructs a new ImageLayer struct with provided data and
194    /// media type application/vnd.oci.image.layer.v1.tar
195    pub fn oci_v1(
196        data: impl Into<bytes::Bytes>,
197        annotations: Option<BTreeMap<String, String>>,
198    ) -> Self {
199        Self::new(data, IMAGE_LAYER_MEDIA_TYPE.to_string(), annotations)
200    }
201    /// Constructs a new ImageLayer struct with provided data and
202    /// media type application/vnd.oci.image.layer.v1.tar+gzip
203    pub fn oci_v1_gzip(
204        data: impl Into<bytes::Bytes>,
205        annotations: Option<BTreeMap<String, String>>,
206    ) -> Self {
207        Self::new(data, IMAGE_LAYER_GZIP_MEDIA_TYPE.to_string(), annotations)
208    }
209
210    /// Helper function to compute the sha256 digest of an image layer
211    pub fn sha256_digest(&self) -> String {
212        sha256_digest(&self.data)
213    }
214}
215
216/// The data and media type for a configuration object
217#[derive(Clone)]
218pub struct Config {
219    /// The data of this config object
220    pub data: bytes::Bytes,
221    /// The media type of this object
222    pub media_type: String,
223    /// This OPTIONAL property contains arbitrary metadata for this descriptor.
224    /// This OPTIONAL property MUST use the [annotation rules](https://github.com/opencontainers/image-spec/blob/main/annotations.md#rules)
225    pub annotations: Option<BTreeMap<String, String>>,
226}
227
228impl Config {
229    /// Constructs a new Config struct with provided data and media type
230    pub fn new(
231        data: impl Into<bytes::Bytes>,
232        media_type: String,
233        annotations: Option<BTreeMap<String, String>>,
234    ) -> Self {
235        Config {
236            data: data.into(),
237            media_type,
238            annotations,
239        }
240    }
241
242    /// Constructs a new Config struct with provided data and
243    /// media type application/vnd.oci.image.config.v1+json
244    pub fn oci_v1(
245        data: impl Into<bytes::Bytes>,
246        annotations: Option<BTreeMap<String, String>>,
247    ) -> Self {
248        Self::new(data, IMAGE_CONFIG_MEDIA_TYPE.to_string(), annotations)
249    }
250
251    /// Construct a new Config struct with provided [`ConfigFile`] and
252    /// media type `application/vnd.oci.image.config.v1+json`
253    pub fn oci_v1_from_config_file(
254        config_file: ConfigFile,
255        annotations: Option<BTreeMap<String, String>>,
256    ) -> Result<Self> {
257        let data = serde_json::to_vec(&config_file)?;
258        Ok(Self::new(
259            data,
260            IMAGE_CONFIG_MEDIA_TYPE.to_string(),
261            annotations,
262        ))
263    }
264
265    /// Helper function to compute the sha256 digest of this config object
266    pub fn sha256_digest(&self) -> String {
267        sha256_digest(&self.data)
268    }
269}
270
271impl TryFrom<Config> for ConfigFile {
272    type Error = crate::errors::OciDistributionError;
273
274    fn try_from(config: Config) -> Result<Self> {
275        let config = String::from_utf8(config.data.into())
276            .map_err(|e| OciDistributionError::ConfigConversionError(e.to_string()))?;
277        let config_file: ConfigFile = serde_json::from_str(&config)
278            .map_err(|e| OciDistributionError::ConfigConversionError(e.to_string()))?;
279        Ok(config_file)
280    }
281}
282
283/// The OCI client connects to an OCI registry and fetches OCI images.
284///
285/// An OCI registry is a container registry that adheres to the OCI Distribution
286/// specification. DockerHub is one example, as are ACR and GCR. This client
287/// provides a native Rust implementation for pulling OCI images.
288///
289/// Some OCI registries support completely anonymous access. But most require
290/// at least an Oauth2 handshake. Typically, you will want to create a new
291/// client, and then run the `auth()` method, which will attempt to get
292/// a read-only bearer token. From there, pulling images can be done with
293/// the `pull_*` functions.
294///
295/// For true anonymous access, you can skip `auth()`. This is not recommended
296/// unless you are sure that the remote registry does not require Oauth2.
297#[derive(Clone)]
298pub struct Client {
299    config: Arc<ClientConfig>,
300    // Registry -> RegistryAuth
301    auth_store: Arc<RwLock<HashMap<String, RegistryAuth>>>,
302    /// Token cache for the client
303    pub tokens: TokenCache,
304    client: reqwest::Client,
305    push_chunk_size: usize,
306}
307
308impl Default for Client {
309    fn default() -> Self {
310        Self {
311            config: Arc::default(),
312            auth_store: Arc::default(),
313            tokens: TokenCache::new(DEFAULT_TOKEN_EXPIRATION_SECS),
314            client: reqwest::Client::default(),
315            push_chunk_size: PUSH_CHUNK_MAX_SIZE,
316        }
317    }
318}
319
320/// A source that can provide a `ClientConfig`.
321/// If you are using this crate in your own application, you can implement this
322/// trait on your configuration type so that it can be passed to `Client::from_source`.
323pub trait ClientConfigSource {
324    /// Provides a `ClientConfig`.
325    fn client_config(&self) -> ClientConfig;
326}
327
328impl TryFrom<ClientConfig> for Client {
329    type Error = OciDistributionError;
330
331    fn try_from(config: ClientConfig) -> std::result::Result<Self, Self::Error> {
332        #[allow(unused_mut)]
333        let mut client_builder = reqwest::Client::builder();
334        #[cfg(not(target_arch = "wasm32"))]
335        let mut client_builder =
336            client_builder.danger_accept_invalid_certs(config.accept_invalid_certificates);
337
338        client_builder = match () {
339            #[cfg(all(feature = "native-tls", not(target_arch = "wasm32")))]
340            () => client_builder.danger_accept_invalid_hostnames(config.accept_invalid_hostnames),
341            #[cfg(any(not(feature = "native-tls"), target_arch = "wasm32"))]
342            () => client_builder,
343        };
344
345        #[cfg(not(target_arch = "wasm32"))]
346        {
347            if !config.tls_certs_only.is_empty() {
348                client_builder =
349                    client_builder.tls_certs_only(convert_certificates(&config.tls_certs_only)?);
350            }
351            client_builder = client_builder
352                .tls_certs_merge(convert_certificates(&config.extra_root_certificates)?);
353        }
354
355        if let Some(timeout) = config.read_timeout {
356            client_builder = client_builder.read_timeout(timeout);
357        }
358        if let Some(timeout) = config.connect_timeout {
359            client_builder = client_builder.connect_timeout(timeout);
360        }
361
362        client_builder = client_builder.user_agent(config.user_agent);
363
364        if let Some(proxy_addr) = &config.https_proxy {
365            let no_proxy = config
366                .no_proxy
367                .as_ref()
368                .and_then(|no_proxy| NoProxy::from_string(no_proxy));
369            let proxy = Proxy::https(proxy_addr)?.no_proxy(no_proxy);
370            client_builder = client_builder.proxy(proxy);
371        }
372
373        if let Some(proxy_addr) = &config.http_proxy {
374            let no_proxy = config
375                .no_proxy
376                .as_ref()
377                .and_then(|no_proxy| NoProxy::from_string(no_proxy));
378            let proxy = Proxy::http(proxy_addr)?.no_proxy(no_proxy);
379            client_builder = client_builder.proxy(proxy);
380        }
381
382        let default_token_expiration_secs = config.default_token_expiration_secs;
383        Ok(Self {
384            config: Arc::new(config),
385            tokens: TokenCache::new(default_token_expiration_secs),
386            client: client_builder.build()?,
387            push_chunk_size: PUSH_CHUNK_MAX_SIZE,
388            ..Default::default()
389        })
390    }
391}
392
393impl Client {
394    /// Create a new client with the supplied config
395    pub fn new(config: ClientConfig) -> Self {
396        let default_token_expiration_secs = config.default_token_expiration_secs;
397        Client::try_from(config).unwrap_or_else(|err| {
398            warn!("Cannot create OCI client from config: {:?}", err);
399            warn!("Creating client with default configuration");
400            Self {
401                tokens: TokenCache::new(default_token_expiration_secs),
402                push_chunk_size: PUSH_CHUNK_MAX_SIZE,
403                ..Default::default()
404            }
405        })
406    }
407
408    /// Create a new client with the supplied config
409    pub fn from_source(config_source: &impl ClientConfigSource) -> Self {
410        Self::new(config_source.client_config())
411    }
412
413    async fn store_auth(&self, registry: &str, auth: RegistryAuth) {
414        self.auth_store
415            .write()
416            .await
417            .insert(registry.to_string(), auth);
418    }
419
420    async fn is_stored_auth(&self, registry: &str) -> bool {
421        self.auth_store.read().await.contains_key(registry)
422    }
423
424    /// Store the authentication information for this registry if it's not already stored in the client.
425    ///
426    /// Most of the time, you don't need to call this method directly. It's called by other
427    /// methods (where you have to provide the authentication information as parameter).
428    ///
429    /// But if you want to pull/push a blob without calling any of the other methods first, which would
430    /// store the authentication information, you can call this method to store the authentication
431    /// information manually.
432    pub async fn store_auth_if_needed(&self, registry: &str, auth: &RegistryAuth) {
433        if !self.is_stored_auth(registry).await {
434            self.store_auth(registry, auth.clone()).await;
435        }
436    }
437
438    /// Checks if we got a token, if we don't - create it and store it in cache.
439    async fn get_auth_token(
440        &self,
441        reference: &Reference,
442        op: RegistryOperation,
443    ) -> Option<RegistryTokenType> {
444        let registry = reference.resolve_registry();
445        let auth = self.auth_store.read().await.get(registry)?.clone();
446        match self.tokens.get(reference, op).await {
447            Some(token) => Some(token),
448            None => {
449                let token = self._auth(reference, &auth, op).await.ok()??;
450                self.tokens.insert(reference, op, token.clone()).await;
451                Some(token)
452            }
453        }
454    }
455
456    /// Fetches the available Tags for the given Reference
457    ///
458    /// The client will check if it's already been authenticated and if
459    /// not will attempt to do.
460    pub async fn list_tags(
461        &self,
462        image: &Reference,
463        auth: &RegistryAuth,
464        n: Option<usize>,
465        last: Option<&str>,
466    ) -> Result<TagResponse> {
467        let op = RegistryOperation::Pull;
468        let url = self.to_list_tags_url(image);
469
470        self.store_auth_if_needed(image.resolve_registry(), auth)
471            .await;
472
473        let request = self.client.get(&url);
474        let request = if let Some(num) = n {
475            request.query(&[("n", num)])
476        } else {
477            request
478        };
479        let request = if let Some(l) = last {
480            request.query(&[("last", l)])
481        } else {
482            request
483        };
484        let request = RequestBuilderWrapper {
485            client: self,
486            request_builder: request,
487        };
488        let res = request
489            .apply_auth(image, op)
490            .await?
491            .into_request_builder()
492            .send()
493            .await?;
494        let status = res.status();
495        let body = res.bytes().await?;
496
497        validate_registry_response(status, &body, &url)?;
498
499        Ok(serde_json::from_str(std::str::from_utf8(&body)?)?)
500    }
501
502    /// Pull an image and return the bytes
503    ///
504    /// The client will check if it's already been authenticated and if
505    /// not will attempt to do.
506    pub async fn pull(
507        &self,
508        image: &Reference,
509        auth: &RegistryAuth,
510        accepted_media_types: Vec<&str>,
511    ) -> Result<ImageData> {
512        debug!("Pulling image: {:?}", image);
513        self.store_auth_if_needed(image.resolve_registry(), auth)
514            .await;
515
516        let (manifest, digest, config) = self._pull_manifest_and_config(image).await?;
517
518        self.validate_layers(&manifest, accepted_media_types)
519            .await?;
520
521        let layers = stream::iter(&manifest.layers)
522            .map(|layer| {
523                // This avoids moving `self` which is &Self
524                // into the async block. We only want to capture
525                // as &Self
526                let this = &self;
527                async move {
528                    let mut out: Vec<u8> = Vec::new();
529                    debug!("Pulling image layer");
530                    this.pull_blob(image, layer, &mut out).await?;
531                    Ok::<_, OciDistributionError>(ImageLayer::new(
532                        out,
533                        layer.media_type.clone(),
534                        layer.annotations.clone(),
535                    ))
536                }
537            })
538            .boxed() // Workaround to rustc issue https://github.com/rust-lang/rust/issues/104382
539            .buffer_unordered(self.config.max_concurrent_download)
540            .try_collect()
541            .await?;
542
543        Ok(ImageData {
544            layers,
545            manifest: Some(manifest),
546            config,
547            digest: Some(digest),
548        })
549    }
550
551    /// Checks if a blob exists in the remote registry
552    pub async fn blob_exists(&self, image: &Reference, digest: &str) -> Result<bool> {
553        let url = self.to_v2_blob_url(image, digest);
554        let request = RequestBuilderWrapper {
555            client: self,
556            request_builder: self.client.head(&url),
557        };
558
559        let res = request
560            .apply_auth(image, RegistryOperation::Pull)
561            .await?
562            .into_request_builder()
563            .send()
564            .await?;
565
566        match res.error_for_status() {
567            Ok(_) => Ok(true),
568            Err(err) => {
569                if err.status() == Some(StatusCode::NOT_FOUND) {
570                    Ok(false)
571                } else {
572                    Err(err.into())
573                }
574            }
575        }
576    }
577
578    /// Push an image and return the uploaded URL of the image
579    ///
580    /// The client will check if it's already been authenticated and if
581    /// not will attempt to do.
582    ///
583    /// If a manifest is not provided, the client will attempt to generate
584    /// it from the provided image and config data.
585    ///
586    /// Returns pullable URL for the image
587    pub async fn push(
588        &self,
589        image_ref: &Reference,
590        layers: &[ImageLayer],
591        config: Config,
592        auth: &RegistryAuth,
593        manifest: Option<OciImageManifest>,
594    ) -> Result<PushResponse> {
595        debug!("Pushing image: {:?}", image_ref);
596        self.store_auth_if_needed(image_ref.resolve_registry(), auth)
597            .await;
598
599        let manifest: OciImageManifest = match manifest {
600            Some(m) => m,
601            None => OciImageManifest::build(layers, &config, None),
602        };
603
604        // Upload layers.
605        //
606        // Reuse the per-layer digests already computed while building (or
607        // supplied with) the manifest, rather than hashing every layer a
608        // second time here. For large layers this avoids a full redundant
609        // SHA-256 pass over the data. When `build` produced the manifest its
610        // `layers` are in the same order as `layers`; if a caller supplied a
611        // manifest whose layer count does not match, fall back to hashing each
612        // layer so behaviour is unchanged.
613        let layer_digests: Vec<String> = if manifest.layers.len() == layers.len() {
614            manifest.layers.iter().map(|d| d.digest.clone()).collect()
615        } else {
616            layers.iter().map(|l| l.sha256_digest()).collect()
617        };
618        stream::iter(layers.iter().zip(layer_digests))
619            .map(|(layer, digest)| {
620                // This avoids moving `self` which is &Self
621                // into the async block. We only want to capture
622                // as &Self
623                let this = &self;
624                async move {
625                    this.push_blob(image_ref, layer.data.clone(), &digest)
626                        .await?;
627                    Result::Ok(())
628                }
629            })
630            .boxed() // Workaround to rustc issue https://github.com/rust-lang/rust/issues/104382
631            .buffer_unordered(self.config.max_concurrent_upload)
632            .try_for_each(future::ok)
633            .await?;
634
635        let config_url = self
636            .push_blob(image_ref, config.data, &manifest.config.digest)
637            .await?;
638        let manifest_url = self.push_manifest(image_ref, &manifest.into()).await?;
639
640        Ok(PushResponse {
641            config_url,
642            manifest_url,
643        })
644    }
645
646    /// Pushes a blob to the registry
647    pub async fn push_blob(
648        &self,
649        image_ref: &Reference,
650        data: impl Into<bytes::Bytes>,
651        digest: &str,
652    ) -> Result<String> {
653        if self.config.use_monolithic_push {
654            return self.push_blob_monolithically(image_ref, data, digest).await;
655        }
656        let data = data.into();
657        // Cloning the bytes here is cheap (e.g. doesn't allocate anything except some space for
658        // some pointers). If any cloning happened, it is because the caller's passed data was not
659        // already a `Bytes` type or static data.
660        match self
661            .push_blob_chunked(image_ref, data.clone(), digest)
662            .await
663        {
664            Ok(url) => Ok(url),
665            Err(OciDistributionError::SpecViolationError(violation)) => {
666                warn!(?violation, "Registry is not respecting the OCI Distribution Specification when doing chunked push operations");
667                warn!("Attempting monolithic push");
668                self.push_blob_monolithically(image_ref, data, digest).await
669            }
670            Err(e) => Err(e),
671        }
672    }
673
674    /// Pushes a blob to the registry as a monolith
675    ///
676    /// Returns the pullable location of the blob
677    async fn push_blob_monolithically(
678        &self,
679        image: &Reference,
680        blob_data: impl Into<bytes::Bytes>,
681        blob_digest: &str,
682    ) -> Result<String> {
683        let location = self.begin_push_monolithical_session(image).await?;
684        self.push_monolithically(&location, image, blob_data, blob_digest)
685            .await
686    }
687
688    /// Pushes a blob to the registry as a series of chunks
689    ///
690    /// Returns the pullable location of the blob
691    async fn push_blob_chunked(
692        &self,
693        image: &Reference,
694        blob_data: impl Into<bytes::Bytes>,
695        blob_digest: &str,
696    ) -> Result<String> {
697        let mut location = self.begin_push_chunked_session(image).await?;
698        let mut start: usize = 0;
699
700        let mut blob_data: bytes::Bytes = blob_data.into();
701        while !blob_data.is_empty() {
702            let chunk_size = self.push_chunk_size.min(blob_data.len());
703            let chunk = blob_data.split_to(chunk_size);
704            (location, start) = self.push_chunk(&location, image, chunk, start).await?;
705        }
706        self.end_push_chunked_session(&location, image, blob_digest)
707            .await
708    }
709
710    /// Pushes a blob to the registry from an input stream.
711    ///
712    /// If `use_monolithic_push` is set in the client config, a single PUT is used (monolithic
713    /// push). In that case `size` must be `Some`, as it is required to set `Content-Length` on the
714    /// request. If `size` is `None` and `use_monolithic_push` is true, an error is returned.
715    ///
716    /// If `use_monolithic_push` is false the blob is sent as a series of chunked PATCH requests
717    /// and `size` is ignored.
718    ///
719    /// Note: unlike [`push_blob`], there is no automatic fallback to monolithic push on a
720    /// `SpecViolationError` from the chunked path, because a stream cannot be replayed after
721    /// it has been consumed.
722    ///
723    /// Returns the pullable location of the blob.
724    pub async fn push_blob_stream<T: Stream<Item = Result<bytes::Bytes>> + Send + 'static>(
725        &self,
726        image: &Reference,
727        blob_data_stream: T,
728        blob_digest: &str,
729        size: Option<u64>,
730    ) -> Result<String> {
731        if self.config.use_monolithic_push {
732            let size = size.ok_or_else(|| {
733                OciDistributionError::GenericError(Some(
734                    "size must be provided when use_monolithic_push is enabled".to_string(),
735                ))
736            })?;
737            let location = self.begin_push_monolithical_session(image).await?;
738            return self
739                .push_stream_monolithically(&location, image, blob_data_stream, size, blob_digest)
740                .await;
741        }
742
743        let location = self.begin_push_chunked_session(image).await?;
744        let (location, _size) = self
745            .push_stream_chunks(location, image, blob_data_stream, |_| {})
746            .await?;
747        self.end_push_chunked_session(&location, image, blob_digest)
748            .await
749    }
750
751    /// Pushes a blob to the registry from an input stream using chunked transfer, computing
752    /// the SHA256 digest on-the-fly.
753    ///
754    /// Unlike [`push_blob_stream`], the caller does not need to know the digest upfront.
755    /// The digest is computed incrementally as each chunk is sent, then supplied to the
756    /// registry in the final commit request.
757    ///
758    /// Monolithic push is not supported by this method; the blob is always sent as a series
759    /// of chunked PATCH requests regardless of the `use_monolithic_push` setting.
760    ///
761    /// Returns the pullable location of the blob, the computed digest, and the blob size.
762    pub async fn push_blob_stream_chunked<
763        T: Stream<Item = Result<bytes::Bytes>> + Send + 'static,
764    >(
765        &self,
766        image: &Reference,
767        blob_data_stream: T,
768    ) -> Result<PushBlobStreamChunkedResponse> {
769        if self.config.use_monolithic_push {
770            debug!(
771                "use_monolithic_push is enabled, but push_blob_stream_chunked always uses chunked PATCH requests; ignoring"
772            );
773        }
774
775        let location = self.begin_push_chunked_session(image).await?;
776
777        let mut digester = Digester::Sha256(sha2::Sha256::new());
778        let (location, size) = self
779            .push_stream_chunks(location, image, blob_data_stream, |chunk| {
780                digester.update(chunk)
781            })
782            .await?;
783
784        if size == 0 {
785            return Err(OciDistributionError::PushNoDataError);
786        }
787
788        let blob_digest = digester.finalize();
789        let location = self
790            .end_push_chunked_session(&location, image, &blob_digest)
791            .await?;
792
793        Ok(PushBlobStreamChunkedResponse {
794            blob_url: location,
795            blob_digest,
796            size,
797        })
798    }
799
800    /// Perform an OAuth v2 auth request if necessary.
801    ///
802    /// This performs authorization and then stores the token internally to be used
803    /// on other requests.
804    pub async fn auth(
805        &self,
806        image: &Reference,
807        authentication: &RegistryAuth,
808        operation: RegistryOperation,
809    ) -> Result<Option<String>> {
810        self.store_auth_if_needed(image.resolve_registry(), authentication)
811            .await;
812        // preserve old caching behavior
813        match self._auth(image, authentication, operation).await {
814            Ok(Some(RegistryTokenType::Bearer(token))) => {
815                self.tokens
816                    .insert(image, operation, RegistryTokenType::Bearer(token.clone()))
817                    .await;
818                Ok(Some(token.token().to_string()))
819            }
820            Ok(Some(RegistryTokenType::Basic(username, password))) => {
821                self.tokens
822                    .insert(
823                        image,
824                        operation,
825                        RegistryTokenType::Basic(username, password),
826                    )
827                    .await;
828                Ok(None)
829            }
830            Ok(None) => Ok(None),
831            Err(e) => Err(e),
832        }
833    }
834
835    /// Internal auth that retrieves token.
836    async fn _auth(
837        &self,
838        image: &Reference,
839        authentication: &RegistryAuth,
840        operation: RegistryOperation,
841    ) -> Result<Option<RegistryTokenType>> {
842        debug!("Authorizing for image: {:?}", image);
843        // The version request will tell us where to go.
844        let url = format!(
845            "{}://{}/v2/",
846            self.config.protocol.scheme_for(image.resolve_registry()),
847            image.resolve_registry()
848        );
849        debug!(?url);
850
851        if let RegistryAuth::Bearer(token) = authentication {
852            return Ok(Some(RegistryTokenType::Bearer(RegistryToken::Token {
853                token: token.clone(),
854            })));
855        }
856
857        let res = self.client.get(&url).send().await?;
858        let dist_hdr = match res.headers().get(reqwest::header::WWW_AUTHENTICATE) {
859            Some(h) => h,
860            None => return Ok(None),
861        };
862
863        let challenge = match BearerChallenge::try_from(dist_hdr) {
864            Ok(c) => c,
865            Err(e) => {
866                debug!(error = ?e, "Falling back to HTTP Basic Auth");
867                if let RegistryAuth::Basic(username, password) = authentication {
868                    return Ok(Some(RegistryTokenType::Basic(
869                        username.to_string(),
870                        password.to_string(),
871                    )));
872                }
873                return Ok(None);
874            }
875        };
876
877        // Allow for either push or pull authentication
878        let scope = match operation {
879            RegistryOperation::Pull => format!("repository:{}:pull", image.repository()),
880            RegistryOperation::Push => format!("repository:{}:pull,push", image.repository()),
881        };
882
883        let realm = challenge.realm.as_ref();
884        let service = challenge.service.as_ref();
885        let mut query = vec![("scope", &scope)];
886
887        if let Some(s) = service {
888            query.push(("service", s))
889        }
890
891        // TODO: At some point in the future, we should support sending a secret to the
892        // server for auth. This particular workflow is for read-only public auth.
893        debug!(?realm, ?service, ?scope, "Making authentication call");
894
895        let auth_res = self
896            .client
897            .get(realm)
898            .query(&query)
899            .apply_authentication(authentication)
900            .send()
901            .await?;
902
903        match auth_res.status() {
904            reqwest::StatusCode::OK => {
905                let text = auth_res.text().await?;
906                debug!("Received response from auth request");
907                let token: RegistryToken = serde_json::from_str(&text)
908                    .map_err(|e| OciDistributionError::RegistryTokenDecodeError(e.to_string()))?;
909                debug!("Successfully authorized for image '{:?}'", image);
910                Ok(Some(RegistryTokenType::Bearer(token)))
911            }
912            _ => {
913                let reason = auth_res.text().await?;
914                debug!("Failed to authenticate for image '{:?}': {}", image, reason);
915                Err(OciDistributionError::AuthenticationFailure(reason))
916            }
917        }
918    }
919
920    /// Fetch a manifest's digest from the remote OCI Distribution service.
921    ///
922    /// If the connection has already gone through authentication, this will
923    /// use the bearer token. Otherwise, this will attempt an anonymous pull.
924    ///
925    /// Will first attempt to read the `Docker-Content-Digest` header using a
926    /// HEAD request. If this header is not present, will make a second GET
927    /// request and return the SHA256 of the response body.
928    pub async fn fetch_manifest_digest(
929        &self,
930        image: &Reference,
931        auth: &RegistryAuth,
932    ) -> Result<String> {
933        self.store_auth_if_needed(image.resolve_registry(), auth)
934            .await;
935
936        let url = self.to_v2_manifest_url(image);
937        debug!("HEAD image manifest from {}", url);
938        let res = RequestBuilderWrapper::from_client(self, |client| client.head(&url))
939            .apply_accept(MIME_TYPES_DISTRIBUTION_MANIFEST)?
940            .apply_auth(image, RegistryOperation::Pull)
941            .await?
942            .into_request_builder()
943            .send()
944            .await?;
945
946        if let Some(digest) = digest_header_value(res.headers().clone())? {
947            let status = res.status();
948            let body = res.bytes().await?;
949            validate_registry_response(status, &body, &url)?;
950
951            // If the reference has a digest and the digest header has a matching algorithm, compare
952            // them and return an error if they don't match.
953            if let Some(img_digest) = image.digest() {
954                let header_digest = Digest::new(&digest)?;
955                let image_digest = Digest::new(img_digest)?;
956                if header_digest.algorithm == image_digest.algorithm
957                    && header_digest != image_digest
958                {
959                    return Err(DigestError::VerificationError {
960                        expected: img_digest.to_string(),
961                        actual: digest,
962                    }
963                    .into());
964                }
965            }
966
967            Ok(digest)
968        } else {
969            debug!("GET image manifest from {}", url);
970            let res = RequestBuilderWrapper::from_client(self, |client| client.get(&url))
971                .apply_accept(MIME_TYPES_DISTRIBUTION_MANIFEST)?
972                .apply_auth(image, RegistryOperation::Pull)
973                .await?
974                .into_request_builder()
975                .send()
976                .await?;
977            let status = res.status();
978            trace!(headers = ?res.headers(), "Got Headers");
979            let headers = res.headers().clone();
980            let body = res.bytes().await?;
981            validate_registry_response(status, &body, &url)?;
982
983            validate_digest(&body, digest_header_value(headers)?, image.digest())
984                .map_err(OciDistributionError::from)
985        }
986    }
987
988    async fn validate_layers(
989        &self,
990        manifest: &OciImageManifest,
991        accepted_media_types: Vec<&str>,
992    ) -> Result<()> {
993        if manifest.layers.is_empty() {
994            return Err(OciDistributionError::PullNoLayersError);
995        }
996
997        for layer in &manifest.layers {
998            if !accepted_media_types.iter().any(|i| i.eq(&layer.media_type)) {
999                return Err(OciDistributionError::IncompatibleLayerMediaTypeError(
1000                    layer.media_type.clone(),
1001                ));
1002            }
1003        }
1004
1005        Ok(())
1006    }
1007
1008    /// Pull a manifest from the remote OCI Distribution service.
1009    ///
1010    /// The client will check if it's already been authenticated and if
1011    /// not will attempt to do.
1012    ///
1013    /// A Tuple is returned containing the [OciImageManifest]
1014    /// and the manifest content digest hash.
1015    ///
1016    /// If a multi-platform Image Index manifest is encountered, a platform-specific
1017    /// Image manifest will be selected using the client's default platform resolution.
1018    pub async fn pull_image_manifest(
1019        &self,
1020        image: &Reference,
1021        auth: &RegistryAuth,
1022    ) -> Result<(OciImageManifest, String)> {
1023        self.store_auth_if_needed(image.resolve_registry(), auth)
1024            .await;
1025
1026        self._pull_image_manifest(image).await
1027    }
1028
1029    /// Pull a manifest from the remote OCI Distribution service.
1030    ///
1031    /// The client will check if it's already been authenticated and if
1032    /// not will attempt to do.
1033    ///
1034    /// Returns `(image_manifest, manifest_digest, Option<manifest_list_digest>)`.
1035    /// The manifest list digest is `Some` when the original reference pointed to
1036    /// an image index / manifest list; `None` when it pointed directly to a
1037    /// single-platform image manifest.
1038    ///
1039    /// If a multi-platform Image Index manifest is encountered, a platform-specific
1040    /// Image manifest will be selected using the client's default platform resolution.
1041    pub async fn pull_image_manifest_and_list_digest(
1042        &self,
1043        image: &Reference,
1044        auth: &RegistryAuth,
1045    ) -> Result<(OciImageManifest, String, Option<String>)> {
1046        self.store_auth_if_needed(image.resolve_registry(), auth)
1047            .await;
1048
1049        self._pull_image_manifest_and_list_digest(image).await
1050    }
1051
1052    /// Pull a manifest from the remote OCI Distribution service without parsing it.
1053    ///
1054    /// The client will check if it's already been authenticated and if
1055    /// not will attempt to do.
1056    ///
1057    /// A Tuple is returned containing raw byte representation of the manifest
1058    /// and the manifest content digest.
1059    pub async fn pull_manifest_raw(
1060        &self,
1061        image: &Reference,
1062        auth: &RegistryAuth,
1063        accepted_media_types: &[&str],
1064    ) -> Result<(bytes::Bytes, String)> {
1065        self.store_auth_if_needed(image.resolve_registry(), auth)
1066            .await;
1067
1068        self._pull_manifest_raw(image, accepted_media_types).await
1069    }
1070
1071    /// Pull a manifest from the remote OCI Distribution service.
1072    ///
1073    /// The client will check if it's already been authenticated and if
1074    /// not will attempt to do.
1075    ///
1076    /// A Tuple is returned containing the [Manifest](crate::manifest::OciImageManifest)
1077    /// and the manifest content digest hash.
1078    pub async fn pull_manifest(
1079        &self,
1080        image: &Reference,
1081        auth: &RegistryAuth,
1082    ) -> Result<(OciManifest, String)> {
1083        self.store_auth_if_needed(image.resolve_registry(), auth)
1084            .await;
1085
1086        self._pull_manifest(image).await
1087    }
1088
1089    /// Pull an image manifest from the remote OCI Distribution service.
1090    ///
1091    /// If the connection has already gone through authentication, this will
1092    /// use the bearer token. Otherwise, this will attempt an anonymous pull.
1093    ///
1094    /// If a multi-platform Image Index manifest is encountered, a platform-specific
1095    /// Image manifest will be selected using the client's default platform resolution.
1096    async fn _pull_image_manifest(&self, image: &Reference) -> Result<(OciImageManifest, String)> {
1097        let (manifest, digest, _list_digest) =
1098            self._pull_image_manifest_and_list_digest(image).await?;
1099        Ok((manifest, digest))
1100    }
1101
1102    /// Pull an image manifest from the remote OCI Distribution service,
1103    /// also returning the manifest list digest if the image is multi-arch.
1104    ///
1105    /// If the connection has already gone through authentication, this will
1106    /// use the bearer token. Otherwise, this will attempt an anonymous pull.
1107    ///
1108    /// Returns `(image_manifest, manifest_digest, Option<manifest_list_digest>)`.
1109    /// The manifest list digest is `Some` when the original reference pointed to
1110    /// an image index / manifest list; `None` when it pointed directly to a
1111    /// single-platform image manifest.
1112    async fn _pull_image_manifest_and_list_digest(
1113        &self,
1114        image: &Reference,
1115    ) -> Result<(OciImageManifest, String, Option<String>)> {
1116        let (manifest, digest) = self._pull_manifest(image).await?;
1117        match manifest {
1118            OciManifest::Image(image_manifest) => Ok((image_manifest, digest, None)),
1119            OciManifest::ImageIndex(image_index_manifest) => {
1120                let list_digest = digest;
1121                debug!("Inspecting Image Index Manifest");
1122                let platform_digest = if let Some(resolver) = &self.config.platform_resolver {
1123                    resolver(&image_index_manifest.manifests)
1124                } else {
1125                    return Err(OciDistributionError::ImageIndexParsingNoPlatformResolverError);
1126                };
1127
1128                match platform_digest {
1129                    Some(platform_digest) => {
1130                        debug!("Selected manifest entry with digest: {}", platform_digest);
1131                        let manifest_entry_reference =
1132                            image.clone_with_digest(platform_digest.clone());
1133                        self._pull_manifest(&manifest_entry_reference)
1134                            .await
1135                            .and_then(|(manifest, _digest)| match manifest {
1136                                OciManifest::Image(manifest) => {
1137                                    Ok((manifest, platform_digest, Some(list_digest)))
1138                                }
1139                                OciManifest::ImageIndex(_) => {
1140                                    Err(OciDistributionError::ImageManifestNotFoundError(
1141                                        "received Image Index manifest instead".to_string(),
1142                                    ))
1143                                }
1144                            })
1145                    }
1146                    None => Err(OciDistributionError::ImageManifestNotFoundError(
1147                        "no entry found in image index manifest matching client's default platform"
1148                            .to_string(),
1149                    )),
1150                }
1151            }
1152        }
1153    }
1154
1155    /// Pull a manifest from the remote OCI Distribution service without parsing it.
1156    ///
1157    /// If the connection has already gone through authentication, this will
1158    /// use the bearer token. Otherwise, this will attempt an anonymous pull.
1159    async fn _pull_manifest_raw(
1160        &self,
1161        image: &Reference,
1162        accepted_media_types: &[&str],
1163    ) -> Result<(bytes::Bytes, String)> {
1164        let url = self.to_v2_manifest_url(image);
1165        debug!("Pulling image manifest from {}", url);
1166
1167        let res = RequestBuilderWrapper::from_client(self, |client| client.get(&url))
1168            .apply_accept(accepted_media_types)?
1169            .apply_auth(image, RegistryOperation::Pull)
1170            .await?
1171            .into_request_builder()
1172            .send()
1173            .await?;
1174        let status = res.status();
1175        let headers = res.headers().clone();
1176        let body = res.bytes().await?;
1177
1178        validate_registry_response(status, &body, &url)?;
1179
1180        let digest_header = digest_header_value(headers)?;
1181        let digest = validate_digest(&body, digest_header, image.digest())?;
1182
1183        Ok((body, digest))
1184    }
1185
1186    /// Pull a manifest from the remote OCI Distribution service.
1187    ///
1188    /// If the connection has already gone through authentication, this will
1189    /// use the bearer token. Otherwise, this will attempt an anonymous pull.
1190    async fn _pull_manifest(&self, image: &Reference) -> Result<(OciManifest, String)> {
1191        let (body, digest) = self
1192            ._pull_manifest_raw(image, MIME_TYPES_DISTRIBUTION_MANIFEST)
1193            .await?;
1194
1195        self.validate_image_manifest(&body).await?;
1196
1197        debug!("Parsing response as Manifest");
1198        let manifest = serde_json::from_slice(&body)
1199            .map_err(|e| OciDistributionError::ManifestParsingError(e.to_string()))?;
1200        Ok((manifest, digest))
1201    }
1202
1203    async fn validate_image_manifest(&self, body: &[u8]) -> Result<()> {
1204        let versioned: Versioned = serde_json::from_slice(body)
1205            .map_err(|e| OciDistributionError::VersionedParsingError(e.to_string()))?;
1206        debug!(?versioned, "validating manifest");
1207        if versioned.schema_version != 2 {
1208            return Err(OciDistributionError::UnsupportedSchemaVersionError(
1209                versioned.schema_version,
1210            ));
1211        }
1212        if let Some(media_type) = versioned.media_type {
1213            if media_type != IMAGE_MANIFEST_MEDIA_TYPE
1214                && media_type != OCI_IMAGE_MEDIA_TYPE
1215                && media_type != IMAGE_MANIFEST_LIST_MEDIA_TYPE
1216                && media_type != OCI_IMAGE_INDEX_MEDIA_TYPE
1217            {
1218                return Err(OciDistributionError::UnsupportedMediaTypeError(media_type));
1219            }
1220        }
1221
1222        Ok(())
1223    }
1224
1225    /// Pull a manifest and its config from the remote OCI Distribution service.
1226    ///
1227    /// The client will check if it's already been authenticated and if
1228    /// not will attempt to do.
1229    ///
1230    /// A Tuple is returned containing the [OciImageManifest],
1231    /// the manifest content digest hash and the contents of the manifests config layer
1232    /// as a String.
1233    pub async fn pull_manifest_and_config(
1234        &self,
1235        image: &Reference,
1236        auth: &RegistryAuth,
1237    ) -> Result<(OciImageManifest, String, String)> {
1238        self.store_auth_if_needed(image.resolve_registry(), auth)
1239            .await;
1240
1241        self._pull_manifest_and_config(image)
1242            .await
1243            .and_then(|(manifest, digest, config)| {
1244                Ok((
1245                    manifest,
1246                    digest,
1247                    String::from_utf8(config.data.into()).map_err(|e| {
1248                        OciDistributionError::GenericError(Some(format!(
1249                            "Cannot parse config as UTF-8 string: {e}"
1250                        )))
1251                    })?,
1252                ))
1253            })
1254    }
1255
1256    /// Pull a manifest and its config from the remote OCI Distribution service.
1257    ///
1258    /// The client will check if it's already been authenticated and if
1259    /// not will attempt to do.
1260    ///
1261    /// Returns `(image_manifest, manifest_digest, config_json, Option<manifest_list_digest>)`.
1262    /// The manifest list digest is `Some` when the original reference pointed to
1263    /// an image index / manifest list; `None` when it pointed directly to a
1264    /// single-platform image manifest.
1265    ///
1266    /// If a multi-platform Image Index manifest is encountered, a platform-specific
1267    /// Image manifest will be selected using the client's default platform resolution.
1268    pub async fn pull_manifest_and_config_and_list_digest(
1269        &self,
1270        image: &Reference,
1271        auth: &RegistryAuth,
1272    ) -> Result<(OciImageManifest, String, String, Option<String>)> {
1273        self.store_auth_if_needed(image.resolve_registry(), auth)
1274            .await;
1275
1276        self._pull_manifest_and_config_and_list_digest(image)
1277            .await
1278            .and_then(|(manifest, digest, config, list_digest)| {
1279                Ok((
1280                    manifest,
1281                    digest,
1282                    String::from_utf8(config.data.into()).map_err(|e| {
1283                        OciDistributionError::GenericError(Some(format!(
1284                            "Cannot parse config as UTF-8 string: {e}"
1285                        )))
1286                    })?,
1287                    list_digest,
1288                ))
1289            })
1290    }
1291
1292    async fn _pull_manifest_and_config(
1293        &self,
1294        image: &Reference,
1295    ) -> Result<(OciImageManifest, String, Config)> {
1296        let (manifest, digest, config, _list_digest) = self
1297            ._pull_manifest_and_config_and_list_digest(image)
1298            .await?;
1299        Ok((manifest, digest, config))
1300    }
1301
1302    async fn _pull_manifest_and_config_and_list_digest(
1303        &self,
1304        image: &Reference,
1305    ) -> Result<(OciImageManifest, String, Config, Option<String>)> {
1306        let (manifest, digest, list_digest) =
1307            self._pull_image_manifest_and_list_digest(image).await?;
1308
1309        let mut out: Vec<u8> = Vec::new();
1310        debug!("Pulling config layer");
1311        self.pull_blob(image, &manifest.config, &mut out).await?;
1312        let media_type = manifest.config.media_type.clone();
1313        let annotations = manifest.annotations.clone();
1314        Ok((
1315            manifest,
1316            digest,
1317            Config::new(out, media_type, annotations),
1318            list_digest,
1319        ))
1320    }
1321
1322    /// Push a manifest list to an OCI registry.
1323    ///
1324    /// This pushes a manifest list to an OCI registry.
1325    pub async fn push_manifest_list(
1326        &self,
1327        reference: &Reference,
1328        auth: &RegistryAuth,
1329        manifest: OciImageIndex,
1330    ) -> Result<String> {
1331        self.store_auth_if_needed(reference.resolve_registry(), auth)
1332            .await;
1333        self.push_manifest(reference, &OciManifest::ImageIndex(manifest))
1334            .await
1335    }
1336
1337    /// Pull a single layer from an OCI registry.
1338    ///
1339    /// This pulls the layer for a particular image that is identified by the given layer
1340    /// descriptor. The layer descriptor can be anything that can be referenced as a layer
1341    /// descriptor. The image reference is used to find the repository and the registry, but it is
1342    /// not used to verify that the digest is a layer inside of the image. (The manifest is used for
1343    /// that.)
1344    pub async fn pull_blob<T: AsyncWrite>(
1345        &self,
1346        image: &Reference,
1347        layer: impl AsLayerDescriptor,
1348        out: T,
1349    ) -> Result<()> {
1350        let response = self.pull_blob_response(image, &layer, None, None).await?;
1351
1352        let mut maybe_header_digester = digest_header_value(response.headers().clone())?
1353            .map(|digest| Digester::new(&digest).map(|d| (d, digest)))
1354            .transpose()?;
1355
1356        // With a blob pull, we need to use the digest from the layer and not the image
1357        let layer_digest = layer.as_layer_descriptor().digest.to_string();
1358        let mut layer_digester = Digester::new(&layer_digest)?;
1359
1360        let status = response.status();
1361        let url = response.url().to_string();
1362        if !status.is_success() {
1363            let body = response.bytes().await?;
1364            return validate_registry_response(status, &body, &url);
1365        }
1366        let mut stream = response.bytes_stream();
1367
1368        let mut out = pin!(out);
1369
1370        while let Some(bytes) = stream.next().await {
1371            let bytes = bytes?;
1372            if let Some((ref mut digester, _)) = maybe_header_digester.as_mut() {
1373                digester.update(&bytes);
1374            }
1375            layer_digester.update(&bytes);
1376            out.write_all(&bytes).await?;
1377        }
1378
1379        // Ensure all buffered writes are flushed before returning.
1380        out.flush().await?;
1381
1382        if let Some((mut digester, expected)) = maybe_header_digester.take() {
1383            let digest = digester.finalize();
1384
1385            if digest != expected {
1386                return Err(DigestError::VerificationError {
1387                    expected,
1388                    actual: digest,
1389                }
1390                .into());
1391            }
1392        }
1393
1394        let digest = layer_digester.finalize();
1395        if digest != layer_digest {
1396            return Err(DigestError::VerificationError {
1397                expected: layer_digest,
1398                actual: digest,
1399            }
1400            .into());
1401        }
1402
1403        Ok(())
1404    }
1405
1406    /// Stream a single layer from an OCI registry.
1407    ///
1408    /// This is a streaming version of [`Client::pull_blob`]. Returns [`SizedStream`], which
1409    /// implements [`Stream`] or can be used directly to get the content
1410    /// length of the response
1411    ///
1412    /// # Example
1413    /// ```rust
1414    /// use std::future::Future;
1415    /// use std::io::Error;
1416    ///
1417    /// use futures_util::TryStreamExt;
1418    /// use oci_client::{Client, Reference};
1419    /// use oci_client::client::ClientConfig;
1420    /// use oci_client::manifest::OciDescriptor;
1421    ///
1422    /// async {
1423    ///   let client = Client::new(Default::default());
1424    ///   let imgRef: Reference = "busybox:latest".parse().unwrap();
1425    ///   let desc = OciDescriptor { digest: "sha256:deadbeef".to_owned(), ..Default::default() };
1426    ///   let mut stream = client.pull_blob_stream(&imgRef, &desc).await.unwrap();
1427    ///   // Check the optional content length
1428    ///   let content_length = stream.content_length.unwrap_or_default();
1429    ///   // Use as a stream
1430    ///   stream.try_next().await.unwrap().unwrap();
1431    ///   // Use the underlying stream
1432    ///   let mut stream = stream.stream;
1433    /// };
1434    /// ```
1435    pub async fn pull_blob_stream(
1436        &self,
1437        image: &Reference,
1438        layer: impl AsLayerDescriptor,
1439    ) -> Result<SizedStream> {
1440        stream_from_response(
1441            self.pull_blob_response(image, &layer, None, None).await?,
1442            layer,
1443            true,
1444        )
1445        .await
1446    }
1447
1448    /// Stream a single layer from an OCI registry starting with a byte offset. This can be used to
1449    /// continue downloading a layer after a network error. Please note that when doing a partial
1450    /// download (meaning it returns the [`BlobResponse::Partial`] variant), the layer digest is not
1451    /// verified as all the bytes are not available. The returned blob response will contain the
1452    /// header from the request digest, if it was set, that can be used (in addition to the digest
1453    /// from the layer) to verify the blob once all the bytes have been downloaded. Failure to do
1454    /// this means your content will not be verified.
1455    ///
1456    /// Returns [`BlobResponse`] which indicates if the response was a full or partial response.
1457    pub async fn pull_blob_stream_partial(
1458        &self,
1459        image: &Reference,
1460        layer: impl AsLayerDescriptor,
1461        offset: u64,
1462        length: Option<u64>,
1463    ) -> Result<BlobResponse> {
1464        let response = self
1465            .pull_blob_response(image, &layer, Some(offset), length)
1466            .await?;
1467
1468        let status = response.status();
1469        match status {
1470            StatusCode::OK => Ok(BlobResponse::Full(
1471                stream_from_response(response, &layer, true).await?,
1472            )),
1473            StatusCode::PARTIAL_CONTENT => Ok(BlobResponse::Partial(
1474                stream_from_response(response, &layer, false).await?,
1475            )),
1476            _ => {
1477                let url = response.url().to_string();
1478                let body = response.bytes().await?;
1479                Err(validate_registry_response(status, &body, &url).expect_err("validate_registry_response should return an error for non-success status codes"))
1480            }
1481        }
1482    }
1483
1484    /// Pull a single layer from an OCI registry.
1485    async fn pull_blob_response(
1486        &self,
1487        image: &Reference,
1488        layer: impl AsLayerDescriptor,
1489        offset: Option<u64>,
1490        length: Option<u64>,
1491    ) -> Result<Response> {
1492        let layer = layer.as_layer_descriptor();
1493        let url = self.to_v2_blob_url(image, layer.digest);
1494
1495        let mut request = RequestBuilderWrapper::from_client(self, |client| client.get(&url))
1496            .apply_accept(MIME_TYPES_DISTRIBUTION_MANIFEST)?
1497            .apply_auth(image, RegistryOperation::Pull)
1498            .await?
1499            .into_request_builder();
1500        if let (Some(off), Some(len)) = (offset, length) {
1501            let end = (off + len).saturating_sub(1);
1502            request = request.header(
1503                RANGE,
1504                HeaderValue::from_str(&format!("bytes={off}-{end}")).unwrap(),
1505            );
1506        } else if let Some(offset) = offset {
1507            request = request.header(
1508                RANGE,
1509                HeaderValue::from_str(&format!("bytes={offset}-")).unwrap(),
1510            );
1511        }
1512        let mut response = request.send().await?;
1513
1514        if let Some(urls) = &layer.urls {
1515            for url in urls {
1516                if response.error_for_status_ref().is_ok() {
1517                    break;
1518                }
1519
1520                let url = Url::parse(url)
1521                    .map_err(|e| OciDistributionError::UrlParseError(e.to_string()))?;
1522
1523                if url.scheme() == "http" || url.scheme() == "https" {
1524                    // NOTE: we must not authenticate on additional URLs as those
1525                    // can be abused to leak credentials or tokens.  Please
1526                    // refer to CVE-2020-15157 for more information.
1527                    request =
1528                        RequestBuilderWrapper::from_client(self, |client| client.get(url.clone()))
1529                            .apply_accept(MIME_TYPES_DISTRIBUTION_MANIFEST)?
1530                            .into_request_builder();
1531                    if let Some(offset) = offset {
1532                        request = request.header(
1533                            RANGE,
1534                            HeaderValue::from_str(&format!("bytes={offset}-")).unwrap(),
1535                        );
1536                    }
1537                    response = request.send().await?
1538                }
1539            }
1540        }
1541
1542        Ok(response)
1543    }
1544
1545    /// Begins a session to push an image to registry in a monolithical way
1546    ///
1547    /// Returns URL with session UUID
1548    async fn begin_push_monolithical_session(&self, image: &Reference) -> Result<String> {
1549        let url = &self.to_v2_blob_upload_url(image);
1550        debug!(?url, "begin_push_monolithical_session");
1551        let res = RequestBuilderWrapper::from_client(self, |client| client.post(url))
1552            .apply_auth(image, RegistryOperation::Push)
1553            .await?
1554            .into_request_builder()
1555            // We set "Content-Length" to 0 here even though the OCI Distribution
1556            // spec does not strictly require that. In practice we have seen that
1557            // certain registries require "Content-Length" to be present for all
1558            // types of push sessions.
1559            .header("Content-Length", 0)
1560            .send()
1561            .await?;
1562
1563        // OCI spec requires the status code be 202 Accepted to successfully begin the push process
1564        self.extract_location_header(image, res, &reqwest::StatusCode::ACCEPTED)
1565            .await
1566    }
1567
1568    /// Begins a session to push an image to registry as a series of chunks
1569    ///
1570    /// Returns URL with session UUID
1571    async fn begin_push_chunked_session(&self, image: &Reference) -> Result<String> {
1572        let url = &self.to_v2_blob_upload_url(image);
1573        debug!(?url, "begin_push_session");
1574        let res = RequestBuilderWrapper::from_client(self, |client| client.post(url))
1575            .apply_auth(image, RegistryOperation::Push)
1576            .await?
1577            .into_request_builder()
1578            .header("Content-Length", 0)
1579            .send()
1580            .await?;
1581
1582        // OCI spec requires the status code be 202 Accepted to successfully begin the push process
1583        self.extract_location_header(image, res, &reqwest::StatusCode::ACCEPTED)
1584            .await
1585    }
1586
1587    /// Closes the chunked push session
1588    ///
1589    /// Returns the pullable URL for the image
1590    async fn end_push_chunked_session(
1591        &self,
1592        location: &str,
1593        image: &Reference,
1594        digest: &str,
1595    ) -> Result<String> {
1596        let url = Url::parse_with_params(location, &[("digest", digest)])
1597            .map_err(|e| OciDistributionError::GenericError(Some(e.to_string())))?;
1598        let res = RequestBuilderWrapper::from_client(self, |client| client.put(url.clone()))
1599            .apply_auth(image, RegistryOperation::Push)
1600            .await?
1601            .into_request_builder()
1602            .header("Content-Length", 0)
1603            .send()
1604            .await?;
1605        self.extract_location_header(image, res, &reqwest::StatusCode::CREATED)
1606            .await
1607    }
1608
1609    /// Pushes a layer to a registry as a monolithical blob.
1610    ///
1611    /// Returns the URL location for the next layer
1612    async fn push_stream_monolithically(
1613        &self,
1614        location: &str,
1615        image: &Reference,
1616        layer: impl Stream<Item = Result<bytes::Bytes>> + Send + 'static,
1617        size: u64,
1618        blob_digest: &str,
1619    ) -> Result<String> {
1620        let mut url =
1621            Url::parse(location).map_err(|e| OciDistributionError::UrlParseError(e.to_string()))?;
1622        url.query_pairs_mut().append_pair("digest", blob_digest);
1623        let url = url.to_string();
1624
1625        debug!(size, location = ?url, "Pushing monolithically");
1626        let mut headers = HeaderMap::new();
1627        headers.insert(
1628            "Content-Length",
1629            format!("{}", size)
1630                .parse()
1631                .map_err(|e: reqwest::header::InvalidHeaderValue| {
1632                    OciDistributionError::GenericError(Some(e.to_string()))
1633                })?,
1634        );
1635        headers.insert("Content-Type", "application/octet-stream".parse().unwrap());
1636
1637        let res = RequestBuilderWrapper::from_client(self, |client| client.put(&url))
1638            .apply_auth(image, RegistryOperation::Push)
1639            .await?
1640            .into_request_builder()
1641            .headers(headers)
1642            .body(reqwest::Body::wrap_stream(layer))
1643            .send()
1644            .await?;
1645
1646        // Returns location
1647        self.extract_location_header(image, res, &reqwest::StatusCode::CREATED)
1648            .await
1649    }
1650
1651    /// Pushes a layer to a registry as a monolithical blob.
1652    ///
1653    /// Returns the URL location for the next layer
1654    async fn push_monolithically(
1655        &self,
1656        location: &str,
1657        image: &Reference,
1658        layer: impl Into<bytes::Bytes>,
1659        blob_digest: &str,
1660    ) -> Result<String> {
1661        let mut url = Url::parse(location).unwrap();
1662        url.query_pairs_mut().append_pair("digest", blob_digest);
1663        let url = url.to_string();
1664
1665        let layer = layer.into();
1666        debug!(size = layer.len(), location = ?url, "Pushing monolithically");
1667        if layer.is_empty() {
1668            return Err(OciDistributionError::PushNoDataError);
1669        };
1670        let mut headers = HeaderMap::new();
1671        headers.insert(
1672            "Content-Length",
1673            format!("{}", layer.len()).parse().unwrap(),
1674        );
1675        headers.insert("Content-Type", "application/octet-stream".parse().unwrap());
1676
1677        let res = RequestBuilderWrapper::from_client(self, |client| client.put(&url))
1678            .apply_auth(image, RegistryOperation::Push)
1679            .await?
1680            .into_request_builder()
1681            .headers(headers)
1682            .body(layer)
1683            .send()
1684            .await?;
1685
1686        // Returns location
1687        self.extract_location_header(image, res, &reqwest::StatusCode::CREATED)
1688            .await
1689    }
1690
1691    /// Pushes a single chunk of a blob to a registry, as part of a chunked blob upload.
1692    /// The caller is responsible for chunking the blob data into smaller parts, if needed.
1693    ///
1694    /// Returns the URL location for the next chunk, alongside the start of the next range to upload.
1695    async fn push_chunk(
1696        &self,
1697        location: &str,
1698        image: &Reference,
1699        blob_chunk: bytes::Bytes,
1700        range_start: usize,
1701    ) -> Result<(String, usize)> {
1702        if blob_chunk.is_empty() {
1703            return Err(OciDistributionError::PushNoDataError);
1704        };
1705
1706        let chunk_size = blob_chunk.len();
1707        let end_range_inclusive = range_start + chunk_size - 1;
1708
1709        let mut headers = HeaderMap::new();
1710        headers.insert(
1711            "Content-Range",
1712            format!("{range_start}-{end_range_inclusive}")
1713                .parse()
1714                .unwrap(),
1715        );
1716
1717        headers.insert("Content-Length", format!("{chunk_size}").parse().unwrap());
1718        headers.insert("Content-Type", "application/octet-stream".parse().unwrap());
1719
1720        debug!(
1721            ?range_start,
1722            ?end_range_inclusive,
1723            chunk_size,
1724            ?location,
1725            ?headers,
1726            "Pushing chunk"
1727        );
1728
1729        let res = RequestBuilderWrapper::from_client(self, |client| client.patch(location))
1730            .apply_auth(image, RegistryOperation::Push)
1731            .await?
1732            .into_request_builder()
1733            .headers(headers)
1734            .body(blob_chunk)
1735            .send()
1736            .await?;
1737
1738        // Returns location for next chunk and the start byte for the next range
1739        Ok((
1740            self.extract_location_header(image, res, &reqwest::StatusCode::ACCEPTED)
1741                .await?,
1742            end_range_inclusive + 1,
1743        ))
1744    }
1745
1746    /// Sends `stream` to an already-open chunked upload session as a series of PATCH requests.
1747    ///
1748    /// `on_chunk` is invoked with each chunk right before it is sent, allowing callers to
1749    /// incrementally compute a digest or otherwise observe the data without buffering it.
1750    ///
1751    /// Returns the upload location to use for the final commit request, alongside the total
1752    /// number of bytes sent.
1753    async fn push_stream_chunks(
1754        &self,
1755        location: String,
1756        image: &Reference,
1757        blob_data_stream: impl Stream<Item = Result<bytes::Bytes>> + Send + 'static,
1758        mut on_chunk: impl FnMut(&bytes::Bytes),
1759    ) -> Result<(String, u64)> {
1760        let mut location = location;
1761        let mut range_start = 0;
1762        let mut size = 0u64;
1763
1764        let mut blob_data_stream = pin!(blob_data_stream);
1765
1766        while let Some(blob_data) = blob_data_stream.next().await {
1767            let mut blob_data = blob_data?;
1768            while !blob_data.is_empty() {
1769                let chunk = blob_data.split_to(self.push_chunk_size.min(blob_data.len()));
1770                size += chunk.len() as u64;
1771                on_chunk(&chunk);
1772                (location, range_start) = self
1773                    .push_chunk(&location, image, chunk, range_start)
1774                    .await?;
1775            }
1776        }
1777
1778        Ok((location, size))
1779    }
1780
1781    /// Mounts a blob to the provided reference, from the given source
1782    pub async fn mount_blob(
1783        &self,
1784        image: &Reference,
1785        source: &Reference,
1786        digest: &str,
1787    ) -> Result<()> {
1788        let base_url = self.to_v2_blob_upload_url(image);
1789        let url = Url::parse_with_params(
1790            &base_url,
1791            &[("mount", digest), ("from", source.repository())],
1792        )
1793        .map_err(|e| OciDistributionError::UrlParseError(e.to_string()))?;
1794
1795        let res = RequestBuilderWrapper::from_client(self, |client| client.post(url.clone()))
1796            .apply_auth(image, RegistryOperation::Push)
1797            .await?
1798            .into_request_builder()
1799            .send()
1800            .await?;
1801
1802        self.extract_location_header(image, res, &reqwest::StatusCode::CREATED)
1803            .await?;
1804
1805        Ok(())
1806    }
1807
1808    /// Pushes the manifest for a specified image
1809    ///
1810    /// Returns pullable manifest URL
1811    pub async fn push_manifest(&self, image: &Reference, manifest: &OciManifest) -> Result<String> {
1812        let mut headers = HeaderMap::new();
1813        let content_type = manifest.content_type();
1814        headers.insert("Content-Type", content_type.parse().unwrap());
1815
1816        // Serialize the manifest with a canonical json formatter, as described at
1817        // https://github.com/opencontainers/image-spec/blob/main/considerations.md#json
1818        let mut body = Vec::new();
1819        let mut ser = serde_json::Serializer::with_formatter(&mut body, CanonicalFormatter::new());
1820        manifest.serialize(&mut ser).unwrap();
1821
1822        self.push_manifest_raw(image, body, manifest.content_type().parse().unwrap())
1823            .await
1824    }
1825
1826    /// Pushes the manifest, provided as raw bytes, for a specified image
1827    ///
1828    /// Returns pullable manifest url
1829    pub async fn push_manifest_raw(
1830        &self,
1831        image: &Reference,
1832        body: impl Into<bytes::Bytes>,
1833        content_type: HeaderValue,
1834    ) -> Result<String> {
1835        let url = self.to_v2_manifest_url(image);
1836        debug!(?url, ?content_type, "push manifest");
1837
1838        let mut headers = HeaderMap::new();
1839        headers.insert("Content-Type", content_type);
1840
1841        let body = body.into();
1842
1843        // Calculate the digest of the manifest, this is useful
1844        // if the remote registry is violating the OCI Distribution Specification.
1845        // See below for more details.
1846        let manifest_hash = sha256_digest(&body);
1847
1848        let res = RequestBuilderWrapper::from_client(self, |client| client.put(url.clone()))
1849            .apply_auth(image, RegistryOperation::Push)
1850            .await?
1851            .into_request_builder()
1852            .headers(headers)
1853            .body(body)
1854            .send()
1855            .await?;
1856
1857        let ret = self
1858            .extract_location_header(image, res, &reqwest::StatusCode::CREATED)
1859            .await;
1860
1861        if matches!(ret, Err(OciDistributionError::RegistryNoLocationError)) {
1862            // The registry is violating the OCI Distribution Spec, BUT the OCI
1863            // image/artifact has been uploaded successfully.
1864            // The `Location` header contains the sha256 digest of the manifest,
1865            // we can reuse the value we calculated before.
1866            // The workaround is there because repositories such as
1867            // AWS ECR are violating this aspect of the spec. This at least let the
1868            // oci-distribution users interact with these registries.
1869            warn!("Registry is not respecting the OCI Distribution Specification: it didn't return the Location of the uploaded Manifest inside of the response headers. Working around this issue...");
1870
1871            let url_base = url
1872                .strip_suffix(image.tag().unwrap_or("latest"))
1873                .expect("The manifest URL always ends with the image tag suffix");
1874            let url_by_digest = format!("{url_base}{manifest_hash}");
1875
1876            return Ok(url_by_digest);
1877        }
1878
1879        ret
1880    }
1881
1882    /// Pulls the referrers for the given image filtering by the optionally provided artifact type.
1883    ///
1884    /// Implements the [OCI Distribution Spec referrers API][oci-referrers] with an automatic
1885    /// fallback to the [referrers tag schema][oci-tag-schema] when the registry returns a
1886    /// `404 Not Found` for the native endpoint (as required by the spec).
1887    ///
1888    /// Many registries (e.g. ghcr.io) do not implement the native
1889    /// `/v2/<name>/referrers/<digest>` endpoint and return 404 instead. The OCI spec
1890    /// defines a fallback: the referrers index is stored as a regular OCI Image Index
1891    /// under a tag derived from the subject digest by replacing `:` with `-`
1892    /// (e.g. `sha256:abc…` → tag `sha256-abc…`).
1893    ///
1894    /// When the fallback is used, `artifact_type` filtering is applied client-side,
1895    /// since the tag schema stores a single unfiltered index with no query-parameter
1896    /// support.
1897    ///
1898    /// If both the native API and the tag schema fail, an empty `OciImageIndex` is
1899    /// returned, as per the spec recommendation.
1900    ///
1901    /// [oci-referrers]: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-referrers
1902    /// [oci-tag-schema]: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#referrers-tag-schema
1903    pub async fn pull_referrers(
1904        &self,
1905        image: &Reference,
1906        artifact_type: Option<&str>,
1907    ) -> Result<OciImageIndex> {
1908        let url = self.to_v2_referrers_url(image, artifact_type)?;
1909        debug!("Pulling referrers from {}", url);
1910
1911        let res = RequestBuilderWrapper::from_client(self, |client| client.get(&url))
1912            .apply_accept(MIME_TYPES_DISTRIBUTION_MANIFEST)?
1913            .apply_auth(image, RegistryOperation::Pull)
1914            .await?
1915            .into_request_builder()
1916            .send()
1917            .await?;
1918        let status = res.status();
1919        let body = res.bytes().await?;
1920
1921        // Per the OCI Distribution Spec, a 404 on the native referrers endpoint means the
1922        // registry does not support it; fall back to the referrers tag schema.
1923        if status == reqwest::StatusCode::NOT_FOUND {
1924            debug!(
1925                url = %url,
1926                "Native referrers API returned 404; falling back to OCI referrers tag schema"
1927            );
1928            return self
1929                .pull_referrers_via_tag_schema(image, artifact_type)
1930                .await;
1931        }
1932
1933        validate_registry_response(status, &body, &url)?;
1934        let manifest = serde_json::from_slice(&body)
1935            .map_err(|e| OciDistributionError::ManifestParsingError(e.to_string()))?;
1936
1937        Ok(manifest)
1938    }
1939
1940    /// Pulls the referrers index using the OCI referrers tag schema fallback.
1941    ///
1942    /// The tag is the subject digest with `:` replaced by `-`
1943    /// (e.g. `sha256:abc…` → `sha256-abc…`).
1944    ///
1945    /// If `artifact_type` is provided, the returned index is filtered client-side
1946    /// to include only entries whose `artifact_type` matches.
1947    ///
1948    /// If the tag does not exist or does not contain a valid image index, an empty
1949    /// `OciImageIndex` is returned as per the OCI spec recommendation.
1950    async fn pull_referrers_via_tag_schema(
1951        &self,
1952        image: &Reference,
1953        artifact_type: Option<&str>,
1954    ) -> Result<OciImageIndex> {
1955        let digest = image.digest().ok_or_else(|| {
1956            OciDistributionError::GenericError(Some(
1957                "Getting referrers for a tag is not supported".into(),
1958            ))
1959        })?;
1960
1961        let fallback_tag = digest.replace(':', "-");
1962        let fallback_ref = Reference::with_tag(
1963            image.resolve_registry().to_string(),
1964            image.repository().to_string(),
1965            fallback_tag.clone(),
1966        );
1967
1968        debug!(
1969            tag = %fallback_tag,
1970            "Pulling referrers via tag schema"
1971        );
1972
1973        let manifest = match self._pull_manifest(&fallback_ref).await {
1974            Ok((manifest, _digest)) => manifest,
1975            Err(e) => match &e {
1976                OciDistributionError::ImageManifestNotFoundError(_)
1977                | OciDistributionError::RegistryError { .. }
1978                | OciDistributionError::ServerError { code: 404, .. } => {
1979                    debug!(
1980                        error = ?e,
1981                        "Referrers tag schema not found; assuming no referrers"
1982                    );
1983                    return Ok(empty_image_index());
1984                }
1985                _ => return Err(e),
1986            },
1987        };
1988
1989        let mut index = match manifest {
1990            OciManifest::ImageIndex(idx) => idx,
1991            OciManifest::Image(_) => {
1992                return Err(OciDistributionError::SpecViolationError(format!(
1993                    "referrers tag schema: tag '{fallback_tag}' contains an Image manifest; \
1994                     expected an OCI Image Index"
1995                )));
1996            }
1997        };
1998
1999        // Apply client-side artifact_type filtering when requested, since the tag
2000        // schema stores a single unfiltered index.
2001        if let Some(at) = artifact_type {
2002            index.manifests.retain(|entry| {
2003                entry
2004                    .artifact_type
2005                    .as_deref()
2006                    .map(|t| t == at)
2007                    .unwrap_or(false)
2008            });
2009        }
2010
2011        Ok(index)
2012    }
2013
2014    /// Lists available repositories in the registry.
2015    ///
2016    /// Implements the OCI Distribution Spec catalog endpoint (`/v2/_catalog`).
2017    /// Supports pagination via `n` (page size) and `last` (last repo from
2018    /// previous page).
2019    pub async fn catalog(
2020        &self,
2021        image: &Reference,
2022        auth: &RegistryAuth,
2023        n: Option<usize>,
2024        last: Option<&str>,
2025    ) -> Result<CatalogResponse> {
2026        let op = RegistryOperation::Pull;
2027        let url = self.to_catalog_url(image);
2028
2029        self.store_auth_if_needed(image.resolve_registry(), auth)
2030            .await;
2031
2032        let request = self.client.get(&url);
2033        let request = if let Some(num) = n {
2034            request.query(&[("n", num)])
2035        } else {
2036            request
2037        };
2038        let request = if let Some(l) = last {
2039            request.query(&[("last", l)])
2040        } else {
2041            request
2042        };
2043        let request = RequestBuilderWrapper {
2044            client: self,
2045            request_builder: request,
2046        };
2047        let res = request
2048            .apply_auth(image, op)
2049            .await?
2050            .into_request_builder()
2051            .send()
2052            .await?;
2053        let status = res.status();
2054        let body = res.bytes().await?;
2055
2056        validate_registry_response(status, &body, &url)?;
2057
2058        Ok(serde_json::from_str(std::str::from_utf8(&body)?)?)
2059    }
2060
2061    async fn extract_location_header(
2062        &self,
2063        image: &Reference,
2064        res: reqwest::Response,
2065        expected_status: &reqwest::StatusCode,
2066    ) -> Result<String> {
2067        debug!(expected_status_code=?expected_status.as_u16(),
2068            status_code=?res.status().as_u16(),
2069            "extract location header");
2070        if res.status().eq(expected_status) {
2071            let location_header = res.headers().get("Location");
2072            debug!(location=?location_header, "Location header");
2073            match location_header {
2074                None => Err(OciDistributionError::RegistryNoLocationError),
2075                Some(lh) => self.location_header_to_url(image, lh),
2076            }
2077        } else if res.status().is_success() && expected_status.is_success() {
2078            Err(OciDistributionError::SpecViolationError(format!(
2079                "Expected HTTP Status {}, got {} instead",
2080                expected_status,
2081                res.status(),
2082            )))
2083        } else {
2084            let url = res.url().to_string();
2085            let code = res.status().as_u16();
2086            let message = res.text().await?;
2087            Err(OciDistributionError::ServerError { url, code, message })
2088        }
2089    }
2090
2091    /// Helper function to convert location header to URL
2092    ///
2093    /// Location may be absolute (containing the protocol and/or hostname), or relative (containing just the URL path)
2094    /// Returns a properly formatted absolute URL
2095    ///
2096    /// An absolute location pointing at a different host is returned as is and
2097    /// will be followed, but requests to it are never authenticated (see
2098    /// [`RequestBuilderWrapper::apply_auth`]).
2099    fn location_header_to_url(
2100        &self,
2101        image: &Reference,
2102        location_header: &reqwest::header::HeaderValue,
2103    ) -> Result<String> {
2104        let lh = location_header.to_str()?;
2105        if lh.starts_with("/") {
2106            let registry = image.resolve_registry();
2107            Ok(format!(
2108                "{scheme}://{registry}{lh}",
2109                scheme = self.config.protocol.scheme_for(registry)
2110            ))
2111        } else {
2112            Ok(lh.to_string())
2113        }
2114    }
2115
2116    /// Convert a Reference to a v2 manifest URL.
2117    fn to_v2_manifest_url(&self, reference: &Reference) -> String {
2118        let registry = reference.resolve_registry();
2119        format!(
2120            "{scheme}://{registry}/v2/{repository}/manifests/{reference}{ns}",
2121            scheme = self.config.protocol.scheme_for(registry),
2122            repository = reference.repository(),
2123            reference = if let Some(digest) = reference.digest() {
2124                digest
2125            } else {
2126                reference.tag().unwrap_or("latest")
2127            },
2128            ns = reference
2129                .namespace()
2130                .map(|ns| format!("?ns={ns}"))
2131                .unwrap_or_default(),
2132        )
2133    }
2134
2135    /// Convert a Reference to a v2 blob (layer) URL.
2136    fn to_v2_blob_url(&self, reference: &Reference, digest: &str) -> String {
2137        let registry = reference.resolve_registry();
2138        format!(
2139            "{scheme}://{registry}/v2/{repository}/blobs/{digest}{ns}",
2140            scheme = self.config.protocol.scheme_for(registry),
2141            repository = reference.repository(),
2142            ns = reference
2143                .namespace()
2144                .map(|ns| format!("?ns={ns}"))
2145                .unwrap_or_default(),
2146        )
2147    }
2148
2149    /// Convert a Reference to a v2 blob upload URL.
2150    fn to_v2_blob_upload_url(&self, reference: &Reference) -> String {
2151        self.to_v2_blob_url(reference, "uploads/")
2152    }
2153
2154    fn to_list_tags_url(&self, reference: &Reference) -> String {
2155        let registry = reference.resolve_registry();
2156        format!(
2157            "{scheme}://{registry}/v2/{repository}/tags/list{ns}",
2158            scheme = self.config.protocol.scheme_for(registry),
2159            repository = reference.repository(),
2160            ns = reference
2161                .namespace()
2162                .map(|ns| format!("?ns={ns}"))
2163                .unwrap_or_default(),
2164        )
2165    }
2166
2167    fn to_catalog_url(&self, reference: &Reference) -> String {
2168        let registry = reference.resolve_registry();
2169        format!(
2170            "{scheme}://{registry}/v2/_catalog",
2171            scheme = self.config.protocol.scheme_for(registry),
2172        )
2173    }
2174
2175    /// Convert a Reference to a v2 referrers URL.
2176    fn to_v2_referrers_url(
2177        &self,
2178        reference: &Reference,
2179        artifact_type: Option<&str>,
2180    ) -> Result<String> {
2181        let digest = reference.digest().ok_or_else(|| {
2182            OciDistributionError::GenericError(Some(
2183                "Getting referrers for a tag is not supported".into(),
2184            ))
2185        })?;
2186
2187        let registry = reference.resolve_registry();
2188        let base = format!(
2189            "{scheme}://{registry}",
2190            scheme = self.config.protocol.scheme_for(registry),
2191        );
2192        let mut url =
2193            Url::parse(&base).map_err(|e| OciDistributionError::UrlParseError(e.to_string()))?;
2194        url.path_segments_mut()
2195            .map_err(|_| {
2196                OciDistributionError::GenericError(Some(
2197                    "cannot build referrers URL: base URL is cannot-be-a-base".into(),
2198                ))
2199            })?
2200            .push("v2")
2201            .extend(reference.repository().split('/'))
2202            .push("referrers")
2203            .push(digest);
2204        if let Some(at) = artifact_type {
2205            url.query_pairs_mut().append_pair("artifactType", at);
2206        }
2207        Ok(url.into())
2208    }
2209}
2210
2211/// The OCI spec technically does not allow any codes but 200, 500, 401, and 404.
2212/// Obviously, HTTP servers are going to send other codes. This tries to catch the
2213/// obvious ones (200, 4XX, 5XX). Anything else is just treated as an error.
2214fn validate_registry_response(status: reqwest::StatusCode, body: &[u8], url: &str) -> Result<()> {
2215    match status {
2216        reqwest::StatusCode::OK => Ok(()),
2217        reqwest::StatusCode::UNAUTHORIZED => Err(OciDistributionError::UnauthorizedError {
2218            url: url.to_string(),
2219        }),
2220        s if s.is_success() => Err(OciDistributionError::SpecViolationError(format!(
2221            "Expected HTTP Status {}, got {} instead",
2222            reqwest::StatusCode::OK,
2223            status,
2224        ))),
2225        s if s.is_client_error() => {
2226            match serde_json::from_slice::<OciEnvelope>(body) {
2227                // According to the OCI spec, we should see an error in the message body.
2228                Ok(envelope) => Err(OciDistributionError::RegistryError {
2229                    envelope,
2230                    url: url.to_string(),
2231                }),
2232                // Fall back to a plain server error if the body isn't a valid `OciEnvelope`
2233                Err(_) => Err(OciDistributionError::ServerError {
2234                    code: s.as_u16(),
2235                    url: url.to_string(),
2236                    message: String::from_utf8_lossy(body).to_string(),
2237                }),
2238            }
2239        }
2240        // Catch-all for any remaining status: mostly 5xx, but also 1xx, 3xx and non-standard codes.
2241        // Use a lossy conversion so a non UTF-8 body doesn't hide the status code
2242        s => Err(OciDistributionError::ServerError {
2243            code: s.as_u16(),
2244            url: url.to_string(),
2245            message: String::from_utf8_lossy(body).to_string(),
2246        }),
2247    }
2248}
2249
2250/// Returns an empty OCI Image Index, as used when no referrers exist.
2251fn empty_image_index() -> OciImageIndex {
2252    OciImageIndex {
2253        schema_version: 2,
2254        media_type: Some(crate::manifest::OCI_IMAGE_INDEX_MEDIA_TYPE.to_string()),
2255        artifact_type: None,
2256        annotations: None,
2257        manifests: vec![],
2258    }
2259}
2260
2261/// Converts a response into a stream
2262async fn stream_from_response(
2263    response: Response,
2264    layer: impl AsLayerDescriptor,
2265    verify: bool,
2266) -> Result<SizedStream> {
2267    let status = response.status();
2268    let url = response.url().to_string();
2269    let content_length = response.content_length();
2270    let headers = response.headers().clone();
2271    if !status.is_success() {
2272        let body = response.bytes().await?;
2273        return Err(validate_registry_response(status, &body, &url).expect_err(
2274            "validate_registry_response should return an error for non-success status codes",
2275        ));
2276    }
2277    let stream = response.bytes_stream().map_err(std::io::Error::other);
2278
2279    let expected_layer_digest = layer.as_layer_descriptor().digest.to_string();
2280    let layer_digester = Digester::new(&expected_layer_digest)?;
2281    let header_digester_and_digest = match digest_header_value(headers)? {
2282        // If the digests match, we don't need to do both digesters
2283        Some(digest) if digest == expected_layer_digest => None,
2284        Some(digest) => Some((Digester::new(&digest)?, digest)),
2285        None => None,
2286    };
2287    let header_digest = header_digester_and_digest
2288        .as_ref()
2289        .map(|(_, digest)| digest.to_owned());
2290    let stream: BoxStream<'static, std::result::Result<bytes::Bytes, std::io::Error>> = if verify {
2291        Box::pin(VerifyingStream::new(
2292            Box::pin(stream),
2293            layer_digester,
2294            expected_layer_digest,
2295            header_digester_and_digest,
2296        ))
2297    } else {
2298        Box::pin(stream)
2299    };
2300    Ok(SizedStream {
2301        content_length,
2302        digest_header_value: header_digest,
2303        stream,
2304    })
2305}
2306
2307/// The request builder wrapper allows to be instantiated from a
2308/// `Client` and allows composable operations on the request builder,
2309/// to produce a `RequestBuilder` object that can be executed.
2310struct RequestBuilderWrapper<'a> {
2311    client: &'a Client,
2312    request_builder: RequestBuilder,
2313}
2314
2315// RequestBuilderWrapper type management
2316impl<'a> RequestBuilderWrapper<'a> {
2317    /// Create a `RequestBuilderWrapper` from a `Client` instance, by
2318    /// instantiating the internal `RequestBuilder` with the provided
2319    /// function `f`.
2320    fn from_client(
2321        client: &'a Client,
2322        f: impl Fn(&reqwest::Client) -> RequestBuilder,
2323    ) -> RequestBuilderWrapper<'a> {
2324        let request_builder = f(&client.client);
2325        RequestBuilderWrapper {
2326            client,
2327            request_builder,
2328        }
2329    }
2330
2331    // Produces a final `RequestBuilder` out of this `RequestBuilderWrapper`
2332    fn into_request_builder(self) -> RequestBuilder {
2333        self.request_builder
2334    }
2335}
2336
2337// Composable functions applicable to a `RequestBuilderWrapper`
2338impl<'a> RequestBuilderWrapper<'a> {
2339    /// Returns a clone of the inner `RequestBuilder`.
2340    ///
2341    /// Cloning fails if the request has a streaming body, which is never the
2342    /// case here since bodies are attached after the wrapper is consumed.
2343    fn cloned_request_builder(&self) -> Result<RequestBuilder> {
2344        self.request_builder.try_clone().ok_or_else(|| {
2345            OciDistributionError::GenericError(Some("could not clone request builder".to_string()))
2346        })
2347    }
2348
2349    fn apply_accept(&self, accept: &[&str]) -> Result<RequestBuilderWrapper<'_>> {
2350        let request_builder = self
2351            .cloned_request_builder()?
2352            .header("Accept", Vec::from(accept).join(", "));
2353
2354        Ok(RequestBuilderWrapper {
2355            client: self.client,
2356            request_builder,
2357        })
2358    }
2359
2360    /// Returns whether the request being built is addressed to the registry the
2361    /// credentials of `image` belong to.
2362    ///
2363    /// The upload `Location` returned by a registry may be absolute and point at
2364    /// another host (e.g. a signed URL of a cloud storage provider), which the
2365    /// distribution specification permits. Sending the `Authorization` header
2366    /// there is what it does not permit: clients "MUST NOT forward Authorization
2367    /// headers across host boundaries unless explicitly configured to do so".
2368    /// See also CVE-2020-15157.
2369    ///
2370    /// A same-host location that drops back from https to http is treated the
2371    /// same way, since the credentials would otherwise go out in the clear.
2372    fn targets_credential_registry(&self, image: &Reference) -> Result<bool> {
2373        let request = self.cloned_request_builder()?.build()?;
2374        let target = request.url();
2375
2376        let registry = image.resolve_registry();
2377        let registry_url = Url::parse(&format!(
2378            "{scheme}://{registry}",
2379            scheme = self.client.config.protocol.scheme_for(registry)
2380        ))
2381        .map_err(|e| OciDistributionError::UrlParseError(e.to_string()))?;
2382
2383        if target.host_str() != registry_url.host_str() {
2384            return Ok(false);
2385        }
2386        if target.port_or_known_default() != registry_url.port_or_known_default() {
2387            return Ok(false);
2388        }
2389        // The registry is reached over https, the credentials must not go out
2390        // in the clear.
2391        if registry_url.scheme() == "https" && target.scheme() != "https" {
2392            return Ok(false);
2393        }
2394
2395        Ok(true)
2396    }
2397
2398    /// Updates request as necessary for authentication.
2399    ///
2400    /// If the struct has Some(bearer), this will insert the bearer token in an
2401    /// Authorization header. It will also set the Accept header, which must
2402    /// be set on all OCI Registry requests. If the struct has HTTP Basic Auth
2403    /// credentials, these will be configured.
2404    ///
2405    /// Requests addressed to a host other than the registry the credentials
2406    /// belong to are left unauthenticated, see
2407    /// [`Self::targets_credential_registry`].
2408    async fn apply_auth(
2409        &self,
2410        image: &Reference,
2411        op: RegistryOperation,
2412    ) -> Result<RequestBuilderWrapper<'_>> {
2413        // NOTE: we must not authenticate requests addressed outside of the
2414        // registry, as those can be abused to leak credentials or tokens.
2415        // Please refer to CVE-2020-15157 for more information.
2416        if !self.targets_credential_registry(image)? {
2417            debug!(
2418                registry = image.resolve_registry(),
2419                "Not authenticating a request addressed outside of the registry"
2420            );
2421            return Ok(RequestBuilderWrapper {
2422                client: self.client,
2423                request_builder: self.cloned_request_builder()?,
2424            });
2425        }
2426
2427        let mut headers = HeaderMap::new();
2428        if let Some(token) = self.client.get_auth_token(image, op).await {
2429            match token {
2430                RegistryTokenType::Bearer(token) => {
2431                    debug!("Using bearer token authentication.");
2432                    headers.insert("Authorization", token.bearer_token().parse().unwrap());
2433                }
2434                RegistryTokenType::Basic(username, password) => {
2435                    debug!("Using HTTP basic authentication.");
2436                    return Ok(RequestBuilderWrapper {
2437                        client: self.client,
2438                        request_builder: self
2439                            .cloned_request_builder()?
2440                            .headers(headers)
2441                            .basic_auth(username.to_string(), Some(password.to_string())),
2442                    });
2443                }
2444            }
2445        }
2446        Ok(RequestBuilderWrapper {
2447            client: self.client,
2448            request_builder: self.cloned_request_builder()?.headers(headers),
2449        })
2450    }
2451}
2452
2453/// The encoding of the certificate
2454#[derive(Debug, Clone)]
2455pub enum CertificateEncoding {
2456    #[allow(missing_docs)]
2457    Der,
2458    #[allow(missing_docs)]
2459    Pem,
2460}
2461
2462/// A x509 certificate
2463#[derive(Debug, Clone)]
2464pub struct Certificate {
2465    /// Which encoding is used by the certificate
2466    pub encoding: CertificateEncoding,
2467
2468    /// Actual certificate
2469    pub data: Vec<u8>,
2470}
2471
2472impl TryFrom<&Certificate> for reqwest::Certificate {
2473    type Error = OciDistributionError;
2474
2475    fn try_from(cert: &Certificate) -> Result<Self> {
2476        match cert.encoding {
2477            CertificateEncoding::Der => Ok(reqwest::Certificate::from_der(cert.data.as_slice())?),
2478            CertificateEncoding::Pem => Ok(reqwest::Certificate::from_pem(cert.data.as_slice())?),
2479        }
2480    }
2481}
2482
2483fn convert_certificates(certs: &[Certificate]) -> Result<Vec<reqwest::Certificate>> {
2484    certs.iter().map(reqwest::Certificate::try_from).collect()
2485}
2486
2487/// A client configuration
2488pub struct ClientConfig {
2489    /// Which protocol the client should use
2490    pub protocol: ClientProtocol,
2491
2492    /// Accept invalid hostname. Defaults to false
2493    #[cfg(feature = "native-tls")]
2494    pub accept_invalid_hostnames: bool,
2495
2496    /// Accept invalid certificates. Defaults to false
2497    pub accept_invalid_certificates: bool,
2498
2499    /// Use monolithic push for pushing blobs. Defaults to false
2500    pub use_monolithic_push: bool,
2501
2502    /// Use only the provided certificate roots.
2503    ///
2504    /// This option disables any native or built-in roots, and **only** uses
2505    /// the roots provided to this method.
2506    pub tls_certs_only: Vec<Certificate>,
2507
2508    /// A list of extra root certificate to trust. This can be used to connect
2509    /// to servers using self-signed certificates
2510    pub extra_root_certificates: Vec<Certificate>,
2511
2512    /// A function that defines the client's behaviour if an Image Index Manifest
2513    /// (i.e Manifest List) is encountered when pulling an image.
2514    /// Defaults to [current_platform_resolver],
2515    /// which attempts to choose an image matching the running OS and Arch.
2516    ///
2517    /// If set to None, an error is raised if an Image Index manifest is received
2518    /// during an image pull.
2519    pub platform_resolver: Option<Box<PlatformResolverFn>>,
2520
2521    /// Maximum number of concurrent uploads to perform during a `push`
2522    /// operation.
2523    ///
2524    /// This defaults to [`DEFAULT_MAX_CONCURRENT_UPLOAD`].
2525    pub max_concurrent_upload: usize,
2526
2527    /// Maximum number of concurrent downloads to perform during a `pull`
2528    /// operation.
2529    ///
2530    /// This defaults to [`DEFAULT_MAX_CONCURRENT_DOWNLOAD`].
2531    pub max_concurrent_download: usize,
2532
2533    /// Default token expiration in seconds, to use when the token claim
2534    /// doesn't provide a value.
2535    ///
2536    /// This defaults to [`DEFAULT_TOKEN_EXPIRATION_SECS`].
2537    pub default_token_expiration_secs: usize,
2538
2539    /// Enables a read timeout for the client.
2540    ///
2541    /// See [`reqwest::ClientBuilder::read_timeout`] for more information.
2542    pub read_timeout: Option<Duration>,
2543
2544    /// Set a timeout for the connect phase for the client.
2545    ///
2546    /// See [`reqwest::ClientBuilder::connect_timeout`] for more information.
2547    pub connect_timeout: Option<Duration>,
2548
2549    /// Set the `User-Agent` used by the client.
2550    ///
2551    /// This defaults to `oci-client/<version>` where `<version>` is the crate version.
2552    pub user_agent: &'static str,
2553
2554    /// Set the `HTTPS PROXY` used by the client.
2555    ///
2556    /// This defaults to `None`.
2557    pub https_proxy: Option<String>,
2558
2559    /// Set the `HTTP PROXY` used by the client.
2560    ///
2561    /// This defaults to `None`.
2562    pub http_proxy: Option<String>,
2563
2564    /// Set the `NO PROXY` used by the client.
2565    ///
2566    /// This defaults to `None`.
2567    pub no_proxy: Option<String>,
2568}
2569
2570impl Default for ClientConfig {
2571    fn default() -> Self {
2572        Self {
2573            protocol: ClientProtocol::default(),
2574            #[cfg(feature = "native-tls")]
2575            accept_invalid_hostnames: false,
2576            accept_invalid_certificates: false,
2577            use_monolithic_push: false,
2578            tls_certs_only: Vec::new(),
2579            extra_root_certificates: Vec::new(),
2580            platform_resolver: Some(Box::new(current_platform_resolver)),
2581            max_concurrent_upload: DEFAULT_MAX_CONCURRENT_UPLOAD,
2582            max_concurrent_download: DEFAULT_MAX_CONCURRENT_DOWNLOAD,
2583            default_token_expiration_secs: DEFAULT_TOKEN_EXPIRATION_SECS,
2584            read_timeout: None,
2585            connect_timeout: None,
2586            user_agent: DEFAULT_USER_AGENT,
2587            https_proxy: None,
2588            http_proxy: None,
2589            no_proxy: None,
2590        }
2591    }
2592}
2593
2594// Be explicit about the traits supported by this type. This is needed to use
2595// the Client behind a dynamic reference.
2596// Something similar to what is described here: https://users.rust-lang.org/t/how-to-send-function-closure-to-another-thread/43549
2597type PlatformResolverFn = dyn Fn(&[ImageIndexEntry]) -> Option<String> + Send + Sync;
2598
2599/// A platform resolver that chooses the first linux/amd64 variant, if present
2600pub fn linux_amd64_resolver(manifests: &[ImageIndexEntry]) -> Option<String> {
2601    manifests
2602        .iter()
2603        .find(|entry| {
2604            entry.platform.as_ref().is_some_and(|platform| {
2605                platform.os == Os::Linux && platform.architecture == Arch::Amd64
2606            })
2607        })
2608        .map(|entry| entry.digest.clone())
2609}
2610
2611/// A platform resolver that chooses the first windows/amd64 variant, if present
2612pub fn windows_amd64_resolver(manifests: &[ImageIndexEntry]) -> Option<String> {
2613    manifests
2614        .iter()
2615        .find(|entry| {
2616            entry.platform.as_ref().is_some_and(|platform| {
2617                platform.os == Os::Windows && platform.architecture == Arch::Amd64
2618            })
2619        })
2620        .map(|entry| entry.digest.clone())
2621}
2622
2623/// A platform resolver that chooses the first variant matching the running OS/Arch, if present.
2624/// Doesn't currently handle platform.variants.
2625pub fn current_platform_resolver(manifests: &[ImageIndexEntry]) -> Option<String> {
2626    manifests
2627        .iter()
2628        .find(|entry| {
2629            entry.platform.as_ref().is_some_and(|platform| {
2630                platform.os == Os::default() && platform.architecture == Arch::default()
2631            })
2632        })
2633        .map(|entry| entry.digest.clone())
2634}
2635
2636/// The protocol that the client should use to connect
2637#[derive(Debug, Clone, PartialEq, Eq, Default)]
2638pub enum ClientProtocol {
2639    #[allow(missing_docs)]
2640    Http,
2641    #[allow(missing_docs)]
2642    #[default]
2643    Https,
2644    #[allow(missing_docs)]
2645    HttpsExcept(Vec<String>),
2646}
2647
2648impl ClientProtocol {
2649    fn scheme_for(&self, registry: &str) -> &str {
2650        match self {
2651            ClientProtocol::Https => "https",
2652            ClientProtocol::Http => "http",
2653            ClientProtocol::HttpsExcept(exceptions) => {
2654                if exceptions.contains(&registry.to_owned()) {
2655                    "http"
2656                } else {
2657                    "https"
2658                }
2659            }
2660        }
2661    }
2662}
2663
2664#[derive(Clone, Debug)]
2665struct BearerChallenge {
2666    pub realm: Box<str>,
2667    pub service: Option<String>,
2668}
2669
2670impl TryFrom<&HeaderValue> for BearerChallenge {
2671    type Error = String;
2672
2673    fn try_from(value: &HeaderValue) -> std::result::Result<Self, Self::Error> {
2674        let parser = ChallengeParser::new(
2675            value
2676                .to_str()
2677                .map_err(|e| format!("cannot convert header value to string: {e:?}"))?,
2678        );
2679        parser
2680            .filter_map(|parser_res| {
2681                if let Ok(chalenge_ref) = parser_res {
2682                    let bearer_challenge = BearerChallenge::try_from(&chalenge_ref);
2683                    bearer_challenge.ok()
2684                } else {
2685                    None
2686                }
2687            })
2688            .next()
2689            .ok_or_else(|| "Cannot find Bearer challenge".to_string())
2690    }
2691}
2692
2693impl TryFrom<&ChallengeRef<'_>> for BearerChallenge {
2694    type Error = String;
2695
2696    fn try_from(value: &ChallengeRef<'_>) -> std::result::Result<Self, Self::Error> {
2697        if !value.scheme.eq_ignore_ascii_case("Bearer") {
2698            return Err(format!(
2699                "BearerChallenge doesn't support challenge scheme {:?}",
2700                value.scheme
2701            ));
2702        }
2703        let mut realm = None;
2704        let mut service = None;
2705        for (k, v) in &value.params {
2706            if k.eq_ignore_ascii_case("realm") {
2707                realm = Some(v.to_unescaped());
2708            }
2709
2710            if k.eq_ignore_ascii_case("service") {
2711                service = Some(v.to_unescaped());
2712            }
2713        }
2714
2715        let realm = realm.ok_or("missing required parameter realm")?;
2716
2717        Ok(BearerChallenge {
2718            realm: realm.into_boxed_str(),
2719            service,
2720        })
2721    }
2722}
2723
2724#[cfg(test)]
2725mod test {
2726    use super::*;
2727    use std::convert::TryFrom;
2728    use std::fs;
2729    use std::path;
2730    use std::result::Result;
2731
2732    use bytes::Bytes;
2733    use rstest::rstest;
2734    use tempfile::TempDir;
2735    use tokio::io::AsyncReadExt;
2736    use tokio_util::io::StreamReader;
2737
2738    use crate::errors::OciErrorCode;
2739    use crate::manifest::{self, IMAGE_DOCKER_LAYER_GZIP_MEDIA_TYPE};
2740
2741    #[test]
2742    fn test_validate_registry_response_server_error_non_utf8_body() {
2743        let err = validate_registry_response(
2744            reqwest::StatusCode::BAD_GATEWAY,
2745            &[0xff, 0xfe, b'o', b'k'],
2746            "https://example.com",
2747        )
2748        .expect_err("5xx should be an error");
2749        match err {
2750            OciDistributionError::ServerError { code, message, .. } => {
2751                assert_eq!(502, code);
2752                assert_eq!("\u{fffd}\u{fffd}ok", message);
2753            }
2754            e => panic!("expected ServerError, got {e:?}"),
2755        }
2756    }
2757
2758    #[test]
2759    fn test_validate_registry_response_unknown_error_code() {
2760        let err = validate_registry_response(
2761            reqwest::StatusCode::CONFLICT,
2762            br#"{"errors":[{"code":"ARTIFACT_LOCKED","message":"artifact is locked"}]}"#,
2763            "https://example.com",
2764        )
2765        .expect_err("4xx should be an error");
2766        match err {
2767            OciDistributionError::RegistryError { envelope, .. } => {
2768                assert_eq!(
2769                    OciErrorCode::Other("ARTIFACT_LOCKED".to_string()),
2770                    envelope.errors[0].code
2771                );
2772                assert_eq!("artifact is locked", envelope.errors[0].message);
2773            }
2774            e => panic!("expected RegistryError, got {e:?}"),
2775        }
2776    }
2777
2778    #[cfg(feature = "test-registry")]
2779    use testcontainers::{
2780        core::{Mount, WaitFor},
2781        runners::AsyncRunner,
2782        ContainerRequest, GenericImage, ImageExt,
2783    };
2784
2785    const HELLO_IMAGE_NO_TAG: &str = "webassembly.azurecr.io/hello-wasm";
2786    const HELLO_IMAGE_TAG: &str = "webassembly.azurecr.io/hello-wasm:v1";
2787    const HELLO_IMAGE_DIGEST: &str = "webassembly.azurecr.io/hello-wasm@sha256:51d9b231d5129e3ffc267c9d455c49d789bf3167b611a07ab6e4b3304c96b0e7";
2788    const HELLO_IMAGE_TAG_AND_DIGEST: &str = "webassembly.azurecr.io/hello-wasm:v1@sha256:51d9b231d5129e3ffc267c9d455c49d789bf3167b611a07ab6e4b3304c96b0e7";
2789    const TEST_IMAGES: &[&str] = &[
2790        // TODO(jlegrone): this image cannot be pulled currently because no `latest`
2791        //                 tag exists on the image repository. Re-enable this image
2792        //                 in tests once `latest` is published.
2793        // HELLO_IMAGE_NO_TAG,
2794        HELLO_IMAGE_TAG,
2795        HELLO_IMAGE_DIGEST,
2796        HELLO_IMAGE_TAG_AND_DIGEST,
2797    ];
2798    const GHCR_IO_IMAGE: &str = "ghcr.io/krustlet/oci-distribution/hello-wasm:v1";
2799    const DOCKER_IO_IMAGE: &str = "docker.io/library/hello-world@sha256:37a0b92b08d4919615c3ee023f7ddb068d12b8387475d64c622ac30f45c29c51";
2800    const HTPASSWD: &str = "testuser:$2y$05$8/q2bfRcX74EuxGf0qOcSuhWDQJXrgWiy6Fi73/JM2tKC66qSrLve";
2801    const HTPASSWD_USERNAME: &str = "testuser";
2802    const HTPASSWD_PASSWORD: &str = "testpassword";
2803
2804    const EMPTY_JSON_BLOB: &str = "{}";
2805    const EMPTY_JSON_DIGEST: &str =
2806        "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a";
2807
2808    #[test]
2809    fn test_apply_accept() -> anyhow::Result<()> {
2810        assert_eq!(
2811            RequestBuilderWrapper::from_client(&Client::default(), |client| client
2812                .get("https://example.com/some/module.wasm"))
2813            .apply_accept(&["*/*"])?
2814            .into_request_builder()
2815            .build()?
2816            .headers()["Accept"],
2817            "*/*"
2818        );
2819
2820        assert_eq!(
2821            RequestBuilderWrapper::from_client(&Client::default(), |client| client
2822                .get("https://example.com/some/module.wasm"))
2823            .apply_accept(MIME_TYPES_DISTRIBUTION_MANIFEST)?
2824            .into_request_builder()
2825            .build()?
2826            .headers()["Accept"],
2827            MIME_TYPES_DISTRIBUTION_MANIFEST.join(", ")
2828        );
2829
2830        Ok(())
2831    }
2832
2833    #[tokio::test]
2834    async fn test_apply_auth_no_token() -> anyhow::Result<()> {
2835        assert!(
2836            !RequestBuilderWrapper::from_client(&Client::default(), |client| client
2837                .get("https://example.com/some/module.wasm"))
2838            .apply_auth(
2839                &Reference::try_from(HELLO_IMAGE_TAG)?,
2840                RegistryOperation::Pull
2841            )
2842            .await?
2843            .into_request_builder()
2844            .build()?
2845            .headers()
2846            .contains_key("Authorization")
2847        );
2848
2849        Ok(())
2850    }
2851
2852    #[tokio::test]
2853    async fn test_apply_auth_bearer_token() -> anyhow::Result<()> {
2854        let _ = tracing_subscriber::fmt::try_init();
2855        let client = Client::default();
2856        // The token cache only reads the JWT payload; it never verifies signatures.
2857        let token = "e30.e30.signature".to_string();
2858
2859        // we have to have it in the stored auth so we'll get to the token cache check.
2860        client
2861            .store_auth(
2862                Reference::try_from(HELLO_IMAGE_TAG)?.resolve_registry(),
2863                RegistryAuth::Anonymous,
2864            )
2865            .await;
2866
2867        client
2868            .tokens
2869            .insert(
2870                &Reference::try_from(HELLO_IMAGE_TAG)?,
2871                RegistryOperation::Pull,
2872                RegistryTokenType::Bearer(RegistryToken::Token {
2873                    token: token.clone(),
2874                }),
2875            )
2876            .await;
2877
2878        assert_eq!(
2879            RequestBuilderWrapper::from_client(&client, |client| client
2880                .get("https://webassembly.azurecr.io/v2/hello-wasm/blobs/sha256:deadbeef"))
2881            .apply_auth(
2882                &Reference::try_from(HELLO_IMAGE_TAG)?,
2883                RegistryOperation::Pull
2884            )
2885            .await?
2886            .into_request_builder()
2887            .build()?
2888            .headers()["Authorization"],
2889            format!("Bearer {}", &token)
2890        );
2891
2892        // The token must not be sent to a host other than the registry it
2893        // belongs to.
2894        assert!(!RequestBuilderWrapper::from_client(&client, |client| client
2895            .get("https://example.com/some/module.wasm"))
2896        .apply_auth(
2897            &Reference::try_from(HELLO_IMAGE_TAG)?,
2898            RegistryOperation::Pull
2899        )
2900        .await?
2901        .into_request_builder()
2902        .build()?
2903        .headers()
2904        .contains_key("Authorization"));
2905
2906        Ok(())
2907    }
2908
2909    #[tokio::test]
2910    async fn test_apply_auth_basic_not_forwarded_cross_host() -> anyhow::Result<()> {
2911        let client = Client::default();
2912        let image = Reference::try_from(HELLO_IMAGE_TAG)?;
2913        client
2914            .store_auth(
2915                image.resolve_registry(),
2916                RegistryAuth::Basic("user".to_string(), "pass".to_string()),
2917            )
2918            .await;
2919        client
2920            .tokens
2921            .insert(
2922                &image,
2923                RegistryOperation::Push,
2924                RegistryTokenType::Basic("user".to_string(), "pass".to_string()),
2925            )
2926            .await;
2927
2928        assert!(RequestBuilderWrapper::from_client(&client, |client| client
2929            .patch("https://webassembly.azurecr.io/v2/hello-wasm/blobs/uploads/abc"))
2930        .apply_auth(&image, RegistryOperation::Push)
2931        .await?
2932        .into_request_builder()
2933        .build()?
2934        .headers()
2935        .contains_key("Authorization"));
2936
2937        assert!(!RequestBuilderWrapper::from_client(&client, |client| client
2938            .patch("https://elsewhere.example.com/upload/abc"))
2939        .apply_auth(&image, RegistryOperation::Push)
2940        .await?
2941        .into_request_builder()
2942        .build()?
2943        .headers()
2944        .contains_key("Authorization"));
2945
2946        Ok(())
2947    }
2948
2949    #[rstest]
2950    #[case::registry("https://webassembly.azurecr.io/v2/hello-wasm/blobs/uploads/abc", true)]
2951    #[case::explicit_default_port(
2952        "https://webassembly.azurecr.io:443/v2/hello-wasm/blobs/uploads/abc",
2953        true
2954    )]
2955    #[case::another_port(
2956        "https://webassembly.azurecr.io:8443/v2/hello-wasm/blobs/uploads/abc",
2957        false
2958    )]
2959    #[case::another_host("https://elsewhere.example.com/v2/hello-wasm/blobs/uploads/abc", false)]
2960    #[case::subdomain(
2961        "https://evil.webassembly.azurecr.io/v2/hello-wasm/blobs/uploads/abc",
2962        false
2963    )]
2964    #[case::downgraded_to_http(
2965        "http://webassembly.azurecr.io/v2/hello-wasm/blobs/uploads/abc",
2966        false
2967    )]
2968    fn test_targets_credential_registry(#[case] location: &str, #[case] expected: bool) {
2969        let image = Reference::try_from(HELLO_IMAGE_TAG).expect("failed to parse reference");
2970        let client = Client::default();
2971        let request = RequestBuilderWrapper::from_client(&client, |c| c.patch(location));
2972
2973        assert_eq!(
2974            request
2975                .targets_credential_registry(&image)
2976                .expect("failed to compare the location with the registry"),
2977            expected
2978        );
2979    }
2980
2981    #[rstest]
2982    #[case::mirror("https://docker.mirror.io/v2/hello-wasm/blobs/uploads/abc", true)]
2983    #[case::upstream_registry(
2984        "https://webassembly.azurecr.io/v2/hello-wasm/blobs/uploads/abc",
2985        false
2986    )]
2987    fn test_targets_credential_registry_with_mirror(
2988        #[case] location: &str,
2989        #[case] expected: bool,
2990    ) {
2991        let mut image = Reference::try_from(HELLO_IMAGE_TAG).expect("failed to parse reference");
2992        image.set_mirror_registry("docker.mirror.io".to_owned());
2993        let client = Client::default();
2994        let request = RequestBuilderWrapper::from_client(&client, |c| c.patch(location));
2995
2996        assert_eq!(
2997            request
2998                .targets_credential_registry(&image)
2999                .expect("failed to compare the location with the registry"),
3000            expected
3001        );
3002    }
3003
3004    #[rstest]
3005    #[case::http_registry("http://localhost:5000/v2/hello-wasm/blobs/uploads/abc", true)]
3006    #[case::upgraded_to_https("https://localhost:5000/v2/hello-wasm/blobs/uploads/abc", true)]
3007    #[case::another_port("http://localhost:5001/v2/hello-wasm/blobs/uploads/abc", false)]
3008    fn test_targets_credential_registry_plain_http(#[case] location: &str, #[case] expected: bool) {
3009        let image =
3010            Reference::try_from("localhost:5000/hello-wasm:v1").expect("failed to parse reference");
3011        let client = Client::new(ClientConfig {
3012            protocol: ClientProtocol::Http,
3013            ..Default::default()
3014        });
3015        let request = RequestBuilderWrapper::from_client(&client, |c| c.patch(location));
3016
3017        assert_eq!(
3018            request
3019                .targets_credential_registry(&image)
3020                .expect("failed to compare the location with the registry"),
3021            expected
3022        );
3023    }
3024
3025    #[test]
3026    fn test_to_v2_blob_url() {
3027        let mut image = Reference::try_from(HELLO_IMAGE_TAG).expect("failed to parse reference");
3028        let c = Client::default();
3029
3030        assert_eq!(
3031            c.to_v2_blob_url(&image, "sha256:deadbeef"),
3032            "https://webassembly.azurecr.io/v2/hello-wasm/blobs/sha256:deadbeef"
3033        );
3034
3035        image.set_mirror_registry("docker.mirror.io".to_owned());
3036        assert_eq!(
3037            c.to_v2_blob_url(&image, "sha256:deadbeef"),
3038            "https://docker.mirror.io/v2/hello-wasm/blobs/sha256:deadbeef?ns=webassembly.azurecr.io"
3039        );
3040    }
3041
3042    #[rstest(image, expected_uri, expected_mirror_uri,
3043        case(HELLO_IMAGE_NO_TAG, "https://webassembly.azurecr.io/v2/hello-wasm/manifests/latest", "https://docker.mirror.io/v2/hello-wasm/manifests/latest?ns=webassembly.azurecr.io"), // TODO: confirm this is the right translation when no tag
3044        case(HELLO_IMAGE_TAG, "https://webassembly.azurecr.io/v2/hello-wasm/manifests/v1", "https://docker.mirror.io/v2/hello-wasm/manifests/v1?ns=webassembly.azurecr.io"),
3045        case(HELLO_IMAGE_DIGEST, "https://webassembly.azurecr.io/v2/hello-wasm/manifests/sha256:51d9b231d5129e3ffc267c9d455c49d789bf3167b611a07ab6e4b3304c96b0e7", "https://docker.mirror.io/v2/hello-wasm/manifests/sha256:51d9b231d5129e3ffc267c9d455c49d789bf3167b611a07ab6e4b3304c96b0e7?ns=webassembly.azurecr.io"),
3046        case(HELLO_IMAGE_TAG_AND_DIGEST, "https://webassembly.azurecr.io/v2/hello-wasm/manifests/sha256:51d9b231d5129e3ffc267c9d455c49d789bf3167b611a07ab6e4b3304c96b0e7", "https://docker.mirror.io/v2/hello-wasm/manifests/sha256:51d9b231d5129e3ffc267c9d455c49d789bf3167b611a07ab6e4b3304c96b0e7?ns=webassembly.azurecr.io"),
3047    )]
3048    fn test_to_v2_manifest(image: &str, expected_uri: &str, expected_mirror_uri: &str) {
3049        let mut reference = Reference::try_from(image).expect("failed to parse reference");
3050        let c = Client::default();
3051        assert_eq!(c.to_v2_manifest_url(&reference), expected_uri);
3052
3053        reference.set_mirror_registry("docker.mirror.io".to_owned());
3054        assert_eq!(c.to_v2_manifest_url(&reference), expected_mirror_uri);
3055    }
3056
3057    #[test]
3058    fn test_to_v2_blob_upload_url() {
3059        let image = Reference::try_from(HELLO_IMAGE_TAG).expect("failed to parse reference");
3060        let blob_url = Client::default().to_v2_blob_upload_url(&image);
3061
3062        assert_eq!(
3063            blob_url,
3064            "https://webassembly.azurecr.io/v2/hello-wasm/blobs/uploads/"
3065        )
3066    }
3067
3068    #[test]
3069    fn test_to_list_tags_url() {
3070        let mut image = Reference::try_from(HELLO_IMAGE_TAG).expect("failed to parse reference");
3071        let c = Client::default();
3072
3073        assert_eq!(
3074            c.to_list_tags_url(&image),
3075            "https://webassembly.azurecr.io/v2/hello-wasm/tags/list"
3076        );
3077
3078        image.set_mirror_registry("docker.mirror.io".to_owned());
3079        assert_eq!(
3080            c.to_list_tags_url(&image),
3081            "https://docker.mirror.io/v2/hello-wasm/tags/list?ns=webassembly.azurecr.io"
3082        );
3083    }
3084
3085    #[test]
3086    fn test_to_catalog_url() {
3087        let mut image = Reference::try_from(HELLO_IMAGE_TAG).expect("failed to parse reference");
3088        let c = Client::default();
3089
3090        assert_eq!(
3091            c.to_catalog_url(&image),
3092            "https://webassembly.azurecr.io/v2/_catalog"
3093        );
3094
3095        image.set_mirror_registry("docker.mirror.io".to_owned());
3096        assert_eq!(
3097            c.to_catalog_url(&image),
3098            "https://docker.mirror.io/v2/_catalog"
3099        );
3100    }
3101
3102    #[test]
3103    fn test_to_v2_referrers_url() {
3104        let image = Reference::try_from(HELLO_IMAGE_DIGEST).expect("failed to parse reference");
3105        let c = Client::default();
3106
3107        // No filter: no query string.
3108        assert_eq!(
3109            c.to_v2_referrers_url(&image, None).unwrap(),
3110            "https://webassembly.azurecr.io/v2/hello-wasm/referrers/sha256:51d9b231d5129e3ffc267c9d455c49d789bf3167b611a07ab6e4b3304c96b0e7"
3111        );
3112
3113        // With filter: the artifactType value is percent-encoded. The `+` in `+json`
3114        // media types must become `%2B`, otherwise standard query-string decoding turns
3115        // it into a space and the registry filter matches nothing.
3116        assert_eq!(
3117            c.to_v2_referrers_url(&image, Some("application/spdx+json")).unwrap(),
3118            "https://webassembly.azurecr.io/v2/hello-wasm/referrers/sha256:51d9b231d5129e3ffc267c9d455c49d789bf3167b611a07ab6e4b3304c96b0e7?artifactType=application%2Fspdx%2Bjson"
3119        );
3120    }
3121
3122    #[test]
3123    fn manifest_url_generation_respects_http_protocol() {
3124        let c = Client::new(ClientConfig {
3125            protocol: ClientProtocol::Http,
3126            ..Default::default()
3127        });
3128        let reference = Reference::try_from("webassembly.azurecr.io/hello:v1".to_owned())
3129            .expect("Could not parse reference");
3130        assert_eq!(
3131            "http://webassembly.azurecr.io/v2/hello/manifests/v1",
3132            c.to_v2_manifest_url(&reference)
3133        );
3134    }
3135
3136    #[test]
3137    fn blob_url_generation_respects_http_protocol() {
3138        let c = Client::new(ClientConfig {
3139            protocol: ClientProtocol::Http,
3140            ..Default::default()
3141        });
3142        let reference = Reference::try_from("webassembly.azurecr.io/hello@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_owned())
3143            .expect("Could not parse reference");
3144        assert_eq!(
3145            "http://webassembly.azurecr.io/v2/hello/blobs/sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
3146            c.to_v2_blob_url(&reference, reference.digest().unwrap())
3147        );
3148    }
3149
3150    #[test]
3151    fn manifest_url_generation_uses_https_if_not_on_exception_list() {
3152        let insecure_registries = vec!["localhost".to_owned(), "oci.registry.local".to_owned()];
3153        let protocol = ClientProtocol::HttpsExcept(insecure_registries);
3154        let c = Client::new(ClientConfig {
3155            protocol,
3156            ..Default::default()
3157        });
3158        let reference = Reference::try_from("webassembly.azurecr.io/hello:v1".to_owned())
3159            .expect("Could not parse reference");
3160        assert_eq!(
3161            "https://webassembly.azurecr.io/v2/hello/manifests/v1",
3162            c.to_v2_manifest_url(&reference)
3163        );
3164    }
3165
3166    #[test]
3167    fn manifest_url_generation_uses_http_if_on_exception_list() {
3168        let insecure_registries = vec!["localhost".to_owned(), "oci.registry.local".to_owned()];
3169        let protocol = ClientProtocol::HttpsExcept(insecure_registries);
3170        let c = Client::new(ClientConfig {
3171            protocol,
3172            ..Default::default()
3173        });
3174        let reference = Reference::try_from("oci.registry.local/hello:v1".to_owned())
3175            .expect("Could not parse reference");
3176        assert_eq!(
3177            "http://oci.registry.local/v2/hello/manifests/v1",
3178            c.to_v2_manifest_url(&reference)
3179        );
3180    }
3181
3182    #[test]
3183    fn blob_url_generation_uses_https_if_not_on_exception_list() {
3184        let insecure_registries = vec!["localhost".to_owned(), "oci.registry.local".to_owned()];
3185        let protocol = ClientProtocol::HttpsExcept(insecure_registries);
3186        let c = Client::new(ClientConfig {
3187            protocol,
3188            ..Default::default()
3189        });
3190        let reference = Reference::try_from("webassembly.azurecr.io/hello@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_owned())
3191            .expect("Could not parse reference");
3192        assert_eq!(
3193            "https://webassembly.azurecr.io/v2/hello/blobs/sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
3194            c.to_v2_blob_url(&reference, reference.digest().unwrap())
3195        );
3196    }
3197
3198    #[test]
3199    fn blob_url_generation_uses_http_if_on_exception_list() {
3200        let insecure_registries = vec!["localhost".to_owned(), "oci.registry.local".to_owned()];
3201        let protocol = ClientProtocol::HttpsExcept(insecure_registries);
3202        let c = Client::new(ClientConfig {
3203            protocol,
3204            ..Default::default()
3205        });
3206        let reference = Reference::try_from("oci.registry.local/hello@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_owned())
3207            .expect("Could not parse reference");
3208        assert_eq!(
3209            "http://oci.registry.local/v2/hello/blobs/sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
3210            c.to_v2_blob_url(&reference, reference.digest().unwrap())
3211        );
3212    }
3213
3214    #[test]
3215    fn can_generate_valid_digest() {
3216        let bytes = b"hellobytes";
3217        let hash = sha256_digest(bytes);
3218
3219        let combination = vec![b"hello".to_vec(), b"bytes".to_vec()];
3220        let combination_hash =
3221            sha256_digest(&combination.into_iter().flatten().collect::<Vec<u8>>());
3222
3223        assert_eq!(
3224            hash,
3225            "sha256:fdbd95aafcbc814a2600fcc54c1e1706f52d2f9bf45cf53254f25bcd7599ce99"
3226        );
3227        assert_eq!(
3228            combination_hash,
3229            "sha256:fdbd95aafcbc814a2600fcc54c1e1706f52d2f9bf45cf53254f25bcd7599ce99"
3230        );
3231    }
3232
3233    #[test]
3234    fn test_registry_token_deserialize() {
3235        // 'token' field, standalone
3236        let text = r#"{"token": "abc"}"#;
3237        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3238        assert!(res.is_ok());
3239        let rt = res.unwrap();
3240        assert_eq!(rt.token(), "abc");
3241
3242        // 'access_token' field, standalone
3243        let text = r#"{"access_token": "xyz"}"#;
3244        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3245        assert!(res.is_ok());
3246        let rt = res.unwrap();
3247        assert_eq!(rt.token(), "xyz");
3248
3249        // both 'token' and 'access_token' fields, 'token' field takes precedence
3250        let text = r#"{"access_token": "xyz", "token": "abc"}"#;
3251        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3252        assert!(res.is_ok());
3253        let rt = res.unwrap();
3254        assert_eq!(rt.token(), "abc");
3255
3256        // both 'token' and 'access_token' fields, 'token' field takes precedence (reverse order)
3257        let text = r#"{"token": "abc", "access_token": "xyz"}"#;
3258        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3259        assert!(res.is_ok());
3260        let rt = res.unwrap();
3261        assert_eq!(rt.token(), "abc");
3262
3263        // non-string fields do not break parsing
3264        let text = r#"{"aaa": 300, "access_token": "xyz", "token": "abc", "zzz": 600}"#;
3265        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3266        assert!(res.is_ok());
3267
3268        // Note: tokens should always be strings. The next two tests ensure that if one field
3269        // is invalid (integer), then parse can still succeed if the other field is a string.
3270        //
3271        // numeric 'access_token' field, but string 'token' field does not in parse error
3272        let text = r#"{"access_token": 300, "token": "abc"}"#;
3273        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3274        assert!(res.is_ok());
3275        let rt = res.unwrap();
3276        assert_eq!(rt.token(), "abc");
3277
3278        // numeric 'token' field, but string 'accesss_token' field does not in parse error
3279        let text = r#"{"access_token": "xyz", "token": 300}"#;
3280        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3281        assert!(res.is_ok());
3282        let rt = res.unwrap();
3283        assert_eq!(rt.token(), "xyz");
3284
3285        // numeric 'token' field results in parse error
3286        let text = r#"{"token": 300}"#;
3287        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3288        assert!(res.is_err());
3289
3290        // numeric 'access_token' field results in parse error
3291        let text = r#"{"access_token": 300}"#;
3292        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3293        assert!(res.is_err());
3294
3295        // object 'token' field results in parse error
3296        let text = r#"{"token": {"some": "thing"}}"#;
3297        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3298        assert!(res.is_err());
3299
3300        // object 'access_token' field results in parse error
3301        let text = r#"{"access_token": {"some": "thing"}}"#;
3302        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3303        assert!(res.is_err());
3304
3305        // missing fields results in parse error
3306        let text = r#"{"some": "thing"}"#;
3307        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3308        assert!(res.is_err());
3309
3310        // bad JSON results in parse error
3311        let text = r#"{"token": "abc""#;
3312        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3313        assert!(res.is_err());
3314
3315        // worse JSON results in parse error
3316        let text = r#"_ _ _ kjbwef??98{9898 }} }}"#;
3317        let res: Result<RegistryToken, serde_json::Error> = serde_json::from_str(text);
3318        assert!(res.is_err());
3319    }
3320
3321    fn check_auth_token(token: &str) {
3322        // We test that the token is longer than a minimal hash.
3323        assert!(token.len() > 64);
3324    }
3325
3326    #[tokio::test]
3327    async fn test_auth() {
3328        let _ = tracing_subscriber::fmt::try_init();
3329        for &image in TEST_IMAGES {
3330            let reference = Reference::try_from(image).expect("failed to parse reference");
3331            let c = Client::default();
3332            let token = c
3333                .auth(
3334                    &reference,
3335                    &RegistryAuth::Anonymous,
3336                    RegistryOperation::Pull,
3337                )
3338                .await
3339                .expect("result from auth request");
3340
3341            assert!(token.is_some());
3342            check_auth_token(token.unwrap().as_ref());
3343
3344            let tok = c
3345                .tokens
3346                .get(&reference, RegistryOperation::Pull)
3347                .await
3348                .expect("token is available");
3349            // We test that the token is longer than a minimal hash.
3350            if let RegistryTokenType::Bearer(tok) = tok {
3351                check_auth_token(tok.token());
3352            } else {
3353                panic!("Unexpeted Basic Auth Token");
3354            }
3355        }
3356    }
3357
3358    #[cfg(feature = "test-registry")]
3359    #[tokio::test]
3360    async fn test_list_tags() {
3361        let test_container = registry_image_edge()
3362            .start()
3363            .await
3364            .expect("Failed to start registry container");
3365        let port = test_container
3366            .get_host_port_ipv4(5000)
3367            .await
3368            .expect("Failed to get port");
3369        let auth =
3370            RegistryAuth::Basic(HTPASSWD_USERNAME.to_string(), HTPASSWD_PASSWORD.to_string());
3371
3372        let client = Client::new(ClientConfig {
3373            protocol: ClientProtocol::HttpsExcept(vec![format!("localhost:{}", port)]),
3374            ..Default::default()
3375        });
3376
3377        let image: Reference = HELLO_IMAGE_TAG_AND_DIGEST.parse().unwrap();
3378        client
3379            .auth(&image, &RegistryAuth::Anonymous, RegistryOperation::Pull)
3380            .await
3381            .expect("cannot authenticate against registry for pull operation");
3382
3383        let (manifest, _digest) = client
3384            ._pull_image_manifest(&image)
3385            .await
3386            .expect("failed to pull manifest");
3387
3388        let image_data = client
3389            .pull(&image, &auth, vec![manifest::WASM_LAYER_MEDIA_TYPE])
3390            .await
3391            .expect("failed to pull image");
3392
3393        for i in 0..=3 {
3394            let push_image: Reference = format!("localhost:{port}/hello-wasm:1.0.{i}")
3395                .parse()
3396                .unwrap();
3397            client
3398                .auth(&push_image, &auth, RegistryOperation::Push)
3399                .await
3400                .expect("authenticated");
3401            client
3402                .push(
3403                    &push_image,
3404                    &image_data.layers,
3405                    image_data.config.clone(),
3406                    &auth,
3407                    Some(manifest.clone()),
3408                )
3409                .await
3410                .expect("Failed to push Image");
3411        }
3412
3413        let image: Reference = format!("localhost:{port}/hello-wasm:1.0.1")
3414            .parse()
3415            .unwrap();
3416        let response = client
3417            .list_tags(&image, &RegistryAuth::Anonymous, Some(2), Some("1.0.1"))
3418            .await
3419            .expect("Cannot list Tags");
3420        assert_eq!(response.tags, vec!["1.0.2", "1.0.3"])
3421    }
3422
3423    #[cfg(feature = "test-registry")]
3424    #[tokio::test]
3425    async fn test_catalog() {
3426        let test_container = registry_image_edge()
3427            .start()
3428            .await
3429            .expect("Failed to start registry container");
3430        let port = test_container
3431            .get_host_port_ipv4(5000)
3432            .await
3433            .expect("Failed to get port");
3434        let auth =
3435            RegistryAuth::Basic(HTPASSWD_USERNAME.to_string(), HTPASSWD_PASSWORD.to_string());
3436
3437        let client = Client::new(ClientConfig {
3438            protocol: ClientProtocol::HttpsExcept(vec![format!("localhost:{}", port)]),
3439            ..Default::default()
3440        });
3441
3442        let image: Reference = HELLO_IMAGE_TAG_AND_DIGEST.parse().unwrap();
3443        client
3444            .auth(&image, &RegistryAuth::Anonymous, RegistryOperation::Pull)
3445            .await
3446            .expect("cannot authenticate against registry for pull operation");
3447
3448        let (manifest, _digest) = client
3449            ._pull_image_manifest(&image)
3450            .await
3451            .expect("failed to pull manifest");
3452
3453        let image_data = client
3454            .pull(&image, &auth, vec![manifest::WASM_LAYER_MEDIA_TYPE])
3455            .await
3456            .expect("failed to pull image");
3457
3458        // Push to two different repositories
3459        for repo in &["hello-catalog-a", "hello-catalog-b"] {
3460            let push_image: Reference = format!("localhost:{port}/{repo}:latest").parse().unwrap();
3461            client
3462                .auth(&push_image, &auth, RegistryOperation::Push)
3463                .await
3464                .expect("authenticated");
3465            client
3466                .push(
3467                    &push_image,
3468                    &image_data.layers,
3469                    image_data.config.clone(),
3470                    &auth,
3471                    Some(manifest.clone()),
3472                )
3473                .await
3474                .expect("Failed to push Image");
3475        }
3476
3477        // Use any valid reference for the same registry to call catalog
3478        let catalog_ref: Reference = format!("localhost:{port}/hello-catalog-a:latest")
3479            .parse()
3480            .unwrap();
3481        let response = client
3482            .catalog(&catalog_ref, &RegistryAuth::Anonymous, None, None)
3483            .await
3484            .expect("Cannot list catalog");
3485        assert!(response
3486            .repositories
3487            .contains(&"hello-catalog-a".to_string()));
3488        assert!(response
3489            .repositories
3490            .contains(&"hello-catalog-b".to_string()));
3491
3492        // Test pagination: request 1 result at a time
3493        let page1 = client
3494            .catalog(&catalog_ref, &RegistryAuth::Anonymous, Some(1), None)
3495            .await
3496            .expect("Cannot list catalog page 1");
3497        assert_eq!(page1.repositories.len(), 1);
3498
3499        let page2 = client
3500            .catalog(
3501                &catalog_ref,
3502                &RegistryAuth::Anonymous,
3503                Some(1),
3504                Some(&page1.repositories[0]),
3505            )
3506            .await
3507            .expect("Cannot list catalog page 2");
3508        assert_eq!(page2.repositories.len(), 1);
3509        assert_ne!(page1.repositories[0], page2.repositories[0]);
3510    }
3511
3512    #[tokio::test]
3513    async fn test_pull_manifest_private() {
3514        for &image in TEST_IMAGES {
3515            let reference = Reference::try_from(image).expect("failed to parse reference");
3516            // Currently, pull_manifest does not perform Authz, so this will fail.
3517            let c = Client::default();
3518            c._pull_image_manifest(&reference)
3519                .await
3520                .expect_err("pull manifest should fail");
3521
3522            // But this should pass
3523            let c = Client::default();
3524            c.auth(
3525                &reference,
3526                &RegistryAuth::Anonymous,
3527                RegistryOperation::Pull,
3528            )
3529            .await
3530            .expect("authenticated");
3531            let (manifest, _) = c
3532                ._pull_image_manifest(&reference)
3533                .await
3534                .expect("pull manifest should not fail");
3535
3536            // The test on the manifest checks all fields. This is just a brief sanity check.
3537            assert_eq!(manifest.schema_version, 2);
3538            assert!(!manifest.layers.is_empty());
3539        }
3540    }
3541
3542    #[tokio::test]
3543    async fn test_pull_manifest_public() {
3544        for &image in TEST_IMAGES {
3545            let reference = Reference::try_from(image).expect("failed to parse reference");
3546            let c = Client::default();
3547            let (manifest, _) = c
3548                .pull_image_manifest(&reference, &RegistryAuth::Anonymous)
3549                .await
3550                .expect("pull manifest should not fail");
3551
3552            // The test on the manifest checks all fields. This is just a brief sanity check.
3553            assert_eq!(manifest.schema_version, 2);
3554            assert!(!manifest.layers.is_empty());
3555        }
3556    }
3557
3558    #[tokio::test]
3559    async fn pull_manifest_and_config_public() {
3560        for &image in TEST_IMAGES {
3561            let reference = Reference::try_from(image).expect("failed to parse reference");
3562            let c = Client::default();
3563            let (manifest, _, config) = c
3564                .pull_manifest_and_config(&reference, &RegistryAuth::Anonymous)
3565                .await
3566                .expect("pull manifest and config should not fail");
3567
3568            // The test on the manifest checks all fields. This is just a brief sanity check.
3569            assert_eq!(manifest.schema_version, 2);
3570            assert!(!manifest.layers.is_empty());
3571            assert!(!config.is_empty());
3572        }
3573    }
3574
3575    #[tokio::test]
3576    async fn test_fetch_digest() {
3577        let c = Client::default();
3578
3579        for &image in TEST_IMAGES {
3580            let reference = Reference::try_from(image).expect("failed to parse reference");
3581            c.fetch_manifest_digest(&reference, &RegistryAuth::Anonymous)
3582                .await
3583                .expect("pull manifest should not fail");
3584
3585            // This should pass
3586            let reference = Reference::try_from(image).expect("failed to parse reference");
3587            let c = Client::default();
3588            c.auth(
3589                &reference,
3590                &RegistryAuth::Anonymous,
3591                RegistryOperation::Pull,
3592            )
3593            .await
3594            .expect("authenticated");
3595            let digest = c
3596                .fetch_manifest_digest(&reference, &RegistryAuth::Anonymous)
3597                .await
3598                .expect("pull manifest should not fail");
3599
3600            assert_eq!(
3601                digest,
3602                "sha256:51d9b231d5129e3ffc267c9d455c49d789bf3167b611a07ab6e4b3304c96b0e7"
3603            );
3604        }
3605    }
3606
3607    #[tokio::test]
3608    async fn test_pull_blob() {
3609        let c = Client::default();
3610
3611        for &image in TEST_IMAGES {
3612            let reference = Reference::try_from(image).expect("failed to parse reference");
3613            c.auth(
3614                &reference,
3615                &RegistryAuth::Anonymous,
3616                RegistryOperation::Pull,
3617            )
3618            .await
3619            .expect("authenticated");
3620            let (manifest, _) = c
3621                ._pull_image_manifest(&reference)
3622                .await
3623                .expect("failed to pull manifest");
3624
3625            // Pull one specific layer
3626            let mut file: Vec<u8> = Vec::new();
3627            let layer0 = &manifest.layers[0];
3628
3629            // This call likes to flake, so we try it at least 5 times
3630            let mut last_error = None;
3631            for i in 1..6 {
3632                if let Err(e) = c.pull_blob(&reference, layer0, &mut file).await {
3633                    println!("Got error on pull_blob call attempt {i}. Will retry in 1s: {e:?}");
3634                    last_error.replace(e);
3635                    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
3636                } else {
3637                    last_error = None;
3638                    break;
3639                }
3640            }
3641
3642            if let Some(e) = last_error {
3643                panic!("Unable to pull layer: {e:?}");
3644            }
3645
3646            // The manifest says how many bytes we should expect.
3647            assert_eq!(file.len(), layer0.size as usize);
3648        }
3649    }
3650
3651    #[tokio::test]
3652    async fn test_pull_blob_stream() {
3653        let c = Client::default();
3654
3655        for &image in TEST_IMAGES {
3656            let reference = Reference::try_from(image).expect("failed to parse reference");
3657            c.auth(
3658                &reference,
3659                &RegistryAuth::Anonymous,
3660                RegistryOperation::Pull,
3661            )
3662            .await
3663            .expect("authenticated");
3664            let (manifest, _) = c
3665                ._pull_image_manifest(&reference)
3666                .await
3667                .expect("failed to pull manifest");
3668
3669            // Pull one specific layer
3670            let mut file: Vec<u8> = Vec::new();
3671            let layer0 = &manifest.layers[0];
3672
3673            let layer_stream = c
3674                .pull_blob_stream(&reference, layer0)
3675                .await
3676                .expect("failed to pull blob stream");
3677
3678            assert_eq!(layer_stream.content_length, Some(layer0.size as u64));
3679            AsyncReadExt::read_to_end(&mut StreamReader::new(layer_stream.stream), &mut file)
3680                .await
3681                .unwrap();
3682
3683            // The manifest says how many bytes we should expect.
3684            assert_eq!(file.len(), layer0.size as usize);
3685        }
3686    }
3687
3688    #[tokio::test]
3689    async fn test_pull_blob_stream_partial() {
3690        let c = Client::default();
3691
3692        for &image in TEST_IMAGES {
3693            let reference = Reference::try_from(image).expect("failed to parse reference");
3694            c.auth(
3695                &reference,
3696                &RegistryAuth::Anonymous,
3697                RegistryOperation::Pull,
3698            )
3699            .await
3700            .expect("authenticated");
3701            let (manifest, _) = c
3702                ._pull_image_manifest(&reference)
3703                .await
3704                .expect("failed to pull manifest");
3705
3706            // Pull part of one specific layer
3707            let mut partial_file: Vec<u8> = Vec::new();
3708            let layer0 = &manifest.layers[0];
3709            let (offset, length) = (10, 6);
3710
3711            let partial_response = c
3712                .pull_blob_stream_partial(&reference, layer0, offset, Some(length))
3713                .await
3714                .expect("failed to pull blob stream");
3715            let full_response = c
3716                .pull_blob_stream_partial(&reference, layer0, 0, Some(layer0.size as u64))
3717                .await
3718                .expect("failed to pull blob stream");
3719
3720            let layer_stream_partial = match partial_response {
3721                BlobResponse::Full(_stream) => panic!("expected partial response"),
3722                BlobResponse::Partial(stream) => stream,
3723            };
3724            assert_eq!(layer_stream_partial.content_length, Some(length));
3725            AsyncReadExt::read_to_end(
3726                &mut StreamReader::new(layer_stream_partial.stream),
3727                &mut partial_file,
3728            )
3729            .await
3730            .unwrap();
3731
3732            // Also pull the full layer into a separate file to compare with the partial.
3733            let mut full_file: Vec<u8> = Vec::new();
3734            let layer_stream_full = match full_response {
3735                BlobResponse::Full(_stream) => panic!("expected partial response"),
3736                BlobResponse::Partial(stream) => stream,
3737            };
3738            assert_eq!(layer_stream_full.content_length, Some(layer0.size as u64));
3739            AsyncReadExt::read_to_end(
3740                &mut StreamReader::new(layer_stream_full.stream),
3741                &mut full_file,
3742            )
3743            .await
3744            .unwrap();
3745
3746            // The partial read length says how many bytes we should expect.
3747            assert_eq!(partial_file.len(), length as usize);
3748            // The manifest says how many bytes we should expect on a full read.
3749            assert_eq!(full_file.len(), layer0.size as usize);
3750            // Check that the partial read retrieved the correct bytes.
3751            let end: usize = (offset + length) as usize;
3752            assert_eq!(partial_file, full_file[offset as usize..end]);
3753        }
3754    }
3755
3756    #[tokio::test]
3757    async fn test_pull() {
3758        for &image in TEST_IMAGES {
3759            let reference = Reference::try_from(image).expect("failed to parse reference");
3760
3761            // This call likes to flake, so we try it at least 5 times
3762            let mut last_error = None;
3763            let mut image_data = None;
3764            for i in 1..6 {
3765                match Client::default()
3766                    .pull(
3767                        &reference,
3768                        &RegistryAuth::Anonymous,
3769                        vec![manifest::WASM_LAYER_MEDIA_TYPE],
3770                    )
3771                    .await
3772                {
3773                    Ok(data) => {
3774                        image_data = Some(data);
3775                        last_error = None;
3776                        break;
3777                    }
3778                    Err(e) => {
3779                        println!("Got error on pull call attempt {i}. Will retry in 1s: {e:?}");
3780                        last_error.replace(e);
3781                        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
3782                    }
3783                }
3784            }
3785
3786            if let Some(e) = last_error {
3787                panic!("Unable to pull layer: {e:?}");
3788            }
3789
3790            assert!(image_data.is_some());
3791            let image_data = image_data.unwrap();
3792            assert!(!image_data.layers.is_empty());
3793            assert!(image_data.digest.is_some());
3794        }
3795    }
3796
3797    /// Attempting to pull an image without any layer validation should fail.
3798    #[tokio::test]
3799    async fn test_pull_without_layer_validation() {
3800        for &image in TEST_IMAGES {
3801            let reference = Reference::try_from(image).expect("failed to parse reference");
3802            assert!(Client::default()
3803                .pull(&reference, &RegistryAuth::Anonymous, vec![],)
3804                .await
3805                .is_err());
3806        }
3807    }
3808
3809    /// Attempting to pull an image with the wrong list of layer validations should fail.
3810    #[tokio::test]
3811    async fn test_pull_wrong_layer_validation() {
3812        for &image in TEST_IMAGES {
3813            let reference = Reference::try_from(image).expect("failed to parse reference");
3814            assert!(Client::default()
3815                .pull(&reference, &RegistryAuth::Anonymous, vec!["text/plain"],)
3816                .await
3817                .is_err());
3818        }
3819    }
3820
3821    // This is the latest build of distribution/distribution from the `main` branch
3822    // Until distribution v3 is relased, this is the only way to have this fix
3823    // https://github.com/distribution/distribution/pull/3143
3824    //
3825    // We require this fix only when testing the capability to list tags
3826    #[cfg(feature = "test-registry")]
3827    fn registry_image_edge() -> GenericImage {
3828        GenericImage::new("distribution/distribution", "edge")
3829            .with_wait_for(WaitFor::message_on_stderr("listening on "))
3830    }
3831
3832    #[cfg(feature = "test-registry")]
3833    fn registry_image() -> GenericImage {
3834        GenericImage::new("docker.io/library/registry", "2")
3835            .with_wait_for(WaitFor::message_on_stderr("listening on "))
3836    }
3837
3838    #[cfg(feature = "test-registry")]
3839    fn registry_image_basic_auth(auth_path: &str) -> ContainerRequest<GenericImage> {
3840        GenericImage::new("docker.io/library/registry", "2")
3841            .with_wait_for(WaitFor::message_on_stderr("listening on "))
3842            .with_env_var("REGISTRY_AUTH", "htpasswd")
3843            .with_env_var("REGISTRY_AUTH_HTPASSWD_REALM", "Registry Realm")
3844            .with_env_var("REGISTRY_AUTH_HTPASSWD_PATH", "/auth/htpasswd")
3845            .with_mount(Mount::bind_mount(auth_path, "/auth"))
3846    }
3847
3848    #[tokio::test]
3849    #[cfg(feature = "test-registry")]
3850    async fn can_push_chunk() {
3851        let test_container = registry_image()
3852            .start()
3853            .await
3854            .expect("Failed to start registry container");
3855        let port = test_container
3856            .get_host_port_ipv4(5000)
3857            .await
3858            .expect("Failed to get port");
3859
3860        let c = Client::new(ClientConfig {
3861            protocol: ClientProtocol::Http,
3862            ..Default::default()
3863        });
3864        let url = format!("localhost:{port}/hello-wasm:v1");
3865        let image: Reference = url.parse().unwrap();
3866
3867        c.auth(&image, &RegistryAuth::Anonymous, RegistryOperation::Push)
3868            .await
3869            .expect("result from auth request");
3870
3871        let location = c
3872            .begin_push_chunked_session(&image)
3873            .await
3874            .expect("failed to begin push session");
3875
3876        let image_data = Bytes::from(b"iamawebassemblymodule".to_vec());
3877        let (next_location, next_byte) = c
3878            .push_chunk(&location, &image, image_data.clone(), 0)
3879            .await
3880            .expect("failed to push layer");
3881
3882        // Location should include original URL with at session ID appended
3883        assert!(next_location.len() >= url.len() + "6987887f-0196-45ee-91a1-2dfad901bea0".len());
3884        assert_eq!(next_byte, image_data.len());
3885
3886        let layer_location = c
3887            .end_push_chunked_session(&next_location, &image, &sha256_digest(&image_data))
3888            .await
3889            .expect("failed to end push session");
3890
3891        assert_eq!(layer_location, format!("http://localhost:{port}/v2/hello-wasm/blobs/sha256:6165c4ad43c0803798b6f2e49d6348c915d52c999a5f890846cee77ea65d230b"));
3892    }
3893
3894    #[tokio::test]
3895    #[cfg(feature = "test-registry")]
3896    async fn can_push_multiple_chunks() {
3897        let test_container = registry_image()
3898            .start()
3899            .await
3900            .expect("Failed to start registry container");
3901        let port = test_container
3902            .get_host_port_ipv4(5000)
3903            .await
3904            .expect("Failed to get port");
3905
3906        let mut c = Client::new(ClientConfig {
3907            protocol: ClientProtocol::Http,
3908            ..Default::default()
3909        });
3910        // set a super small chunk size - done to force multiple pushes
3911        c.push_chunk_size = 3;
3912        let url = format!("localhost:{port}/hello-wasm:v1");
3913        let image: Reference = url.parse().unwrap();
3914
3915        c.auth(&image, &RegistryAuth::Anonymous, RegistryOperation::Push)
3916            .await
3917            .expect("result from auth request");
3918
3919        let image_data: Vec<u8> =
3920            b"i am a big webassembly mode that needs chunked uploads".to_vec();
3921        let image_digest = sha256_digest(&image_data);
3922
3923        let location = c
3924            .push_blob_chunked(&image, image_data, &image_digest)
3925            .await
3926            .expect("failed to begin push session");
3927
3928        assert_eq!(
3929            location,
3930            format!("http://localhost:{port}/v2/hello-wasm/blobs/{image_digest}")
3931        );
3932    }
3933
3934    #[tokio::test]
3935    #[cfg(feature = "test-registry")]
3936    async fn test_image_roundtrip_anon_auth() {
3937        let test_container = registry_image()
3938            .start()
3939            .await
3940            .expect("Failed to start registry container");
3941
3942        test_image_roundtrip(&RegistryAuth::Anonymous, &test_container).await;
3943    }
3944
3945    #[tokio::test]
3946    #[cfg(feature = "test-registry")]
3947    async fn test_image_roundtrip_basic_auth() {
3948        let auth_dir = TempDir::new().expect("cannot create tmp directory");
3949        let htpasswd_path = path::Path::join(auth_dir.path(), "htpasswd");
3950        fs::write(htpasswd_path, HTPASSWD).expect("cannot write htpasswd file");
3951
3952        let image = registry_image_basic_auth(
3953            auth_dir
3954                .path()
3955                .to_str()
3956                .expect("cannot convert htpasswd_path to string"),
3957        );
3958        let test_container = image.start().await.expect("cannot registry container");
3959
3960        let auth =
3961            RegistryAuth::Basic(HTPASSWD_USERNAME.to_string(), HTPASSWD_PASSWORD.to_string());
3962
3963        test_image_roundtrip(&auth, &test_container).await;
3964    }
3965
3966    #[cfg(feature = "test-registry")]
3967    async fn test_image_roundtrip(
3968        registry_auth: &RegistryAuth,
3969        test_container: &testcontainers::ContainerAsync<GenericImage>,
3970    ) {
3971        let _ = tracing_subscriber::fmt::try_init();
3972        let port = test_container
3973            .get_host_port_ipv4(5000)
3974            .await
3975            .expect("Failed to get port");
3976
3977        let c = Client::new(ClientConfig {
3978            protocol: ClientProtocol::HttpsExcept(vec![format!("localhost:{}", port)]),
3979            ..Default::default()
3980        });
3981
3982        // pulling webassembly.azurecr.io/hello-wasm:v1
3983        let image: Reference = HELLO_IMAGE_TAG_AND_DIGEST.parse().unwrap();
3984        c.auth(&image, &RegistryAuth::Anonymous, RegistryOperation::Pull)
3985            .await
3986            .expect("cannot authenticate against registry for pull operation");
3987
3988        let (manifest, _digest) = c
3989            ._pull_image_manifest(&image)
3990            .await
3991            .expect("failed to pull manifest");
3992
3993        let image_data = c
3994            .pull(&image, registry_auth, vec![manifest::WASM_LAYER_MEDIA_TYPE])
3995            .await
3996            .expect("failed to pull image");
3997
3998        let push_image: Reference = format!("localhost:{port}/hello-wasm:v1").parse().unwrap();
3999        c.auth(&push_image, registry_auth, RegistryOperation::Push)
4000            .await
4001            .expect("authenticated");
4002
4003        c.push(
4004            &push_image,
4005            &image_data.layers,
4006            image_data.config.clone(),
4007            registry_auth,
4008            Some(manifest.clone()),
4009        )
4010        .await
4011        .expect("failed to push image");
4012
4013        let pulled_image_data = c
4014            .pull(
4015                &push_image,
4016                registry_auth,
4017                vec![manifest::WASM_LAYER_MEDIA_TYPE],
4018            )
4019            .await
4020            .expect("failed to pull pushed image");
4021
4022        let (pulled_manifest, _digest) = c
4023            ._pull_image_manifest(&push_image)
4024            .await
4025            .expect("failed to pull pushed image manifest");
4026
4027        assert!(image_data.layers.len() == 1);
4028        assert!(pulled_image_data.layers.len() == 1);
4029        assert_eq!(
4030            image_data.layers[0].data.len(),
4031            pulled_image_data.layers[0].data.len()
4032        );
4033        assert_eq!(image_data.layers[0].data, pulled_image_data.layers[0].data);
4034
4035        assert_eq!(manifest.media_type, pulled_manifest.media_type);
4036        assert_eq!(manifest.schema_version, pulled_manifest.schema_version);
4037        assert_eq!(manifest.config.digest, pulled_manifest.config.digest);
4038    }
4039
4040    #[tokio::test]
4041    async fn test_raw_manifest_digest() {
4042        let _ = tracing_subscriber::fmt::try_init();
4043
4044        let c = Client::default();
4045
4046        // pulling webassembly.azurecr.io/hello-wasm:v1@sha256:51d9b231d5129e3ffc267c9d455c49d789bf3167b611a07ab6e4b3304c96b0e7
4047        let image: Reference = HELLO_IMAGE_TAG_AND_DIGEST.parse().unwrap();
4048        c.auth(&image, &RegistryAuth::Anonymous, RegistryOperation::Pull)
4049            .await
4050            .expect("cannot authenticate against registry for pull operation");
4051
4052        let (manifest, _) = c
4053            .pull_manifest_raw(
4054                &image,
4055                &RegistryAuth::Anonymous,
4056                MIME_TYPES_DISTRIBUTION_MANIFEST,
4057            )
4058            .await
4059            .expect("failed to pull manifest");
4060
4061        // Compute the digest of the returned manifest text.
4062        let digest = sha2::Sha256::digest(manifest);
4063        let hex = format!("sha256:{}", hex::encode(digest));
4064
4065        // Validate that the computed digest and the digest in the pulled reference match.
4066        assert_eq!(image.digest().unwrap(), hex);
4067    }
4068
4069    #[tokio::test]
4070    #[cfg(feature = "test-registry")]
4071    async fn test_mount() {
4072        // initialize the registry
4073        let test_container = registry_image()
4074            .start()
4075            .await
4076            .expect("Failed to start registry");
4077        let port = test_container
4078            .get_host_port_ipv4(5000)
4079            .await
4080            .expect("Failed to get port");
4081
4082        let c = Client::new(ClientConfig {
4083            protocol: ClientProtocol::HttpsExcept(vec![format!("localhost:{}", port)]),
4084            ..Default::default()
4085        });
4086
4087        // Create a dummy layer and push it to `layer-repository`
4088        let layer_reference: Reference = format!("localhost:{port}/layer-repository")
4089            .parse()
4090            .unwrap();
4091        let layer_data = vec![1u8, 2, 3, 4];
4092        let layer = OciDescriptor {
4093            digest: sha256_digest(&layer_data),
4094            ..Default::default()
4095        };
4096        c.push_blob(
4097            &layer_reference,
4098            Bytes::copy_from_slice(&layer_data),
4099            &layer.digest,
4100        )
4101        .await
4102        .expect("Failed to push");
4103
4104        // Mount the layer at `image-repository`
4105        let image_reference: Reference = format!("localhost:{port}/image-repository")
4106            .parse()
4107            .unwrap();
4108        c.mount_blob(&image_reference, &layer_reference, &layer.digest)
4109            .await
4110            .expect("Failed to mount");
4111
4112        // Pull the layer from `image-repository`
4113        let mut buf = Vec::new();
4114        c.pull_blob(&image_reference, &layer, &mut buf)
4115            .await
4116            .expect("Failed to pull");
4117
4118        assert_eq!(layer_data, buf);
4119    }
4120
4121    #[tokio::test]
4122    async fn test_platform_resolution() {
4123        // test that we get an error when we pull a manifest list
4124        let reference = Reference::try_from(DOCKER_IO_IMAGE).expect("failed to parse reference");
4125        let mut c = Client::new(ClientConfig {
4126            platform_resolver: None,
4127            ..Default::default()
4128        });
4129        let err = c
4130            .pull_image_manifest(&reference, &RegistryAuth::Anonymous)
4131            .await
4132            .unwrap_err();
4133        assert_eq!(
4134            format!("{err}"),
4135            "Received Image Index/Manifest List, but platform_resolver was not defined on the client config. Consider setting platform_resolver"
4136        );
4137
4138        c = Client::new(ClientConfig {
4139            platform_resolver: Some(Box::new(linux_amd64_resolver)),
4140            ..Default::default()
4141        });
4142        let (_manifest, digest) = c
4143            .pull_image_manifest(&reference, &RegistryAuth::Anonymous)
4144            .await
4145            .expect("Couldn't pull manifest");
4146        assert_eq!(
4147            digest,
4148            "sha256:f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4"
4149        );
4150    }
4151
4152    #[tokio::test]
4153    async fn test_pull_ghcr_io() {
4154        let reference = Reference::try_from(GHCR_IO_IMAGE).expect("failed to parse reference");
4155        let c = Client::default();
4156        let (manifest, _manifest_str) = c
4157            .pull_image_manifest(&reference, &RegistryAuth::Anonymous)
4158            .await
4159            .unwrap();
4160        assert_eq!(manifest.config.media_type, manifest::WASM_CONFIG_MEDIA_TYPE);
4161    }
4162
4163    #[tokio::test]
4164    async fn test_list_all_tags_ghcr_io() {
4165        const MAX_TAGS_PER_LIST: usize = 100;
4166        const MAX_TAG_REQUESTS: usize = 10;
4167
4168        let reference = Reference::try_from(GHCR_IO_IMAGE).expect("failed to parse reference");
4169        let c = Client::default();
4170
4171        // When listing beyond the last tag in the repository, ghcr.io has been observed to emit
4172        // a JSON `null` for the "tags" field rather than an empty array. This must be handled
4173        // to paginate through tags using the `last` parameter.
4174        let mut last_tag = None;
4175        for _ in 0..MAX_TAG_REQUESTS {
4176            let mut response = c
4177                .list_tags(
4178                    &reference,
4179                    &RegistryAuth::Anonymous,
4180                    Some(MAX_TAGS_PER_LIST),
4181                    last_tag.as_deref(),
4182                )
4183                .await
4184                .expect("failed to list tags in registry");
4185
4186            if let Some(tag) = response.tags.pop() {
4187                last_tag = Some(tag);
4188            } else {
4189                return;
4190            }
4191        }
4192
4193        panic!("failed to list all tags for {GHCR_IO_IMAGE} in {MAX_TAG_REQUESTS} requests");
4194    }
4195
4196    #[tokio::test]
4197    #[ignore]
4198    async fn test_roundtrip_multiple_layers() {
4199        let _ = tracing_subscriber::fmt::try_init();
4200        let c = Client::new(ClientConfig {
4201            protocol: ClientProtocol::HttpsExcept(vec!["oci.registry.local".to_string()]),
4202            ..Default::default()
4203        });
4204        let src_image = Reference::try_from("registry:2.7.1").expect("failed to parse reference");
4205        let dest_image = Reference::try_from("oci.registry.local/registry:roundtrip-test")
4206            .expect("failed to parse reference");
4207
4208        let image = c
4209            .pull(
4210                &src_image,
4211                &RegistryAuth::Anonymous,
4212                vec![IMAGE_DOCKER_LAYER_GZIP_MEDIA_TYPE],
4213            )
4214            .await
4215            .expect("Failed to pull manifest");
4216        assert!(image.layers.len() > 1);
4217
4218        let ImageData {
4219            layers,
4220            config,
4221            manifest,
4222            ..
4223        } = image;
4224        c.push(
4225            &dest_image,
4226            &layers,
4227            config,
4228            &RegistryAuth::Anonymous,
4229            manifest,
4230        )
4231        .await
4232        .expect("Failed to pull manifest");
4233
4234        c.pull_image_manifest(&dest_image, &RegistryAuth::Anonymous)
4235            .await
4236            .expect("Failed to pull manifest");
4237    }
4238
4239    #[tokio::test]
4240    async fn test_hashable_image_layer() {
4241        use itertools::Itertools;
4242
4243        // First two should be identical; others differ
4244        let image_layers = Vec::from([
4245            ImageLayer {
4246                data: Bytes::from_static(&[0, 1, 2, 3]),
4247                media_type: "media_type".to_owned(),
4248                annotations: Some(BTreeMap::from([
4249                    ("0".to_owned(), "1".to_owned()),
4250                    ("2".to_owned(), "3".to_owned()),
4251                ])),
4252            },
4253            ImageLayer {
4254                data: Bytes::from_static(&[0, 1, 2, 3]),
4255                media_type: "media_type".to_owned(),
4256                annotations: Some(BTreeMap::from([
4257                    ("2".to_owned(), "3".to_owned()),
4258                    ("0".to_owned(), "1".to_owned()),
4259                ])),
4260            },
4261            ImageLayer {
4262                data: Bytes::from_static(&[0, 1, 2, 3]),
4263                media_type: "different_media_type".to_owned(),
4264                annotations: Some(BTreeMap::from([
4265                    ("0".to_owned(), "1".to_owned()),
4266                    ("2".to_owned(), "3".to_owned()),
4267                ])),
4268            },
4269            ImageLayer {
4270                data: Bytes::from_static(&[0, 1, 2]),
4271                media_type: "media_type".to_owned(),
4272                annotations: Some(BTreeMap::from([
4273                    ("0".to_owned(), "1".to_owned()),
4274                    ("2".to_owned(), "3".to_owned()),
4275                ])),
4276            },
4277            ImageLayer {
4278                data: Bytes::from_static(&[0, 1, 2, 3]),
4279                media_type: "media_type".to_owned(),
4280                annotations: Some(BTreeMap::from([
4281                    ("1".to_owned(), "0".to_owned()),
4282                    ("2".to_owned(), "3".to_owned()),
4283                ])),
4284            },
4285        ]);
4286
4287        assert_eq!(
4288            &image_layers[0], &image_layers[1],
4289            "image_layers[0] should equal image_layers[1]"
4290        );
4291        assert_ne!(
4292            &image_layers[0], &image_layers[2],
4293            "image_layers[0] should not equal image_layers[2]"
4294        );
4295        assert_ne!(
4296            &image_layers[0], &image_layers[3],
4297            "image_layers[0] should not equal image_layers[3]"
4298        );
4299        assert_ne!(
4300            &image_layers[0], &image_layers[4],
4301            "image_layers[0] should not equal image_layers[4]"
4302        );
4303        assert_ne!(
4304            &image_layers[2], &image_layers[3],
4305            "image_layers[2] should not equal image_layers[3]"
4306        );
4307        assert_ne!(
4308            &image_layers[2], &image_layers[4],
4309            "image_layers[2] should not equal image_layers[4]"
4310        );
4311        assert_ne!(
4312            &image_layers[3], &image_layers[4],
4313            "image_layers[3] should not equal image_layers[4]"
4314        );
4315
4316        let deduped: Vec<ImageLayer> = image_layers.clone().into_iter().unique().collect();
4317        assert_eq!(
4318            image_layers.len() - 1,
4319            deduped.len(),
4320            "after deduplication, there should be one less image layer"
4321        );
4322    }
4323
4324    #[tokio::test]
4325    #[cfg(feature = "test-registry")]
4326    async fn test_blob_exists() {
4327        let real_registry = registry_image_edge()
4328            .start()
4329            .await
4330            .expect("Failed to start registry container");
4331
4332        let server_port = real_registry
4333            .get_host_port_ipv4(5000)
4334            .await
4335            .expect("Failed to get port");
4336
4337        let client = Client::new(ClientConfig {
4338            protocol: ClientProtocol::HttpsExcept(vec![format!("localhost:{}", server_port)]),
4339            ..Default::default()
4340        });
4341
4342        let reference = Reference::try_from(format!("localhost:{server_port}/empty"))
4343            .expect("failed to parse reference");
4344
4345        assert!(!client
4346            .blob_exists(&reference, EMPTY_JSON_DIGEST)
4347            .await
4348            .expect("failed to check blob existence"));
4349        client
4350            .push_blob(&reference, EMPTY_JSON_BLOB.as_bytes(), EMPTY_JSON_DIGEST)
4351            .await
4352            .expect("failed to push empty json blob");
4353        assert!(client
4354            .blob_exists(&reference, EMPTY_JSON_DIGEST)
4355            .await
4356            .expect("failed to check blob existence"));
4357    }
4358
4359    #[rstest]
4360    #[case::chunked(false)]
4361    #[case::monolithic(true)]
4362    #[tokio::test]
4363    #[cfg(feature = "test-registry")]
4364    async fn test_push_stream(#[case] use_monolithic_push: bool) {
4365        let real_registry = registry_image_edge()
4366            .start()
4367            .await
4368            .expect("Failed to start registry container");
4369
4370        let server_port = real_registry
4371            .get_host_port_ipv4(5000)
4372            .await
4373            .expect("Failed to get port");
4374
4375        let mut client = Client::new(ClientConfig {
4376            protocol: ClientProtocol::HttpsExcept(vec![format!("localhost:{}", server_port)]),
4377            use_monolithic_push,
4378            ..Default::default()
4379        });
4380        client.push_chunk_size = 253;
4381
4382        // hash for a byte array counting 16 times from 0 to 255 ([0, 1, 2, ..., 255] * 16)
4383        let data_hash = "sha256:c8f5d0341d54d951a71b136e6e2afcb14d11ed8489a7ae126a8fee0df6ecf193";
4384        let repeat = 16usize;
4385        let chunk_size = 256usize; // Bytes::from_iter(0u8..=255)
4386        let data_stream = |n| {
4387            futures_util::stream::repeat(Bytes::from_iter(0u8..=255))
4388                .take(n)
4389                .map(Ok)
4390        };
4391        let size = Some((repeat * chunk_size) as u64);
4392
4393        let reference = Reference::try_from(format!("localhost:{server_port}/test-push-stream"))
4394            .expect("failed to parse reference");
4395
4396        // Sanity check: verify that the server rejects the push if the blob has a mismatched digest
4397        client
4398            .push_blob_stream(&reference, data_stream(1), data_hash, None)
4399            .await
4400            .expect_err("expected push to fail with mismatched digest");
4401
4402        // Now push the stream with the correct digest
4403        client
4404            .push_blob_stream(&reference, data_stream(repeat), data_hash, size)
4405            .await
4406            .expect("failed to push stream");
4407
4408        assert!(client
4409            .blob_exists(&reference, data_hash)
4410            .await
4411            .expect("failed to check blob existence"));
4412    }
4413
4414    #[tokio::test]
4415    #[cfg(feature = "test-registry")]
4416    async fn test_push_stream_monolithic_requires_size() {
4417        let real_registry = registry_image_edge()
4418            .start()
4419            .await
4420            .expect("Failed to start registry container");
4421
4422        let server_port = real_registry
4423            .get_host_port_ipv4(5000)
4424            .await
4425            .expect("Failed to get port");
4426
4427        let client = Client::new(ClientConfig {
4428            protocol: ClientProtocol::HttpsExcept(vec![format!("localhost:{}", server_port)]),
4429            use_monolithic_push: true,
4430            ..Default::default()
4431        });
4432
4433        let data_hash = "sha256:c8f5d0341d54d951a71b136e6e2afcb14d11ed8489a7ae126a8fee0df6ecf193";
4434        let data_stream = futures_util::stream::repeat(Bytes::from_iter(0u8..=255))
4435            .take(16)
4436            .map(Ok);
4437
4438        let reference = Reference::try_from(format!("localhost:{server_port}/test-push-stream"))
4439            .expect("failed to parse reference");
4440
4441        client
4442            .push_blob_stream(&reference, data_stream, data_hash, None)
4443            .await
4444            .expect_err("expected error when use_monolithic_push is true but size is None");
4445    }
4446
4447    #[tokio::test]
4448    #[cfg(feature = "test-registry")]
4449    async fn test_push_stream_chunked_without_digest() {
4450        let real_registry = registry_image_edge()
4451            .start()
4452            .await
4453            .expect("Failed to start registry container");
4454
4455        let server_port = real_registry
4456            .get_host_port_ipv4(5000)
4457            .await
4458            .expect("Failed to get port");
4459
4460        let mut client = Client::new(ClientConfig {
4461            protocol: ClientProtocol::HttpsExcept(vec![format!("localhost:{}", server_port)]),
4462            use_monolithic_push: true,
4463            ..Default::default()
4464        });
4465        client.push_chunk_size = 253;
4466
4467        // hash for a byte array counting 16 times from 0 to 255 ([0, 1, 2, ..., 255] * 16)
4468        let data_hash = "sha256:c8f5d0341d54d951a71b136e6e2afcb14d11ed8489a7ae126a8fee0df6ecf193";
4469        let repeat = 16usize;
4470        let data_stream = futures_util::stream::repeat(Bytes::from_iter(0u8..=255))
4471            .take(repeat)
4472            .map(Ok);
4473
4474        let reference =
4475            Reference::try_from(format!("localhost:{server_port}/test-push-stream-chunked"))
4476                .expect("failed to parse reference");
4477
4478        let response = client
4479            .push_blob_stream_chunked(&reference, data_stream)
4480            .await
4481            .expect("failed to push stream with chunked-only API");
4482
4483        assert_eq!(response.blob_digest, data_hash);
4484        assert_eq!(response.size, (repeat * 256) as u64);
4485        assert!(response.blob_url.ends_with(data_hash));
4486
4487        assert!(client
4488            .blob_exists(&reference, data_hash)
4489            .await
4490            .expect("failed to check blob existence"));
4491    }
4492
4493    #[tokio::test]
4494    #[cfg(feature = "test-registry")]
4495    async fn test_push_stream_chunked_empty_stream() {
4496        let real_registry = registry_image_edge()
4497            .start()
4498            .await
4499            .expect("Failed to start registry container");
4500
4501        let server_port = real_registry
4502            .get_host_port_ipv4(5000)
4503            .await
4504            .expect("Failed to get port");
4505
4506        let client = Client::new(ClientConfig {
4507            protocol: ClientProtocol::HttpsExcept(vec![format!("localhost:{}", server_port)]),
4508            ..Default::default()
4509        });
4510
4511        let data_stream = futures_util::stream::empty::<crate::errors::Result<Bytes>>();
4512
4513        let reference = Reference::try_from(format!(
4514            "localhost:{server_port}/test-push-stream-chunked-empty"
4515        ))
4516        .expect("failed to parse reference");
4517
4518        let err = client
4519            .push_blob_stream_chunked(&reference, data_stream)
4520            .await
4521            .expect_err("expected error when pushing an empty stream");
4522
4523        assert!(matches!(err, OciDistributionError::PushNoDataError));
4524    }
4525
4526    /// Push a minimal OCI image manifest (empty config blob, no layers) to the registry and
4527    /// return its digest.
4528    ///
4529    /// The manifest is pushed under the given `reference`.  The caller is responsible for
4530    /// authenticating the client for push operations beforehand.
4531    #[cfg(feature = "test-registry")]
4532    async fn push_minimal_manifest(
4533        client: &Client,
4534        reference: &Reference,
4535        artifact_type: Option<&str>,
4536    ) -> String {
4537        // Empty config blob.
4538        let config_data = b"{}";
4539        let config_digest = sha256_digest(config_data);
4540        client
4541            .push_blob(reference, config_data.as_slice(), &config_digest)
4542            .await
4543            .expect("failed to push config blob");
4544
4545        let manifest = OciImageManifest {
4546            schema_version: 2,
4547            media_type: Some(manifest::OCI_IMAGE_MEDIA_TYPE.to_string()),
4548            artifact_type: artifact_type.map(str::to_string),
4549            config: OciDescriptor {
4550                media_type: manifest::IMAGE_CONFIG_MEDIA_TYPE.to_string(),
4551                digest: config_digest.clone(),
4552                size: config_data.len() as i64,
4553                ..Default::default()
4554            },
4555            layers: vec![],
4556            subject: None,
4557            annotations: None,
4558        };
4559
4560        let oci_manifest = OciManifest::Image(manifest);
4561        client
4562            .push_manifest(reference, &oci_manifest)
4563            .await
4564            .expect("failed to push manifest")
4565            // push_manifest returns the URL; extract the digest from the end
4566            .rsplit('/')
4567            .next()
4568            .expect("manifest URL has no digest component")
4569            .to_string()
4570    }
4571
4572    /// `distribution/distribution` does not implement the native OCI referrers API — it returns 404 for
4573    /// `/v2/<name>/referrers/<digest>`.  These tests verify that `pull_referrers` correctly
4574    /// falls back to the referrers tag schema in that situation.
4575    ///
4576    /// Referrers support is being tracked upstream by this issue: https://github.com/distribution/distribution/issues/3716
4577    ///
4578    /// Setup overview:
4579    ///   1. Push a "target" image manifest to get its digest.
4580    ///   2. Manually build and push an `OciImageIndex` as the referrers tag
4581    ///      (`sha256-<target-digest>`), containing descriptor entries for two
4582    ///      hypothetical referrers with different `artifact_type` values.
4583    ///   3. Call `pull_referrers` and verify that the fallback is used and the
4584    ///      returned index contains the expected entries (both unfiltered and filtered).
4585    #[tokio::test]
4586    #[cfg(feature = "test-registry")]
4587    async fn test_pull_referrers_with_tag_schema_fallback() {
4588        let test_container = registry_image()
4589            .start()
4590            .await
4591            .expect("Failed to start registry container");
4592        let port = test_container
4593            .get_host_port_ipv4(5000)
4594            .await
4595            .expect("Failed to get port");
4596
4597        let client = Client::new(ClientConfig {
4598            protocol: ClientProtocol::HttpsExcept(vec![format!("localhost:{port}")]),
4599            ..Default::default()
4600        });
4601
4602        let repo = format!("localhost:{port}/referrers-test");
4603
4604        // --- Step 1: push the target manifest ---
4605        let target_ref: Reference = format!("{repo}:target").parse().unwrap();
4606        client
4607            .auth(
4608                &target_ref,
4609                &RegistryAuth::Anonymous,
4610                RegistryOperation::Push,
4611            )
4612            .await
4613            .expect("failed to authenticate for push");
4614        let target_digest = push_minimal_manifest(&client, &target_ref, None).await;
4615
4616        // --- Step 2: push a referrers tag index ---
4617        //
4618        // The tag is the target digest with ':' replaced by '-'.
4619        // We include two descriptors so we can also test artifact_type filtering:
4620        //   - one with artifact_type "application/vnd.test.sig"
4621        //   - one with artifact_type "application/vnd.test.sbom"
4622        const SIG_ARTIFACT_TYPE: &str = "application/vnd.test.sig";
4623        const SBOM_ARTIFACT_TYPE: &str = "application/vnd.test.sbom";
4624
4625        // Push two real minimal manifests to use as referrer entries.
4626        let sig_ref: Reference = format!("{repo}:sig").parse().unwrap();
4627        let sig_digest = push_minimal_manifest(&client, &sig_ref, Some(SIG_ARTIFACT_TYPE)).await;
4628
4629        let sbom_ref: Reference = format!("{repo}:sbom").parse().unwrap();
4630        let sbom_digest = push_minimal_manifest(&client, &sbom_ref, Some(SBOM_ARTIFACT_TYPE)).await;
4631
4632        // Pull the manifests back to get the accurate serialised sizes.
4633        let (sig_raw, _) = client
4634            .pull_manifest_raw(
4635                &sig_ref,
4636                &RegistryAuth::Anonymous,
4637                MIME_TYPES_DISTRIBUTION_MANIFEST,
4638            )
4639            .await
4640            .expect("failed to pull sig manifest raw");
4641        let sig_size = sig_raw.len() as i64;
4642
4643        let (sbom_raw, _) = client
4644            .pull_manifest_raw(
4645                &sbom_ref,
4646                &RegistryAuth::Anonymous,
4647                MIME_TYPES_DISTRIBUTION_MANIFEST,
4648            )
4649            .await
4650            .expect("failed to pull sbom manifest raw");
4651        let sbom_size = sbom_raw.len() as i64;
4652
4653        let referrers_index = OciImageIndex {
4654            schema_version: 2,
4655            media_type: Some(manifest::OCI_IMAGE_INDEX_MEDIA_TYPE.to_string()),
4656            artifact_type: None,
4657            annotations: None,
4658            manifests: vec![
4659                ImageIndexEntry {
4660                    media_type: manifest::OCI_IMAGE_MEDIA_TYPE.to_string(),
4661                    digest: sig_digest,
4662                    size: sig_size,
4663                    artifact_type: Some(SIG_ARTIFACT_TYPE.to_string()),
4664                    platform: None,
4665                    annotations: None,
4666                },
4667                ImageIndexEntry {
4668                    media_type: manifest::OCI_IMAGE_MEDIA_TYPE.to_string(),
4669                    digest: sbom_digest,
4670                    size: sbom_size,
4671                    artifact_type: Some(SBOM_ARTIFACT_TYPE.to_string()),
4672                    platform: None,
4673                    annotations: None,
4674                },
4675            ],
4676        };
4677
4678        let fallback_tag = target_digest.replace(':', "-");
4679        let tag_ref: Reference = format!("{repo}:{fallback_tag}").parse().unwrap();
4680        client
4681            .push_manifest(&tag_ref, &OciManifest::ImageIndex(referrers_index))
4682            .await
4683            .expect("failed to push referrers tag index");
4684
4685        // --- Step 3: pull_referrers — no filter, expect both entries ---
4686        let digest_ref = Reference::with_digest(
4687            format!("localhost:{port}"),
4688            "referrers-test".to_string(),
4689            target_digest.clone(),
4690        );
4691        client
4692            .auth(
4693                &digest_ref,
4694                &RegistryAuth::Anonymous,
4695                RegistryOperation::Pull,
4696            )
4697            .await
4698            .expect("failed to authenticate for pull");
4699
4700        let index = client
4701            .pull_referrers(&digest_ref, None)
4702            .await
4703            .expect("pull_referrers failed");
4704        assert_eq!(
4705            index.manifests.len(),
4706            2,
4707            "expected 2 referrers (unfiltered), got {:?}",
4708            index.manifests
4709        );
4710
4711        // --- Step 4: pull_referrers — filtered by SIG_ARTIFACT_TYPE ---
4712        let index_filtered = client
4713            .pull_referrers(&digest_ref, Some(SIG_ARTIFACT_TYPE))
4714            .await
4715            .expect("pull_referrers with artifact_type filter failed");
4716        assert_eq!(
4717            index_filtered.manifests.len(),
4718            1,
4719            "expected 1 referrer after filtering by {SIG_ARTIFACT_TYPE}, got {:?}",
4720            index_filtered.manifests
4721        );
4722        assert_eq!(
4723            index_filtered.manifests[0].artifact_type.as_deref(),
4724            Some(SIG_ARTIFACT_TYPE),
4725        );
4726    }
4727
4728    /// Verify that `pull_referrers` returns an empty index when neither the native referrers
4729    /// API nor the referrers tag schema returns anything — i.e. the target image exists but
4730    /// has no referrers at all.
4731    #[tokio::test]
4732    #[cfg(feature = "test-registry")]
4733    async fn test_pull_referrers_no_tag_schema() {
4734        let test_container = registry_image()
4735            .start()
4736            .await
4737            .expect("Failed to start registry container");
4738        let port = test_container
4739            .get_host_port_ipv4(5000)
4740            .await
4741            .expect("Failed to get port");
4742
4743        let client = Client::new(ClientConfig {
4744            protocol: ClientProtocol::HttpsExcept(vec![format!("localhost:{port}")]),
4745            ..Default::default()
4746        });
4747
4748        let repo = format!("localhost:{port}/referrers-none-test");
4749
4750        // Push a target manifest — but do NOT push any referrers tag.
4751        let target_ref: Reference = format!("{repo}:target").parse().unwrap();
4752        client
4753            .auth(
4754                &target_ref,
4755                &RegistryAuth::Anonymous,
4756                RegistryOperation::Push,
4757            )
4758            .await
4759            .expect("failed to authenticate for push");
4760        let target_digest = push_minimal_manifest(&client, &target_ref, None).await;
4761
4762        let digest_ref = Reference::with_digest(
4763            format!("localhost:{port}"),
4764            "referrers-none-test".to_string(),
4765            target_digest,
4766        );
4767        client
4768            .auth(
4769                &digest_ref,
4770                &RegistryAuth::Anonymous,
4771                RegistryOperation::Pull,
4772            )
4773            .await
4774            .expect("failed to authenticate for pull");
4775
4776        let index = client
4777            .pull_referrers(&digest_ref, None)
4778            .await
4779            .expect("pull_referrers should succeed (returning empty index)");
4780        assert!(
4781            index.manifests.is_empty(),
4782            "expected empty referrers index, got {:?}",
4783            index.manifests
4784        );
4785    }
4786}