Skip to main content

bobview_server/
files.rs

1//! Serve files from the served directories without following symbolic
2//! links out of them (the default; see --follow-symlinks).
3//!
4//! Files are streamed, with `Last-Modified` and `ETag` for revalidation
5//! (`If-None-Match`, `If-Modified-Since`) and single byte ranges, as
6//! tower-http's `ServeDir` does.
7
8use std::fs::{File, Metadata};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use axum::body::Body;
14use axum::extract::Request;
15use axum::http::header::{
16    ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE,
17    IF_NONE_MATCH, IF_RANGE, LAST_MODIFIED, LOCATION, RANGE,
18};
19use axum::http::{HeaderMap, HeaderValue, Method, StatusCode};
20use axum::response::{IntoResponse, Response};
21
22/// The bytes read from a file at a time.
23const CHUNK: usize = 64 * 1024;
24
25/// Whether Windows would take `segment` for something other than the file
26/// it names: a device (`NUL`, `COM1.txt`), or a name it trims (`a.bob.`,
27/// `a.bob `) and so opens another file under.
28fn windows_unsafe(segment: &str) -> bool {
29    if segment.ends_with(['.', ' ']) {
30        return true;
31    }
32    let stem = segment
33        .split('.')
34        .next()
35        .unwrap_or("")
36        .trim_end_matches(' ');
37    let upper = stem.to_ascii_uppercase();
38    match upper.as_str() {
39        "CON" | "PRN" | "AUX" | "NUL" => true,
40        _ => match (upper.get(..3), upper.get(3..)) {
41            (Some("COM" | "LPT"), Some(port)) => {
42                let mut c = port.chars();
43                matches!(
44                    (c.next(), c.next()),
45                    (Some('1'..='9' | '\u{b9}' | '\u{b2}' | '\u{b3}'), None)
46                )
47            }
48            _ => false,
49        },
50    }
51}
52
53/// The directories files are served from, in order, each with its real
54/// path (symbolic links resolved).
55pub struct Roots(Vec<(PathBuf, Option<PathBuf>)>);
56
57impl Roots {
58    pub fn new(dirs: &[PathBuf]) -> Self {
59        Self(
60            dirs.iter()
61                .map(|d| (d.clone(), std::fs::canonicalize(d).ok()))
62                .collect(),
63        )
64    }
65
66    /// Open the file a request path names, from the first root that has it
67    /// (`index.html` for a directory), unless it is really outside that
68    /// root: a symbolic link may lead anywhere on the machine. The file is
69    /// checked after it is opened, and served from what was opened, so a
70    /// link swapped in meanwhile is caught too.
71    fn open(&self, path: &str) -> Found {
72        let decoded = percent_encoding::percent_decode_str(path).decode_utf8_lossy();
73        let mut rel = PathBuf::new();
74        for segment in decoded.split('/') {
75            match segment {
76                "" | "." => {}
77                ".." => return Found::Missing,
78                s if s.contains('\\') || s.contains('\0') => return Found::Missing,
79                // A drive (`C:`) or a file's stream (`a.bob:s`) on Windows.
80                s if cfg!(windows) && s.contains(':') => return Found::Missing,
81                s if cfg!(windows) && windows_unsafe(s) => return Found::Missing,
82                s => rel.push(s),
83            }
84        }
85        for (dir, real_dir) in &self.0 {
86            let inside = |real: &Path| real_dir.as_ref().is_some_and(|d| real.starts_with(d));
87            let mut file = dir.join(&rel);
88            match std::fs::metadata(&file) {
89                Ok(m) if m.is_dir() => {
90                    if !path.ends_with('/') {
91                        // Not redirected if it leads out, which would show
92                        // that the name is there.
93                        if !std::fs::canonicalize(&file).is_ok_and(|r| inside(&r)) {
94                            return Found::Refused;
95                        }
96                        // One path, not `//host/`, which a browser would
97                        // take for another server.
98                        return Found::Redirect(format!("/{}/", path.trim_start_matches('/')));
99                    }
100                    file.push("index.html");
101                }
102                Ok(m) if m.is_file() => {}
103                // A FIFO or device: opening one may block, or do something.
104                Ok(_) => return Found::Missing,
105                // Not here (or a dangling link): look in the next.
106                Err(_) => continue,
107            }
108            // Without blocking, should a FIFO be swapped in meanwhile; the
109            // check below refuses anything but a regular file.
110            let Ok(opened) = open_file(&file) else {
111                continue;
112            };
113            let checked = || -> Option<Metadata> {
114                let real = std::fs::canonicalize(&file).ok()?;
115                let then = opened.metadata().ok()?;
116                // The real path is inside, and is the file that was opened.
117                (inside(&real) && then.is_file() && is_opened(&real, &opened, &then)?)
118                    .then_some(then)
119            };
120            return match checked() {
121                Some(meta) => Found::File(opened, file, meta),
122                None => Found::Refused,
123            };
124        }
125        Found::Missing
126    }
127}
128
129/// Open `file` for reading, without blocking should it be a FIFO.
130#[cfg(unix)]
131fn open_file(file: &Path) -> std::io::Result<File> {
132    use std::os::unix::fs::OpenOptionsExt;
133    std::fs::OpenOptions::new()
134        .read(true)
135        .custom_flags(libc::O_NONBLOCK)
136        .open(file)
137}
138
139#[cfg(not(unix))]
140fn open_file(file: &Path) -> std::io::Result<File> {
141    File::open(file)
142}
143
144/// Whether the file at `real` is `opened` (whose metadata is `meta`).
145#[cfg(unix)]
146fn is_opened(real: &Path, _opened: &File, meta: &Metadata) -> Option<bool> {
147    use std::os::unix::fs::MetadataExt;
148    let now = std::fs::metadata(real).ok()?;
149    Some((now.dev(), now.ino()) == (meta.dev(), meta.ino()))
150}
151
152/// Whether the file at `real` is `opened`: the same volume and file index.
153#[cfg(windows)]
154fn is_opened(real: &Path, opened: &File, _meta: &Metadata) -> Option<bool> {
155    use std::os::windows::io::AsRawHandle;
156    use windows_sys::Win32::Storage::FileSystem::{
157        BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
158    };
159    let id = |f: &File| {
160        // SAFETY: an open file's handle, and a struct the call fills in.
161        let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
162        let ok = unsafe { GetFileInformationByHandle(f.as_raw_handle(), &mut info) };
163        let index = (info.nFileIndexHigh, info.nFileIndexLow);
164        (ok != 0).then_some((info.dwVolumeSerialNumber, index))
165    };
166    Some(id(&File::open(real).ok()?)? == id(opened)?)
167}
168
169/// What a request path names.
170enum Found {
171    File(File, PathBuf, Metadata),
172    /// A directory, named without its trailing `/`.
173    Redirect(String),
174    Missing,
175    /// Something outside the served directories.
176    Refused,
177}
178
179/// Serve a file from `roots`, refusing those reached through a symbolic
180/// link that leads out of them (see --follow-symlinks).
181pub async fn serve(roots: Arc<Roots>, request: Request) -> Response {
182    let head = match *request.method() {
183        Method::GET => false,
184        Method::HEAD => true,
185        _ => return StatusCode::METHOD_NOT_ALLOWED.into_response(),
186    };
187    let path = request.uri().path().to_string();
188    let found = tokio::task::spawn_blocking(move || roots.open(&path)).await;
189    match found {
190        Ok(Found::File(file, name, meta)) => respond(request.headers(), head, file, &name, &meta),
191        Ok(Found::Redirect(to)) => {
192            let to = match request.uri().query() {
193                Some(q) => format!("{to}?{q}"),
194                None => to,
195            };
196            match HeaderValue::from_str(&to) {
197                Ok(to) => (StatusCode::TEMPORARY_REDIRECT, [(LOCATION, to)]).into_response(),
198                Err(_) => StatusCode::NOT_FOUND.into_response(),
199            }
200        }
201        Ok(Found::Refused) => {
202            log::warn!(
203                "refused {}: a link out of the served directories (see --follow-symlinks)",
204                request.uri().path()
205            );
206            StatusCode::NOT_FOUND.into_response()
207        }
208        _ => StatusCode::NOT_FOUND.into_response(),
209    }
210}
211
212/// The validators of a file: its `ETag` and its modification time.
213struct Validators {
214    etag: String,
215    modified: Option<SystemTime>,
216}
217
218impl Validators {
219    fn of(meta: &Metadata) -> Self {
220        let modified = meta.modified().ok();
221        let since = modified.and_then(|m| m.duration_since(UNIX_EPOCH).ok());
222        let nanos = since.unwrap_or_default().as_nanos();
223        #[cfg(unix)]
224        let etag = {
225            use std::os::unix::fs::MetadataExt;
226            format!("\"{:x}-{:x}-{nanos:x}\"", meta.ino(), meta.len())
227        };
228        #[cfg(not(unix))]
229        let etag = format!("\"{:x}-{nanos:x}\"", meta.len());
230        Self { etag, modified }
231    }
232
233    fn last_modified(&self) -> Option<String> {
234        self.modified.map(httpdate::fmt_http_date)
235    }
236
237    /// Whether the client's copy is current: `If-None-Match` if given,
238    /// else `If-Modified-Since`, to the second.
239    fn not_modified(&self, headers: &HeaderMap) -> bool {
240        if let Some(tags) = headers.get(IF_NONE_MATCH) {
241            let Ok(tags) = tags.to_str() else {
242                return false;
243            };
244            // Compared weakly, as RFC 9110 has for If-None-Match.
245            return tags
246                .split(',')
247                .map(str::trim)
248                .any(|t| t == "*" || t.strip_prefix("W/").unwrap_or(t) == self.etag);
249        }
250        let since = headers
251            .get(IF_MODIFIED_SINCE)
252            .and_then(|v| v.to_str().ok())
253            .and_then(|v| httpdate::parse_http_date(v).ok());
254        match (since, self.modified) {
255            (Some(since), Some(modified)) => secs(modified) <= secs(since),
256            _ => false,
257        }
258    }
259
260    /// Whether `If-Range`, if given, names this version of the file.
261    fn same(&self, headers: &HeaderMap) -> bool {
262        match headers.get(IF_RANGE).map(HeaderValue::to_str) {
263            None => true,
264            Some(Ok(v)) if v.starts_with('"') => v == self.etag,
265            Some(Ok(v)) => self.last_modified().is_some_and(|lm| lm == v),
266            Some(Err(_)) => false,
267        }
268    }
269}
270
271fn secs(t: SystemTime) -> u64 {
272    t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
273}
274
275/// The part of a file of `len` bytes that a `Range` header asks for.
276#[derive(Debug, PartialEq)]
277enum Part {
278    Whole,
279    /// From the first byte to the last, inclusive.
280    Bytes(u64, u64),
281    Unsatisfiable,
282}
283
284/// Read a `Range` header. Only one range of bytes is served; anything
285/// else, several ranges or one that does not parse, gets the whole file.
286fn part(range: Option<&str>, len: u64) -> Part {
287    let Some(spec) = range.and_then(|r| r.trim().strip_prefix("bytes=")) else {
288        return Part::Whole;
289    };
290    let Some((first, last)) = spec.split_once('-') else {
291        return Part::Whole;
292    };
293    let (first, last) = (first.trim(), last.trim());
294    if spec.contains(',') {
295        return Part::Whole;
296    }
297    if first.is_empty() {
298        // The last `n` bytes.
299        return match last.parse::<u64>() {
300            Ok(0) => Part::Unsatisfiable,
301            Ok(_) if len == 0 => Part::Unsatisfiable,
302            Ok(n) => Part::Bytes(len.saturating_sub(n), len - 1),
303            Err(_) => Part::Whole,
304        };
305    }
306    let Ok(first) = first.parse::<u64>() else {
307        return Part::Whole;
308    };
309    let last = match last {
310        "" => u64::MAX,
311        l => match l.parse::<u64>() {
312            Ok(l) if l >= first => l,
313            _ => return Part::Whole,
314        },
315    };
316    if first >= len {
317        return Part::Unsatisfiable;
318    }
319    Part::Bytes(first, last.min(len - 1))
320}
321
322/// The response for an opened file: 304, 206, 416 or 200, streamed.
323fn respond(headers: &HeaderMap, head: bool, file: File, name: &Path, meta: &Metadata) -> Response {
324    let len = meta.len();
325    let valid = Validators::of(meta);
326    let mut out = HeaderMap::new();
327    let mime = mime_guess::from_path(name).first_or_octet_stream();
328    out.insert(
329        CONTENT_TYPE,
330        HeaderValue::from_str(mime.as_ref())
331            .unwrap_or(HeaderValue::from_static("application/octet-stream")),
332    );
333    out.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
334    if let Ok(etag) = HeaderValue::from_str(&valid.etag) {
335        out.insert(ETAG, etag);
336    }
337    if let Some(lm) = valid
338        .last_modified()
339        .and_then(|v| HeaderValue::from_str(&v).ok())
340    {
341        out.insert(LAST_MODIFIED, lm);
342    }
343    if valid.not_modified(headers) {
344        out.remove(CONTENT_TYPE);
345        return (StatusCode::NOT_MODIFIED, out).into_response();
346    }
347    let range = headers.get(RANGE).and_then(|r| r.to_str().ok());
348    let wanted = if valid.same(headers) {
349        part(range, len)
350    } else {
351        Part::Whole
352    };
353    let (status, first, count) = match wanted {
354        Part::Whole => (StatusCode::OK, 0, len),
355        Part::Bytes(first, last) => {
356            let range = format!("bytes {first}-{last}/{len}");
357            out.insert(CONTENT_RANGE, HeaderValue::from_str(&range).expect("ASCII"));
358            (StatusCode::PARTIAL_CONTENT, first, last - first + 1)
359        }
360        Part::Unsatisfiable => {
361            let range = format!("bytes */{len}");
362            out.insert(CONTENT_RANGE, HeaderValue::from_str(&range).expect("ASCII"));
363            return (StatusCode::RANGE_NOT_SATISFIABLE, out).into_response();
364        }
365    };
366    out.insert(CONTENT_LENGTH, HeaderValue::from(count));
367    if head {
368        return (status, out).into_response();
369    }
370    let body = match stream(file, first, count) {
371        Ok(body) => body,
372        Err(_) => return StatusCode::NOT_FOUND.into_response(),
373    };
374    (status, out, body).into_response()
375}
376
377/// `count` bytes of `file` from `first`, read a chunk at a time as the
378/// client takes them, so that memory does not grow with the file's size.
379fn stream(mut file: File, first: u64, count: u64) -> std::io::Result<Body> {
380    use std::io::{Seek, SeekFrom};
381    use tokio::io::AsyncReadExt;
382    if first > 0 {
383        file.seek(SeekFrom::Start(first))?;
384    }
385    let file = tokio::fs::File::from_std(file).take(count);
386    Ok(Body::from_stream(
387        tokio_util::io::ReaderStream::with_capacity(file, CHUNK),
388    ))
389}
390
391#[cfg(test)]
392mod tests {
393    use super::{Found, Part, Roots, part, windows_unsafe};
394    #[cfg(unix)]
395    use std::path::PathBuf;
396
397    #[test]
398    fn windows_names() {
399        for s in [
400            "CON",
401            "con",
402            "Nul",
403            "aux.txt",
404            "PRN.a.b",
405            "nul .txt",
406            "COM1",
407            "com9.bob",
408            "LPT5",
409            "COM\u{b9}",
410            "lpt\u{b3}.x",
411            "a.bob.",
412            "a.bob ",
413            "..bob.",
414            " ",
415        ] {
416            assert!(windows_unsafe(s), "{s:?}");
417        }
418        for s in [
419            "a.bob",
420            "CONSOLE",
421            "con1",
422            "COM0",
423            "COM10",
424            "LPT",
425            "nul_x",
426            "xCON",
427            ".nul",
428            "COM\u{b4}",
429        ] {
430            assert!(!windows_unsafe(s), "{s:?}");
431        }
432    }
433
434    #[test]
435    fn ranges() {
436        assert_eq!(part(None, 10), Part::Whole);
437        assert_eq!(part(Some("bytes=0-3"), 10), Part::Bytes(0, 3));
438        assert_eq!(part(Some("bytes=5-"), 10), Part::Bytes(5, 9));
439        assert_eq!(part(Some("bytes=5-100"), 10), Part::Bytes(5, 9));
440        assert_eq!(part(Some("bytes=-3"), 10), Part::Bytes(7, 9));
441        assert_eq!(part(Some("bytes=-30"), 10), Part::Bytes(0, 9));
442        assert_eq!(part(Some("bytes=10-"), 10), Part::Unsatisfiable);
443        assert_eq!(part(Some("bytes=-0"), 10), Part::Unsatisfiable);
444        assert_eq!(part(Some("bytes=-3"), 0), Part::Unsatisfiable);
445        // Served whole: several ranges, other units, nonsense.
446        assert_eq!(part(Some("bytes=0-1,4-5"), 10), Part::Whole);
447        assert_eq!(part(Some("items=0-1"), 10), Part::Whole);
448        assert_eq!(part(Some("bytes=3-1"), 10), Part::Whole);
449        assert_eq!(part(Some("bytes=x-"), 10), Part::Whole);
450    }
451
452    #[test]
453    fn drives_and_streams_are_missing() {
454        let base = std::env::temp_dir().join(format!("bobview-drives-{}", std::process::id()));
455        std::fs::create_dir_all(&base).unwrap();
456        std::fs::write(base.join("a.bob"), "a").unwrap();
457        let roots = Roots::new(std::slice::from_ref(&base));
458        assert!(matches!(roots.open("/a.bob"), Found::File(..)));
459        for path in [
460            "/a.bob:s",
461            "/a.bob::$DATA",
462            "/C:/Windows/win.ini",
463            "/%5C%5Chost/x",
464        ] {
465            assert!(matches!(roots.open(path), Found::Missing), "{path}");
466        }
467        std::fs::remove_dir_all(&base).unwrap();
468    }
469
470    #[cfg(unix)]
471    #[test]
472    fn links_out_of_the_root_are_refused() {
473        let base = std::env::temp_dir().join(format!("bobview-confine-{}", std::process::id()));
474        let opi = base.join("opi");
475        let web = base.join("web");
476        let outside = base.join("outside");
477        for d in [&opi, &web, &outside, &opi.join("sub")] {
478            std::fs::create_dir_all(d).unwrap();
479        }
480        std::fs::write(opi.join("a.bob"), "a").unwrap();
481        std::fs::write(opi.join("sub/b.bob"), "b").unwrap();
482        std::fs::write(web.join("index.html"), "w").unwrap();
483        std::fs::write(outside.join("secret"), "s").unwrap();
484        let link = |target: &PathBuf, name: &str| {
485            std::os::unix::fs::symlink(target, opi.join(name)).unwrap();
486        };
487        link(&outside.join("secret"), "secret.bob");
488        link(&outside, "out");
489        // Links within the root are fine.
490        link(&opi.join("sub"), "inside");
491
492        let roots = Roots::new(&[opi.clone(), web.clone()]);
493        let read = |path: &str| match roots.open(path) {
494            Found::File(mut f, ..) => {
495                let mut text = String::new();
496                std::io::Read::read_to_string(&mut f, &mut text).unwrap();
497                text
498            }
499            Found::Redirect(to) => format!("-> {to}"),
500            Found::Missing => "missing".into(),
501            Found::Refused => "refused".into(),
502        };
503        assert_eq!(read("/a.bob"), "a");
504        assert_eq!(read("/sub/b.bob"), "b");
505        assert_eq!(read("/inside/b.bob"), "b");
506        assert_eq!(read("/%73ub/b.bob"), "b");
507        assert_eq!(read("/index.html"), "w");
508        // The web build's index, as opi has none.
509        assert_eq!(read("/"), "w");
510        assert_eq!(read("/sub"), "-> /sub/");
511        assert_eq!(read("/missing.bob"), "missing");
512        assert_eq!(read("/../opi/a.bob"), "missing");
513        assert_eq!(read("/secret.bob"), "refused");
514        assert_eq!(read("/out/secret"), "refused");
515        assert_eq!(read("/%73ecret.bob"), "refused");
516        assert_eq!(read("/out%2Fsecret"), "refused");
517        // A FIFO, here or out of the root, is not opened: that would block.
518        let fifo = |p: &PathBuf| {
519            let ok = std::process::Command::new("mkfifo")
520                .arg(p)
521                .status()
522                .unwrap();
523            assert!(ok.success());
524        };
525        fifo(&opi.join("f.bob"));
526        fifo(&outside.join("f"));
527        link(&outside.join("f"), "outf.bob");
528        assert_eq!(read("/f.bob"), "missing");
529        assert_eq!(read("/outf.bob"), "missing");
530        // A directory out is refused before it would be redirected.
531        assert_eq!(read("/out"), "refused");
532        assert_eq!(read("//sub"), "-> /sub/");
533        std::fs::remove_dir_all(&base).unwrap();
534    }
535
536    #[tokio::test]
537    async fn files_are_revalidated_and_served_in_parts() {
538        use axum::http::header::*;
539        use axum::http::{HeaderMap, HeaderValue, StatusCode};
540        let dir = std::env::temp_dir().join(format!("bobview-files-{}", std::process::id()));
541        std::fs::create_dir_all(&dir).unwrap();
542        let path = dir.join("a.wasm");
543        std::fs::write(&path, "0123456789").unwrap();
544        let get = async |head: bool, pairs: &[(HeaderName, String)]| {
545            let mut headers = HeaderMap::new();
546            for (k, v) in pairs {
547                headers.insert(k, HeaderValue::from_str(v).unwrap());
548            }
549            let file = std::fs::File::open(&path).unwrap();
550            let meta = file.metadata().unwrap();
551            let r = super::respond(&headers, head, file, &path, &meta);
552            let (parts, body) = r.into_parts();
553            let body = axum::body::to_bytes(body, 1 << 20).await.unwrap();
554            let text = String::from_utf8(body.to_vec()).unwrap();
555            (parts.status, parts.headers, text)
556        };
557        let (status, h, body) = get(false, &[]).await;
558        assert_eq!((status, body.as_str()), (StatusCode::OK, "0123456789"));
559        assert_eq!(h[CONTENT_TYPE], "application/wasm");
560        assert_eq!(h[CONTENT_LENGTH], "10");
561        assert_eq!(h[ACCEPT_RANGES], "bytes");
562        let etag = h[ETAG].to_str().unwrap().to_string();
563        let modified = h[LAST_MODIFIED].to_str().unwrap().to_string();
564
565        let (status, _, body) = get(false, &[(IF_MODIFIED_SINCE, modified.clone())]).await;
566        assert_eq!((status, body.as_str()), (StatusCode::NOT_MODIFIED, ""));
567        let (status, _, _) = get(false, &[(IF_NONE_MATCH, etag.clone())]).await;
568        assert_eq!(status, StatusCode::NOT_MODIFIED);
569        let (status, _, _) = get(false, &[(IF_NONE_MATCH, format!("W/{etag}"))]).await;
570        assert_eq!(status, StatusCode::NOT_MODIFIED);
571        // A different tag wins over a date that would match.
572        let other = [
573            (IF_NONE_MATCH, "\"other\"".to_string()),
574            (IF_MODIFIED_SINCE, modified.clone()),
575        ];
576        assert_eq!(get(false, &other).await.0, StatusCode::OK);
577        let old = "Thu, 01 Jan 1970 00:00:00 GMT".to_string();
578        assert_eq!(
579            get(false, &[(IF_MODIFIED_SINCE, old)]).await.0,
580            StatusCode::OK
581        );
582
583        let (status, h, body) = get(false, &[(RANGE, "bytes=2-4".into())]).await;
584        assert_eq!(
585            (status, body.as_str()),
586            (StatusCode::PARTIAL_CONTENT, "234")
587        );
588        assert_eq!(h[CONTENT_RANGE], "bytes 2-4/10");
589        assert_eq!(h[CONTENT_LENGTH], "3");
590        let (_, _, body) = get(false, &[(RANGE, "bytes=-2".into())]).await;
591        assert_eq!(body, "89");
592        let (status, h, _) = get(false, &[(RANGE, "bytes=20-".into())]).await;
593        assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE);
594        assert_eq!(h[CONTENT_RANGE], "bytes */10");
595        // If-Range: the range only if the file is the one named.
596        let same = [(RANGE, "bytes=0-0".into()), (IF_RANGE, etag.clone())];
597        assert_eq!(get(false, &same).await.2, "0");
598        let same = [(RANGE, "bytes=0-0".into()), (IF_RANGE, modified.clone())];
599        assert_eq!(get(false, &same).await.2, "0");
600        let changed = [(RANGE, "bytes=0-0".into()), (IF_RANGE, "\"old\"".into())];
601        assert_eq!(get(false, &changed).await.2, "0123456789");
602
603        // HEAD: the headers, with no body read.
604        let (status, h, body) = get(true, &[]).await;
605        assert_eq!((status, body.as_str()), (StatusCode::OK, ""));
606        assert_eq!(h[CONTENT_LENGTH], "10");
607        std::fs::remove_dir_all(&dir).unwrap();
608    }
609}