Skip to main content

fastapi_core/
static_files.rs

1//! Static file serving for fastapi_rust.
2//!
3//! This module provides utilities for serving static files from directories.
4//! It includes security measures, caching support, and various configuration options.
5//!
6//! # Features
7//!
8//! - Directory mounting at path prefix
9//! - Index file support (index.html by default)
10//! - Content-Type detection from file extension
11//! - ETag generation for caching
12//! - Last-Modified headers
13//! - Optional directory listing
14//! - Symlink handling (configurable)
15//! - Path traversal prevention
16//! - Hidden file exclusion
17//!
18//! # Example
19//!
20//! ```ignore
21//! use fastapi_core::static_files::{StaticFiles, StaticFilesConfig};
22//!
23//! // Basic usage - serve ./public at /static
24//! let static_handler = StaticFiles::new("./public")
25//!     .prefix("/static");
26//!
27//! // Advanced configuration
28//! let static_handler = StaticFiles::with_config(StaticFilesConfig {
29//!     directory: "./assets".into(),
30//!     prefix: "/assets".into(),
31//!     index_files: vec!["index.html".into(), "index.htm".into()],
32//!     show_hidden: false,
33//!     follow_symlinks: false,
34//!     enable_etag: true,
35//!     enable_last_modified: true,
36//!     directory_listing: false,
37//!     ..Default::default()
38//! });
39//! ```
40//!
41//! # Security
42//!
43//! This module implements several security measures:
44//!
45//! - **Path traversal prevention**: Requests containing `..` or attempting to
46//!   escape the root directory are rejected with 403 Forbidden.
47//! - **Hidden file exclusion**: Files starting with `.` are not served by default.
48//! - **Symlink protection**: Symlinks are not followed by default to prevent
49//!   serving files outside the intended directory.
50
51use std::path::{Path, PathBuf};
52use std::time::SystemTime;
53
54use crate::response::{Response, ResponseBody, StatusCode, mime_type_for_extension};
55
56/// Configuration for static file serving.
57#[derive(Debug, Clone)]
58#[allow(clippy::struct_excessive_bools)]
59pub struct StaticFilesConfig {
60    /// Root directory to serve files from.
61    pub directory: PathBuf,
62    /// URL path prefix (e.g., "/static").
63    pub prefix: String,
64    /// Index files to look for in directories.
65    pub index_files: Vec<String>,
66    /// Whether to serve hidden files (starting with `.`).
67    pub show_hidden: bool,
68    /// Whether to follow symlinks.
69    pub follow_symlinks: bool,
70    /// Whether to generate ETag headers.
71    pub enable_etag: bool,
72    /// Whether to add Last-Modified headers.
73    pub enable_last_modified: bool,
74    /// Whether to enable directory listing.
75    pub directory_listing: bool,
76    /// Custom 404 page path (relative to directory).
77    pub not_found_page: Option<String>,
78    /// Additional headers to add to all responses.
79    pub extra_headers: Vec<(String, String)>,
80}
81
82impl Default for StaticFilesConfig {
83    fn default() -> Self {
84        Self {
85            directory: PathBuf::from("."),
86            prefix: String::new(),
87            index_files: vec!["index.html".to_string()],
88            show_hidden: false,
89            follow_symlinks: false,
90            enable_etag: true,
91            enable_last_modified: true,
92            directory_listing: false,
93            not_found_page: None,
94            extra_headers: Vec::new(),
95        }
96    }
97}
98
99impl StaticFilesConfig {
100    /// Create a new configuration with the given directory.
101    #[must_use]
102    pub fn new(directory: impl Into<PathBuf>) -> Self {
103        Self {
104            directory: directory.into(),
105            ..Default::default()
106        }
107    }
108
109    /// Set the URL path prefix.
110    #[must_use]
111    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
112        self.prefix = prefix.into();
113        self
114    }
115
116    /// Set the index files to look for.
117    #[must_use]
118    pub fn index_files(mut self, files: Vec<String>) -> Self {
119        self.index_files = files;
120        self
121    }
122
123    /// Enable or disable showing hidden files.
124    #[must_use]
125    pub fn show_hidden(mut self, show: bool) -> Self {
126        self.show_hidden = show;
127        self
128    }
129
130    /// Enable or disable following symlinks.
131    #[must_use]
132    pub fn follow_symlinks(mut self, follow: bool) -> Self {
133        self.follow_symlinks = follow;
134        self
135    }
136
137    /// Enable or disable ETag generation.
138    #[must_use]
139    pub fn enable_etag(mut self, enable: bool) -> Self {
140        self.enable_etag = enable;
141        self
142    }
143
144    /// Enable or disable Last-Modified headers.
145    #[must_use]
146    pub fn enable_last_modified(mut self, enable: bool) -> Self {
147        self.enable_last_modified = enable;
148        self
149    }
150
151    /// Enable or disable directory listing.
152    #[must_use]
153    pub fn directory_listing(mut self, enable: bool) -> Self {
154        self.directory_listing = enable;
155        self
156    }
157
158    /// Set a custom 404 page.
159    #[must_use]
160    pub fn not_found_page(mut self, page: impl Into<String>) -> Self {
161        self.not_found_page = Some(page.into());
162        self
163    }
164
165    /// Add an extra header to all responses.
166    #[must_use]
167    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
168        self.extra_headers.push((name.into(), value.into()));
169        self
170    }
171}
172
173/// Static file server.
174///
175/// Serves files from a directory with various security and caching features.
176///
177/// # Example
178///
179/// ```ignore
180/// use fastapi_core::static_files::StaticFiles;
181///
182/// let handler = StaticFiles::new("./public")
183///     .prefix("/static")
184///     .index_file("index.html");
185/// ```
186#[derive(Debug, Clone)]
187pub struct StaticFiles {
188    config: StaticFilesConfig,
189}
190
191impl StaticFiles {
192    /// Create a new static file server for the given directory.
193    #[must_use]
194    pub fn new(directory: impl Into<PathBuf>) -> Self {
195        Self {
196            config: StaticFilesConfig::new(directory),
197        }
198    }
199
200    /// Create a static file server with full configuration.
201    #[must_use]
202    pub fn with_config(config: StaticFilesConfig) -> Self {
203        Self { config }
204    }
205
206    /// Set the URL path prefix.
207    #[must_use]
208    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
209        self.config.prefix = prefix.into();
210        self
211    }
212
213    /// Set the index file to look for in directories.
214    #[must_use]
215    pub fn index_file(mut self, file: impl Into<String>) -> Self {
216        self.config.index_files = vec![file.into()];
217        self
218    }
219
220    /// Enable directory listing.
221    #[must_use]
222    pub fn enable_directory_listing(mut self) -> Self {
223        self.config.directory_listing = true;
224        self
225    }
226
227    /// Enable following symlinks.
228    #[must_use]
229    pub fn follow_symlinks(mut self) -> Self {
230        self.config.follow_symlinks = true;
231        self
232    }
233
234    /// Serve a request for a static file.
235    ///
236    /// # Arguments
237    ///
238    /// * `request_path` - The URL path of the request (e.g., "/static/css/style.css")
239    ///
240    /// # Returns
241    ///
242    /// A response containing the file contents, or an error response (404, 403, etc.)
243    pub fn serve(&self, request_path: &str) -> Response {
244        // Strip prefix from path
245        let path_without_prefix = self.strip_prefix(request_path);
246
247        // Security: prevent path traversal
248        if !is_safe_path(path_without_prefix) {
249            return Response::with_status(StatusCode::FORBIDDEN)
250                .header("content-type", b"text/plain".to_vec())
251                .body(ResponseBody::Bytes(b"Forbidden: Invalid path".to_vec()));
252        }
253
254        // Build the full file path
255        let file_path = self
256            .config
257            .directory
258            .join(path_without_prefix.trim_start_matches('/'));
259
260        // Canonicalize to resolve any remaining path tricks
261        let Some(canonical_path) = self.resolve_path(&file_path) else {
262            return self.not_found_response();
263        };
264
265        // Ensure the resolved path is within our directory
266        let Ok(canonical_dir) = self.config.directory.canonicalize() else {
267            return self.not_found_response();
268        };
269
270        if !canonical_path.starts_with(&canonical_dir) {
271            return Response::with_status(StatusCode::FORBIDDEN)
272                .header("content-type", b"text/plain".to_vec())
273                .body(ResponseBody::Bytes(
274                    b"Forbidden: Path traversal detected".to_vec(),
275                ));
276        }
277
278        // Check for hidden path components before serving
279        if !self.config.show_hidden && has_hidden_component(&canonical_path) {
280            return self.not_found_response();
281        }
282
283        // Check if it's a directory
284        if canonical_path.is_dir() {
285            return self.serve_directory(&canonical_path, request_path);
286        }
287
288        // Serve the file
289        self.serve_file(&canonical_path)
290    }
291
292    /// Strip the URL prefix from the request path.
293    fn strip_prefix<'a>(&self, path: &'a str) -> &'a str {
294        if self.config.prefix.is_empty() {
295            return path;
296        }
297
298        path.strip_prefix(&self.config.prefix).unwrap_or(path)
299    }
300
301    /// Resolve the file path, handling symlinks according to config.
302    fn resolve_path(&self, path: &Path) -> Option<PathBuf> {
303        if self.config.follow_symlinks {
304            // Follow symlinks - use canonicalize which resolves all symlinks
305            path.canonicalize().ok()
306        } else {
307            // Don't follow symlinks - check each path component
308            if !path.exists() {
309                return None;
310            }
311
312            // Walk from the root directory to the target, checking for symlinks
313            let canonical_dir = self.config.directory.canonicalize().ok()?;
314            let relative_path = path.strip_prefix(&self.config.directory).ok()?;
315
316            let mut current = canonical_dir.clone();
317            for component in relative_path.components() {
318                current.push(component);
319
320                // Check if this component is a symlink
321                let metadata = std::fs::symlink_metadata(&current).ok()?;
322                if metadata.file_type().is_symlink() {
323                    return None; // Reject any symlink in the path
324                }
325            }
326
327            // Return the built path (without following symlinks)
328            Some(current)
329        }
330    }
331
332    /// Serve a directory (index file or listing).
333    fn serve_directory(&self, dir_path: &Path, request_path: &str) -> Response {
334        // Try index files
335        for index_file in &self.config.index_files {
336            // Security: validate index file path to prevent traversal
337            if !is_safe_path(index_file) {
338                continue;
339            }
340            let index_path = dir_path.join(index_file);
341            // Verify the resolved path is still within the directory
342            if let Ok(canonical) = index_path.canonicalize() {
343                if !canonical.starts_with(dir_path) {
344                    continue;
345                }
346                if canonical.is_file() {
347                    return self.serve_file(&canonical);
348                }
349            }
350        }
351
352        // Directory listing if enabled
353        if self.config.directory_listing {
354            return self.generate_directory_listing(dir_path, request_path);
355        }
356
357        // No index file and no listing - return 404
358        self.not_found_response()
359    }
360
361    /// Serve a single file.
362    fn serve_file(&self, file_path: &Path) -> Response {
363        // Check for hidden files in any path component (not just the leaf)
364        if !self.config.show_hidden && has_hidden_component(file_path) {
365            return self.not_found_response();
366        }
367
368        // Read file contents
369        let Ok(contents) = std::fs::read(file_path) else {
370            return self.not_found_response();
371        };
372
373        // Get file metadata for caching headers
374        let metadata = std::fs::metadata(file_path).ok();
375
376        // Determine content type
377        let content_type = file_path
378            .extension()
379            .and_then(|ext| ext.to_str())
380            .map(mime_type_for_extension)
381            .unwrap_or("application/octet-stream");
382
383        let mut response = Response::ok()
384            .header("content-type", content_type.as_bytes().to_vec())
385            .header("accept-ranges", b"bytes".to_vec());
386
387        // Add ETag if enabled
388        if self.config.enable_etag {
389            let etag = generate_etag(&contents);
390            response = response.header("etag", etag.into_bytes());
391        }
392
393        // Add Last-Modified if enabled
394        if self.config.enable_last_modified {
395            if let Some(ref meta) = metadata {
396                if let Ok(modified) = meta.modified() {
397                    let http_date = format_http_date(modified);
398                    response = response.header("last-modified", http_date.into_bytes());
399                }
400            }
401        }
402
403        // Add extra headers
404        for (name, value) in &self.config.extra_headers {
405            response = response.header(name.clone(), value.clone().into_bytes());
406        }
407
408        response.body(ResponseBody::Bytes(contents))
409    }
410
411    /// Generate a directory listing HTML page.
412    fn generate_directory_listing(&self, dir_path: &Path, request_path: &str) -> Response {
413        let mut entries = Vec::new();
414
415        // Add parent directory link if not at root
416        if request_path != "/" && request_path != self.config.prefix {
417            entries.push(DirectoryEntry {
418                name: "..".to_string(),
419                is_dir: true,
420                size: 0,
421                modified: None,
422            });
423        }
424
425        // Read directory entries
426        if let Ok(read_dir) = std::fs::read_dir(dir_path) {
427            for entry in read_dir.flatten() {
428                let name = entry.file_name().to_string_lossy().to_string();
429
430                // Skip hidden files if not showing them
431                if !self.config.show_hidden && name.starts_with('.') {
432                    continue;
433                }
434
435                if let Ok(metadata) = entry.metadata() {
436                    entries.push(DirectoryEntry {
437                        name,
438                        is_dir: metadata.is_dir(),
439                        size: metadata.len(),
440                        modified: metadata.modified().ok(),
441                    });
442                }
443            }
444        }
445
446        // Sort: directories first, then by name
447        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
448            (true, false) => std::cmp::Ordering::Less,
449            (false, true) => std::cmp::Ordering::Greater,
450            _ => a.name.cmp(&b.name),
451        });
452
453        // Generate HTML
454        let html = generate_listing_html(request_path, &entries);
455
456        Response::ok()
457            .header("content-type", b"text/html; charset=utf-8".to_vec())
458            .body(ResponseBody::Bytes(html.into_bytes()))
459    }
460
461    /// Generate a 404 response, optionally using custom page.
462    fn not_found_response(&self) -> Response {
463        if let Some(ref not_found_path) = self.config.not_found_page {
464            // Security: validate path to prevent traversal
465            if is_safe_path(not_found_path) {
466                let path = self.config.directory.join(not_found_path);
467                // Verify the resolved path is within the configured directory
468                if let Ok(canonical) = path.canonicalize() {
469                    if let Ok(base_canonical) = self.config.directory.canonicalize() {
470                        if canonical.starts_with(&base_canonical) {
471                            if let Ok(contents) = std::fs::read(&canonical) {
472                                let content_type = canonical
473                                    .extension()
474                                    .and_then(|ext| ext.to_str())
475                                    .map(mime_type_for_extension)
476                                    .unwrap_or("text/html; charset=utf-8");
477
478                                return Response::with_status(StatusCode::NOT_FOUND)
479                                    .header("content-type", content_type.as_bytes().to_vec())
480                                    .body(ResponseBody::Bytes(contents));
481                            }
482                        }
483                    }
484                }
485            }
486        }
487
488        Response::with_status(StatusCode::NOT_FOUND)
489            .header("content-type", b"text/plain".to_vec())
490            .body(ResponseBody::Bytes(b"Not Found".to_vec()))
491    }
492}
493
494/// Check if any component of a path starts with `.` (hidden file/directory).
495fn has_hidden_component(path: &Path) -> bool {
496    path.components().any(|c| {
497        c.as_os_str()
498            .to_str()
499            .is_some_and(|s| s.starts_with('.') && s != "." && s != "..")
500    })
501}
502
503/// Check if a path is safe (no path traversal attempts).
504fn is_safe_path(path: &str) -> bool {
505    // Reject paths with null bytes
506    if path.contains('\0') {
507        return false;
508    }
509
510    // Decode percent-encoded characters first to catch encoded traversal
511    let decoded = percent_decode(path);
512
513    // Reject paths with null bytes in decoded form
514    if decoded.contains('\0') {
515        return false;
516    }
517
518    // Reject paths with .. components (check decoded path)
519    for component in decoded.split('/') {
520        if component == ".." {
521            return false;
522        }
523    }
524
525    true
526}
527
528/// Simple percent-decoding for path safety checks.
529///
530/// Decodes percent-encoded bytes into a string. Invalid UTF-8 sequences
531/// are replaced with the Unicode replacement character.
532fn percent_decode(s: &str) -> String {
533    let bytes = s.as_bytes();
534    let mut decoded_bytes = Vec::with_capacity(bytes.len());
535    let mut i = 0;
536
537    while i < bytes.len() {
538        if bytes[i] == b'%' && i + 2 < bytes.len() {
539            let hi = hex_val(bytes[i + 1]);
540            let lo = hex_val(bytes[i + 2]);
541            if let (Some(h), Some(l)) = (hi, lo) {
542                decoded_bytes.push((h << 4) | l);
543                i += 3;
544                continue;
545            }
546        }
547        decoded_bytes.push(bytes[i]);
548        i += 1;
549    }
550
551    String::from_utf8_lossy(&decoded_bytes).into_owned()
552}
553
554fn hex_val(b: u8) -> Option<u8> {
555    match b {
556        b'0'..=b'9' => Some(b - b'0'),
557        b'a'..=b'f' => Some(b - b'a' + 10),
558        b'A'..=b'F' => Some(b - b'A' + 10),
559        _ => None,
560    }
561}
562
563/// Generate an ETag from file contents using FNV-1a hash.
564fn generate_etag(contents: &[u8]) -> String {
565    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
566    const FNV_PRIME: u64 = 0x100000001b3;
567
568    let mut hash = FNV_OFFSET_BASIS;
569    for &byte in contents {
570        hash ^= u64::from(byte);
571        hash = hash.wrapping_mul(FNV_PRIME);
572    }
573
574    format!("\"{:016x}\"", hash)
575}
576
577/// Format a SystemTime as an HTTP date (RFC 7231).
578fn format_http_date(time: SystemTime) -> String {
579    match time.duration_since(std::time::UNIX_EPOCH) {
580        Ok(duration) => {
581            let secs = duration.as_secs();
582            let days = secs / 86400;
583            let remaining_secs = secs % 86400;
584            let hours = remaining_secs / 3600;
585            let minutes = (remaining_secs % 3600) / 60;
586            let seconds = remaining_secs % 60;
587
588            let day_of_week = ((days + 4) % 7) as usize;
589            let day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
590
591            let (year, month, day) = days_to_date(days);
592            let month_names = [
593                "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
594            ];
595
596            format!(
597                "{}, {:02} {} {} {:02}:{:02}:{:02} GMT",
598                day_names[day_of_week],
599                day,
600                month_names[(month - 1) as usize],
601                year,
602                hours,
603                minutes,
604                seconds
605            )
606        }
607        Err(_) => "Thu, 01 Jan 1970 00:00:00 GMT".to_string(),
608    }
609}
610
611/// Convert days since UNIX epoch to (year, month, day).
612fn days_to_date(days: u64) -> (u64, u64, u64) {
613    let mut remaining_days = days;
614    let mut year = 1970u64;
615
616    loop {
617        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
618        if remaining_days < days_in_year {
619            break;
620        }
621        remaining_days -= days_in_year;
622        year += 1;
623    }
624
625    let leap = is_leap_year(year);
626    let month_days: [u64; 12] = if leap {
627        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
628    } else {
629        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
630    };
631
632    let mut month = 1u64;
633    for &days_in_month in &month_days {
634        if remaining_days < days_in_month {
635            break;
636        }
637        remaining_days -= days_in_month;
638        month += 1;
639    }
640
641    (year, month, remaining_days + 1)
642}
643
644/// Check if a year is a leap year.
645fn is_leap_year(year: u64) -> bool {
646    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
647}
648
649/// Directory entry for listing.
650struct DirectoryEntry {
651    name: String,
652    is_dir: bool,
653    size: u64,
654    modified: Option<SystemTime>,
655}
656
657/// Generate HTML for directory listing.
658fn generate_listing_html(path: &str, entries: &[DirectoryEntry]) -> String {
659    let mut html = String::new();
660
661    html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
662    html.push_str("<meta charset=\"utf-8\">\n");
663    html.push_str(&format!("<title>Index of {}</title>\n", escape_html(path)));
664    html.push_str("<style>\n");
665    html.push_str("body { font-family: monospace; margin: 20px; }\n");
666    html.push_str("h1 { border-bottom: 1px solid #ccc; padding-bottom: 10px; }\n");
667    html.push_str("table { border-collapse: collapse; width: 100%; }\n");
668    html.push_str("th, td { text-align: left; padding: 8px; border-bottom: 1px solid #eee; }\n");
669    html.push_str("th { background: #f5f5f5; }\n");
670    html.push_str("a { text-decoration: none; color: #0066cc; }\n");
671    html.push_str("a:hover { text-decoration: underline; }\n");
672    html.push_str(".dir { font-weight: bold; }\n");
673    html.push_str(".size { text-align: right; }\n");
674    html.push_str("</style>\n");
675    html.push_str("</head>\n<body>\n");
676
677    html.push_str(&format!("<h1>Index of {}</h1>\n", escape_html(path)));
678    html.push_str("<table>\n");
679    html.push_str("<tr><th>Name</th><th>Size</th><th>Modified</th></tr>\n");
680
681    for entry in entries {
682        let href = if entry.name == ".." {
683            parent_path(path)
684        } else if entry.is_dir {
685            format!("{}/{}/", path.trim_end_matches('/'), &entry.name)
686        } else {
687            format!("{}/{}", path.trim_end_matches('/'), &entry.name)
688        };
689
690        let class = if entry.is_dir { " class=\"dir\"" } else { "" };
691        let display_name = if entry.is_dir {
692            format!("{}/", &entry.name)
693        } else {
694            entry.name.clone()
695        };
696
697        let size_str = if entry.is_dir {
698            "-".to_string()
699        } else {
700            format_size(entry.size)
701        };
702
703        let modified_str = entry
704            .modified
705            .map(|t| format_http_date(t))
706            .unwrap_or_else(|| "-".to_string());
707
708        html.push_str(&format!(
709            "<tr><td{}><a href=\"{}\">{}</a></td><td class=\"size\">{}</td><td>{}</td></tr>\n",
710            class,
711            escape_html(&href),
712            escape_html(&display_name),
713            size_str,
714            modified_str
715        ));
716    }
717
718    html.push_str("</table>\n");
719    html.push_str("<hr>\n<p>fastapi_rust static file server</p>\n");
720    html.push_str("</body>\n</html>");
721
722    html
723}
724
725/// Get the parent path.
726fn parent_path(path: &str) -> String {
727    let trimmed = path.trim_end_matches('/');
728    match trimmed.rfind('/') {
729        Some(pos) if pos > 0 => format!("{}/", &trimmed[..pos]),
730        _ => "/".to_string(),
731    }
732}
733
734/// Escape HTML special characters.
735fn escape_html(s: &str) -> String {
736    s.replace('&', "&amp;")
737        .replace('<', "&lt;")
738        .replace('>', "&gt;")
739        .replace('"', "&quot;")
740}
741
742/// Format file size for display.
743#[allow(clippy::cast_precision_loss)]
744fn format_size(size: u64) -> String {
745    const KB: u64 = 1024;
746    const MB: u64 = 1024 * KB;
747    const GB: u64 = 1024 * MB;
748
749    if size >= GB {
750        format!("{:.1}G", size as f64 / GB as f64)
751    } else if size >= MB {
752        format!("{:.1}M", size as f64 / MB as f64)
753    } else if size >= KB {
754        format!("{:.1}K", size as f64 / KB as f64)
755    } else {
756        format!("{}", size)
757    }
758}
759
760// ============================================================================
761// Tests
762// ============================================================================
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767
768    #[test]
769    fn safe_path_normal() {
770        assert!(is_safe_path("/static/css/style.css"));
771        assert!(is_safe_path("/images/logo.png"));
772        assert!(is_safe_path("/"));
773        assert!(is_safe_path(""));
774    }
775
776    #[test]
777    fn safe_path_traversal_blocked() {
778        assert!(!is_safe_path("/../etc/passwd"));
779        assert!(!is_safe_path("/static/../../../etc/passwd"));
780        assert!(!is_safe_path(".."));
781        assert!(!is_safe_path("/.."));
782    }
783
784    #[test]
785    fn safe_path_encoded_traversal_blocked() {
786        assert!(!is_safe_path("/%2e%2e/etc/passwd"));
787        assert!(!is_safe_path("/static/%2e%2e/%2e%2e/etc/passwd"));
788    }
789
790    #[test]
791    fn safe_path_allows_double_dots_in_filename() {
792        // Legitimate filenames with double dots should be allowed
793        assert!(is_safe_path("/files/test..data.txt"));
794        assert!(is_safe_path("/files/archive..tar.gz"));
795        assert!(is_safe_path("/files/version..1.2.txt"));
796    }
797
798    #[test]
799    fn safe_path_null_byte_blocked() {
800        assert!(!is_safe_path("/static/file\0.txt"));
801    }
802
803    #[test]
804    fn percent_decode_works() {
805        assert_eq!(percent_decode("%2e%2e"), "..");
806        assert_eq!(percent_decode("%2F"), "/");
807        assert_eq!(percent_decode("hello%20world"), "hello world");
808        assert_eq!(percent_decode("normal"), "normal");
809    }
810
811    #[test]
812    fn etag_generation() {
813        let contents = b"Hello, World!";
814        let etag = generate_etag(contents);
815        assert!(etag.starts_with('"'));
816        assert!(etag.ends_with('"'));
817        assert_eq!(etag.len(), 18); // 16 hex chars + 2 quotes
818    }
819
820    #[test]
821    fn etag_deterministic() {
822        let contents = b"test data";
823        let etag1 = generate_etag(contents);
824        let etag2 = generate_etag(contents);
825        assert_eq!(etag1, etag2);
826    }
827
828    #[test]
829    fn etag_different_for_different_content() {
830        let etag1 = generate_etag(b"content 1");
831        let etag2 = generate_etag(b"content 2");
832        assert_ne!(etag1, etag2);
833    }
834
835    #[test]
836    fn format_size_bytes() {
837        assert_eq!(format_size(0), "0");
838        assert_eq!(format_size(100), "100");
839        assert_eq!(format_size(1023), "1023");
840    }
841
842    #[test]
843    fn format_size_kb() {
844        assert_eq!(format_size(1024), "1.0K");
845        assert_eq!(format_size(2048), "2.0K");
846        assert_eq!(format_size(1536), "1.5K");
847    }
848
849    #[test]
850    fn format_size_mb() {
851        assert_eq!(format_size(1024 * 1024), "1.0M");
852        assert_eq!(format_size(5 * 1024 * 1024), "5.0M");
853    }
854
855    #[test]
856    fn format_size_gb() {
857        assert_eq!(format_size(1024 * 1024 * 1024), "1.0G");
858        assert_eq!(format_size(2 * 1024 * 1024 * 1024), "2.0G");
859    }
860
861    #[test]
862    fn escape_html_special_chars() {
863        assert_eq!(escape_html("<script>"), "&lt;script&gt;");
864        assert_eq!(escape_html("a & b"), "a &amp; b");
865        assert_eq!(escape_html("\"quoted\""), "&quot;quoted&quot;");
866    }
867
868    #[test]
869    fn parent_path_normal() {
870        assert_eq!(parent_path("/static/css/"), "/static/");
871        assert_eq!(parent_path("/static/"), "/");
872        assert_eq!(parent_path("/"), "/");
873    }
874
875    #[test]
876    fn config_builder() {
877        let config = StaticFilesConfig::new("./public")
878            .prefix("/static")
879            .show_hidden(false)
880            .directory_listing(true);
881
882        assert_eq!(config.directory, PathBuf::from("./public"));
883        assert_eq!(config.prefix, "/static");
884        assert!(!config.show_hidden);
885        assert!(config.directory_listing);
886    }
887
888    #[test]
889    fn static_files_builder() {
890        let handler = StaticFiles::new("./assets")
891            .prefix("/assets")
892            .index_file("index.htm")
893            .enable_directory_listing();
894
895        assert_eq!(handler.config.prefix, "/assets");
896        assert_eq!(handler.config.index_files, vec!["index.htm"]);
897        assert!(handler.config.directory_listing);
898    }
899
900    #[test]
901    fn leap_year_detection() {
902        assert!(!is_leap_year(1900)); // Divisible by 100 but not 400
903        assert!(is_leap_year(2000)); // Divisible by 400
904        assert!(is_leap_year(2024)); // Divisible by 4 but not 100
905        assert!(!is_leap_year(2023)); // Not divisible by 4
906    }
907
908    #[test]
909    fn http_date_format() {
910        let date = format_http_date(std::time::UNIX_EPOCH);
911        assert_eq!(date, "Thu, 01 Jan 1970 00:00:00 GMT");
912    }
913}