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);
+}