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::spa;
33use crate::tool;
34use crate::watcher::{start_watching, Broadcaster};
35
36const ACCEPT_BACKOFF_INITIAL: Duration = Duration::from_millis(10);
37const ACCEPT_BACKOFF_MAX: Duration = Duration::from_secs(1);
38
39const DEFAULT_MAX_CONNECTIONS: usize = 1024;
41
42trait TcpAccept {
46 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)>;
47}
48
49impl TcpAccept for TcpListener {
50 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
51 TcpListener::accept(self).await
52 }
53}
54
55async fn accept_and_permit<L: TcpAccept>(
66 listener: &L,
67 backoff: &mut Duration,
68 semaphore: &Arc<Semaphore>,
69) -> Option<(TcpStream, OwnedSemaphorePermit)> {
70 loop {
71 let stream = match listener.accept().await {
72 Ok((stream, _)) => {
73 *backoff = ACCEPT_BACKOFF_INITIAL;
74 stream
75 }
76 Err(_) => {
77 tokio::time::sleep(*backoff).await;
78 *backoff = (*backoff * 2).min(ACCEPT_BACKOFF_MAX);
79 continue;
80 }
81 };
82 return semaphore
83 .clone()
84 .acquire_owned()
85 .await
86 .ok()
87 .map(|permit| (stream, permit));
88 }
89}
90
91type ImmutablePredicate = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
94
95#[derive(Clone)]
128pub struct Server {
129 root_canon: PathBuf,
130 bundle_roots: Vec<PathBuf>,
131 max_connections: usize,
132 live_reload: bool,
133 broadcaster: Option<Broadcaster>,
134 spa_mode: bool,
135 spa_root: Option<String>,
136 immutable_predicate: Option<ImmutablePredicate>,
137 source_folders: Vec<PathBuf>,
138 asset_folders: Vec<PathBuf>,
139 output_dir: PathBuf,
140 css_tool: Option<(CssTool, CssOptions)>,
141 js_tool: Option<(JsTool, JsOptions)>,
142 prune_output: bool,
143}
144
145fn paths_overlap(a: &Path, b: &Path) -> bool {
152 a.starts_with(b) || b.starts_with(a)
153}
154
155impl Server {
156 pub fn new(root: &Path) -> Result<Self, StaticError> {
166 let root_canon = root.canonicalize().map_err(StaticError::Io)?;
167 let output_dir = root_canon.clone();
168 Ok(Server {
169 root_canon,
170 bundle_roots: Vec::new(),
171 max_connections: DEFAULT_MAX_CONNECTIONS,
172 live_reload: false,
173 broadcaster: None,
174 spa_mode: false,
175 spa_root: None,
176 immutable_predicate: None,
177 source_folders: Vec::new(),
178 asset_folders: Vec::new(),
179 output_dir,
180 css_tool: None,
181 js_tool: None,
182 prune_output: false,
183 })
184 }
185
186 pub fn with_max_connections(mut self, max: usize) -> Self {
194 self.max_connections = max;
195 self
196 }
197
198 pub fn with_live_reload(mut self) -> Self {
230 self.live_reload = true;
231 self
232 }
233
234 pub fn with_spa_mode(mut self) -> Self {
259 self.spa_mode = true;
260 self
261 }
262
263 pub fn with_spa_root(mut self, selector: &str) -> Self {
313 self.spa_mode = true;
314 self.spa_root = Some(selector.to_string());
315 self
316 }
317
318 pub fn with_immutable_assets<F>(mut self, predicate: F) -> Self
345 where
346 F: Fn(&Path) -> bool + Send + Sync + 'static,
347 {
348 self.immutable_predicate = Some(Arc::new(predicate));
349 self
350 }
351
352 fn cache_control_for(&self, path: &Path) -> &'static str {
355 match &self.immutable_predicate {
356 Some(predicate) if predicate(path) => "public, max-age=31536000, immutable",
357 _ => "no-cache",
358 }
359 }
360
361 pub fn with_bundle_root(mut self, path: &Path) -> Result<Self, StaticError> {
384 let canon = path.canonicalize().map_err(StaticError::Io)?;
385 self.bundle_roots.push(canon);
386 Ok(self)
387 }
388
389 pub fn with_source_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
403 let canon = dir.canonicalize().map_err(StaticError::Io)?;
404
405 if paths_overlap(&canon, &self.output_dir) {
406 return Err(StaticError::Traversal(format!(
407 "source folder {} overlaps the output dir {}",
408 canon.display(),
409 self.output_dir.display()
410 )));
411 }
412 if self
413 .source_folders
414 .iter()
415 .chain(self.asset_folders.iter())
416 .any(|existing| paths_overlap(&canon, existing))
417 {
418 return Err(StaticError::Traversal(format!(
419 "source folder {} overlaps an already-registered source/asset folder",
420 canon.display()
421 )));
422 }
423
424 self.source_folders.push(canon);
425 Ok(self)
426 }
427
428 pub fn with_asset_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
446 let canon = dir.canonicalize().map_err(StaticError::Io)?;
447
448 if paths_overlap(&canon, &self.output_dir) {
449 return Err(StaticError::Traversal(format!(
450 "asset folder {} overlaps the output dir {}",
451 canon.display(),
452 self.output_dir.display()
453 )));
454 }
455 if self
456 .source_folders
457 .iter()
458 .chain(self.asset_folders.iter())
459 .any(|existing| paths_overlap(&canon, existing))
460 {
461 return Err(StaticError::Traversal(format!(
462 "asset folder {} overlaps an already-registered source/asset folder",
463 canon.display()
464 )));
465 }
466
467 self.asset_folders.push(canon);
468 Ok(self)
469 }
470
471 pub fn with_output_dir(mut self, dir: &Path) -> Result<Self, StaticError> {
482 let canon = dir.canonicalize().map_err(StaticError::Io)?;
483
484 if self
485 .source_folders
486 .iter()
487 .chain(self.asset_folders.iter())
488 .any(|existing| paths_overlap(&canon, existing))
489 {
490 return Err(StaticError::Traversal(format!(
491 "output dir {} overlaps a registered source/asset folder",
492 canon.display()
493 )));
494 }
495
496 self.output_dir = canon;
497 Ok(self)
498 }
499
500 pub fn with_css_tool(mut self, tool: CssTool, options: CssOptions) -> Self {
528 self.css_tool = Some((tool, options));
529 self
530 }
531
532 pub fn with_js_tool(mut self, tool: JsTool, options: JsOptions) -> Result<Self, StaticError> {
568 if let Some(entry) = options.entry() {
569 let entry_canon = entry.canonicalize().map_err(StaticError::Io)?;
570 let under_source_folder = self
571 .source_folders
572 .iter()
573 .any(|folder| entry_canon.starts_with(folder));
574 if !under_source_folder {
575 return Err(StaticError::Traversal(format!(
576 "js bundle entry {} is not under any registered source folder",
577 entry_canon.display()
578 )));
579 }
580 }
581
582 self.js_tool = Some((tool, options));
583 Ok(self)
584 }
585
586 pub fn with_prune_output(mut self) -> Self {
590 self.prune_output = true;
591 self
592 }
593
594 fn has_pipeline(&self) -> bool {
597 self.css_tool.is_some()
598 || self.js_tool.is_some()
599 || !self.source_folders.is_empty()
600 || !self.asset_folders.is_empty()
601 }
602
603 fn required_tool_binaries(&self) -> Vec<(&'static str, &'static str)> {
608 let mut required = Vec::new();
609 if let Some((css_tool, options)) = &self.css_tool {
610 if options.is_bundle() || options.is_minify() {
611 required.push((css_tool.binary_name(), css_tool.install_hint()));
612 }
613 }
614 if let Some((js_tool, options)) = &self.js_tool {
615 if options.is_bundle() || options.is_minify() {
616 required.push((js_tool.binary_name(), js_tool.install_hint()));
617 }
618 }
619 required
620 }
621
622 fn watch_targets(&self) -> Vec<PathBuf> {
626 let mut targets = Vec::new();
627 for dir in self
628 .source_folders
629 .iter()
630 .chain(self.bundle_roots.iter())
631 .chain(self.asset_folders.iter())
632 {
633 if !targets.contains(dir) {
634 targets.push(dir.clone());
635 }
636 }
637 targets
638 }
639
640 pub async fn build(&self) -> Result<(), StaticError> {
668 for (binary, install_hint) in self.required_tool_binaries() {
669 if !tool::locate_on_path(binary) {
670 return Err(StaticError::PipelineSetup(format!(
671 "{binary} not found on PATH ({install_hint})"
672 )));
673 }
674 }
675
676 let pipeline = SourcePipeline::new(
677 self.source_folders.clone(),
678 self.bundle_roots.clone(),
679 self.asset_folders.clone(),
680 self.output_dir.clone(),
681 self.css_tool.clone(),
682 self.js_tool.clone(),
683 self.prune_output,
684 Broadcaster::new(),
685 );
686 pipeline
687 .full_build()
688 .await
689 .map_err(|e| StaticError::Build(e.to_string()))
690 }
691
692 pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
702 resolve::resolve_with_canonical_root(&self.root_canon, request_path)
703 }
704
705 pub async fn run_on(
744 &self,
745 addr: SocketAddr,
746 header_timeout: Duration,
747 ) -> Result<(u16, ServerHandle), StaticError> {
748 for (binary, install_hint) in self.required_tool_binaries() {
749 if !tool::locate_on_path(binary) {
750 return Err(StaticError::PipelineSetup(format!(
751 "{binary} not found on PATH ({install_hint})"
752 )));
753 }
754 }
755
756 let listener = TcpListener::bind(addr).await.map_err(StaticError::Io)?;
757 let port = listener.local_addr().map_err(StaticError::Io)?.port();
758
759 let mut server = self.clone();
760 if server.live_reload {
761 let broadcaster = Broadcaster::new();
762
763 if server.has_pipeline() {
767 let pipeline = Arc::new(SourcePipeline::new(
768 server.source_folders.clone(),
769 server.bundle_roots.clone(),
770 server.asset_folders.clone(),
771 server.output_dir.clone(),
772 server.css_tool.clone(),
773 server.js_tool.clone(),
774 server.prune_output,
775 broadcaster.clone(),
776 ));
777 let mut rx = broadcaster.subscribe();
778 tokio::spawn(async move {
779 if let Err(e) = pipeline.full_build().await {
782 eprintln!("source pipeline build error: {e}");
783 }
784 while let Some(event) = rx.recv().await {
785 if let Err(e) = pipeline
786 .process_change(&event.path, &event.change_type)
787 .await
788 {
789 eprintln!("source pipeline error: {e}");
790 }
791 }
792 });
793 }
794
795 for dir in server.watch_targets() {
796 start_watching(Arc::new(dir), broadcaster.clone());
797 }
798
799 server.broadcaster = Some(broadcaster);
800 } else if server.has_pipeline() {
801 let pipeline = Arc::new(SourcePipeline::new(
805 server.source_folders.clone(),
806 server.bundle_roots.clone(),
807 server.asset_folders.clone(),
808 server.output_dir.clone(),
809 server.css_tool.clone(),
810 server.js_tool.clone(),
811 server.prune_output,
812 Broadcaster::new(),
813 ));
814 tokio::spawn(async move {
815 if let Err(e) = pipeline.full_build().await {
816 eprintln!("source pipeline build error: {e}");
817 }
818 });
819 }
820 let semaphore = Arc::new(Semaphore::new(server.max_connections));
821 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
822
823 let accept_task = tokio::spawn(async move {
824 let mut backoff = ACCEPT_BACKOFF_INITIAL;
825 let mut join_set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
826 let mut shutdown_pin = std::pin::pin!(shutdown_rx);
827 let mut shutting_down = false;
828
829 loop {
830 if !shutting_down {
831 tokio::select! {
835 accepted = accept_and_permit(&listener, &mut backoff, &semaphore) => {
836 match accepted {
837 Some((stream, permit)) => {
838 let server = server.clone();
839 join_set.spawn(async move {
840 let _permit = permit;
841 serve_connection(stream, server, header_timeout).await;
842 });
843 }
844 None => shutting_down = true,
845 }
846 }
847 _ = shutdown_pin.as_mut() => {
848 shutting_down = true;
849 }
850 }
851 continue;
852 }
853
854 match join_set.join_next().await {
856 Some(_) => continue,
857 None => break,
858 }
859 }
860 });
861
862 Ok((
863 port,
864 ServerHandle {
865 shutdown_tx: Some(shutdown_tx),
866 accept_task,
867 },
868 ))
869 }
870
871 pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
876 self.run_on(([127, 0, 0, 1], 0).into(), header_timeout)
877 .await
878 }
879
880 pub async fn run_all(
886 &self,
887 port: u16,
888 header_timeout: Duration,
889 ) -> Result<(u16, ServerHandle), StaticError> {
890 self.run_on(([0, 0, 0, 0], port).into(), header_timeout)
891 .await
892 }
893
894 pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
914 self.run(DEFAULT_HEADER_TIMEOUT).await
915 }
916
917 pub async fn handle_request(
934 &self,
935 method: &Method,
936 request_path: &str,
937 headers: &HeaderMap,
938 ) -> Response<ResponseBody> {
939 if method != Method::GET && method != Method::HEAD {
940 return text(
941 response(StatusCode::METHOD_NOT_ALLOWED).header("Allow", "GET, HEAD"),
942 "method not allowed\n",
943 );
944 }
945
946 if *method == Method::GET && request_path == reload::LIVE_RELOAD_PATH {
950 if let Some(broadcaster) = &self.broadcaster {
951 return finish(
952 response(StatusCode::OK)
953 .header("Content-Type", "text/event-stream")
954 .header("Cache-Control", "no-cache")
955 .header("Connection", "keep-alive")
956 .body(ResponseBody::Sse(SseBody::new(broadcaster.subscribe()))),
957 );
958 }
959 }
960
961 let server = self.clone();
967 let owned_request_path = request_path.to_string();
968 let resolved =
969 tokio::task::spawn_blocking(move || server.resolve(&owned_request_path)).await;
970 let path = match resolved {
971 Err(_) => return internal_error_response(),
972 Ok(Err(e)) => {
973 return text(
974 response(StatusCode::NOT_FOUND),
975 format!("{}\n", e.user_message()),
976 )
977 }
978 Ok(Ok(path)) => path,
979 };
980
981 let decoded_request_path = resolve::decode_request_path(request_path);
987 if path.file_name().is_some_and(|name| name == "index.html")
988 && !decoded_request_path.ends_with('/')
989 && !decoded_request_path.ends_with("index.html")
990 {
991 let location = format!("{}/", request_path.trim_end_matches('/'));
995 return text(
996 response(StatusCode::MOVED_PERMANENTLY).header("Location", location),
997 "moved\n",
998 );
999 }
1000
1001 let Ok(file) = File::open(&path).await else {
1002 return internal_error_response();
1003 };
1004 let Ok(metadata) = file.metadata().await else {
1005 return internal_error_response();
1006 };
1007
1008 let content_type = mime_type_for_path(&path);
1009 let html_injection =
1016 (self.broadcaster.is_some() || self.spa_mode) && content_type.starts_with("text/html");
1017
1018 let range_header = header_str(headers, "range");
1019 let if_range_header = header_str(headers, "if-range");
1020
1021 let accept_encoding = header_str(headers, "accept-encoding");
1022 let sidecar = if html_injection || range_header.is_some() {
1024 None
1025 } else {
1026 select_precompressed_sidecar(&path, accept_encoding).await
1027 };
1028 let (mut file, metadata, content_encoding) = match sidecar {
1029 Some((sidecar_file, sidecar_metadata, encoding)) => {
1030 (sidecar_file, sidecar_metadata, Some(encoding))
1031 }
1032 None => (file, metadata, None),
1033 };
1034
1035 let etag = generate_etag(&metadata);
1038 let cache_control = self.cache_control_for(&path);
1039
1040 if header_str(headers, "if-none-match").is_some_and(|value| is_etag_match(value, &etag)) {
1041 return finish(
1042 Response::builder()
1043 .status(StatusCode::NOT_MODIFIED)
1044 .header("Cache-Control", cache_control)
1045 .header("Vary", "Accept-Encoding")
1046 .header("ETag", etag)
1047 .header("Accept-Ranges", "bytes")
1048 .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1049 );
1050 }
1051
1052 let transformed: Option<Bytes> = if html_injection {
1058 let mut html = Vec::with_capacity(metadata.len() as usize);
1059 if file.read_to_end(&mut html).await.is_err() {
1060 return internal_error_response();
1061 }
1062 if self.broadcaster.is_some() {
1063 reload::inject_reload_script(&mut html);
1064 }
1065 if self.spa_mode {
1066 spa::inject_spa_script(&mut html, self.spa_root.as_deref());
1067 }
1068 Some(Bytes::from(html))
1069 } else {
1070 None
1071 };
1072
1073 let file_size = transformed
1074 .as_ref()
1075 .map_or(metadata.len(), |bytes| bytes.len() as u64);
1076
1077 let range_outcome = range_header.map(|h| parse_range_header(h, file_size));
1079 let range_check = if let Some(outcome) = &range_outcome {
1080 match outcome {
1081 RangeOutcome::Satisfiable(start, end) => {
1082 if let Some(if_range) = if_range_header {
1084 if !if_range_valid(if_range, &etag) {
1085 RangeCheck::IgnoreRange
1086 } else {
1087 RangeCheck::Satisfiable(*start, *end)
1088 }
1089 } else {
1090 RangeCheck::Satisfiable(*start, *end)
1091 }
1092 }
1093 RangeOutcome::MultiRangeIgnored => RangeCheck::IgnoreRange,
1094 RangeOutcome::Unsatisfiable => RangeCheck::Unsatisfiable,
1095 RangeOutcome::NoRange => RangeCheck::IgnoreRange,
1096 }
1097 } else {
1098 RangeCheck::IgnoreRange
1099 };
1100
1101 match &range_check {
1102 RangeCheck::Unsatisfiable => {
1103 return finish(
1104 Response::builder()
1105 .status(StatusCode::RANGE_NOT_SATISFIABLE)
1106 .header("Content-Range", format!("bytes */{}", file_size))
1107 .header("Accept-Ranges", "bytes")
1108 .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1109 );
1110 }
1111 RangeCheck::Satisfiable(start, end) => {
1112 let range_len = end - start + 1;
1113
1114 if transformed.is_none() {
1116 if file.seek(std::io::SeekFrom::Start(*start)).await.is_err() {
1117 return internal_error_response();
1118 }
1119 }
1120
1121 let body = if *method == Method::HEAD {
1123 ResponseBody::Buffered(Full::new(Bytes::new()))
1124 } else {
1125 match transformed {
1126 Some(ref bytes) => ResponseBody::Buffered(Full::new(
1127 bytes.slice(*start as usize..(*end as usize + 1)),
1128 )),
1129 None => ResponseBody::Streamed(FileBody::new_ranged(file, range_len)),
1130 }
1131 };
1132
1133 let mut builder = Response::builder()
1134 .status(StatusCode::PARTIAL_CONTENT)
1135 .header("Content-Type", content_type)
1136 .header("Content-Length", range_len.to_string())
1137 .header(
1138 "Content-Range",
1139 format!("bytes {}-{}/{}", start, end, file_size),
1140 )
1141 .header("Cache-Control", cache_control)
1142 .header("Vary", "Accept-Encoding")
1143 .header("ETag", etag)
1144 .header("Accept-Ranges", "bytes");
1145 if let Some(encoding) = content_encoding {
1146 builder = builder.header("Content-Encoding", encoding);
1147 }
1148 return finish(builder.body(body));
1149 }
1150 RangeCheck::IgnoreRange => {}
1151 }
1152
1153 let body = if *method == Method::HEAD {
1155 ResponseBody::Buffered(Full::new(Bytes::new()))
1156 } else {
1157 match transformed {
1158 Some(bytes) => ResponseBody::Buffered(Full::new(bytes)),
1159 None => ResponseBody::Streamed(FileBody::new(file)),
1160 }
1161 };
1162
1163 let mut builder = response(StatusCode::OK)
1164 .header("Content-Type", content_type)
1165 .header("Content-Length", file_size.to_string())
1166 .header("Cache-Control", cache_control)
1167 .header("Vary", "Accept-Encoding")
1168 .header("ETag", etag)
1169 .header("Accept-Ranges", "bytes");
1170 if let Some(encoding) = content_encoding {
1171 builder = builder.header("Content-Encoding", encoding);
1172 }
1173 finish(builder.body(body))
1174 }
1175}
1176
1177const MAX_HEADER_BYTES: usize = 64 * 1024;
1183
1184#[derive(Debug)]
1188enum HeaderReadError {
1189 ConnectionClosed,
1192 TooLarge,
1194 #[allow(dead_code)]
1197 Io(std::io::Error),
1198}
1199
1200async fn read_header_prefix(stream: &mut TcpStream) -> Result<Vec<u8>, HeaderReadError> {
1207 let mut buf = Vec::new();
1208 let mut chunk = [0u8; 4096];
1209
1210 loop {
1211 let n = stream.read(&mut chunk).await.map_err(HeaderReadError::Io)?;
1212 if n == 0 {
1213 return Err(HeaderReadError::ConnectionClosed);
1214 }
1215 buf.extend_from_slice(&chunk[..n]);
1216
1217 if buf.len() > MAX_HEADER_BYTES {
1218 return Err(HeaderReadError::TooLarge);
1219 }
1220 let scan_from = buf.len().saturating_sub(n + 3);
1224 if buf[scan_from..].windows(4).any(|w| w == b"\r\n\r\n") {
1225 return Ok(buf);
1226 }
1227 }
1228}
1229
1230struct PrefixedIo {
1236 prefix: Bytes,
1237 prefix_pos: usize,
1238 inner: TcpStream,
1239}
1240
1241impl PrefixedIo {
1242 fn new(prefix: Vec<u8>, inner: TcpStream) -> Self {
1243 PrefixedIo {
1244 prefix: Bytes::from(prefix),
1245 prefix_pos: 0,
1246 inner,
1247 }
1248 }
1249}
1250
1251impl AsyncRead for PrefixedIo {
1252 fn poll_read(
1253 self: Pin<&mut Self>,
1254 cx: &mut Context<'_>,
1255 buf: &mut ReadBuf<'_>,
1256 ) -> Poll<std::io::Result<()>> {
1257 let this = self.get_mut();
1258 if this.prefix_pos < this.prefix.len() {
1259 let remaining = &this.prefix[this.prefix_pos..];
1260 let n = remaining.len().min(buf.remaining());
1261 buf.put_slice(&remaining[..n]);
1262 this.prefix_pos += n;
1263 return Poll::Ready(Ok(()));
1264 }
1265 Pin::new(&mut this.inner).poll_read(cx, buf)
1266 }
1267}
1268
1269impl AsyncWrite for PrefixedIo {
1270 fn poll_write(
1271 self: Pin<&mut Self>,
1272 cx: &mut Context<'_>,
1273 buf: &[u8],
1274 ) -> Poll<std::io::Result<usize>> {
1275 Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
1276 }
1277
1278 fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1279 Pin::new(&mut self.get_mut().inner).poll_flush(cx)
1280 }
1281
1282 fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1283 Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
1284 }
1285}
1286
1287async fn serve_connection(mut stream: TcpStream, server: Server, header_timeout: Duration) {
1301 let prefix = match timeout(header_timeout, read_header_prefix(&mut stream)).await {
1302 Ok(Ok(prefix)) => prefix,
1303 Ok(Err(_)) | Err(_) => return,
1304 };
1305
1306 let io = TokioIo::new(PrefixedIo::new(prefix, stream));
1307 let svc = service_fn(move |req: Request<Incoming>| {
1308 let server = server.clone();
1309 async move {
1310 let resp = server
1311 .handle_request(req.method(), req.uri().path(), req.headers())
1312 .await;
1313 Ok::<_, Infallible>(resp)
1314 }
1315 });
1316 let _ = AutoBuilder::new(TokioExecutor::new())
1317 .serve_connection(io, svc)
1318 .await;
1319}
1320
1321const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(30);
1323
1324const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
1331
1332pub struct ServerHandle {
1338 shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
1339 accept_task: tokio::task::JoinHandle<()>,
1340}
1341
1342impl ServerHandle {
1343 pub async fn shutdown(self) {
1348 self.shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
1349 .await;
1350 }
1351
1352 pub async fn shutdown_with_timeout(mut self, drain_timeout: Duration) {
1363 if let Some(tx) = self.shutdown_tx.take() {
1364 let _ = tx.send(());
1365 }
1366 if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
1367 self.accept_task.abort();
1368 }
1369 }
1370}
1371
1372fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
1374 headers.get(name).and_then(|value| value.to_str().ok())
1375}
1376
1377fn response(status: StatusCode) -> Builder {
1381 Response::builder()
1382 .status(status)
1383 .header("X-Content-Type-Options", "nosniff")
1384}
1385
1386fn text(builder: Builder, body: impl Into<Bytes>) -> Response<ResponseBody> {
1389 finish(builder.body(ResponseBody::Buffered(Full::new(body.into()))))
1390}
1391
1392fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
1403 built.unwrap_or_else(|_| bad_request_response())
1404}
1405
1406fn internal_error_response() -> Response<ResponseBody> {
1411 response(StatusCode::INTERNAL_SERVER_ERROR)
1412 .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1413 b"internal server error\n",
1414 ))))
1415 .unwrap()
1416}
1417
1418fn bad_request_response() -> Response<ResponseBody> {
1419 response(StatusCode::BAD_REQUEST)
1420 .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1421 b"bad request\n",
1422 ))))
1423 .unwrap()
1424}
1425
1426const SIDECAR_ENCODINGS: [(&str, &str); 2] = [("br", ".br"), ("gzip", ".gz")];
1430
1431fn accepts_encoding(accept_encoding: Option<&str>, encoding: &str) -> bool {
1437 accept_encoding.is_some_and(|header| header.contains(encoding))
1438}
1439
1440async fn select_precompressed_sidecar(
1448 path: &Path,
1449 accept_encoding: Option<&str>,
1450) -> Option<(File, fs::Metadata, &'static str)> {
1451 for (encoding, ext) in SIDECAR_ENCODINGS {
1452 if !accepts_encoding(accept_encoding, encoding) {
1453 continue;
1454 }
1455 let mut sidecar = path.as_os_str().to_os_string();
1456 sidecar.push(ext);
1457 let sidecar_path = PathBuf::from(sidecar);
1458
1459 debug_assert_eq!(
1465 sidecar_path.parent(),
1466 path.parent(),
1467 "sidecar path must stay in the same directory as the already-resolved path"
1468 );
1469
1470 if let Ok(sidecar_file) = File::open(&sidecar_path).await {
1471 if let Ok(sidecar_metadata) = sidecar_file.metadata().await {
1472 return Some((sidecar_file, sidecar_metadata, encoding));
1473 }
1474 }
1475 }
1476 None
1477}
1478
1479fn generate_etag(metadata: &fs::Metadata) -> String {
1483 let mtime = metadata
1484 .modified()
1485 .ok()
1486 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1487 .map(|d| d.as_secs())
1488 .unwrap_or(0);
1489 format!("\"{}-{}\"", metadata.len(), mtime)
1490}
1491
1492fn mime_type_for_path(path: &Path) -> &'static str {
1494 let ext = path
1495 .extension()
1496 .and_then(|ext| ext.to_str())
1497 .unwrap_or_default()
1498 .to_lowercase();
1499
1500 match ext.as_str() {
1501 "html" | "htm" => "text/html; charset=utf-8",
1502 "css" => "text/css; charset=utf-8",
1503 "js" => "application/javascript; charset=utf-8",
1504 "json" => "application/json; charset=utf-8",
1505 "svg" => "image/svg+xml",
1506 "png" => "image/png",
1507 "jpg" | "jpeg" => "image/jpeg",
1508 "gif" => "image/gif",
1509 "webp" => "image/webp",
1510 "ico" => "image/x-icon",
1511 "woff" => "font/woff",
1512 "woff2" => "font/woff2",
1513 "ttf" => "font/ttf",
1514 "md" | "markdown" => "text/markdown; charset=utf-8",
1515 "txt" => "text/plain; charset=utf-8",
1516 "xml" => "application/xml",
1517 "pdf" => "application/pdf",
1518 "zip" => "application/zip",
1519 _ => "application/octet-stream",
1520 }
1521}
1522
1523fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
1526 if if_none_match == "*" {
1527 return true;
1528 }
1529 if_none_match.split(',').any(|tag| tag.trim() == etag)
1530}
1531
1532#[derive(Debug)]
1533enum RangeOutcome {
1534 NoRange,
1535 Satisfiable(u64, u64),
1536 Unsatisfiable,
1537 MultiRangeIgnored,
1538}
1539
1540enum RangeCheck {
1541 IgnoreRange,
1542 Satisfiable(u64, u64),
1543 Unsatisfiable,
1544}
1545
1546fn parse_range_header(header: &str, file_size: u64) -> RangeOutcome {
1547 let header = header.trim();
1548 if !header.starts_with("bytes=") {
1549 return RangeOutcome::NoRange;
1550 }
1551
1552 let range_spec = &header[6..];
1553
1554 if range_spec.contains(',') {
1555 return RangeOutcome::MultiRangeIgnored;
1556 }
1557
1558 if let Some(suffix_pos) = range_spec.find('-') {
1559 if suffix_pos == 0 {
1560 let suffix_len_str = &range_spec[1..];
1561 if let Ok(suffix_len) = suffix_len_str.parse::<u64>() {
1562 if suffix_len == 0 {
1563 return RangeOutcome::Unsatisfiable;
1564 }
1565 if suffix_len >= file_size {
1566 return RangeOutcome::Satisfiable(0, file_size - 1);
1567 }
1568 return RangeOutcome::Satisfiable(file_size - suffix_len, file_size - 1);
1569 }
1570 return RangeOutcome::Unsatisfiable;
1571 }
1572
1573 let start_str = &range_spec[..suffix_pos];
1574 let end_str = &range_spec[suffix_pos + 1..];
1575
1576 if let Ok(start) = start_str.parse::<u64>() {
1577 if start >= file_size {
1578 return RangeOutcome::Unsatisfiable;
1579 }
1580
1581 if end_str.is_empty() {
1582 return RangeOutcome::Satisfiable(start, file_size - 1);
1583 }
1584
1585 if let Ok(end) = end_str.parse::<u64>() {
1586 if end < start {
1587 return RangeOutcome::Unsatisfiable;
1588 }
1589 let clamped_end = (end + 1).min(file_size) - 1;
1590 if start > clamped_end {
1591 return RangeOutcome::Unsatisfiable;
1592 }
1593 return RangeOutcome::Satisfiable(start, clamped_end);
1594 }
1595 }
1596 }
1597
1598 RangeOutcome::Unsatisfiable
1599}
1600
1601fn if_range_valid(if_range_header: &str, current_etag: &str) -> bool {
1602 if_range_header.trim() == current_etag
1603}
1604
1605#[cfg(test)]
1606mod precompressed_sidecar_tests {
1607 use super::*;
1608
1609 #[tokio::test]
1616 async fn sidecar_never_leaves_the_resolved_files_directory() {
1617 let root = tempfile::TempDir::new().unwrap();
1618 let sub = root.path().join("assets");
1619 fs::create_dir(&sub).unwrap();
1620 let resolved = sub.join("app.js");
1621 fs::write(&resolved, b"plain").unwrap();
1622 fs::write(sub.join("app.js.br"), b"brotli-bytes").unwrap();
1623 fs::write(sub.join("app.js.gz"), b"gzip-bytes").unwrap();
1624
1625 let (_, _, encoding) = select_precompressed_sidecar(&resolved, Some("br, gzip"))
1626 .await
1627 .expect("both sidecars present, br should be preferred");
1628 assert_eq!(
1629 encoding, "br",
1630 "br must be preferred over gzip when both are accepted"
1631 );
1632
1633 let (_, _, encoding) = select_precompressed_sidecar(&resolved, Some("gzip"))
1634 .await
1635 .expect("gzip sidecar present");
1636 assert_eq!(encoding, "gzip");
1637
1638 assert!(
1639 select_precompressed_sidecar(&resolved, None)
1640 .await
1641 .is_none(),
1642 "no Accept-Encoding header should never select a sidecar"
1643 );
1644 }
1645
1646 #[test]
1647 fn accepts_encoding_matches_only_listed_directives() {
1648 assert!(!accepts_encoding(None, "br"));
1649 assert!(!accepts_encoding(Some("identity"), "br"));
1650 assert!(!accepts_encoding(Some("identity"), "gzip"));
1651 assert!(accepts_encoding(Some("gzip, br"), "br"));
1652 assert!(accepts_encoding(Some("gzip"), "gzip"));
1653 assert!(!accepts_encoding(Some("gzip"), "br"));
1654 }
1655}
1656
1657#[cfg(test)]
1658mod file_body_tests {
1659 use super::*;
1660 use crate::handler::FILE_CHUNK_SIZE;
1661 use http_body_util::BodyExt;
1662
1663 #[tokio::test]
1668 async fn file_body_yields_multiple_bounded_chunks_not_one_buffered_frame() {
1669 let dir = tempfile::TempDir::new().unwrap();
1670 let path = dir.path().join("big.bin");
1671 let content = vec![7u8; FILE_CHUNK_SIZE * 3 + 12_345];
1672 fs::write(&path, &content).unwrap();
1673
1674 let file = File::open(&path).await.unwrap();
1675 let mut body = FileBody::new(file);
1676
1677 let mut frame_count = 0usize;
1678 let mut max_frame_len = 0usize;
1679 let mut reassembled = Vec::new();
1680
1681 while let Some(frame) = body.frame().await {
1682 let frame = frame.unwrap();
1683 let data = frame.into_data().unwrap();
1684 frame_count += 1;
1685 max_frame_len = max_frame_len.max(data.len());
1686 reassembled.extend_from_slice(&data);
1687 }
1688
1689 assert!(
1690 frame_count > 1,
1691 "expected the file to be delivered as multiple frames, got {frame_count}"
1692 );
1693 assert!(
1694 max_frame_len <= FILE_CHUNK_SIZE,
1695 "no single frame should exceed the chunk size ({FILE_CHUNK_SIZE}), got {max_frame_len}"
1696 );
1697 assert_eq!(
1698 reassembled, content,
1699 "reassembled chunks must match original file content exactly"
1700 );
1701 }
1702}
1703
1704#[cfg(test)]
1705mod accept_tests {
1706 use super::*;
1707 use std::sync::atomic::{AtomicUsize, Ordering};
1708 use std::sync::Mutex;
1709
1710 struct FlakyListener {
1714 inner: TcpListener,
1715 remaining_failures: AtomicUsize,
1716 attempts: Mutex<Vec<tokio::time::Instant>>,
1717 }
1718
1719 impl TcpAccept for FlakyListener {
1720 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
1721 self.attempts
1722 .lock()
1723 .unwrap()
1724 .push(tokio::time::Instant::now());
1725 if self.remaining_failures.fetch_sub(1, Ordering::SeqCst) > 0 {
1726 Err(std::io::Error::other("simulated accept error"))
1727 } else {
1728 TcpAccept::accept(&self.inner).await
1729 }
1730 }
1731 }
1732
1733 #[tokio::test(start_paused = true)]
1739 async fn accept_loop_backs_off_between_repeated_errors_instead_of_busy_spinning() {
1740 let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1741 let addr = inner.local_addr().unwrap();
1742
1743 let flaky = FlakyListener {
1744 inner,
1745 remaining_failures: AtomicUsize::new(5),
1746 attempts: Mutex::new(Vec::new()),
1747 };
1748
1749 tokio::spawn(async move {
1750 let _ = TcpStream::connect(addr).await;
1751 });
1752
1753 let semaphore = Arc::new(Semaphore::new(1));
1754 let mut backoff = ACCEPT_BACKOFF_INITIAL;
1755 let result = accept_and_permit(&flaky, &mut backoff, &semaphore).await;
1756 assert!(
1757 result.is_some(),
1758 "accept should eventually succeed once the flaky listener stops failing"
1759 );
1760
1761 let recorded = flaky.attempts.lock().unwrap();
1762 assert_eq!(recorded.len(), 6, "5 failures then 1 success");
1763
1764 let expected_gaps = [
1765 ACCEPT_BACKOFF_INITIAL,
1766 ACCEPT_BACKOFF_INITIAL * 2,
1767 ACCEPT_BACKOFF_INITIAL * 4,
1768 ACCEPT_BACKOFF_INITIAL * 8,
1769 ACCEPT_BACKOFF_INITIAL * 16,
1770 ];
1771 for (i, expected) in expected_gaps.iter().enumerate() {
1772 let gap = recorded[i + 1] - recorded[i];
1773 assert_eq!(
1774 gap,
1775 *expected,
1776 "gap between attempt {i} and {} should reflect the backoff delay, not a busy spin",
1777 i + 1
1778 );
1779 }
1780
1781 let mut capped = ACCEPT_BACKOFF_MAX;
1783 capped = (capped * 2).min(ACCEPT_BACKOFF_MAX);
1784 assert_eq!(capped, ACCEPT_BACKOFF_MAX);
1785 }
1786
1787 #[tokio::test(start_paused = true)]
1790 async fn a_successful_accept_resets_the_backoff() {
1791 let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1792 let addr = inner.local_addr().unwrap();
1793 let flaky = FlakyListener {
1794 inner,
1795 remaining_failures: AtomicUsize::new(3),
1796 attempts: Mutex::new(Vec::new()),
1797 };
1798 tokio::spawn(async move {
1799 let _ = TcpStream::connect(addr).await;
1800 });
1801
1802 let semaphore = Arc::new(Semaphore::new(1));
1803 let mut backoff = ACCEPT_BACKOFF_INITIAL * 32;
1804 accept_and_permit(&flaky, &mut backoff, &semaphore).await;
1805
1806 assert_eq!(
1807 backoff, ACCEPT_BACKOFF_INITIAL,
1808 "the delay must return to its initial value once an accept succeeds"
1809 );
1810 }
1811}
1812
1813#[cfg(test)]
1814mod finish_tests {
1815 use super::*;
1816
1817 #[test]
1824 fn finish_degrades_to_400_on_invalid_header_value_instead_of_panicking() {
1825 let built = Response::builder()
1826 .status(StatusCode::OK)
1827 .header("X-Test", "invalid\r\nvalue")
1828 .body(ResponseBody::Buffered(Full::new(Bytes::new())));
1829 assert!(
1830 built.is_err(),
1831 "CR/LF in a header value should be rejected by the builder"
1832 );
1833
1834 let response = finish(built);
1835 assert_eq!(
1836 response.status(),
1837 StatusCode::BAD_REQUEST,
1838 "finish() should degrade to 400 rather than panicking on an invalid header value"
1839 );
1840 }
1841}
1842
1843#[cfg(test)]
1844mod header_prefix_tests {
1845 use super::*;
1846 use tokio::io::AsyncWriteExt;
1847
1848 async fn connected_pair() -> (TcpStream, TcpStream) {
1852 let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1853 let addr = listener.local_addr().unwrap();
1854 let client = TcpStream::connect(addr).await.unwrap();
1855 let (server_side, _) = listener.accept().await.unwrap();
1856 (server_side, client)
1857 }
1858
1859 #[tokio::test]
1860 async fn reads_exactly_up_to_and_including_the_terminating_blank_line() {
1861 let (mut server_side, mut client) = connected_pair().await;
1862
1863 client
1864 .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
1865 .await
1866 .unwrap();
1867
1868 let prefix = read_header_prefix(&mut server_side)
1869 .await
1870 .unwrap_or_else(|_| {
1871 panic!("expected a complete header block to be read");
1872 });
1873
1874 assert_eq!(prefix, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
1875 }
1876
1877 #[tokio::test]
1883 async fn assembles_a_header_block_split_across_multiple_writes() {
1884 let (mut server_side, mut client) = connected_pair().await;
1885
1886 client
1887 .write_all(b"GET /page HTTP/1.1\r\nHost: localhost\r")
1888 .await
1889 .unwrap();
1890 client.write_all(b"\n\r\n").await.unwrap();
1891
1892 let prefix = read_header_prefix(&mut server_side)
1893 .await
1894 .unwrap_or_else(|_| {
1895 panic!("expected a complete header block to be read across multiple writes");
1896 });
1897
1898 assert_eq!(prefix, b"GET /page HTTP/1.1\r\nHost: localhost\r\n\r\n");
1899 }
1900
1901 #[tokio::test]
1905 async fn preserves_bytes_sent_past_the_header_block() {
1906 let (mut server_side, mut client) = connected_pair().await;
1907
1908 let first = b"GET /a HTTP/1.1\r\nHost: localhost\r\n\r\n";
1909 let second = b"GET /b HTTP/1.1\r\nHost: localhost\r\n\r\n";
1910 let mut sent = Vec::new();
1911 sent.extend_from_slice(first);
1912 sent.extend_from_slice(second);
1913 client.write_all(&sent).await.unwrap();
1914
1915 let prefix = read_header_prefix(&mut server_side)
1916 .await
1917 .unwrap_or_else(|_| {
1918 panic!("expected a complete header block to be read");
1919 });
1920
1921 assert_eq!(
1922 &prefix, &sent,
1923 "pipelined bytes past the first header block must survive intact"
1924 );
1925 }
1926
1927 #[tokio::test]
1928 async fn errors_with_connection_closed_when_client_disconnects_before_headers_complete() {
1929 let (mut server_side, client) = connected_pair().await;
1930 drop(client);
1931
1932 match read_header_prefix(&mut server_side).await {
1933 Err(HeaderReadError::ConnectionClosed) => {}
1934 Err(_) => panic!("expected ConnectionClosed, got a different error variant"),
1935 Ok(_) => {
1936 panic!("expected an error, got a complete header block from a closed connection")
1937 }
1938 }
1939 }
1940
1941 #[tokio::test]
1945 async fn errors_with_too_large_once_max_header_bytes_is_exceeded_without_a_terminator() {
1946 let (mut server_side, mut client) = connected_pair().await;
1947
1948 let garbage = vec![b'a'; MAX_HEADER_BYTES + 1];
1949 client.write_all(&garbage).await.unwrap();
1950
1951 match read_header_prefix(&mut server_side).await {
1952 Err(HeaderReadError::TooLarge) => {}
1953 Err(_) => panic!("expected TooLarge, got a different error variant"),
1954 Ok(_) => {
1955 panic!("expected an error, got a complete header block from unterminated garbage")
1956 }
1957 }
1958 }
1959
1960 #[tokio::test]
1961 async fn prefixed_io_replays_the_prefix_before_reading_from_the_live_socket() {
1962 let (server_side, mut client) = connected_pair().await;
1963 let mut io = PrefixedIo::new(b"buffered-prefix".to_vec(), server_side);
1964
1965 client.write_all(b"-live-bytes").await.unwrap();
1966
1967 let mut collected = Vec::new();
1968 let mut chunk = [0u8; 8];
1969 while collected.len() < b"buffered-prefix-live-bytes".len() {
1970 let n = io.read(&mut chunk).await.unwrap();
1971 assert!(n > 0, "read returned 0 before all expected bytes arrived");
1972 collected.extend_from_slice(&chunk[..n]);
1973 }
1974
1975 assert_eq!(collected, b"buffered-prefix-live-bytes");
1976 }
1977}
1978
1979#[cfg(test)]
1980mod css_bundle_tests {
1981 use super::*;
1982 use std::fs;
1983 use std::time::Duration;
1984 use tempfile::TempDir;
1985 use tokio::time::sleep;
1986
1987 #[tokio::test]
1988 async fn source_folder_overlapping_output_dir_is_rejected() {
1989 let root = TempDir::new().unwrap();
1990
1991 let result = Server::new(root.path())
1995 .unwrap()
1996 .with_source_folder(root.path());
1997 assert!(
1998 result.is_err(),
1999 "a source folder equal to the output dir must be rejected"
2000 );
2001 }
2002
2003 #[tokio::test]
2004 async fn source_folder_inside_output_dir_is_rejected() {
2005 let root = TempDir::new().unwrap();
2006 let nested = root.path().join("nested");
2007 fs::create_dir(&nested).unwrap();
2008
2009 let result = Server::new(root.path())
2010 .unwrap()
2011 .with_source_folder(&nested);
2012 assert!(
2013 result.is_err(),
2014 "a source folder nested in the output dir must be rejected"
2015 );
2016 }
2017
2018 #[tokio::test]
2019 async fn output_dir_overlapping_source_folder_is_rejected() {
2020 let root = TempDir::new().unwrap();
2021 let source = TempDir::new().unwrap();
2022
2023 let server = Server::new(root.path())
2024 .unwrap()
2025 .with_source_folder(source.path())
2026 .unwrap();
2027
2028 let result = server.with_output_dir(source.path());
2029 assert!(
2030 result.is_err(),
2031 "an output dir equal to a source folder must be rejected"
2032 );
2033 }
2034
2035 #[tokio::test]
2036 async fn css_bundle_creates_output_on_startup_with_live_reload() {
2037 let src = TempDir::new().unwrap();
2038 let out = TempDir::new().unwrap();
2039
2040 fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();
2041
2042 let server = Server::new(out.path())
2043 .unwrap()
2044 .with_live_reload()
2045 .with_source_folder(src.path())
2046 .unwrap()
2047 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2048
2049 let (_port, handle) = server.run_ephemeral().await.unwrap();
2050
2051 sleep(Duration::from_millis(800)).await;
2052
2053 let bundle = out.path().join("styles.css");
2054 assert!(
2055 bundle.exists(),
2056 "bundle should be written to the default <output>/styles.css"
2057 );
2058 let content = fs::read_to_string(&bundle).unwrap();
2059 assert!(!content.is_empty(), "bundle should contain CSS");
2060
2061 handle.shutdown().await;
2062 }
2063
2064 #[tokio::test]
2065 async fn css_bundle_rebuilds_once_and_settles_when_source_css_changes() {
2066 let src = TempDir::new().unwrap();
2067 let out = TempDir::new().unwrap();
2068 let src_path = src.path();
2069 let bundle = out.path().join("styles.css");
2070
2071 fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();
2072
2073 let server = Server::new(out.path())
2074 .unwrap()
2075 .with_live_reload()
2076 .with_source_folder(src_path)
2077 .unwrap()
2078 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2079
2080 let (_port, handle) = server.run_ephemeral().await.unwrap();
2081
2082 sleep(Duration::from_millis(800)).await;
2084 assert!(bundle.exists());
2085
2086 fs::write(
2087 src_path.join("style.css"),
2088 "body { margin: 0; color: blue; }",
2089 )
2090 .unwrap();
2091
2092 sleep(Duration::from_millis(1500)).await;
2094 let content_v2 = fs::read_to_string(&bundle).unwrap();
2095 assert!(
2096 content_v2.contains("color"),
2097 "rebundle should contain the new color rule"
2098 );
2099
2100 let mtime_after = fs::metadata(&bundle).unwrap().modified().unwrap();
2101 sleep(Duration::from_millis(1200)).await;
2102 let mtime_later = fs::metadata(&bundle).unwrap().modified().unwrap();
2103
2104 assert_eq!(
2108 mtime_after, mtime_later,
2109 "bundle mtime must settle after one rebuild — an ongoing loop would keep changing it"
2110 );
2111
2112 handle.shutdown().await;
2113 }
2114
2115 #[tokio::test]
2116 async fn css_bundle_creates_output_on_startup_without_live_reload() {
2117 let src = TempDir::new().unwrap();
2118 let out = TempDir::new().unwrap();
2119
2120 fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();
2121
2122 let server = Server::new(out.path())
2123 .unwrap()
2124 .with_source_folder(src.path())
2125 .unwrap()
2126 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2127
2128 let (_port, handle) = server.run_ephemeral().await.unwrap();
2129
2130 sleep(Duration::from_millis(200)).await;
2131
2132 let bundle = out.path().join("styles.css");
2133 assert!(
2134 bundle.exists(),
2135 "bundle should be created even without live_reload"
2136 );
2137 let content = fs::read_to_string(&bundle).unwrap();
2138 assert!(!content.is_empty(), "bundle should contain CSS");
2139
2140 handle.shutdown().await;
2141 }
2142
2143 #[tokio::test]
2144 async fn css_bundle_concatenates_multiple_source_css_files() {
2145 let src = TempDir::new().unwrap();
2146 let out = TempDir::new().unwrap();
2147
2148 fs::write(src.path().join("reset.css"), "* { margin: 0; padding: 0; }").unwrap();
2149 fs::write(src.path().join("theme.css"), "body { background: white; }").unwrap();
2150
2151 let server = Server::new(out.path())
2152 .unwrap()
2153 .with_live_reload()
2154 .with_source_folder(src.path())
2155 .unwrap()
2156 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2157
2158 let (_port, handle) = server.run_ephemeral().await.unwrap();
2159
2160 sleep(Duration::from_millis(800)).await;
2161
2162 let content = fs::read_to_string(out.path().join("styles.css")).unwrap();
2163 assert!(
2164 content.contains("margin"),
2165 "output should contain reset CSS"
2166 );
2167 assert!(
2168 content.contains("background"),
2169 "output should contain theme CSS"
2170 );
2171
2172 handle.shutdown().await;
2173 }
2174}
2175
2176#[cfg(test)]
2177mod asset_folder_tests {
2178 use super::*;
2179 use std::fs;
2180 use std::time::Duration;
2181 use tempfile::TempDir;
2182 use tokio::time::sleep;
2183
2184 #[tokio::test]
2185 async fn asset_folder_overlapping_output_dir_is_rejected() {
2186 let root = TempDir::new().unwrap();
2187 let result = Server::new(root.path())
2188 .unwrap()
2189 .with_asset_folder(root.path());
2190 assert!(
2191 result.is_err(),
2192 "an asset folder equal to the output dir must be rejected"
2193 );
2194 }
2195
2196 #[tokio::test]
2197 async fn asset_folder_overlapping_an_existing_asset_folder_is_rejected() {
2198 let root = TempDir::new().unwrap();
2199 let assets = TempDir::new().unwrap();
2200
2201 let result = Server::new(root.path())
2202 .unwrap()
2203 .with_asset_folder(assets.path())
2204 .unwrap()
2205 .with_asset_folder(assets.path());
2206 assert!(
2207 result.is_err(),
2208 "registering the same asset folder twice must be rejected"
2209 );
2210 }
2211
2212 #[tokio::test]
2213 async fn asset_folder_overlapping_a_source_folder_is_rejected_both_ways() {
2214 let root = TempDir::new().unwrap();
2215 let shared = TempDir::new().unwrap();
2216
2217 let via_asset_then_source = Server::new(root.path())
2218 .unwrap()
2219 .with_asset_folder(shared.path())
2220 .unwrap()
2221 .with_source_folder(shared.path());
2222 assert!(
2223 via_asset_then_source.is_err(),
2224 "a source folder overlapping an already-registered asset folder must be rejected"
2225 );
2226
2227 let via_source_then_asset = Server::new(root.path())
2228 .unwrap()
2229 .with_source_folder(shared.path())
2230 .unwrap()
2231 .with_asset_folder(shared.path());
2232 assert!(
2233 via_source_then_asset.is_err(),
2234 "an asset folder overlapping an already-registered source folder must be rejected"
2235 );
2236 }
2237
2238 #[tokio::test]
2239 async fn asset_folder_files_are_served_after_startup_build() {
2240 let assets = TempDir::new().unwrap();
2241 let out = TempDir::new().unwrap();
2242 fs::write(assets.path().join("index.html"), "<html>hi</html>").unwrap();
2243 fs::create_dir(assets.path().join("images")).unwrap();
2244 fs::write(assets.path().join("images/logo.svg"), "<svg></svg>").unwrap();
2245
2246 let server = Server::new(out.path())
2247 .unwrap()
2248 .with_asset_folder(assets.path())
2249 .unwrap();
2250 let (port, handle) = server.run_ephemeral().await.unwrap();
2251
2252 sleep(Duration::from_millis(200)).await;
2253
2254 let index = fs::read_to_string(out.path().join("index.html")).unwrap();
2255 assert_eq!(index, "<html>hi</html>");
2256 let logo = fs::read_to_string(out.path().join("images/logo.svg")).unwrap();
2257 assert_eq!(logo, "<svg></svg>");
2258
2259 let mut conn = tokio::net::TcpStream::connect(("127.0.0.1", port))
2260 .await
2261 .unwrap();
2262 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2263 conn.write_all(b"GET /index.html HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
2264 .await
2265 .unwrap();
2266 let mut response = Vec::new();
2267 conn.read_to_end(&mut response).await.unwrap();
2268 let response = String::from_utf8_lossy(&response);
2269 assert!(response.contains("HTTP/1.1 200"), "got: {response}");
2270 assert!(response.contains("<html>hi</html>"), "got: {response}");
2271
2272 handle.shutdown().await;
2273 }
2274
2275 #[tokio::test]
2276 async fn asset_folder_change_rebuilds_and_live_reloads() {
2277 let assets = TempDir::new().unwrap();
2278 let out = TempDir::new().unwrap();
2279 fs::write(assets.path().join("index.html"), "v1").unwrap();
2280
2281 let server = Server::new(out.path())
2282 .unwrap()
2283 .with_live_reload()
2284 .with_asset_folder(assets.path())
2285 .unwrap();
2286 let (_port, handle) = server.run_ephemeral().await.unwrap();
2287
2288 sleep(Duration::from_millis(800)).await;
2289 assert_eq!(
2290 fs::read_to_string(out.path().join("index.html")).unwrap(),
2291 "v1"
2292 );
2293
2294 fs::write(assets.path().join("index.html"), "v2").unwrap();
2295 sleep(Duration::from_millis(1500)).await;
2296
2297 assert_eq!(
2298 fs::read_to_string(out.path().join("index.html")).unwrap(),
2299 "v2",
2300 "editing the source asset must re-copy it into the output dir"
2301 );
2302
2303 handle.shutdown().await;
2304 }
2305}
2306
2307#[cfg(test)]
2308mod build_once_tests {
2309 use super::*;
2310 use std::fs;
2311 use tempfile::TempDir;
2312
2313 #[tokio::test]
2314 async fn build_populates_the_output_dir_without_starting_a_server() {
2315 let assets = TempDir::new().unwrap();
2316 let out = TempDir::new().unwrap();
2317 fs::write(assets.path().join("index.html"), "<html>hi</html>").unwrap();
2318
2319 let server = Server::new(out.path())
2320 .unwrap()
2321 .with_asset_folder(assets.path())
2322 .unwrap();
2323
2324 server.build().await.unwrap();
2325
2326 assert_eq!(
2327 fs::read_to_string(out.path().join("index.html")).unwrap(),
2328 "<html>hi</html>",
2329 "build() must populate the output dir synchronously, no server needed"
2330 );
2331 }
2332
2333 #[tokio::test]
2334 async fn build_with_no_pipeline_configured_is_a_harmless_no_op() {
2335 let out = TempDir::new().unwrap();
2336 let server = Server::new(out.path()).unwrap();
2337
2338 server
2339 .build()
2340 .await
2341 .expect("build() with nothing configured must succeed trivially");
2342 }
2343
2344 #[tokio::test]
2345 async fn build_fails_fast_when_a_required_tool_binary_is_missing() {
2346 let src = TempDir::new().unwrap();
2347 let out = TempDir::new().unwrap();
2348 fs::write(src.path().join("a.css"), "body{}").unwrap();
2349
2350 let server = Server::new(out.path())
2351 .unwrap()
2352 .with_source_folder(src.path())
2353 .unwrap()
2354 .with_css_tool(CssTool::TestMissing, CssOptions::new().minify(true));
2355
2356 let result = server.build().await;
2357
2358 assert!(
2359 matches!(result, Err(StaticError::PipelineSetup(_))),
2360 "expected PipelineSetup, got {result:?}"
2361 );
2362 }
2363}
2364
2365#[cfg(test)]
2366mod range_header_tests {
2367 use super::*;
2368
2369 #[test]
2370 fn no_range_header_returns_unsatisfiable() {
2371 match parse_range_header("bytes=", 1000) {
2372 RangeOutcome::Unsatisfiable => {}
2373 other => panic!("expected Unsatisfiable, got {other:?}"),
2374 }
2375 }
2376
2377 #[test]
2378 fn invalid_format_returns_unsatisfiable() {
2379 match parse_range_header("invalid", 1000) {
2380 RangeOutcome::NoRange => {}
2381 other => panic!("expected NoRange, got {other:?}"),
2382 }
2383 }
2384
2385 #[test]
2386 fn simple_range_returns_satisfiable() {
2387 match parse_range_header("bytes=0-99", 1000) {
2388 RangeOutcome::Satisfiable(start, end) => {
2389 assert_eq!(start, 0);
2390 assert_eq!(end, 99);
2391 }
2392 other => panic!("expected Satisfiable(0, 99), got {other:?}"),
2393 }
2394 }
2395
2396 #[test]
2397 fn open_ended_range_returns_satisfiable() {
2398 match parse_range_header("bytes=100-", 1000) {
2399 RangeOutcome::Satisfiable(start, end) => {
2400 assert_eq!(start, 100);
2401 assert_eq!(end, 999);
2402 }
2403 other => panic!("expected Satisfiable(100, 999), got {other:?}"),
2404 }
2405 }
2406
2407 #[test]
2408 fn suffix_range_returns_satisfiable() {
2409 match parse_range_header("bytes=-100", 1000) {
2410 RangeOutcome::Satisfiable(start, end) => {
2411 assert_eq!(start, 900);
2412 assert_eq!(end, 999);
2413 }
2414 other => panic!("expected Satisfiable(900, 999), got {other:?}"),
2415 }
2416 }
2417
2418 #[test]
2419 fn suffix_range_longer_than_file_returns_full_range() {
2420 match parse_range_header("bytes=-2000", 1000) {
2421 RangeOutcome::Satisfiable(start, end) => {
2422 assert_eq!(start, 0);
2423 assert_eq!(end, 999);
2424 }
2425 other => panic!("expected Satisfiable(0, 999), got {other:?}"),
2426 }
2427 }
2428
2429 #[test]
2430 fn end_overshooting_file_clamps_correctly() {
2431 match parse_range_header("bytes=0-2000", 1000) {
2432 RangeOutcome::Satisfiable(start, end) => {
2433 assert_eq!(start, 0);
2434 assert_eq!(end, 999);
2435 }
2436 other => panic!("expected Satisfiable(0, 999), got {other:?}"),
2437 }
2438 }
2439
2440 #[test]
2441 fn start_at_file_boundary_returns_unsatisfiable() {
2442 match parse_range_header("bytes=1000-", 1000) {
2443 RangeOutcome::Unsatisfiable => {}
2444 other => panic!("expected Unsatisfiable, got {other:?}"),
2445 }
2446 }
2447
2448 #[test]
2449 fn start_beyond_file_returns_unsatisfiable() {
2450 match parse_range_header("bytes=2000-3000", 1000) {
2451 RangeOutcome::Unsatisfiable => {}
2452 other => panic!("expected Unsatisfiable, got {other:?}"),
2453 }
2454 }
2455
2456 #[test]
2457 fn end_before_start_returns_unsatisfiable() {
2458 match parse_range_header("bytes=100-50", 1000) {
2459 RangeOutcome::Unsatisfiable => {}
2460 other => panic!("expected Unsatisfiable, got {other:?}"),
2461 }
2462 }
2463
2464 #[test]
2465 fn multi_range_returns_multi_range_ignored() {
2466 match parse_range_header("bytes=0-99,200-299", 1000) {
2467 RangeOutcome::MultiRangeIgnored => {}
2468 other => panic!("expected MultiRangeIgnored, got {other:?}"),
2469 }
2470 }
2471
2472 #[test]
2473 fn zero_suffix_length_returns_unsatisfiable() {
2474 match parse_range_header("bytes=-0", 1000) {
2475 RangeOutcome::Unsatisfiable => {}
2476 other => panic!("expected Unsatisfiable, got {other:?}"),
2477 }
2478 }
2479
2480 #[test]
2481 fn if_range_valid_with_matching_etag() {
2482 assert!(if_range_valid("\"abc123\"", "\"abc123\""));
2483 }
2484
2485 #[test]
2486 fn if_range_valid_with_mismatched_etag() {
2487 assert!(!if_range_valid("\"abc123\"", "\"def456\""));
2488 }
2489
2490 #[test]
2491 fn if_range_valid_with_whitespace() {
2492 assert!(if_range_valid(" \"abc123\" ", "\"abc123\""));
2493 }
2494}