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