Skip to main content

multistore/
error.rs

1//! Error types for the proxy.
2
3use thiserror::Error;
4
5/// Central error type for the proxy, mapping each variant to an S3-compatible HTTP response.
6#[derive(Debug, Error)]
7pub enum ProxyError {
8    /// The requested virtual bucket does not exist in the registry.
9    #[error("bucket not found: {0}")]
10    BucketNotFound(String),
11
12    /// The requested object key was not found in the backend store.
13    #[error("no such key: {0}")]
14    NoSuchKey(String),
15
16    /// The caller's identity lacks permission for the requested operation.
17    #[error("access denied")]
18    AccessDenied,
19
20    /// The SigV4 signature in the request does not match the expected value.
21    #[error("signature mismatch")]
22    SignatureDoesNotMatch,
23
24    /// The request is malformed or contains invalid parameters.
25    #[error("invalid request: {0}")]
26    InvalidRequest(String),
27
28    /// The XML in the request body was not well-formed or did not validate
29    /// against the expected schema (e.g. a batch-delete body that is malformed,
30    /// empty, or exceeds the 1000-key limit). Maps to S3's `400 MalformedXML`.
31    #[error("malformed XML: {0}")]
32    MalformedXml(String),
33
34    /// The requested S3 operation is recognized but not supported by the proxy
35    /// (e.g. server-side copy via `x-amz-copy-source`). Maps to S3's
36    /// `501 NotImplemented`.
37    #[error("not implemented: {0}")]
38    NotImplemented(String),
39
40    /// The upload body exceeds the proxy's configured maximum size. Returned
41    /// as S3's `EntityTooLarge` so clients get an actionable error instead of a
42    /// runtime-specific rejection (e.g. Cloudflare's edge `413`).
43    #[error("entity too large")]
44    EntityTooLarge,
45
46    /// The request contains no authentication credentials.
47    #[error("missing authentication")]
48    MissingAuth,
49
50    /// The credentials used to sign the request have expired.
51    #[error("expired credentials")]
52    ExpiredCredentials,
53
54    /// The OIDC token provided for STS role assumption is invalid or untrusted.
55    #[error("invalid OIDC token: {0}")]
56    InvalidOidcToken(String),
57
58    /// The IAM role specified in an STS request does not exist.
59    #[error("role not found: {0}")]
60    RoleNotFound(String),
61
62    /// The upstream object store backend returned an error.
63    #[error("backend error: {0}")]
64    BackendError(String),
65
66    /// The proxy could not obtain credentials from the backend cloud's identity
67    /// broker — e.g. AWS STS rejected the proxy's federated identity
68    /// (`InvalidIdentityToken`), or the broker was unreachable. Kept distinct
69    /// from [`Internal`](Self::Internal) so a federation/trust misconfiguration
70    /// surfaces as a diagnosable `502` rather than an opaque `500`. Carries the
71    /// provider error *code* only (safe to expose); the full provider message —
72    /// which may contain role ARNs / account IDs — is logged at the conversion
73    /// site, not returned to the caller.
74    #[error("backend authentication failed: {0}")]
75    BackendAuthError(String),
76
77    /// A conditional request header (e.g. `If-Match`) was not satisfied.
78    #[error("precondition failed")]
79    PreconditionFailed,
80
81    /// The object has not been modified since the time specified by `If-Modified-Since`.
82    #[error("not modified")]
83    NotModified,
84
85    /// The proxy configuration is invalid or incomplete.
86    #[error("config error: {0}")]
87    ConfigError(String),
88
89    /// An unexpected internal error occurred.
90    #[error("internal error: {0}")]
91    Internal(String),
92}
93
94impl ProxyError {
95    /// Return the S3-compatible XML error code.
96    pub fn s3_error_code(&self) -> &'static str {
97        match self {
98            Self::BucketNotFound(_) => "NoSuchBucket",
99            Self::NoSuchKey(_) => "NoSuchKey",
100            Self::AccessDenied => "AccessDenied",
101            Self::SignatureDoesNotMatch => "SignatureDoesNotMatch",
102            Self::InvalidRequest(_) => "InvalidRequest",
103            Self::MalformedXml(_) => "MalformedXML",
104            Self::NotImplemented(_) => "NotImplemented",
105            Self::EntityTooLarge => "EntityTooLarge",
106            Self::MissingAuth => "AccessDenied",
107            Self::ExpiredCredentials => "ExpiredToken",
108            Self::InvalidOidcToken(_) => "InvalidIdentityToken",
109            Self::RoleNotFound(_) => "AccessDenied",
110            Self::BackendError(_) => "ServiceUnavailable",
111            Self::BackendAuthError(_) => "BackendAuthenticationFailed",
112            Self::PreconditionFailed => "PreconditionFailed",
113            Self::NotModified => "NotModified",
114            Self::ConfigError(_) => "InternalError",
115            Self::Internal(_) => "InternalError",
116        }
117    }
118
119    /// HTTP status code for this error.
120    pub fn status_code(&self) -> u16 {
121        match self {
122            Self::BucketNotFound(_) | Self::NoSuchKey(_) => 404,
123            Self::AccessDenied | Self::MissingAuth | Self::ExpiredCredentials => 403,
124            Self::SignatureDoesNotMatch => 403,
125            Self::InvalidRequest(_) => 400,
126            Self::MalformedXml(_) => 400,
127            Self::NotImplemented(_) => 501,
128            Self::EntityTooLarge => 400,
129            Self::InvalidOidcToken(_) => 400,
130            Self::RoleNotFound(_) => 403,
131            Self::PreconditionFailed => 412,
132            Self::NotModified => 304,
133            Self::BackendError(_) => 503,
134            Self::BackendAuthError(_) => 502,
135            Self::ConfigError(_) | Self::Internal(_) => 500,
136        }
137    }
138
139    /// Return a message safe to show to external clients.
140    ///
141    /// For server-side errors (5xx), returns a generic message to avoid
142    /// leaking backend infrastructure details. For client errors (4xx),
143    /// returns the full message (the client already knows the bucket name,
144    /// key, etc.).
145    pub fn safe_message(&self) -> String {
146        match self {
147            Self::BackendError(_) => "Service unavailable".to_string(),
148            Self::ConfigError(_) | Self::Internal(_) => "Internal server error".to_string(),
149            // Safe to surface: holds only the provider error code (e.g.
150            // `InvalidIdentityToken`), never the raw provider message.
151            Self::BackendAuthError(code) => {
152                format!("Failed to obtain backend credentials: {code}")
153            }
154            other => other.to_string(),
155        }
156    }
157
158    /// Convert an `object_store::Error` into a `ProxyError`.
159    pub fn from_object_store_error(e: object_store::Error) -> Self {
160        match e {
161            object_store::Error::NotFound { path, .. } => Self::NoSuchKey(path),
162            object_store::Error::Precondition { .. } => Self::PreconditionFailed,
163            object_store::Error::NotModified { .. } => Self::NotModified,
164            _ => Self::BackendError(e.to_string()),
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn backend_auth_error_is_502_and_not_opaque() {
175        let err = ProxyError::BackendAuthError("InvalidIdentityToken".into());
176        assert_eq!(err.status_code(), 502);
177        assert_eq!(err.s3_error_code(), "BackendAuthenticationFailed");
178        // Distinct from the generic internal-error message, and surfaces the
179        // provider code so the failure is diagnosable from the response alone.
180        let msg = err.safe_message();
181        assert_ne!(msg, "Internal server error");
182        assert!(msg.contains("InvalidIdentityToken"), "got: {msg}");
183    }
184
185    #[test]
186    fn internal_error_stays_opaque_5xx() {
187        // Genuine internal errors keep the generic message (no detail leak).
188        let err = ProxyError::Internal("secret backend detail".into());
189        assert_eq!(err.status_code(), 500);
190        assert_eq!(err.safe_message(), "Internal server error");
191    }
192}