tus-protocol 0.0.1

Rust implementation of the TUS resumable upload protocol
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
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
//! TUS protocol error types.
//!
//! This module defines all error types that can occur during TUS operations.
//! Each error variant includes the appropriate HTTP status code to return to clients.
//!
//! Framework adapters (`tus-axum`, etc.) build their HTTP responses from
//! [`Error::error_response`]. This crate intentionally has no dependency on a
//! specific HTTP framework.

/// Errors that can occur during TUS protocol operations.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// Upload not found (404 Not Found).
    #[error("upload not found: {0}")]
    NotFound(String),

    /// Upload already exists (409 Conflict).
    #[error("upload already exists: {0}")]
    AlreadyExists(String),

    /// Offset mismatch (409 Conflict).
    #[error("offset mismatch: expected {expected}, got {actual}")]
    OffsetMismatch {
        /// Offset the server expected the client to upload next.
        expected: u64,
        /// Offset the client actually supplied.
        actual: u64,
    },

    /// Upload size exceeds maximum (413 Payload Too Large).
    #[error("upload size {size} exceeds maximum {max}")]
    SizeExceeded {
        /// Declared or observed upload size.
        size: u64,
        /// Maximum size allowed by the server configuration.
        max: u64,
    },

    /// Invalid content type (415 Unsupported Media Type).
    #[error("invalid content type: expected {expected}, got {actual}")]
    InvalidContentType {
        /// Content type the server required.
        expected: String,
        /// Content type the client supplied.
        actual: String,
    },

    /// Missing required header (400 Bad Request).
    #[error("missing required header: {0}")]
    MissingHeader(&'static str),

    /// Missing Tus-Resumable header (412 Precondition Failed).
    #[error("missing Tus-Resumable header")]
    MissingTusResumable,

    /// Unsupported TUS version (412 Precondition Failed).
    #[error("unsupported TUS version: {0}")]
    UnsupportedTusVersion(String),

    /// Invalid header value (400 Bad Request).
    #[error("invalid header value for {header}: {message}")]
    InvalidHeader {
        /// Name of the header that failed validation.
        header: &'static str,
        /// Human-readable description of why the value was rejected.
        message: String,
    },

    /// Upload is locked by another operation (423 Locked).
    #[error("upload is locked: {0}")]
    Locked(String),

    /// Lock acquisition timeout (423 Locked).
    #[error("lock acquisition timeout for upload: {0}")]
    LockTimeout(String),

    /// Upload has expired (410 Gone).
    #[error("upload has expired: {0}")]
    Expired(String),

    /// Checksum mismatch (460 Checksum Mismatch - TUS specific).
    #[error("checksum mismatch: expected {expected}, got {actual}")]
    ChecksumMismatch {
        /// Checksum value supplied by the client (base64).
        expected: String,
        /// Checksum value computed by the server (base64).
        actual: String,
    },

    /// Unsupported checksum algorithm (400 Bad Request).
    #[error("unsupported checksum algorithm: {0}")]
    UnsupportedChecksum(String),

    /// Extension not supported (400 Bad Request).
    #[error("extension not supported: {0}")]
    ExtensionNotSupported(String),

    /// Invalid metadata format (400 Bad Request).
    #[error("invalid metadata: {0}")]
    InvalidMetadata(String),

    /// Concatenation error (400 Bad Request).
    #[error("concatenation error: {0}")]
    ConcatenationError(String),

    /// Partial upload required for concatenation (400 Bad Request).
    #[error("upload {0} is not a partial upload")]
    NotPartialUpload(String),

    /// Upload is incomplete (400 Bad Request).
    #[error("upload {0} is incomplete")]
    IncompleteUpload(String),

    /// Requested byte range cannot be satisfied (416 Range Not Satisfiable).
    #[error("range not satisfiable for resource of size {size}")]
    RangeNotSatisfiable {
        /// Total resource size in bytes.
        size: u64,
    },

    /// Storage key not set (500 Internal Server Error).
    #[error("storage key not set for upload")]
    StorageKeyMissing,

    /// Storage operation failed (500 Internal Server Error).
    ///
    /// The underlying cause is exposed through `std::error::Error::source`
    /// only (not embedded in the display message), so error-chain reporters
    /// don't print it twice.
    #[error("storage error")]
    Storage(#[source] Box<dyn std::error::Error + Send + Sync>),

    /// State store operation failed (500 Internal Server Error).
    ///
    /// The underlying cause is exposed through `std::error::Error::source`
    /// only (not embedded in the display message), so error-chain reporters
    /// don't print it twice.
    #[error("state store error")]
    StateStore(#[source] Box<dyn std::error::Error + Send + Sync>),

    /// Hook execution failed (500 Internal Server Error or hook-determined).
    ///
    /// The underlying cause is exposed through `std::error::Error::source`
    /// only (not embedded in the display message), so error-chain reporters
    /// don't print it twice.
    #[error("hook error")]
    Hook(#[source] Box<dyn std::error::Error + Send + Sync>),

    /// Hook rejected the operation (hook-determined status code).
    #[error("hook rejected: {message}")]
    HookRejected {
        /// HTTP status code the hook wants the server to return.
        status_code: u16,
        /// Human-readable rejection message.
        message: String,
    },

    /// Request body size cannot be determined up front (411 Length Required).
    ///
    /// Returned for streamed bodies without a `Content-Length` header when the
    /// server has no configured `max_chunk_size` to bound intake buffering.
    #[error("length required: request must declare Content-Length")]
    LengthRequired,

    /// IO error (500 Internal Server Error).
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),

    /// Internal error (500 Internal Server Error).
    #[error("internal error: {0}")]
    Internal(String),

    /// Method not allowed (405 Method Not Allowed).
    ///
    /// Carries the rejected method. RFC 9110 requires a 405 response to carry
    /// an `Allow` header listing the methods the target resource *does*
    /// support, but that set is route- and configuration-dependent and is not
    /// known to this framework-neutral error. Adapters therefore own the
    /// `Allow` header; `error_response` does not synthesize it. The first-party
    /// `tus-axum` adapter attaches `Allow` on every 405 path.
    #[error("method not allowed: {0}")]
    MethodNotAllowed(String),

    /// Cannot modify a final concatenated upload (403 Forbidden).
    #[error("cannot modify final upload: {0}")]
    FinalUploadModificationForbidden(String),

    /// Cannot modify an already-completed upload (403 Forbidden).
    #[error("cannot modify completed upload: {0}")]
    CompletedUploadModificationForbidden(String),

    /// Upload id failed shape validation (400 Bad Request).
    ///
    /// The id was either empty, too long, contained a path
    /// separator, or contained a control character (NUL, etc.).
    /// Returned by [`UploadId`](crate::protocol::UploadId) parsing.
    #[error("invalid upload id: {0}")]
    InvalidUploadId(String),
}

impl Error {
    /// Returns the HTTP status code for this error.
    pub fn status_code(&self) -> u16 {
        match self {
            Error::NotFound(_) => 404,
            Error::AlreadyExists(_) => 409,
            Error::OffsetMismatch { .. } => 409,
            Error::SizeExceeded { .. } => 413,
            Error::InvalidContentType { .. } => 415,
            Error::MissingHeader(_) => 400,
            Error::MissingTusResumable => 412,
            Error::UnsupportedTusVersion(_) => 412,
            Error::InvalidHeader { .. } => 400,
            Error::Locked(_) => 423,
            Error::LockTimeout(_) => 423,
            Error::Expired(_) => 410,
            Error::ChecksumMismatch { .. } => 460, // TUS-specific status code
            Error::UnsupportedChecksum(_) => 400,
            Error::ExtensionNotSupported(_) => 400,
            Error::InvalidMetadata(_) => 400,
            Error::ConcatenationError(_) => 400,
            Error::NotPartialUpload(_) => 400,
            Error::IncompleteUpload(_) => 400,
            Error::RangeNotSatisfiable { .. } => 416,
            Error::StorageKeyMissing => 500,
            Error::Storage(_) => 500,
            Error::StateStore(_) => 500,
            Error::Hook(_) => 500,
            // Hooks supply arbitrary integers; anything outside the valid
            // HTTP range must not reach the response layer.
            Error::HookRejected { status_code, .. } => {
                if (100..=599).contains(status_code) {
                    *status_code
                } else {
                    500
                }
            }
            Error::LengthRequired => 411,
            Error::Io(_) => 500,
            Error::Internal(_) => 500,
            Error::MethodNotAllowed(_) => 405,
            Error::FinalUploadModificationForbidden(_) => 403,
            Error::CompletedUploadModificationForbidden(_) => 403,
            Error::InvalidUploadId(_) => 400,
        }
    }

    /// Returns whether this error should include details in the response body.
    ///
    /// Some errors (like internal server errors) should not expose details to clients.
    pub fn should_expose_details(&self) -> bool {
        !matches!(
            self,
            Error::Storage(_)
                | Error::StateStore(_)
                | Error::Hook(_)
                | Error::Io(_)
                | Error::Internal(_)
        )
    }

    /// Creates a storage error from any error type.
    pub fn storage<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
        Error::Storage(Box::new(err))
    }

    /// Creates a state store error from any error type.
    pub fn state_store<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
        Error::StateStore(Box::new(err))
    }

    /// Creates a hook error from any error type.
    pub fn hook<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
        Error::Hook(Box::new(err))
    }

    /// Returns the framework-neutral pieces of a TUS-spec-compliant error
    /// response.
    ///
    /// This is the single source of truth for the TUS error→response mapping.
    /// Framework adapters (axum, Cloudflare Workers) build their concrete
    /// `Response` types from the returned [`ErrorResponse`]. Keeping the
    /// mapping in one place stops the adapter code paths from drifting.
    ///
    /// One header is deliberately *not* synthesized here: the `Allow` header on
    /// a `405` ([`Error::MethodNotAllowed`]). Its value is the set of methods
    /// the target route accepts, which depends on route registration and
    /// configuration the protocol error cannot see. Adapters own it.
    pub fn error_response(&self) -> ErrorResponse {
        let status = self.status_code();
        let body = if self.should_expose_details() {
            self.to_string()
        } else {
            "Internal server error".to_string()
        };
        let mut headers: Vec<(&'static str, String)> =
            vec![("tus-resumable", crate::config::TUS_RESUMABLE.to_string())];
        match self {
            Error::MissingTusResumable | Error::UnsupportedTusVersion(_) => {
                headers.push(("tus-version", crate::config::TUS_RESUMABLE.to_string()));
            }
            Error::OffsetMismatch { expected, .. } => {
                headers.push(("upload-offset", expected.to_string()));
            }
            Error::RangeNotSatisfiable { size } => {
                headers.push(("content-range", format!("bytes */{size}")));
            }
            _ => {}
        }
        ErrorResponse {
            status,
            headers,
            body,
        }
    }
}

