Skip to main content

fraiseql_error/
file.rs

1/// Errors that occur during file upload, validation, storage, or retrieval.
2#[derive(Debug, thiserror::Error)]
3#[non_exhaustive]
4pub enum FileError {
5    /// The uploaded file exceeds the configured maximum size.
6    #[error("File too large: {size} bytes (max: {max} bytes)")]
7    TooLarge {
8        /// Actual size of the uploaded file in bytes.
9        size: usize,
10        /// Maximum allowed file size in bytes.
11        max:  usize,
12    },
13
14    /// The file's extension or declared MIME type is not on the allow-list.
15    #[error("Invalid file type: {got} (allowed: {allowed:?})")]
16    InvalidType {
17        /// The MIME type or extension that was supplied.
18        got:     String,
19        /// The set of allowed MIME types or extensions.
20        allowed: Vec<String>,
21    },
22
23    /// The file's declared MIME type does not match its detected MIME type.
24    ///
25    /// This can indicate a spoofed `Content-Type` header.
26    #[error("MIME type mismatch: declared {declared}, detected {detected}")]
27    MimeMismatch {
28        /// The MIME type stated by the client.
29        declared: String,
30        /// The MIME type detected by content inspection.
31        detected: String,
32    },
33
34    /// An error occurred while writing to or reading from the backing storage
35    /// system (e.g. local disk, object store).
36    #[error("Storage error: {message}")]
37    Storage {
38        /// Description of the storage failure.
39        message: String,
40        /// Optional chained error from the storage backend.
41        #[source]
42        source:  Option<Box<dyn std::error::Error + Send + Sync>>,
43    },
44
45    /// An error occurred while processing the file contents (e.g. image
46    /// resizing, format conversion).
47    #[error("Processing error: {message}")]
48    Processing {
49        /// Description of the processing failure.
50        message: String,
51    },
52
53    /// The requested file does not exist in the storage backend.
54    #[error("File not found: {id}")]
55    NotFound {
56        /// Identifier of the file that was not found.
57        id: String,
58    },
59
60    /// A virus or malware scanner flagged the uploaded file.
61    #[error("Virus detected: {details}")]
62    VirusDetected {
63        /// Scanner-provided details about the detected threat (server-side only).
64        details: String,
65    },
66
67    /// The user or tenant has exhausted their file storage quota.
68    #[error("Upload quota exceeded")]
69    QuotaExceeded,
70
71    // ------------------------------------------------------------------------
72    // Backend / infrastructure variants (F050 — absorb `FraiseQLError::Storage`)
73    // ------------------------------------------------------------------------
74    /// The backend refused the request because the caller lacks permission
75    /// (e.g. an object-store bucket policy denied the operation, IAM credentials
76    /// are missing the required action, the SAS token is expired).
77    ///
78    /// Distinct from a missing-credentials authentication failure — those
79    /// surface as [`crate::FraiseQLError::Authentication`].
80    #[error("Permission denied: {message}")]
81    PermissionDenied {
82        /// Description of the permission failure (server-side only; the
83        /// HTTP body is generic).
84        message: String,
85        /// Optional chained error from the storage backend.
86        #[source]
87        source:  Option<Box<dyn std::error::Error + Send + Sync>>,
88    },
89
90    /// A low-level I/O failure occurred while talking to the storage backend
91    /// (filesystem read/write error, socket failure, JSON-parse failure on a
92    /// backend response).
93    #[error("I/O error: {message}")]
94    IoError {
95        /// Description of the I/O failure.
96        message: String,
97        /// Optional chained error from the storage backend.
98        #[source]
99        source:  Option<Box<dyn std::error::Error + Send + Sync>>,
100    },
101
102    /// The caller supplied a key that fails validation (empty, path-traversal
103    /// segment, leading `/` or `\`). User-fixable; HTTP 400.
104    #[error("Invalid storage key: {message}")]
105    InvalidKey {
106        /// Description of why the key was rejected.
107        message: String,
108    },
109
110    /// The requested operation is not implemented for this backend
111    /// (e.g. `list` on GCS, presigned URLs without V4 signing on Azure).
112    /// The capability exists in the API surface but is unimplemented.
113    #[error("Not implemented: {message}")]
114    NotImplemented {
115        /// Description of the unimplemented operation.
116        message: String,
117    },
118
119    /// The requested operation is not supported by this backend at all
120    /// (e.g. presigned URLs for the local filesystem, presign-PUT for the
121    /// non-S3 enum variants).
122    #[error("Unsupported: {message}")]
123    Unsupported {
124        /// Description of the unsupported operation.
125        message: String,
126    },
127
128    /// The upload exceeds the configured per-bucket size limit.
129    ///
130    /// Distinct from [`Self::TooLarge`], which is set by client-side
131    /// validation against `max_object_bytes`. This variant is raised by the
132    /// service layer when the limit is enforced post-upload (e.g. for streaming
133    /// uploads where the total size is only known once the body has been
134    /// read).
135    #[error("Size limit exceeded: {message}")]
136    SizeLimitExceeded {
137        /// Description of the size-limit violation.
138        message: String,
139        /// Configured maximum size in bytes, if known.
140        limit:   Option<u64>,
141        /// Actual size of the payload in bytes, if known.
142        actual:  Option<u64>,
143    },
144
145    /// The uploaded content type is not on the per-bucket allow-list.
146    ///
147    /// Distinct from [`Self::InvalidType`], which is raised at extension /
148    /// MIME-sniffing time. This variant is raised by the service layer
149    /// against the configured `allowed_mime_types` list.
150    #[error("MIME type not allowed: {message}")]
151    MimeTypeNotAllowed {
152        /// Description of the rejection.
153        message: String,
154        /// The rejected MIME type, if known.
155        mime:    Option<String>,
156    },
157
158    /// A generic backend / infrastructure failure with no more-specific
159    /// classification.
160    ///
161    /// Used for object-store authentication setup failures (missing env vars,
162    /// invalid credentials JSON), backend HTTP-request failures, configuration
163    /// errors (`s3` backend without `bucket` config), and database failures in
164    /// the storage metadata layer. Returns HTTP 500.
165    #[error("Backend error: {message}")]
166    Backend {
167        /// Description of the backend failure.
168        message: String,
169        /// Optional chained error from the underlying SDK / IO call.
170        #[source]
171        source:  Option<Box<dyn std::error::Error + Send + Sync>>,
172    },
173}
174
175impl FileError {
176    /// Returns a short, stable error code string suitable for API responses and
177    /// structured logging.
178    #[must_use]
179    pub const fn error_code(&self) -> &'static str {
180        match self {
181            Self::TooLarge { .. } => "file_too_large",
182            Self::InvalidType { .. } => "file_invalid_type",
183            Self::MimeMismatch { .. } => "file_mime_mismatch",
184            Self::Storage { .. } => "file_storage_error",
185            Self::Processing { .. } => "file_processing_error",
186            Self::NotFound { .. } => "file_not_found",
187            Self::VirusDetected { .. } => "file_virus_detected",
188            Self::QuotaExceeded => "file_quota_exceeded",
189            Self::PermissionDenied { .. } => "file_permission_denied",
190            Self::IoError { .. } => "file_io_error",
191            Self::InvalidKey { .. } => "file_invalid_key",
192            Self::NotImplemented { .. } => "file_not_implemented",
193            Self::Unsupported { .. } => "file_unsupported",
194            Self::SizeLimitExceeded { .. } => "file_size_limit_exceeded",
195            Self::MimeTypeNotAllowed { .. } => "file_mime_type_not_allowed",
196            Self::Backend { .. } => "file_backend_error",
197        }
198    }
199
200    /// Returns the HTTP status code this variant maps to when surfaced
201    /// through [`crate::FraiseQLError::File`].
202    ///
203    /// User-fixable validation failures map to 4xx; backend / infrastructure
204    /// failures map to 5xx. The split here preserves what
205    /// `fraiseql-storage/src/routes/mod.rs::storage_error_response` previously
206    /// hard-coded via the `code: Option<String>` discriminator on the
207    /// (now-deprecated) `FraiseQLError::Storage` variant:
208    ///
209    /// - `NotFound` → 404 — backend reports object missing
210    /// - `PermissionDenied` → 403 — backend refuses the operation
211    /// - `InvalidKey` → 400 — caller supplied a malformed key
212    /// - `IoError`, `Backend`, `NotImplemented`, `Unsupported`, `SizeLimitExceeded`,
213    ///   `MimeTypeNotAllowed` → 500 — preserves the legacy behavior of `FraiseQLError::Storage`
214    ///   (which routed every `code` *except* `not_found`/`permission_denied` to 500 via
215    ///   `storage_error_response`)
216    /// - All other (pre-existing) variants — `TooLarge`, `InvalidType`, `MimeMismatch`,
217    ///   `VirusDetected`, `QuotaExceeded`, `Storage`, `Processing` — fall back to the
218    ///   `FraiseQLError::File`-level 400 via the wildcard arm so that pre-F050 callers see
219    ///   unchanged HTTP responses.
220    #[must_use]
221    pub const fn status_code(&self) -> u16 {
222        match self {
223            Self::NotFound { .. } => 404,
224            Self::PermissionDenied { .. } => 403,
225            Self::IoError { .. }
226            | Self::Backend { .. }
227            | Self::NotImplemented { .. }
228            | Self::Unsupported { .. }
229            | Self::SizeLimitExceeded { .. }
230            | Self::MimeTypeNotAllowed { .. } => 500,
231            // F050 InvalidKey (user-fixable) plus pre-F050 variants — both
232            // map to 400 to preserve the legacy `FraiseQLError::File` → 400
233            // routing for backwards-compatibility.
234            Self::InvalidKey { .. }
235            | Self::TooLarge { .. }
236            | Self::InvalidType { .. }
237            | Self::MimeMismatch { .. }
238            | Self::VirusDetected { .. }
239            | Self::QuotaExceeded
240            | Self::Storage { .. }
241            | Self::Processing { .. } => 400,
242        }
243    }
244}