1use std::path::{Path, PathBuf};
9use std::pin::Pin;
10use std::sync::Arc;
11use std::time::UNIX_EPOCH;
12
13use crate::config::{ServeConfig, ServeState};
14use crate::fs::{ResolvedDirectory, ResolvedResource, RootGuard};
15use crate::path::{ConfinedPath, PathPolicy};
16use crate::policy::{DirectoryListingPolicy, DotfilePolicy, StaticPolicy};
17use crate::primitives::body::BodySource;
18use crate::primitives::canonical::{
19 normalize_response, NormalizeRequest, Response as CanonicalResponse, ResponseBody, StatusCode,
20};
21use crate::primitives::header_block::{HeaderName, HeaderValue};
22use crate::primitives::http::ReadOnlyMethod;
23use crate::primitives::planner::plan_file_response;
24use crate::primitives::request::Request;
25use crate::primitives::request_head::RequestHead;
26use crate::primitives::response::HeaderMapPlan;
27use crate::server::service::{Service, ServiceError};
28
29#[derive(Debug)]
31#[must_use]
32pub struct StaticServiceBuilder {
33 root: PathBuf,
34 policy: StaticPolicy,
35 default_content_type: String,
36 extra_response_headers: Vec<(String, String)>,
37}
38
39impl StaticServiceBuilder {
40 pub fn policy(mut self, policy: StaticPolicy) -> Self {
42 self.policy = policy;
43 self
44 }
45
46 pub fn default_content_type(mut self, content_type: impl Into<String>) -> Self {
48 self.default_content_type = content_type.into();
49 self
50 }
51
52 pub fn extra_response_headers(mut self, headers: Vec<(String, String)>) -> Self {
54 self.extra_response_headers = headers;
55 self
56 }
57
58 pub fn build(self) -> Result<StaticService, ServiceError> {
60 let config = Arc::new(ServeConfig {
61 root: self.root,
62 static_policy: self.policy,
63 default_content_type: self.default_content_type,
64 extra_response_headers: self.extra_response_headers,
65 ..ServeConfig::default()
66 });
67 StaticService::from_serve_config(config)
68 .map_err(|e| ServiceError::internal(format!("failed to initialize static root: {e}")))
69 }
70}
71
72#[derive(Clone)]
74pub struct StaticService {
75 state: Arc<ServeState>,
76}
77
78impl StaticService {
79 pub fn builder(root: impl AsRef<Path>) -> StaticServiceBuilder {
81 StaticServiceBuilder {
82 root: root.as_ref().to_path_buf(),
83 policy: StaticPolicy::safe_default(),
84 default_content_type: "application/octet-stream".to_string(),
85 extra_response_headers: Vec::new(),
86 }
87 }
88
89 pub(crate) fn from_serve_config(config: Arc<ServeConfig>) -> Result<Self, std::io::Error> {
91 let state = Arc::new(ServeState::new(config)?);
92 crate::ops::Logger::global().emit(crate::ops::Event::new(
93 crate::ops::Severity::Info,
94 crate::ops::EventKind::RootInitialized,
95 "root initialized",
96 ));
97 Ok(Self { state })
98 }
99
100 #[allow(dead_code)]
102 pub(crate) fn from_state(state: Arc<ServeState>) -> Self {
103 Self { state }
104 }
105}
106
107impl Service for StaticService {
108 fn request_body_policy(
109 &self,
110 _head: &RequestHead,
111 ) -> crate::primitives::request_body_policy::RequestBodyPolicy {
112 crate::primitives::request_body_policy::RequestBodyPolicy::Reject
115 }
116
117 fn call(
118 &self,
119 request: Request,
120 ) -> Pin<
121 Box<dyn std::future::Future<Output = Result<CanonicalResponse, ServiceError>> + Send + '_>,
122 > {
123 let state = self.state.clone();
124 let (head, _body) = request.into_head_and_body();
125 Box::pin(async move { plan_static_request(head, &state) })
126 }
127}
128
129fn plan_static_request(
130 request: RequestHead,
131 state: &ServeState,
132) -> Result<CanonicalResponse, ServiceError> {
133 let method = request.method();
134 let is_head = method.is_head();
135 if !method.is_get() && !is_head {
136 return error_response(
137 StatusCode::METHOD_NOT_ALLOWED,
138 "405 Method Not Allowed\n",
139 is_head,
140 true,
141 );
142 }
143
144 let target = request.target();
145 if target.raw().contains("://") {
146 return error_response(StatusCode::BAD_REQUEST, "400 Bad Request\n", is_head, false);
147 }
148
149 let config = state.config();
150 let path_policy = PathPolicy {
151 dotfiles: match config.static_policy.dotfiles {
152 DotfilePolicy::Denied => PathPolicy::default().dotfiles,
153 DotfilePolicy::Serve => crate::path::DotfilePolicy::Allow,
154 },
155 reject_backslash: true,
156 };
157 let confined = match ConfinedPath::parse(target.path(), &path_policy) {
158 Ok(path) => path,
159 Err(rejection) => {
160 let malformed = matches!(
161 rejection,
162 crate::path::PathRejection::MalformedPercentEncoding
163 | crate::path::PathRejection::InvalidUtf8
164 | crate::path::PathRejection::NulByte
165 | crate::path::PathRejection::Empty
166 | crate::path::PathRejection::UnsupportedUriForm
167 | crate::path::PathRejection::TooLong
168 );
169 return error_response(
170 if malformed {
171 StatusCode::BAD_REQUEST
172 } else {
173 StatusCode::FORBIDDEN
174 },
175 if malformed {
176 "400 Bad Request\n"
177 } else {
178 "403 Forbidden\n"
179 },
180 is_head,
181 false,
182 );
183 }
184 };
185
186 let guard = RootGuard::new(state.pinned_root());
187 let if_none_match = request
188 .headers()
189 .get_first("if-none-match")
190 .map(|v| v.as_str());
191 let if_modified_since = request
192 .headers()
193 .get_first("if-modified-since")
194 .map(|v| v.as_str());
195 let range = request.headers().get_first("range").map(|v| v.as_str());
196 let if_range = request.headers().get_first("if-range").map(|v| v.as_str());
197 let method = if is_head {
198 ReadOnlyMethod::Head
199 } else {
200 ReadOnlyMethod::Get
201 };
202
203 match guard.resolve(&confined, &config.static_policy) {
204 ResolvedResource::File(file) => planned_file_response(
205 file,
206 config,
207 method,
208 if_none_match,
209 if_modified_since,
210 range,
211 if_range,
212 is_head,
213 ),
214 ResolvedResource::Directory(dir) => {
215 let raw_path = confined.as_str();
216 if !raw_path.ends_with('/') {
217 let mut location = raw_path.to_string();
218 if !location.ends_with('/') {
219 location.push('/');
220 }
221 if let Some(q) = target.query() {
222 location.push('?');
223 location.push_str(q);
224 }
225 let mut builder =
226 CanonicalResponse::builder().status(StatusCode::MOVED_PERMANENTLY);
227 builder = builder.push_header(
228 crate::primitives::header_block::HeaderName::new("location")
229 .map_err(|e| ServiceError::internal(e.to_string()))?,
230 crate::primitives::header_block::HeaderValue::new(&location)
231 .map_err(|e| ServiceError::internal(e.to_string()))?,
232 );
233 let response = builder
234 .body(ResponseBody::Empty)
235 .map_err(|e| ServiceError::internal(e.to_string()))?;
236 return normalize_response(response, &NormalizeRequest::new(is_head))
237 .map_err(|e| ServiceError::internal(e.to_string()));
238 }
239 plan_directory_response(
240 &guard,
241 dir,
242 config,
243 method,
244 if_none_match,
245 if_modified_since,
246 range,
247 if_range,
248 is_head,
249 )
250 }
251 ResolvedResource::NotFound => {
252 error_response(StatusCode::NOT_FOUND, "404 Not Found\n", is_head, false)
253 }
254 ResolvedResource::Denied(_) => {
255 error_response(StatusCode::FORBIDDEN, "403 Forbidden\n", is_head, false)
256 }
257 }
258}
259
260#[allow(clippy::too_many_arguments)]
261fn planned_file_response(
262 file: crate::fs::ResolvedFile,
263 config: &ServeConfig,
264 method: ReadOnlyMethod,
265 if_none_match: Option<&str>,
266 if_modified_since: Option<&str>,
267 range: Option<&str>,
268 if_range: Option<&str>,
269 is_head: bool,
270) -> Result<CanonicalResponse, ServiceError> {
271 let mut plan = plan_file_response(
272 method,
273 &file.metadata,
274 {
275 let detected = crate::mime::mime_for_path(
276 &file.safe_relative_components.iter().collect::<PathBuf>(),
277 );
278 if detected == "application/octet-stream" {
279 &config.default_content_type
280 } else {
281 detected
282 }
283 },
284 if_none_match,
285 if_modified_since,
286 range,
287 if_range,
288 );
289 if plan.status.as_u16() == 200 {
290 append_extra_headers(&mut plan.headers, config);
291 }
292 let body = file
293 .into_body(&plan)
294 .map_err(|e| ServiceError::internal(format!("file body conversion failed: {e}")))?;
295 canonical_response(plan.status.as_u16(), &plan.headers, body, is_head)
296}
297
298#[allow(clippy::too_many_arguments)]
299fn plan_directory_response(
300 guard: &RootGuard<'_>,
301 dir: ResolvedDirectory,
302 config: &ServeConfig,
303 method: ReadOnlyMethod,
304 if_none_match: Option<&str>,
305 if_modified_since: Option<&str>,
306 range: Option<&str>,
307 if_range: Option<&str>,
308 is_head: bool,
309) -> Result<CanonicalResponse, ServiceError> {
310 for index in ["index.html", "index.htm"] {
311 match guard.resolve_child(&dir, index, &config.static_policy) {
312 ResolvedResource::File(file) => {
313 return planned_file_response(
314 file,
315 config,
316 method,
317 if_none_match,
318 if_modified_since,
319 range,
320 if_range,
321 is_head,
322 );
323 }
324 ResolvedResource::NotFound => continue,
325 ResolvedResource::Denied(_) => {
326 return error_response(StatusCode::FORBIDDEN, "403 Forbidden\n", is_head, false)
327 }
328 ResolvedResource::Directory(_) => {
329 return error_response(
330 StatusCode::INTERNAL_SERVER_ERROR,
331 "500 Internal Server Error\n",
332 is_head,
333 false,
334 )
335 }
336 }
337 }
338
339 match config.static_policy.directory_listing {
340 DirectoryListingPolicy::Disabled => {
341 error_response(StatusCode::FORBIDDEN, "403 Forbidden\n", is_head, false)
342 }
343 DirectoryListingPolicy::Enabled => {
344 let entries = guard
345 .list_directory(
346 &dir,
347 &config.static_policy,
348 config.limits.max_listing_entries,
349 )
350 .map_err(|_| ServiceError::internal("directory listing failed"))?;
351 let body =
352 render_directory_listing(&entries, config.limits.max_listing_response_bytes)?;
353 let mut headers = listing_headers();
354 append_extra_headers(&mut headers, config);
355 canonical_response(
356 StatusCode::OK.as_u16(),
357 &headers,
358 BodySource::Bytes(body),
359 is_head,
360 )
361 }
362 }
363}
364
365fn append_extra_headers(headers: &mut HeaderMapPlan, config: &ServeConfig) {
366 let owned_names: Vec<String> = headers.iter().map(|header| header.name.clone()).collect();
367 for (name, value) in &config.extra_response_headers {
368 if !owned_names
369 .iter()
370 .any(|existing| existing.eq_ignore_ascii_case(name))
371 {
372 headers.push(name.clone(), value.clone());
373 }
374 }
375}
376
377fn canonical_response(
378 status: u16,
379 planned_headers: &HeaderMapPlan,
380 body: BodySource,
381 is_head: bool,
382) -> Result<CanonicalResponse, ServiceError> {
383 let status = StatusCode::new(status).map_err(|e| ServiceError::internal(e.to_string()))?;
384 let mut builder = CanonicalResponse::builder().status(status);
385 for header in planned_headers.iter() {
386 builder = builder.push_header(
387 HeaderName::new(&header.name).map_err(|e| ServiceError::internal(e.to_string()))?,
388 HeaderValue::new(&header.value).map_err(|e| ServiceError::internal(e.to_string()))?,
389 );
390 }
391 let response_body = match body {
392 BodySource::Empty if is_head && status.permits_payload_body() => planned_headers
393 .get("content-length")
394 .and_then(|value| value.parse::<u64>().ok())
395 .map(ResponseBody::EmptyWithLength)
396 .unwrap_or(ResponseBody::Empty),
397 BodySource::Empty => ResponseBody::Empty,
398 BodySource::Bytes(bytes) => ResponseBody::Bytes(bytes),
399 body @ (BodySource::FileFull { .. } | BodySource::FileRange { .. }) => {
400 ResponseBody::File(body)
401 }
402 };
403 let response = builder
404 .body(response_body)
405 .map_err(|e| ServiceError::internal(e.to_string()))?;
406 normalize_response(response, &NormalizeRequest::new(is_head))
407 .map_err(|e| ServiceError::internal(e.to_string()))
408}
409
410fn error_response(
411 status: StatusCode,
412 text: &'static str,
413 is_head: bool,
414 method_not_allowed: bool,
415) -> Result<CanonicalResponse, ServiceError> {
416 let mut builder = CanonicalResponse::builder().status(status).push_header(
417 crate::primitives::header_block::HeaderName::new("content-type")
418 .map_err(|e| ServiceError::internal(e.to_string()))?,
419 crate::primitives::header_block::HeaderValue::new("text/plain; charset=utf-8")
420 .map_err(|e| ServiceError::internal(e.to_string()))?,
421 );
422 if method_not_allowed {
423 builder = builder.push_header(
424 crate::primitives::header_block::HeaderName::new("allow")
425 .map_err(|e| ServiceError::internal(e.to_string()))?,
426 crate::primitives::header_block::HeaderValue::new("GET, HEAD")
427 .map_err(|e| ServiceError::internal(e.to_string()))?,
428 );
429 }
430 let response = builder
431 .body(ResponseBody::Bytes(text.as_bytes().to_vec()))
432 .map_err(|e| ServiceError::internal(e.to_string()))?;
433 normalize_response(response, &NormalizeRequest::new(is_head))
434 .map_err(|e| ServiceError::internal(e.to_string()))
435}
436
437fn listing_headers() -> HeaderMapPlan {
438 let mut headers = HeaderMapPlan::new();
439 headers.push("content-type", "text/html; charset=utf-8");
440 headers.push(
441 "content-security-policy",
442 "default-src 'none'; base-uri 'none'; form-action 'none'",
443 );
444 headers.push("referrer-policy", "no-referrer");
445 headers.push("x-content-type-options", "nosniff");
446 headers
447}
448
449fn render_directory_listing(
450 entries: &[(String, bool)],
451 max_response_bytes: usize,
452) -> Result<Vec<u8>, ServiceError> {
453 let prefix = "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>Directory listing</title>\n</head>\n<body>\n<h1>Directory listing</h1>\n<ul>\n";
454 let suffix = "</ul>\n</body>\n</html>\n";
455 if prefix
456 .len()
457 .checked_add(suffix.len())
458 .is_none_or(|n| n > max_response_bytes)
459 {
460 return Err(ServiceError::internal(
461 "directory listing exceeds configured bound",
462 ));
463 }
464 let mut html = String::from(prefix);
465 for (name, is_dir) in entries {
466 let visible = html_escape(name);
467 let href = html_escape(&percent_encode_path_segment(name));
468 let entry = if *is_dir {
469 format!("<li><a href=\"{href}/\">{visible}/</a></li>\n")
470 } else {
471 format!("<li><a href=\"{href}\">{visible}</a></li>\n")
472 };
473 if html
474 .len()
475 .checked_add(entry.len())
476 .and_then(|n| n.checked_add(suffix.len()))
477 .is_none_or(|n| n > max_response_bytes)
478 {
479 return Err(ServiceError::internal(
480 "directory listing exceeds configured bound",
481 ));
482 }
483 html.push_str(&entry);
484 }
485 html.push_str(suffix);
486 Ok(html.into_bytes())
487}
488
489fn html_escape(value: &str) -> String {
490 let mut out = String::with_capacity(value.len());
491 for c in value.chars() {
492 match c {
493 '&' => out.push_str("&"),
494 '<' => out.push_str("<"),
495 '>' => out.push_str(">"),
496 '"' => out.push_str("""),
497 '\'' => out.push_str("'"),
498 c if !c.is_control() => out.push(c),
499 _ => {}
500 }
501 }
502 out
503}
504
505fn percent_encode_path_segment(value: &str) -> String {
506 let mut out = String::with_capacity(value.len());
507 for byte in value.as_bytes() {
508 if matches!(*byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~') {
509 out.push(*byte as char);
510 } else {
511 out.push_str(&format!("%{byte:02X}"));
512 }
513 }
514 out
515}
516
517#[allow(dead_code)]
518fn _generate_etag(metadata: &std::fs::Metadata) -> Option<String> {
519 let epoch = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
520 Some(format!(
521 "W/\"{}-{}-{}\"",
522 metadata.len(),
523 epoch.as_secs(),
524 epoch.subsec_nanos()
525 ))
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use crate::primitives::header_block::HeaderBlock;
532 use crate::primitives::method::Method;
533 use crate::primitives::request_target::RequestTarget;
534 use crate::primitives::version::HttpVersion;
535 use tempfile::TempDir;
536
537 fn request(method: Method, path: &str) -> Request {
538 request_with_headers(method, path, HeaderBlock::new())
539 }
540
541 fn request_with_headers(method: Method, path: &str, headers: HeaderBlock) -> Request {
542 Request::new(
543 RequestHead::new(
544 method,
545 RequestTarget::parse(path).unwrap(),
546 HttpVersion::Http11,
547 headers,
548 ),
549 crate::primitives::request_body::RequestBody::empty(),
550 crate::primitives::connection_info::ConnectionInfo {
551 local_addr: "127.0.0.1:8000".parse().unwrap(),
552 remote_addr: "127.0.0.1:12345".parse().unwrap(),
553 scheme: crate::primitives::connection_info::Scheme::Http,
554 tls: None,
555 },
556 )
557 }
558
559 #[tokio::test]
560 async fn file_and_range_bodies_remain_canonical_file_sources() {
561 let tmp = TempDir::new().unwrap();
562 std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
563 let service = StaticService::builder(tmp.path()).build().unwrap();
564 let get = service
565 .call(request(Method::get(), "/file.txt"))
566 .await
567 .unwrap();
568 assert!(matches!(
569 get.body(),
570 Some(ResponseBody::File(BodySource::FileFull { .. }))
571 ));
572
573 let mut range_headers = HeaderBlock::new();
574 range_headers.push_str("range", "bytes=2-4").unwrap();
575 let range_request = Request::new(
576 RequestHead::new(
577 Method::get(),
578 RequestTarget::parse("/file.txt").unwrap(),
579 HttpVersion::Http11,
580 range_headers,
581 ),
582 crate::primitives::request_body::RequestBody::empty(),
583 crate::primitives::connection_info::ConnectionInfo {
584 local_addr: "127.0.0.1:8000".parse().unwrap(),
585 remote_addr: "127.0.0.1:12345".parse().unwrap(),
586 scheme: crate::primitives::connection_info::Scheme::Http,
587 tls: None,
588 },
589 );
590 let range = service.call(range_request).await.unwrap();
591 assert!(matches!(
592 range.body(),
593 Some(ResponseBody::File(BodySource::FileRange { .. }))
594 ));
595 }
596
597 #[tokio::test]
598 async fn head_and_conditional_responses_have_no_file_body() {
599 let tmp = TempDir::new().unwrap();
600 std::fs::write(tmp.path().join("file.txt"), b"hello").unwrap();
601 let service = StaticService::builder(tmp.path()).build().unwrap();
602 let head = service
603 .call(request(Method::head(), "/file.txt"))
604 .await
605 .unwrap();
606 assert!(!matches!(head.body(), Some(ResponseBody::File(_))));
607 assert_eq!(
608 head.headers().get_first("content-length").unwrap().as_str(),
609 "5"
610 );
611 }
612
613 #[tokio::test]
614 async fn canonical_response_preserves_planner_metadata() {
615 let tmp = TempDir::new().unwrap();
616 std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
617 let service = StaticService::builder(tmp.path()).build().unwrap();
618
619 let full = service
620 .call(request(Method::get(), "/file.txt"))
621 .await
622 .unwrap();
623 assert_eq!(full.status().as_u16(), 200);
624 assert_eq!(
625 full.headers().get_first("content-type").unwrap().as_str(),
626 "text/plain; charset=utf-8"
627 );
628 assert_eq!(
629 full.headers().get_first("content-length").unwrap().as_str(),
630 "10"
631 );
632 assert_eq!(
633 full.headers().get_first("accept-ranges").unwrap().as_str(),
634 "bytes"
635 );
636 let etag = full
637 .headers()
638 .get_first("etag")
639 .unwrap()
640 .as_str()
641 .to_owned();
642 assert!(full.headers().contains("last-modified"));
643
644 let mut range_headers = HeaderBlock::new();
645 range_headers.push_str("range", "bytes=2-4").unwrap();
646 let range = service
647 .call(request_with_headers(
648 Method::get(),
649 "/file.txt",
650 range_headers,
651 ))
652 .await
653 .unwrap();
654 assert_eq!(range.status().as_u16(), 206);
655 assert_eq!(
656 range.headers().get_first("content-range").unwrap().as_str(),
657 "bytes 2-4/10"
658 );
659 assert_eq!(
660 range
661 .headers()
662 .get_first("content-length")
663 .unwrap()
664 .as_str(),
665 "3"
666 );
667
668 let mut unsatisfiable_headers = HeaderBlock::new();
669 unsatisfiable_headers
670 .push_str("range", "bytes=20-30")
671 .unwrap();
672 let unsatisfiable = service
673 .call(request_with_headers(
674 Method::get(),
675 "/file.txt",
676 unsatisfiable_headers,
677 ))
678 .await
679 .unwrap();
680 assert_eq!(unsatisfiable.status().as_u16(), 416);
681 assert_eq!(
682 unsatisfiable
683 .headers()
684 .get_first("content-range")
685 .unwrap()
686 .as_str(),
687 "bytes */10"
688 );
689 assert!(!matches!(unsatisfiable.body(), Some(ResponseBody::File(_))));
690
691 let mut conditional_headers = HeaderBlock::new();
692 conditional_headers
693 .push_str("if-none-match", &etag)
694 .unwrap();
695 let conditional = service
696 .call(request_with_headers(
697 Method::get(),
698 "/file.txt",
699 conditional_headers,
700 ))
701 .await
702 .unwrap();
703 assert_eq!(conditional.status().as_u16(), 304);
704 assert_eq!(
705 conditional.headers().get_first("etag").unwrap().as_str(),
706 etag
707 );
708 assert!(!matches!(conditional.body(), Some(ResponseBody::File(_))));
709
710 let head = service
711 .call(request(Method::head(), "/file.txt"))
712 .await
713 .unwrap();
714 assert_eq!(head.status().as_u16(), full.status().as_u16());
715 assert_eq!(
716 head.headers().get_first("content-type").unwrap().as_str(),
717 "text/plain; charset=utf-8"
718 );
719 assert_eq!(
720 head.headers().get_first("content-length").unwrap().as_str(),
721 "10"
722 );
723 assert!(!matches!(head.body(), Some(ResponseBody::File(_))));
724 }
725
726 #[tokio::test]
727 async fn canonical_response_preserves_listing_and_error_metadata() {
728 let tmp = TempDir::new().unwrap();
729 std::fs::create_dir(tmp.path().join("dir")).unwrap();
730 std::fs::write(tmp.path().join("dir/file.txt"), b"file").unwrap();
731 let mut config = ServeConfig {
732 root: tmp.path().to_path_buf(),
733 ..ServeConfig::default()
734 };
735 config.static_policy.directory_listing = DirectoryListingPolicy::Enabled;
736 let service = StaticService::from_serve_config(Arc::new(config)).unwrap();
737
738 let listing = service.call(request(Method::get(), "/dir/")).await.unwrap();
739 assert_eq!(
740 listing
741 .headers()
742 .get_first("content-type")
743 .unwrap()
744 .as_str(),
745 "text/html; charset=utf-8"
746 );
747 assert_eq!(
748 listing
749 .headers()
750 .get_first("content-security-policy")
751 .unwrap()
752 .as_str(),
753 "default-src 'none'; base-uri 'none'; form-action 'none'"
754 );
755 assert_eq!(
756 listing
757 .headers()
758 .get_first("referrer-policy")
759 .unwrap()
760 .as_str(),
761 "no-referrer"
762 );
763 assert_eq!(
764 listing
765 .headers()
766 .get_first("x-content-type-options")
767 .unwrap()
768 .as_str(),
769 "nosniff"
770 );
771 assert!(listing.headers().contains("content-length"));
772
773 let not_allowed = service
774 .call(request(Method::post(), "/dir/"))
775 .await
776 .unwrap();
777 assert_eq!(not_allowed.status().as_u16(), 405);
778 assert_eq!(
779 not_allowed.headers().get_first("allow").unwrap().as_str(),
780 "GET, HEAD"
781 );
782 assert_eq!(
783 not_allowed
784 .headers()
785 .get_first("content-type")
786 .unwrap()
787 .as_str(),
788 "text/plain; charset=utf-8"
789 );
790 assert!(not_allowed.headers().contains("content-length"));
791 }
792
793 #[tokio::test]
794 async fn index_and_listing_use_canonical_bodies() {
795 let tmp = TempDir::new().unwrap();
796 std::fs::create_dir(tmp.path().join("dir")).unwrap();
797 std::fs::write(tmp.path().join("dir/index.htm"), b"index").unwrap();
798 let mut config = ServeConfig {
799 root: tmp.path().to_path_buf(),
800 ..ServeConfig::default()
801 };
802 config.static_policy.directory_listing = DirectoryListingPolicy::Enabled;
803 let service = StaticService::from_serve_config(Arc::new(config)).unwrap();
804 let index = service.call(request(Method::get(), "/dir/")).await.unwrap();
805 assert!(matches!(index.body(), Some(ResponseBody::File(_))));
806 std::fs::remove_file(tmp.path().join("dir/index.htm")).unwrap();
807 let listing = service.call(request(Method::get(), "/dir/")).await.unwrap();
808 assert!(matches!(listing.body(), Some(ResponseBody::Bytes(_))));
809 }
810}