Skip to main content

fileloft_core/
error.rs

1use http::StatusCode;
2use thiserror::Error;
3
4#[derive(Debug, Error)]
5pub enum TusError {
6    // --- Protocol-level errors ---
7    #[error("missing Tus-Resumable header")]
8    MissingTusResumable,
9
10    #[error("unsupported tus version: {version}")]
11    UnsupportedVersion { version: String },
12
13    #[error("missing Upload-Offset header")]
14    MissingUploadOffset,
15
16    #[error("upload offset mismatch: expected {expected}, got {actual}")]
17    OffsetMismatch { expected: u64, actual: u64 },
18
19    #[error("wrong Content-Type: expected application/offset+octet-stream, got {0}")]
20    WrongContentType(String),
21
22    #[error("upload not found: {0}")]
23    NotFound(String),
24
25    #[error("upload has expired")]
26    Gone,
27
28    #[error("upload size exceeds server maximum of {max} bytes")]
29    EntityTooLarge { max: u64 },
30
31    #[error("chunk exceeds declared upload length (declared {declared} bytes, end offset would be {end})")]
32    ExceedsUploadLength { declared: u64, end: u64 },
33
34    #[error("checksum mismatch")]
35    ChecksumMismatch,
36
37    #[error("unsupported checksum algorithm: {0}")]
38    UnsupportedChecksumAlgorithm(String),
39
40    #[error("missing Upload-Length (or Upload-Defer-Length)")]
41    MissingUploadLength,
42
43    #[error("Upload-Length cannot be changed once set")]
44    UploadLengthAlreadySet,
45
46    #[error("extension not enabled: {0}")]
47    ExtensionNotEnabled(&'static str),
48
49    #[error("invalid metadata: {0}")]
50    InvalidMetadata(String),
51
52    #[error("invalid upload ID")]
53    InvalidUploadId,
54
55    #[error("concatenation requires at least one partial upload URL")]
56    EmptyConcatenation,
57
58    #[error("partial upload {0} is not yet complete")]
59    PartialUploadIncomplete(String),
60
61    #[error("PATCH is not allowed on a final concatenated upload")]
62    PatchOnFinalUpload,
63
64    #[error("upload is not complete or not available for download")]
65    UploadNotReadyForDownload,
66
67    #[error("method not allowed")]
68    MethodNotAllowed,
69
70    // --- Concurrency errors ---
71    #[error("lock acquisition timed out for upload {0}")]
72    LockTimeout(String),
73
74    #[error("lock is already held for upload {0}")]
75    LockConflict(String),
76
77    // --- Hook errors ---
78    #[error("hook rejected request: {0}")]
79    HookRejected(String),
80
81    // --- Storage / internal errors ---
82    #[error("I/O error: {0}")]
83    Io(#[from] std::io::Error),
84
85    #[error("serialization error: {0}")]
86    Serialization(#[from] serde_json::Error),
87
88    #[error("internal error: {0}")]
89    Internal(String),
90}
91
92impl TusError {
93    pub fn client_message(&self) -> String {
94        match self {
95            Self::Io(_) | Self::Serialization(_) | Self::Internal(_) => {
96                "internal server error".to_string()
97            }
98            _ => self.to_string(),
99        }
100    }
101
102    /// Maps each variant to the appropriate HTTP status code per the tus 1.0.x spec.
103    pub fn status_code(&self) -> StatusCode {
104        match self {
105            Self::MissingTusResumable => StatusCode::PRECONDITION_FAILED,
106            Self::UnsupportedVersion { .. } => StatusCode::PRECONDITION_FAILED,
107            Self::MissingUploadOffset => StatusCode::BAD_REQUEST,
108            Self::OffsetMismatch { .. } => StatusCode::CONFLICT,
109            Self::WrongContentType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
110            Self::NotFound(_) => StatusCode::NOT_FOUND,
111            Self::Gone => StatusCode::GONE,
112            Self::EntityTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
113            Self::ExceedsUploadLength { .. } => StatusCode::PAYLOAD_TOO_LARGE,
114            // 460 is a non-standard tus status code; http crate accepts arbitrary codes.
115            Self::ChecksumMismatch => match StatusCode::from_u16(460) {
116                Ok(s) => s,
117                Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
118            },
119            Self::UnsupportedChecksumAlgorithm(_) => StatusCode::BAD_REQUEST,
120            Self::MissingUploadLength => StatusCode::BAD_REQUEST,
121            Self::UploadLengthAlreadySet => StatusCode::BAD_REQUEST,
122            Self::ExtensionNotEnabled(_) => StatusCode::NOT_FOUND,
123            Self::InvalidMetadata(_) => StatusCode::BAD_REQUEST,
124            Self::InvalidUploadId => StatusCode::BAD_REQUEST,
125            Self::EmptyConcatenation => StatusCode::BAD_REQUEST,
126            Self::PartialUploadIncomplete(_) => StatusCode::BAD_REQUEST,
127            Self::PatchOnFinalUpload => StatusCode::FORBIDDEN,
128            Self::UploadNotReadyForDownload => StatusCode::BAD_REQUEST,
129            Self::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
130            Self::LockTimeout(_) => StatusCode::REQUEST_TIMEOUT,
131            Self::LockConflict(_) => StatusCode::LOCKED,
132            Self::HookRejected(_) => StatusCode::FORBIDDEN,
133            Self::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
134            Self::Serialization(_) => StatusCode::INTERNAL_SERVER_ERROR,
135            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    /// Every TusError variant must map to a well-defined HTTP status code.
145    #[test]
146    fn status_code_mapping() {
147        let cases: &[(TusError, u16)] = &[
148            (TusError::MissingTusResumable, 412),
149            (
150                TusError::UnsupportedVersion {
151                    version: "0.9".into(),
152                },
153                412,
154            ),
155            (TusError::MissingUploadOffset, 400),
156            (
157                TusError::OffsetMismatch {
158                    expected: 10,
159                    actual: 5,
160                },
161                409,
162            ),
163            (TusError::WrongContentType("text/plain".into()), 415),
164            (TusError::NotFound("abc".into()), 404),
165            (TusError::Gone, 410),
166            (TusError::EntityTooLarge { max: 1024 }, 413),
167            (
168                TusError::ExceedsUploadLength {
169                    declared: 10,
170                    end: 20,
171                },
172                413,
173            ),
174            (TusError::ChecksumMismatch, 460),
175            (TusError::UnsupportedChecksumAlgorithm("crc32".into()), 400),
176            (TusError::MissingUploadLength, 400),
177            (TusError::UploadLengthAlreadySet, 400),
178            (TusError::ExtensionNotEnabled("concatenation"), 404),
179            (TusError::InvalidMetadata("bad base64".into()), 400),
180            (TusError::InvalidUploadId, 400),
181            (TusError::EmptyConcatenation, 400),
182            (TusError::PartialUploadIncomplete("id1".into()), 400),
183            (TusError::PatchOnFinalUpload, 403),
184            (TusError::UploadNotReadyForDownload, 400),
185            (TusError::MethodNotAllowed, 405),
186            (TusError::LockTimeout("id1".into()), 408),
187            (TusError::LockConflict("id1".into()), 423),
188            (TusError::HookRejected("not allowed".into()), 403),
189            (TusError::Io(std::io::Error::other("disk full")), 500),
190            (
191                TusError::Serialization(serde_json::from_str::<()>("!").unwrap_err()),
192                500,
193            ),
194            (TusError::Internal("oops".into()), 500),
195        ];
196
197        for (err, expected_status) in cases {
198            assert_eq!(
199                err.status_code().as_u16(),
200                *expected_status,
201                "wrong status for: {err}"
202            );
203        }
204    }
205}