1use headers::{AcceptRanges, HeaderMap, HeaderMapExt, HeaderValue};
13use hyper::{Body, Method, Response, StatusCode, header::CONTENT_ENCODING, header::CONTENT_LENGTH};
14use std::cell::RefCell;
15use std::collections::HashSet;
16use std::fs::{File, Metadata};
17use std::path::{Path, PathBuf};
18
19use crate::Result;
20use crate::conditional_headers::ConditionalHeaders;
21use crate::fs::meta::{FileMetadata, try_file_open, try_metadata, try_metadata_with_html_suffix};
22use crate::fs::path::{PathExt, sanitize_path};
23use crate::http_ext::{HTTP_SUPPORTED_METHODS, MethodExt};
24use crate::response::response_body;
25
26#[cfg(feature = "experimental")]
27use crate::mem_cache::{cache, cache::MemCacheOpts};
28
29use crate::compression_static;
30
31#[cfg(feature = "directory-listing")]
32use crate::{
33 directory_listing,
34 directory_listing::{DirListFmt, DirListOpts},
35};
36
37#[cfg(feature = "directory-listing-download")]
38use crate::directory_listing_download::{
39 DOWNLOAD_PARAM_KEY, DirDownloadFmt, DirDownloadOpts, archive_reply,
40};
41
42const DEFAULT_INDEX_FILES: &[&str; 1] = &["index.html"];
43
44const CONTAINMENT_CACHE_CAP: usize = 1024;
49
50#[cfg(test)]
51thread_local! {
52 static FILE_OPEN_RACE_HOOK: RefCell<Option<Box<dyn FnOnce()>>> = RefCell::new(None);
55
56 static PRE_FILE_OPEN_RACE_HOOK: RefCell<Option<Box<dyn FnOnce()>>> = RefCell::new(None);
60}
61
62thread_local! {
63 static CONTAINMENT_CACHE: RefCell<HashSet<PathBuf>> =
77 RefCell::new(HashSet::with_capacity(64));
78}
79
80#[cfg(all(test, unix))]
84fn set_file_open_race_hook(hook: impl FnOnce() + 'static) {
85 FILE_OPEN_RACE_HOOK.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
86}
87
88#[cfg(test)]
89fn run_file_open_race_hook() {
90 FILE_OPEN_RACE_HOOK.with(|slot| {
91 if let Some(hook) = slot.borrow_mut().take() {
92 hook();
93 }
94 });
95}
96
97#[cfg(all(test, unix))]
98fn set_pre_file_open_race_hook(hook: impl FnOnce() + 'static) {
99 PRE_FILE_OPEN_RACE_HOOK.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
100}
101
102#[cfg(test)]
103fn run_pre_file_open_race_hook() {
104 PRE_FILE_OPEN_RACE_HOOK.with(|slot| {
105 if let Some(hook) = slot.borrow_mut().take() {
106 hook();
107 }
108 });
109}
110
111#[cfg(test)]
112thread_local! {
113 static CANONICALIZE_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
117}
118
119#[cfg(all(test, unix))]
120fn reset_canonicalize_calls() {
121 CANONICALIZE_CALLS.with(|counter| counter.set(0));
122}
123
124#[cfg(all(test, unix))]
125fn canonicalize_calls() -> usize {
126 CANONICALIZE_CALLS.with(std::cell::Cell::get)
127}
128
129#[inline]
134fn cache_safe_probe(probe: &Path) {
135 CONTAINMENT_CACHE.with(|c| {
136 let mut set = c.borrow_mut();
137 if set.len() >= CONTAINMENT_CACHE_CAP {
138 set.clear();
139 }
140 set.insert(probe.to_path_buf());
141 });
142}
143
144pub struct HandleOpts<'a> {
146 pub method: &'a Method,
148 #[cfg(feature = "experimental")]
150 pub memory_cache: Option<&'a MemCacheOpts>,
151 pub headers: &'a HeaderMap<HeaderValue>,
153 pub base_path: &'a PathBuf,
155 pub uri_path: &'a str,
157 pub index_files: &'a [&'a str],
159 pub uri_query: Option<&'a str>,
161 #[cfg(feature = "directory-listing")]
163 #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
164 pub dir_listing: bool,
165 #[cfg(feature = "directory-listing")]
167 #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
168 pub dir_listing_order: u8,
169 #[cfg(feature = "directory-listing")]
171 #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
172 pub dir_listing_format: &'a DirListFmt,
173 #[cfg(feature = "directory-listing-download")]
175 #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing-download")))]
176 pub dir_listing_download: &'a [DirDownloadFmt],
177 pub redirect_trailing_slash: bool,
179 pub compression_static: bool,
181 pub ignore_hidden_files: bool,
183 pub disable_symlinks: bool,
185}
186
187enum SafeOpenPath {
194 Canonical(PathBuf),
196 Requested,
204}
205
206impl SafeOpenPath {
207 #[inline]
210 fn as_path<'a>(&'a self, requested: &'a Path) -> &'a Path {
211 match self {
212 Self::Canonical(path) => path.as_path(),
213 Self::Requested => requested,
214 }
215 }
216}
217
218fn enforce_path_security(
229 file_path: &Path,
230 is_dir: bool,
231 resolved_exists: bool,
232 opts: &HandleOpts<'_>,
233 use_containment_cache: bool,
234) -> Result<SafeOpenPath, StatusCode> {
235 let is_placeholder = is_dir && !resolved_exists;
236 let mut probe = file_path.to_path_buf();
237 if is_placeholder {
238 probe.pop();
239 }
240
241 let relative = probe.strip_prefix(opts.base_path).map_err(|err| {
242 tracing::error!(
243 "unable to strip prefix from file path '{}': {}",
244 file_path.display(),
245 err,
246 );
247 StatusCode::NOT_FOUND
248 })?;
249
250 let contained = enforce_containment(&probe, opts.base_path, use_containment_cache)?;
251
252 if opts.disable_symlinks {
253 enforce_symlink_policy(relative, opts.base_path, file_path)?;
254 }
255
256 let hidden_relative = if is_dir && resolved_exists {
260 relative.parent().unwrap_or(relative)
261 } else {
262 relative
263 };
264 if opts.ignore_hidden_files && hidden_relative.is_hidden() {
265 tracing::trace!(
266 "considering hidden file {} as not found",
267 file_path.display()
268 );
269 return Err(StatusCode::NOT_FOUND);
270 }
271
272 if is_placeholder {
276 return Ok(SafeOpenPath::Requested);
277 }
278
279 Ok(match contained {
280 Some(resolved) => SafeOpenPath::Canonical(resolved),
281 None => SafeOpenPath::Requested,
282 })
283}
284
285fn enforce_containment(
294 probe: &Path,
295 base_path: &Path,
296 use_cache: bool,
297) -> Result<Option<PathBuf>, StatusCode> {
298 if use_cache && CONTAINMENT_CACHE.with(|cache| cache.borrow().contains(probe)) {
299 return Ok(None);
300 }
301
302 #[cfg(test)]
303 CANONICALIZE_CALLS.with(|counter| counter.set(counter.get() + 1));
304
305 let resolved = probe.canonicalize().map_err(|err| {
306 tracing::error!(
307 "unable to resolve '{}' symlink path: {}",
308 probe.display(),
309 err,
310 );
311 StatusCode::NOT_FOUND
312 })?;
313
314 if resolved.starts_with(base_path) {
318 if use_cache {
319 cache_safe_probe(probe);
320 }
321 return Ok(Some(resolved));
322 }
323
324 let base_path = base_path.canonicalize().map_err(|err| {
326 tracing::error!(
327 "unable to resolve '{}' base path: {}",
328 base_path.display(),
329 err,
330 );
331 StatusCode::NOT_FOUND
332 })?;
333
334 if !resolved.starts_with(&base_path) {
335 tracing::error!(
336 "file path '{}' resolves outside of the base path, access denied",
337 resolved.display()
338 );
339 return Err(StatusCode::NOT_FOUND);
340 }
341
342 if use_cache {
343 cache_safe_probe(probe);
344 }
345 Ok(Some(resolved))
346}
347
348fn enforce_symlink_policy(
350 relative: &Path,
351 base_path: &Path,
352 file_path: &Path,
353) -> Result<(), StatusCode> {
354 let has_symlink = relative.contains_symlink(base_path).map_err(|err| {
355 tracing::error!(
356 "unable to check if file path '{}' contains symlink: {}",
357 relative.display(),
358 err,
359 );
360 StatusCode::NOT_FOUND
361 })?;
362
363 if has_symlink {
364 tracing::warn!(
365 "file path '{}' contains a symlink, access denied",
366 file_path.display()
367 );
368 return Err(StatusCode::FORBIDDEN);
369 }
370
371 Ok(())
372}
373
374pub struct StaticFileResponse {
376 pub resp: Response<Body>,
378 pub file_path: PathBuf,
380}
381
382pub async fn handle(opts: &HandleOpts<'_>) -> Result<StaticFileResponse, StatusCode> {
385 let method = opts.method;
386 if !method.is_allowed() {
388 return Err(StatusCode::METHOD_NOT_ALLOWED);
389 }
390
391 let uri_path = opts.uri_path;
392 let mut file_path = sanitize_path(opts.base_path, uri_path)?;
393
394 let headers_opt = opts.headers;
395
396 #[cfg(feature = "experimental")]
398 if opts.memory_cache.is_some() {
399 if opts.redirect_trailing_slash && uri_path.ends_with('/') {
402 file_path.push("index.html");
403 }
404
405 if let Some(result) = cache::get_or_acquire(file_path.as_path(), headers_opt).await {
406 match result {
407 cache::CacheResult::Hit(result) => {
408 return Ok(StaticFileResponse {
409 resp: result?,
410 file_path,
411 });
412 }
413 cache::CacheResult::Error(status) => {
414 return Err(status);
415 }
416 cache::CacheResult::Miss(_permit) => {
417 }
421 }
422 }
423 }
424
425 let FileMetadata {
426 file_path,
427 is_dir,
428 resolved_exists,
429 precompressed_variant,
430 } = get_composed_file_metadata(
431 &mut file_path,
432 headers_opt,
433 opts.compression_static,
434 opts.index_files,
435 )?;
436
437 let use_containment_cache = opts.disable_symlinks;
444
445 let safe_path = enforce_path_security(
446 file_path,
447 is_dir,
448 resolved_exists,
449 opts,
450 use_containment_cache,
451 )?;
452
453 let safe_precompressed_path = match precompressed_variant.as_ref() {
457 Some((precompressed_path, _)) => Some(enforce_path_security(
458 precompressed_path,
459 false,
460 true,
461 opts,
462 use_containment_cache,
463 )?),
464 None => None,
465 };
466
467 let resp_file_path = file_path.to_owned();
468
469 if is_dir && opts.redirect_trailing_slash && !uri_path.ends_with('/') {
472 let query = opts.uri_query.map_or(String::new(), |s| ["?", s].concat());
473 let uri = [uri_path, "/", query.as_str()].concat();
474 let loc = match HeaderValue::from_str(uri.as_str()) {
475 Ok(val) => val,
476 Err(err) => {
477 tracing::error!("invalid header value from current uri: {:?}", err);
478 return Err(StatusCode::INTERNAL_SERVER_ERROR);
479 }
480 };
481
482 let mut resp = Response::new(Body::empty());
483 resp.headers_mut().insert(hyper::header::LOCATION, loc);
484 *resp.status_mut() = StatusCode::PERMANENT_REDIRECT;
485
486 tracing::trace!("uri doesn't end with a slash so redirecting permanently");
487 return Ok(StaticFileResponse {
488 resp,
489 file_path: resp_file_path,
490 });
491 }
492
493 if method.is_options() {
495 let mut resp = Response::new(Body::empty());
496 *resp.status_mut() = StatusCode::NO_CONTENT;
497 resp.headers_mut()
498 .typed_insert(headers::Allow::from_iter(HTTP_SUPPORTED_METHODS.clone()));
499 resp.headers_mut().typed_insert(AcceptRanges::bytes());
500
501 return Ok(StaticFileResponse {
502 resp,
503 file_path: resp_file_path,
504 });
505 }
506
507 #[cfg(feature = "directory-listing")]
512 if is_dir && opts.dir_listing && !resolved_exists {
513 #[cfg(feature = "directory-listing-download")]
518 if !opts.dir_listing_download.is_empty()
519 && let Some((_k, _dl_archive_opt)) =
520 form_urlencoded::parse(opts.uri_query.unwrap_or("").as_bytes())
521 .find(|(k, _v)| k == DOWNLOAD_PARAM_KEY)
522 {
523 let mut fp = file_path.clone();
525 fp.pop();
526 if let Some(filename) = fp.file_name() {
527 let resp = archive_reply(
528 filename,
529 &fp,
530 DirDownloadOpts {
531 method,
532 disable_symlinks: opts.disable_symlinks,
533 ignore_hidden_files: opts.ignore_hidden_files,
534 },
535 );
536 return Ok(StaticFileResponse {
537 resp,
538 file_path: resp_file_path,
539 });
540 } else {
541 tracing::error!("Unable to get filename from {}", fp.to_string_lossy());
542 return Err(StatusCode::INTERNAL_SERVER_ERROR);
543 }
544 }
545
546 let resp = directory_listing::auto_index(DirListOpts {
547 root_path: opts.base_path.as_path(),
548 method,
549 current_path: uri_path,
550 uri_query: opts.uri_query,
551 filepath: file_path,
552 dir_listing_order: opts.dir_listing_order,
553 dir_listing_format: opts.dir_listing_format,
554 ignore_hidden_files: opts.ignore_hidden_files,
555 disable_symlinks: opts.disable_symlinks,
556 #[cfg(feature = "directory-listing-download")]
557 dir_listing_download: opts.dir_listing_download,
558 })?;
559
560 return Ok(StaticFileResponse {
561 resp,
562 file_path: resp_file_path,
563 });
564 }
565
566 if let Some((precomp_path, precomp_encoding)) = precompressed_variant {
568 let open_path = safe_precompressed_path
572 .as_ref()
573 .map_or(precomp_path.as_path(), |safe| safe.as_path(&precomp_path));
574
575 #[cfg(test)]
576 run_pre_file_open_race_hook();
577
578 let (file, precomp_meta) = try_file_open(open_path)?;
579
580 #[cfg(test)]
581 run_file_open_race_hook();
582
583 let mut resp = file_reply(
584 headers_opt,
585 file_path,
586 &precomp_meta,
587 file,
588 #[cfg(feature = "experimental")]
593 None,
594 )?;
595
596 resp.headers_mut().remove(CONTENT_LENGTH);
598 let encoding = match HeaderValue::from_str(precomp_encoding.as_str()) {
599 Ok(val) => val,
600 Err(err) => {
601 tracing::error!(
602 "unable to parse header value from content encoding: {:?}",
603 err
604 );
605 return Err(StatusCode::INTERNAL_SERVER_ERROR);
606 }
607 };
608 resp.headers_mut().insert(CONTENT_ENCODING, encoding);
609
610 return Ok(StaticFileResponse {
611 resp,
612 file_path: resp_file_path,
613 });
614 }
615
616 #[cfg(test)]
619 run_pre_file_open_race_hook();
620
621 let (file, serve_meta) = try_file_open(safe_path.as_path(file_path))?;
622
623 #[cfg(test)]
624 run_file_open_race_hook();
625
626 #[cfg(feature = "experimental")]
627 let resp = file_reply(headers_opt, file_path, &serve_meta, file, opts.memory_cache)?;
628
629 #[cfg(not(feature = "experimental"))]
630 let resp = file_reply(headers_opt, file_path, &serve_meta, file)?;
631
632 Ok(StaticFileResponse {
633 resp,
634 file_path: resp_file_path,
635 })
636}
637
638fn get_composed_file_metadata<'a>(
648 mut file_path: &'a mut PathBuf,
649 headers: &'a HeaderMap<HeaderValue>,
650 compression_static: bool,
651 mut index_files: &'a [&'a str],
652) -> Result<FileMetadata<'a>, StatusCode> {
653 tracing::trace!("getting metadata for file {}", file_path.display());
654
655 match try_metadata(file_path) {
657 Ok((_, is_dir)) => {
658 let mut resolved_exists = !is_dir;
666 if is_dir {
667 if index_files.is_empty() {
669 index_files = DEFAULT_INDEX_FILES;
670 }
671 for index in index_files {
672 tracing::debug!("dir: appending {} to the directory path", index);
674 file_path.push(index);
675
676 if matches!(try_metadata(file_path), Ok((_, false))) {
677 resolved_exists = true;
678 break;
679 }
680
681 file_path.pop();
683 let new_meta: Option<Metadata>;
684 (file_path, new_meta) = try_metadata_with_html_suffix(file_path);
685 if new_meta.is_some() {
686 resolved_exists = true;
687 break;
688 }
689 }
690
691 if !resolved_exists && !index_files.is_empty() {
694 file_path.push(index_files.last().unwrap());
695 }
696 }
697
698 let precompressed_variant = (compression_static && resolved_exists)
704 .then(|| compression_static::precompressed_variant(file_path, headers))
705 .flatten()
706 .map(|p| (p.file_path, p.encoding));
707
708 Ok(FileMetadata {
709 file_path,
710 is_dir,
711 resolved_exists,
712 precompressed_variant,
713 })
714 }
715 Err(err) => {
716 let new_meta: Option<Metadata>;
725 (file_path, new_meta) = try_metadata_with_html_suffix(file_path);
726
727 if new_meta.is_none() {
728 return Err(err);
732 }
733
734 let precompressed_variant = compression_static
737 .then(|| compression_static::precompressed_variant(file_path, headers))
738 .flatten()
739 .map(|p| (p.file_path, p.encoding));
740
741 Ok(FileMetadata {
742 file_path,
743 is_dir: false,
744 resolved_exists: true,
745 precompressed_variant,
746 })
747 }
748 }
749}
750
751fn file_reply<'a>(
753 headers: &'a HeaderMap<HeaderValue>,
754 path: &'a Path,
755 meta: &'a Metadata,
756 file: File,
757 #[cfg(feature = "experimental")] memory_cache: Option<&'a MemCacheOpts>,
758) -> Result<Response<Body>, StatusCode> {
759 let conditionals = ConditionalHeaders::new(headers);
760
761 #[cfg(feature = "experimental")]
762 let resp = response_body(file, path, meta, conditionals, memory_cache);
763
764 #[cfg(not(feature = "experimental"))]
765 let resp = response_body(file, path, meta, conditionals);
766
767 resp
768}
769
770#[cfg(all(test, unix))]
771mod tests {
772 use super::{
773 HandleOpts, StaticFileResponse, canonicalize_calls, handle, reset_canonicalize_calls,
774 set_file_open_race_hook, set_pre_file_open_race_hook,
775 };
776 use headers::HeaderMap;
777 use hyper::{Method, StatusCode};
778 use std::fs;
779 use std::os::unix::fs::symlink;
780 use std::path::{Path, PathBuf};
781 use std::sync::atomic::{AtomicU64, Ordering};
782 use std::time::{SystemTime, UNIX_EPOCH};
783
784 struct TempDir {
785 path: PathBuf,
786 }
787
788 impl TempDir {
789 fn new(tag: &str) -> Self {
790 static COUNTER: AtomicU64 = AtomicU64::new(0);
791 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
792 let nanos = SystemTime::now()
793 .duration_since(UNIX_EPOCH)
794 .map(|duration| duration.as_nanos())
795 .unwrap_or(0);
796 let path = std::env::temp_dir().join(format!(
797 "sws-static-files-{tag}-{}-{nanos}-{seq}",
798 std::process::id(),
799 ));
800 fs::create_dir_all(&path).expect("unexpected error creating temporary directory");
801 Self { path }
802 }
803
804 fn path(&self) -> &Path {
805 &self.path
806 }
807 }
808
809 impl Drop for TempDir {
810 fn drop(&mut self) {
811 let _ = fs::remove_dir_all(&self.path);
812 }
813 }
814
815 fn web_root(tag: &str) -> (TempDir, PathBuf) {
818 let temp_dir = TempDir::new(tag);
819 let root_dir = temp_dir.path().join("public");
820 fs::create_dir(&root_dir).expect("unexpected error creating web root");
821 (temp_dir, root_dir)
822 }
823
824 struct RequestOpts<'a> {
825 uri_path: &'a str,
826 encoding: &'a str,
827 disable_symlinks: bool,
828 ignore_hidden_files: bool,
829 index_files: &'a [&'a str],
830 }
831
832 impl Default for RequestOpts<'_> {
833 fn default() -> Self {
834 Self {
835 uri_path: "/app.js",
836 encoding: "identity",
837 disable_symlinks: false,
838 ignore_hidden_files: false,
839 index_files: &[],
840 }
841 }
842 }
843
844 async fn request_with(
845 root_dir: &PathBuf,
846 opts: RequestOpts<'_>,
847 ) -> Result<StaticFileResponse, StatusCode> {
848 let mut headers = HeaderMap::new();
849 headers.insert(
850 http::header::ACCEPT_ENCODING,
851 opts.encoding.parse().expect("unexpected invalid encoding"),
852 );
853
854 handle(&HandleOpts {
855 method: &Method::GET,
856 headers: &headers,
857 base_path: root_dir,
858 uri_path: opts.uri_path,
859 uri_query: None,
860 #[cfg(feature = "experimental")]
861 memory_cache: None,
862 #[cfg(feature = "directory-listing")]
863 dir_listing: false,
864 #[cfg(feature = "directory-listing")]
865 dir_listing_order: 6,
866 #[cfg(feature = "directory-listing")]
867 dir_listing_format: &crate::directory_listing::DirListFmt::Html,
868 #[cfg(feature = "directory-listing-download")]
869 dir_listing_download: &[],
870 redirect_trailing_slash: true,
871 compression_static: true,
872 ignore_hidden_files: opts.ignore_hidden_files,
873 disable_symlinks: opts.disable_symlinks,
874 index_files: opts.index_files,
875 })
876 .await
877 }
878
879 async fn request(
880 root_dir: &PathBuf,
881 encoding: &str,
882 disable_symlinks: bool,
883 ) -> Result<StaticFileResponse, StatusCode> {
884 request_with(
885 root_dir,
886 RequestOpts {
887 encoding,
888 disable_symlinks,
889 ..Default::default()
890 },
891 )
892 .await
893 }
894
895 async fn body_of(result: StaticFileResponse) -> Vec<u8> {
896 hyper::body::to_bytes(result.resp.into_body())
897 .await
898 .expect("unexpected bytes error during body conversion")
899 .to_vec()
900 }
901
902 #[tokio::test]
903 async fn opened_precompressed_file_retargeted_before_response_uses_opened_handle() {
904 for (extension, encoding) in [("gz", "gzip"), ("br", "br"), ("zst", "zstd")] {
905 for disable_symlinks in [false, true] {
906 let (temp_dir, root_dir) = web_root("open-race");
907 fs::write(root_dir.join("app.js"), "source")
908 .expect("unexpected error writing source file");
909 let inside_marker = root_dir.join("inside-marker");
910 fs::write(&inside_marker, "inside")
911 .expect("unexpected error writing inside marker");
912 let outside_marker = temp_dir.path().join("outside-marker");
913 fs::write(&outside_marker, "beyond")
914 .expect("unexpected error writing outside marker");
915 let precompressed_path = root_dir.join(format!("app.js.{extension}"));
916
917 if disable_symlinks {
918 fs::write(&precompressed_path, "inside")
919 .expect("unexpected error writing pre-compressed file");
920 } else {
921 symlink(&inside_marker, &precompressed_path)
922 .expect("unexpected error creating contained pre-compressed symlink");
923 }
924
925 let race_path = precompressed_path.clone();
926 set_file_open_race_hook(move || {
927 fs::remove_file(&race_path)
928 .expect("unexpected error removing pre-compressed path");
929 symlink(&outside_marker, &race_path)
930 .expect("unexpected error retargeting pre-compressed path");
931 });
932
933 let result = request(&root_dir, encoding, disable_symlinks)
934 .await
935 .expect("expected pre-compressed response");
936
937 assert_eq!(
938 body_of(result).await,
939 b"inside",
940 "served retargeted {encoding} path with disable_symlinks={disable_symlinks}"
941 );
942 }
943 }
944 }
945
946 #[tokio::test]
947 async fn precompressed_file_retargeted_within_check_open_window_is_not_served() {
948 for (extension, encoding) in [("gz", "gzip"), ("br", "br"), ("zst", "zstd")] {
949 let (temp_dir, root_dir) = web_root("precomp-check-open-race");
950 fs::write(root_dir.join("app.js"), "source")
951 .expect("unexpected error writing source file");
952 let inside_marker = root_dir.join("inside-marker");
953 fs::write(&inside_marker, "inside").expect("unexpected error writing inside marker");
954 let outside_marker = temp_dir.path().join("outside-marker");
955 fs::write(&outside_marker, "beyond").expect("unexpected error writing outside marker");
956
957 let precompressed_path = root_dir.join(format!("app.js.{extension}"));
958 symlink(&inside_marker, &precompressed_path)
959 .expect("unexpected error creating contained pre-compressed symlink");
960
961 let race_path = precompressed_path.clone();
964 set_pre_file_open_race_hook(move || {
965 fs::remove_file(&race_path).expect("unexpected error removing pre-compressed path");
966 symlink(&outside_marker, &race_path)
967 .expect("unexpected error retargeting pre-compressed path");
968 });
969
970 let result = request(&root_dir, encoding, false)
971 .await
972 .expect("expected pre-compressed response");
973
974 assert_eq!(
975 body_of(result).await,
976 b"inside",
977 "served {encoding} variant retargeted inside the check-then-open window"
978 );
979 }
980 }
981
982 #[tokio::test]
983 async fn requested_file_retargeted_within_check_open_window_is_not_served() {
984 let (temp_dir, root_dir) = web_root("requested-check-open-race");
985 let inside_marker = root_dir.join("inside-marker");
986 fs::write(&inside_marker, "inside").expect("unexpected error writing inside marker");
987 let outside_marker = temp_dir.path().join("outside-marker");
988 fs::write(&outside_marker, "beyond").expect("unexpected error writing outside marker");
989
990 let requested_path = root_dir.join("app.js");
991 symlink(&inside_marker, &requested_path)
992 .expect("unexpected error creating contained requested symlink");
993
994 let race_path = requested_path.clone();
995 set_pre_file_open_race_hook(move || {
996 fs::remove_file(&race_path).expect("unexpected error removing requested path");
997 symlink(&outside_marker, &race_path)
998 .expect("unexpected error retargeting requested path");
999 });
1000
1001 let result = request(&root_dir, "identity", false)
1002 .await
1003 .expect("expected requested-file response");
1004
1005 assert_eq!(
1006 body_of(result).await,
1007 b"inside",
1008 "served requested file retargeted inside the check-then-open window"
1009 );
1010 }
1011
1012 #[tokio::test]
1013 async fn index_file_symlink_escaping_root_is_rejected() {
1014 let (temp_dir, root_dir) = web_root("index-symlink-outside");
1015 let outside_marker = temp_dir.path().join("outside-marker");
1016 fs::write(&outside_marker, "beyond").expect("unexpected error writing outside marker");
1017 symlink(&outside_marker, root_dir.join("index.html"))
1018 .expect("unexpected error creating escaping index symlink");
1019
1020 match request_with(
1021 &root_dir,
1022 RequestOpts {
1023 uri_path: "/",
1024 ..Default::default()
1025 },
1026 )
1027 .await
1028 {
1029 Ok(result) => panic!(
1030 "expected escaping index symlink to be rejected, got {}",
1031 result.resp.status()
1032 ),
1033 Err(status) => assert_eq!(status, StatusCode::NOT_FOUND),
1034 }
1035 }
1036
1037 #[tokio::test]
1038 async fn index_file_symlink_is_rejected_when_symlinks_are_disabled() {
1039 let (_temp_dir, root_dir) = web_root("index-symlink-disabled");
1040 let inside_marker = root_dir.join("inside-marker");
1041 fs::write(&inside_marker, "inside").expect("unexpected error writing inside marker");
1042 symlink(&inside_marker, root_dir.join("index.html"))
1043 .expect("unexpected error creating contained index symlink");
1044
1045 match request_with(
1046 &root_dir,
1047 RequestOpts {
1048 uri_path: "/",
1049 disable_symlinks: true,
1050 ..Default::default()
1051 },
1052 )
1053 .await
1054 {
1055 Ok(result) => panic!(
1056 "expected index symlink to be rejected, got {}",
1057 result.resp.status()
1058 ),
1059 Err(status) => assert_eq!(status, StatusCode::FORBIDDEN),
1060 }
1061 }
1062
1063 #[tokio::test]
1064 async fn index_file_symlink_inside_root_is_served_when_symlinks_are_enabled() {
1065 let (_temp_dir, root_dir) = web_root("index-symlink-allowed");
1066 let inside_marker = root_dir.join("inside-marker");
1067 fs::write(&inside_marker, "inside").expect("unexpected error writing inside marker");
1068 symlink(&inside_marker, root_dir.join("index.html"))
1069 .expect("unexpected error creating contained index symlink");
1070
1071 let result = request_with(
1072 &root_dir,
1073 RequestOpts {
1074 uri_path: "/",
1075 ..Default::default()
1076 },
1077 )
1078 .await
1079 .expect("expected contained index symlink to be served");
1080
1081 assert_eq!(body_of(result).await, b"inside");
1082 }
1083
1084 #[tokio::test]
1085 async fn precompressed_index_variant_escaping_root_is_rejected() {
1086 for (extension, encoding) in [("gz", "gzip"), ("br", "br"), ("zst", "zstd")] {
1087 let (temp_dir, root_dir) = web_root("index-precomp-outside");
1088 fs::write(root_dir.join("index.html"), "source")
1089 .expect("unexpected error writing index file");
1090 let outside_marker = temp_dir.path().join("outside-marker");
1091 fs::write(&outside_marker, "beyond").expect("unexpected error writing outside marker");
1092 symlink(
1093 &outside_marker,
1094 root_dir.join(format!("index.html.{extension}")),
1095 )
1096 .expect("unexpected error creating escaping variant symlink");
1097
1098 match request_with(
1099 &root_dir,
1100 RequestOpts {
1101 uri_path: "/",
1102 encoding,
1103 ..Default::default()
1104 },
1105 )
1106 .await
1107 {
1108 Ok(result) => panic!(
1109 "expected escaping {encoding} index variant to be rejected, got {}",
1110 result.resp.status()
1111 ),
1112 Err(status) => assert_eq!(status, StatusCode::NOT_FOUND),
1113 }
1114 }
1115 }
1116
1117 #[tokio::test]
1118 async fn index_file_in_hidden_directory_is_served_when_hidden_files_are_ignored() {
1119 let (_temp_dir, root_dir) = web_root("hidden-index");
1120 fs::write(root_dir.join(".hidden-index.html"), "hidden-index")
1121 .expect("unexpected error writing hidden index file");
1122
1123 let result = request_with(
1126 &root_dir,
1127 RequestOpts {
1128 uri_path: "/",
1129 ignore_hidden_files: true,
1130 index_files: &[".hidden-index.html"],
1131 ..Default::default()
1132 },
1133 )
1134 .await
1135 .expect("expected configured hidden index file to be served");
1136
1137 assert_eq!(body_of(result).await, b"hidden-index");
1138 }
1139
1140 #[tokio::test]
1141 async fn hidden_directory_request_is_still_rejected_when_hidden_files_are_ignored() {
1142 let (_temp_dir, root_dir) = web_root("hidden-dir");
1143 let hidden_dir = root_dir.join(".hidden");
1144 fs::create_dir(&hidden_dir).expect("unexpected error creating hidden directory");
1145 fs::write(hidden_dir.join("index.html"), "hidden-dir-index")
1146 .expect("unexpected error writing hidden directory index");
1147
1148 match request_with(
1149 &root_dir,
1150 RequestOpts {
1151 uri_path: "/.hidden/",
1152 ignore_hidden_files: true,
1153 ..Default::default()
1154 },
1155 )
1156 .await
1157 {
1158 Ok(result) => panic!(
1159 "expected hidden directory request to be rejected, got {}",
1160 result.resp.status()
1161 ),
1162 Err(status) => assert_eq!(status, StatusCode::NOT_FOUND),
1163 }
1164 }
1165
1166 #[cfg(feature = "experimental")]
1167 #[tokio::test]
1168 async fn precompressed_variant_is_not_inserted_into_memory_cache() {
1169 use crate::mem_cache::cache::{CACHE_STORE, MemCacheOpts};
1170 use compact_str::CompactString;
1171
1172 let (_temp_dir, root_dir) = web_root("precomp-mem-cache");
1173 fs::write(root_dir.join("app.js"), "source").expect("unexpected error writing source file");
1174 let precompressed_body = b"pre-compressed-bytes";
1175 fs::write(root_dir.join("app.js.gz"), precompressed_body)
1176 .expect("unexpected error writing pre-compressed file");
1177
1178 let _ = CACHE_STORE.set(
1181 mini_moka::sync::Cache::builder()
1182 .max_capacity(8)
1183 .time_to_live(std::time::Duration::from_secs(60))
1184 .build(),
1185 );
1186 let store = CACHE_STORE
1187 .get()
1188 .expect("expected an in-memory cache store");
1189
1190 let memory_cache = MemCacheOpts::new(8);
1191 let mut headers = HeaderMap::new();
1192 headers.insert(
1193 http::header::ACCEPT_ENCODING,
1194 "gzip".parse().expect("unexpected invalid encoding"),
1195 );
1196
1197 let result = handle(&HandleOpts {
1198 method: &Method::GET,
1199 headers: &headers,
1200 base_path: &root_dir,
1201 uri_path: "/app.js",
1202 uri_query: None,
1203 memory_cache: Some(&memory_cache),
1204 #[cfg(feature = "directory-listing")]
1205 dir_listing: false,
1206 #[cfg(feature = "directory-listing")]
1207 dir_listing_order: 6,
1208 #[cfg(feature = "directory-listing")]
1209 dir_listing_format: &crate::directory_listing::DirListFmt::Html,
1210 #[cfg(feature = "directory-listing-download")]
1211 dir_listing_download: &[],
1212 redirect_trailing_slash: true,
1213 compression_static: true,
1214 ignore_hidden_files: false,
1215 disable_symlinks: false,
1216 index_files: &[],
1217 })
1218 .await
1219 .expect("expected pre-compressed response");
1220
1221 assert_eq!(body_of(result).await, precompressed_body);
1222
1223 let key = CompactString::from(
1227 root_dir
1228 .join("app.js")
1229 .to_str()
1230 .expect("unexpected non-UTF-8 web root"),
1231 );
1232 assert!(
1233 store.get(&key).is_none(),
1234 "pre-compressed body was inserted into the in-memory cache store"
1235 );
1236 }
1237
1238 #[tokio::test]
1239 async fn containment_cache_is_bypassed_when_symlinks_are_followed() {
1240 let (_temp_dir, root_dir) = web_root("containment-cache-bypassed");
1241 fs::write(root_dir.join("app.js"), "source").expect("unexpected error writing source file");
1242
1243 reset_canonicalize_calls();
1246 for _ in 0..3 {
1247 request(&root_dir, "identity", false)
1248 .await
1249 .expect("expected a successful response");
1250 }
1251 assert_eq!(
1252 canonicalize_calls(),
1253 3,
1254 "containment decisions were reused while symlinks are followed"
1255 );
1256 }
1257
1258 #[tokio::test]
1259 async fn containment_cache_is_reused_when_symlinks_are_disabled() {
1260 let (_temp_dir, root_dir) = web_root("containment-cache-reused");
1261 fs::write(root_dir.join("app.js"), "source").expect("unexpected error writing source file");
1262
1263 reset_canonicalize_calls();
1267 for _ in 0..3 {
1268 request(&root_dir, "identity", true)
1269 .await
1270 .expect("expected a successful response");
1271 }
1272 assert_eq!(
1273 canonicalize_calls(),
1274 1,
1275 "containment decisions were not reused while symlinks are disabled"
1276 );
1277 }
1278}