1use std::convert::Infallible;
2use std::fs;
3use std::net::SocketAddr;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6use std::sync::Arc;
7use std::task::{Context, Poll};
8use std::time::{Duration, SystemTime};
9
10use bytes::Bytes;
11use http_body_util::Full;
12use hyper::body::Incoming;
13use hyper::http::response::Builder;
14use hyper::service::service_fn;
15use hyper::{HeaderMap, Method, Request, Response, StatusCode};
16use hyper_util::rt::TokioExecutor;
17use hyper_util::rt::TokioIo;
18use hyper_util::server::conn::auto::Builder as AutoBuilder;
19use tokio::fs::File;
20use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWrite, ReadBuf};
21use tokio::net::{TcpListener, TcpStream};
22use tokio::sync::{OwnedSemaphorePermit, Semaphore};
23use tokio::time::timeout;
24
25use crate::css::{CssOptions, CssTool};
26use crate::error::StaticError;
27use crate::handler::{FileBody, ResponseBody};
28use crate::js::{JsOptions, JsTool};
29use crate::reload::{self, SseBody};
30use crate::resolve;
31use crate::source::SourcePipeline;
32use crate::tool;
33use crate::watcher::{start_watching, Broadcaster};
34
35const ACCEPT_BACKOFF_INITIAL: Duration = Duration::from_millis(10);
36const ACCEPT_BACKOFF_MAX: Duration = Duration::from_secs(1);
37
38const DEFAULT_MAX_CONNECTIONS: usize = 1024;
40
41trait TcpAccept {
45 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)>;
46}
47
48impl TcpAccept for TcpListener {
49 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
50 TcpListener::accept(self).await
51 }
52}
53
54async fn accept_and_permit<L: TcpAccept>(
65 listener: &L,
66 backoff: &mut Duration,
67 semaphore: &Arc<Semaphore>,
68) -> Option<(TcpStream, OwnedSemaphorePermit)> {
69 loop {
70 let stream = match listener.accept().await {
71 Ok((stream, _)) => {
72 *backoff = ACCEPT_BACKOFF_INITIAL;
73 stream
74 }
75 Err(_) => {
76 tokio::time::sleep(*backoff).await;
77 *backoff = (*backoff * 2).min(ACCEPT_BACKOFF_MAX);
78 continue;
79 }
80 };
81 return semaphore
82 .clone()
83 .acquire_owned()
84 .await
85 .ok()
86 .map(|permit| (stream, permit));
87 }
88}
89
90type ImmutablePredicate = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
93
94#[derive(Clone)]
127pub struct Server {
128 root_canon: PathBuf,
129 bundle_roots: Vec<PathBuf>,
130 max_connections: usize,
131 live_reload: bool,
132 broadcaster: Option<Broadcaster>,
133 immutable_predicate: Option<ImmutablePredicate>,
134 source_folders: Vec<PathBuf>,
135 asset_folders: Vec<PathBuf>,
136 output_dir: PathBuf,
137 css_tool: Option<(CssTool, CssOptions)>,
138 js_tool: Option<(JsTool, JsOptions)>,
139 prune_output: bool,
140}
141
142fn paths_overlap(a: &Path, b: &Path) -> bool {
149 a.starts_with(b) || b.starts_with(a)
150}
151
152impl Server {
153 pub fn new(root: &Path) -> Result<Self, StaticError> {
163 let root_canon = root.canonicalize().map_err(StaticError::Io)?;
164 let output_dir = root_canon.clone();
165 Ok(Server {
166 root_canon,
167 bundle_roots: Vec::new(),
168 max_connections: DEFAULT_MAX_CONNECTIONS,
169 live_reload: false,
170 broadcaster: None,
171 immutable_predicate: None,
172 source_folders: Vec::new(),
173 asset_folders: Vec::new(),
174 output_dir,
175 css_tool: None,
176 js_tool: None,
177 prune_output: false,
178 })
179 }
180
181 pub fn with_max_connections(mut self, max: usize) -> Self {
189 self.max_connections = max;
190 self
191 }
192
193 pub fn with_live_reload(mut self) -> Self {
225 self.live_reload = true;
226 self
227 }
228
229 pub fn with_immutable_assets<F>(mut self, predicate: F) -> Self
256 where
257 F: Fn(&Path) -> bool + Send + Sync + 'static,
258 {
259 self.immutable_predicate = Some(Arc::new(predicate));
260 self
261 }
262
263 fn cache_control_for(&self, path: &Path) -> &'static str {
266 match &self.immutable_predicate {
267 Some(predicate) if predicate(path) => "public, max-age=31536000, immutable",
268 _ => "no-cache",
269 }
270 }
271
272 pub fn with_bundle_root(mut self, path: &Path) -> Result<Self, StaticError> {
295 let canon = path.canonicalize().map_err(StaticError::Io)?;
296 self.bundle_roots.push(canon);
297 Ok(self)
298 }
299
300 pub fn with_source_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
314 let canon = dir.canonicalize().map_err(StaticError::Io)?;
315
316 if paths_overlap(&canon, &self.output_dir) {
317 return Err(StaticError::Traversal(format!(
318 "source folder {} overlaps the output dir {}",
319 canon.display(),
320 self.output_dir.display()
321 )));
322 }
323 if self
324 .source_folders
325 .iter()
326 .chain(self.asset_folders.iter())
327 .any(|existing| paths_overlap(&canon, existing))
328 {
329 return Err(StaticError::Traversal(format!(
330 "source folder {} overlaps an already-registered source/asset folder",
331 canon.display()
332 )));
333 }
334
335 self.source_folders.push(canon);
336 Ok(self)
337 }
338
339 pub fn with_asset_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
357 let canon = dir.canonicalize().map_err(StaticError::Io)?;
358
359 if paths_overlap(&canon, &self.output_dir) {
360 return Err(StaticError::Traversal(format!(
361 "asset folder {} overlaps the output dir {}",
362 canon.display(),
363 self.output_dir.display()
364 )));
365 }
366 if self
367 .source_folders
368 .iter()
369 .chain(self.asset_folders.iter())
370 .any(|existing| paths_overlap(&canon, existing))
371 {
372 return Err(StaticError::Traversal(format!(
373 "asset folder {} overlaps an already-registered source/asset folder",
374 canon.display()
375 )));
376 }
377
378 self.asset_folders.push(canon);
379 Ok(self)
380 }
381
382 pub fn with_output_dir(mut self, dir: &Path) -> Result<Self, StaticError> {
393 let canon = dir.canonicalize().map_err(StaticError::Io)?;
394
395 if self
396 .source_folders
397 .iter()
398 .chain(self.asset_folders.iter())
399 .any(|existing| paths_overlap(&canon, existing))
400 {
401 return Err(StaticError::Traversal(format!(
402 "output dir {} overlaps a registered source/asset folder",
403 canon.display()
404 )));
405 }
406
407 self.output_dir = canon;
408 Ok(self)
409 }
410
411 pub fn with_css_tool(mut self, tool: CssTool, options: CssOptions) -> Self {
439 self.css_tool = Some((tool, options));
440 self
441 }
442
443 pub fn with_js_tool(mut self, tool: JsTool, options: JsOptions) -> Result<Self, StaticError> {
479 if let Some(entry) = options.entry() {
480 let entry_canon = entry.canonicalize().map_err(StaticError::Io)?;
481 let under_source_folder = self
482 .source_folders
483 .iter()
484 .any(|folder| entry_canon.starts_with(folder));
485 if !under_source_folder {
486 return Err(StaticError::Traversal(format!(
487 "js bundle entry {} is not under any registered source folder",
488 entry_canon.display()
489 )));
490 }
491 }
492
493 self.js_tool = Some((tool, options));
494 Ok(self)
495 }
496
497 pub fn with_prune_output(mut self) -> Self {
501 self.prune_output = true;
502 self
503 }
504
505 fn has_pipeline(&self) -> bool {
508 self.css_tool.is_some()
509 || self.js_tool.is_some()
510 || !self.source_folders.is_empty()
511 || !self.asset_folders.is_empty()
512 }
513
514 fn required_tool_binaries(&self) -> Vec<(&'static str, &'static str)> {
519 let mut required = Vec::new();
520 if let Some((css_tool, options)) = &self.css_tool {
521 if options.is_bundle() || options.is_minify() {
522 required.push((css_tool.binary_name(), css_tool.install_hint()));
523 }
524 }
525 if let Some((js_tool, options)) = &self.js_tool {
526 if options.is_bundle() || options.is_minify() {
527 required.push((js_tool.binary_name(), js_tool.install_hint()));
528 }
529 }
530 required
531 }
532
533 fn watch_targets(&self) -> Vec<PathBuf> {
537 let mut targets = Vec::new();
538 for dir in self
539 .source_folders
540 .iter()
541 .chain(self.bundle_roots.iter())
542 .chain(self.asset_folders.iter())
543 {
544 if !targets.contains(dir) {
545 targets.push(dir.clone());
546 }
547 }
548 targets
549 }
550
551 pub async fn build(&self) -> Result<(), StaticError> {
579 for (binary, install_hint) in self.required_tool_binaries() {
580 if !tool::locate_on_path(binary) {
581 return Err(StaticError::PipelineSetup(format!(
582 "{binary} not found on PATH ({install_hint})"
583 )));
584 }
585 }
586
587 let pipeline = SourcePipeline::new(
588 self.source_folders.clone(),
589 self.bundle_roots.clone(),
590 self.asset_folders.clone(),
591 self.output_dir.clone(),
592 self.css_tool.clone(),
593 self.js_tool.clone(),
594 self.prune_output,
595 Broadcaster::new(),
596 );
597 pipeline
598 .full_build()
599 .await
600 .map_err(|e| StaticError::Build(e.to_string()))
601 }
602
603 pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
613 resolve::resolve_with_canonical_root(&self.root_canon, request_path)
614 }
615
616 pub async fn run_on(
655 &self,
656 addr: SocketAddr,
657 header_timeout: Duration,
658 ) -> Result<(u16, ServerHandle), StaticError> {
659 for (binary, install_hint) in self.required_tool_binaries() {
660 if !tool::locate_on_path(binary) {
661 return Err(StaticError::PipelineSetup(format!(
662 "{binary} not found on PATH ({install_hint})"
663 )));
664 }
665 }
666
667 let listener = TcpListener::bind(addr).await.map_err(StaticError::Io)?;
668 let port = listener.local_addr().map_err(StaticError::Io)?.port();
669
670 let mut server = self.clone();
671 if server.live_reload {
672 let broadcaster = Broadcaster::new();
673
674 if server.has_pipeline() {
678 let pipeline = Arc::new(SourcePipeline::new(
679 server.source_folders.clone(),
680 server.bundle_roots.clone(),
681 server.asset_folders.clone(),
682 server.output_dir.clone(),
683 server.css_tool.clone(),
684 server.js_tool.clone(),
685 server.prune_output,
686 broadcaster.clone(),
687 ));
688 let mut rx = broadcaster.subscribe();
689 tokio::spawn(async move {
690 if let Err(e) = pipeline.full_build().await {
693 eprintln!("source pipeline build error: {e}");
694 }
695 while let Some(event) = rx.recv().await {
696 if let Err(e) = pipeline
697 .process_change(&event.path, &event.change_type)
698 .await
699 {
700 eprintln!("source pipeline error: {e}");
701 }
702 }
703 });
704 }
705
706 for dir in server.watch_targets() {
707 start_watching(Arc::new(dir), broadcaster.clone());
708 }
709
710 server.broadcaster = Some(broadcaster);
711 } else if server.has_pipeline() {
712 let pipeline = Arc::new(SourcePipeline::new(
716 server.source_folders.clone(),
717 server.bundle_roots.clone(),
718 server.asset_folders.clone(),
719 server.output_dir.clone(),
720 server.css_tool.clone(),
721 server.js_tool.clone(),
722 server.prune_output,
723 Broadcaster::new(),
724 ));
725 tokio::spawn(async move {
726 if let Err(e) = pipeline.full_build().await {
727 eprintln!("source pipeline build error: {e}");
728 }
729 });
730 }
731 let semaphore = Arc::new(Semaphore::new(server.max_connections));
732 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
733
734 let accept_task = tokio::spawn(async move {
735 let mut backoff = ACCEPT_BACKOFF_INITIAL;
736 let mut join_set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
737 let mut shutdown_pin = std::pin::pin!(shutdown_rx);
738 let mut shutting_down = false;
739
740 loop {
741 if !shutting_down {
742 tokio::select! {
746 accepted = accept_and_permit(&listener, &mut backoff, &semaphore) => {
747 match accepted {
748 Some((stream, permit)) => {
749 let server = server.clone();
750 join_set.spawn(async move {
751 let _permit = permit;
752 serve_connection(stream, server, header_timeout).await;
753 });
754 }
755 None => shutting_down = true,
756 }
757 }
758 _ = shutdown_pin.as_mut() => {
759 shutting_down = true;
760 }
761 }
762 continue;
763 }
764
765 match join_set.join_next().await {
767 Some(_) => continue,
768 None => break,
769 }
770 }
771 });
772
773 Ok((
774 port,
775 ServerHandle {
776 shutdown_tx: Some(shutdown_tx),
777 accept_task,
778 },
779 ))
780 }
781
782 pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
787 self.run_on(([127, 0, 0, 1], 0).into(), header_timeout)
788 .await
789 }
790
791 pub async fn run_all(
797 &self,
798 port: u16,
799 header_timeout: Duration,
800 ) -> Result<(u16, ServerHandle), StaticError> {
801 self.run_on(([0, 0, 0, 0], port).into(), header_timeout)
802 .await
803 }
804
805 pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
825 self.run(DEFAULT_HEADER_TIMEOUT).await
826 }
827
828 pub async fn handle_request(
845 &self,
846 method: &Method,
847 request_path: &str,
848 headers: &HeaderMap,
849 ) -> Response<ResponseBody> {
850 if method != Method::GET && method != Method::HEAD {
851 return text(
852 response(StatusCode::METHOD_NOT_ALLOWED).header("Allow", "GET, HEAD"),
853 "method not allowed\n",
854 );
855 }
856
857 if *method == Method::GET && request_path == reload::LIVE_RELOAD_PATH {
861 if let Some(broadcaster) = &self.broadcaster {
862 return finish(
863 response(StatusCode::OK)
864 .header("Content-Type", "text/event-stream")
865 .header("Cache-Control", "no-cache")
866 .header("Connection", "keep-alive")
867 .body(ResponseBody::Sse(SseBody::new(broadcaster.subscribe()))),
868 );
869 }
870 }
871
872 let server = self.clone();
878 let owned_request_path = request_path.to_string();
879 let resolved =
880 tokio::task::spawn_blocking(move || server.resolve(&owned_request_path)).await;
881 let path = match resolved {
882 Err(_) => return internal_error_response(),
883 Ok(Err(e)) => {
884 return text(
885 response(StatusCode::NOT_FOUND),
886 format!("{}\n", e.user_message()),
887 )
888 }
889 Ok(Ok(path)) => path,
890 };
891
892 let decoded_request_path = resolve::decode_request_path(request_path);
898 if path.file_name().is_some_and(|name| name == "index.html")
899 && !decoded_request_path.ends_with('/')
900 && !decoded_request_path.ends_with("index.html")
901 {
902 let location = format!("{}/", request_path.trim_end_matches('/'));
906 return text(
907 response(StatusCode::MOVED_PERMANENTLY).header("Location", location),
908 "moved\n",
909 );
910 }
911
912 let Ok(file) = File::open(&path).await else {
913 return internal_error_response();
914 };
915 let Ok(metadata) = file.metadata().await else {
916 return internal_error_response();
917 };
918
919 let content_type = mime_type_for_path(&path);
920 let html_injection = self.broadcaster.is_some() && content_type.starts_with("text/html");
923
924 let range_header = header_str(headers, "range");
925 let if_range_header = header_str(headers, "if-range");
926
927 let accept_encoding = header_str(headers, "accept-encoding");
928 let sidecar = if html_injection || range_header.is_some() {
930 None
931 } else {
932 select_precompressed_sidecar(&path, accept_encoding).await
933 };
934 let (mut file, metadata, content_encoding) = match sidecar {
935 Some((sidecar_file, sidecar_metadata, encoding)) => {
936 (sidecar_file, sidecar_metadata, Some(encoding))
937 }
938 None => (file, metadata, None),
939 };
940
941 let etag = generate_etag(&metadata);
944 let cache_control = self.cache_control_for(&path);
945
946 if header_str(headers, "if-none-match").is_some_and(|value| is_etag_match(value, &etag)) {
947 return finish(
948 Response::builder()
949 .status(StatusCode::NOT_MODIFIED)
950 .header("Cache-Control", cache_control)
951 .header("Vary", "Accept-Encoding")
952 .header("ETag", etag)
953 .header("Accept-Ranges", "bytes")
954 .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
955 );
956 }
957
958 let transformed: Option<Bytes> = if html_injection {
964 let mut html = Vec::with_capacity(metadata.len() as usize);
965 if file.read_to_end(&mut html).await.is_err() {
966 return internal_error_response();
967 }
968 reload::inject_reload_script(&mut html);
969 Some(Bytes::from(html))
970 } else {
971 None
972 };
973
974 let file_size = transformed
975 .as_ref()
976 .map_or(metadata.len(), |bytes| bytes.len() as u64);
977
978 let range_outcome = range_header.map(|h| parse_range_header(h, file_size));
980 let range_check = if let Some(outcome) = &range_outcome {
981 match outcome {
982 RangeOutcome::Satisfiable(start, end) => {
983 if let Some(if_range) = if_range_header {
985 if !if_range_valid(if_range, &etag) {
986 RangeCheck::IgnoreRange
987 } else {
988 RangeCheck::Satisfiable(*start, *end)
989 }
990 } else {
991 RangeCheck::Satisfiable(*start, *end)
992 }
993 }
994 RangeOutcome::MultiRangeIgnored => RangeCheck::IgnoreRange,
995 RangeOutcome::Unsatisfiable => RangeCheck::Unsatisfiable,
996 RangeOutcome::NoRange => RangeCheck::IgnoreRange,
997 }
998 } else {
999 RangeCheck::IgnoreRange
1000 };
1001
1002 match &range_check {
1003 RangeCheck::Unsatisfiable => {
1004 return finish(
1005 Response::builder()
1006 .status(StatusCode::RANGE_NOT_SATISFIABLE)
1007 .header("Content-Range", format!("bytes */{}", file_size))
1008 .header("Accept-Ranges", "bytes")
1009 .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1010 );
1011 }
1012 RangeCheck::Satisfiable(start, end) => {
1013 let range_len = end - start + 1;
1014
1015 if transformed.is_none() {
1017 if file.seek(std::io::SeekFrom::Start(*start)).await.is_err() {
1018 return internal_error_response();
1019 }
1020 }
1021
1022 let body = if *method == Method::HEAD {
1024 ResponseBody::Buffered(Full::new(Bytes::new()))
1025 } else {
1026 match transformed {
1027 Some(ref bytes) => ResponseBody::Buffered(Full::new(bytes.slice(*start as usize..(*end as usize + 1)))),
1028 None => ResponseBody::Streamed(FileBody::new_ranged(file, range_len)),
1029 }
1030 };
1031
1032 let mut builder = Response::builder()
1033 .status(StatusCode::PARTIAL_CONTENT)
1034 .header("Content-Type", content_type)
1035 .header("Content-Length", range_len.to_string())
1036 .header("Content-Range", format!("bytes {}-{}/{}", start, end, file_size))
1037 .header("Cache-Control", cache_control)
1038 .header("Vary", "Accept-Encoding")
1039 .header("ETag", etag)
1040 .header("Accept-Ranges", "bytes");
1041 if let Some(encoding) = content_encoding {
1042 builder = builder.header("Content-Encoding", encoding);
1043 }
1044 return finish(builder.body(body));
1045 }
1046 RangeCheck::IgnoreRange => {}
1047 }
1048
1049 let body = if *method == Method::HEAD {
1051 ResponseBody::Buffered(Full::new(Bytes::new()))
1052 } else {
1053 match transformed {
1054 Some(bytes) => ResponseBody::Buffered(Full::new(bytes)),
1055 None => ResponseBody::Streamed(FileBody::new(file)),
1056 }
1057 };
1058
1059 let mut builder = response(StatusCode::OK)
1060 .header("Content-Type", content_type)
1061 .header("Content-Length", file_size.to_string())
1062 .header("Cache-Control", cache_control)
1063 .header("Vary", "Accept-Encoding")
1064 .header("ETag", etag)
1065 .header("Accept-Ranges", "bytes");
1066 if let Some(encoding) = content_encoding {
1067 builder = builder.header("Content-Encoding", encoding);
1068 }
1069 finish(builder.body(body))
1070 }
1071}
1072
1073const MAX_HEADER_BYTES: usize = 64 * 1024;
1079
1080#[derive(Debug)]
1084enum HeaderReadError {
1085 ConnectionClosed,
1088 TooLarge,
1090 #[allow(dead_code)]
1093 Io(std::io::Error),
1094}
1095
1096async fn read_header_prefix(stream: &mut TcpStream) -> Result<Vec<u8>, HeaderReadError> {
1103 let mut buf = Vec::new();
1104 let mut chunk = [0u8; 4096];
1105
1106 loop {
1107 let n = stream.read(&mut chunk).await.map_err(HeaderReadError::Io)?;
1108 if n == 0 {
1109 return Err(HeaderReadError::ConnectionClosed);
1110 }
1111 buf.extend_from_slice(&chunk[..n]);
1112
1113 if buf.len() > MAX_HEADER_BYTES {
1114 return Err(HeaderReadError::TooLarge);
1115 }
1116 let scan_from = buf.len().saturating_sub(n + 3);
1120 if buf[scan_from..].windows(4).any(|w| w == b"\r\n\r\n") {
1121 return Ok(buf);
1122 }
1123 }
1124}
1125
1126struct PrefixedIo {
1132 prefix: Bytes,
1133 prefix_pos: usize,
1134 inner: TcpStream,
1135}
1136
1137impl PrefixedIo {
1138 fn new(prefix: Vec<u8>, inner: TcpStream) -> Self {
1139 PrefixedIo {
1140 prefix: Bytes::from(prefix),
1141 prefix_pos: 0,
1142 inner,
1143 }
1144 }
1145}
1146
1147impl AsyncRead for PrefixedIo {
1148 fn poll_read(
1149 self: Pin<&mut Self>,
1150 cx: &mut Context<'_>,
1151 buf: &mut ReadBuf<'_>,
1152 ) -> Poll<std::io::Result<()>> {
1153 let this = self.get_mut();
1154 if this.prefix_pos < this.prefix.len() {
1155 let remaining = &this.prefix[this.prefix_pos..];
1156 let n = remaining.len().min(buf.remaining());
1157 buf.put_slice(&remaining[..n]);
1158 this.prefix_pos += n;
1159 return Poll::Ready(Ok(()));
1160 }
1161 Pin::new(&mut this.inner).poll_read(cx, buf)
1162 }
1163}
1164
1165impl AsyncWrite for PrefixedIo {
1166 fn poll_write(
1167 self: Pin<&mut Self>,
1168 cx: &mut Context<'_>,
1169 buf: &[u8],
1170 ) -> Poll<std::io::Result<usize>> {
1171 Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
1172 }
1173
1174 fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1175 Pin::new(&mut self.get_mut().inner).poll_flush(cx)
1176 }
1177
1178 fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1179 Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
1180 }
1181}
1182
1183async fn serve_connection(mut stream: TcpStream, server: Server, header_timeout: Duration) {
1197 let prefix = match timeout(header_timeout, read_header_prefix(&mut stream)).await {
1198 Ok(Ok(prefix)) => prefix,
1199 Ok(Err(_)) | Err(_) => return,
1200 };
1201
1202 let io = TokioIo::new(PrefixedIo::new(prefix, stream));
1203 let svc = service_fn(move |req: Request<Incoming>| {
1204 let server = server.clone();
1205 async move {
1206 let resp = server
1207 .handle_request(req.method(), req.uri().path(), req.headers())
1208 .await;
1209 Ok::<_, Infallible>(resp)
1210 }
1211 });
1212 let _ = AutoBuilder::new(TokioExecutor::new())
1213 .serve_connection(io, svc)
1214 .await;
1215}
1216
1217const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(30);
1219
1220const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
1227
1228pub struct ServerHandle {
1234 shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
1235 accept_task: tokio::task::JoinHandle<()>,
1236}
1237
1238impl ServerHandle {
1239 pub async fn shutdown(self) {
1244 self.shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
1245 .await;
1246 }
1247
1248 pub async fn shutdown_with_timeout(mut self, drain_timeout: Duration) {
1259 if let Some(tx) = self.shutdown_tx.take() {
1260 let _ = tx.send(());
1261 }
1262 if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
1263 self.accept_task.abort();
1264 }
1265 }
1266}
1267
1268fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
1270 headers.get(name).and_then(|value| value.to_str().ok())
1271}
1272
1273fn response(status: StatusCode) -> Builder {
1277 Response::builder()
1278 .status(status)
1279 .header("X-Content-Type-Options", "nosniff")
1280}
1281
1282fn text(builder: Builder, body: impl Into<Bytes>) -> Response<ResponseBody> {
1285 finish(builder.body(ResponseBody::Buffered(Full::new(body.into()))))
1286}
1287
1288fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
1299 built.unwrap_or_else(|_| bad_request_response())
1300}
1301
1302fn internal_error_response() -> Response<ResponseBody> {
1307 response(StatusCode::INTERNAL_SERVER_ERROR)
1308 .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1309 b"internal server error\n",
1310 ))))
1311 .unwrap()
1312}
1313
1314fn bad_request_response() -> Response<ResponseBody> {
1315 response(StatusCode::BAD_REQUEST)
1316 .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1317 b"bad request\n",
1318 ))))
1319 .unwrap()
1320}
1321
1322const SIDECAR_ENCODINGS: [(&str, &str); 2] = [("br", ".br"), ("gzip", ".gz")];
1326
1327fn accepts_encoding(accept_encoding: Option<&str>, encoding: &str) -> bool {
1333 accept_encoding.is_some_and(|header| header.contains(encoding))
1334}
1335
1336async fn select_precompressed_sidecar(
1344 path: &Path,
1345 accept_encoding: Option<&str>,
1346) -> Option<(File, fs::Metadata, &'static str)> {
1347 for (encoding, ext) in SIDECAR_ENCODINGS {
1348 if !accepts_encoding(accept_encoding, encoding) {
1349 continue;
1350 }
1351 let mut sidecar = path.as_os_str().to_os_string();
1352 sidecar.push(ext);
1353 let sidecar_path = PathBuf::from(sidecar);
1354
1355 debug_assert_eq!(
1361 sidecar_path.parent(),
1362 path.parent(),
1363 "sidecar path must stay in the same directory as the already-resolved path"
1364 );
1365
1366 if let Ok(sidecar_file) = File::open(&sidecar_path).await {
1367 if let Ok(sidecar_metadata) = sidecar_file.metadata().await {
1368 return Some((sidecar_file, sidecar_metadata, encoding));
1369 }
1370 }
1371 }
1372 None
1373}
1374
1375fn generate_etag(metadata: &fs::Metadata) -> String {
1379 let mtime = metadata
1380 .modified()
1381 .ok()
1382 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1383 .map(|d| d.as_secs())
1384 .unwrap_or(0);
1385 format!("\"{}-{}\"", metadata.len(), mtime)
1386}
1387
1388fn mime_type_for_path(path: &Path) -> &'static str {
1390 let ext = path
1391 .extension()
1392 .and_then(|ext| ext.to_str())
1393 .unwrap_or_default()
1394 .to_lowercase();
1395
1396 match ext.as_str() {
1397 "html" | "htm" => "text/html; charset=utf-8",
1398 "css" => "text/css; charset=utf-8",
1399 "js" => "application/javascript; charset=utf-8",
1400 "json" => "application/json; charset=utf-8",
1401 "svg" => "image/svg+xml",
1402 "png" => "image/png",
1403 "jpg" | "jpeg" => "image/jpeg",
1404 "gif" => "image/gif",
1405 "webp" => "image/webp",
1406 "ico" => "image/x-icon",
1407 "woff" => "font/woff",
1408 "woff2" => "font/woff2",
1409 "ttf" => "font/ttf",
1410 "md" | "markdown" => "text/markdown; charset=utf-8",
1411 "txt" => "text/plain; charset=utf-8",
1412 "xml" => "application/xml",
1413 "pdf" => "application/pdf",
1414 "zip" => "application/zip",
1415 _ => "application/octet-stream",
1416 }
1417}
1418
1419fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
1422 if if_none_match == "*" {
1423 return true;
1424 }
1425 if_none_match.split(',').any(|tag| tag.trim() == etag)
1426}
1427
1428#[derive(Debug)]
1429enum RangeOutcome {
1430 NoRange,
1431 Satisfiable(u64, u64),
1432 Unsatisfiable,
1433 MultiRangeIgnored,
1434}
1435
1436enum RangeCheck {
1437 IgnoreRange,
1438 Satisfiable(u64, u64),
1439 Unsatisfiable,
1440}
1441
1442fn parse_range_header(header: &str, file_size: u64) -> RangeOutcome {
1443 let header = header.trim();
1444 if !header.starts_with("bytes=") {
1445 return RangeOutcome::NoRange;
1446 }
1447
1448 let range_spec = &header[6..];
1449
1450 if range_spec.contains(',') {
1451 return RangeOutcome::MultiRangeIgnored;
1452 }
1453
1454 if let Some(suffix_pos) = range_spec.find('-') {
1455 if suffix_pos == 0 {
1456 let suffix_len_str = &range_spec[1..];
1457 if let Ok(suffix_len) = suffix_len_str.parse::<u64>() {
1458 if suffix_len == 0 {
1459 return RangeOutcome::Unsatisfiable;
1460 }
1461 if suffix_len >= file_size {
1462 return RangeOutcome::Satisfiable(0, file_size - 1);
1463 }
1464 return RangeOutcome::Satisfiable(file_size - suffix_len, file_size - 1);
1465 }
1466 return RangeOutcome::Unsatisfiable;
1467 }
1468
1469 let start_str = &range_spec[..suffix_pos];
1470 let end_str = &range_spec[suffix_pos + 1..];
1471
1472 if let Ok(start) = start_str.parse::<u64>() {
1473 if start >= file_size {
1474 return RangeOutcome::Unsatisfiable;
1475 }
1476
1477 if end_str.is_empty() {
1478 return RangeOutcome::Satisfiable(start, file_size - 1);
1479 }
1480
1481 if let Ok(end) = end_str.parse::<u64>() {
1482 if end < start {
1483 return RangeOutcome::Unsatisfiable;
1484 }
1485 let clamped_end = (end + 1).min(file_size) - 1;
1486 if start > clamped_end {
1487 return RangeOutcome::Unsatisfiable;
1488 }
1489 return RangeOutcome::Satisfiable(start, clamped_end);
1490 }
1491 }
1492 }
1493
1494 RangeOutcome::Unsatisfiable
1495}
1496
1497fn if_range_valid(if_range_header: &str, current_etag: &str) -> bool {
1498 if_range_header.trim() == current_etag
1499}
1500
1501#[cfg(test)]
1502mod precompressed_sidecar_tests {
1503 use super::*;
1504
1505 #[tokio::test]
1512 async fn sidecar_never_leaves_the_resolved_files_directory() {
1513 let root = tempfile::TempDir::new().unwrap();
1514 let sub = root.path().join("assets");
1515 fs::create_dir(&sub).unwrap();
1516 let resolved = sub.join("app.js");
1517 fs::write(&resolved, b"plain").unwrap();
1518 fs::write(sub.join("app.js.br"), b"brotli-bytes").unwrap();
1519 fs::write(sub.join("app.js.gz"), b"gzip-bytes").unwrap();
1520
1521 let (_, _, encoding) = select_precompressed_sidecar(&resolved, Some("br, gzip"))
1522 .await
1523 .expect("both sidecars present, br should be preferred");
1524 assert_eq!(
1525 encoding, "br",
1526 "br must be preferred over gzip when both are accepted"
1527 );
1528
1529 let (_, _, encoding) = select_precompressed_sidecar(&resolved, Some("gzip"))
1530 .await
1531 .expect("gzip sidecar present");
1532 assert_eq!(encoding, "gzip");
1533
1534 assert!(
1535 select_precompressed_sidecar(&resolved, None)
1536 .await
1537 .is_none(),
1538 "no Accept-Encoding header should never select a sidecar"
1539 );
1540 }
1541
1542 #[test]
1543 fn accepts_encoding_matches_only_listed_directives() {
1544 assert!(!accepts_encoding(None, "br"));
1545 assert!(!accepts_encoding(Some("identity"), "br"));
1546 assert!(!accepts_encoding(Some("identity"), "gzip"));
1547 assert!(accepts_encoding(Some("gzip, br"), "br"));
1548 assert!(accepts_encoding(Some("gzip"), "gzip"));
1549 assert!(!accepts_encoding(Some("gzip"), "br"));
1550 }
1551}
1552
1553#[cfg(test)]
1554mod file_body_tests {
1555 use super::*;
1556 use crate::handler::FILE_CHUNK_SIZE;
1557 use http_body_util::BodyExt;
1558
1559 #[tokio::test]
1564 async fn file_body_yields_multiple_bounded_chunks_not_one_buffered_frame() {
1565 let dir = tempfile::TempDir::new().unwrap();
1566 let path = dir.path().join("big.bin");
1567 let content = vec![7u8; FILE_CHUNK_SIZE * 3 + 12_345];
1568 fs::write(&path, &content).unwrap();
1569
1570 let file = File::open(&path).await.unwrap();
1571 let mut body = FileBody::new(file);
1572
1573 let mut frame_count = 0usize;
1574 let mut max_frame_len = 0usize;
1575 let mut reassembled = Vec::new();
1576
1577 while let Some(frame) = body.frame().await {
1578 let frame = frame.unwrap();
1579 let data = frame.into_data().unwrap();
1580 frame_count += 1;
1581 max_frame_len = max_frame_len.max(data.len());
1582 reassembled.extend_from_slice(&data);
1583 }
1584
1585 assert!(
1586 frame_count > 1,
1587 "expected the file to be delivered as multiple frames, got {frame_count}"
1588 );
1589 assert!(
1590 max_frame_len <= FILE_CHUNK_SIZE,
1591 "no single frame should exceed the chunk size ({FILE_CHUNK_SIZE}), got {max_frame_len}"
1592 );
1593 assert_eq!(
1594 reassembled, content,
1595 "reassembled chunks must match original file content exactly"
1596 );
1597 }
1598}
1599
1600#[cfg(test)]
1601mod accept_tests {
1602 use super::*;
1603 use std::sync::atomic::{AtomicUsize, Ordering};
1604 use std::sync::Mutex;
1605
1606 struct FlakyListener {
1610 inner: TcpListener,
1611 remaining_failures: AtomicUsize,
1612 attempts: Mutex<Vec<tokio::time::Instant>>,
1613 }
1614
1615 impl TcpAccept for FlakyListener {
1616 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
1617 self.attempts
1618 .lock()
1619 .unwrap()
1620 .push(tokio::time::Instant::now());
1621 if self.remaining_failures.fetch_sub(1, Ordering::SeqCst) > 0 {
1622 Err(std::io::Error::other("simulated accept error"))
1623 } else {
1624 TcpAccept::accept(&self.inner).await
1625 }
1626 }
1627 }
1628
1629 #[tokio::test(start_paused = true)]
1635 async fn accept_loop_backs_off_between_repeated_errors_instead_of_busy_spinning() {
1636 let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1637 let addr = inner.local_addr().unwrap();
1638
1639 let flaky = FlakyListener {
1640 inner,
1641 remaining_failures: AtomicUsize::new(5),
1642 attempts: Mutex::new(Vec::new()),
1643 };
1644
1645 tokio::spawn(async move {
1646 let _ = TcpStream::connect(addr).await;
1647 });
1648
1649 let semaphore = Arc::new(Semaphore::new(1));
1650 let mut backoff = ACCEPT_BACKOFF_INITIAL;
1651 let result = accept_and_permit(&flaky, &mut backoff, &semaphore).await;
1652 assert!(
1653 result.is_some(),
1654 "accept should eventually succeed once the flaky listener stops failing"
1655 );
1656
1657 let recorded = flaky.attempts.lock().unwrap();
1658 assert_eq!(recorded.len(), 6, "5 failures then 1 success");
1659
1660 let expected_gaps = [
1661 ACCEPT_BACKOFF_INITIAL,
1662 ACCEPT_BACKOFF_INITIAL * 2,
1663 ACCEPT_BACKOFF_INITIAL * 4,
1664 ACCEPT_BACKOFF_INITIAL * 8,
1665 ACCEPT_BACKOFF_INITIAL * 16,
1666 ];
1667 for (i, expected) in expected_gaps.iter().enumerate() {
1668 let gap = recorded[i + 1] - recorded[i];
1669 assert_eq!(
1670 gap,
1671 *expected,
1672 "gap between attempt {i} and {} should reflect the backoff delay, not a busy spin",
1673 i + 1
1674 );
1675 }
1676
1677 let mut capped = ACCEPT_BACKOFF_MAX;
1679 capped = (capped * 2).min(ACCEPT_BACKOFF_MAX);
1680 assert_eq!(capped, ACCEPT_BACKOFF_MAX);
1681 }
1682
1683 #[tokio::test(start_paused = true)]
1686 async fn a_successful_accept_resets_the_backoff() {
1687 let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1688 let addr = inner.local_addr().unwrap();
1689 let flaky = FlakyListener {
1690 inner,
1691 remaining_failures: AtomicUsize::new(3),
1692 attempts: Mutex::new(Vec::new()),
1693 };
1694 tokio::spawn(async move {
1695 let _ = TcpStream::connect(addr).await;
1696 });
1697
1698 let semaphore = Arc::new(Semaphore::new(1));
1699 let mut backoff = ACCEPT_BACKOFF_INITIAL * 32;
1700 accept_and_permit(&flaky, &mut backoff, &semaphore).await;
1701
1702 assert_eq!(
1703 backoff, ACCEPT_BACKOFF_INITIAL,
1704 "the delay must return to its initial value once an accept succeeds"
1705 );
1706 }
1707}
1708
1709#[cfg(test)]
1710mod finish_tests {
1711 use super::*;
1712
1713 #[test]
1720 fn finish_degrades_to_400_on_invalid_header_value_instead_of_panicking() {
1721 let built = Response::builder()
1722 .status(StatusCode::OK)
1723 .header("X-Test", "invalid\r\nvalue")
1724 .body(ResponseBody::Buffered(Full::new(Bytes::new())));
1725 assert!(
1726 built.is_err(),
1727 "CR/LF in a header value should be rejected by the builder"
1728 );
1729
1730 let response = finish(built);
1731 assert_eq!(
1732 response.status(),
1733 StatusCode::BAD_REQUEST,
1734 "finish() should degrade to 400 rather than panicking on an invalid header value"
1735 );
1736 }
1737}
1738
1739#[cfg(test)]
1740mod header_prefix_tests {
1741 use super::*;
1742 use tokio::io::AsyncWriteExt;
1743
1744 async fn connected_pair() -> (TcpStream, TcpStream) {
1748 let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1749 let addr = listener.local_addr().unwrap();
1750 let client = TcpStream::connect(addr).await.unwrap();
1751 let (server_side, _) = listener.accept().await.unwrap();
1752 (server_side, client)
1753 }
1754
1755 #[tokio::test]
1756 async fn reads_exactly_up_to_and_including_the_terminating_blank_line() {
1757 let (mut server_side, mut client) = connected_pair().await;
1758
1759 client
1760 .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
1761 .await
1762 .unwrap();
1763
1764 let prefix = read_header_prefix(&mut server_side)
1765 .await
1766 .unwrap_or_else(|_| {
1767 panic!("expected a complete header block to be read");
1768 });
1769
1770 assert_eq!(prefix, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
1771 }
1772
1773 #[tokio::test]
1779 async fn assembles_a_header_block_split_across_multiple_writes() {
1780 let (mut server_side, mut client) = connected_pair().await;
1781
1782 client
1783 .write_all(b"GET /page HTTP/1.1\r\nHost: localhost\r")
1784 .await
1785 .unwrap();
1786 client.write_all(b"\n\r\n").await.unwrap();
1787
1788 let prefix = read_header_prefix(&mut server_side)
1789 .await
1790 .unwrap_or_else(|_| {
1791 panic!("expected a complete header block to be read across multiple writes");
1792 });
1793
1794 assert_eq!(prefix, b"GET /page HTTP/1.1\r\nHost: localhost\r\n\r\n");
1795 }
1796
1797 #[tokio::test]
1801 async fn preserves_bytes_sent_past_the_header_block() {
1802 let (mut server_side, mut client) = connected_pair().await;
1803
1804 let first = b"GET /a HTTP/1.1\r\nHost: localhost\r\n\r\n";
1805 let second = b"GET /b HTTP/1.1\r\nHost: localhost\r\n\r\n";
1806 let mut sent = Vec::new();
1807 sent.extend_from_slice(first);
1808 sent.extend_from_slice(second);
1809 client.write_all(&sent).await.unwrap();
1810
1811 let prefix = read_header_prefix(&mut server_side)
1812 .await
1813 .unwrap_or_else(|_| {
1814 panic!("expected a complete header block to be read");
1815 });
1816
1817 assert_eq!(
1818 &prefix, &sent,
1819 "pipelined bytes past the first header block must survive intact"
1820 );
1821 }
1822
1823 #[tokio::test]
1824 async fn errors_with_connection_closed_when_client_disconnects_before_headers_complete() {
1825 let (mut server_side, client) = connected_pair().await;
1826 drop(client);
1827
1828 match read_header_prefix(&mut server_side).await {
1829 Err(HeaderReadError::ConnectionClosed) => {}
1830 Err(_) => panic!("expected ConnectionClosed, got a different error variant"),
1831 Ok(_) => {
1832 panic!("expected an error, got a complete header block from a closed connection")
1833 }
1834 }
1835 }
1836
1837 #[tokio::test]
1841 async fn errors_with_too_large_once_max_header_bytes_is_exceeded_without_a_terminator() {
1842 let (mut server_side, mut client) = connected_pair().await;
1843
1844 let garbage = vec![b'a'; MAX_HEADER_BYTES + 1];
1845 client.write_all(&garbage).await.unwrap();
1846
1847 match read_header_prefix(&mut server_side).await {
1848 Err(HeaderReadError::TooLarge) => {}
1849 Err(_) => panic!("expected TooLarge, got a different error variant"),
1850 Ok(_) => {
1851 panic!("expected an error, got a complete header block from unterminated garbage")
1852 }
1853 }
1854 }
1855
1856 #[tokio::test]
1857 async fn prefixed_io_replays_the_prefix_before_reading_from_the_live_socket() {
1858 let (server_side, mut client) = connected_pair().await;
1859 let mut io = PrefixedIo::new(b"buffered-prefix".to_vec(), server_side);
1860
1861 client.write_all(b"-live-bytes").await.unwrap();
1862
1863 let mut collected = Vec::new();
1864 let mut chunk = [0u8; 8];
1865 while collected.len() < b"buffered-prefix-live-bytes".len() {
1866 let n = io.read(&mut chunk).await.unwrap();
1867 assert!(n > 0, "read returned 0 before all expected bytes arrived");
1868 collected.extend_from_slice(&chunk[..n]);
1869 }
1870
1871 assert_eq!(collected, b"buffered-prefix-live-bytes");
1872 }
1873}
1874
1875#[cfg(test)]
1876mod css_bundle_tests {
1877 use super::*;
1878 use std::fs;
1879 use std::time::Duration;
1880 use tempfile::TempDir;
1881 use tokio::time::sleep;
1882
1883 #[tokio::test]
1884 async fn source_folder_overlapping_output_dir_is_rejected() {
1885 let root = TempDir::new().unwrap();
1886
1887 let result = Server::new(root.path())
1891 .unwrap()
1892 .with_source_folder(root.path());
1893 assert!(
1894 result.is_err(),
1895 "a source folder equal to the output dir must be rejected"
1896 );
1897 }
1898
1899 #[tokio::test]
1900 async fn source_folder_inside_output_dir_is_rejected() {
1901 let root = TempDir::new().unwrap();
1902 let nested = root.path().join("nested");
1903 fs::create_dir(&nested).unwrap();
1904
1905 let result = Server::new(root.path())
1906 .unwrap()
1907 .with_source_folder(&nested);
1908 assert!(
1909 result.is_err(),
1910 "a source folder nested in the output dir must be rejected"
1911 );
1912 }
1913
1914 #[tokio::test]
1915 async fn output_dir_overlapping_source_folder_is_rejected() {
1916 let root = TempDir::new().unwrap();
1917 let source = TempDir::new().unwrap();
1918
1919 let server = Server::new(root.path())
1920 .unwrap()
1921 .with_source_folder(source.path())
1922 .unwrap();
1923
1924 let result = server.with_output_dir(source.path());
1925 assert!(
1926 result.is_err(),
1927 "an output dir equal to a source folder must be rejected"
1928 );
1929 }
1930
1931 #[tokio::test]
1932 async fn css_bundle_creates_output_on_startup_with_live_reload() {
1933 let src = TempDir::new().unwrap();
1934 let out = TempDir::new().unwrap();
1935
1936 fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();
1937
1938 let server = Server::new(out.path())
1939 .unwrap()
1940 .with_live_reload()
1941 .with_source_folder(src.path())
1942 .unwrap()
1943 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
1944
1945 let (_port, handle) = server.run_ephemeral().await.unwrap();
1946
1947 sleep(Duration::from_millis(800)).await;
1948
1949 let bundle = out.path().join("styles.css");
1950 assert!(
1951 bundle.exists(),
1952 "bundle should be written to the default <output>/styles.css"
1953 );
1954 let content = fs::read_to_string(&bundle).unwrap();
1955 assert!(!content.is_empty(), "bundle should contain CSS");
1956
1957 handle.shutdown().await;
1958 }
1959
1960 #[tokio::test]
1961 async fn css_bundle_rebuilds_once_and_settles_when_source_css_changes() {
1962 let src = TempDir::new().unwrap();
1963 let out = TempDir::new().unwrap();
1964 let src_path = src.path();
1965 let bundle = out.path().join("styles.css");
1966
1967 fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();
1968
1969 let server = Server::new(out.path())
1970 .unwrap()
1971 .with_live_reload()
1972 .with_source_folder(src_path)
1973 .unwrap()
1974 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
1975
1976 let (_port, handle) = server.run_ephemeral().await.unwrap();
1977
1978 sleep(Duration::from_millis(800)).await;
1980 assert!(bundle.exists());
1981
1982 fs::write(
1983 src_path.join("style.css"),
1984 "body { margin: 0; color: blue; }",
1985 )
1986 .unwrap();
1987
1988 sleep(Duration::from_millis(1500)).await;
1990 let content_v2 = fs::read_to_string(&bundle).unwrap();
1991 assert!(
1992 content_v2.contains("color"),
1993 "rebundle should contain the new color rule"
1994 );
1995
1996 let mtime_after = fs::metadata(&bundle).unwrap().modified().unwrap();
1997 sleep(Duration::from_millis(1200)).await;
1998 let mtime_later = fs::metadata(&bundle).unwrap().modified().unwrap();
1999
2000 assert_eq!(
2004 mtime_after, mtime_later,
2005 "bundle mtime must settle after one rebuild — an ongoing loop would keep changing it"
2006 );
2007
2008 handle.shutdown().await;
2009 }
2010
2011 #[tokio::test]
2012 async fn css_bundle_creates_output_on_startup_without_live_reload() {
2013 let src = TempDir::new().unwrap();
2014 let out = TempDir::new().unwrap();
2015
2016 fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();
2017
2018 let server = Server::new(out.path())
2019 .unwrap()
2020 .with_source_folder(src.path())
2021 .unwrap()
2022 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2023
2024 let (_port, handle) = server.run_ephemeral().await.unwrap();
2025
2026 sleep(Duration::from_millis(200)).await;
2027
2028 let bundle = out.path().join("styles.css");
2029 assert!(
2030 bundle.exists(),
2031 "bundle should be created even without live_reload"
2032 );
2033 let content = fs::read_to_string(&bundle).unwrap();
2034 assert!(!content.is_empty(), "bundle should contain CSS");
2035
2036 handle.shutdown().await;
2037 }
2038
2039 #[tokio::test]
2040 async fn css_bundle_concatenates_multiple_source_css_files() {
2041 let src = TempDir::new().unwrap();
2042 let out = TempDir::new().unwrap();
2043
2044 fs::write(src.path().join("reset.css"), "* { margin: 0; padding: 0; }").unwrap();
2045 fs::write(src.path().join("theme.css"), "body { background: white; }").unwrap();
2046
2047 let server = Server::new(out.path())
2048 .unwrap()
2049 .with_live_reload()
2050 .with_source_folder(src.path())
2051 .unwrap()
2052 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2053
2054 let (_port, handle) = server.run_ephemeral().await.unwrap();
2055
2056 sleep(Duration::from_millis(800)).await;
2057
2058 let content = fs::read_to_string(out.path().join("styles.css")).unwrap();
2059 assert!(
2060 content.contains("margin"),
2061 "output should contain reset CSS"
2062 );
2063 assert!(
2064 content.contains("background"),
2065 "output should contain theme CSS"
2066 );
2067
2068 handle.shutdown().await;
2069 }
2070}
2071
2072#[cfg(test)]
2073mod asset_folder_tests {
2074 use super::*;
2075 use std::fs;
2076 use std::time::Duration;
2077 use tempfile::TempDir;
2078 use tokio::time::sleep;
2079
2080 #[tokio::test]
2081 async fn asset_folder_overlapping_output_dir_is_rejected() {
2082 let root = TempDir::new().unwrap();
2083 let result = Server::new(root.path())
2084 .unwrap()
2085 .with_asset_folder(root.path());
2086 assert!(
2087 result.is_err(),
2088 "an asset folder equal to the output dir must be rejected"
2089 );
2090 }
2091
2092 #[tokio::test]
2093 async fn asset_folder_overlapping_an_existing_asset_folder_is_rejected() {
2094 let root = TempDir::new().unwrap();
2095 let assets = TempDir::new().unwrap();
2096
2097 let result = Server::new(root.path())
2098 .unwrap()
2099 .with_asset_folder(assets.path())
2100 .unwrap()
2101 .with_asset_folder(assets.path());
2102 assert!(
2103 result.is_err(),
2104 "registering the same asset folder twice must be rejected"
2105 );
2106 }
2107
2108 #[tokio::test]
2109 async fn asset_folder_overlapping_a_source_folder_is_rejected_both_ways() {
2110 let root = TempDir::new().unwrap();
2111 let shared = TempDir::new().unwrap();
2112
2113 let via_asset_then_source = Server::new(root.path())
2114 .unwrap()
2115 .with_asset_folder(shared.path())
2116 .unwrap()
2117 .with_source_folder(shared.path());
2118 assert!(
2119 via_asset_then_source.is_err(),
2120 "a source folder overlapping an already-registered asset folder must be rejected"
2121 );
2122
2123 let via_source_then_asset = Server::new(root.path())
2124 .unwrap()
2125 .with_source_folder(shared.path())
2126 .unwrap()
2127 .with_asset_folder(shared.path());
2128 assert!(
2129 via_source_then_asset.is_err(),
2130 "an asset folder overlapping an already-registered source folder must be rejected"
2131 );
2132 }
2133
2134 #[tokio::test]
2135 async fn asset_folder_files_are_served_after_startup_build() {
2136 let assets = TempDir::new().unwrap();
2137 let out = TempDir::new().unwrap();
2138 fs::write(assets.path().join("index.html"), "<html>hi</html>").unwrap();
2139 fs::create_dir(assets.path().join("images")).unwrap();
2140 fs::write(assets.path().join("images/logo.svg"), "<svg></svg>").unwrap();
2141
2142 let server = Server::new(out.path())
2143 .unwrap()
2144 .with_asset_folder(assets.path())
2145 .unwrap();
2146 let (port, handle) = server.run_ephemeral().await.unwrap();
2147
2148 sleep(Duration::from_millis(200)).await;
2149
2150 let index = fs::read_to_string(out.path().join("index.html")).unwrap();
2151 assert_eq!(index, "<html>hi</html>");
2152 let logo = fs::read_to_string(out.path().join("images/logo.svg")).unwrap();
2153 assert_eq!(logo, "<svg></svg>");
2154
2155 let mut conn = tokio::net::TcpStream::connect(("127.0.0.1", port))
2156 .await
2157 .unwrap();
2158 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2159 conn.write_all(b"GET /index.html HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
2160 .await
2161 .unwrap();
2162 let mut response = Vec::new();
2163 conn.read_to_end(&mut response).await.unwrap();
2164 let response = String::from_utf8_lossy(&response);
2165 assert!(response.contains("HTTP/1.1 200"), "got: {response}");
2166 assert!(response.contains("<html>hi</html>"), "got: {response}");
2167
2168 handle.shutdown().await;
2169 }
2170
2171 #[tokio::test]
2172 async fn asset_folder_change_rebuilds_and_live_reloads() {
2173 let assets = TempDir::new().unwrap();
2174 let out = TempDir::new().unwrap();
2175 fs::write(assets.path().join("index.html"), "v1").unwrap();
2176
2177 let server = Server::new(out.path())
2178 .unwrap()
2179 .with_live_reload()
2180 .with_asset_folder(assets.path())
2181 .unwrap();
2182 let (_port, handle) = server.run_ephemeral().await.unwrap();
2183
2184 sleep(Duration::from_millis(800)).await;
2185 assert_eq!(
2186 fs::read_to_string(out.path().join("index.html")).unwrap(),
2187 "v1"
2188 );
2189
2190 fs::write(assets.path().join("index.html"), "v2").unwrap();
2191 sleep(Duration::from_millis(1500)).await;
2192
2193 assert_eq!(
2194 fs::read_to_string(out.path().join("index.html")).unwrap(),
2195 "v2",
2196 "editing the source asset must re-copy it into the output dir"
2197 );
2198
2199 handle.shutdown().await;
2200 }
2201}
2202
2203#[cfg(test)]
2204mod build_once_tests {
2205 use super::*;
2206 use std::fs;
2207 use tempfile::TempDir;
2208
2209 #[tokio::test]
2210 async fn build_populates_the_output_dir_without_starting_a_server() {
2211 let assets = TempDir::new().unwrap();
2212 let out = TempDir::new().unwrap();
2213 fs::write(assets.path().join("index.html"), "<html>hi</html>").unwrap();
2214
2215 let server = Server::new(out.path())
2216 .unwrap()
2217 .with_asset_folder(assets.path())
2218 .unwrap();
2219
2220 server.build().await.unwrap();
2221
2222 assert_eq!(
2223 fs::read_to_string(out.path().join("index.html")).unwrap(),
2224 "<html>hi</html>",
2225 "build() must populate the output dir synchronously, no server needed"
2226 );
2227 }
2228
2229 #[tokio::test]
2230 async fn build_with_no_pipeline_configured_is_a_harmless_no_op() {
2231 let out = TempDir::new().unwrap();
2232 let server = Server::new(out.path()).unwrap();
2233
2234 server
2235 .build()
2236 .await
2237 .expect("build() with nothing configured must succeed trivially");
2238 }
2239
2240 #[tokio::test]
2241 async fn build_fails_fast_when_a_required_tool_binary_is_missing() {
2242 let src = TempDir::new().unwrap();
2243 let out = TempDir::new().unwrap();
2244 fs::write(src.path().join("a.css"), "body{}").unwrap();
2245
2246 let server = Server::new(out.path())
2247 .unwrap()
2248 .with_source_folder(src.path())
2249 .unwrap()
2250 .with_css_tool(CssTool::TestMissing, CssOptions::new().minify(true));
2251
2252 let result = server.build().await;
2253
2254 assert!(
2255 matches!(result, Err(StaticError::PipelineSetup(_))),
2256 "expected PipelineSetup, got {result:?}"
2257 );
2258 }
2259}
2260
2261#[cfg(test)]
2262mod range_header_tests {
2263 use super::*;
2264
2265 #[test]
2266 fn no_range_header_returns_unsatisfiable() {
2267 match parse_range_header("bytes=", 1000) {
2268 RangeOutcome::Unsatisfiable => {}
2269 other => panic!("expected Unsatisfiable, got {other:?}"),
2270 }
2271 }
2272
2273 #[test]
2274 fn invalid_format_returns_unsatisfiable() {
2275 match parse_range_header("invalid", 1000) {
2276 RangeOutcome::NoRange => {}
2277 other => panic!("expected NoRange, got {other:?}"),
2278 }
2279 }
2280
2281 #[test]
2282 fn simple_range_returns_satisfiable() {
2283 match parse_range_header("bytes=0-99", 1000) {
2284 RangeOutcome::Satisfiable(start, end) => {
2285 assert_eq!(start, 0);
2286 assert_eq!(end, 99);
2287 }
2288 other => panic!("expected Satisfiable(0, 99), got {other:?}"),
2289 }
2290 }
2291
2292 #[test]
2293 fn open_ended_range_returns_satisfiable() {
2294 match parse_range_header("bytes=100-", 1000) {
2295 RangeOutcome::Satisfiable(start, end) => {
2296 assert_eq!(start, 100);
2297 assert_eq!(end, 999);
2298 }
2299 other => panic!("expected Satisfiable(100, 999), got {other:?}"),
2300 }
2301 }
2302
2303 #[test]
2304 fn suffix_range_returns_satisfiable() {
2305 match parse_range_header("bytes=-100", 1000) {
2306 RangeOutcome::Satisfiable(start, end) => {
2307 assert_eq!(start, 900);
2308 assert_eq!(end, 999);
2309 }
2310 other => panic!("expected Satisfiable(900, 999), got {other:?}"),
2311 }
2312 }
2313
2314 #[test]
2315 fn suffix_range_longer_than_file_returns_full_range() {
2316 match parse_range_header("bytes=-2000", 1000) {
2317 RangeOutcome::Satisfiable(start, end) => {
2318 assert_eq!(start, 0);
2319 assert_eq!(end, 999);
2320 }
2321 other => panic!("expected Satisfiable(0, 999), got {other:?}"),
2322 }
2323 }
2324
2325 #[test]
2326 fn end_overshooting_file_clamps_correctly() {
2327 match parse_range_header("bytes=0-2000", 1000) {
2328 RangeOutcome::Satisfiable(start, end) => {
2329 assert_eq!(start, 0);
2330 assert_eq!(end, 999);
2331 }
2332 other => panic!("expected Satisfiable(0, 999), got {other:?}"),
2333 }
2334 }
2335
2336 #[test]
2337 fn start_at_file_boundary_returns_unsatisfiable() {
2338 match parse_range_header("bytes=1000-", 1000) {
2339 RangeOutcome::Unsatisfiable => {}
2340 other => panic!("expected Unsatisfiable, got {other:?}"),
2341 }
2342 }
2343
2344 #[test]
2345 fn start_beyond_file_returns_unsatisfiable() {
2346 match parse_range_header("bytes=2000-3000", 1000) {
2347 RangeOutcome::Unsatisfiable => {}
2348 other => panic!("expected Unsatisfiable, got {other:?}"),
2349 }
2350 }
2351
2352 #[test]
2353 fn end_before_start_returns_unsatisfiable() {
2354 match parse_range_header("bytes=100-50", 1000) {
2355 RangeOutcome::Unsatisfiable => {}
2356 other => panic!("expected Unsatisfiable, got {other:?}"),
2357 }
2358 }
2359
2360 #[test]
2361 fn multi_range_returns_multi_range_ignored() {
2362 match parse_range_header("bytes=0-99,200-299", 1000) {
2363 RangeOutcome::MultiRangeIgnored => {}
2364 other => panic!("expected MultiRangeIgnored, got {other:?}"),
2365 }
2366 }
2367
2368 #[test]
2369 fn zero_suffix_length_returns_unsatisfiable() {
2370 match parse_range_header("bytes=-0", 1000) {
2371 RangeOutcome::Unsatisfiable => {}
2372 other => panic!("expected Unsatisfiable, got {other:?}"),
2373 }
2374 }
2375
2376 #[test]
2377 fn if_range_valid_with_matching_etag() {
2378 assert!(if_range_valid("\"abc123\"", "\"abc123\""));
2379 }
2380
2381 #[test]
2382 fn if_range_valid_with_mismatched_etag() {
2383 assert!(!if_range_valid("\"abc123\"", "\"def456\""));
2384 }
2385
2386 #[test]
2387 fn if_range_valid_with_whitespace() {
2388 assert!(if_range_valid(" \"abc123\" ", "\"abc123\""));
2389 }
2390}