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 const 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            _ => Self::new(OciErrorCode::Unsupported, msg),
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::{OciError, OciErrorCode};
230    use http::StatusCode;
231
232    #[test]
233    fn code_wire_strings_match_spec() {
234        assert_eq!(OciErrorCode::BlobUnknown.as_str(), "BLOB_UNKNOWN");
235        assert_eq!(
236            OciErrorCode::BlobUploadInvalid.as_str(),
237            "BLOB_UPLOAD_INVALID"
238        );
239        assert_eq!(
240            OciErrorCode::ManifestBlobUnknown.as_str(),
241            "MANIFEST_BLOB_UNKNOWN"
242        );
243        assert_eq!(OciErrorCode::NameInvalid.as_str(), "NAME_INVALID");
244        assert_eq!(OciErrorCode::TooManyRequests.as_str(), "TOOMANYREQUESTS");
245    }
246
247    #[test]
248    fn default_statuses_align_with_spec() {
249        assert_eq!(OciErrorCode::BlobUnknown.status(), StatusCode::NOT_FOUND);
250        assert_eq!(
251            OciErrorCode::DigestInvalid.status(),
252            StatusCode::BAD_REQUEST
253        );
254        assert_eq!(
255            OciErrorCode::Unauthorized.status(),
256            StatusCode::UNAUTHORIZED
257        );
258        assert_eq!(OciErrorCode::Denied.status(), StatusCode::FORBIDDEN);
259    }
260
261    #[test]
262    fn body_contains_single_error_entry() {
263        let err = OciError::new(OciErrorCode::NameInvalid, "bad name");
264        let body = err.body();
265        assert_eq!(body.errors.len(), 1);
266        assert_eq!(body.errors[0].code, "NAME_INVALID");
267        assert_eq!(body.errors[0].message, "bad name");
268    }
269
270    #[test]
271    fn status_override_wins_over_code_default() {
272        let err = OciError::new(OciErrorCode::Unsupported, "no delete by tag")
273            .with_status(StatusCode::METHOD_NOT_ALLOWED);
274        assert_eq!(err.status(), StatusCode::METHOD_NOT_ALLOWED);
275    }
276
277    #[test]
278    fn every_code_has_wire_string_and_status() {
279        use OciErrorCode::{
280            BlobUnknown, BlobUploadInvalid, BlobUploadUnknown, Denied, DigestInvalid,
281            ManifestBlobUnknown, ManifestInvalid, ManifestUnknown, NameInvalid, NameUnknown,
282            SizeInvalid, TooManyRequests, Unauthorized, Unsupported,
283        };
284        // (variant, wire string, expected HTTP status) — covers every
285        // arm of `as_str` and `status` per OCI Distribution Spec §3.1.
286        let cases = [
287            (BlobUnknown, "BLOB_UNKNOWN", StatusCode::NOT_FOUND),
288            (
289                BlobUploadInvalid,
290                "BLOB_UPLOAD_INVALID",
291                StatusCode::BAD_REQUEST,
292            ),
293            (
294                BlobUploadUnknown,
295                "BLOB_UPLOAD_UNKNOWN",
296                StatusCode::NOT_FOUND,
297            ),
298            (DigestInvalid, "DIGEST_INVALID", StatusCode::BAD_REQUEST),
299            (
300                ManifestBlobUnknown,
301                "MANIFEST_BLOB_UNKNOWN",
302                StatusCode::NOT_FOUND,
303            ),
304            (ManifestInvalid, "MANIFEST_INVALID", StatusCode::BAD_REQUEST),
305            (ManifestUnknown, "MANIFEST_UNKNOWN", StatusCode::NOT_FOUND),
306            (NameInvalid, "NAME_INVALID", StatusCode::BAD_REQUEST),
307            (NameUnknown, "NAME_UNKNOWN", StatusCode::NOT_FOUND),
308            (SizeInvalid, "SIZE_INVALID", StatusCode::BAD_REQUEST),
309            (Unauthorized, "UNAUTHORIZED", StatusCode::UNAUTHORIZED),
310            (Denied, "DENIED", StatusCode::FORBIDDEN),
311            (
312                Unsupported,
313                "UNSUPPORTED",
314                StatusCode::METHOD_NOT_ALLOWED,
315            ),
316            (
317                TooManyRequests,
318                "TOOMANYREQUESTS",
319                StatusCode::TOO_MANY_REQUESTS,
320            ),
321        ];
322        for (code, wire, status) in cases {
323            assert_eq!(code.as_str(), wire, "{code:?} wire string");
324            assert_eq!(code.status(), status, "{code:?} status");
325            // `Display` delegates to `as_str`.
326            assert_eq!(code.to_string(), wire, "{code:?} display");
327        }
328    }
329
330    #[test]
331    fn with_detail_is_surfaced_in_body() {
332        let err = OciError::new(OciErrorCode::ManifestInvalid, "bad")
333            .with_detail(serde_json::json!({ "field": "config" }));
334        let body = err.body();
335        assert_eq!(
336            body.errors[0].detail.as_ref().expect("detail")["field"],
337            "config"
338        );
339    }
340
341    #[test]
342    fn blob_store_error_maps_to_oci_codes() {
343        use ferro_blob_store::BlobStoreError;
344
345        let not_found: OciError = BlobStoreError::NotFound("x".into()).into();
346        assert_eq!(not_found.code, OciErrorCode::BlobUnknown);
347
348        // `InvalidDigest` wraps a real parse failure.
349        let parse_err = "no-colon".parse::<ferro_blob_store::Digest>().unwrap_err();
350        let bad_digest: OciError = BlobStoreError::InvalidDigest(parse_err).into();
351        assert_eq!(bad_digest.code, OciErrorCode::DigestInvalid);
352
353        let mismatch: OciError = BlobStoreError::DigestMismatch {
354            expected: "a".into(),
355            computed: "b".into(),
356        }
357        .into();
358        assert_eq!(mismatch.code, OciErrorCode::DigestInvalid);
359
360        let io: OciError = BlobStoreError::Io(std::io::Error::other("disk gone")).into();
361        assert_eq!(io.code, OciErrorCode::Unsupported);
362    }
363}