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::collections::HashMap;
15use std::fs::File;
16use std::io::{Read, Seek, SeekFrom};
17use std::path::{Path, PathBuf};
18use std::sync::{Arc, Mutex, MutexGuard, PoisonError, RwLock};
19use std::time::{Duration, Instant, SystemTime};
20
21use parse_net::{ByteRange, EntityTag, HttpDate, RangeOutcome};
22use serve_net::{HttpReply, HttpRequest, Method, Outcome};
23
24use crate::url;
25
26/// Files up to this size are read whole into the reply; larger ones are sent
27/// in parts.
28///
29/// ```
30/// // Files up to 1 MiB go out in one piece.
31/// assert_eq!(web_net::INLINE_LIMIT, 1024 * 1024);
32/// ```
33pub const INLINE_LIMIT: u64 = 1 << 20;
34
35/// The size of one part of a file sent in parts.
36const PART: usize = 256 << 10;
37
38/// How a reply in parts that cannot finish ends: serve-net answers a
39/// `Deferred` without `reply.defer()` with a refusal, and a refusal once
40/// the head has left closes the connection abortively, so the client sees
41/// a cut-short body and never a clean end with the wrong bytes.
42/// (`Outcome::Close` would end the chunked body cleanly.) serve-net is
43/// asked for an explicit abort (PLAN O-7).
44const ABORT: Outcome = Outcome::Deferred;
45
46/// A request whose large file is going out in parts: its connection and
47/// request number.
48pub(crate) type StreamKey = (u64, u32);
49
50/// The most replies in parts one application follows at once. Past it a
51/// new one is refused with 503 and `Retry-After`; a reply in progress is
52/// never forgotten (a forgotten one would restart mid-body).
53pub(crate) const MAX_STREAMS: usize = 16 * 1024;
54
55/// One large file going out in parts: the file resolved for the first
56/// part, what it was then, and the bytes of it the reply carries.
57pub(crate) struct Stream {
58    path: Arc<Path>,
59    len: u64,
60    modified: Option<SystemTime>,
61    start: u64,
62    total: u64,
63}
64
65/// The replies in parts of one application, by connection and request.
66/// Each record lives from the first part to the last, or until serve-net
67/// says the request is done (`HttpService::done`), so a download the client
68/// dropped frees its record too.
69#[derive(Default)]
70pub(crate) struct Streams(Mutex<HashMap<StreamKey, Stream>>);
71
72impl Streams {
73    fn lock(&self) -> MutexGuard<'_, HashMap<StreamKey, Stream>> {
74        self.0.lock().unwrap_or_else(PoisonError::into_inner)
75    }
76
77    /// The record of a reply whose first part went out, taken out for this
78    /// part (put back with [`keep`](Self::keep) when more follow).
79    pub(crate) fn take(&self, key: StreamKey) -> Option<Stream> {
80        self.lock().remove(&key)
81    }
82
83    /// Starts following a reply; `false` when the table is full.
84    fn start(&self, key: StreamKey, stream: Stream) -> bool {
85        let mut m = self.lock();
86        if m.len() >= MAX_STREAMS {
87            return false;
88        }
89        m.insert(key, stream);
90        true
91    }
92
93    fn keep(&self, key: StreamKey, stream: Stream) {
94        self.lock().insert(key, stream);
95    }
96
97    /// The request is done: whatever it held goes.
98    pub(crate) fn remove(&self, key: StreamKey) {
99        let mut m = self.lock();
100        if !m.is_empty() {
101            m.remove(&key);
102        }
103    }
104
105    #[cfg(test)]
106    pub(crate) fn len(&self) -> usize {
107        self.lock().len()
108    }
109}
110
111thread_local! {
112    /// The buffer a part is read into, reused between parts and requests.
113    static PART_BUF: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
114}
115
116/// The next part of a reply in parts. The file is opened again by its
117/// resolved path and must still be what it was for the first part (same
118/// length and modification time); if not, or if it cannot be read, the
119/// connection ends, never the reply.
120pub(crate) fn next_part(
121    streams: &Streams,
122    key: StreamKey,
123    stream: Stream,
124    reply: &mut HttpReply,
125) -> Outcome {
126    let sent = reply.stream().sent();
127    let outcome = send_part(&stream, sent, reply);
128    if outcome == Outcome::More {
129        streams.keep(key, stream);
130    }
131    outcome
132}
133
134/// Whether the request's `If-Range` (if any) holds for the file.
135fn if_range_holds(found: &Found<'_>, tag: &str, req: &HttpRequest<'_>) -> bool {
136    let Some(v) = req.header("if-range") else {
137        return true;
138    };
139    let current = EntityTag::strong(&tag.as_bytes()[1..tag.len() - 1]).ok();
140    let modified = found
141        .modified
142        .and_then(|m| HttpDate::from_system_time(m).ok());
143    parse_net::range::parse_if_range(v)
144        .map(|ir| ir.matches(current.as_ref(), modified))
145        .unwrap_or(false)
146}
147
148/// Resolved files of one mount, by request path and then encoding pick
149/// (`br`, `gzip`, none), kept for [`REVALIDATE`]: a hit costs one metadata
150/// call, not a resolution. Small files keep their bytes too, used while
151/// the file's length and modification time are unchanged.
152#[derive(Default)]
153pub(crate) struct Cache {
154    map: RwLock<HashMap<Box<str>, [Option<Cached>; 3]>>,
155    /// Bytes of file contents held, across the mount.
156    held: std::sync::atomic::AtomicUsize,
157}
158
159#[derive(Clone)]
160struct Cached {
161    path: Arc<Path>,
162    encoding: Option<&'static str>,
163    mime: &'static str,
164    vary: bool,
165    at: Instant,
166    content: Option<Content>,
167}
168
169/// A small file's bytes, and the length and time they were read at.
170#[derive(Clone)]
171struct Content {
172    len: u64,
173    modified: Option<SystemTime>,
174    bytes: Arc<[u8]>,
175}
176
177/// Files up to this size keep their bytes in the cache.
178const CONTENT_LIMIT: u64 = 64 << 10;
179
180/// The most file bytes one mount holds in its cache.
181const HELD_LIMIT: usize = 32 << 20;
182
183impl Cache {
184    fn read(&self) -> std::sync::RwLockReadGuard<'_, HashMap<Box<str>, [Option<Cached>; 3]>> {
185        self.map.read().unwrap_or_else(PoisonError::into_inner)
186    }
187
188    fn write(&self) -> std::sync::RwLockWriteGuard<'_, HashMap<Box<str>, [Option<Cached>; 3]>> {
189        self.map.write().unwrap_or_else(PoisonError::into_inner)
190    }
191
192    /// Keeps a small file's bytes beside its resolution.
193    fn keep_content(&self, rel: &str, pick: u8, content: Content) {
194        use std::sync::atomic::Ordering;
195        let size = content.bytes.len();
196        if self.held.load(Ordering::Relaxed) + size > HELD_LIMIT {
197            return;
198        }
199        let mut map = self.write();
200        if let Some(Some(c)) = map.get_mut(rel).map(|slots| &mut slots[pick as usize]) {
201            if c.content.is_none() {
202                self.held.fetch_add(size, Ordering::Relaxed);
203            }
204            c.content = Some(content);
205        }
206    }
207}
208
209/// How long a resolution is trusted before the path is resolved again (a
210/// new precompressed variant, a link changed inside the folder).
211const REVALIDATE: Duration = Duration::from_secs(2);
212
213/// The most resolutions one mount keeps; past it the cache starts over.
214const CACHE_LIMIT: usize = 4096;
215
216impl std::fmt::Debug for Cache {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        f.write_str("Cache")
219    }
220}
221
222/// A folder served under a mount path.
223///
224/// ```no_run
225/// use web_net::{App, Assets};
226///
227/// let app = App::builder()
228///     .assets(Assets::new("/static", "./public").cache_control("public, max-age=31536000, immutable"))
229///     .build()?;
230/// # Ok::<(), web_net::BuildError>(())
231/// ```
232#[derive(Clone, Debug)]
233pub struct Assets {
234    pub(crate) mount: String,
235    pub(crate) dir: PathBuf,
236    pub(crate) root: PathBuf,
237    pub(crate) cache_control: Box<str>,
238    pub(crate) index: Option<Box<str>>,
239    pub(crate) precompressed: bool,
240    pub(crate) hidden: bool,
241    pub(crate) cache: Arc<Cache>,
242}
243
244impl Assets {
245    /// Serves `dir` under `mount` (`/static`). The folder must exist when the
246    /// application is built.
247    ///
248    /// ```
249    /// use web_net::{App, Assets};
250    ///
251    /// let dir = std::env::temp_dir().join("web-net-doc-assets-new");
252    /// std::fs::create_dir_all(&dir)?;
253    /// std::fs::write(dir.join("site.css"), "body { margin: 0 }")?;
254    ///
255    /// let app = App::builder().assets(Assets::new("/static", &dir)).build()?;
256    /// let r = serve_net::testing::respond(&app, b"GET /static/site.css HTTP/1.1\r\nHost: localhost\r\n\r\n");
257    /// assert_eq!(r.header("content-type"), Some(&b"text/css; charset=utf-8"[..]));
258    /// assert_eq!(r.text(), "body { margin: 0 }");
259    /// # Ok::<(), Box<dyn std::error::Error>>(())
260    /// ```
261    #[must_use]
262    pub fn new(mount: &str, dir: impl Into<PathBuf>) -> Self {
263        Self {
264            mount: mount.trim_end_matches('/').to_owned(),
265            dir: dir.into(),
266            root: PathBuf::new(),
267            cache_control: "no-cache".into(),
268            index: None,
269            precompressed: true,
270            hidden: false,
271            cache: Arc::default(),
272        }
273    }
274
275    /// The `Cache-Control` value (default `no-cache`: the browser keeps the
276    /// file and revalidates it, which a matching `ETag` answers with 304).
277    /// For files whose names change with their content:
278    /// `public, max-age=31536000, immutable`.
279    ///
280    /// ```
281    /// use web_net::{App, Assets};
282    ///
283    /// let dir = std::env::temp_dir().join("web-net-doc-assets-cache");
284    /// std::fs::create_dir_all(&dir)?;
285    /// std::fs::write(dir.join("app.3f9a.js"), "let x = 1;")?;
286    ///
287    /// let app = App::builder()
288    ///     .assets(Assets::new("/static", &dir).cache_control("public, max-age=31536000, immutable"))
289    ///     .build()?;
290    /// let r = serve_net::testing::respond(&app, b"GET /static/app.3f9a.js HTTP/1.1\r\nHost: localhost\r\n\r\n");
291    /// assert_eq!(r.header("cache-control"), Some(&b"public, max-age=31536000, immutable"[..]));
292    /// # Ok::<(), Box<dyn std::error::Error>>(())
293    /// ```
294    #[must_use]
295    pub fn cache_control(mut self, value: &str) -> Self {
296        self.cache_control = value.into();
297        self
298    }
299
300    /// The file served for a folder (`index.html`). Without one, a folder
301    /// is a 404: there are never listings.
302    ///
303    /// ```
304    /// use web_net::{App, Assets};
305    ///
306    /// let dir = std::env::temp_dir().join("web-net-doc-assets-index");
307    /// std::fs::create_dir_all(dir.join("guide"))?;
308    /// std::fs::write(dir.join("guide").join("index.html"), "<h1>Guide</h1>")?;
309    ///
310    /// let app = App::builder().assets(Assets::new("/docs", &dir).index("index.html")).build()?;
311    /// let r = serve_net::testing::respond(&app, b"GET /docs/guide HTTP/1.1\r\nHost: localhost\r\n\r\n");
312    /// assert_eq!(r.text(), "<h1>Guide</h1>");
313    /// # Ok::<(), Box<dyn std::error::Error>>(())
314    /// ```
315    #[must_use]
316    pub fn index(mut self, name: &str) -> Self {
317        self.index = Some(name.into());
318        self
319    }
320
321    /// Whether `file.br` and `file.gz` next to a file are served to clients
322    /// that accept them (default on).
323    ///
324    /// ```
325    /// use web_net::{App, Assets};
326    ///
327    /// let dir = std::env::temp_dir().join("web-net-doc-assets-precompressed");
328    /// std::fs::create_dir_all(&dir)?;
329    /// std::fs::write(dir.join("app.js"), "let x = 1;")?;
330    /// std::fs::write(dir.join("app.js.gz"), b"not used")?;
331    ///
332    /// let app = App::builder().assets(Assets::new("/static", &dir).precompressed(false)).build()?;
333    /// let r = serve_net::testing::respond(&app, b"GET /static/app.js HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n");
334    /// assert_eq!((r.text(), r.header("content-encoding")), ("let x = 1;", None));
335    /// # Ok::<(), Box<dyn std::error::Error>>(())
336    /// ```
337    #[must_use]
338    pub fn precompressed(mut self, on: bool) -> Self {
339        self.precompressed = on;
340        self
341    }
342
343    /// Serve names that start with a dot (default off: `.env`, `.git`,
344    /// `.htaccess` are 404).
345    ///
346    /// ```
347    /// use web_net::{App, Assets};
348    ///
349    /// let dir = std::env::temp_dir().join("web-net-doc-assets-hidden");
350    /// std::fs::create_dir_all(&dir)?;
351    /// std::fs::write(dir.join(".about.txt"), "hidden")?;
352    ///
353    /// let shown = App::builder().assets(Assets::new("/static", &dir).hidden_files(true)).build()?;
354    /// let kept = App::builder().assets(Assets::new("/static", &dir)).build()?;
355    /// let req = b"GET /static/.about.txt HTTP/1.1\r\nHost: localhost\r\n\r\n";
356    /// assert_eq!(serve_net::testing::respond(&shown, req).status, 200);
357    /// assert_eq!(serve_net::testing::respond(&kept, req).status, 404);
358    /// # Ok::<(), Box<dyn std::error::Error>>(())
359    /// ```
360    #[must_use]
361    pub fn hidden_files(mut self, on: bool) -> Self {
362        self.hidden = on;
363        self
364    }
365
366    /// The part of `path` under this mount: `/static/a.css` gives `/a.css`,
367    /// `/static` gives `` (the mount itself).
368    pub(crate) fn strip<'p>(&self, path: &'p str) -> Option<&'p str> {
369        let rest = path.strip_prefix(self.mount.as_str())?;
370        (rest.is_empty() || rest.starts_with('/')).then_some(rest)
371    }
372}
373
374/// Windows device names, refused everywhere so a site behaves the same on
375/// every system.
376fn is_device_name(seg: &str) -> bool {
377    // Windows ignores spaces before the extension (`CON .txt`) and takes
378    // superscript digits as port numbers (`COM¹`).
379    let stem = seg
380        .split('.')
381        .next()
382        .unwrap_or(seg)
383        .trim_end()
384        .to_ascii_uppercase();
385    if matches!(
386        stem.as_str(),
387        "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
388    ) {
389        return true;
390    }
391    let Some(port) = stem
392        .strip_prefix("COM")
393        .or_else(|| stem.strip_prefix("LPT"))
394    else {
395        return false;
396    };
397    let mut chars = port.chars();
398    matches!(
399        (chars.next(), chars.next()),
400        (Some('0'..='9' | '¹' | '²' | '³'), None)
401    )
402}
403
404/// Whether a decoded segment may name a file.
405fn safe_segment(seg: &str, hidden: bool) -> bool {
406    !seg.is_empty()
407        && (hidden || !seg.starts_with('.'))
408        && !seg.contains([':', '\\', '/', '\0'])
409        && !seg.ends_with(['.', ' '])
410        && !is_device_name(seg)
411}
412
413/// A file found for a request.
414pub(crate) struct Found<'r> {
415    path: Arc<Path>,
416    len: u64,
417    modified: Option<SystemTime>,
418    encoding: Option<&'static str>,
419    mime: &'static str,
420    vary: bool,
421    /// The cached bytes, when they are still the file's.
422    bytes: Option<Arc<[u8]>>,
423    /// Where it sits in the mount's cache.
424    rel: &'r str,
425    pick: u8,
426}
427
428/// Resolves `rel` (the path under the mount, canonical) to a file inside the
429/// root, or `None` for 404. A resolution made in the last [`REVALIDATE`] is
430/// reused when the file it found is still a plain file.
431pub(crate) fn find<'r>(
432    assets: &Assets,
433    rel: &'r str,
434    accept_encoding: Option<&[u8]>,
435) -> Option<Found<'r>> {
436    let pick = if assets.precompressed {
437        let offers = ["br", "gzip", "identity"];
438        // No Accept-Encoding: the plain file (RFC 9110 allows any coding,
439        // but clients that send none are rarely ready for one).
440        match accept_encoding.and_then(|v| parse_net::best_encoding(Some(v), &offers)) {
441            Some(0) => 0,
442            Some(1) => 1,
443            _ => 2,
444        }
445    } else {
446        2
447    };
448    let hit = assets
449        .cache
450        .read()
451        .get(rel)
452        .and_then(|slots| slots[pick as usize].as_ref())
453        .filter(|c| c.at.elapsed() < REVALIDATE)
454        .cloned();
455    if let Some(c) = hit {
456        // Not followed: a file replaced by a link is resolved again.
457        if let Ok(md) = std::fs::symlink_metadata(&c.path) {
458            if md.is_file() {
459                let (len, modified) = (md.len(), md.modified().ok());
460                let bytes = c
461                    .content
462                    .filter(|k| k.len == len && k.modified == modified)
463                    .map(|k| k.bytes);
464                return Some(Found {
465                    path: c.path,
466                    len,
467                    modified,
468                    encoding: c.encoding,
469                    mime: c.mime,
470                    vary: c.vary,
471                    bytes,
472                    rel,
473                    pick,
474                });
475            }
476        }
477    }
478    let found = resolve(assets, rel, pick)?;
479    let mut cache = assets.cache.write();
480    if cache.len() >= CACHE_LIMIT {
481        cache.clear();
482        assets
483            .cache
484            .held
485            .store(0, std::sync::atomic::Ordering::Relaxed);
486    }
487    let slots = cache.entry(rel.into()).or_default();
488    if let Some(old) = slots[pick as usize].take() {
489        if let Some(k) = old.content {
490            assets
491                .cache
492                .held
493                .fetch_sub(k.bytes.len(), std::sync::atomic::Ordering::Relaxed);
494        }
495    }
496    slots[pick as usize] = Some(Cached {
497        path: Arc::clone(&found.path),
498        encoding: found.encoding,
499        mime: found.mime,
500        vary: found.vary,
501        at: Instant::now(),
502        content: None,
503    });
504    Some(found)
505}
506
507/// Resolves `rel` the whole way: every segment checked, the path made
508/// canonical and checked to be inside the root, the variant for `pick`
509/// (0 `br`, 1 `gzip`, 2 none) looked for.
510fn resolve<'r>(assets: &Assets, rel: &'r str, pick: u8) -> Option<Found<'r>> {
511    let mut path = assets.root.clone();
512    for raw in rel.split('/').filter(|s| !s.is_empty()) {
513        let seg = url::decode(raw);
514        if !safe_segment(&seg, assets.hidden) {
515            return None;
516        }
517        path.push(seg.as_ref());
518    }
519    let mut meta = inside(&assets.root, &path, assets.hidden)?;
520    if meta.1.is_dir() {
521        let index = assets.index.as_deref()?;
522        path = meta.0.join(index);
523        meta = inside(&assets.root, &path, assets.hidden)?;
524    }
525    let (file, md) = meta;
526    if !md.is_file() {
527        return None;
528    }
529    let mime = mime_for(&file);
530    let mut found = Found {
531        len: md.len(),
532        modified: md.modified().ok(),
533        path: Arc::from(file),
534        encoding: None,
535        mime,
536        vary: assets.precompressed,
537        bytes: None,
538        rel,
539        pick,
540    };
541    let ext = match pick {
542        0 => Some(("br", "br")),
543        1 => Some(("gzip", "gz")),
544        _ => None,
545    };
546    if let Some((coding, ext)) = ext {
547        let mut alt = found.path.as_os_str().to_owned();
548        alt.push(".");
549        alt.push(ext);
550        if let Some((p, m)) = inside(&assets.root, Path::new(&alt), assets.hidden) {
551            if m.is_file() {
552                found.path = Arc::from(p);
553                found.len = m.len();
554                found.modified = m.modified().ok();
555                found.encoding = Some(coding);
556            }
557        }
558    }
559    Some(found)
560}
561
562/// Resolves `path` and checks it is still under `root` (both canonical).
563fn inside(root: &Path, path: &Path, hidden: bool) -> Option<(PathBuf, std::fs::Metadata)> {
564    let real = std::fs::canonicalize(path).ok()?;
565    let below = real.strip_prefix(root).ok()?;
566    // The resolved names are checked again: a request may name a file by
567    // another spelling the file system resolves (a Windows 8.3 short name
568    // such as `ENVSEC~1` for `.envsecret`, or a link inside the folder).
569    for part in below.components() {
570        let std::path::Component::Normal(name) = part else {
571            return None;
572        };
573        if !name.to_str().is_some_and(|n| safe_segment(n, hidden)) {
574            return None;
575        }
576    }
577    let md = std::fs::metadata(&real).ok()?;
578    Some((real, md))
579}
580
581/// The entity tag: length and modification time, and the coding, so each
582/// representation has its own.
583fn etag(found: &Found<'_>) -> String {
584    let nanos = found
585        .modified
586        .and_then(|m| m.duration_since(SystemTime::UNIX_EPOCH).ok())
587        .map_or(0, |d| d.as_nanos());
588    let coding = match found.encoding {
589        Some("br") => "-br",
590        Some(_) => "-gz",
591        None => "",
592    };
593    format!("\"{:x}-{:x}{coding}\"", found.len, nanos)
594}
595
596/// Why a file could not be answered (nothing was written).
597pub(crate) enum Unserved {
598    /// It could not be read: 500.
599    Unreadable,
600    /// The application follows [`MAX_STREAMS`] large replies already: 503.
601    Busy,
602}
603
604/// Answers a request for a file.
605pub(crate) fn serve(
606    assets: &Assets,
607    found: &Found<'_>,
608    streams: &Streams,
609    key: StreamKey,
610    req: &HttpRequest<'_>,
611    reply: &mut HttpReply,
612) -> Result<Outcome, Unserved> {
613    let tag = etag(found);
614    let opaque = &tag.as_bytes()[1..tag.len() - 1];
615    let current = EntityTag::strong(opaque).ok();
616    let last_modified = found
617        .modified
618        .and_then(|m| HttpDate::from_system_time(m).ok());
619
620    let head = |reply: &mut HttpReply, status: u16| {
621        reply
622            .status(status)
623            .header("etag", tag.as_bytes())
624            .header("cache-control", assets.cache_control.as_bytes())
625            .header("x-content-type-options", "nosniff");
626        if let Some(d) = last_modified {
627            reply.header("last-modified", d.format());
628        }
629        if found.vary {
630            reply.header("vary", "accept-encoding");
631        }
632    };
633
634    // Conditional requests (RFC 9110 section 13.2.2).
635    if let (Some(inm), Some(cur)) = (req.header("if-none-match"), current.as_ref()) {
636        if parse_net::if_none_match(inm, cur).unwrap_or(false) {
637            head(reply, 304);
638            return Ok(Outcome::Done);
639        }
640    } else if let (Some(ims), Some(lm)) = (req.header("if-modified-since"), last_modified) {
641        if let Ok(since) = parse_net::http_date(ims) {
642            if lm <= since {
643                head(reply, 304);
644                return Ok(Outcome::Done);
645            }
646        }
647    }
648
649    // One range, when it applies (multiple ranges get the whole file).
650    let mut range: Option<ByteRange> = None;
651    if req.method() == Method::Get {
652        if let Some(value) = req.header("range") {
653            if if_range_holds(found, &tag, req) {
654                let mut out = [ByteRange { start: 0, end: 0 }];
655                match parse_net::byte_ranges(value, found.len, &mut out) {
656                    Ok(RangeOutcome::Ranges(1)) => range = Some(out[0]),
657                    Ok(RangeOutcome::Unsatisfiable) => {
658                        head(reply, 416);
659                        reply.header("content-range", format!("bytes */{}", found.len));
660                        return Ok(Outcome::Done);
661                    }
662                    _ => {}
663                }
664            }
665        }
666    }
667
668    let (status, start, len) = match range {
669        Some(r) => (206, r.start, r.end - r.start + 1),
670        None => (200, 0, found.len),
671    };
672    let stream = Stream {
673        path: Arc::clone(&found.path),
674        len: found.len,
675        modified: found.modified,
676        start,
677        total: len,
678    };
679    let fields = |reply: &mut HttpReply| {
680        head(reply, status);
681        reply
682            .header("content-type", found.mime)
683            .header("accept-ranges", "bytes");
684        if let Some(c) = found.encoding {
685            reply.header("content-encoding", c);
686        }
687        if let Some(r) = range {
688            reply.header(
689                "content-range",
690                format!("bytes {}-{}/{}", r.start, r.end, found.len),
691            );
692        }
693    };
694    // A small file goes whole (for HEAD too: its length is the body's). It
695    // is read before any field is written, so a file that cannot be read
696    // leaves an empty reply for the 500.
697    if let Some(bytes) = &found.bytes {
698        // Cached and unchanged: no file is opened.
699        let from = start as usize;
700        fields(reply);
701        reply.body(&bytes[from..from + len as usize]);
702        return Ok(Outcome::Done);
703    }
704    if len <= INLINE_LIMIT {
705        return PART_BUF.with(|buf| {
706            let mut buf = buf.borrow_mut();
707            read_into(&stream, start, len as usize, &mut buf).ok_or(Unserved::Unreadable)?;
708            fields(reply);
709            reply.body(&buf);
710            if start == 0 && len == found.len && len <= CONTENT_LIMIT {
711                assets.cache.keep_content(
712                    found.rel,
713                    found.pick,
714                    Content {
715                        len: found.len,
716                        modified: found.modified,
717                        bytes: Arc::from(&buf[..]),
718                    },
719                );
720            }
721            shrink(&mut buf);
722            Ok(Outcome::Done)
723        });
724    }
725    // Large: the file must open and still be what was found.
726    open_same(&stream).ok_or(Unserved::Unreadable)?;
727    if req.method() == Method::Head {
728        fields(reply);
729        reply.stream();
730        return Ok(Outcome::Done);
731    }
732    // Sent in parts: serve-net calls again for each part, with the same
733    // request; `sent` says where the next part starts. The status and fields
734    // set above leave with the first part only. The record is made before
735    // anything is written, so a full table is a clean 503.
736    if !streams.start(key, stream) {
737        return Err(Unserved::Busy);
738    }
739    fields(reply);
740    let Some(stream) = streams.take(key) else {
741        return Ok(ABORT);
742    };
743    Ok(next_part(streams, key, stream, reply))
744}
745
746fn send_part(stream: &Stream, sent: u64, reply: &mut HttpReply) -> Outcome {
747    if sent >= stream.total {
748        return Outcome::Done;
749    }
750    let want = (stream.total - sent).min(PART as u64) as usize;
751    PART_BUF.with(|buf| {
752        let mut buf = buf.borrow_mut();
753        if read_into(stream, stream.start + sent, want, &mut buf).is_none() {
754            return ABORT;
755        }
756        reply.stream().send(&buf);
757        if sent + want as u64 >= stream.total {
758            Outcome::Done
759        } else {
760            Outcome::More
761        }
762    })
763}
764
765/// Opens the file and checks it is still the one found: the same length and
766/// modification time.
767fn open_same(stream: &Stream) -> Option<File> {
768    let f = File::open(&stream.path).ok()?;
769    let md = f.metadata().ok()?;
770    (md.is_file() && md.len() == stream.len && md.modified().ok() == stream.modified).then_some(f)
771}
772
773/// Reads exactly `len` bytes at `start` into `buf`, from the file as it was
774/// found; `None` when it changed or cannot be read.
775fn read_into(stream: &Stream, start: u64, len: usize, buf: &mut Vec<u8>) -> Option<()> {
776    let mut f = open_same(stream)?;
777    if start > 0 {
778        f.seek(SeekFrom::Start(start)).ok()?;
779    }
780    buf.clear();
781    buf.reserve(len);
782    f.take(len as u64).read_to_end(buf).ok()?;
783    (buf.len() == len).then_some(())
784}
785
786/// Buffers bigger than one part are not kept between requests.
787fn shrink(buf: &mut Vec<u8>) {
788    if buf.capacity() > PART {
789        *buf = Vec::new();
790    }
791}
792
793/// The media type for a file name, by extension. Text types carry UTF-8.
794///
795/// ```
796/// use std::path::Path;
797/// use web_net::mime_for;
798///
799/// assert_eq!(mime_for(Path::new("site.css")), "text/css; charset=utf-8");
800/// assert_eq!(mime_for(Path::new("photo.JPG")), "image/jpeg");
801/// assert_eq!(mime_for(Path::new("data.bin")), "application/octet-stream");
802/// ```
803#[must_use]
804pub fn mime_for(path: &Path) -> &'static str {
805    let ext = path
806        .extension()
807        .and_then(|e| e.to_str())
808        .map(str::to_ascii_lowercase)
809        .unwrap_or_default();
810    match ext.as_str() {
811        "html" | "htm" => "text/html; charset=utf-8",
812        "css" => "text/css; charset=utf-8",
813        "js" | "mjs" => "text/javascript; charset=utf-8",
814        "json" | "map" => "application/json",
815        "txt" => "text/plain; charset=utf-8",
816        "md" => "text/markdown; charset=utf-8",
817        "csv" => "text/csv; charset=utf-8",
818        "xml" => "application/xml",
819        "svg" => "image/svg+xml",
820        "png" => "image/png",
821        "jpg" | "jpeg" => "image/jpeg",
822        "gif" => "image/gif",
823        "webp" => "image/webp",
824        "avif" => "image/avif",
825        "ico" => "image/x-icon",
826        "bmp" => "image/bmp",
827        "woff" => "font/woff",
828        "woff2" => "font/woff2",
829        "ttf" => "font/ttf",
830        "otf" => "font/otf",
831        "pdf" => "application/pdf",
832        "wasm" => "application/wasm",
833        "zip" => "application/zip",
834        "gz" => "application/gzip",
835        "mp4" => "video/mp4",
836        "webm" => "video/webm",
837        "mp3" => "audio/mpeg",
838        "ogg" => "audio/ogg",
839        "wav" => "audio/wav",
840        "webmanifest" => "application/manifest+json",
841        "rss" => "application/rss+xml",
842        "atom" => "application/atom+xml",
843        _ => "application/octet-stream",
844    }
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850
851    fn stream() -> Stream {
852        Stream {
853            path: Arc::from(Path::new("x")),
854            len: 1,
855            modified: None,
856            start: 0,
857            total: 1,
858        }
859    }
860
861    #[test]
862    fn test_streams_full_refuses_new_never_forgets_old() {
863        let s = Streams::default();
864        for i in 0..MAX_STREAMS as u32 {
865            assert!(s.start((1, i), stream()));
866        }
867        assert!(!s.start((2, 0), stream()), "a full table refuses");
868        assert_eq!(s.len(), MAX_STREAMS);
869        // Every reply in progress is still there.
870        assert!(s.take((1, 0)).is_some());
871        assert!(s.take((1, MAX_STREAMS as u32 - 1)).is_some());
872        // A request that is done frees its slot.
873        s.remove((1, 5));
874        assert_eq!(s.len(), MAX_STREAMS - 3);
875        assert!(s.start((2, 0), stream()));
876    }
877
878    #[test]
879    fn test_unsafe_segments() {
880        for bad in [
881            ".env",
882            ".git",
883            "a:b",
884            "file.txt::$DATA",
885            "c:",
886            "x.",
887            "x ",
888            "CON",
889            "con.txt",
890            "Nul",
891            "COM1",
892            "lpt9.log",
893            "a\\b",
894            "",
895        ] {
896            assert!(!safe_segment(bad, false), "{bad:?}");
897        }
898        for ok in [
899            "a.css",
900            "index.html",
901            "COM",
902            "COM10",
903            "console.js",
904            "a b.txt",
905        ] {
906            assert!(safe_segment(ok, false), "{ok:?}");
907        }
908        assert!(safe_segment(".well-known", true));
909    }
910
911    #[test]
912    fn test_mime() {
913        assert_eq!(mime_for(Path::new("a/B.CSS")), "text/css; charset=utf-8");
914        assert_eq!(mime_for(Path::new("x")), "application/octet-stream");
915        assert_eq!(mime_for(Path::new("x.woff2")), "font/woff2");
916    }
917
918    #[test]
919    fn test_strip() {
920        let a = Assets::new("/static/", ".");
921        assert_eq!(a.strip("/static/a.css"), Some("/a.css"));
922        assert_eq!(a.strip("/static"), Some(""));
923        assert_eq!(a.strip("/statics/a"), None);
924    }
925}