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