Skip to main content

rustack_s3_core/
error.rs

1//! S3-specific error types.
2//!
3//! Defines [`S3ServiceError`], a domain-specific error enum covering all S3
4//! error codes that Rustack may produce. Each variant maps to a concrete
5//! [`rustack_s3_model::error::S3ErrorCode`] and HTTP status code through
6//! the [`From`] implementation, making it easy to convert domain errors into
7//! wire errors.
8//!
9//! # Usage
10//!
11//! ```
12//! use rustack_s3_core::error::S3ServiceError;
13//! use rustack_s3_model::error::{S3Error, S3ErrorCode};
14//!
15//! let err = S3ServiceError::NoSuchBucket {
16//!     bucket: "my-bucket".to_owned(),
17//! };
18//! let s3_err: S3Error = err.into();
19//! assert_eq!(s3_err.code, S3ErrorCode::NoSuchBucket);
20//! ```
21
22use rustack_s3_model::error::{S3Error, S3ErrorCode};
23
24/// S3 service error type.
25///
26/// Each variant corresponds to a well-known S3 error code. Converting to
27/// [`S3Error`] via [`From`] attaches the correct error code and a
28/// human-readable message; the HTTP status code is derived automatically from
29/// the error code.
30#[derive(Debug, thiserror::Error)]
31pub enum S3ServiceError {
32    // -----------------------------------------------------------------------
33    // Bucket errors
34    // -----------------------------------------------------------------------
35    /// The specified bucket does not exist.
36    #[error("The specified bucket does not exist: {bucket}")]
37    NoSuchBucket {
38        /// The bucket name that was not found.
39        bucket: String,
40    },
41
42    /// The requested bucket name is not available (owned by another account).
43    #[error("The requested bucket name is not available: {bucket}")]
44    BucketAlreadyExists {
45        /// The bucket name that already exists.
46        bucket: String,
47    },
48
49    /// The bucket already exists and is owned by you.
50    #[error(
51        "Your previous request to create the named bucket succeeded and you already own it: \
52         {bucket}"
53    )]
54    BucketAlreadyOwnedByYou {
55        /// The bucket name that already exists.
56        bucket: String,
57    },
58
59    /// The bucket is not empty and cannot be deleted.
60    #[error("The bucket you tried to delete is not empty: {bucket}")]
61    BucketNotEmpty {
62        /// The bucket name that is not empty.
63        bucket: String,
64    },
65
66    // -----------------------------------------------------------------------
67    // Object / key errors
68    // -----------------------------------------------------------------------
69    /// The specified key does not exist.
70    #[error("The specified key does not exist: {key}")]
71    NoSuchKey {
72        /// The key that was not found.
73        key: String,
74    },
75
76    /// The specified version does not exist.
77    #[error("The specified version does not exist: key={key}, version_id={version_id}")]
78    NoSuchVersion {
79        /// The key for the version.
80        key: String,
81        /// The version ID that was not found.
82        version_id: String,
83    },
84
85    // -----------------------------------------------------------------------
86    // Multipart upload errors
87    // -----------------------------------------------------------------------
88    /// The specified multipart upload does not exist.
89    #[error("The specified upload does not exist: {upload_id}")]
90    NoSuchUpload {
91        /// The upload ID that was not found.
92        upload_id: String,
93    },
94
95    /// The list of parts was not in ascending order.
96    #[error("The list of parts was not in ascending order")]
97    InvalidPartOrder,
98
99    /// One or more of the specified parts could not be found.
100    #[error("One or more of the specified parts could not be found")]
101    InvalidPart,
102
103    /// A proposed upload part is smaller than the minimum allowed size.
104    #[error("Your proposed upload is smaller than the minimum allowed object size")]
105    EntityTooSmall,
106
107    /// The entity body is too large.
108    #[error("Your proposed upload exceeds the maximum allowed object size")]
109    EntityTooLarge,
110
111    // -----------------------------------------------------------------------
112    // Validation errors
113    // -----------------------------------------------------------------------
114    /// The specified bucket name is not valid.
115    #[error("Invalid bucket name: {name}: {reason}")]
116    InvalidBucketName {
117        /// The invalid bucket name.
118        name: String,
119        /// The reason for the error.
120        reason: String,
121    },
122
123    /// An argument provided is invalid.
124    #[error("Invalid argument: {message}")]
125    InvalidArgument {
126        /// Description of the invalid argument.
127        message: String,
128    },
129
130    /// The requested range is not satisfiable.
131    #[error("The requested range is not satisfiable")]
132    InvalidRange,
133
134    /// A tag key or value is invalid.
135    #[error("Invalid tag: {message}")]
136    InvalidTag {
137        /// Description of the tag error.
138        message: String,
139    },
140
141    /// The XML body is malformed.
142    #[error("The XML you provided was not well-formed")]
143    MalformedXml,
144
145    // -----------------------------------------------------------------------
146    // Authorization / access errors
147    // -----------------------------------------------------------------------
148    /// Access denied.
149    #[error("Access Denied")]
150    AccessDenied,
151
152    /// The HTTP method is not allowed against this resource.
153    #[error("The specified method is not allowed against this resource")]
154    MethodNotAllowed,
155
156    // -----------------------------------------------------------------------
157    // Feature / implementation errors
158    // -----------------------------------------------------------------------
159    /// The requested functionality is not implemented.
160    #[error("A header you provided implies functionality that is not implemented")]
161    NotImplemented,
162
163    // -----------------------------------------------------------------------
164    // Conditional request errors
165    // -----------------------------------------------------------------------
166    /// A precondition specified in the request was not met.
167    #[error("At least one of the preconditions you specified did not hold")]
168    PreconditionFailed,
169
170    /// The conditional request cannot be processed.
171    #[error("The conditional request cannot be processed")]
172    ConditionalRequestConflict,
173
174    /// The resource has not been modified (304).
175    #[error("Not Modified")]
176    NotModified,
177
178    // -----------------------------------------------------------------------
179    // Object state errors
180    // -----------------------------------------------------------------------
181    /// The operation is not valid for the object's storage class.
182    #[error("The operation is not valid for the object's storage class")]
183    InvalidObjectState,
184
185    /// The object is not in an active tier.
186    #[error("The source object of the COPY action is not in the active tier")]
187    ObjectNotInActiveTierError,
188
189    // -----------------------------------------------------------------------
190    // Digest / content errors
191    // -----------------------------------------------------------------------
192    /// The Content-MD5 you specified is invalid.
193    #[error("The Content-MD5 you specified is not valid")]
194    InvalidDigest,
195
196    /// The Content-MD5 you specified did not match what we received.
197    #[error("The Content-MD5 you specified did not match what we received")]
198    BadDigest,
199
200    /// Missing Content-Length header.
201    #[error("You must provide the Content-Length HTTP header")]
202    MissingContentLength,
203
204    /// The key is too long.
205    #[error("Your key is too long")]
206    KeyTooLong,
207
208    /// The message body exceeds the maximum length.
209    #[error("Your request was too big")]
210    MaxMessageLengthExceeded,
211
212    // -----------------------------------------------------------------------
213    // Configuration-not-found errors
214    // -----------------------------------------------------------------------
215    /// The CORS configuration does not exist.
216    #[error("The CORS configuration does not exist")]
217    NoSuchCorsConfiguration,
218
219    /// The tag set does not exist.
220    #[error("The TagSet does not exist")]
221    NoSuchTagSet,
222
223    /// The lifecycle configuration does not exist.
224    #[error("The lifecycle configuration does not exist")]
225    NoSuchLifecycleConfiguration,
226
227    /// The bucket policy does not exist.
228    #[error("The bucket policy does not exist")]
229    NoSuchBucketPolicy,
230
231    /// The website configuration does not exist.
232    #[error("The specified bucket does not have a website configuration")]
233    NoSuchWebsiteConfiguration,
234
235    /// The public access block configuration does not exist.
236    #[error("The public access block configuration was not found")]
237    NoSuchPublicAccessBlockConfiguration,
238
239    /// The server-side encryption configuration does not exist.
240    #[error("The server-side encryption configuration was not found")]
241    ServerSideEncryptionConfigurationNotFoundError,
242
243    /// The object lock configuration does not exist.
244    #[error("Object Lock configuration does not exist for this bucket")]
245    ObjectLockConfigurationNotFoundError,
246
247    /// The ownership controls configuration does not exist.
248    #[error("The bucket ownership controls were not found")]
249    OwnershipControlsNotFoundError,
250
251    /// The replication configuration does not exist.
252    #[error("The replication configuration was not found")]
253    ReplicationConfigurationNotFoundError,
254
255    // -----------------------------------------------------------------------
256    // Internal / catch-all
257    // -----------------------------------------------------------------------
258    /// Internal error with context.
259    #[error(transparent)]
260    Internal(#[from] anyhow::Error),
261}
262
263impl S3ServiceError {
264    /// Convert this error into an [`S3Error`].
265    ///
266    /// This is equivalent to `S3Error::from(self)` but available as a
267    /// method for convenience in chained calls.
268    #[must_use]
269    pub fn into_s3_error(self) -> S3Error {
270        S3Error::from(self)
271    }
272}
273
274impl From<S3ServiceError> for S3Error {
275    fn from(err: S3ServiceError) -> Self {
276        let code = error_code(&err);
277        // Use the error code's standard message for the XML <Message> element.
278        // SDKs compare message strings exactly, so context-specific details
279        // (bucket/key names) must not appear in <Message>.
280        // For validation errors, preserve the specific message.
281        let message = match &err {
282            S3ServiceError::InvalidArgument { message }
283            | S3ServiceError::InvalidTag { message } => message.clone(),
284            S3ServiceError::InvalidBucketName { name, reason } => {
285                format!("Invalid bucket name: {name}: {reason}")
286            }
287            S3ServiceError::Internal(e) => e.to_string(),
288            _ => code.default_message().to_owned(),
289        };
290        S3Error::with_message(code, message)
291    }
292}
293
294/// Map an [`S3ServiceError`] variant to the corresponding [`S3ErrorCode`].
295fn error_code(err: &S3ServiceError) -> S3ErrorCode {
296    match err {
297        S3ServiceError::NoSuchBucket { .. } => S3ErrorCode::NoSuchBucket,
298        S3ServiceError::BucketAlreadyExists { .. } => S3ErrorCode::BucketAlreadyExists,
299        S3ServiceError::BucketAlreadyOwnedByYou { .. } => S3ErrorCode::BucketAlreadyOwnedByYou,
300        S3ServiceError::BucketNotEmpty { .. } => S3ErrorCode::BucketNotEmpty,
301        S3ServiceError::NoSuchKey { .. } => S3ErrorCode::NoSuchKey,
302        S3ServiceError::NoSuchVersion { .. } => S3ErrorCode::NoSuchVersion,
303        S3ServiceError::NoSuchUpload { .. } => S3ErrorCode::NoSuchUpload,
304        S3ServiceError::InvalidPartOrder => S3ErrorCode::InvalidPartOrder,
305        S3ServiceError::InvalidPart => S3ErrorCode::InvalidPart,
306        S3ServiceError::EntityTooSmall => S3ErrorCode::EntityTooSmall,
307        S3ServiceError::EntityTooLarge => S3ErrorCode::EntityTooLarge,
308        S3ServiceError::InvalidBucketName { .. } => S3ErrorCode::InvalidBucketName,
309        S3ServiceError::InvalidArgument { .. } | S3ServiceError::InvalidTag { .. } => {
310            S3ErrorCode::InvalidArgument
311        }
312        S3ServiceError::InvalidRange => S3ErrorCode::InvalidRange,
313        S3ServiceError::MalformedXml => S3ErrorCode::MalformedXML,
314        S3ServiceError::AccessDenied => S3ErrorCode::AccessDenied,
315        S3ServiceError::MethodNotAllowed => S3ErrorCode::MethodNotAllowed,
316        S3ServiceError::NotImplemented => S3ErrorCode::NotImplemented,
317        S3ServiceError::PreconditionFailed => S3ErrorCode::PreconditionFailed,
318        S3ServiceError::ConditionalRequestConflict => S3ErrorCode::ConditionalRequestConflict,
319        S3ServiceError::NotModified => S3ErrorCode::NotModified,
320        S3ServiceError::InvalidObjectState => S3ErrorCode::InvalidObjectState,
321        S3ServiceError::ObjectNotInActiveTierError => S3ErrorCode::ObjectNotInActiveTierError,
322        S3ServiceError::InvalidDigest => S3ErrorCode::InvalidDigest,
323        S3ServiceError::BadDigest => S3ErrorCode::BadDigest,
324        S3ServiceError::MissingContentLength => S3ErrorCode::MissingContentLength,
325        S3ServiceError::KeyTooLong => S3ErrorCode::KeyTooLongError,
326        S3ServiceError::MaxMessageLengthExceeded => S3ErrorCode::MaxMessageLengthExceeded,
327        S3ServiceError::NoSuchCorsConfiguration => S3ErrorCode::NoSuchCORSConfiguration,
328        S3ServiceError::NoSuchTagSet => S3ErrorCode::NoSuchTagSet,
329        S3ServiceError::NoSuchLifecycleConfiguration => S3ErrorCode::NoSuchLifecycleConfiguration,
330        S3ServiceError::NoSuchBucketPolicy => S3ErrorCode::NoSuchBucketPolicy,
331        S3ServiceError::NoSuchWebsiteConfiguration => S3ErrorCode::NoSuchWebsiteConfiguration,
332        S3ServiceError::NoSuchPublicAccessBlockConfiguration => {
333            S3ErrorCode::NoSuchPublicAccessBlockConfiguration
334        }
335        S3ServiceError::ServerSideEncryptionConfigurationNotFoundError => {
336            S3ErrorCode::ServerSideEncryptionConfigurationNotFoundError
337        }
338        S3ServiceError::ObjectLockConfigurationNotFoundError => {
339            S3ErrorCode::ObjectLockConfigurationNotFoundError
340        }
341        S3ServiceError::OwnershipControlsNotFoundError => {
342            S3ErrorCode::OwnershipControlsNotFoundError
343        }
344        S3ServiceError::ReplicationConfigurationNotFoundError => {
345            S3ErrorCode::ReplicationConfigurationNotFoundError
346        }
347        S3ServiceError::Internal(_) => S3ErrorCode::InternalError,
348    }
349}
350
351/// Convenience result type for S3 service operations.
352pub type S3ServiceResult<T> = Result<T, S3ServiceError>;
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_should_convert_no_such_bucket_to_s3_error() {
360        let err = S3ServiceError::NoSuchBucket {
361            bucket: "my-bucket".to_owned(),
362        };
363        let s3_err: S3Error = err.into();
364        assert_eq!(s3_err.code, S3ErrorCode::NoSuchBucket);
365        // Message should use the standard S3 error message without bucket name.
366        assert_eq!(s3_err.message, "The specified bucket does not exist");
367    }
368
369    #[test]
370    fn test_should_convert_no_such_key_to_s3_error() {
371        let err = S3ServiceError::NoSuchKey {
372            key: "path/to/obj".to_owned(),
373        };
374        let s3_err: S3Error = err.into();
375        assert_eq!(s3_err.code, S3ErrorCode::NoSuchKey);
376    }
377
378    #[test]
379    fn test_should_convert_bucket_already_exists_to_s3_error() {
380        let err = S3ServiceError::BucketAlreadyExists {
381            bucket: "taken".to_owned(),
382        };
383        let s3_err: S3Error = err.into();
384        assert_eq!(s3_err.code, S3ErrorCode::BucketAlreadyExists);
385    }
386
387    #[test]
388    fn test_should_convert_bucket_already_owned_to_s3_error() {
389        let err = S3ServiceError::BucketAlreadyOwnedByYou {
390            bucket: "mine".to_owned(),
391        };
392        let s3_err: S3Error = err.into();
393        assert_eq!(s3_err.code, S3ErrorCode::BucketAlreadyOwnedByYou);
394    }
395
396    #[test]
397    fn test_should_convert_bucket_not_empty_to_s3_error() {
398        let err = S3ServiceError::BucketNotEmpty {
399            bucket: "full".to_owned(),
400        };
401        let s3_err: S3Error = err.into();
402        assert_eq!(s3_err.code, S3ErrorCode::BucketNotEmpty);
403    }
404
405    #[test]
406    fn test_should_convert_invalid_bucket_name_to_s3_error() {
407        let err = S3ServiceError::InvalidBucketName {
408            name: "BAD".to_owned(),
409            reason: "uppercase".to_owned(),
410        };
411        let s3_err: S3Error = err.into();
412        assert_eq!(s3_err.code, S3ErrorCode::InvalidBucketName);
413    }
414
415    #[test]
416    fn test_should_convert_entity_too_small_to_s3_error() {
417        let err = S3ServiceError::EntityTooSmall;
418        let s3_err: S3Error = err.into();
419        assert_eq!(s3_err.code, S3ErrorCode::EntityTooSmall);
420    }
421
422    #[test]
423    fn test_should_convert_access_denied_to_s3_error() {
424        let err = S3ServiceError::AccessDenied;
425        let s3_err: S3Error = err.into();
426        assert_eq!(s3_err.code, S3ErrorCode::AccessDenied);
427    }
428
429    #[test]
430    fn test_should_convert_internal_error_to_s3_error() {
431        let err = S3ServiceError::Internal(anyhow::anyhow!("disk I/O failure"));
432        let s3_err: S3Error = err.into();
433        assert_eq!(s3_err.code, S3ErrorCode::InternalError);
434    }
435
436    #[test]
437    fn test_should_use_into_s3_error_method() {
438        let err = S3ServiceError::InvalidRange;
439        let s3_err = err.into_s3_error();
440        assert_eq!(s3_err.code, S3ErrorCode::InvalidRange);
441    }
442
443    #[test]
444    fn test_should_convert_no_such_upload_to_s3_error() {
445        let err = S3ServiceError::NoSuchUpload {
446            upload_id: "abc123".to_owned(),
447        };
448        let s3_err: S3Error = err.into();
449        assert_eq!(s3_err.code, S3ErrorCode::NoSuchUpload);
450    }
451
452    #[test]
453    fn test_should_convert_precondition_failed_to_s3_error() {
454        let err = S3ServiceError::PreconditionFailed;
455        let s3_err: S3Error = err.into();
456        assert_eq!(s3_err.code, S3ErrorCode::PreconditionFailed);
457    }
458
459    #[test]
460    fn test_should_convert_config_not_found_errors() {
461        let cases: Vec<(S3ServiceError, S3ErrorCode)> = vec![
462            (
463                S3ServiceError::NoSuchCorsConfiguration,
464                S3ErrorCode::NoSuchCORSConfiguration,
465            ),
466            (S3ServiceError::NoSuchTagSet, S3ErrorCode::NoSuchTagSet),
467            (
468                S3ServiceError::NoSuchLifecycleConfiguration,
469                S3ErrorCode::NoSuchLifecycleConfiguration,
470            ),
471            (
472                S3ServiceError::NoSuchBucketPolicy,
473                S3ErrorCode::NoSuchBucketPolicy,
474            ),
475            (
476                S3ServiceError::NoSuchWebsiteConfiguration,
477                S3ErrorCode::NoSuchWebsiteConfiguration,
478            ),
479            (
480                S3ServiceError::NoSuchPublicAccessBlockConfiguration,
481                S3ErrorCode::NoSuchPublicAccessBlockConfiguration,
482            ),
483            (
484                S3ServiceError::ObjectLockConfigurationNotFoundError,
485                S3ErrorCode::ObjectLockConfigurationNotFoundError,
486            ),
487            (
488                S3ServiceError::OwnershipControlsNotFoundError,
489                S3ErrorCode::OwnershipControlsNotFoundError,
490            ),
491            (
492                S3ServiceError::ReplicationConfigurationNotFoundError,
493                S3ErrorCode::ReplicationConfigurationNotFoundError,
494            ),
495            (
496                S3ServiceError::ServerSideEncryptionConfigurationNotFoundError,
497                S3ErrorCode::ServerSideEncryptionConfigurationNotFoundError,
498            ),
499        ];
500
501        for (err, expected_code) in cases {
502            let s3_err: S3Error = err.into();
503            assert_eq!(s3_err.code, expected_code);
504        }
505    }
506
507    #[test]
508    fn test_should_convert_not_modified_to_304() {
509        let err = S3ServiceError::NotModified;
510        let s3_err: S3Error = err.into();
511        assert_eq!(s3_err.code, S3ErrorCode::NotModified);
512        assert_eq!(s3_err.status_code, http::StatusCode::NOT_MODIFIED);
513    }
514
515    #[test]
516    fn test_should_convert_conditional_request_conflict_to_409() {
517        let err = S3ServiceError::ConditionalRequestConflict;
518        let s3_err: S3Error = err.into();
519        assert_eq!(s3_err.code, S3ErrorCode::ConditionalRequestConflict);
520        assert_eq!(s3_err.status_code, http::StatusCode::CONFLICT);
521    }
522
523    #[test]
524    fn test_should_convert_bad_digest_to_400() {
525        let err = S3ServiceError::BadDigest;
526        let s3_err: S3Error = err.into();
527        assert_eq!(s3_err.code, S3ErrorCode::BadDigest);
528        assert_eq!(s3_err.status_code, http::StatusCode::BAD_REQUEST);
529    }
530
531    #[test]
532    fn test_should_convert_max_message_length_exceeded_to_400() {
533        let err = S3ServiceError::MaxMessageLengthExceeded;
534        let s3_err: S3Error = err.into();
535        assert_eq!(s3_err.code, S3ErrorCode::MaxMessageLengthExceeded);
536        assert_eq!(s3_err.status_code, http::StatusCode::BAD_REQUEST);
537    }
538}