Skip to main content

autumn_web/
range.rs

1//! HTTP `Range` request support (RFC 7233) for in-memory and streamed bodies.
2//!
3//! This is the reusable core behind [`Download`](crate::download::Download)'s
4//! ranged responses and the embedded static-asset path. It parses a single
5//! `Range: bytes=…` header against a known total size and resolves it to a
6//! byte interval, an "ignore the range" signal, or a "not satisfiable" signal —
7//! then builds the matching `206 Partial Content` / `200 OK` / `416 Range Not
8//! Satisfiable` response.
9//!
10//! # What it handles
11//!
12//! - `bytes=N-` — from byte `N` to the end.
13//! - `bytes=N-M` — inclusive `[N, M]` (M clamped to `total-1`).
14//! - `bytes=-N` — the final `N` bytes (a *suffix* range; `N` clamped to `total`).
15//! - **Multi-range** (`bytes=0-50,100-150`): collapsed deterministically to the
16//!   **first satisfiable** range and served as a single-range `206`. A true
17//!   `multipart/byteranges` body is intentionally not emitted — the collapse
18//!   guarantees a well-formed single-range response rather than a malformed
19//!   multi-part one. See [`resolve`].
20//! - **`If-Range`** (RFC 7233 §3.2): a strong entity-tag or an HTTP-date
21//!   validator. When the client's validator is stale (or absent), the `Range`
22//!   is ignored and the full `200` is served.
23//!
24//! Invalid or unparseable ranges are ignored (RFC 7233 §3.1: serve the whole
25//! representation with `200`), never rejected.
26//!
27//! # Seekable video from a stored blob (AC #8)
28//!
29//! ```ignore
30//! use autumn_web::download::Download;
31//! use autumn_web::storage::SharedBlobStore;
32//! use autumn_web::{secured, AutumnError};
33//! use http::HeaderMap;
34//!
35//! // A browser `<video>` element issues `Range` requests to seek. Returning
36//! // the download through `into_response_ranged` makes the stream seekable:
37//! // the store is asked for only the requested byte slice.
38//! #[secured(policy = "media.watch")]
39//! async fn watch(
40//!     store: SharedBlobStore,
41//!     key: String,
42//!     headers: HeaderMap,
43//! ) -> Result<axum::response::Response, AutumnError> {
44//!     Ok(Download::from_blob(&store, key)
45//!         .await?
46//!         .content_type("video/mp4")
47//!         .inline()
48//!         .into_response_ranged(&headers)
49//!         .await)
50//! }
51//! ```
52
53use axum::body::Body;
54use axum::response::Response;
55use bytes::Bytes;
56use http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, IF_RANGE, RANGE};
57use http::{HeaderMap, HeaderValue, StatusCode};
58
59use crate::etag::ETag;
60
61/// The resolved outcome of a `Range` request against a known total size.
62///
63/// `start` and `end` are **inclusive** byte offsets, matching the HTTP
64/// `Content-Range` convention.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum RangeResolution {
67    /// Serve the whole representation with `200 OK`. Produced when there is no
68    /// (valid) `Range` header, the range unit is not `bytes`, the range cannot
69    /// be parsed, or an `If-Range` validator did not match.
70    Full,
71    /// Serve the inclusive byte interval `[start, end]` with `206 Partial
72    /// Content`.
73    Partial {
74        /// First byte offset (inclusive).
75        start: u64,
76        /// Last byte offset (inclusive).
77        end: u64,
78        /// Total size of the full representation.
79        total: u64,
80    },
81    /// The requested range cannot be satisfied (start past EOF, empty suffix,
82    /// or an empty representation). Serve `416 Range Not Satisfiable` with
83    /// `Content-Range: bytes */total`.
84    Unsatisfiable {
85        /// Total size of the full representation.
86        total: u64,
87    },
88}
89
90/// The current validators for an `If-Range` conditional range request.
91///
92/// Carries the resource's current **strong** [`ETag`] and/or its
93/// `Last-Modified` HTTP-date string. When a request includes `If-Range`, the
94/// range is honoured only if the client's validator still matches; otherwise
95/// the full representation is returned (see [`resolve`]).
96#[derive(Debug, Clone, Copy, Default)]
97pub struct Validator<'a> {
98    /// The resource's current strong entity-tag, if any.
99    etag: Option<&'a ETag>,
100    /// The resource's current `Last-Modified` value as an HTTP-date string.
101    last_modified: Option<&'a str>,
102}
103
104impl<'a> Validator<'a> {
105    /// An empty validator (matches nothing — any `If-Range` falls back to `200`).
106    #[must_use]
107    pub const fn new() -> Self {
108        Self {
109            etag: None,
110            last_modified: None,
111        }
112    }
113
114    /// Attach the resource's current strong [`ETag`].
115    #[must_use]
116    pub const fn with_etag(mut self, etag: &'a ETag) -> Self {
117        self.etag = Some(etag);
118        self
119    }
120
121    /// Attach the resource's current `Last-Modified` HTTP-date string.
122    #[must_use]
123    pub const fn with_last_modified(mut self, last_modified: &'a str) -> Self {
124        self.last_modified = Some(last_modified);
125        self
126    }
127}
128
129/// Resolve a request's `Range` header against a `total` byte size.
130///
131/// Returns a [`RangeResolution`]:
132///
133/// - No `Range` header, a non-`bytes` unit, or an unparseable value → [`Full`]
134///   (RFC 7233 §3.1: ignore an invalid range, serve `200`).
135/// - An `If-Range` header whose validator is stale or unmatched → [`Full`].
136/// - A satisfiable range → [`Partial`], carrying the inclusive `[start, end]`.
137/// - A syntactically valid but unsatisfiable range (start ≥ `total`, a zero
138///   suffix, or `total == 0`) → [`Unsatisfiable`].
139///
140/// # Multi-range collapse
141///
142/// A multi-range request (`bytes=0-50,100-150`) is collapsed to the **first
143/// satisfiable** sub-range and served as a single-range `206`. This is
144/// deterministic and always well-formed; a `multipart/byteranges` body is
145/// deliberately not produced.
146///
147/// [`Full`]: RangeResolution::Full
148/// [`Partial`]: RangeResolution::Partial
149/// [`Unsatisfiable`]: RangeResolution::Unsatisfiable
150#[must_use]
151pub fn resolve(
152    req_headers: &HeaderMap,
153    total: u64,
154    validator: Option<Validator<'_>>,
155) -> RangeResolution {
156    let Some(range_value) = req_headers.get(RANGE) else {
157        return RangeResolution::Full;
158    };
159    let Ok(range_str) = range_value.to_str() else {
160        return RangeResolution::Full;
161    };
162
163    // RFC 7233 §3.2: an unmatched (or absent-validator) `If-Range` means the
164    // client's cached copy is stale, so the `Range` MUST be ignored and the
165    // full representation returned.
166    if let Some(if_range) = req_headers.get(IF_RANGE).and_then(|v| v.to_str().ok())
167        && !if_range_matches(if_range, validator.as_ref())
168    {
169        return RangeResolution::Full;
170    }
171
172    parse_range(range_str, total)
173}
174
175/// Outcome of parsing a single byte-range spec.
176enum SpecOutcome {
177    /// Syntactically invalid — ignore this spec (RFC 7233 §3.1).
178    Invalid,
179    /// A satisfiable inclusive interval.
180    Satisfiable { start: u64, end: u64 },
181    /// Syntactically valid but not satisfiable against the total size.
182    Unsatisfiable,
183}
184
185/// Parse a `bytes=…` header value, applying the single-range collapse.
186fn parse_range(range_str: &str, total: u64) -> RangeResolution {
187    let Some(rest) = range_str.trim().strip_prefix("bytes=") else {
188        // Non-`bytes` unit or malformed prefix → ignore, serve full.
189        return RangeResolution::Full;
190    };
191
192    let mut saw_unsatisfiable = false;
193
194    for spec in rest.split(',') {
195        let spec = spec.trim();
196        if spec.is_empty() {
197            continue;
198        }
199        match parse_single_spec(spec, total) {
200            SpecOutcome::Invalid => {}
201            SpecOutcome::Satisfiable { start, end } => {
202                // First satisfiable range wins (single-range collapse).
203                return RangeResolution::Partial { start, end, total };
204            }
205            SpecOutcome::Unsatisfiable => {
206                saw_unsatisfiable = true;
207            }
208        }
209    }
210
211    if saw_unsatisfiable {
212        RangeResolution::Unsatisfiable { total }
213    } else {
214        // No syntactically valid range at all → ignore, serve full.
215        RangeResolution::Full
216    }
217}
218
219/// Parse one byte-range spec (`N-`, `N-M`, or `-N`) against `total`.
220fn parse_single_spec(spec: &str, total: u64) -> SpecOutcome {
221    // Suffix form: `-N` → the final N bytes.
222    if let Some(suffix) = spec.strip_prefix('-') {
223        let Ok(n) = suffix.trim().parse::<u64>() else {
224            return SpecOutcome::Invalid;
225        };
226        if n == 0 || total == 0 {
227            // A zero-length suffix (or empty representation) is unsatisfiable.
228            return SpecOutcome::Unsatisfiable;
229        }
230        // Clamp N ≤ total → start at 0.
231        let start = total.saturating_sub(n);
232        return SpecOutcome::Satisfiable {
233            start,
234            end: total - 1,
235        };
236    }
237
238    let Some((start_s, end_s)) = spec.split_once('-') else {
239        return SpecOutcome::Invalid;
240    };
241    let Ok(start) = start_s.trim().parse::<u64>() else {
242        return SpecOutcome::Invalid;
243    };
244
245    // `N-` → from N to the end.
246    if end_s.trim().is_empty() {
247        if total == 0 || start >= total {
248            return SpecOutcome::Unsatisfiable;
249        }
250        return SpecOutcome::Satisfiable {
251            start,
252            end: total - 1,
253        };
254    }
255
256    // `N-M` → inclusive [N, min(M, total-1)].
257    let Ok(end) = end_s.trim().parse::<u64>() else {
258        return SpecOutcome::Invalid;
259    };
260    if start > end {
261        // Backwards range (`5-2`) is invalid — ignore this spec.
262        return SpecOutcome::Invalid;
263    }
264    if total == 0 || start >= total {
265        return SpecOutcome::Unsatisfiable;
266    }
267    SpecOutcome::Satisfiable {
268        start,
269        end: end.min(total - 1),
270    }
271}
272
273/// Whether a client `If-Range` validator still matches the current resource.
274///
275/// `If-Range` may be a strong entity-tag or an HTTP-date. A weak entity-tag can
276/// never match (RFC 7233 §3.2 forbids weak validators here). A missing
277/// validator never matches.
278fn if_range_matches(if_range: &str, validator: Option<&Validator<'_>>) -> bool {
279    let Some(validator) = validator else {
280        return false;
281    };
282    let trimmed = if_range.trim();
283
284    // Entity-tag form starts with a quote or the weak prefix.
285    if trimmed.starts_with('"') || trimmed.starts_with("W/") {
286        // Weak validators are not usable for `If-Range`.
287        if trimmed.starts_with("W/") {
288            return false;
289        }
290        let Some(etag) = validator.etag else {
291            return false;
292        };
293        if etag.is_weak() {
294            return false;
295        }
296        trimmed.trim_matches('"') == etag.tag()
297    } else {
298        // HTTP-date form: must equal the current `Last-Modified` exactly.
299        // Clients echo the value verbatim, so a byte comparison is correct and
300        // avoids a second date-parsing dependency.
301        validator
302            .last_modified
303            .is_some_and(|lm| lm.trim() == trimmed)
304    }
305}
306
307// ── Response builders ───────────────────────────────────────────────────────
308
309/// Slice the inclusive byte interval `[start, end]` out of `full`.
310///
311/// `start`/`end` are `u64` offsets that were validated against the total size;
312/// this clamps defensively (and via `try_from`, not `as`, so it never truncates
313/// on a 32-bit target) so a bad caller can never panic on slice bounds.
314fn slice_inclusive(full: &Bytes, start: u64, end: u64) -> Bytes {
315    if full.is_empty() {
316        return Bytes::new();
317    }
318    let last = full.len() - 1;
319    let lo = usize::try_from(start).unwrap_or(usize::MAX).min(last);
320    let hi = usize::try_from(end).unwrap_or(usize::MAX).min(last);
321    if lo > hi {
322        return Bytes::new();
323    }
324    full.slice(lo..=hi)
325}
326
327/// Build the in-memory response for a resolved range over `full` bytes.
328///
329/// - [`Full`](RangeResolution::Full) → `200 OK`, the whole body,
330///   `Accept-Ranges: bytes`, `Content-Length` = `full.len()`.
331/// - [`Partial`](RangeResolution::Partial) → `206 Partial Content`, the sliced
332///   body, `Accept-Ranges: bytes`, `Content-Range: bytes start-end/total`,
333///   `Content-Length` = slice length.
334/// - [`Unsatisfiable`](RangeResolution::Unsatisfiable) → `416 Range Not
335///   Satisfiable`, empty body, `Content-Range: bytes */total`.
336///
337/// The caller layers `Content-Type` / `Content-Disposition` on top.
338#[must_use]
339pub fn partial_bytes_response(resolution: &RangeResolution, full: Bytes) -> Response<Body> {
340    match *resolution {
341        RangeResolution::Full => {
342            let len = full.len();
343            let mut response = Response::new(Body::from(full));
344            set_accept_ranges(response.headers_mut());
345            response
346                .headers_mut()
347                .insert(CONTENT_LENGTH, HeaderValue::from(len));
348            response
349        }
350        RangeResolution::Partial { start, end, total } => {
351            let slice = slice_inclusive(&full, start, end);
352            let len = slice.len();
353            let mut response = Response::new(Body::from(slice));
354            *response.status_mut() = StatusCode::PARTIAL_CONTENT;
355            let headers = response.headers_mut();
356            set_accept_ranges(headers);
357            if let Ok(v) = HeaderValue::from_str(&content_range_value(start, end, total)) {
358                headers.insert(CONTENT_RANGE, v);
359            }
360            headers.insert(CONTENT_LENGTH, HeaderValue::from(len));
361            response
362        }
363        RangeResolution::Unsatisfiable { total } => {
364            let mut response = Response::new(Body::empty());
365            *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
366            let headers = response.headers_mut();
367            set_accept_ranges(headers);
368            if let Ok(v) = HeaderValue::from_str(&unsatisfied_content_range(total)) {
369                headers.insert(CONTENT_RANGE, v);
370            }
371            headers.insert(CONTENT_LENGTH, HeaderValue::from(0));
372            response
373        }
374    }
375}
376
377/// Format a `Content-Range: bytes start-end/total` value (`start`/`end`
378/// inclusive).
379#[must_use]
380pub fn content_range_value(start: u64, end: u64, total: u64) -> String {
381    format!("bytes {start}-{end}/{total}")
382}
383
384/// Format an unsatisfied `Content-Range: bytes */total` value for a `416`.
385#[must_use]
386pub fn unsatisfied_content_range(total: u64) -> String {
387    format!("bytes */{total}")
388}
389
390/// Set `Accept-Ranges: bytes` on a header map, advertising range support.
391pub fn set_accept_ranges(headers: &mut HeaderMap) {
392    headers.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    fn headers_with_range(value: &str) -> HeaderMap {
400        let mut h = HeaderMap::new();
401        h.insert(RANGE, HeaderValue::from_str(value).unwrap());
402        h
403    }
404
405    // ── parser table ────────────────────────────────────────────────────────
406
407    #[test]
408    fn no_range_header_is_full() {
409        assert_eq!(resolve(&HeaderMap::new(), 10, None), RangeResolution::Full);
410    }
411
412    #[test]
413    fn simple_closed_range() {
414        assert_eq!(
415            resolve(&headers_with_range("bytes=0-3"), 10, None),
416            RangeResolution::Partial {
417                start: 0,
418                end: 3,
419                total: 10
420            }
421        );
422    }
423
424    #[test]
425    fn open_ended_range_extends_to_eof() {
426        assert_eq!(
427            resolve(&headers_with_range("bytes=5-"), 10, None),
428            RangeResolution::Partial {
429                start: 5,
430                end: 9,
431                total: 10
432            }
433        );
434    }
435
436    #[test]
437    fn suffix_range_takes_last_n_bytes() {
438        assert_eq!(
439            resolve(&headers_with_range("bytes=-4"), 10, None),
440            RangeResolution::Partial {
441                start: 6,
442                end: 9,
443                total: 10
444            }
445        );
446    }
447
448    #[test]
449    fn suffix_range_clamps_when_larger_than_total() {
450        assert_eq!(
451            resolve(&headers_with_range("bytes=-100"), 10, None),
452            RangeResolution::Partial {
453                start: 0,
454                end: 9,
455                total: 10
456            }
457        );
458    }
459
460    #[test]
461    fn closed_end_is_clamped_to_last_byte() {
462        assert_eq!(
463            resolve(&headers_with_range("bytes=2-999"), 10, None),
464            RangeResolution::Partial {
465                start: 2,
466                end: 9,
467                total: 10
468            }
469        );
470    }
471
472    #[test]
473    fn multi_range_collapses_to_first_satisfiable() {
474        assert_eq!(
475            resolve(&headers_with_range("bytes=0-3,5-7"), 10, None),
476            RangeResolution::Partial {
477                start: 0,
478                end: 3,
479                total: 10
480            }
481        );
482    }
483
484    #[test]
485    fn multi_range_skips_leading_unsatisfiable() {
486        // First spec is past EOF; the collapse picks the next satisfiable one.
487        assert_eq!(
488            resolve(&headers_with_range("bytes=100-200,2-4"), 10, None),
489            RangeResolution::Partial {
490                start: 2,
491                end: 4,
492                total: 10
493            }
494        );
495    }
496
497    #[test]
498    fn start_beyond_eof_is_unsatisfiable() {
499        assert_eq!(
500            resolve(&headers_with_range("bytes=100-"), 10, None),
501            RangeResolution::Unsatisfiable { total: 10 }
502        );
503    }
504
505    #[test]
506    fn non_numeric_range_is_full() {
507        assert_eq!(
508            resolve(&headers_with_range("bytes=abc"), 10, None),
509            RangeResolution::Full
510        );
511    }
512
513    #[test]
514    fn backwards_range_is_full() {
515        assert_eq!(
516            resolve(&headers_with_range("bytes=5-2"), 10, None),
517            RangeResolution::Full
518        );
519    }
520
521    #[test]
522    fn non_bytes_unit_is_full() {
523        assert_eq!(
524            resolve(&headers_with_range("items=0-3"), 10, None),
525            RangeResolution::Full
526        );
527    }
528
529    #[test]
530    fn zero_total_range_is_unsatisfiable() {
531        assert_eq!(
532            resolve(&headers_with_range("bytes=0-3"), 0, None),
533            RangeResolution::Unsatisfiable { total: 0 }
534        );
535    }
536
537    #[test]
538    fn zero_suffix_is_unsatisfiable() {
539        assert_eq!(
540            resolve(&headers_with_range("bytes=-0"), 10, None),
541            RangeResolution::Unsatisfiable { total: 10 }
542        );
543    }
544
545    // ── If-Range ──────────────────────────────────────────────────────────────
546
547    #[test]
548    fn if_range_matching_etag_honours_range() {
549        let etag = ETag::strong("v1");
550        let mut h = headers_with_range("bytes=0-3");
551        h.insert(IF_RANGE, etag.header_value());
552        let validator = Validator::new().with_etag(&etag);
553        assert_eq!(
554            resolve(&h, 10, Some(validator)),
555            RangeResolution::Partial {
556                start: 0,
557                end: 3,
558                total: 10
559            }
560        );
561    }
562
563    #[test]
564    fn if_range_stale_etag_falls_back_to_full() {
565        let current = ETag::strong("v2");
566        let mut h = headers_with_range("bytes=0-3");
567        h.insert(IF_RANGE, HeaderValue::from_static("\"v1\""));
568        let validator = Validator::new().with_etag(&current);
569        assert_eq!(resolve(&h, 10, Some(validator)), RangeResolution::Full);
570    }
571
572    #[test]
573    fn if_range_weak_etag_never_matches() {
574        let weak = ETag::weak("v1");
575        let mut h = headers_with_range("bytes=0-3");
576        h.insert(IF_RANGE, HeaderValue::from_static("W/\"v1\""));
577        let validator = Validator::new().with_etag(&weak);
578        assert_eq!(resolve(&h, 10, Some(validator)), RangeResolution::Full);
579    }
580
581    #[test]
582    fn if_range_matching_last_modified_honours_range() {
583        let lm = "Wed, 21 Oct 2015 07:28:00 GMT";
584        let mut h = headers_with_range("bytes=0-3");
585        h.insert(
586            IF_RANGE,
587            HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"),
588        );
589        let validator = Validator::new().with_last_modified(lm);
590        assert_eq!(
591            resolve(&h, 10, Some(validator)),
592            RangeResolution::Partial {
593                start: 0,
594                end: 3,
595                total: 10
596            }
597        );
598    }
599
600    #[test]
601    fn if_range_stale_last_modified_falls_back_to_full() {
602        let lm = "Wed, 21 Oct 2015 07:28:00 GMT";
603        let mut h = headers_with_range("bytes=0-3");
604        h.insert(
605            IF_RANGE,
606            HeaderValue::from_static("Tue, 20 Oct 2015 00:00:00 GMT"),
607        );
608        let validator = Validator::new().with_last_modified(lm);
609        assert_eq!(resolve(&h, 10, Some(validator)), RangeResolution::Full);
610    }
611
612    #[test]
613    fn if_range_without_validator_falls_back_to_full() {
614        let mut h = headers_with_range("bytes=0-3");
615        h.insert(IF_RANGE, HeaderValue::from_static("\"v1\""));
616        assert_eq!(resolve(&h, 10, None), RangeResolution::Full);
617    }
618
619    // ── response builders ─────────────────────────────────────────────────────
620
621    #[tokio::test]
622    async fn partial_response_slices_body_and_sets_headers() {
623        use http_body_util::BodyExt as _;
624
625        let full = Bytes::from_static(b"0123456789");
626        let resolution = RangeResolution::Partial {
627            start: 2,
628            end: 5,
629            total: 10,
630        };
631        let resp = partial_bytes_response(&resolution, full);
632        assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
633        assert_eq!(resp.headers().get(CONTENT_RANGE).unwrap(), "bytes 2-5/10");
634        assert_eq!(resp.headers().get(CONTENT_LENGTH).unwrap(), "4");
635        assert_eq!(resp.headers().get(ACCEPT_RANGES).unwrap(), "bytes");
636        let body = resp.into_body().collect().await.unwrap().to_bytes();
637        assert_eq!(&body[..], b"2345");
638    }
639
640    #[tokio::test]
641    async fn full_response_sets_accept_ranges_and_length() {
642        let full = Bytes::from_static(b"0123456789");
643        let resp = partial_bytes_response(&RangeResolution::Full, full);
644        assert_eq!(resp.status(), StatusCode::OK);
645        assert_eq!(resp.headers().get(ACCEPT_RANGES).unwrap(), "bytes");
646        assert_eq!(resp.headers().get(CONTENT_LENGTH).unwrap(), "10");
647    }
648
649    #[tokio::test]
650    async fn unsatisfiable_response_is_416_with_star_content_range() {
651        let full = Bytes::from_static(b"0123456789");
652        let resp = partial_bytes_response(&RangeResolution::Unsatisfiable { total: 10 }, full);
653        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
654        assert_eq!(resp.headers().get(CONTENT_RANGE).unwrap(), "bytes */10");
655    }
656
657    #[test]
658    fn content_range_helpers_format_correctly() {
659        assert_eq!(content_range_value(0, 3, 10), "bytes 0-3/10");
660        assert_eq!(unsatisfied_content_range(10), "bytes */10");
661    }
662}