Skip to main content

docker_registry/v2/manifest/
mod.rs

1use std::{iter::FromIterator, str::FromStr};
2
3use log::{debug, trace};
4use reqwest::{self, StatusCode, Url, header};
5
6use crate::{
7  errors::{Error, Result},
8  mediatypes,
9  v2::*,
10};
11
12mod manifest_schema1;
13pub use self::manifest_schema1::*;
14
15mod manifest_schema2;
16pub use self::manifest_schema2::{
17  ConfigBlob, ManifestList, ManifestObj, ManifestSchema2, ManifestSchema2Spec, Platform,
18};
19
20mod oci_image_index;
21pub use self::oci_image_index::{OciDescriptor, OciImageIndex};
22
23impl Client {
24  /// Fetch an image manifest.
25  ///
26  /// The name and reference parameters identify the image.
27  /// The reference may be either a tag or digest.
28  pub async fn get_manifest(&self, name: &str, reference: &str) -> Result<Manifest> {
29    self
30      .get_manifest_and_ref(name, reference)
31      .await
32      .map(|(manifest, _)| manifest)
33  }
34
35  /// Fetch an image manifest and return it with its digest.
36  ///
37  /// The name and reference parameters identify the image.
38  /// The reference may be either a tag or digest.
39  pub async fn get_manifest_and_ref(&self, name: &str, reference: &str) -> Result<(Manifest, Option<String>)> {
40    let url = self.build_url(name, reference)?;
41
42    let accept_headers = build_accept_headers(&self.accepted_types);
43
44    let client_spare0 = self.clone();
45
46    let res = self
47      .build_reqwest(Method::GET, url.clone())
48      .headers(accept_headers)
49      .send()
50      .await?;
51
52    let status = res.status();
53    trace!("GET '{}' status: {:?}", res.url(), status);
54
55    match status {
56      StatusCode::OK => {}
57      _ => return Err(ApiErrors::from(res).await),
58    }
59
60    let headers = res.headers();
61    let content_digest = match headers.get("docker-content-digest") {
62      Some(content_digest_value) => Some(content_digest_value.to_str()?.to_string()),
63      None => {
64        debug!("cannot find manifestref in headers");
65        None
66      }
67    };
68
69    let header_content_type = headers.get(header::CONTENT_TYPE);
70    let media_type = evaluate_media_type(header_content_type, &url)?;
71
72    trace!("content-type: {header_content_type:?}, media-type: {media_type:?}");
73
74    match media_type {
75      mediatypes::MediaTypes::ManifestV2S1Signed => Ok((
76        res.json::<ManifestSchema1Signed>().await.map(Manifest::S1Signed)?,
77        content_digest,
78      )),
79      mediatypes::MediaTypes::ManifestV2S2 | mediatypes::MediaTypes::OciImageManifest => {
80        let m = res.json::<ManifestSchema2Spec>().await?;
81        Ok((
82          m.fetch_config_blob(client_spare0, name.to_string())
83            .await
84            .map(Manifest::S2)?,
85          content_digest,
86        ))
87      }
88      mediatypes::MediaTypes::ManifestList => {
89        Ok((res.json::<ManifestList>().await.map(Manifest::ML)?, content_digest))
90      }
91      mediatypes::MediaTypes::OciImageIndexV1 => {
92        Ok((res.json::<OciImageIndex>().await.map(Manifest::OciIndex)?, content_digest))
93      }
94      unsupported => Err(Error::UnsupportedMediaType(unsupported)),
95    }
96  }
97
98  fn build_url(&self, name: &str, reference: &str) -> Result<Url> {
99    let ep = format!("{}/v2/{}/manifests/{}", self.base_url.clone(), name, reference);
100    reqwest::Url::parse(&ep).map_err(Error::from)
101  }
102
103  /// Fetch content digest for a particular tag.
104  pub async fn get_manifestref(&self, name: &str, reference: &str) -> Result<Option<String>> {
105    let url = self.build_url(name, reference)?;
106
107    let accept_headers = build_accept_headers(&self.accepted_types);
108
109    let res = self
110      .build_reqwest(Method::HEAD, url)
111      .headers(accept_headers)
112      .send()
113      .await?;
114
115    let status = res.status();
116    trace!("HEAD '{}' status: {:?}", res.url(), status);
117
118    match status {
119      StatusCode::OK => {}
120      _ => return Err(ApiErrors::from(res).await),
121    }
122
123    let headers = res.headers();
124    let content_digest = match headers.get("docker-content-digest") {
125      Some(content_digest_value) => Some(content_digest_value.to_str()?.to_string()),
126      None => {
127        debug!("cannot find manifestref in headers");
128        None
129      }
130    };
131    Ok(content_digest)
132  }
133
134  /// Check if an image manifest exists.
135  ///
136  /// The name and reference parameters identify the image.
137  /// The reference may be either a tag or digest.
138  pub async fn has_manifest(
139    &self,
140    name: &str,
141    reference: &str,
142    mediatypes: Option<&[&str]>,
143  ) -> Result<Option<mediatypes::MediaTypes>> {
144    let url = self.build_url(name, reference)?;
145    let accept_types = match mediatypes {
146      None => {
147        let m = mediatypes::MediaTypes::ManifestV2S2.to_mime();
148        vec![m]
149      }
150      Some(v) => to_mimes(v),
151    };
152
153    let mut accept_headers = header::HeaderMap::with_capacity(accept_types.len());
154    for accept_type in accept_types {
155      let header_value =
156        header::HeaderValue::from_str(accept_type.as_ref()).expect("mime type is always valid header value");
157      accept_headers.insert(header::ACCEPT, header_value);
158    }
159
160    trace!("HEAD {url:?}");
161
162    let r = self
163      .build_reqwest(Method::HEAD, url.clone())
164      .headers(accept_headers)
165      .send()
166      .await
167      .map_err(Error::from)?;
168
169    let status = r.status();
170
171    trace!("Manifest check status '{:?}', headers '{:?}", r.status(), r.headers(),);
172
173    match status {
174      StatusCode::MOVED_PERMANENTLY | StatusCode::TEMPORARY_REDIRECT | StatusCode::FOUND | StatusCode::OK => {
175        let media_type = evaluate_media_type(r.headers().get(header::CONTENT_TYPE), r.url())?;
176        trace!("Manifest media-type: {media_type:?}");
177        Ok(Some(media_type))
178      }
179      StatusCode::NOT_FOUND => Ok(None),
180      _ => Err(ApiErrors::from(r).await),
181    }
182  }
183}
184
185fn to_mimes(v: &[&str]) -> Vec<mime::Mime> {
186  v.iter()
187    .filter_map(|x| {
188      let mtype = mediatypes::MediaTypes::from_str(x);
189      match mtype {
190        Ok(m) => Some(m.to_mime()),
191        _ => None,
192      }
193    })
194    .collect()
195}
196
197// Evaluate the `MediaTypes` from the the request header.
198fn evaluate_media_type(
199  content_type: Option<&reqwest::header::HeaderValue>,
200  url: &Url,
201) -> Result<mediatypes::MediaTypes> {
202  let header_content_type = content_type
203    .map(|hv| hv.to_str())
204    .map(std::result::Result::unwrap_or_default);
205
206  let is_pulp_based = url.path().starts_with("/pulp/docker/v2");
207
208  match (header_content_type, is_pulp_based) {
209    (Some(header_value), false) => mediatypes::MediaTypes::from_str(header_value).map_err(strum::ParseError::into),
210    (None, false) => Err(Error::MediaTypeSniff),
211    (Some(header_value), true) => {
212      // TODO: remove this workaround once Satellite returns a proper content-type here
213      match header_value {
214        "application/x-troff-man" => {
215          trace!("Applying workaround for pulp-based registries, e.g. Satellite");
216          mediatypes::MediaTypes::from_str("application/vnd.docker.distribution.manifest.v1+prettyjws")
217            .map_err(strum::ParseError::into)
218        }
219        _ => {
220          debug!(
221            "Received content-type '{header_value}' from pulp-based registry. Feeling lucky and trying to parse it...",
222          );
223          mediatypes::MediaTypes::from_str(header_value).map_err(strum::ParseError::into)
224        }
225      }
226    }
227    (None, true) => {
228      trace!("Applying workaround for pulp-based registries, e.g. Satellite");
229      mediatypes::MediaTypes::from_str("application/vnd.docker.distribution.manifest.v1+prettyjws")
230        .map_err(strum::ParseError::into)
231    }
232  }
233}
234
235fn build_accept_headers(accepted_types: &[(MediaTypes, Option<f64>)]) -> header::HeaderMap {
236  let accepted_types_string = accepted_types
237    .iter()
238    .map(|(ty, q)| {
239      format!(
240        "{}{}",
241        ty,
242        match q {
243          None => String::default(),
244          Some(v) => format!("; q={v}"),
245        }
246      )
247    })
248    .collect::<Vec<_>>()
249    .join(",");
250
251  header::HeaderMap::from_iter(vec![(
252    header::ACCEPT,
253    header::HeaderValue::from_str(&accepted_types_string)
254      .expect("should be always valid because both float and mime type only use allowed ASCII chard"),
255  )])
256}
257
258/// Umbrella type for common actions on the different manifest schema types
259#[derive(Clone, Debug)]
260pub enum Manifest {
261  S1Signed(manifest_schema1::ManifestSchema1Signed),
262  S2(manifest_schema2::ManifestSchema2),
263  ML(manifest_schema2::ManifestList),
264  OciIndex(oci_image_index::OciImageIndex),
265}
266
267#[derive(Debug, thiserror::Error)]
268pub enum ManifestError {
269  #[error("no architecture in manifest")]
270  NoArchitecture,
271  #[error("architecture mismatch")]
272  ArchitectureMismatch,
273  #[error("manifest {0} does not support the 'layer_digests' method")]
274  LayerDigestsUnsupported(String),
275  #[error("manifest {0} does not support the 'architecture' method")]
276  ArchitectureNotSupported(String),
277}
278
279impl Manifest {
280  /// List digests of all layers referenced by this manifest, if available.
281  /// For ManifestList, returns the digests of all the manifest list images.
282  ///
283  /// As manifest list images only contain digests of the
284  /// images contained in the manifest, the `layers_digests`
285  /// function returns the digests of all the images
286  /// contained in the ManifestList instead of individual
287  /// layers of the manifests.
288  /// The layers of a specific image from manifest list can
289  /// be obtained using the digest of the image from the
290  /// manifest list and getting its manifest and manifestref
291  /// (get_manifest_and_ref()) and using this manifest of
292  /// the individual image to get the layers.
293  ///
294  /// The returned layers list for non ManifestList images is ordered starting with the base image first.
295  pub fn layers_digests(&self, architecture: Option<&str>) -> Result<Vec<String>> {
296    match (self, self.architectures(), architecture) {
297      (Manifest::S1Signed(m), _, None) => Ok(m.get_layers()),
298      (Manifest::S2(m), _, None) => Ok(m.get_layers()),
299      (Manifest::S1Signed(m), Ok(ref self_architectures), Some(ref a)) => {
300        let self_a = self_architectures.first().ok_or(ManifestError::NoArchitecture)?;
301        if self_a != a {
302          return Err(ManifestError::ArchitectureMismatch.into());
303        }
304        Ok(m.get_layers())
305      }
306      (Manifest::S2(m), Ok(ref self_architectures), Some(ref a)) => {
307        let self_a = self_architectures.first().ok_or(ManifestError::NoArchitecture)?;
308        if self_a != a {
309          return Err(ManifestError::ArchitectureMismatch.into());
310        }
311        Ok(m.get_layers())
312      }
313      (Manifest::ML(m), _, _) => Ok(m.get_digests()),
314      (Manifest::OciIndex(m), _, _) => Ok(m.get_digests()),
315      _ => Err(ManifestError::LayerDigestsUnsupported(format!("{self:?}")).into()),
316    }
317  }
318
319  /// The architectures of the image the manifest points to, if available.
320  pub fn architectures(&self) -> Result<Vec<String>> {
321    match self {
322      Manifest::S1Signed(m) => Ok([m.architecture.clone()].to_vec()),
323      Manifest::S2(m) => Ok([m.architecture()].to_vec()),
324      Manifest::ML(m) => Ok(m.architectures()),
325      Manifest::OciIndex(m) => Ok(m.architectures()),
326    }
327  }
328}
329
330#[cfg(test)]
331mod tests {
332  use test_case::test_case;
333
334  use super::*;
335  use crate::v2::Client;
336
337  #[test_case("not-gcr.io" => "application/vnd.docker.distribution.manifest.v2+json; q=0.5,application/vnd.docker.distribution.manifest.v1+prettyjws; q=0.4,application/vnd.docker.distribution.manifest.list.v2+json; q=0.5,application/vnd.oci.image.manifest.v1+json; q=0.5,application/vnd.oci.image.index.v1+json; q=0.5"; "Not gcr registry")]
338  #[test_case("gcr.io" => "application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json"; "gcr.io")]
339  #[test_case("foobar.gcr.io" => "application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json"; "Custom gcr.io registry")]
340  #[test_case("foobar.k8s.io" => "application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json"; "Custom k8s.io registry")]
341  fn gcr_io_accept_headers(registry: &str) -> String {
342    let client_builder = Client::configure().registry(registry);
343    let client = client_builder.build().unwrap();
344    let header_map = build_accept_headers(&client.accepted_types);
345    header_map.get(header::ACCEPT).unwrap().to_str().unwrap().to_string()
346  }
347
348  #[test_case(None => "application/vnd.docker.distribution.manifest.v2+json; q=0.5,application/vnd.docker.distribution.manifest.v1+prettyjws; q=0.4,application/vnd.docker.distribution.manifest.list.v2+json; q=0.5,application/vnd.oci.image.manifest.v1+json; q=0.5,application/vnd.oci.image.index.v1+json; q=0.5"; "Default settings")]
349  #[test_case(Some(vec![
350        (MediaTypes::ManifestV2S2, Some(0.5)),
351        (MediaTypes::ManifestV2S1Signed, Some(0.2)),
352        (MediaTypes::ManifestList, Some(0.5)),
353        (MediaTypes::OciImageManifest, Some(0.2)),
354    ]) => "application/vnd.docker.distribution.manifest.v2+json; q=0.5,application/vnd.docker.distribution.manifest.v1+prettyjws; q=0.2,application/vnd.docker.distribution.manifest.list.v2+json; q=0.5,application/vnd.oci.image.manifest.v1+json; q=0.2"; "Custom accept types with weight")]
355  #[test_case(Some(vec![
356        (MediaTypes::ManifestV2S2, None),
357        (MediaTypes::ManifestList, None),
358        (MediaTypes::OciImageIndexV1, None),
359    ]) => "application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json"; "Custom accept types, no weight")]
360  fn custom_accept_headers(accept_headers: Option<Vec<(MediaTypes, Option<f64>)>>) -> String {
361    let registry = "https://example.com";
362
363    let client_builder = Client::configure().registry(registry).accepted_types(accept_headers);
364    let client = client_builder.build().unwrap();
365    let header_map = build_accept_headers(&client.accepted_types);
366    header_map.get(header::ACCEPT).unwrap().to_str().unwrap().to_string()
367  }
368}