Skip to main content

web_net/
assets.rs

1//! Static files: served before routing, from a folder, never outside it.
2//!
3//! A request under a mount (`/static/...`) is answered here, before the gate
4//! and the router run. The path is canonical already (no dot segments, no
5//! encoded slash), and every segment is checked again for what file systems
6//! read specially: a leading dot (hidden files such as `.env` or `.git`), a
7//! colon (Windows alternate data streams and drive letters), a trailing dot
8//! or space (stripped by Windows, so `secret.txt.` would open `secret.txt`),
9//! and Windows device names (`CON`, `NUL`, `COM1`, ...). The joined path is
10//! then resolved and must still be inside the folder, so a symbolic link
11//! cannot lead out of it.
12
13use std::cell::RefCell;
14use std::fs::File;
15use std::io::{Read, Seek, SeekFrom};
16use std::path::{Path, PathBuf};
17use std::time::SystemTime;
18
19use parse_net::{ByteRange, EntityTag, HttpDate, RangeOutcome};
20use serve_net::{HttpReply, HttpRequest, Method, Outcome};
21
22use crate::url;
23
24/// Files up to this size are read whole into the reply; larger ones are sent
25/// in parts.
26pub const INLINE_LIMIT: u64 = 1 << 20;
27
28/// The size of one part of a file sent in parts.
29const PART: usize = 64 << 10;
30
31/// How a reply in parts that cannot finish ends: serve-net answers a
32/// `Deferred` without `reply.defer()` with a refusal, and a refusal once
33/// the head has left closes the connection abortively, so the client sees
34/// a cut-short body and never a clean end with the wrong bytes.
35/// (`Outcome::Close` would end the chunked body cleanly.) serve-net is
36/// asked for an explicit abort (PLAN O-7).
37const ABORT: Outcome = Outcome::Deferred;
38
39/// A request whose large file is going out in parts: its connection and
40/// request number.
41pub(crate) type StreamKey = (u64, u32);
42
43/// The most replies in parts one thread follows at once; past it the oldest
44/// is forgotten (and its next part closes its connection).
45const MAX_IN_FLIGHT: usize = 256;
46
47thread_local! {
48    /// The entity tag of each large file going out in parts on this thread.
49    /// A reply in parts stays on the worker that owns its connection, so
50    /// every part of it comes back to the same thread. A later part whose
51    /// file no longer has that tag ends the connection, never the reply.
52    static IN_FLIGHT: RefCell<Vec<(StreamKey, String)>> = const { RefCell::new(Vec::new()) };
53}
54
55fn begin(key: StreamKey, tag: &str) {
56    IN_FLIGHT.with(|m| {
57        let mut m = m.borrow_mut();
58        m.retain(|(k, _)| *k != key);
59        if m.len() >= MAX_IN_FLIGHT {
60            m.remove(0);
61        }
62        m.push((key, tag.to_owned()));
63    });
64}
65
66fn end(key: StreamKey) {
67    IN_FLIGHT.with(|m| m.borrow_mut().retain(|(k, _)| *k != key));
68}
69
70/// The entity tag of the file this request is sending in parts, if a part
71/// of it has gone out already.
72pub(crate) fn continuing(key: StreamKey) -> Option<String> {
73    IN_FLIGHT.with(|m| {
74        m.borrow()
75            .iter()
76            .find(|(k, _)| *k == key)
77            .map(|(_, t)| t.clone())
78    })
79}
80
81/// The next part of a reply in parts. `found` is the file resolved again
82/// for this part, `None` when it is gone; a file that changed (another tag)
83/// or went away ends the connection.
84pub(crate) fn next_part(
85    key: StreamKey,
86    tag: &str,
87    found: Option<&Found>,
88    req: &HttpRequest<'_>,
89    reply: &mut HttpReply,
90) -> Outcome {
91    let Some(found) = found.filter(|f| etag(f) == tag) else {
92        end(key);
93        return ABORT;
94    };
95    let (start, len) = match req.header("range").map(|v| {
96        let mut out = [ByteRange { start: 0, end: 0 }];
97        (parse_net::byte_ranges(v, found.len, &mut out), out[0])
98    }) {
99        Some((Ok(RangeOutcome::Ranges(1)), r)) if if_range_holds(found, tag, req) => {
100            (r.start, r.end - r.start + 1)
101        }
102        _ => (0, found.len),
103    };
104    let sent = reply.stream().sent();
105    let outcome = send_part(found, start, len, sent, reply);
106    if outcome != Outcome::More {
107        end(key);
108    }
109    outcome
110}
111
112/// Whether the request's `If-Range` (if any) holds for the file: the same
113/// test the first part made.
114fn if_range_holds(found: &Found, tag: &str, req: &HttpRequest<'_>) -> bool {
115    let Some(v) = req.header("if-range") else {
116        return true;
117    };
118    let current = EntityTag::strong(&tag.as_bytes()[1..tag.len() - 1]).ok();
119    let modified = found
120        .modified
121        .and_then(|m| HttpDate::from_system_time(m).ok());
122    parse_net::range::parse_if_range(v)
123        .map(|ir| ir.matches(current.as_ref(), modified))
124        .unwrap_or(false)
125}
126
127/// A folder served under a mount path.
128///
129/// ```no_run
130/// use web_net::{App, Assets};
131///
132/// let app = App::builder()
133///     .assets(Assets::new("/static", "./public").cache_control("public, max-age=31536000, immutable"))
134///     .build()?;
135/// # Ok::<(), web_net::BuildError>(())
136/// ```
137#[derive(Clone, Debug)]
138pub struct Assets {
139    pub(crate) mount: String,
140    pub(crate) dir: PathBuf,
141    pub(crate) root: PathBuf,
142    pub(crate) cache_control: Box<str>,
143    pub(crate) index: Option<Box<str>>,
144    pub(crate) precompressed: bool,
145    pub(crate) hidden: bool,
146}
147
148impl Assets {
149    /// Serves `dir` under `mount` (`/static`). The folder must exist when the
150    /// application is built.
151    #[must_use]
152    pub fn new(mount: &str, dir: impl Into<PathBuf>) -> Self {
153        Self {
154            mount: mount.trim_end_matches('/').to_owned(),
155            dir: dir.into(),
156            root: PathBuf::new(),
157            cache_control: "no-cache".into(),
158            index: None,
159            precompressed: true,
160            hidden: false,
161        }
162    }
163
164    /// The `Cache-Control` value (default `no-cache`: the browser keeps the
165    /// file and revalidates it, which a matching `ETag` answers with 304).
166    /// For files whose names change with their content:
167    /// `public, max-age=31536000, immutable`.
168    #[must_use]
169    pub fn cache_control(mut self, value: &str) -> Self {
170        self.cache_control = value.into();
171        self
172    }
173
174    /// The file served for a folder (`index.html`). Without one, a folder
175    /// is a 404: there are never listings.
176    #[must_use]
177    pub fn index(mut self, name: &str) -> Self {
178        self.index = Some(name.into());
179        self
180    }
181
182    /// Whether `file.br` and `file.gz` next to a file are served to clients
183    /// that accept them (default on).
184    #[must_use]
185    pub fn precompressed(mut self, on: bool) -> Self {
186        self.precompressed = on;
187        self
188    }
189
190    /// Serve names that start with a dot (default off: `.env`, `.git`,
191    /// `.htaccess` are 404).
192    #[must_use]
193    pub fn hidden_files(mut self, on: bool) -> Self {
194        self.hidden = on;
195        self
196    }
197
198    /// The part of `path` under this mount: `/static/a.css` gives `/a.css`,
199    /// `/static` gives `` (the mount itself).
200    pub(crate) fn strip<'p>(&self, path: &'p str) -> Option<&'p str> {
201        let rest = path.strip_prefix(self.mount.as_str())?;
202        (rest.is_empty() || rest.starts_with('/')).then_some(rest)
203    }
204}
205
206/// Windows device names, refused everywhere so a site behaves the same on
207/// every system.
208fn is_device_name(seg: &str) -> bool {
209    // Windows ignores spaces before the extension (`CON .txt`) and takes
210    // superscript digits as port numbers (`COM¹`).
211    let stem = seg
212        .split('.')
213        .next()
214        .unwrap_or(seg)
215        .trim_end()
216        .to_ascii_uppercase();
217    if matches!(
218        stem.as_str(),
219        "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
220    ) {
221        return true;
222    }
223    let Some(port) = stem
224        .strip_prefix("COM")
225        .or_else(|| stem.strip_prefix("LPT"))
226    else {
227        return false;
228    };
229    let mut chars = port.chars();
230    matches!(
231        (chars.next(), chars.next()),
232        (Some('0'..='9' | '¹' | '²' | '³'), None)
233    )
234}
235
236/// Whether a decoded segment may name a file.
237fn safe_segment(seg: &str, hidden: bool) -> bool {
238    !seg.is_empty()
239        && (hidden || !seg.starts_with('.'))
240        && !seg.contains([':', '\\', '/', '\0'])
241        && !seg.ends_with(['.', ' '])
242        && !is_device_name(seg)
243}
244
245/// A file found for a request.
246pub(crate) struct Found {
247    path: PathBuf,
248    len: u64,
249    modified: Option<SystemTime>,
250    encoding: Option<&'static str>,
251    mime: &'static str,
252    vary: bool,
253}
254
255/// Resolves `rel` (the path under the mount, canonical) to a file inside the
256/// root, or `None` for 404.
257pub(crate) fn find(assets: &Assets, rel: &str, accept_encoding: Option<&[u8]>) -> Option<Found> {
258    let mut path = assets.root.clone();
259    for raw in rel.split('/').filter(|s| !s.is_empty()) {
260        let seg = url::decode(raw);
261        if !safe_segment(&seg, assets.hidden) {
262            return None;
263        }
264        path.push(seg.as_ref());
265    }
266    let mut meta = inside(&assets.root, &path, assets.hidden)?;
267    if meta.1.is_dir() {
268        let index = assets.index.as_deref()?;
269        path = meta.0.join(index);
270        meta = inside(&assets.root, &path, assets.hidden)?;
271    }
272    let (file, md) = meta;
273    if !md.is_file() {
274        return None;
275    }
276    let mime = mime_for(&file);
277    let mut found = Found {
278        len: md.len(),
279        modified: md.modified().ok(),
280        path: file,
281        encoding: None,
282        mime,
283        vary: false,
284    };
285    if assets.precompressed {
286        found.vary = true;
287        let offers = ["br", "gzip", "identity"];
288        // No Accept-Encoding: the plain file (RFC 9110 allows any coding,
289        // but clients that send none are rarely ready for one).
290        let pick = accept_encoding.and_then(|v| parse_net::best_encoding(Some(v), &offers));
291        let ext = match pick {
292            Some(0) => Some(("br", "br")),
293            Some(1) => Some(("gzip", "gz")),
294            _ => None,
295        };
296        if let Some((coding, ext)) = ext {
297            let mut alt = found.path.clone().into_os_string();
298            alt.push(".");
299            alt.push(ext);
300            if let Some((p, m)) = inside(&assets.root, Path::new(&alt), assets.hidden) {
301                if m.is_file() {
302                    found.path = p;
303                    found.len = m.len();
304                    found.modified = m.modified().ok();
305                    found.encoding = Some(coding);
306                }
307            }
308        }
309    }
310    Some(found)
311}
312
313/// Resolves `path` and checks it is still under `root` (both canonical).
314fn inside(root: &Path, path: &Path, hidden: bool) -> Option<(PathBuf, std::fs::Metadata)> {
315    let real = std::fs::canonicalize(path).ok()?;
316    let below = real.strip_prefix(root).ok()?;
317    // The resolved names are checked again: a request may name a file by
318    // another spelling the file system resolves (a Windows 8.3 short name
319    // such as `ENVSEC~1` for `.envsecret`, or a link inside the folder).
320    for part in below.components() {
321        let std::path::Component::Normal(name) = part else {
322            return None;
323        };
324        if !name.to_str().is_some_and(|n| safe_segment(n, hidden)) {
325            return None;
326        }
327    }
328    let md = std::fs::metadata(&real).ok()?;
329    Some((real, md))
330}
331
332/// The entity tag: length and modification time, and the coding, so each
333/// representation has its own.
334fn etag(found: &Found) -> String {
335    let nanos = found
336        .modified
337        .and_then(|m| m.duration_since(SystemTime::UNIX_EPOCH).ok())
338        .map_or(0, |d| d.as_nanos());
339    let coding = match found.encoding {
340        Some("br") => "-br",
341        Some(_) => "-gz",
342        None => "",
343    };
344    format!("\"{:x}-{:x}{coding}\"", found.len, nanos)
345}
346
347/// Answers a request for a file. `None` when the file could not be read
348/// (nothing was written; the caller answers 500).
349pub(crate) fn serve(
350    assets: &Assets,
351    found: &Found,
352    key: StreamKey,
353    req: &HttpRequest<'_>,
354    reply: &mut HttpReply,
355) -> Option<Outcome> {
356    let tag = etag(found);
357    let opaque = &tag.as_bytes()[1..tag.len() - 1];
358    let current = EntityTag::strong(opaque).ok();
359    let last_modified = found
360        .modified
361        .and_then(|m| HttpDate::from_system_time(m).ok());
362
363    let head = |reply: &mut HttpReply, status: u16| {
364        reply
365            .status(status)
366            .header("etag", tag.as_bytes())
367            .header("cache-control", assets.cache_control.as_bytes())
368            .header("x-content-type-options", "nosniff");
369        if let Some(d) = last_modified {
370            reply.header("last-modified", d.format());
371        }
372        if found.vary {
373            reply.header("vary", "accept-encoding");
374        }
375    };
376
377    // Conditional requests (RFC 9110 section 13.2.2).
378    if let (Some(inm), Some(cur)) = (req.header("if-none-match"), current.as_ref()) {
379        if parse_net::if_none_match(inm, cur).unwrap_or(false) {
380            head(reply, 304);
381            return Some(Outcome::Done);
382        }
383    } else if let (Some(ims), Some(lm)) = (req.header("if-modified-since"), last_modified) {
384        if let Ok(since) = parse_net::http_date(ims) {
385            if lm <= since {
386                head(reply, 304);
387                return Some(Outcome::Done);
388            }
389        }
390    }
391
392    // One range, when it applies (multiple ranges get the whole file).
393    let mut range: Option<ByteRange> = None;
394    if req.method() == Method::Get {
395        if let Some(value) = req.header("range") {
396            let if_range_ok = if_range_holds(found, &tag, req);
397            if if_range_ok {
398                let mut out = [ByteRange { start: 0, end: 0 }];
399                match parse_net::byte_ranges(value, found.len, &mut out) {
400                    Ok(RangeOutcome::Ranges(1)) => range = Some(out[0]),
401                    Ok(RangeOutcome::Unsatisfiable) => {
402                        head(reply, 416);
403                        reply.header("content-range", format!("bytes */{}", found.len));
404                        return Some(Outcome::Done);
405                    }
406                    _ => {}
407                }
408            }
409        }
410    }
411
412    let (status, start, len) = match range {
413        Some(r) => (206, r.start, r.end - r.start + 1),
414        None => (200, 0, found.len),
415    };
416    // Read (or at least open) the file before any field is written, so a
417    // file that cannot be read leaves an empty reply for the 500.
418    let inline = if len <= INLINE_LIMIT {
419        Some(read_at(&found.path, start, len as usize)?)
420    } else {
421        drop(File::open(&found.path).ok()?);
422        None
423    };
424    head(reply, status);
425    reply
426        .header("content-type", found.mime)
427        .header("accept-ranges", "bytes");
428    if let Some(c) = found.encoding {
429        reply.header("content-encoding", c);
430    }
431    if let Some(r) = range {
432        reply.header(
433            "content-range",
434            format!("bytes {}-{}/{}", r.start, r.end, found.len),
435        );
436    }
437    // A small file goes whole (for HEAD too: its length is the body's).
438    if let Some(bytes) = inline {
439        reply.body(&bytes);
440        return Some(Outcome::Done);
441    }
442    if req.method() == Method::Head {
443        reply.stream();
444        return Some(Outcome::Done);
445    }
446    // Sent in parts: serve-net calls again for each part, with the same
447    // request; `sent` says where the next part starts. The status and fields
448    // set above leave with the first part only.
449    begin(key, &tag);
450    let outcome = send_part(found, start, len, 0, reply);
451    if outcome != Outcome::More {
452        end(key);
453    }
454    Some(outcome)
455}
456
457fn send_part(found: &Found, start: u64, len: u64, sent: u64, reply: &mut HttpReply) -> Outcome {
458    if sent >= len {
459        return Outcome::Done;
460    }
461    let want = (len - sent).min(PART as u64) as usize;
462    let Some(bytes) = read_at(&found.path, start + sent, want) else {
463        return ABORT;
464    };
465    if bytes.len() != want {
466        // Shorter than its length now: the file changed under the reply.
467        return ABORT;
468    }
469    reply.stream().send(&bytes);
470    if sent + want as u64 >= len {
471        Outcome::Done
472    } else {
473        Outcome::More
474    }
475}
476
477fn read_at(path: &Path, start: u64, len: usize) -> Option<Vec<u8>> {
478    let mut f = File::open(path).ok()?;
479    if start > 0 {
480        f.seek(SeekFrom::Start(start)).ok()?;
481    }
482    let mut buf = Vec::with_capacity(len);
483    f.take(len as u64).read_to_end(&mut buf).ok()?;
484    Some(buf)
485}
486
487/// The media type for a file name, by extension. Text types carry UTF-8.
488#[must_use]
489pub fn mime_for(path: &Path) -> &'static str {
490    let ext = path
491        .extension()
492        .and_then(|e| e.to_str())
493        .map(str::to_ascii_lowercase)
494        .unwrap_or_default();
495    match ext.as_str() {
496        "html" | "htm" => "text/html; charset=utf-8",
497        "css" => "text/css; charset=utf-8",
498        "js" | "mjs" => "text/javascript; charset=utf-8",
499        "json" | "map" => "application/json",
500        "txt" => "text/plain; charset=utf-8",
501        "md" => "text/markdown; charset=utf-8",
502        "csv" => "text/csv; charset=utf-8",
503        "xml" => "application/xml",
504        "svg" => "image/svg+xml",
505        "png" => "image/png",
506        "jpg" | "jpeg" => "image/jpeg",
507        "gif" => "image/gif",
508        "webp" => "image/webp",
509        "avif" => "image/avif",
510        "ico" => "image/x-icon",
511        "bmp" => "image/bmp",
512        "woff" => "font/woff",
513        "woff2" => "font/woff2",
514        "ttf" => "font/ttf",
515        "otf" => "font/otf",
516        "pdf" => "application/pdf",
517        "wasm" => "application/wasm",
518        "zip" => "application/zip",
519        "gz" => "application/gzip",
520        "mp4" => "video/mp4",
521        "webm" => "video/webm",
522        "mp3" => "audio/mpeg",
523        "ogg" => "audio/ogg",
524        "wav" => "audio/wav",
525        "webmanifest" => "application/manifest+json",
526        "rss" => "application/rss+xml",
527        "atom" => "application/atom+xml",
528        _ => "application/octet-stream",
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    #[test]
537    fn test_unsafe_segments() {
538        for bad in [
539            ".env",
540            ".git",
541            "a:b",
542            "file.txt::$DATA",
543            "c:",
544            "x.",
545            "x ",
546            "CON",
547            "con.txt",
548            "Nul",
549            "COM1",
550            "lpt9.log",
551            "a\\b",
552            "",
553        ] {
554            assert!(!safe_segment(bad, false), "{bad:?}");
555        }
556        for ok in [
557            "a.css",
558            "index.html",
559            "COM",
560            "COM10",
561            "console.js",
562            "a b.txt",
563        ] {
564            assert!(safe_segment(ok, false), "{ok:?}");
565        }
566        assert!(safe_segment(".well-known", true));
567    }
568
569    #[test]
570    fn test_mime() {
571        assert_eq!(mime_for(Path::new("a/B.CSS")), "text/css; charset=utf-8");
572        assert_eq!(mime_for(Path::new("x")), "application/octet-stream");
573        assert_eq!(mime_for(Path::new("x.woff2")), "font/woff2");
574    }
575
576    #[test]
577    fn test_strip() {
578        let a = Assets::new("/static/", ".");
579        assert_eq!(a.strip("/static/a.css"), Some("/a.css"));
580        assert_eq!(a.strip("/static"), Some(""));
581        assert_eq!(a.strip("/statics/a"), None);
582    }
583}