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