/// The framework-neutral pieces of a TUS-spec-compliant error response,
/// returned by [`Error::error_response`].
///
/// Adapters read the fields to build their concrete HTTP response. The type is
/// `#[non_exhaustive]` so additional fields (for example a content type or a
/// structured problem body) can be added later without a breaking change;
/// adapters should therefore access fields by name rather than destructuring
/// exhaustively.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ErrorResponse {
    /// HTTP status code for the response.
    ///
    /// A `u16` rather than `http::StatusCode` because TUS uses codes outside
    /// the IANA registry (for example `460 Checksum Mismatch`) and hooks may
    /// supply arbitrary rejection codes.
    pub status: u16,

    /// Response headers required by the TUS spec.
    ///
    /// Always contains `tus-resumable`. Some variants append further headers
    /// (`tus-version` on version errors, `upload-offset` on offset mismatches,
    /// `content-range` on range errors).
    pub headers: Vec<(&'static str, String)>,

    /// Response body. Internal-detail variants return a redacted body string.
    pub body: String,
}

/// Result type alias for TUS operations.
pub type Result<T> = std::result::Result<T, Error>;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_status_codes() {
        assert_eq!(Error::NotFound("test".into()).status_code(), 404);
        assert_eq!(Error::AlreadyExists("test".into()).status_code(), 409);
        assert_eq!(
            Error::OffsetMismatch {
                expected: 0,
                actual: 100
            }
            .status_code(),
            409
        );
        assert_eq!(
            Error::SizeExceeded { size: 100, max: 50 }.status_code(),
            413
        );
        assert_eq!(
            Error::CompletedUploadModificationForbidden("test".into()).status_code(),
            403
        );
        assert_eq!(Error::Locked("test".into()).status_code(), 423);
        assert_eq!(Error::Expired("test".into()).status_code(), 410);
        assert_eq!(
            Error::ChecksumMismatch {
                expected: "a".into(),
                actual: "b".into()
            }
            .status_code(),
            460
        );
    }

    #[test]
    fn wrapped_error_display_does_not_embed_source() {
        let cases: [(Error, &str); 3] = [
            (
                Error::storage(std::io::Error::other("disk on fire")),
                "storage error",
            ),
            (
                Error::state_store(std::io::Error::other("redis ate it")),
                "state store error",
            ),
            (
                Error::hook(std::io::Error::other("hook crashed")),
                "hook error",
            ),
        ];
        for (err, display) in cases {
            assert_eq!(err.to_string(), display, "source leaked into Display");
            let source = std::error::Error::source(&err).expect("source must be preserved");
            assert!(!source.to_string().is_empty());
        }
    }

    #[test]
    fn test_should_expose_details() {
        assert!(Error::NotFound("test".into()).should_expose_details());
        assert!(
            Error::OffsetMismatch {
                expected: 0,
                actual: 100
            }
            .should_expose_details()
        );
        assert!(!Error::Internal("secret".into()).should_expose_details());
    }

    fn header(headers: &[(&'static str, String)], name: &str) -> Option<String> {
        headers
            .iter()
            .find(|(n, _)| n.eq_ignore_ascii_case(name))
            .map(|(_, v)| v.clone())
    }

    #[test]
    fn error_response_status_matches_status_code() {
        for make in variant_constructors() {
            let err = make();
            let expected = err.status_code();
            assert_eq!(
                err.error_response().status,
                expected,
                "status mismatch for variant"
            );
        }
    }

    #[test]
    fn error_response_always_includes_tus_resumable() {
        for make in variant_constructors() {
            let err = make();
            let response = err.error_response();
            assert_eq!(
                header(&response.headers, "tus-resumable").as_deref(),
                Some(crate::config::TUS_RESUMABLE),
                "tus-resumable missing or wrong",
            );
        }
    }

    #[test]
    fn error_response_version_errors_include_tus_version() {
        let response = Error::MissingTusResumable.error_response();
        assert_eq!(
            header(&response.headers, "tus-version").as_deref(),
            Some(crate::config::TUS_RESUMABLE),
        );
        let response = Error::UnsupportedTusVersion("9.9.9".into()).error_response();
        assert_eq!(
            header(&response.headers, "tus-version").as_deref(),
            Some(crate::config::TUS_RESUMABLE),
        );
    }

    #[test]
    fn error_response_offset_mismatch_includes_upload_offset() {
        let response = Error::OffsetMismatch {
            expected: 4096,
            actual: 0,
        }
        .error_response();
        assert_eq!(
            header(&response.headers, "upload-offset").as_deref(),
            Some("4096"),
        );
    }

    #[test]
    fn error_response_range_not_satisfiable_includes_content_range() {
        let response = Error::RangeNotSatisfiable { size: 1024 }.error_response();
        assert_eq!(
            header(&response.headers, "content-range").as_deref(),
            Some("bytes */1024"),
        );
    }

    #[test]
    fn error_response_redacts_internal_error_bodies() {
        let cases = [
            Error::Internal("secret".into()),
            Error::Storage(Box::new(std::io::Error::other("disk on fire"))),
            Error::StateStore(Box::new(std::io::Error::other("redis ate it"))),
            Error::Hook(Box::new(std::io::Error::other("hook crashed"))),
            Error::Io(std::io::Error::other("eio")),
        ];
        for err in cases {
            assert_eq!(
                err.error_response().body,
                "Internal server error",
                "leaked details"
            );
        }
    }

    #[test]
    fn error_response_exposes_safe_error_bodies() {
        let err = Error::NotFound("upload-123".into());
        let display = err.to_string();
        assert_eq!(err.error_response().body, display);
    }

    #[test]
    fn error_response_hook_rejected_uses_provided_status() {
        let response = Error::HookRejected {
            status_code: 451,
            message: "legal".into(),
        }
        .error_response();
        assert_eq!(response.status, 451);
    }

    #[test]
    fn error_response_no_extra_headers_for_unrelated_variants() {
        let response = Error::NotFound("x".into()).error_response();
        let names: Vec<&str> = response.headers.iter().map(|(n, _)| *n).collect();
        assert_eq!(names, vec!["tus-resumable"]);
    }

    /// Constructors for every Error variant. Used by parity and coverage tests
    /// so adding a new variant forces a thoughtful update here.
    fn variant_constructors() -> Vec<Box<dyn Fn() -> Error>> {
        vec![
            Box::new(|| Error::NotFound("x".into())),
            Box::new(|| Error::AlreadyExists("x".into())),
            Box::new(|| Error::OffsetMismatch {
                expected: 5,
                actual: 3,
            }),
            Box::new(|| Error::SizeExceeded { size: 100, max: 50 }),
            Box::new(|| Error::InvalidContentType {
                expected: "application/offset+octet-stream".into(),
                actual: "text/plain".into(),
            }),
            Box::new(|| Error::MissingHeader("Upload-Offset")),
            Box::new(|| Error::MissingTusResumable),
            Box::new(|| Error::UnsupportedTusVersion("9.9.9".into())),
            Box::new(|| Error::InvalidHeader {
                header: "Upload-Length",
                message: "not a number".into(),
            }),
            Box::new(|| Error::Locked("x".into())),
            Box::new(|| Error::LockTimeout("x".into())),
            Box::new(|| Error::Expired("x".into())),
            Box::new(|| Error::ChecksumMismatch {
                expected: "abc".into(),
                actual: "def".into(),
            }),
            Box::new(|| Error::UnsupportedChecksum("xyz".into())),
            Box::new(|| Error::ExtensionNotSupported("foo".into())),
            Box::new(|| Error::InvalidMetadata("bad".into())),
            Box::new(|| Error::ConcatenationError("bad".into())),
            Box::new(|| Error::NotPartialUpload("x".into())),
            Box::new(|| Error::IncompleteUpload("x".into())),
            Box::new(|| Error::RangeNotSatisfiable { size: 1024 }),
            Box::new(|| Error::StorageKeyMissing),
            Box::new(|| Error::Storage(Box::new(std::io::Error::other("x")))),
            Box::new(|| Error::StateStore(Box::new(std::io::Error::other("x")))),
            Box::new(|| Error::Hook(Box::new(std::io::Error::other("x")))),
            Box::new(|| Error::HookRejected {
                status_code: 418,
                message: "teapot".into(),
            }),
            Box::new(|| Error::Io(std::io::Error::other("x"))),
            Box::new(|| Error::Internal("x".into())),
            Box::new(|| Error::MethodNotAllowed("PATCH".into())),
            Box::new(|| Error::FinalUploadModificationForbidden("x".into())),
            Box::new(|| Error::CompletedUploadModificationForbidden("x".into())),
            Box::new(|| Error::InvalidUploadId("contains NUL".into())),
        ]
    }
}