diff --git a/Cargo.toml b/Cargo.toml index d50dfdd9..85235baa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ log = "0.4" mime = "0.3" regex = "^1.1.0" serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["raw_value"] } serde_ignored = "0.1" strum = "0.23" strum_macros = "0.23" diff --git a/src/errors.rs b/src/errors.rs index d063c0aa..a96ad551 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -3,6 +3,8 @@ #[non_exhaustive] #[derive(thiserror::Error, Debug)] pub enum Error { + #[error("Api Error: {0}")] + Api(#[from] crate::v2::ApiErrors), #[error("base64 decode error")] Base64Decode(#[from] base64::DecodeError), #[error("header parse error")] diff --git a/src/mediatypes.rs b/src/mediatypes.rs index fb09c4d0..ed780f28 100644 --- a/src/mediatypes.rs +++ b/src/mediatypes.rs @@ -32,6 +32,14 @@ pub enum MediaTypes { #[strum(serialize = "application/vnd.docker.container.image.v1+json")] #[strum(props(Sub = "vnd.docker.container.image.v1+json"))] ContainerConfigV1, + /// OCI Manifest + #[strum(serialize = "application/vnd.oci.image.manifest.v1+json")] + #[strum(props(Sub = "vnd.oci.image.manifest.v1+json"))] + OciImageManifest, + // OCI Image index + #[strum(serialize = "application/vnd.oci.image.index.v1+json")] + #[strum(props(Sub = "vnd.oci.image.index.v1+json"))] + OciImageIndexV1, /// Generic JSON #[strum(serialize = "application/json")] #[strum(props(Sub = "json"))] @@ -55,6 +63,8 @@ impl MediaTypes { } ("vnd.docker.image.rootfs.diff.tar.gzip", _) => Ok(MediaTypes::ImageLayerTgz), ("vnd.docker.container.image.v1", "json") => Ok(MediaTypes::ContainerConfigV1), + ("vnd.oci.image.manifest.v1", "json") => Ok(MediaTypes::OciImageManifest), + ("vnd.oci.image.index.v1", "json") => Ok(MediaTypes::OciImageIndexV1), _ => Err(crate::Error::UnknownMimeType(mtype.clone())), } } diff --git a/src/v2/blobs.rs b/src/v2/blobs.rs index 0b83091b..ae772233 100644 --- a/src/v2/blobs.rs +++ b/src/v2/blobs.rs @@ -45,7 +45,7 @@ impl Client { } Ok(BlobResponse::new(resp, ContentDigest::try_new(digest)?)) } - Err(_) if status.is_client_error() => Err(Error::Client { status }), + Err(_) if status.is_client_error() => Err(ApiErrors::from(resp).await), Err(_) if status.is_server_error() => Err(Error::Server { status }), Err(_) => { error!("Received unexpected HTTP status '{}'", status); diff --git a/src/v2/config.rs b/src/v2/config.rs index 20d2df36..97cc60bd 100644 --- a/src/v2/config.rs +++ b/src/v2/config.rs @@ -114,6 +114,8 @@ impl Config { (MediaTypes::ManifestV2S2, Some(0.5)), (MediaTypes::ManifestV2S1Signed, Some(0.4)), (MediaTypes::ManifestList, Some(0.5)), + (MediaTypes::OciImageManifest, Some(0.5)), + (MediaTypes::OciImageIndexV1, Some(0.5)), ], // GCR incorrectly parses `q` parameters, so we use special Accept for it. // Bug: https://issuetracker.google.com/issues/159827510. @@ -122,6 +124,8 @@ impl Config { (MediaTypes::ManifestV2S2, None), (MediaTypes::ManifestV2S1Signed, None), (MediaTypes::ManifestList, None), + (MediaTypes::OciImageManifest, None), + (MediaTypes::OciImageIndexV1, None), ], }, }; diff --git a/src/v2/manifest/manifest_schema2.rs b/src/v2/manifest/manifest_schema2.rs index 2c5529d2..49c22fac 100644 --- a/src/v2/manifest/manifest_schema2.rs +++ b/src/v2/manifest/manifest_schema2.rs @@ -1,6 +1,8 @@ -use crate::errors::{Error, Result}; +use crate::errors::Result; use reqwest::Method; +pub use crate::v2::ApiErrors; + /// Manifest version 2 schema 2. /// /// Specification is at . @@ -112,7 +114,7 @@ impl ManifestSchema2Spec { trace!("GET {:?}: {}", url, &status); if !status.is_success() { - return Err(Error::UnexpectedHttpStatus(status)); + return Err(ApiErrors::from(r).await); } let config_blob = r.json::().await?; diff --git a/src/v2/manifest/mod.rs b/src/v2/manifest/mod.rs index aaa54239..74b60a04 100644 --- a/src/v2/manifest/mod.rs +++ b/src/v2/manifest/mod.rs @@ -50,7 +50,7 @@ impl Client { match status { StatusCode::OK => {} - _ => return Err(Error::UnexpectedHttpStatus(status)), + _ => return Err(ApiErrors::from(res).await), } let headers = res.headers(); @@ -78,7 +78,7 @@ impl Client { .map(Manifest::S1Signed)?, content_digest, )), - mediatypes::MediaTypes::ManifestV2S2 => { + mediatypes::MediaTypes::ManifestV2S2 | mediatypes::MediaTypes::OciImageManifest => { let m = res.json::().await?; Ok(( m.fetch_config_blob(client_spare0, name.to_string()) @@ -87,7 +87,7 @@ impl Client { content_digest, )) } - mediatypes::MediaTypes::ManifestList => Ok(( + mediatypes::MediaTypes::ManifestList | mediatypes::MediaTypes::OciImageIndexV1 => Ok(( res.json::().await.map(Manifest::ML)?, content_digest, )), @@ -122,7 +122,7 @@ impl Client { match status { StatusCode::OK => {} - _ => return Err(Error::UnexpectedHttpStatus(status)), + _ => return Err(ApiErrors::from(res).await), } let headers = res.headers(); @@ -190,7 +190,7 @@ impl Client { Ok(Some(media_type)) } StatusCode::NOT_FOUND => Ok(None), - _ => Err(Error::UnexpectedHttpStatus(status)), + _ => Err(ApiErrors::from(r).await), } } } diff --git a/src/v2/mod.rs b/src/v2/mod.rs index 034d49fa..5a61717c 100644 --- a/src/v2/mod.rs +++ b/src/v2/mod.rs @@ -26,11 +26,12 @@ //! # run().await.unwrap(); //! # } //! ``` +use std::fmt; -use crate::errors::*; +use crate::errors::{self, *}; use crate::mediatypes::MediaTypes; use futures::prelude::*; -use reqwest::{Method, StatusCode, Url}; +use reqwest::{Method, StatusCode, Url, Response}; mod config; pub use self::config::Config; @@ -128,13 +129,66 @@ impl Client { } #[derive(Debug, Default, Deserialize, Serialize)] -struct ApiError { +pub struct ApiError { code: String, - message: String, - detail: String, + message: Option, + detail: Option>, } -#[derive(Debug, Default, Deserialize, Serialize)] -struct Errors { - errors: Vec, +#[derive(Debug, Default, Deserialize, Serialize, thiserror::Error)] +pub struct ApiErrors { + errors: Option>, +} + +impl ApiError { + /// Return the API error code. + pub fn code(&self) -> &str { + &self.code + } + + pub fn message(&self) -> Option<&str> { + self.message.as_deref() + } +} +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "({})", self.code)?; + if let Some(message) = &self.message { + write!(f, ", message: {}", message)?; + } + if let Some(detail) = &self.detail { + write!(f, ", detail: {}", detail)?; + } + Ok(()) + } +} + +impl ApiErrors { + /// Create a new ApiErrors from a API Json response. + /// Returns an ApiError if the content is a valid per + /// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes + pub async fn from(r: Response) -> errors::Error { + match r.json::().await { + Ok(e) => errors::Error::Api(e), + Err(e) => errors::Error::Reqwest(e), + } + } + + /// Returns the errors returned by the API. + pub fn errors(&self) -> &Option> { + &self.errors + } +} + + +impl fmt::Display for ApiErrors { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.errors.is_none() { + return Ok(()); + } + for error in self.errors.as_ref().unwrap().iter() { + write!(f, "({})", error)? + } + Ok(()) +} } diff --git a/tests/fixtures/api_error_fixture_with_detail.json b/tests/fixtures/api_error_fixture_with_detail.json new file mode 100644 index 00000000..a40a6c64 --- /dev/null +++ b/tests/fixtures/api_error_fixture_with_detail.json @@ -0,0 +1,16 @@ +{ + "errors": [ + { + "code": "UNAUTHORIZED", + "message": "authentication required", + "detail": [ + { + "Type": "repository", + "Class":"", + "Name":"some/image", + "Action":"some_action" + } + ] + } + ] +} diff --git a/tests/fixtures/api_error_fixture_without_detail.json b/tests/fixtures/api_error_fixture_without_detail.json new file mode 100644 index 00000000..1c314b5c --- /dev/null +++ b/tests/fixtures/api_error_fixture_without_detail.json @@ -0,0 +1,8 @@ +{ + "errors": [ + { + "code": "UNAUTHORIZED", + "message": "authentication required" + } + ] +} diff --git a/tests/fixtures/manifest_oci_image_manifest.json b/tests/fixtures/manifest_oci_image_manifest.json new file mode 100644 index 00000000..9ce72b16 --- /dev/null +++ b/tests/fixtures/manifest_oci_image_manifest.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "size": 2297, + "digest": "sha256:7324f32f94760ec1dc237858203ea520fc4e6dfbd0bc018f392e54b1392ac722" + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "size": 28028344, + "digest": "sha256:b2afc8f0dccbc5496c814ae03ac3fff7e86393abd18b2d2910a9c489bfe64311" + } + ] +} diff --git a/tests/manifest.rs b/tests/manifest.rs index 9fb2c88f..a8505b79 100644 --- a/tests/manifest.rs +++ b/tests/manifest.rs @@ -69,6 +69,13 @@ fn test_manifest_v2s2() -> Result<(), Box> { Ok(()) } +#[test] +fn test_deserialize_oci_image_manifest() { + let f = fs::File::open("tests/fixtures/manifest_oci_image_manifest.json").expect("Missing fixture"); + let bufrd = io::BufReader::new(f); + let _manif: dkregistry::v2::manifest::ManifestSchema2Spec = serde_json::from_reader(bufrd).unwrap(); +} + #[test] fn test_deserialize_manifest_list_v2() { let f = fs::File::open("tests/fixtures/manifest_list_v2.json").expect("Missing fixture"); diff --git a/tests/mock/base_client.rs b/tests/mock/base_client.rs index 465cfaf2..003b646b 100644 --- a/tests/mock/base_client.rs +++ b/tests/mock/base_client.rs @@ -5,6 +5,8 @@ extern crate tokio; use self::mockito::mock; use self::tokio::runtime::Runtime; +use dkregistry::errors; + static API_VERSION_K: &'static str = "Docker-Distribution-API-Version"; static API_VERSION_V: &'static str = "registry/2.0"; @@ -91,6 +93,44 @@ fn test_base_custom_useragent() { mockito::reset(); } +/// Test that we properly deserialize API error payload and can access error contents. +#[test_case::test_case("tests/fixtures/api_error_fixture_with_detail.json".to_string() ; "API error with detail")] +#[test_case::test_case("tests/fixtures/api_error_fixture_without_detail.json".to_string() ; "API error without detail")] +fn test_base_api_error(fixture: String) { + let ua = "custom-ua/1.0"; + let image = "fake/image"; + let version = "fakeversion"; + let addr = mockito::server_address().to_string(); + let _m = mock("GET", format!("/v2/{}/manifests/{}", image, version).as_str()) + .match_header("user-agent", ua) + .with_status(404) + .with_header(API_VERSION_K, API_VERSION_V) + .with_body_from_file(fixture) + .create(); + + let runtime = Runtime::new().unwrap(); + let dclient = dkregistry::v2::Client::configure() + .registry(&addr) + .insecure_registry(true) + .user_agent(Some(ua.to_string())) + .username(None) + .password(None) + .build() + .unwrap(); + + let futcheck = dclient.get_manifest(image, version); + + let res = runtime.block_on(futcheck); + assert_eq!(res.is_err(), true); + + assert!(matches!(res, Err(errors::Error::Api(_)))); + if let errors::Error::Api(e) = res.unwrap_err() { + assert_eq!(e.errors().as_ref().unwrap()[0].code(), "UNAUTHORIZED"); + assert_eq!(e.errors().as_ref().unwrap()[0].message().unwrap(), "authentication required"); + } + mockito::reset(); +} + mod test_custom_root_certificate { use dkregistry::v2::Client; use native_tls::{HandshakeError, Identity, TlsStream}; diff --git a/tests/net/docker_io/mod.rs b/tests/net/docker_io/mod.rs index 82d269d7..d9bd492c 100644 --- a/tests/net/docker_io/mod.rs +++ b/tests/net/docker_io/mod.rs @@ -2,6 +2,7 @@ extern crate dkregistry; extern crate tokio; use self::tokio::runtime::Runtime; +use dkregistry::errors; static REGISTRY: &'static str = "registry-1.docker.io"; @@ -85,3 +86,54 @@ fn test_dockerio_anonymous_auth() { let res = runtime.block_on(futcheck); assert_eq!(res.is_ok(), true); } + +/// Check that when requesting an image that does not exist +/// we get an Api error. +#[test] +fn test_dockerio_anonymous_non_existent_image() { + let runtime = Runtime::new().unwrap(); + let image = "bad/image"; + let version = "latest"; + let login_scope = format!("repository:{}:pull", image); + let scopes = vec![login_scope.as_str()]; + let dclient_future = dkregistry::v2::Client::configure() + .registry(REGISTRY) + .insecure_registry(false) + .username(None) + .password(None) + .build() + .unwrap() + .authenticate(scopes.as_slice()); + + let dclient = runtime.block_on(dclient_future).unwrap(); + let futcheck = dclient.get_manifest(image, version); + + let res = runtime.block_on(futcheck); + assert_eq!(res.is_ok(), false); + assert!(matches!(res, Err(errors::Error::Api(_)))); +} + +/// Test that we can deserialize OCI image manifest, as is +/// returned for s390x/ubuntu image. +#[test] +fn test_dockerio_anonymous_auth_oci_manifest() { + let runtime = Runtime::new().unwrap(); + let image = "s390x/ubuntu"; + let version = "latest"; + let login_scope = format!("repository:{}:pull", image); + let scopes = vec![login_scope.as_str()]; + let dclient_future = dkregistry::v2::Client::configure() + .registry(REGISTRY) + .insecure_registry(false) + .username(None) + .password(None) + .build() + .unwrap() + .authenticate(scopes.as_slice()); + + let dclient = runtime.block_on(dclient_future).unwrap(); + let futcheck = dclient.get_manifest(image, version); + + let res = runtime.block_on(futcheck); + assert_eq!(res.is_ok(), true); +}