1use std::fs::{File, Metadata};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use axum::body::Body;
14use axum::extract::Request;
15use axum::http::header::{
16 ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE,
17 IF_NONE_MATCH, IF_RANGE, LAST_MODIFIED, LOCATION, RANGE,
18};
19use axum::http::{HeaderMap, HeaderValue, Method, StatusCode};
20use axum::response::{IntoResponse, Response};
21
22const CHUNK: usize = 64 * 1024;
24
25fn windows_unsafe(segment: &str) -> bool {
29 if segment.ends_with(['.', ' ']) {
30 return true;
31 }
32 let stem = segment
33 .split('.')
34 .next()
35 .unwrap_or("")
36 .trim_end_matches(' ');
37 let upper = stem.to_ascii_uppercase();
38 match upper.as_str() {
39 "CON" | "PRN" | "AUX" | "NUL" => true,
40 _ => match (upper.get(..3), upper.get(3..)) {
41 (Some("COM" | "LPT"), Some(port)) => {
42 let mut c = port.chars();
43 matches!(
44 (c.next(), c.next()),
45 (Some('1'..='9' | '\u{b9}' | '\u{b2}' | '\u{b3}'), None)
46 )
47 }
48 _ => false,
49 },
50 }
51}
52
53pub struct Roots(Vec<(PathBuf, Option<PathBuf>)>);
56
57impl Roots {
58 pub fn new(dirs: &[PathBuf]) -> Self {
59 Self(
60 dirs.iter()
61 .map(|d| (d.clone(), std::fs::canonicalize(d).ok()))
62 .collect(),
63 )
64 }
65
66 fn open(&self, path: &str) -> Found {
72 let decoded = percent_encoding::percent_decode_str(path).decode_utf8_lossy();
73 let mut rel = PathBuf::new();
74 for segment in decoded.split('/') {
75 match segment {
76 "" | "." => {}
77 ".." => return Found::Missing,
78 s if s.contains('\\') || s.contains('\0') => return Found::Missing,
79 s if cfg!(windows) && s.contains(':') => return Found::Missing,
81 s if cfg!(windows) && windows_unsafe(s) => return Found::Missing,
82 s => rel.push(s),
83 }
84 }
85 for (dir, real_dir) in &self.0 {
86 let inside = |real: &Path| real_dir.as_ref().is_some_and(|d| real.starts_with(d));
87 let mut file = dir.join(&rel);
88 match std::fs::metadata(&file) {
89 Ok(m) if m.is_dir() => {
90 if !path.ends_with('/') {
91 if !std::fs::canonicalize(&file).is_ok_and(|r| inside(&r)) {
94 return Found::Refused;
95 }
96 return Found::Redirect(format!("/{}/", path.trim_start_matches('/')));
99 }
100 file.push("index.html");
101 }
102 Ok(m) if m.is_file() => {}
103 Ok(_) => return Found::Missing,
105 Err(_) => continue,
107 }
108 let Ok(opened) = open_file(&file) else {
111 continue;
112 };
113 let checked = || -> Option<Metadata> {
114 let real = std::fs::canonicalize(&file).ok()?;
115 let then = opened.metadata().ok()?;
116 (inside(&real) && then.is_file() && is_opened(&real, &opened, &then)?)
118 .then_some(then)
119 };
120 return match checked() {
121 Some(meta) => Found::File(opened, file, meta),
122 None => Found::Refused,
123 };
124 }
125 Found::Missing
126 }
127}
128
129#[cfg(unix)]
131fn open_file(file: &Path) -> std::io::Result<File> {
132 use std::os::unix::fs::OpenOptionsExt;
133 std::fs::OpenOptions::new()
134 .read(true)
135 .custom_flags(libc::O_NONBLOCK)
136 .open(file)
137}
138
139#[cfg(not(unix))]
140fn open_file(file: &Path) -> std::io::Result<File> {
141 File::open(file)
142}
143
144#[cfg(unix)]
146fn is_opened(real: &Path, _opened: &File, meta: &Metadata) -> Option<bool> {
147 use std::os::unix::fs::MetadataExt;
148 let now = std::fs::metadata(real).ok()?;
149 Some((now.dev(), now.ino()) == (meta.dev(), meta.ino()))
150}
151
152#[cfg(windows)]
154fn is_opened(real: &Path, opened: &File, _meta: &Metadata) -> Option<bool> {
155 use std::os::windows::io::AsRawHandle;
156 use windows_sys::Win32::Storage::FileSystem::{
157 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
158 };
159 let id = |f: &File| {
160 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
162 let ok = unsafe { GetFileInformationByHandle(f.as_raw_handle(), &mut info) };
163 let index = (info.nFileIndexHigh, info.nFileIndexLow);
164 (ok != 0).then_some((info.dwVolumeSerialNumber, index))
165 };
166 Some(id(&File::open(real).ok()?)? == id(opened)?)
167}
168
169enum Found {
171 File(File, PathBuf, Metadata),
172 Redirect(String),
174 Missing,
175 Refused,
177}
178
179pub async fn serve(roots: Arc<Roots>, request: Request) -> Response {
182 let head = match *request.method() {
183 Method::GET => false,
184 Method::HEAD => true,
185 _ => return StatusCode::METHOD_NOT_ALLOWED.into_response(),
186 };
187 let path = request.uri().path().to_string();
188 let found = tokio::task::spawn_blocking(move || roots.open(&path)).await;
189 match found {
190 Ok(Found::File(file, name, meta)) => respond(request.headers(), head, file, &name, &meta),
191 Ok(Found::Redirect(to)) => {
192 let to = match request.uri().query() {
193 Some(q) => format!("{to}?{q}"),
194 None => to,
195 };
196 match HeaderValue::from_str(&to) {
197 Ok(to) => (StatusCode::TEMPORARY_REDIRECT, [(LOCATION, to)]).into_response(),
198 Err(_) => StatusCode::NOT_FOUND.into_response(),
199 }
200 }
201 Ok(Found::Refused) => {
202 log::warn!(
203 "refused {}: a link out of the served directories (see --follow-symlinks)",
204 request.uri().path()
205 );
206 StatusCode::NOT_FOUND.into_response()
207 }
208 _ => StatusCode::NOT_FOUND.into_response(),
209 }
210}
211
212struct Validators {
214 etag: String,
215 modified: Option<SystemTime>,
216}
217
218impl Validators {
219 fn of(meta: &Metadata) -> Self {
220 let modified = meta.modified().ok();
221 let since = modified.and_then(|m| m.duration_since(UNIX_EPOCH).ok());
222 let nanos = since.unwrap_or_default().as_nanos();
223 #[cfg(unix)]
224 let etag = {
225 use std::os::unix::fs::MetadataExt;
226 format!("\"{:x}-{:x}-{nanos:x}\"", meta.ino(), meta.len())
227 };
228 #[cfg(not(unix))]
229 let etag = format!("\"{:x}-{nanos:x}\"", meta.len());
230 Self { etag, modified }
231 }
232
233 fn last_modified(&self) -> Option<String> {
234 self.modified.map(httpdate::fmt_http_date)
235 }
236
237 fn not_modified(&self, headers: &HeaderMap) -> bool {
240 if let Some(tags) = headers.get(IF_NONE_MATCH) {
241 let Ok(tags) = tags.to_str() else {
242 return false;
243 };
244 return tags
246 .split(',')
247 .map(str::trim)
248 .any(|t| t == "*" || t.strip_prefix("W/").unwrap_or(t) == self.etag);
249 }
250 let since = headers
251 .get(IF_MODIFIED_SINCE)
252 .and_then(|v| v.to_str().ok())
253 .and_then(|v| httpdate::parse_http_date(v).ok());
254 match (since, self.modified) {
255 (Some(since), Some(modified)) => secs(modified) <= secs(since),
256 _ => false,
257 }
258 }
259
260 fn same(&self, headers: &HeaderMap) -> bool {
262 match headers.get(IF_RANGE).map(HeaderValue::to_str) {
263 None => true,
264 Some(Ok(v)) if v.starts_with('"') => v == self.etag,
265 Some(Ok(v)) => self.last_modified().is_some_and(|lm| lm == v),
266 Some(Err(_)) => false,
267 }
268 }
269}
270
271fn secs(t: SystemTime) -> u64 {
272 t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
273}
274
275#[derive(Debug, PartialEq)]
277enum Part {
278 Whole,
279 Bytes(u64, u64),
281 Unsatisfiable,
282}
283
284fn part(range: Option<&str>, len: u64) -> Part {
287 let Some(spec) = range.and_then(|r| r.trim().strip_prefix("bytes=")) else {
288 return Part::Whole;
289 };
290 let Some((first, last)) = spec.split_once('-') else {
291 return Part::Whole;
292 };
293 let (first, last) = (first.trim(), last.trim());
294 if spec.contains(',') {
295 return Part::Whole;
296 }
297 if first.is_empty() {
298 return match last.parse::<u64>() {
300 Ok(0) => Part::Unsatisfiable,
301 Ok(_) if len == 0 => Part::Unsatisfiable,
302 Ok(n) => Part::Bytes(len.saturating_sub(n), len - 1),
303 Err(_) => Part::Whole,
304 };
305 }
306 let Ok(first) = first.parse::<u64>() else {
307 return Part::Whole;
308 };
309 let last = match last {
310 "" => u64::MAX,
311 l => match l.parse::<u64>() {
312 Ok(l) if l >= first => l,
313 _ => return Part::Whole,
314 },
315 };
316 if first >= len {
317 return Part::Unsatisfiable;
318 }
319 Part::Bytes(first, last.min(len - 1))
320}
321
322fn respond(headers: &HeaderMap, head: bool, file: File, name: &Path, meta: &Metadata) -> Response {
324 let len = meta.len();
325 let valid = Validators::of(meta);
326 let mut out = HeaderMap::new();
327 let mime = mime_guess::from_path(name).first_or_octet_stream();
328 out.insert(
329 CONTENT_TYPE,
330 HeaderValue::from_str(mime.as_ref())
331 .unwrap_or(HeaderValue::from_static("application/octet-stream")),
332 );
333 out.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
334 if let Ok(etag) = HeaderValue::from_str(&valid.etag) {
335 out.insert(ETAG, etag);
336 }
337 if let Some(lm) = valid
338 .last_modified()
339 .and_then(|v| HeaderValue::from_str(&v).ok())
340 {
341 out.insert(LAST_MODIFIED, lm);
342 }
343 if valid.not_modified(headers) {
344 out.remove(CONTENT_TYPE);
345 return (StatusCode::NOT_MODIFIED, out).into_response();
346 }
347 let range = headers.get(RANGE).and_then(|r| r.to_str().ok());
348 let wanted = if valid.same(headers) {
349 part(range, len)
350 } else {
351 Part::Whole
352 };
353 let (status, first, count) = match wanted {
354 Part::Whole => (StatusCode::OK, 0, len),
355 Part::Bytes(first, last) => {
356 let range = format!("bytes {first}-{last}/{len}");
357 out.insert(CONTENT_RANGE, HeaderValue::from_str(&range).expect("ASCII"));
358 (StatusCode::PARTIAL_CONTENT, first, last - first + 1)
359 }
360 Part::Unsatisfiable => {
361 let range = format!("bytes */{len}");
362 out.insert(CONTENT_RANGE, HeaderValue::from_str(&range).expect("ASCII"));
363 return (StatusCode::RANGE_NOT_SATISFIABLE, out).into_response();
364 }
365 };
366 out.insert(CONTENT_LENGTH, HeaderValue::from(count));
367 if head {
368 return (status, out).into_response();
369 }
370 let body = match stream(file, first, count) {
371 Ok(body) => body,
372 Err(_) => return StatusCode::NOT_FOUND.into_response(),
373 };
374 (status, out, body).into_response()
375}
376
377fn stream(mut file: File, first: u64, count: u64) -> std::io::Result<Body> {
380 use std::io::{Seek, SeekFrom};
381 use tokio::io::AsyncReadExt;
382 if first > 0 {
383 file.seek(SeekFrom::Start(first))?;
384 }
385 let file = tokio::fs::File::from_std(file).take(count);
386 Ok(Body::from_stream(
387 tokio_util::io::ReaderStream::with_capacity(file, CHUNK),
388 ))
389}
390
391#[cfg(test)]
392mod tests {
393 use super::{Found, Part, Roots, part, windows_unsafe};
394 #[cfg(unix)]
395 use std::path::PathBuf;
396
397 #[test]
398 fn windows_names() {
399 for s in [
400 "CON",
401 "con",
402 "Nul",
403 "aux.txt",
404 "PRN.a.b",
405 "nul .txt",
406 "COM1",
407 "com9.bob",
408 "LPT5",
409 "COM\u{b9}",
410 "lpt\u{b3}.x",
411 "a.bob.",
412 "a.bob ",
413 "..bob.",
414 " ",
415 ] {
416 assert!(windows_unsafe(s), "{s:?}");
417 }
418 for s in [
419 "a.bob",
420 "CONSOLE",
421 "con1",
422 "COM0",
423 "COM10",
424 "LPT",
425 "nul_x",
426 "xCON",
427 ".nul",
428 "COM\u{b4}",
429 ] {
430 assert!(!windows_unsafe(s), "{s:?}");
431 }
432 }
433
434 #[test]
435 fn ranges() {
436 assert_eq!(part(None, 10), Part::Whole);
437 assert_eq!(part(Some("bytes=0-3"), 10), Part::Bytes(0, 3));
438 assert_eq!(part(Some("bytes=5-"), 10), Part::Bytes(5, 9));
439 assert_eq!(part(Some("bytes=5-100"), 10), Part::Bytes(5, 9));
440 assert_eq!(part(Some("bytes=-3"), 10), Part::Bytes(7, 9));
441 assert_eq!(part(Some("bytes=-30"), 10), Part::Bytes(0, 9));
442 assert_eq!(part(Some("bytes=10-"), 10), Part::Unsatisfiable);
443 assert_eq!(part(Some("bytes=-0"), 10), Part::Unsatisfiable);
444 assert_eq!(part(Some("bytes=-3"), 0), Part::Unsatisfiable);
445 assert_eq!(part(Some("bytes=0-1,4-5"), 10), Part::Whole);
447 assert_eq!(part(Some("items=0-1"), 10), Part::Whole);
448 assert_eq!(part(Some("bytes=3-1"), 10), Part::Whole);
449 assert_eq!(part(Some("bytes=x-"), 10), Part::Whole);
450 }
451
452 #[test]
453 fn drives_and_streams_are_missing() {
454 let base = std::env::temp_dir().join(format!("bobview-drives-{}", std::process::id()));
455 std::fs::create_dir_all(&base).unwrap();
456 std::fs::write(base.join("a.bob"), "a").unwrap();
457 let roots = Roots::new(std::slice::from_ref(&base));
458 assert!(matches!(roots.open("/a.bob"), Found::File(..)));
459 for path in [
460 "/a.bob:s",
461 "/a.bob::$DATA",
462 "/C:/Windows/win.ini",
463 "/%5C%5Chost/x",
464 ] {
465 assert!(matches!(roots.open(path), Found::Missing), "{path}");
466 }
467 std::fs::remove_dir_all(&base).unwrap();
468 }
469
470 #[cfg(unix)]
471 #[test]
472 fn links_out_of_the_root_are_refused() {
473 let base = std::env::temp_dir().join(format!("bobview-confine-{}", std::process::id()));
474 let opi = base.join("opi");
475 let web = base.join("web");
476 let outside = base.join("outside");
477 for d in [&opi, &web, &outside, &opi.join("sub")] {
478 std::fs::create_dir_all(d).unwrap();
479 }
480 std::fs::write(opi.join("a.bob"), "a").unwrap();
481 std::fs::write(opi.join("sub/b.bob"), "b").unwrap();
482 std::fs::write(web.join("index.html"), "w").unwrap();
483 std::fs::write(outside.join("secret"), "s").unwrap();
484 let link = |target: &PathBuf, name: &str| {
485 std::os::unix::fs::symlink(target, opi.join(name)).unwrap();
486 };
487 link(&outside.join("secret"), "secret.bob");
488 link(&outside, "out");
489 link(&opi.join("sub"), "inside");
491
492 let roots = Roots::new(&[opi.clone(), web.clone()]);
493 let read = |path: &str| match roots.open(path) {
494 Found::File(mut f, ..) => {
495 let mut text = String::new();
496 std::io::Read::read_to_string(&mut f, &mut text).unwrap();
497 text
498 }
499 Found::Redirect(to) => format!("-> {to}"),
500 Found::Missing => "missing".into(),
501 Found::Refused => "refused".into(),
502 };
503 assert_eq!(read("/a.bob"), "a");
504 assert_eq!(read("/sub/b.bob"), "b");
505 assert_eq!(read("/inside/b.bob"), "b");
506 assert_eq!(read("/%73ub/b.bob"), "b");
507 assert_eq!(read("/index.html"), "w");
508 assert_eq!(read("/"), "w");
510 assert_eq!(read("/sub"), "-> /sub/");
511 assert_eq!(read("/missing.bob"), "missing");
512 assert_eq!(read("/../opi/a.bob"), "missing");
513 assert_eq!(read("/secret.bob"), "refused");
514 assert_eq!(read("/out/secret"), "refused");
515 assert_eq!(read("/%73ecret.bob"), "refused");
516 assert_eq!(read("/out%2Fsecret"), "refused");
517 let fifo = |p: &PathBuf| {
519 let ok = std::process::Command::new("mkfifo")
520 .arg(p)
521 .status()
522 .unwrap();
523 assert!(ok.success());
524 };
525 fifo(&opi.join("f.bob"));
526 fifo(&outside.join("f"));
527 link(&outside.join("f"), "outf.bob");
528 assert_eq!(read("/f.bob"), "missing");
529 assert_eq!(read("/outf.bob"), "missing");
530 assert_eq!(read("/out"), "refused");
532 assert_eq!(read("//sub"), "-> /sub/");
533 std::fs::remove_dir_all(&base).unwrap();
534 }
535
536 #[tokio::test]
537 async fn files_are_revalidated_and_served_in_parts() {
538 use axum::http::header::*;
539 use axum::http::{HeaderMap, HeaderValue, StatusCode};
540 let dir = std::env::temp_dir().join(format!("bobview-files-{}", std::process::id()));
541 std::fs::create_dir_all(&dir).unwrap();
542 let path = dir.join("a.wasm");
543 std::fs::write(&path, "0123456789").unwrap();
544 let get = async |head: bool, pairs: &[(HeaderName, String)]| {
545 let mut headers = HeaderMap::new();
546 for (k, v) in pairs {
547 headers.insert(k, HeaderValue::from_str(v).unwrap());
548 }
549 let file = std::fs::File::open(&path).unwrap();
550 let meta = file.metadata().unwrap();
551 let r = super::respond(&headers, head, file, &path, &meta);
552 let (parts, body) = r.into_parts();
553 let body = axum::body::to_bytes(body, 1 << 20).await.unwrap();
554 let text = String::from_utf8(body.to_vec()).unwrap();
555 (parts.status, parts.headers, text)
556 };
557 let (status, h, body) = get(false, &[]).await;
558 assert_eq!((status, body.as_str()), (StatusCode::OK, "0123456789"));
559 assert_eq!(h[CONTENT_TYPE], "application/wasm");
560 assert_eq!(h[CONTENT_LENGTH], "10");
561 assert_eq!(h[ACCEPT_RANGES], "bytes");
562 let etag = h[ETAG].to_str().unwrap().to_string();
563 let modified = h[LAST_MODIFIED].to_str().unwrap().to_string();
564
565 let (status, _, body) = get(false, &[(IF_MODIFIED_SINCE, modified.clone())]).await;
566 assert_eq!((status, body.as_str()), (StatusCode::NOT_MODIFIED, ""));
567 let (status, _, _) = get(false, &[(IF_NONE_MATCH, etag.clone())]).await;
568 assert_eq!(status, StatusCode::NOT_MODIFIED);
569 let (status, _, _) = get(false, &[(IF_NONE_MATCH, format!("W/{etag}"))]).await;
570 assert_eq!(status, StatusCode::NOT_MODIFIED);
571 let other = [
573 (IF_NONE_MATCH, "\"other\"".to_string()),
574 (IF_MODIFIED_SINCE, modified.clone()),
575 ];
576 assert_eq!(get(false, &other).await.0, StatusCode::OK);
577 let old = "Thu, 01 Jan 1970 00:00:00 GMT".to_string();
578 assert_eq!(
579 get(false, &[(IF_MODIFIED_SINCE, old)]).await.0,
580 StatusCode::OK
581 );
582
583 let (status, h, body) = get(false, &[(RANGE, "bytes=2-4".into())]).await;
584 assert_eq!(
585 (status, body.as_str()),
586 (StatusCode::PARTIAL_CONTENT, "234")
587 );
588 assert_eq!(h[CONTENT_RANGE], "bytes 2-4/10");
589 assert_eq!(h[CONTENT_LENGTH], "3");
590 let (_, _, body) = get(false, &[(RANGE, "bytes=-2".into())]).await;
591 assert_eq!(body, "89");
592 let (status, h, _) = get(false, &[(RANGE, "bytes=20-".into())]).await;
593 assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE);
594 assert_eq!(h[CONTENT_RANGE], "bytes */10");
595 let same = [(RANGE, "bytes=0-0".into()), (IF_RANGE, etag.clone())];
597 assert_eq!(get(false, &same).await.2, "0");
598 let same = [(RANGE, "bytes=0-0".into()), (IF_RANGE, modified.clone())];
599 assert_eq!(get(false, &same).await.2, "0");
600 let changed = [(RANGE, "bytes=0-0".into()), (IF_RANGE, "\"old\"".into())];
601 assert_eq!(get(false, &changed).await.2, "0123456789");
602
603 let (status, h, body) = get(true, &[]).await;
605 assert_eq!((status, body.as_str()), (StatusCode::OK, ""));
606 assert_eq!(h[CONTENT_LENGTH], "10");
607 std::fs::remove_dir_all(&dir).unwrap();
608 }
609}