1use 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
24pub const INLINE_LIMIT: u64 = 1 << 20;
27
28const PART: usize = 64 << 10;
30
31const ABORT: Outcome = Outcome::Deferred;
38
39pub(crate) type StreamKey = (u64, u32);
42
43const MAX_IN_FLIGHT: usize = 256;
46
47thread_local! {
48 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
70pub(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
81pub(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
112fn 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#[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 #[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 #[must_use]
169 pub fn cache_control(mut self, value: &str) -> Self {
170 self.cache_control = value.into();
171 self
172 }
173
174 #[must_use]
177 pub fn index(mut self, name: &str) -> Self {
178 self.index = Some(name.into());
179 self
180 }
181
182 #[must_use]
185 pub fn precompressed(mut self, on: bool) -> Self {
186 self.precompressed = on;
187 self
188 }
189
190 #[must_use]
193 pub fn hidden_files(mut self, on: bool) -> Self {
194 self.hidden = on;
195 self
196 }
197
198 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
206fn is_device_name(seg: &str) -> bool {
209 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
236fn 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
245pub(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
255pub(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 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
313fn 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 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
332fn 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
347pub(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 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 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 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 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 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 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#[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}