Skip to main content

ssh_browser/origin/
range.rs

1//! Range requests, and the two directions in which they are declined.
2
3/// What to serve for a given `Range` header.
4#[derive(Debug, PartialEq, Eq)]
5pub enum Resolved {
6    /// Serve the whole representation. Either nothing was asked for, or the request
7    /// is one we are permitted to answer in full.
8    Whole,
9    /// Serve bytes `start..=end`, inclusive at both ends as HTTP counts them.
10    Part { start: u64, end: u64 },
11    /// The range names nothing inside the representation.
12    Unsatisfiable,
13}
14
15/// Resolve a `Range` header against a known size.
16///
17/// Declines in two directions, both deliberate and both permitted.
18///
19/// A multi-range request is answered whole. Honouring it means emitting
20/// `multipart/byteranges`, which is a lot of surface for something no browser needs in
21/// order to seek in a video or a PDF, and RFC 9110 allows answering with the whole
22/// representation instead.
23///
24/// An `If-Range` is never honoured. The spec permits `If-Range` only with a strong
25/// validator, and the only validator this daemon offers is weak, because SFTP reports
26/// mtime in whole seconds. The specified outcome of that condition evaluating false is
27/// the whole representation, not a `412`.
28pub fn resolve(header: &str, if_range: Option<&str>, size: u64) -> Resolved {
29    if if_range.is_some() {
30        return Resolved::Whole;
31    }
32
33    // An unrecognised unit is a request we are free to ignore: `Range` asks, it does
34    // not instruct.
35    let Some(spec) = header.trim().strip_prefix("bytes=") else {
36        return Resolved::Whole;
37    };
38    if spec.contains(',') {
39        return Resolved::Whole;
40    }
41    let Some((first, last)) = spec.split_once('-') else {
42        return Resolved::Whole;
43    };
44    let (first, last) = (first.trim(), last.trim());
45
46    // A zero-length representation satisfies no range at all, including `-0`.
47    if size == 0 {
48        return Resolved::Unsatisfiable;
49    }
50    let final_byte = size - 1;
51
52    match (first.is_empty(), last.is_empty()) {
53        // `-N`: the last N bytes. An N larger than the file means the whole file,
54        // which is what the spec asks for rather than an error.
55        (true, false) => {
56            let Ok(suffix) = last.parse::<u64>() else {
57                return Resolved::Whole;
58            };
59            if suffix == 0 {
60                return Resolved::Unsatisfiable;
61            }
62            Resolved::Part {
63                start: size.saturating_sub(suffix),
64                end: final_byte,
65            }
66        }
67        // `N-`: from N to the end.
68        (false, true) => {
69            let Ok(start) = first.parse::<u64>() else {
70                return Resolved::Whole;
71            };
72            if start > final_byte {
73                return Resolved::Unsatisfiable;
74            }
75            Resolved::Part {
76                start,
77                end: final_byte,
78            }
79        }
80        // `N-M`: both ends named. An M past the end is clamped, not refused.
81        (false, false) => {
82            let (Ok(start), Ok(end)) = (first.parse::<u64>(), last.parse::<u64>()) else {
83                return Resolved::Whole;
84            };
85            if start > end || start > final_byte {
86                return Resolved::Unsatisfiable;
87            }
88            Resolved::Part {
89                start,
90                end: end.min(final_byte),
91            }
92        }
93        // A bare `-` names nothing.
94        (true, true) => Resolved::Whole,
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn part(start: u64, end: u64) -> Resolved {
103        Resolved::Part { start, end }
104    }
105
106    #[test]
107    fn both_ends_given() {
108        assert_eq!(resolve("bytes=0-9", None, 100), part(0, 9));
109        assert_eq!(resolve("bytes=10-19", None, 100), part(10, 19));
110        // The last byte is size-1, not size.
111        assert_eq!(resolve("bytes=99-99", None, 100), part(99, 99));
112    }
113
114    #[test]
115    fn an_end_past_the_file_is_clamped_rather_than_refused() {
116        assert_eq!(resolve("bytes=0-1000", None, 100), part(0, 99));
117        assert_eq!(resolve("bytes=50-1000", None, 100), part(50, 99));
118    }
119
120    #[test]
121    fn an_open_ended_range_runs_to_the_last_byte() {
122        assert_eq!(resolve("bytes=90-", None, 100), part(90, 99));
123        assert_eq!(resolve("bytes=0-", None, 100), part(0, 99));
124    }
125
126    #[test]
127    fn a_suffix_range_counts_back_from_the_end() {
128        assert_eq!(resolve("bytes=-10", None, 100), part(90, 99));
129        // Asking for more than exists yields the whole file, per the spec.
130        assert_eq!(resolve("bytes=-500", None, 100), part(0, 99));
131    }
132
133    #[test]
134    fn ranges_outside_the_file_are_unsatisfiable() {
135        assert_eq!(resolve("bytes=100-", None, 100), Resolved::Unsatisfiable);
136        assert_eq!(resolve("bytes=100-200", None, 100), Resolved::Unsatisfiable);
137        // A backwards range is not a range.
138        assert_eq!(resolve("bytes=5-3", None, 100), Resolved::Unsatisfiable);
139        // `-0` asks for the last zero bytes, which no representation has.
140        assert_eq!(resolve("bytes=-0", None, 100), Resolved::Unsatisfiable);
141    }
142
143    #[test]
144    fn an_empty_file_satisfies_nothing() {
145        assert_eq!(resolve("bytes=0-0", None, 0), Resolved::Unsatisfiable);
146        assert_eq!(resolve("bytes=0-", None, 0), Resolved::Unsatisfiable);
147        assert_eq!(resolve("bytes=-1", None, 0), Resolved::Unsatisfiable);
148    }
149
150    /// Declining to do multipart is a choice, and it has to be the safe one:
151    /// answering whole is always correct, answering one part of several would be a
152    /// lie about what was sent.
153    #[test]
154    fn a_multi_range_request_is_answered_whole() {
155        assert_eq!(resolve("bytes=0-9,20-29", None, 100), Resolved::Whole);
156    }
157
158    #[test]
159    fn an_unknown_unit_or_malformed_spec_is_answered_whole() {
160        assert_eq!(resolve("items=0-9", None, 100), Resolved::Whole);
161        assert_eq!(resolve("bytes=abc-def", None, 100), Resolved::Whole);
162        assert_eq!(resolve("bytes=-", None, 100), Resolved::Whole);
163        assert_eq!(resolve("nonsense", None, 100), Resolved::Whole);
164        assert_eq!(resolve("", None, 100), Resolved::Whole);
165    }
166
167    /// The validator on offer is weak, so `If-Range` can never be honoured. The whole
168    /// representation is the specified answer, not a 412.
169    #[test]
170    fn if_range_is_never_honoured_because_the_validator_is_weak() {
171        assert_eq!(
172            resolve("bytes=0-9", Some("W/\"64-7\""), 100),
173            Resolved::Whole
174        );
175        // Even a strong-looking one: no strong validator was ever issued, so anything
176        // a client sends here is something we cannot have promised.
177        assert_eq!(resolve("bytes=0-9", Some("\"64-7\""), 100), Resolved::Whole);
178    }
179
180    #[test]
181    fn whitespace_around_the_spec_is_tolerated() {
182        assert_eq!(resolve("  bytes=0-9  ", None, 100), part(0, 9));
183        assert_eq!(resolve("bytes= 10 - 19 ", None, 100), part(10, 19));
184    }
185}