Skip to main content

ullun/
files.rs

1//! Serving files from disk
2//!
3//! [`serve_file`] answers a request with a file: `Content-Type` from the
4//! extension, `Last-Modified` and a weak `ETag`, conditional requests (`304`),
5//! byte ranges (`206`, `416`, `If-Range`) for resumable downloads and media
6//! seeking, and bodies streamed in chunks so large files never sit in memory.
7//! `HEAD` works through the router.
8//!
9//! ```no_run
10//! use ullun::files::{serve_file, FileOptions};
11//! use ullun::prelude::*;
12//!
13//! async fn report(req: Request) -> Result<Response> {
14//!     // path chosen by the server, never built from client input
15//!     serve_file(&req, "/var/data/report.pdf", &FileOptions::new().attachment("report.pdf")).await
16//! }
17//! ```
18//!
19//! The caller is responsible for the path: never join client-supplied names onto
20//! a directory without validating them (see `App::serve_static`, which does).
21
22use crate::error::{Error, Result};
23use crate::request::Request;
24use crate::response::Response;
25use std::path::Path;
26use std::time::{SystemTime, UNIX_EPOCH};
27use tokio::io::{AsyncReadExt, AsyncSeekExt};
28
29/// Bodies up to this size are read into memory in one go
30const BUFFER_LIMIT: u64 = 64 * 1024;
31const CHUNK: usize = 64 * 1024;
32
33/// Per-response settings
34#[derive(Debug, Clone, Default)]
35pub struct FileOptions {
36    cache_control: Option<String>,
37    content_type: Option<String>,
38    attachment: Option<String>,
39}
40
41impl FileOptions {
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// `Cache-Control` header value
47    pub fn cache_control(mut self, value: impl Into<String>) -> Self {
48        self.cache_control = Some(value.into());
49        self
50    }
51
52    /// Override the type guessed from the extension
53    pub fn content_type(mut self, value: impl Into<String>) -> Self {
54        self.content_type = Some(value.into());
55        self
56    }
57
58    /// Offer the file as a download under this name (`Content-Disposition: attachment`)
59    pub fn attachment(mut self, filename: impl Into<String>) -> Self {
60        self.attachment = Some(filename.into());
61        self
62    }
63}
64
65/// Serve `path`, honouring conditional and range headers of `req`
66///
67/// `404` if the path is not a regular file.
68pub async fn serve_file(
69    req: &Request,
70    path: impl AsRef<Path>,
71    options: &FileOptions,
72) -> Result<Response> {
73    let path = path.as_ref();
74    let mut file = tokio::fs::File::open(path)
75        .await
76        .map_err(|_| Error::not_found("File not found"))?;
77    let meta = file
78        .metadata()
79        .await
80        .map_err(|_| Error::not_found("File not found"))?;
81    if !meta.is_file() {
82        return Err(Error::not_found("File not found"));
83    }
84    let size = meta.len();
85    let mtime = meta
86        .modified()
87        .ok()
88        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
89        .map(|d| d.as_secs())
90        .unwrap_or(0);
91    let etag = format!("W/\"{:x}-{:x}\"", size, mtime);
92
93    let content_type = options
94        .content_type
95        .clone()
96        .unwrap_or_else(|| content_type_for(path).to_string());
97
98    let mut base = Response::ok()
99        .with_header("content-type", content_type)
100        .with_header("accept-ranges", "bytes")
101        .with_header("etag", etag.clone())
102        .with_header("last-modified", http_date(mtime));
103    if let Some(cc) = &options.cache_control {
104        base = base.with_header("cache-control", cc.clone());
105    }
106    if let Some(name) = &options.attachment {
107        base = base.with_header("content-disposition", content_disposition(name));
108    }
109
110    // conditional GET: If-None-Match takes precedence over If-Modified-Since
111    let header = |name: &str| req.headers.get(name).and_then(|v| v.to_str().ok());
112    let not_modified = match header("if-none-match") {
113        Some(inm) => crate::conditional::if_none_match_hits(inm, &etag),
114        None => header("if-modified-since")
115            .and_then(parse_http_date)
116            .is_some_and(|since| mtime <= since),
117    };
118    if not_modified {
119        return Ok(base.with_status(http::StatusCode::NOT_MODIFIED));
120    }
121
122    // byte range, unless If-Range says our copy differs from the client's
123    let range_applies = header("if-range").is_none_or(|ir| {
124        if ir.starts_with('"') || ir.starts_with("W/") {
125            // a strong comparison is required, and our tag is weak
126            false
127        } else {
128            parse_http_date(ir) == Some(mtime)
129        }
130    });
131    let range = match header("range").filter(|_| range_applies) {
132        Some(r) => parse_range(r, size),
133        None => RangeOutcome::Full,
134    };
135
136    let (start, len, partial) = match range {
137        RangeOutcome::Full => (0, size, false),
138        RangeOutcome::Partial(start, end) => (start, end - start + 1, true),
139        RangeOutcome::Unsatisfiable => {
140            return Ok(
141                Response::from_error(Error::custom(416, "Range not satisfiable"))
142                    .with_header("content-range", format!("bytes */{}", size))
143                    .with_header("accept-ranges", "bytes"),
144            );
145        }
146    };
147
148    let mut res = base;
149    if partial {
150        res = res
151            .with_status(http::StatusCode::PARTIAL_CONTENT)
152            .with_header(
153                "content-range",
154                format!("bytes {}-{}/{}", start, start + len - 1, size),
155            );
156    }
157
158    if req.method == http::Method::HEAD {
159        // headers only; into_head keeps this explicit length
160        return Ok(res.with_header("content-length", len.to_string()));
161    }
162
163    if len <= BUFFER_LIMIT {
164        let mut buf = vec![0u8; len as usize];
165        if start > 0 {
166            file.seek(std::io::SeekFrom::Start(start))
167                .await
168                .map_err(|_| Error::internal("Failed to read file"))?;
169        }
170        file.read_exact(&mut buf)
171            .await
172            .map_err(|_| Error::internal("Failed to read file"))?;
173        return Ok(res.with_body(buf));
174    }
175
176    let (tx, mut streamed) = Response::stream();
177    streamed.status = res.status;
178    streamed.headers = res.headers;
179    streamed
180        .headers
181        .insert(http::header::CONTENT_LENGTH, http::HeaderValue::from(len));
182    tokio::spawn(async move {
183        if start > 0 && file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
184            return;
185        }
186        let mut remaining = len;
187        let mut buf = vec![0u8; CHUNK];
188        while remaining > 0 {
189            let want = (remaining as usize).min(CHUNK);
190            match file.read(&mut buf[..want]).await {
191                // file shrank while sending: end the body early
192                Ok(0) | Err(_) => return,
193                Ok(n) => {
194                    remaining -= n as u64;
195                    if tx
196                        .send(bytes::Bytes::copy_from_slice(&buf[..n]))
197                        .await
198                        .is_err()
199                    {
200                        return; // client went away
201                    }
202                }
203            }
204        }
205    });
206    Ok(streamed)
207}
208
209enum RangeOutcome {
210    Full,
211    /// Inclusive byte positions
212    Partial(u64, u64),
213    Unsatisfiable,
214}
215
216/// Single `bytes=` range of a representation of `size` bytes. Anything that is
217/// not a single valid byte range is served in full, as RFC 9110 allows.
218fn parse_range(header: &str, size: u64) -> RangeOutcome {
219    let Some(spec) = header.trim().strip_prefix("bytes=") else {
220        return RangeOutcome::Full;
221    };
222    if spec.contains(',') {
223        return RangeOutcome::Full;
224    }
225    let Some((first, last)) = spec.trim().split_once('-') else {
226        return RangeOutcome::Full;
227    };
228    let (first, last) = (first.trim(), last.trim());
229    match (first.is_empty(), last.is_empty()) {
230        // suffix: the last N bytes
231        (true, false) => match last.parse::<u64>() {
232            Ok(0) => RangeOutcome::Unsatisfiable,
233            Ok(_) if size == 0 => RangeOutcome::Unsatisfiable,
234            Ok(n) => RangeOutcome::Partial(size.saturating_sub(n), size - 1),
235            Err(_) => RangeOutcome::Full,
236        },
237        (false, empty_last) => {
238            let Ok(start) = first.parse::<u64>() else {
239                return RangeOutcome::Full;
240            };
241            let end = if empty_last {
242                u64::MAX
243            } else {
244                match last.parse::<u64>() {
245                    Ok(e) if e >= start => e,
246                    _ => return RangeOutcome::Full, // last < first is invalid syntax
247                }
248            };
249            if start >= size {
250                RangeOutcome::Unsatisfiable
251            } else {
252                RangeOutcome::Partial(start, end.min(size - 1))
253            }
254        }
255        (true, true) => RangeOutcome::Full,
256    }
257}
258
259/// `attachment; filename="fallback.txt"; filename*=UTF-8''percent-encoded`
260fn content_disposition(name: &str) -> String {
261    let fallback: String = name
262        .chars()
263        .map(|c| {
264            if c.is_ascii() && !c.is_ascii_control() && c != '"' && c != '\\' {
265                c
266            } else {
267                '_'
268            }
269        })
270        .collect();
271    format!(
272        "attachment; filename=\"{}\"; filename*=UTF-8''{}",
273        fallback,
274        urlencoding::encode(name)
275    )
276}
277
278/// `Content-Type` for a path, by extension (`application/octet-stream` if unknown)
279pub fn content_type_for(path: &Path) -> &'static str {
280    let ext = path
281        .extension()
282        .and_then(|e| e.to_str())
283        .map(|e| e.to_ascii_lowercase());
284    match ext.as_deref() {
285        Some("html" | "htm") => "text/html; charset=utf-8",
286        Some("css") => "text/css; charset=utf-8",
287        Some("js" | "mjs") => "application/javascript; charset=utf-8",
288        Some("json" | "map") => "application/json",
289        Some("xml") => "application/xml",
290        Some("txt" | "log") => "text/plain; charset=utf-8",
291        Some("md") => "text/markdown; charset=utf-8",
292        Some("csv") => "text/csv; charset=utf-8",
293        Some("png") => "image/png",
294        Some("jpg" | "jpeg") => "image/jpeg",
295        Some("gif") => "image/gif",
296        Some("webp") => "image/webp",
297        Some("avif") => "image/avif",
298        Some("svg") => "image/svg+xml",
299        Some("ico") => "image/x-icon",
300        Some("woff") => "font/woff",
301        Some("woff2") => "font/woff2",
302        Some("ttf") => "font/ttf",
303        Some("otf") => "font/otf",
304        Some("pdf") => "application/pdf",
305        Some("zip") => "application/zip",
306        Some("gz") => "application/gzip",
307        Some("tar") => "application/x-tar",
308        Some("wasm") => "application/wasm",
309        Some("mp3") => "audio/mpeg",
310        Some("ogg" | "oga") => "audio/ogg",
311        Some("wav") => "audio/wav",
312        Some("mp4" | "m4v") => "video/mp4",
313        Some("webm") => "video/webm",
314        _ => "application/octet-stream",
315    }
316}
317
318const DAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
319const MONTHS: [&str; 12] = [
320    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
321];
322
323/// HTTP date (IMF-fixdate), e.g. `Sun, 06 Nov 1994 08:49:37 GMT`
324pub fn http_date(unix_secs: u64) -> String {
325    let days = (unix_secs / 86_400) as i64;
326    let rem = unix_secs % 86_400;
327    // civil-from-days (Howard Hinnant)
328    let z = days + 719_468;
329    let era = z.div_euclid(146_097);
330    let doe = z.rem_euclid(146_097);
331    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
332    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
333    let mp = (5 * doy + 2) / 153;
334    let day = doy - (153 * mp + 2) / 5 + 1;
335    let month = if mp < 10 { mp + 3 } else { mp - 9 };
336    let year = yoe + era * 400 + i64::from(month <= 2);
337    format!(
338        "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
339        DAYS[(days.rem_euclid(7)) as usize],
340        day,
341        MONTHS[(month - 1) as usize],
342        year,
343        rem / 3600,
344        (rem % 3600) / 60,
345        rem % 60
346    )
347}
348
349/// Parse an IMF-fixdate (the only format servers must generate)
350pub fn parse_http_date(s: &str) -> Option<u64> {
351    let mut parts = s.trim().split(' ');
352    let day_name = parts.next()?;
353    if !day_name.ends_with(',') {
354        return None;
355    }
356    let day: i64 = parts.next()?.parse().ok()?;
357    let month_name = parts.next()?;
358    let month = MONTHS.iter().position(|m| *m == month_name)? as i64 + 1;
359    let year: i64 = parts.next()?.parse().ok()?;
360    let mut hms = parts.next()?.split(':');
361    let (h, m, sec): (u64, u64, u64) = (
362        hms.next()?.parse().ok()?,
363        hms.next()?.parse().ok()?,
364        hms.next()?.parse().ok()?,
365    );
366    if parts.next()? != "GMT" || parts.next().is_some() || hms.next().is_some() {
367        return None;
368    }
369    if !(1..=31).contains(&day) || h > 23 || m > 59 || sec > 60 || !(1970..=9999).contains(&year) {
370        return None;
371    }
372    // days-from-civil (Howard Hinnant)
373    let y = year - i64::from(month <= 2);
374    let era = y.div_euclid(400);
375    let yoe = y.rem_euclid(400);
376    let mp = if month > 2 { month - 3 } else { month + 9 };
377    let doy = (153 * mp + 2) / 5 + day - 1;
378    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
379    let days = era * 146_097 + doe - 719_468;
380    u64::try_from(days)
381        .ok()
382        .map(|d| d * 86_400 + h * 3600 + m * 60 + sec)
383}
384
385/// Current time as an HTTP date
386pub fn http_date_now() -> String {
387    http_date(
388        SystemTime::now()
389            .duration_since(UNIX_EPOCH)
390            .map(|d| d.as_secs())
391            .unwrap_or(0),
392    )
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn http_dates_roundtrip_and_match_the_rfc_example() {
401        // RFC 9110 section 5.6.7
402        assert_eq!(http_date(784_111_777), "Sun, 06 Nov 1994 08:49:37 GMT");
403        assert_eq!(
404            parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT"),
405            Some(784_111_777)
406        );
407        assert_eq!(http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
408        assert_eq!(http_date(951_782_400), "Tue, 29 Feb 2000 00:00:00 GMT"); // leap day
409        for secs in [
410            0u64,
411            1,
412            86_399,
413            86_400,
414            951_782_400,
415            1_700_000_000,
416            4_102_444_799,
417            253_402_300_799,
418        ] {
419            assert_eq!(parse_http_date(&http_date(secs)), Some(secs), "{secs}");
420        }
421    }
422
423    #[test]
424    fn bad_http_dates_are_rejected() {
425        for bad in [
426            "",
427            "yesterday",
428            "Sun, 06 Nov 1994 08:49:37",
429            "Sun, 06 Nov 1994 08:49:37 UTC",
430            "Sunday, 06-Nov-94 08:49:37 GMT",
431            "Sun, 32 Nov 1994 08:49:37 GMT",
432            "Sun, 06 Foo 1994 08:49:37 GMT",
433            "Sun, 06 Nov 1994 25:49:37 GMT",
434            "Sun, 06 Nov 1969 00:00:00 GMT",
435            "Sun, 06 Nov 1994 08:49:37 GMT extra",
436        ] {
437            assert_eq!(parse_http_date(bad), None, "{bad:?}");
438        }
439    }
440
441    fn range(h: &str, size: u64) -> Option<(u64, u64)> {
442        match parse_range(h, size) {
443            RangeOutcome::Partial(a, b) => Some((a, b)),
444            _ => None,
445        }
446    }
447
448    #[test]
449    fn ranges() {
450        assert_eq!(range("bytes=0-99", 1000), Some((0, 99)));
451        assert_eq!(range("bytes=900-", 1000), Some((900, 999)));
452        assert_eq!(range("bytes=-100", 1000), Some((900, 999)));
453        assert_eq!(range("bytes=-5000", 1000), Some((0, 999)));
454        assert_eq!(range("bytes=990-5000", 1000), Some((990, 999)));
455        assert_eq!(range("bytes=0-0", 1000), Some((0, 0)));
456        assert_eq!(range(" bytes= 5 - 9 ", 1000), Some((5, 9)));
457        // unsatisfiable
458        assert!(matches!(
459            parse_range("bytes=1000-", 1000),
460            RangeOutcome::Unsatisfiable
461        ));
462        assert!(matches!(
463            parse_range("bytes=2000-3000", 1000),
464            RangeOutcome::Unsatisfiable
465        ));
466        assert!(matches!(
467            parse_range("bytes=-0", 1000),
468            RangeOutcome::Unsatisfiable
469        ));
470        assert!(matches!(
471            parse_range("bytes=0-0", 0),
472            RangeOutcome::Unsatisfiable
473        ));
474        assert!(matches!(
475            parse_range("bytes=-5", 0),
476            RangeOutcome::Unsatisfiable
477        ));
478        // not a single valid range: serve everything
479        for ignored in [
480            "bytes=0-1,5-6",
481            "bytes=abc",
482            "bytes=5-2",
483            "items=0-1",
484            "0-1",
485            "bytes=",
486            "bytes=-",
487            "bytes=a-b",
488        ] {
489            assert!(
490                matches!(parse_range(ignored, 1000), RangeOutcome::Full),
491                "{ignored}"
492            );
493        }
494        // overflow must not panic
495        assert!(range("bytes=0-99999999999999999999", 10).is_none());
496        assert_eq!(range(&format!("bytes=0-{}", u64::MAX), 10), Some((0, 9)));
497    }
498
499    #[test]
500    fn attachment_header_has_ascii_fallback_and_utf8_name() {
501        assert_eq!(
502            content_disposition("report.pdf"),
503            "attachment; filename=\"report.pdf\"; filename*=UTF-8''report.pdf"
504        );
505        let h = content_disposition("Übersicht \"Q1\".pdf");
506        assert!(h.contains("filename=\"_bersicht _Q1_.pdf\""), "{h}");
507        assert!(
508            h.contains("filename*=UTF-8''%C3%9Cbersicht%20%22Q1%22.pdf"),
509            "{h}"
510        );
511        assert!(!content_disposition("a\r\nb").contains('\r'));
512    }
513
514    #[test]
515    fn content_types() {
516        let t = |p: &str| content_type_for(Path::new(p));
517        assert_eq!(t("a.HTML"), "text/html; charset=utf-8");
518        assert_eq!(t("photo.JPeG"), "image/jpeg");
519        assert_eq!(t("video.mp4"), "video/mp4");
520        assert_eq!(t("noext"), "application/octet-stream");
521        assert_eq!(t("archive.tar.gz"), "application/gzip");
522    }
523}