1use 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
26pub const INLINE_LIMIT: u64 = 1 << 20;
34
35const PART: usize = 256 << 10;
37
38const ABORT: Outcome = Outcome::Deferred;
45
46pub(crate) type StreamKey = (u64, u32);
49
50pub(crate) const MAX_STREAMS: usize = 16 * 1024;
54
55pub(crate) struct Stream {
58 path: Arc<Path>,
59 len: u64,
60 modified: Option<SystemTime>,
61 start: u64,
62 total: u64,
63}
64
65#[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 pub(crate) fn take(&self, key: StreamKey) -> Option<Stream> {
80 self.lock().remove(&key)
81 }
82
83 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 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 static PART_BUF: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
114}
115
116pub(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
134fn 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#[derive(Default)]
153pub(crate) struct Cache {
154 map: RwLock<HashMap<Box<str>, [Option<Cached>; 3]>>,
155 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#[derive(Clone)]
171struct Content {
172 len: u64,
173 modified: Option<SystemTime>,
174 bytes: Arc<[u8]>,
175}
176
177const CONTENT_LIMIT: u64 = 64 << 10;
179
180const 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 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
209const REVALIDATE: Duration = Duration::from_secs(2);
212
213const 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#[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 #[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 #[must_use]
295 pub fn cache_control(mut self, value: &str) -> Self {
296 self.cache_control = value.into();
297 self
298 }
299
300 #[must_use]
316 pub fn index(mut self, name: &str) -> Self {
317 self.index = Some(name.into());
318 self
319 }
320
321 #[must_use]
338 pub fn precompressed(mut self, on: bool) -> Self {
339 self.precompressed = on;
340 self
341 }
342
343 #[must_use]
361 pub fn hidden_files(mut self, on: bool) -> Self {
362 self.hidden = on;
363 self
364 }
365
366 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
374fn is_device_name(seg: &str) -> bool {
377 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
404fn 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
413pub(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 bytes: Option<Arc<[u8]>>,
423 rel: &'r str,
425 pick: u8,
426}
427
428pub(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 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 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
507fn 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
562fn 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 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
581fn 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
596pub(crate) enum Unserved {
598 Unreadable,
600 Busy,
602}
603
604pub(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 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 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 if let Some(bytes) = &found.bytes {
698 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 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 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
765fn 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
773fn 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
786fn shrink(buf: &mut Vec<u8>) {
788 if buf.capacity() > PART {
789 *buf = Vec::new();
790 }
791}
792
793#[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 assert!(s.take((1, 0)).is_some());
871 assert!(s.take((1, MAX_STREAMS as u32 - 1)).is_some());
872 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}