Skip to main content

ferro_oci_server/
error.rs

1// SPDX-License-Identifier: Apache-2.0
2//! OCI error response shape and mapping to HTTP status codes.
3//!
4//! Spec: OCI Distribution Spec v1.1 §3.1 "Error codes".
5//!
6//! Every 4xx/5xx response returned by the handlers in this crate must be
7//! `application/json` with a body of the form:
8//!
9//! ```json
10//! { "errors": [ { "code": "...", "message": "...", "detail": { ... } } ] }
11//! ```
12//!
13//! The set of valid `code` values is fixed by the specification — the
14//! conformance suite greps response bodies for these exact strings, so
15//! the enum here must never drift from §3.1.
16
17use axum::Json;
18use axum::response::{IntoResponse, Response};
19use http::StatusCode;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23/// Registry error codes defined by OCI Distribution Spec v1.1 §3.1.
24///
25/// The `Display` impl emits the uppercase-with-underscores string that
26/// appears on the wire.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum OciErrorCode {
29    /// Blob unknown to registry. Spec §3.1.
30    BlobUnknown,
31    /// Blob upload invalid. Spec §3.1.
32    BlobUploadInvalid,
33    /// Blob upload unknown to registry. Spec §3.1.
34    BlobUploadUnknown,
35    /// Provided digest did not match uploaded content.
36    DigestInvalid,
37    /// Blob unknown to registry (during manifest PUT).
38    ManifestBlobUnknown,
39    /// Manifest invalid.
40    ManifestInvalid,
41    /// Manifest unknown to registry.
42    ManifestUnknown,
43    /// Invalid repository name.
44    NameInvalid,
45    /// Repository name not known to registry.
46    NameUnknown,
47    /// Provided length did not match content length.
48    SizeInvalid,
49    /// Authentication required.
50    Unauthorized,
51    /// Requested access to the resource is denied.
52    Denied,
53    /// The operation is unsupported.
54    Unsupported,
55    /// The client has been rate-limited.
56    TooManyRequests,
57}
58
59impl OciErrorCode {
60    /// Wire string used in the JSON error body.
61    #[must_use]
62    pub const fn as_str(self) -> &'static str {
63        match self {
64            Self::BlobUnknown => "BLOB_UNKNOWN",
65            Self::BlobUploadInvalid => "BLOB_UPLOAD_INVALID",
66            Self::BlobUploadUnknown => "BLOB_UPLOAD_UNKNOWN",
67            Self::DigestInvalid => "DIGEST_INVALID",
68            Self::ManifestBlobUnknown => "MANIFEST_BLOB_UNKNOWN",
69            Self::ManifestInvalid => "MANIFEST_INVALID",
70            Self::ManifestUnknown => "MANIFEST_UNKNOWN",
71            Self::NameInvalid => "NAME_INVALID",
72            Self::NameUnknown => "NAME_UNKNOWN",
73            Self::SizeInvalid => "SIZE_INVALID",
74            Self::Unauthorized => "UNAUTHORIZED",
75            Self::Denied => "DENIED",
76            Self::Unsupported => "UNSUPPORTED",
77            Self::TooManyRequests => "TOOMANYREQUESTS",
78        }
79    }
80
81    /// HTTP status code recommended by the spec for this code.
82    #[must_use]
83    pub const fn status(self) -> StatusCode {
84        match self {
85            Self::BlobUnknown
86            | Self::BlobUploadUnknown
87            | Self::ManifestBlobUnknown
88            | Self::ManifestUnknown
89            | Self::NameUnknown => StatusCode::NOT_FOUND,
90            Self::BlobUploadInvalid
91            | Self::DigestInvalid
92            | Self::ManifestInvalid
93            | Self::NameInvalid
94            | Self::SizeInvalid => StatusCode::BAD_REQUEST,
95            Self::Unauthorized => StatusCode::UNAUTHORIZED,
96            Self::Denied => StatusCode::FORBIDDEN,
97            Self::Unsupported => StatusCode::METHOD_NOT_ALLOWED,
98            Self::TooManyRequests => StatusCode::TOO_MANY_REQUESTS,
99        }
100    }
101}
102
103impl std::fmt::Display for OciErrorCode {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.write_str(self.as_str())
106    }
107}
108
109/// One entry in the error response array.
110///
111/// Spec §3.1 requires `code` and `message`; `detail` is optional and may
112/// be any JSON value.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct OciErrorInfo {
115    /// Error code string (uppercase, underscore-separated).
116    pub code: String,
117    /// Human-readable message.
118    pub message: String,
119    /// Optional machine-readable detail payload.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub detail: Option<Value>,
122}
123
124/// Top-level JSON response body for an error.
125///
126/// Spec §3.1: `{"errors": [...]}`.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct OciErrorBody {
129    /// Errors, non-empty.
130    pub errors: Vec<OciErrorInfo>,
131}
132
133/// The error type returned by every handler in this crate.
134///
135/// It carries both the spec-defined [`OciErrorCode`] (which determines
136/// the JSON `code` and HTTP status) and a free-form message. An optional
137/// status override covers cases like `405 Method Not Allowed` for a
138/// manifest DELETE-by-tag, which spec §3.1 doesn't have a dedicated
139/// code for.
140#[derive(Debug, Clone, thiserror::Error)]
141#[error("{code}: {message}")]
142pub struct OciError {
143    /// Spec-defined error code.
144    pub code: OciErrorCode,
145    /// Human-readable message.
146    pub message: String,
147    /// Machine-readable detail, forwarded into the response body.
148    pub detail: Option<Value>,
149    /// Optional status override (e.g. 405 for DELETE-by-tag).
150    pub status_override: Option<StatusCode>,
151}
152
153impl OciError {
154    /// Build a new error from a code and message.
155    pub fn new(code: OciErrorCode, message: impl Into<String>) -> Self {
156        Self {
157            code,
158            message: message.into(),
159            detail: None,
160            status_override: None,
161        }
162    }
163
164    /// Attach a JSON `detail` payload to the error.
165    #[must_use]
166    pub fn with_detail(mut self, detail: Value) -> Self {
167        self.detail = Some(detail);
168        self
169    }
170
171    /// Override the HTTP status independent of the error code's default.
172    #[must_use]
173    pub fn with_status(mut self, status: StatusCode) -> Self {
174        self.status_override = Some(status);
175        self
176    }
177
178    /// Final HTTP status to return.
179    #[must_use]
180    pub fn status(&self) -> StatusCode {
181        self.status_override.unwrap_or_else(|| self.code.status())
182    }
183
184    /// Build the JSON body.
185    #[must_use]
186    pub fn body(&self) -> OciErrorBody {
187        OciErrorBody {
188            errors: vec![OciErrorInfo {
189                code: self.code.to_string(),
190                message: self.message.clone(),
191                detail: self.detail.clone(),
192            }],
193        }
194    }
195}
196
197impl IntoResponse for OciError {
198    fn into_response(self) -> Response {
199        let status = self.status();
200        let body = self.body();
201        (status, Json(body)).into_response()
202    }
203}
204
205/// Convenience alias for handler results.
206pub type OciResult<T> = Result<T, OciError>;
207
208/// Map a [`ferro_blob_store::BlobStoreError`] onto an [`OciError`].
209///
210/// Called at the edge of every handler so the protocol crate can use
211/// the workspace-wide `Result` type while still surfacing spec-shaped
212/// responses.
213impl From<ferro_blob_store::BlobStoreError> for OciError {
214    fn from(err: ferro_blob_store::BlobStoreError) -> Self {
215        use ferro_blob_store::BlobStoreError as B;
216        let msg = err.to_string();
217        match err {
218            B::NotFound(_) => Self::new(OciErrorCode::BlobUnknown, msg),
219            B::DigestMismatch { .. } | B::InvalidDigest(_) => {
220                Self::new(OciErrorCode::DigestInvalid, msg)
221            }
222            B::Io(_) => Self::new(OciErrorCode::Unsupported, msg),
223            _ => Self::new(OciErrorCode::Unsupported, msg),
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::{OciError, OciErrorCode};
231    use http::StatusCode;
232
233    #[test]
234    fn code_wire_strings_match_spec() {
235        assert_eq!(OciErrorCode::BlobUnknown.as_str(), "BLOB_UNKNOWN");
236        assert_eq!(
237            OciErrorCode::BlobUploadInvalid.as_str(),
238            "BLOB_UPLOAD_INVALID"
239        );
240        assert_eq!(
241            OciErrorCode::ManifestBlobUnknown.as_str(),
242            "MANIFEST_BLOB_UNKNOWN"
243        );
244        assert_eq!(OciErrorCode::NameInvalid.as_str(), "NAME_INVALID");
245        assert_eq!(OciErrorCode::TooManyRequests.as_str(), "TOOMANYREQUESTS");
246    }
247
248    #[test]
249    fn default_statuses_align_with_spec() {
250        assert_eq!(OciErrorCode::BlobUnknown.status(), StatusCode::NOT_FOUND);
251        assert_eq!(
252            OciErrorCode::DigestInvalid.status(),
253            StatusCode::BAD_REQUEST
254        );
255        assert_eq!(
256            OciErrorCode::Unauthorized.status(),
257            StatusCode::UNAUTHORIZED
258        );
259        assert_eq!(OciErrorCode::Denied.status(), StatusCode::FORBIDDEN);
260    }
261
262    #[test]
263    fn body_contains_single_error_entry() {
264        let err = OciError::new(OciErrorCode::NameInvalid, "bad name");
265        let body = err.body();
266        assert_eq!(body.errors.len(), 1);
267        assert_eq!(body.errors[0].code, "NAME_INVALID");
268        assert_eq!(body.errors[0].message, "bad name");
269    }
270
271    #[test]
272    fn status_override_wins_over_code_default() {
273        let err = OciError::new(OciErrorCode::Unsupported, "no delete by tag")
274            .with_status(StatusCode::METHOD_NOT_ALLOWED);
275        assert_eq!(err.status(), StatusCode::METHOD_NOT_ALLOWED);
276    }
277}