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::fs::File;
14use std::io::{Read, Seek, SeekFrom};
15use std::path::{Path, PathBuf};
16use std::time::SystemTime;
17
18use parse_net::{ByteRange, EntityTag, HttpDate, RangeOutcome};
19use serve_net::{HttpReply, HttpRequest, Method, Outcome};
20
21use crate::url;
22
23/// Files up to this size are read whole into the reply; larger ones are sent
24/// in parts.
25pub const INLINE_LIMIT: u64 = 1 << 20;
26
27/// The size of one part of a file sent in parts.
28const PART: usize = 64 << 10;
29
30/// A folder served under a mount path.
31///
32/// ```no_run
33/// use web_net::{App, Assets};
34///
35/// let app = App::builder()
36///     .assets(Assets::new("/static", "./public").cache_control("public, max-age=31536000, immutable"))
37///     .build()?;
38/// # Ok::<(), web_net::BuildError>(())
39/// ```
40#[derive(Clone, Debug)]
41pub struct Assets {
42    pub(crate) mount: String,
43    pub(crate) dir: PathBuf,
44    pub(crate) root: PathBuf,
45    pub(crate) cache_control: Box<str>,
46    pub(crate) index: Option<Box<str>>,
47    pub(crate) precompressed: bool,
48    pub(crate) hidden: bool,
49}
50
51impl Assets {
52    /// Serves `dir` under `mount` (`/static`). The folder must exist when the
53    /// application is built.
54    #[must_use]
55    pub fn new(mount: &str, dir: impl Into<PathBuf>) -> Self {
56        Self {
57            mount: mount.trim_end_matches('/').to_owned(),
58            dir: dir.into(),
59            root: PathBuf::new(),
60            cache_control: "no-cache".into(),
61            index: None,
62            precompressed: true,
63            hidden: false,
64        }
65    }
66
67    /// The `Cache-Control` value (default `no-cache`: the browser keeps the
68    /// file and revalidates it, which a matching `ETag` answers with 304).
69    /// For files whose names change with their content:
70    /// `public, max-age=31536000, immutable`.
71    #[must_use]
72    pub fn cache_control(mut self, value: &str) -> Self {
73        self.cache_control = value.into();
74        self
75    }
76
77    /// The file served for a folder (`index.html`). Without one, a folder
78    /// is a 404: there are never listings.
79    #[must_use]
80    pub fn index(mut self, name: &str) -> Self {
81        self.index = Some(name.into());
82        self
83    }
84
85    /// Whether `file.br` and `file.gz` next to a file are served to clients
86    /// that accept them (default on).
87    #[must_use]
88    pub fn precompressed(mut self, on: bool) -> Self {
89        self.precompressed = on;
90        self
91    }
92
93    /// Serve names that start with a dot (default off: `.env`, `.git`,
94    /// `.htaccess` are 404).
95    #[must_use]
96    pub fn hidden_files(mut self, on: bool) -> Self {
97        self.hidden = on;
98        self
99    }
100
101    /// The part of `path` under this mount: `/static/a.css` gives `/a.css`,
102    /// `/static` gives `` (the mount itself).
103    pub(crate) fn strip<'p>(&self, path: &'p str) -> Option<&'p str> {
104        let rest = path.strip_prefix(self.mount.as_str())?;
105        (rest.is_empty() || rest.starts_with('/')).then_some(rest)
106    }
107}
108
109/// Windows device names, refused everywhere so a site behaves the same on
110/// every system.
111fn is_device_name(seg: &str) -> bool {
112    let stem = seg.split('.').next().unwrap_or(seg).to_ascii_uppercase();
113    matches!(
114        stem.as_str(),
115        "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
116    ) || ((stem.starts_with("COM") || stem.starts_with("LPT"))
117        && stem.len() == 4
118        && stem.as_bytes()[3].is_ascii_digit())
119}
120
121/// Whether a decoded segment may name a file.
122fn safe_segment(seg: &str, hidden: bool) -> bool {
123    !seg.is_empty()
124        && (hidden || !seg.starts_with('.'))
125        && !seg.contains([':', '\\', '/', '\0'])
126        && !seg.ends_with(['.', ' '])
127        && !is_device_name(seg)
128}
129
130/// A file found for a request.
131pub(crate) struct Found {
132    path: PathBuf,
133    len: u64,
134    modified: Option<SystemTime>,
135    encoding: Option<&'static str>,
136    mime: &'static str,
137    vary: bool,
138}
139
140/// Resolves `rel` (the path under the mount, canonical) to a file inside the
141/// root, or `None` for 404.
142pub(crate) fn find(assets: &Assets, rel: &str, accept_encoding: Option<&[u8]>) -> Option<Found> {
143    let mut path = assets.root.clone();
144    for raw in rel.split('/').filter(|s| !s.is_empty()) {
145        let seg = url::decode(raw);
146        if !safe_segment(&seg, assets.hidden) {
147            return None;
148        }
149        path.push(seg.as_ref());
150    }
151    let mut meta = inside(&assets.root, &path)?;
152    if meta.1.is_dir() {
153        let index = assets.index.as_deref()?;
154        path = meta.0.join(index);
155        meta = inside(&assets.root, &path)?;
156    }
157    let (file, md) = meta;
158    if !md.is_file() {
159        return None;
160    }
161    let mime = mime_for(&file);
162    let mut found = Found {
163        len: md.len(),
164        modified: md.modified().ok(),
165        path: file,
166        encoding: None,
167        mime,
168        vary: false,
169    };
170    if assets.precompressed {
171        found.vary = true;
172        let offers = ["br", "gzip", "identity"];
173        // No Accept-Encoding: the plain file (RFC 9110 allows any coding,
174        // but clients that send none are rarely ready for one).
175        let pick = accept_encoding.and_then(|v| parse_net::best_encoding(Some(v), &offers));
176        let ext = match pick {
177            Some(0) => Some(("br", "br")),
178            Some(1) => Some(("gzip", "gz")),
179            _ => None,
180        };
181        if let Some((coding, ext)) = ext {
182            let mut alt = found.path.clone().into_os_string();
183            alt.push(".");
184            alt.push(ext);
185            if let Some((p, m)) = inside(&assets.root, Path::new(&alt)) {
186                if m.is_file() {
187                    found.path = p;
188                    found.len = m.len();
189                    found.modified = m.modified().ok();
190                    found.encoding = Some(coding);
191                }
192            }
193        }
194    }
195    Some(found)
196}
197
198/// Resolves `path` and checks it is still under `root` (both canonical).
199fn inside(root: &Path, path: &Path) -> Option<(PathBuf, std::fs::Metadata)> {
200    let real = std::fs::canonicalize(path).ok()?;
201    if !real.starts_with(root) {
202        return None;
203    }
204    let md = std::fs::metadata(&real).ok()?;
205    Some((real, md))
206}
207
208/// The entity tag: length and modification time, and the coding, so each
209/// representation has its own.
210fn etag(found: &Found) -> String {
211    let nanos = found
212        .modified
213        .and_then(|m| m.duration_since(SystemTime::UNIX_EPOCH).ok())
214        .map_or(0, |d| d.as_nanos());
215    let coding = match found.encoding {
216        Some("br") => "-br",
217        Some(_) => "-gz",
218        None => "",
219    };
220    format!("\"{:x}-{:x}{coding}\"", found.len, nanos)
221}
222
223/// Answers a request for a file.
224pub(crate) fn serve(
225    assets: &Assets,
226    found: &Found,
227    req: &HttpRequest<'_>,
228    reply: &mut HttpReply,
229) -> Outcome {
230    let tag = etag(found);
231    let opaque = &tag.as_bytes()[1..tag.len() - 1];
232    let current = EntityTag::strong(opaque).ok();
233    let last_modified = found
234        .modified
235        .and_then(|m| HttpDate::from_system_time(m).ok());
236
237    let head = |reply: &mut HttpReply, status: u16| {
238        reply
239            .status(status)
240            .header("etag", tag.as_bytes())
241            .header("cache-control", assets.cache_control.as_bytes())
242            .header("x-content-type-options", "nosniff");
243        if let Some(d) = last_modified {
244            reply.header("last-modified", d.format());
245        }
246        if found.vary {
247            reply.header("vary", "accept-encoding");
248        }
249    };
250
251    // Conditional requests (RFC 9110 section 13.2.2).
252    if let (Some(inm), Some(cur)) = (req.header("if-none-match"), current.as_ref()) {
253        if parse_net::if_none_match(inm, cur).unwrap_or(false) {
254            head(reply, 304);
255            return Outcome::Done;
256        }
257    } else if let (Some(ims), Some(lm)) = (req.header("if-modified-since"), last_modified) {
258        if let Ok(since) = parse_net::http_date(ims) {
259            if lm <= since {
260                head(reply, 304);
261                return Outcome::Done;
262            }
263        }
264    }
265
266    // One range, when it applies (multiple ranges get the whole file).
267    let mut range: Option<ByteRange> = None;
268    if req.method() == Method::Get {
269        if let Some(value) = req.header("range") {
270            let if_range_ok = match req.header("if-range") {
271                None => true,
272                Some(v) => parse_net::range::parse_if_range(v)
273                    .map(|ir| ir.matches(current.as_ref(), last_modified))
274                    .unwrap_or(false),
275            };
276            if if_range_ok {
277                let mut out = [ByteRange { start: 0, end: 0 }];
278                match parse_net::byte_ranges(value, found.len, &mut out) {
279                    Ok(RangeOutcome::Ranges(1)) => range = Some(out[0]),
280                    Ok(RangeOutcome::Unsatisfiable) => {
281                        head(reply, 416);
282                        reply.header("content-range", format!("bytes */{}", found.len));
283                        return Outcome::Done;
284                    }
285                    _ => {}
286                }
287            }
288        }
289    }
290
291    let (status, start, len) = match range {
292        Some(r) => (206, r.start, r.end - r.start + 1),
293        None => (200, 0, found.len),
294    };
295    head(reply, status);
296    reply
297        .header("content-type", found.mime)
298        .header("accept-ranges", "bytes");
299    if let Some(c) = found.encoding {
300        reply.header("content-encoding", c);
301    }
302    if let Some(r) = range {
303        reply.header(
304            "content-range",
305            format!("bytes {}-{}/{}", r.start, r.end, found.len),
306        );
307    }
308    if req.method() == Method::Head {
309        // The length is the body's: read nothing for a small file's HEAD
310        // beyond what the GET would send.
311        if len <= INLINE_LIMIT {
312            return match read_at(&found.path, start, len as usize) {
313                Some(bytes) => {
314                    reply.body(&bytes);
315                    Outcome::Done
316                }
317                None => fail(reply),
318            };
319        }
320        reply.stream();
321        return Outcome::Done;
322    }
323    if len <= INLINE_LIMIT {
324        return match read_at(&found.path, start, len as usize) {
325            Some(bytes) => {
326                reply.body(&bytes);
327                Outcome::Done
328            }
329            None => fail(reply),
330        };
331    }
332    // Sent in parts: serve-net calls again for each part, with the same
333    // request; `sent` says where the next part starts. The status and fields
334    // set above leave with the first part only.
335    let sent = reply.stream().sent();
336    send_part(found, start, len, sent, reply)
337}
338
339fn send_part(found: &Found, start: u64, len: u64, sent: u64, reply: &mut HttpReply) -> Outcome {
340    if sent >= len {
341        return Outcome::Done;
342    }
343    let want = (len - sent).min(PART as u64) as usize;
344    let Some(bytes) = read_at(&found.path, start + sent, want) else {
345        return Outcome::Close;
346    };
347    if bytes.len() != want {
348        // Shorter than its length now: the file changed under the reply.
349        return Outcome::Close;
350    }
351    reply.stream().send(&bytes);
352    if sent + want as u64 >= len {
353        Outcome::Done
354    } else {
355        Outcome::More
356    }
357}
358
359fn read_at(path: &Path, start: u64, len: usize) -> Option<Vec<u8>> {
360    let mut f = File::open(path).ok()?;
361    if start > 0 {
362        f.seek(SeekFrom::Start(start)).ok()?;
363    }
364    let mut buf = Vec::with_capacity(len);
365    f.take(len as u64).read_to_end(&mut buf).ok()?;
366    Some(buf)
367}
368
369fn fail(reply: &mut HttpReply) -> Outcome {
370    reply.text(500, "the file could not be read");
371    Outcome::Done
372}
373
374/// The media type for a file name, by extension. Text types carry UTF-8.
375#[must_use]
376pub fn mime_for(path: &Path) -> &'static str {
377    let ext = path
378        .extension()
379        .and_then(|e| e.to_str())
380        .map(str::to_ascii_lowercase)
381        .unwrap_or_default();
382    match ext.as_str() {
383        "html" | "htm" => "text/html; charset=utf-8",
384        "css" => "text/css; charset=utf-8",
385        "js" | "mjs" => "text/javascript; charset=utf-8",
386        "json" | "map" => "application/json",
387        "txt" => "text/plain; charset=utf-8",
388        "md" => "text/markdown; charset=utf-8",
389        "csv" => "text/csv; charset=utf-8",
390        "xml" => "application/xml",
391        "svg" => "image/svg+xml",
392        "png" => "image/png",
393        "jpg" | "jpeg" => "image/jpeg",
394        "gif" => "image/gif",
395        "webp" => "image/webp",
396        "avif" => "image/avif",
397        "ico" => "image/x-icon",
398        "bmp" => "image/bmp",
399        "woff" => "font/woff",
400        "woff2" => "font/woff2",
401        "ttf" => "font/ttf",
402        "otf" => "font/otf",
403        "pdf" => "application/pdf",
404        "wasm" => "application/wasm",
405        "zip" => "application/zip",
406        "gz" => "application/gzip",
407        "mp4" => "video/mp4",
408        "webm" => "video/webm",
409        "mp3" => "audio/mpeg",
410        "ogg" => "audio/ogg",
411        "wav" => "audio/wav",
412        "webmanifest" => "application/manifest+json",
413        "rss" => "application/rss+xml",
414        "atom" => "application/atom+xml",
415        _ => "application/octet-stream",
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn test_unsafe_segments() {
425        for bad in [
426            ".env",
427            ".git",
428            "a:b",
429            "file.txt::$DATA",
430            "c:",
431            "x.",
432            "x ",
433            "CON",
434            "con.txt",
435            "Nul",
436            "COM1",
437            "lpt9.log",
438            "a\\b",
439            "",
440        ] {
441            assert!(!safe_segment(bad, false), "{bad:?}");
442        }
443        for ok in [
444            "a.css",
445            "index.html",
446            "COM",
447            "COM10",
448            "console.js",
449            "a b.txt",
450        ] {
451            assert!(safe_segment(ok, false), "{ok:?}");
452        }
453        assert!(safe_segment(".well-known", true));
454    }
455
456    #[test]
457    fn test_mime() {
458        assert_eq!(mime_for(Path::new("a/B.CSS")), "text/css; charset=utf-8");
459        assert_eq!(mime_for(Path::new("x")), "application/octet-stream");
460        assert_eq!(mime_for(Path::new("x.woff2")), "font/woff2");
461    }
462
463    #[test]
464    fn test_strip() {
465        let a = Assets::new("/static/", ".");
466        assert_eq!(a.strip("/static/a.css"), Some("/a.css"));
467        assert_eq!(a.strip("/static"), Some(""));
468        assert_eq!(a.strip("/statics/a"), None);
469    }
470}