Skip to main content

eggserve_core/server/
static_service.rs

1//! Canonical, confined static-file service.
2//!
3//! Static resolution and response planning happen here. The service never
4//! constructs a Hyper response and never acquires transport permits. File
5//! capabilities remain in [`ResponseBody::File`] until the runtime's single
6//! transport conversion boundary.
7
8use 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/// Builder for a confined static service.
30#[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    /// Set the static-file security policy.
41    pub fn policy(mut self, policy: StaticPolicy) -> Self {
42        self.policy = policy;
43        self
44    }
45
46    /// Set the fallback type used when a file extension has no known MIME type.
47    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    /// Set ordered, duplicate-preserving headers for successful static 200 responses.
53    pub fn extra_response_headers(mut self, headers: Vec<(String, String)>) -> Self {
54        self.extra_response_headers = headers;
55        self
56    }
57
58    /// Build the service and pin its root exactly once.
59    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/// A hardened static file service.
73#[derive(Clone)]
74pub struct StaticService {
75    state: Arc<ServeState>,
76}
77
78impl StaticService {
79    /// Create a builder for a static service rooted at `root`.
80    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    /// Construct a service from already validated static configuration.
90    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    /// Legacy adapter for callers that already own a pinned static state.
101    #[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        // Static serving never consumes request content. Unsupported bodyless
113        // methods still reach call() and receive the normal 405 response.
114        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("&amp;"),
494            '<' => out.push_str("&lt;"),
495            '>' => out.push_str("&gt;"),
496            '"' => out.push_str("&quot;"),
497            '\'' => out.push_str("&#x27;"),
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}