Skip to main content

rust_web_server/app/controller/static_resource/
mod.rs

1use std::fs::{File, metadata, read_dir};
2use std::time::UNIX_EPOCH;
3use file_ext::FileExt;
4use crate::controller::Controller;
5use crate::ext::decimal::Decimal;
6use crate::header::Header;
7use crate::mime_type::MimeType;
8use crate::range::{ContentRange, Range};
9use crate::request::{METHOD, Request};
10use crate::response::{Error, Response, STATUS_CODE_REASON_PHRASE};
11use crate::server::ConnectionInfo;
12use crate::server_config::ServerConfig;
13use crate::symbol::SYMBOL;
14use crate::url::URL;
15
16#[cfg(test)]
17mod tests;
18pub(crate) mod dictionaries;
19pub(crate) mod open_file_cache;
20
21pub struct StaticResourceController;
22
23impl Controller for StaticResourceController {
24    fn is_matching(request: &Request, _connection: &ConnectionInfo) -> bool {
25        // Every branch below answers "no" for other methods and for "/":
26        // check that before touching the filesystem.
27        let is_get = request.method == METHOD.get;
28        let is_head = request.method == METHOD.head;
29        let is_options = request.method == METHOD.options;
30
31        let is_matching_method = (is_get || is_head || is_options) && (request.request_uri != SYMBOL.slash);
32        if !is_matching_method {
33            return false;
34        }
35
36        // Only origin-form targets name a file ("*" and anything a caller
37        // built by hand don't).
38        let path = match URL::request_target_path(&request.request_uri) {
39            Some(path) => path,
40            None => return false,
41        };
42
43        let boxed_static_filepath = static_fs_path(&os_specific_path(path));
44        if boxed_static_filepath.is_err() {
45            return false
46        }
47
48        let static_filepath = boxed_static_filepath.unwrap();
49
50        // Any existing directory matches now — with an `index.html` it is served as
51        // before, otherwise `process_static_resources` renders a directory listing.
52        let mut is_directory = false;
53
54        // A file the open-file cache holds needs no stat; otherwise one stat
55        // answers both "exists?" and "directory?" — no open().
56        let mut is_file = open_file_cache::is_cached_file(&static_filepath);
57        if !is_file {
58            if let Ok(md) = metadata(&static_filepath) {
59                // Cached files were checked when opened; the rest here.
60                if !inside_root(&static_filepath) {
61                    return false;
62                }
63                is_directory = md.is_dir();
64                is_file = md.is_file();
65            }
66        }
67
68        if is_file || is_directory {
69            true
70        } else {
71            // check if file with same name and .html extension exists
72            if !static_filepath.ends_with(".html") {
73                let html_suffix = ".html";
74                let mut html_file = os_specific_path(path).into_owned();
75                html_file.push_str(html_suffix);
76                if let Ok(html_static_filepath) = static_fs_path(&html_file) {
77                    if File::open(&html_static_filepath).is_ok() {
78                        return true;
79                    }
80                }
81            }
82
83            // Last resort: the configured SPA fallback file (RWS_CONFIG_SPA_FALLBACK),
84            // for a GET/HEAD request that doesn't look like a missed static asset and
85            // isn't under an excluded prefix. Returns false (unchanged prior behavior)
86            // whenever the fallback isn't configured or doesn't apply.
87            spa_fallback_url_path(path).is_some()
88        }
89
90    }
91
92    fn process(request: &Request, mut response: Response, _connection: &ConnectionInfo) -> Response {
93        let config = ServerConfig::current();
94        if let Some(served) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
95            return served;
96        }
97        StaticResourceController::process_general(request, response, &config)
98    }
99}
100
101impl StaticResourceController {
102    /// `true` for a request naming a directory with no `index.html`: what
103    /// [`ServerConfig::directory_listing`] = `false` answers with a 404.
104    pub(crate) fn is_directory_without_index(request: &Request) -> bool {
105        let Some(path) = URL::request_target_path(&request.request_uri) else { return false };
106        let Ok(fs_path) = static_fs_path(&os_specific_path(path)) else { return false };
107        let dir = std::path::Path::new(&fs_path);
108        dir.is_dir() && !dir.join("index.html").is_file()
109    }
110
111    /// [`Controller::process`] minus the [`Self::serve_regular_file`] fast
112    /// path: directories, `.html` resolution, ranges, OPTIONS, symlinks with
113    /// an untyped name, the SPA fallback. For a caller that already tried it.
114    pub(crate) fn process_general(request: &Request, mut response: Response, config: &ServerConfig) -> Response {
115        // A Range with an If-Range that no longer matches is answered as if
116        // there were no Range: the whole, current file (#56). (The fast path
117        // never takes a Range, so only this path needs it.)
118        let without_stale_range;
119        let request = if range_is_stale(request) {
120            let mut copy = request.clone();
121            copy.headers.retain(|h| !h.name.eq_ignore_ascii_case(Header::_RANGE));
122            without_stale_range = copy;
123            &without_stale_range
124        } else {
125            request
126        };
127        let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
128        if boxed_content_range_list.is_ok() {
129            let content_range_list = boxed_content_range_list.unwrap();
130
131            if content_range_list.len() != 0 {
132
133                let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
134
135                let does_request_include_range_header = request.header(Header::_RANGE).is_some();
136                if does_request_include_range_header {
137                    status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
138                }
139
140                let is_options_request = request.method == METHOD.options;
141                if is_options_request {
142                    status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
143                }
144
145
146
147                let url = request.request_uri.to_string();
148
149                let boxed_url_components = URL::parse_request_target(&url);
150                if boxed_url_components.is_err() {
151                    let message = boxed_url_components.as_ref().err().unwrap().to_string();
152                    // unfallable
153                    println!("unexpected error, {}", message);
154                }
155
156                let components = boxed_url_components.unwrap();
157
158                // Decoded and checked like the lookup itself (#55): the raw
159                // path names no file when it has escapes.
160                let static_filepath = static_fs_path(&components.path).unwrap_or_default();
161                let boxed_modified_date_time = file_modified_utc(&static_filepath);
162
163                if boxed_modified_date_time.is_ok() {
164                    let modified_date_time = boxed_modified_date_time.unwrap();
165                    let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
166                    let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
167                    set_static_cache_control(&mut response, &config.static_cache_control);
168                    if push_validators(request, &mut response, modified_date_time, &etag_value, config.nanos_headers) {
169                        response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
170                        response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
171                        response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
172                        return response;
173                    }
174
175                    response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
176
177                    // Stream large files (> 8 MB) without loading into memory, unless it's
178                    // a range request (which needs precise byte slicing from the loaded body).
179                    let is_range_request = request.header(Header::_RANGE).is_some();
180                    if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
181                        let mime = MimeType::detect_mime_type(&static_filepath);
182                        response.headers.push(Header {
183                            name: Header::_CONTENT_TYPE.into(),
184                            value: mime.into(),
185                        });
186                        response.headers.push(Header {
187                            name: Header::_CONTENT_LENGTH.into(),
188                            value: file_size.to_string().into(),
189                        });
190                        response.status_code = *status_code_reason_phrase.status_code;
191                        response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
192                        response.stream_file = Some(static_filepath);
193                        return response;
194                    }
195                }
196
197                response.status_code = *status_code_reason_phrase.status_code;
198                response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
199                response.content_range_list = content_range_list;
200
201            }
202        } else {
203            let error : Error = boxed_content_range_list.err().unwrap();
204            let body = error.message;
205
206            let content_range = Range::get_content_range(
207                Vec::from(body.as_bytes()),
208                MimeType::TEXT_HTML.to_string()
209            );
210
211            let content_range_list = vec![content_range];
212
213            response.status_code = *error.status_code_reason_phrase.status_code;
214            response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
215            response.content_range_list = content_range_list;
216
217            // A 416 says how long the file is (RFC 9110 §15.5.17, #56).
218            if response.status_code == 416 {
219                let length = URL::parse_request_target(&request.request_uri).ok()
220                    .and_then(|c| static_fs_path(&c.path).ok())
221                    .and_then(|p| metadata(p).ok())
222                    .map(|md| md.len());
223                if let Some(length) = length {
224                    response.headers.push(Header { name: Header::_CONTENT_RANGE.into(), value: format!("bytes */{}", length).into() });
225                }
226            }
227        }
228
229
230        response
231    }
232}
233
234//backward compatability
235impl StaticResourceController {
236
237    pub fn is_matching_request(request: &Request) -> bool {
238        let boxed_static_filepath = static_fs_path(&request.request_uri);
239        if boxed_static_filepath.is_err() {
240            return false
241        }
242
243        let static_filepath = boxed_static_filepath.unwrap();
244
245        let boxed_md = metadata(&static_filepath);
246        if boxed_md.is_err() || !inside_root(&static_filepath) {
247            return false
248        }
249
250        let md = boxed_md.unwrap();
251        if md.is_dir() {
252            return false
253        }
254
255        let boxed_file = File::open(&static_filepath);
256
257        let is_get = request.method == METHOD.get;
258        let is_head = request.method == METHOD.head;
259        let is_options = request.method == METHOD.options;
260        boxed_file.is_ok() && (is_get || is_head || is_options && request.request_uri != SYMBOL.slash)
261    }
262
263    pub fn process_request(request: &Request, mut response: Response) -> Response {
264        let config = ServerConfig::current();
265        if let Some(streamed) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
266            return streamed;
267        }
268        let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
269        if boxed_content_range_list.is_ok() {
270            let content_range_list = boxed_content_range_list.unwrap();
271
272            if content_range_list.len() != 0 {
273
274                let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
275
276                let does_request_include_range_header = request.header(Header::_RANGE).is_some();
277                if does_request_include_range_header {
278                    status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
279                }
280
281                let is_options_request = request.method == METHOD.options;
282                if is_options_request {
283                    status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
284                }
285
286
287                let static_filepath = static_fs_path(&request.request_uri).unwrap_or_default();
288                let boxed_modified_date_time = file_modified_utc(&static_filepath);
289
290                if boxed_modified_date_time.is_ok() {
291                    let modified_date_time = boxed_modified_date_time.unwrap();
292                    let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
293                    let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
294                    set_static_cache_control(&mut response, &config.static_cache_control);
295                    if push_validators(request, &mut response, modified_date_time, &etag_value, config.nanos_headers) {
296                        response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
297                        response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
298                        response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
299                        return response;
300                    }
301
302                    response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
303
304                    let is_range_request = request.header(Header::_RANGE).is_some();
305                    if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
306                        let mime = MimeType::detect_mime_type(&static_filepath);
307                        response.headers.push(Header {
308                            name: Header::_CONTENT_TYPE.into(),
309                            value: mime.into(),
310                        });
311                        response.headers.push(Header {
312                            name: Header::_CONTENT_LENGTH.into(),
313                            value: file_size.to_string().into(),
314                        });
315                        response.status_code = *status_code_reason_phrase.status_code;
316                        response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
317                        response.stream_file = Some(static_filepath);
318                        return response;
319                    }
320                }
321
322                response.status_code = *status_code_reason_phrase.status_code;
323                response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
324                response.content_range_list = content_range_list;
325
326            }
327        } else {
328            let error : Error = boxed_content_range_list.err().unwrap();
329            let body = error.message;
330
331            let content_range = Range::get_content_range(
332                Vec::from(body.as_bytes()),
333                MimeType::TEXT_HTML.to_string()
334            );
335
336            let content_range_list = vec![content_range];
337
338            response.status_code = *error.status_code_reason_phrase.status_code;
339            response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
340            response.content_range_list = content_range_list;
341
342        }
343
344
345        response
346    }
347
348    /// Files larger than this are streamed from disk (`Response::stream_file`)
349    /// rather than read into memory — 64 concurrent downloads of a 1 MiB file
350    /// otherwise hold ~64 MiB plus copies.
351    pub const STREAM_THRESHOLD: u64 = 256 * 1024;
352
353    /// Fast path for a GET/HEAD of a regular file without a `Range` header:
354    /// one `open` and one `fstat` — size and modified time come from the open
355    /// handle, and a small file is read from that same handle — like nginx.
356    /// (The general path below stats the file five or six times and opens it
357    /// twice.) Files over [`Self::STREAM_THRESHOLD`] are answered with
358    /// `stream_file` before anything is read. The `Content-Type` comes from
359    /// the requested name. `None` = not applicable; the general path handles
360    /// it (directories, `.html` resolution, ranges, OPTIONS, symlinks whose
361    /// name has no known type).
362    pub(crate) fn serve_regular_file(request: &Request, response: &mut Response, config: &ServerConfig) -> Option<Response> {
363        if request.method != METHOD.get && request.method != METHOD.head {
364            return None;
365        }
366        if request.header(Header::_RANGE).is_some() {
367            return None;
368        }
369        let path = URL::request_target_path(&request.request_uri)?;
370        let fs_path = static_fs_path(&os_specific_path(path)).ok()?;
371        // A symlink whose own name has no recognizable type goes to the
372        // general path, which takes the type from the link target: such names
373        // are opened with O_NOFOLLOW, so a symlink simply fails to open.
374        let mime = MimeType::mime_type_for(&fs_path);
375        let follow_symlinks = mime != MimeType::APPLICATION_OCTET_STREAM;
376        let file = open_file_cache::open(&fs_path, follow_symlinks)?;
377        let state = file.state()?;
378
379        // A compressible text file for a client that accepts br, zstd or gzip
380        // is sent from the cached compressed form (or a precompressed
381        // sibling), made once per file version instead of per request. Only
382        // a request with Accept-Encoding pays for the negotiation.
383        let encoding = if state.len <= open_file_cache::MAX_GZIPPED_FILE
384            && state.len >= crate::compression::MIN_COMPRESS_SIZE as u64
385            && crate::compression::is_compressible(mime)
386        {
387            crate::compression::negotiate(request, crate::compression::STATIC_PREFERENCE)
388        } else {
389            None
390        };
391        // Dictionary compression (#38), only for configured patterns: this
392        // file is offered as a dictionary, and goes out as a delta when the
393        // client holds an earlier version we kept.
394        let dictionary_pattern = if config.compression_dictionaries.is_empty()
395            || state.len > crate::compression::dictionary::MAX_SIZE as u64
396            || !crate::compression::is_compressible(mime)
397        {
398            None
399        } else {
400            dictionaries::matching_pattern(&config.compression_dictionaries, path)
401        };
402        let delta = match dictionary_pattern {
403            Some(_) => {
404                let _ = file.dictionary_hash(&state);
405                match (crate::compression::dictionary::available_dictionary(request), crate::compression::dictionary::negotiate(request)) {
406                    (Some(held), Some(coding)) => match dictionaries::get(&held) {
407                        Some(bytes) => file.delta(&state, coding, held, &bytes).ok()?.map(|delta| (coding, held, delta)),
408                        None => None,
409                    },
410                    _ => None,
411                }
412            }
413            None => None,
414        };
415        // ETag "<modified>-<len>", plus "-<coding>" when the file goes out
416        // compressed (each representation needs its own strong ETag), and the
417        // nanosecond Last-Modified, built without format!: this runs for
418        // every request.
419        let modified = Decimal::new(state.modified);
420        let len = Decimal::new(state.len as u128);
421        let mut etag_value = String::with_capacity(modified.as_str().len() + len.as_str().len() + 8);
422        etag_value.push('"');
423        etag_value.push_str(modified.as_str());
424        etag_value.push('-');
425        etag_value.push_str(len.as_str());
426        if let Some((coding, held, _)) = &delta {
427            // A delta depends on the dictionary too: "-dcz-<its hash>".
428            etag_value.push('-');
429            etag_value.push_str(coding.token());
430            etag_value.push('-');
431            held[..4].iter().for_each(|b| etag_value.push_str(&format!("{b:02x}")));
432        } else if let Some(encoding) = encoding {
433            etag_value.push('-');
434            etag_value.push_str(encoding.token());
435        }
436        etag_value.push('"');
437        let not_modified = is_not_modified(request, state.modified, &etag_value);
438        // Everything that can fail happens before `response` is taken: on
439        // `None` the general path carries on with it.
440        // A body of None means: stream the file (sendfile over plain TCP).
441        // Compressible files up to MAX_GZIPPED_FILE (above the streaming
442        // threshold too) go to gzip clients compressed — a JS bundle of a few
443        // hundred KiB used to be sent uncompressed.
444        let mut sent_encoded = None;
445        let mut sent_delta = None;
446        let large = state.len > StaticResourceController::STREAM_THRESHOLD;
447        let body = if not_modified {
448            None
449        } else if let Some((coding, _, bytes)) = &delta {
450            sent_delta = Some(*coding);
451            Some(bytes.to_vec())
452        } else if let Some(encoding) = encoding {
453            match file.encoded(&state, encoding, &fs_path).ok()? {
454                open_file_cache::Encoded::Smaller(bytes) => {
455                    sent_encoded = Some(encoding);
456                    Some(bytes.to_vec())
457                }
458                open_file_cache::Encoded::NotSmaller if large => None,
459                open_file_cache::Encoded::NotSmaller => Some(file.contents(&state).ok()?),
460            }
461        } else if large {
462            None
463        } else {
464            Some(file.contents(&state).ok()?)
465        };
466
467        let mut response = std::mem::replace(response, Response::empty());
468        set_static_cache_control(&mut response, &config.static_cache_control);
469        if config.nanos_headers {
470            response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.into(), value: modified.as_str().to_string().into() });
471        }
472        response.headers.push(Header { name: Header::_LAST_MODIFIED.into(), value: crate::http::date::format((state.modified / 1_000_000_000) as u64).into() });
473        response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
474        if not_modified {
475            response.set_status(STATUS_CODE_REASON_PHRASE.n304_not_modified);
476            mark_dictionary(&mut response, dictionary_pattern);
477            return Some(response);
478        }
479        response.set_status(STATUS_CODE_REASON_PHRASE.n200_ok);
480
481        let Some(body) = body else {
482            response.headers.push(Header { name: Header::_CONTENT_TYPE.into(), value: mime.to_string().into() });
483            response.headers.push(Header { name: Header::_CONTENT_LENGTH.into(), value: len.as_str().to_string().into() });
484            response.stream_file = Some(fs_path);
485            mark_dictionary(&mut response, dictionary_pattern);
486            return Some(response);
487        };
488
489        response.content_range_list = vec![Range::get_content_range(body, mime.to_string())];
490        if let Some(coding) = sent_delta {
491            crate::compression::dictionary::mark_encoded(&mut response, coding);
492        } else if let Some(encoding) = sent_encoded {
493            crate::compression::mark_encoded(&mut response, encoding);
494        }
495        mark_dictionary(&mut response, dictionary_pattern);
496        Some(response)
497    }
498
499    /// For a GET or HEAD of a path that names nothing but ends with
500    /// punctuation prose and Markdown glue onto links — `)`, `:`, `.`, `,`,
501    /// `;`, `]`, `>`, quotes (`/docs/http2/):` from `[HTTP/2](/docs/http2/):`
502    /// in an llms.txt) — a `301` to the path without it, when that is a file
503    /// or directory. `None` otherwise (the 404 stands). Since 18.15.0.
504    pub(crate) fn trailing_punctuation_redirect(request: &Request) -> Option<Response> {
505        const TRAILING: &[char] = &[')', ':', '.', ',', ';', ']', '>', '\'', '"'];
506        if request.method != METHOD.get && request.method != METHOD.head {
507            return None;
508        }
509        let (raw_path, query) = match request.request_uri.split_once('?') {
510            Some((path, query)) => (path, Some(query)),
511            None => (request.request_uri.as_str(), None),
512        };
513        let decoded = percent_decode_path(raw_path).ok()?;
514        let trimmed = decoded.trim_end_matches(TRAILING);
515        if trimmed.len() == decoded.len() || trimmed.is_empty() || !trimmed.starts_with('/') || trimmed.ends_with("..") {
516            return None;
517        }
518        let fs_path = static_fs_path(&os_specific_path(trimmed)).ok()?;
519        if !std::path::Path::new(&fs_path).exists() {
520            return None;
521        }
522        let mut location = String::with_capacity(trimmed.len() + 16);
523        for c in trimmed.chars() {
524            // Re-encode what a Location header can't carry as is.
525            if c.is_ascii_graphic() && !matches!(c, '%' | '"' | '<' | '>' | '\\' | '^' | '`' | '{' | '|' | '}') {
526                location.push(c);
527            } else {
528                let mut buf = [0u8; 4];
529                for b in c.encode_utf8(&mut buf).bytes() {
530                    location.push_str(&format!("%{b:02X}"));
531                }
532            }
533        }
534        if let Some(query) = query {
535            location.push('?');
536            location.push_str(query);
537        }
538        let mut response = Response::get_response(STATUS_CODE_REASON_PHRASE.n301_moved_permanently, None, None);
539        response.headers.push(Header { name: Header::_LOCATION.into(), value: location.into() });
540        Some(response)
541    }
542
543    pub fn process_static_resources(request: &Request) -> Result<Vec<ContentRange>, Error> {
544
545        let url = request.request_uri.to_string();
546
547        let boxed_url_components = URL::parse_request_target(&url);
548        if boxed_url_components.is_err() {
549            let message = boxed_url_components.as_ref().err().unwrap().to_string();
550            // unfallable
551            println!("unexpected error, {}", message);
552        }
553
554        let components = boxed_url_components.unwrap();
555
556        let os_specific_separator : String = FileExt::get_path_separator();
557        let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
558
559        let static_filepath = match static_fs_path(&os_specific_path) {
560            Ok(path) => path,
561            Err(message) => {
562                return Err(Error { status_code_reason_phrase: STATUS_CODE_REASON_PHRASE.n404_not_found, message });
563            }
564        };
565
566        let mut content_range_list = Vec::new();
567
568
569        let mut boxed_md = metadata(&static_filepath);
570        if boxed_md.is_err() {
571            let dot_html = format!("{}{}", &static_filepath, ".html");
572            boxed_md = metadata(&dot_html);
573
574            if boxed_md.is_err() {
575                let slash_index_html = format!("{}{}{}", &static_filepath, os_specific_separator,  "index.html");
576                boxed_md = metadata(&slash_index_html);
577            }
578        }
579        if boxed_md.is_ok() {
580            let md = boxed_md.unwrap();
581
582            if md.is_dir() {
583                let mut directory_index : String = "index.html".to_string();
584
585                let last_char = components.path.chars().last().unwrap();
586                if last_char != '/' {
587                    let index : String = "index.html".to_string();
588                    directory_index = format!("{}{}", os_specific_separator, index);
589                }
590                let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
591                let index_html_fs_path = format!("{}{}", static_filepath, directory_index);
592
593                if File::open(&index_html_fs_path).is_ok() {
594                    let mut range_header = &Header {
595                        name: Header::_RANGE.into(),
596                        value: "bytes=0-".into()
597                    };
598
599                    let boxed_header = request.header(Header::_RANGE);
600                    if boxed_header.is_some() {
601                        range_header = boxed_header.unwrap();
602                    }
603
604                    let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
605                    if boxed_content_range_list.is_ok() {
606                        content_range_list = boxed_content_range_list.unwrap();
607                    } else {
608                        let error = boxed_content_range_list.err().unwrap();
609                        return Err(error)
610                    }
611                } else {
612                    let listing_html = StaticResourceController::render_directory_listing(&static_filepath, &components.path);
613                    let content_range = Range::get_content_range(listing_html.into_bytes(), MimeType::TEXT_HTML.to_string());
614                    content_range_list = vec![content_range];
615                }
616
617                return Ok(content_range_list);
618            }
619
620            let boxed_file = File::open(&static_filepath);
621            if boxed_file.is_ok()  {
622                let md = metadata(&static_filepath).unwrap();
623                if md.is_dir() {
624                    let mut range_header = &Header {
625                        name: Header::_RANGE.into(),
626                        value: "bytes=0-".into()
627                    };
628
629                    let boxed_header = request.header(Header::_RANGE);
630                    if boxed_header.is_some() {
631                        range_header = boxed_header.unwrap();
632                    }
633
634                    let mut directory_index : String = "index.html".to_string();
635
636                    let last_char = components.path.chars().last().unwrap();
637                    if last_char != '/' {
638                        let index : String = "index.html".to_string();
639                        directory_index = format!("{}{}", os_specific_separator, index);
640                    }
641                    let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
642
643
644                    let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
645                    if boxed_content_range_list.is_ok() {
646                        content_range_list = boxed_content_range_list.unwrap();
647                    } else {
648                        let error = boxed_content_range_list.err().unwrap();
649                        return Err(error)
650                    }
651                }
652
653                if md.is_file() {
654                    let mut range_header = &Header {
655                        name: Header::_RANGE.into(),
656                        value: "bytes=0-".into()
657                    };
658
659                    let boxed_header = request.header(Header::_RANGE);
660                    if boxed_header.is_some() {
661                        range_header = boxed_header.unwrap();
662                    }
663
664                    let boxed_content_range_list = Range::get_content_range_list(&request.request_uri, range_header);
665                    if boxed_content_range_list.is_ok() {
666                        content_range_list = boxed_content_range_list.unwrap();
667                    } else {
668                        let error = boxed_content_range_list.err().unwrap();
669                        return Err(error)
670                    }
671                }
672            }
673
674
675            if boxed_file.is_err() {
676                //check if .html file exists
677                // Decoded like every lookup (#55): `/my%20page` is `my page.html`.
678                let static_filepath = static_fs_path(&format!("{}.html", components.path)).unwrap_or_default();
679
680                let boxed_file = File::open(&static_filepath);
681                if boxed_file.is_ok()  {
682                    let md = metadata(&static_filepath).unwrap();
683                    if md.is_file() {
684                        let mut range_header = &Header {
685                            name: Header::_RANGE.into(),
686                            value: "bytes=0-".into()
687                        };
688
689                        let boxed_header = request.header(Header::_RANGE);
690                        if boxed_header.is_some() {
691                            range_header = boxed_header.unwrap();
692                        }
693
694                        let url = request.request_uri.to_string();
695
696                        let boxed_url_components = URL::parse_request_target(&url);
697                        if boxed_url_components.is_err() {
698                            let message = boxed_url_components.as_ref().err().unwrap().to_string();
699                            // unfallable
700                            println!("unexpected error, {}", message);
701                        }
702
703                        let components = boxed_url_components.unwrap();
704
705                        // let html_file = [SYMBOL.slash, ].join(SYMBOL.empty_string);
706
707
708                        let html_file = [components.path.as_str(), ".html"].join(SYMBOL.empty_string);
709                        let boxed_content_range_list = Range::get_content_range_list(html_file.as_str(), range_header);
710                        if boxed_content_range_list.is_ok() {
711                            content_range_list = boxed_content_range_list.unwrap();
712                        } else {
713                            let error = boxed_content_range_list.err().unwrap();
714                            return Err(error)
715                        }
716                    }
717                }
718            }
719        }
720
721        // Nothing on disk matched — try the configured SPA fallback (a no-op,
722        // returning the untouched empty list, when it's unconfigured or doesn't
723        // apply to this request).
724        if content_range_list.is_empty() {
725            if let Some(fallback_url_path) = spa_fallback_url_path(&components.path) {
726                let mut range_header = &Header {
727                    name: Header::_RANGE.into(),
728                    value: "bytes=0-".into()
729                };
730
731                let boxed_header = request.header(Header::_RANGE);
732                if boxed_header.is_some() {
733                    range_header = boxed_header.unwrap();
734                }
735
736                if let Ok(list) = Range::get_content_range_list(&fallback_url_path, range_header) {
737                    content_range_list = list;
738                }
739            }
740        }
741
742        Ok(content_range_list)
743    }
744}
745
746/// Directory listing generation — used by [`StaticResourceController`] whenever a
747/// requested directory has no `index.html`. Self-contained HTML (inline CSS/JS, no
748/// external requests), dark/light adaptive via `prefers-color-scheme`.
749impl StaticResourceController {
750    /// Renders a directory listing page for `fs_dir_path` (absolute filesystem path)
751    /// requested at `request_path` (the URL path, used to build links and breadcrumbs).
752    /// Hidden entries (dotfiles) are omitted. Directories sort before files;
753    /// each group is sorted case-insensitively by name.
754    pub fn render_directory_listing(fs_dir_path: &str, request_path: &str) -> String {
755        let normalized_request_path = if request_path.ends_with('/') {
756            request_path.to_string()
757        } else {
758            format!("{}/", request_path)
759        };
760
761        struct Entry {
762            name: String,
763            is_dir: bool,
764            size: u64,
765            modified_epoch_secs: u64,
766        }
767
768        let mut entries: Vec<Entry> = Vec::new();
769        if let Ok(dir_entries) = read_dir(fs_dir_path) {
770            for dir_entry in dir_entries.flatten() {
771                let name = dir_entry.file_name().to_string_lossy().to_string();
772                if name.starts_with('.') {
773                    continue;
774                }
775
776                if let Ok(md) = dir_entry.metadata() {
777                    let modified_epoch_secs = md.modified()
778                        .ok()
779                        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
780                        .map(|d| d.as_secs())
781                        .unwrap_or(0);
782
783                    entries.push(Entry {
784                        name,
785                        is_dir: md.is_dir(),
786                        size: md.len(),
787                        modified_epoch_secs,
788                    });
789                }
790            }
791        }
792
793        entries.sort_by(|a, b| {
794            match (a.is_dir, b.is_dir) {
795                (true, false) => std::cmp::Ordering::Less,
796                (false, true) => std::cmp::Ordering::Greater,
797                _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
798            }
799        });
800
801        let segments: Vec<&str> = normalized_request_path.split('/').filter(|s| !s.is_empty()).collect();
802
803        let mut breadcrumb_html = String::from("<a href=\"/\">~</a>");
804        let mut accumulated_path = String::new();
805        for segment in &segments {
806            accumulated_path.push('/');
807            accumulated_path.push_str(segment);
808            breadcrumb_html.push_str(&format!(
809                " <span class=\"sep\">/</span> <a href=\"{}/\">{}</a>",
810                accumulated_path, html_escape(segment)
811            ));
812        }
813
814        let parent_row = if !segments.is_empty() {
815            let parent_path = if segments.len() > 1 {
816                format!("/{}/", segments[..segments.len() - 1].join("/"))
817            } else {
818                "/".to_string()
819            };
820            format!(
821                "<tr class=\"entry parent\"><td class=\"name\"><span class=\"icon\">\u{21A9}\u{FE0F}</span><a href=\"{}\">.. (parent directory)</a></td><td class=\"size\">\u{2014}</td><td class=\"modified\">\u{2014}</td></tr>\n",
822                parent_path
823            )
824        } else {
825            String::new()
826        };
827
828        let entry_count = entries.len();
829
830        let mut rows = String::new();
831        for entry in &entries {
832            let escaped_name = html_escape(&entry.name);
833            let encoded_name = URL::percent_encode(&entry.name);
834            let href = if entry.is_dir {
835                format!("{}{}/", normalized_request_path, encoded_name)
836            } else {
837                format!("{}{}", normalized_request_path, encoded_name)
838            };
839            let size_display = if entry.is_dir { "\u{2014}".to_string() } else { human_size(entry.size) };
840            let modified_display = format_modified(entry.modified_epoch_secs);
841            let icon = icon_for(&entry.name, entry.is_dir);
842            let filter_key = html_escape(&entry.name.to_lowercase());
843
844            rows.push_str(&format!(
845                "<tr class=\"entry\" data-name=\"{}\"><td class=\"name\"><span class=\"icon\">{}</span><a href=\"{}\">{}{}</a></td><td class=\"size\">{}</td><td class=\"modified\">{}</td></tr>\n",
846                filter_key, icon, href, escaped_name, if entry.is_dir { "/" } else { "" }, size_display, modified_display
847            ));
848        }
849
850        let mut html = String::new();
851        html.push_str(DIRECTORY_LISTING_HEAD);
852        html.push_str(&format!("<div class=\"breadcrumb\">{}</div>\n", breadcrumb_html));
853        html.push_str(&format!("<h1>Index of {}</h1>\n", html_escape(&normalized_request_path)));
854        html.push_str(DIRECTORY_LISTING_TOOLBAR);
855        html.push_str("<div class=\"card\"><table><thead><tr><th>Name</th><th>Size</th><th>Modified</th></tr></thead><tbody id=\"rows\">\n");
856        html.push_str(&parent_row);
857        html.push_str(&rows);
858        html.push_str("</tbody></table></div>\n");
859        html.push_str(&format!(
860            "<footer>{} item{} &middot; served by rws</footer>\n",
861            entry_count, if entry_count == 1 { "" } else { "s" }
862        ));
863        html.push_str(DIRECTORY_LISTING_TAIL);
864
865        html
866    }
867}
868
869/// `true` if the last `/`-separated segment of `path` has a file extension
870/// (contains a `.` after the final `/`) — the standard heuristic (also used
871/// by webpack-dev-server's `historyApiFallback`, `sirv`, `vite preview`)
872/// distinguishing a client-side route (`/dashboard/settings`, no extension)
873/// from a missed static asset (`/logo.png`, has one), so the SPA fallback
874/// doesn't silently swallow the latter's real 404.
875fn path_has_extension(path: &str) -> bool {
876    path.rsplit('/').next().unwrap_or("").contains('.')
877}
878
879/// Resolves the SPA-fallback "URL path" to pass to [`Range::get_content_range_list`]
880/// for a request at `url_path` — e.g. `"/index.html"` for the common
881/// `RWS_CONFIG_SPA_FALLBACK=index.html` case — or `None` if the fallback doesn't
882/// apply: unconfigured, `url_path` is under an excluded prefix
883/// (`RWS_CONFIG_SPA_FALLBACK_EXCLUDE_PREFIXES`), `url_path` looks like a missed
884/// static asset (see [`path_has_extension`]), or the configured file doesn't
885/// actually exist on disk. Method filtering (GET/HEAD only) is the caller's
886/// responsibility — both call sites already have it via `is_matching_method`.
887fn spa_fallback_url_path(url_path: &str) -> Option<String> {
888    if path_has_extension(url_path) {
889        return None;
890    }
891
892    let fallback_name = crate::entry_point::get_spa_fallback()?;
893
894    let exclude_prefixes = crate::entry_point::get_spa_fallback_exclude_prefixes();
895    if exclude_prefixes.iter().any(|prefix| url_path.starts_with(prefix.as_str())) {
896        return None;
897    }
898
899    let separator = FileExt::get_path_separator();
900    let normalized_name = fallback_name.trim_start_matches('/').replace('/', &separator);
901    let fallback_url_path = format!("{}{}", separator, normalized_name);
902
903    let boxed_fs_path = static_fs_path(&fallback_url_path);
904    let fs_path = boxed_fs_path.ok()?;
905    if File::open(&fs_path).is_ok() {
906        Some(fallback_url_path)
907    } else {
908        None
909    }
910}
911
912fn html_escape(s: &str) -> String {
913    let mut out = String::with_capacity(s.len());
914    for c in s.chars() {
915        match c {
916            '&' => out.push_str("&amp;"),
917            '<' => out.push_str("&lt;"),
918            '>' => out.push_str("&gt;"),
919            '"' => out.push_str("&quot;"),
920            '\'' => out.push_str("&#39;"),
921            _ => out.push(c),
922        }
923    }
924    out
925}
926
927fn human_size(bytes: u64) -> String {
928    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
929    if bytes < 1024 {
930        return format!("{} {}", bytes, UNITS[0]);
931    }
932
933    let mut size = bytes as f64;
934    let mut unit_index = 0;
935    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
936        size /= 1024.0;
937        unit_index += 1;
938    }
939
940    format!("{:.1} {}", size, UNITS[unit_index])
941}
942
943fn format_modified(epoch_secs: u64) -> String {
944    let (sec, min, hour, day, month, _dow) = crate::scheduler::cron::epoch_to_datetime(epoch_secs);
945    let (year, _, _) = crate::scheduler::cron::days_to_ymd(epoch_secs / 86400);
946    format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec)
947}
948
949fn icon_for(name: &str, is_dir: bool) -> &'static str {
950    if is_dir {
951        return "\u{1F4C1}";
952    }
953
954    let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
955    match ext.as_str() {
956        "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" | "bmp" => "\u{1F5BC}\u{FE0F}",
957        "mp4" | "mov" | "avi" | "mkv" | "webm" => "\u{1F3A5}",
958        "mp3" | "wav" | "flac" | "m4a" | "ogg" => "\u{1F3A7}",
959        "zip" | "tar" | "gz" | "rar" | "7z" | "bz2" => "\u{1F4E6}",
960        "pdf" => "\u{1F4D5}",
961        "html" | "htm" | "css" | "js" | "ts" | "rs" | "py" | "json" | "toml" | "yaml" | "yml" | "sh" => "\u{1F9E9}",
962        _ => "\u{1F4C4}",
963    }
964}
965
966// CSS/JS are served as same-origin `<link>`/`<script src>` assets (see
967// `crate::app::controller::directory_listing::DirectoryListingAssetsController`)
968// rather than inlined here — inline `<style>`/`<script>` would be silently
969// blocked under the framework's default `Content-Security-Policy: default-src 'self'`.
970const DIRECTORY_LISTING_HEAD: &str = concat!(
971    "<!DOCTYPE html>\n",
972    "<html lang=\"en\">\n",
973    "<head>\n",
974    "<meta charset=\"UTF-8\">\n",
975    "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n",
976    "<title>Directory listing</title>\n",
977    "<link rel=\"stylesheet\" href=\"/rws-directory-listing.css\">\n",
978    "</head>\n",
979    "<body>\n",
980    "<div class=\"wrap\">\n",
981);
982
983const DIRECTORY_LISTING_TOOLBAR: &str = "<div class=\"toolbar\"><input type=\"search\" id=\"filter\" placeholder=\"Filter entries...\" autocomplete=\"off\"></div>\n";
984
985const DIRECTORY_LISTING_TAIL: &str = concat!(
986    "</div>\n",
987    "<script src=\"/rws-directory-listing.js\" defer></script>\n",
988    "</body>\n",
989    "</html>\n",
990);
991
992/// The process working directory — the static-file root — read once.
993/// `rws` never changes it after startup, and `std::env::current_dir()` is
994/// several syscalls (on macOS it opens "."): the static-file path used to ask
995/// for it four to six times per request. A library user who changes the
996/// working directory after the first request keeps the original root.
997/// Push `Last-Modified` (and `Last-Modified-Unix-Epoch-Nanos` with
998/// `nanos_headers`), and tell whether the request's validators say the
999/// client's copy is current ([`is_not_modified`]).
1000fn push_validators(request: &Request, response: &mut Response, modified_nanos: u128, etag: &str, nanos_headers: bool) -> bool {
1001    if nanos_headers {
1002        response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.into(), value: modified_nanos.to_string().into() });
1003    }
1004    response.headers.push(Header { name: Header::_LAST_MODIFIED.into(), value: crate::http::date::format((modified_nanos / 1_000_000_000) as u64).into() });
1005    is_not_modified(request, modified_nanos, etag)
1006}
1007
1008/// RFC 9110 §13.1: `If-None-Match` (the ETag, or `*`) decides when present;
1009/// otherwise `If-Modified-Since` for GET/HEAD — the file's modification
1010/// second is not after the given date. An unparseable date is ignored.
1011fn is_not_modified(request: &Request, modified_nanos: u128, etag: &str) -> bool {
1012    if let Some(inm) = request.header(Header::_IF_NONE_MATCH) {
1013        return if_none_match_matches(&inm.value, etag);
1014    }
1015    if request.method != METHOD.get && request.method != METHOD.head {
1016        return false;
1017    }
1018    request
1019        .header(Header::_IF_MODIFIED_SINCE)
1020        .and_then(|ims| crate::http::date::parse(&ims.value))
1021        .is_some_and(|since| (modified_nanos / 1_000_000_000) as u64 <= since)
1022}
1023
1024/// The static-file `Cache-Control` (`RWS_CONFIG_STATIC_CACHE_CONTROL`) in
1025/// place of the default `no-store, …`; an empty setting keeps the default.
1026fn set_static_cache_control(response: &mut Response, value: &str) {
1027    if value.is_empty() {
1028        return;
1029    }
1030    if let Some(header) = response.headers.iter_mut().find(|h| h.name == Header::_CACHE_CONTROL) {
1031        header.value = if value == crate::server_config::STATIC_CACHE_CONTROL_DEFAULT {
1032            std::borrow::Cow::Borrowed(crate::server_config::STATIC_CACHE_CONTROL_DEFAULT)
1033        } else {
1034            std::borrow::Cow::Owned(value.to_string())
1035        };
1036    }
1037}
1038
1039pub(crate) fn working_directory() -> &'static str {
1040    static DIR: std::sync::OnceLock<String> = std::sync::OnceLock::new();
1041    DIR.get_or_init(|| {
1042        std::env::current_dir()
1043            .map(|d| d.to_string_lossy().into_owned())
1044            .unwrap_or_default()
1045    })
1046}
1047
1048/// `If-None-Match` against the current entity tag (RFC 9110 §13.1.2): a
1049/// comma-separated list, or `*`, compared weakly — `W/"x"` matches `"x"`
1050/// (#56). Before 17.113.2 only a header equal to the tag matched, so a
1051/// client sending several tags, or a weak one, never got a `304`.
1052pub(crate) fn if_none_match_matches(header_value: &str, etag: &str) -> bool {
1053    let opaque = |tag: &str| tag.trim().strip_prefix("W/").unwrap_or(tag.trim()).to_string();
1054    let current = opaque(etag);
1055    header_value.split(',').any(|candidate| {
1056        let candidate = candidate.trim();
1057        candidate == "*" || (!candidate.is_empty() && opaque(candidate) == current)
1058    })
1059}
1060
1061/// `true` when the request has a `Range` and an `If-Range` that doesn't
1062/// match the file as it is now (RFC 9110 §13.1.5): an entity tag must match
1063/// the current `ETag` strongly (a weak tag never does); a date must equal the
1064/// file's `Last-Modified` and be a strong validator — at least a second
1065/// older than now, so a second write within that second can't share it.
1066/// Then the `Range` is ignored and the whole file is sent: the client's
1067/// piece is out of date.
1068pub(crate) fn range_is_stale(request: &Request) -> bool {
1069    let Some(if_range) = request.header(Header::_IF_RANGE) else { return false };
1070    if request.header(Header::_RANGE).is_none() {
1071        return false;
1072    }
1073    let validator = if_range.value.trim();
1074    let Some(path) = URL::request_target_path(&request.request_uri) else { return true };
1075    let Ok(fs_path) = static_fs_path(&os_specific_path(path)) else { return true };
1076    let (Ok(modified), Ok(md)) = (file_modified_utc(&fs_path), metadata(&fs_path)) else { return true };
1077    if validator.starts_with('"') {
1078        return validator != format!("\"{}-{}\"", modified, md.len());
1079    }
1080    let modified_secs = (modified / 1_000_000_000) as u64;
1081    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1082    match crate::http::date::parse(validator) {
1083        Some(date) => date != modified_secs || now < modified_secs + 1,
1084        None => true,
1085    }
1086}
1087
1088/// `FileExt::get_static_filepath` without the per-call `current_dir()`:
1089/// the working directory followed by `path` — refused (`Err`) when `path`
1090/// could leave the web root or names a hidden file (#43):
1091///
1092/// - a `..` segment (`/../etc/passwd`): served before 17.113.1;
1093/// - a segment starting with `.` (`.git/`, `.env`), except `.well-known`,
1094///   unless `RWS_CONFIG_SERVE_DOTFILES=true`.
1095///
1096/// A symlink inside the root may still point outside it; [`inside_root`]
1097/// checks the resolved path where files are opened.
1098///
1099/// `path` is the request path as sent: it's percent-decoded here, once,
1100/// before those checks (#55) — `/my%20file.txt` is `my file.txt`, and
1101/// `/%2e%2e/` is refused like `/../`.
1102pub(crate) fn static_fs_path(path: &str) -> Result<String, String> {
1103    let path = percent_decode_path(path)?;
1104    check_request_path(&path)?;
1105    let working_directory = working_directory();
1106    let mut fs_path = String::with_capacity(working_directory.len() + path.len());
1107    fs_path.push_str(working_directory);
1108    fs_path.push_str(&path);
1109    Ok(fs_path)
1110}
1111
1112/// A URL path with `/` turned into the OS path separator — borrowed (no
1113/// copy) where the separator is `/`.
1114pub(crate) fn os_specific_path(path: &str) -> std::borrow::Cow<'_, str> {
1115    if std::path::MAIN_SEPARATOR == '/' {
1116        std::borrow::Cow::Borrowed(path)
1117    } else {
1118        std::borrow::Cow::Owned(path.replace('/', std::path::MAIN_SEPARATOR_STR))
1119    }
1120}
1121
1122/// Percent-decode a request path (RFC 3986 §2.1). `+` stays `+` (it means
1123/// a space only in form-encoded queries); a `%` not followed by two hex
1124/// digits is kept as is; a result that isn't UTF-8 is an error. Borrows
1125/// when there is nothing to decode.
1126pub(crate) fn percent_decode_path(path: &str) -> Result<std::borrow::Cow<'_, str>, String> {
1127    if !path.contains('%') {
1128        return Ok(std::borrow::Cow::Borrowed(path));
1129    }
1130    let hex = |b: u8| (b as char).to_digit(16).map(|d| d as u8);
1131    let bytes = path.as_bytes();
1132    let mut decoded = Vec::with_capacity(bytes.len());
1133    let mut i = 0;
1134    while i < bytes.len() {
1135        if bytes[i] == b'%' && i + 2 < bytes.len() {
1136            if let (Some(high), Some(low)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
1137                decoded.push(high << 4 | low);
1138                i += 3;
1139                continue;
1140            }
1141        }
1142        decoded.push(bytes[i]);
1143        i += 1;
1144    }
1145    String::from_utf8(decoded)
1146        .map(std::borrow::Cow::Owned)
1147        .map_err(|_| "path is not UTF-8 after percent-decoding".to_string())
1148}
1149
1150/// The segment rules of [`static_fs_path`], on a path using `/` or the
1151/// platform separator.
1152pub(crate) fn check_request_path(path: &str) -> Result<(), String> {
1153    for segment in path.split(['/', '\\']) {
1154        if segment == ".." {
1155            return Err("path leaves the web root".to_string());
1156        }
1157        if segment.starts_with('.') && segment != "." && segment != ".well-known" && !serve_dotfiles() {
1158            return Err("hidden files are not served".to_string());
1159        }
1160        if segment.contains('\0') {
1161            return Err("invalid path".to_string());
1162        }
1163    }
1164    // Keys and the config file, wherever they sit in the root (#45).
1165    let name = path.rsplit(['/', '\\']).next().unwrap_or("").to_ascii_lowercase();
1166    if name.ends_with(".pem") || name.ends_with(".key") || name == "rws.config.toml" {
1167        return Err("key and config files are not served".to_string());
1168    }
1169    Ok(())
1170}
1171
1172/// `RWS_CONFIG_SERVE_DOTFILES` (default `false`), read once.
1173fn serve_dotfiles() -> bool {
1174    static SERVE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1175    *SERVE.get_or_init(|| {
1176        std::env::var("RWS_CONFIG_SERVE_DOTFILES").is_ok_and(|v| v.trim().eq_ignore_ascii_case("true"))
1177    })
1178}
1179
1180/// `true` when `fs_path` exists and, symlinks resolved, lies inside the web
1181/// root (the working directory). A symlink in the root pointing outside it
1182/// (`assets -> /etc`) is not followed out (#43). One `realpath`: done where
1183/// a file is opened, not per request on the cached fast path.
1184pub(crate) fn inside_root(fs_path: &str) -> bool {
1185    static ROOT: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
1186    let Some(root) = ROOT.get_or_init(|| std::fs::canonicalize(working_directory()).ok()) else {
1187        return false;
1188    };
1189    std::fs::canonicalize(fs_path).is_ok_and(|resolved| resolved.starts_with(root))
1190}
1191
1192/// `FileExt::file_modified_utc` (nanoseconds since the Unix epoch) from a
1193/// `stat`, without opening the file.
1194pub(crate) fn file_modified_utc(path: &str) -> Result<u128, String> {
1195    let modified = metadata(path)
1196        .and_then(|md| md.modified())
1197        .map_err(|e| format!("<p>Unable to open file: {}</p> <p>error: {}</p>", path, e))?;
1198    Ok(modified
1199        .duration_since(std::time::UNIX_EPOCH)
1200        .map(|d| d.as_nanos())
1201        .unwrap_or(0))
1202}
1203
1204
1205/// A file offered as a compression dictionary (#38): `Use-As-Dictionary`
1206/// with its pattern, and `Vary` on what picks the representation.
1207fn mark_dictionary(response: &mut Response, pattern: Option<&str>) {
1208    let Some(pattern) = pattern else { return };
1209    response.headers.push(Header { name: "Use-As-Dictionary".into(), value: dictionaries::use_as_dictionary(pattern).into() });
1210    crate::compression::dictionary::add_vary(response, "Accept-Encoding");
1211    crate::compression::dictionary::add_vary(response, "Available-Dictionary");
1212}