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/// Calendar fields of a Unix timestamp: (year, month 1-12, day, hour, minute,
324/// second, weekday index with 0 = Thursday)
325fn civil(unix_secs: u64) -> (i64, i64, i64, u64, u64, u64, usize) {
326    let days = (unix_secs / 86_400) as i64;
327    let rem = unix_secs % 86_400;
328    // civil-from-days (Howard Hinnant)
329    let z = days + 719_468;
330    let era = z.div_euclid(146_097);
331    let doe = z.rem_euclid(146_097);
332    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
333    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
334    let mp = (5 * doy + 2) / 153;
335    let day = doy - (153 * mp + 2) / 5 + 1;
336    let month = if mp < 10 { mp + 3 } else { mp - 9 };
337    let year = yoe + era * 400 + i64::from(month <= 2);
338    (
339        year,
340        month,
341        day,
342        rem / 3600,
343        (rem % 3600) / 60,
344        rem % 60,
345        days.rem_euclid(7) as usize,
346    )
347}
348
349/// HTTP date (IMF-fixdate), e.g. `Sun, 06 Nov 1994 08:49:37 GMT`
350pub fn http_date(unix_secs: u64) -> String {
351    let (year, month, day, h, m, s, weekday) = civil(unix_secs);
352    format!(
353        "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
354        DAYS[weekday],
355        day,
356        MONTHS[(month - 1) as usize],
357        year,
358        h,
359        m,
360        s
361    )
362}
363
364/// ISO 8601 / RFC 3339 UTC timestamp, e.g. `1994-11-06T08:49:37Z`
365pub fn iso8601(unix_secs: u64) -> String {
366    let (year, month, day, h, m, s, _) = civil(unix_secs);
367    format!(
368        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
369        year, month, day, h, m, s
370    )
371}
372
373/// Parse an IMF-fixdate (the only format servers must generate)
374pub fn parse_http_date(s: &str) -> Option<u64> {
375    let mut parts = s.trim().split(' ');
376    let day_name = parts.next()?;
377    if !day_name.ends_with(',') {
378        return None;
379    }
380    let day: i64 = parts.next()?.parse().ok()?;
381    let month_name = parts.next()?;
382    let month = MONTHS.iter().position(|m| *m == month_name)? as i64 + 1;
383    let year: i64 = parts.next()?.parse().ok()?;
384    let mut hms = parts.next()?.split(':');
385    let (h, m, sec): (u64, u64, u64) = (
386        hms.next()?.parse().ok()?,
387        hms.next()?.parse().ok()?,
388        hms.next()?.parse().ok()?,
389    );
390    if parts.next()? != "GMT" || parts.next().is_some() || hms.next().is_some() {
391        return None;
392    }
393    if !(1..=31).contains(&day) || h > 23 || m > 59 || sec > 60 || !(1970..=9999).contains(&year) {
394        return None;
395    }
396    // days-from-civil (Howard Hinnant)
397    let y = year - i64::from(month <= 2);
398    let era = y.div_euclid(400);
399    let yoe = y.rem_euclid(400);
400    let mp = if month > 2 { month - 3 } else { month + 9 };
401    let doy = (153 * mp + 2) / 5 + day - 1;
402    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
403    let days = era * 146_097 + doe - 719_468;
404    u64::try_from(days)
405        .ok()
406        .map(|d| d * 86_400 + h * 3600 + m * 60 + sec)
407}
408
409/// Current time as an HTTP date
410pub fn http_date_now() -> String {
411    http_date(
412        SystemTime::now()
413            .duration_since(UNIX_EPOCH)
414            .map(|d| d.as_secs())
415            .unwrap_or(0),
416    )
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn http_dates_roundtrip_and_match_the_rfc_example() {
425        // RFC 9110 section 5.6.7
426        assert_eq!(http_date(784_111_777), "Sun, 06 Nov 1994 08:49:37 GMT");
427        assert_eq!(
428            parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT"),
429            Some(784_111_777)
430        );
431        assert_eq!(http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
432        assert_eq!(http_date(951_782_400), "Tue, 29 Feb 2000 00:00:00 GMT"); // leap day
433        for secs in [
434            0u64,
435            1,
436            86_399,
437            86_400,
438            951_782_400,
439            1_700_000_000,
440            4_102_444_799,
441            253_402_300_799,
442        ] {
443            assert_eq!(parse_http_date(&http_date(secs)), Some(secs), "{secs}");
444        }
445    }
446
447    #[test]
448    fn iso_timestamps() {
449        assert_eq!(iso8601(784_111_777), "1994-11-06T08:49:37Z");
450        assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
451        assert_eq!(iso8601(951_782_400), "2000-02-29T00:00:00Z");
452        assert_eq!(iso8601(253_402_300_799), "9999-12-31T23:59:59Z");
453    }
454
455    #[test]
456    fn bad_http_dates_are_rejected() {
457        for bad in [
458            "",
459            "yesterday",
460            "Sun, 06 Nov 1994 08:49:37",
461            "Sun, 06 Nov 1994 08:49:37 UTC",
462            "Sunday, 06-Nov-94 08:49:37 GMT",
463            "Sun, 32 Nov 1994 08:49:37 GMT",
464            "Sun, 06 Foo 1994 08:49:37 GMT",
465            "Sun, 06 Nov 1994 25:49:37 GMT",
466            "Sun, 06 Nov 1969 00:00:00 GMT",
467            "Sun, 06 Nov 1994 08:49:37 GMT extra",
468        ] {
469            assert_eq!(parse_http_date(bad), None, "{bad:?}");
470        }
471    }
472
473    fn range(h: &str, size: u64) -> Option<(u64, u64)> {
474        match parse_range(h, size) {
475            RangeOutcome::Partial(a, b) => Some((a, b)),
476            _ => None,
477        }
478    }
479
480    #[test]
481    fn ranges() {
482        assert_eq!(range("bytes=0-99", 1000), Some((0, 99)));
483        assert_eq!(range("bytes=900-", 1000), Some((900, 999)));
484        assert_eq!(range("bytes=-100", 1000), Some((900, 999)));
485        assert_eq!(range("bytes=-5000", 1000), Some((0, 999)));
486        assert_eq!(range("bytes=990-5000", 1000), Some((990, 999)));
487        assert_eq!(range("bytes=0-0", 1000), Some((0, 0)));
488        assert_eq!(range(" bytes= 5 - 9 ", 1000), Some((5, 9)));
489        // unsatisfiable
490        assert!(matches!(
491            parse_range("bytes=1000-", 1000),
492            RangeOutcome::Unsatisfiable
493        ));
494        assert!(matches!(
495            parse_range("bytes=2000-3000", 1000),
496            RangeOutcome::Unsatisfiable
497        ));
498        assert!(matches!(
499            parse_range("bytes=-0", 1000),
500            RangeOutcome::Unsatisfiable
501        ));
502        assert!(matches!(
503            parse_range("bytes=0-0", 0),
504            RangeOutcome::Unsatisfiable
505        ));
506        assert!(matches!(
507            parse_range("bytes=-5", 0),
508            RangeOutcome::Unsatisfiable
509        ));
510        // not a single valid range: serve everything
511        for ignored in [
512            "bytes=0-1,5-6",
513            "bytes=abc",
514            "bytes=5-2",
515            "items=0-1",
516            "0-1",
517            "bytes=",
518            "bytes=-",
519            "bytes=a-b",
520        ] {
521            assert!(
522                matches!(parse_range(ignored, 1000), RangeOutcome::Full),
523                "{ignored}"
524            );
525        }
526        // overflow must not panic
527        assert!(range("bytes=0-99999999999999999999", 10).is_none());
528        assert_eq!(range(&format!("bytes=0-{}", u64::MAX), 10), Some((0, 9)));
529    }
530
531    #[test]
532    fn attachment_header_has_ascii_fallback_and_utf8_name() {
533        assert_eq!(
534            content_disposition("report.pdf"),
535            "attachment; filename=\"report.pdf\"; filename*=UTF-8''report.pdf"
536        );
537        let h = content_disposition("Übersicht \"Q1\".pdf");
538        assert!(h.contains("filename=\"_bersicht _Q1_.pdf\""), "{h}");
539        assert!(
540            h.contains("filename*=UTF-8''%C3%9Cbersicht%20%22Q1%22.pdf"),
541            "{h}"
542        );
543        assert!(!content_disposition("a\r\nb").contains('\r'));
544    }
545
546    #[test]
547    fn content_types() {
548        let t = |p: &str| content_type_for(Path::new(p));
549        assert_eq!(t("a.HTML"), "text/html; charset=utf-8");
550        assert_eq!(t("photo.JPeG"), "image/jpeg");
551        assert_eq!(t("video.mp4"), "video/mp4");
552        assert_eq!(t("noext"), "application/octet-stream");
553        assert_eq!(t("archive.tar.gz"), "application/gzip");
554    }
555}