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}