Skip to main content

rust_serv/handler/
handler.rs

1use crate::error::Error;
2use crate::file_service::{FileService, FileMetadata};
3use crate::handler::{RangeRequest, CompressionType, compress, parse_accept_encoding, should_skip_compression};
4use crate::mime_types::MimeDetector;
5use crate::path_security::PathValidator;
6use crate::utils::format_bytes;
7use crate::Config;
8use http_body_util::Full;
9use hyper::{Request, Response, header};
10use hyper::body::{Bytes, Incoming};
11use std::convert::Infallible;
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::fs;
15use std::time::SystemTime;
16
17/// HTTP request handler
18#[derive(Clone)]
19pub struct Handler {
20    config: Arc<Config>,
21    path_validator: PathValidator,
22}
23
24impl Handler {
25    pub fn new(config: Arc<Config>) -> Self {
26        let path_validator = PathValidator::new(config.root.clone());
27        Self {
28            config,
29            path_validator,
30        }
31    }
32
33    pub async fn handle_request(&self, req: Request<Incoming>) -> std::result::Result<Response<Full<Bytes>>, Infallible> {
34        let path = req.uri().path();
35
36        // Remove leading slash and URL decode
37        let clean_path = path.strip_prefix('/').unwrap_or(path);
38        let decoded_path = urlencoding::decode(clean_path).unwrap_or_else(|_| clean_path.to_string().into());
39
40        let full_path = self.config.root.join(decoded_path.as_ref());
41
42        // Check if it's a directory - if so, try index.html or serve directory listing
43        if FileService::is_directory(&full_path) {
44            let index_path = full_path.join("index.html");
45            if index_path.exists() {
46                return self.serve_file(&index_path, req).await;
47            }
48            // Serve directory listing if enabled
49            if self.config.enable_indexing {
50                return Ok(self.serve_directory_index(&full_path));
51            }
52            return Ok(self.serve_not_found());
53        }
54
55        // Validate path security
56        match self.path_validator.validate(&full_path) {
57            Ok(_) => self.serve_file(&full_path, req).await,
58            Err(Error::NotFound(_)) => Ok(self.serve_not_found()),
59            Err(Error::Forbidden(_)) => Ok(self.serve_forbidden()),
60            Err(_) => Ok(self.serve_internal_error()),
61        }
62    }
63
64    async fn serve_file(&self, path: &PathBuf, req: Request<Incoming>) -> std::result::Result<Response<Full<Bytes>>, Infallible> {
65        // Check for Range header
66        let range_header = req.headers().get("Range")
67            .and_then(|v| v.to_str().ok());
68
69        // Check for If-None-Match header (for ETag cache validation)
70        let if_none_match = req.headers().get("If-None-Match")
71            .and_then(|v| v.to_str().ok());
72
73        // Determine compression preference
74        let compression_type = if self.config.enable_compression {
75            parse_accept_encoding(req.headers())
76        } else {
77            CompressionType::None
78        };
79
80        match FileService::read_file(path) {
81            Ok(content) => {
82                let file_size = content.len() as u64;
83
84                // Generate ETag and Last-Modified based on file size and modification time
85                let etag = self.generate_etag(path, file_size);
86
87                // If-None-Match validation
88                if let Some(if_none_match) = if_none_match {
89                    if if_none_match == etag {
90                        // ETag matches, return 304 Not Modified
91                        let last_modified = self.generate_last_modified(path);
92                        return Ok(Response::builder()
93                            .status(304)
94                            .header("Content-Type", MimeDetector::detect(path).to_string())
95                            .header("ETag", etag)
96                            .header("Last-Modified", last_modified)
97                            .body(Full::new(Bytes::new()))
98                            .unwrap());
99                    }
100                }
101
102                // Handle range request
103                if let Some(range_header) = range_header {
104                    // Range requests are not compressed
105                    match RangeRequest::parse(range_header, file_size) {
106                        Ok(Some(range)) => {
107                            // Return 206 Partial Content
108                            let byte_range = range.to_range();
109                            let range_content = content[byte_range.clone()].to_vec();
110                            let range_size = range_content.len() as u64;
111
112                            let content_range_str = format!("bytes {}-{}/{}",
113                                range.start, range.end.unwrap_or(file_size - 1), file_size);
114                            let last_modified = self.generate_last_modified(path);
115
116                            return Ok(Response::builder()
117                                .status(206)
118                                .header("Content-Type", MimeDetector::detect(path).to_string())
119                                .header("Content-Range", content_range_str)
120                                .header("Content-Length", range_size.to_string())
121                                .header("Accept-Ranges", "bytes")
122                                .header("ETag", etag)
123                                .header("Last-Modified", last_modified)
124                                .header("Cache-Control", "public, max-age=86400")
125                                .body(Full::new(Bytes::from(range_content)))
126                                .unwrap());
127                        }
128                        Ok(None) => {
129                            // Invalid range, return full content with compression
130                            return Ok(self.serve_file_with_etag(path, content, etag, compression_type));
131                        }
132                        Err(_) => {
133                            // Parse error, return full content with compression
134                            return Ok(self.serve_file_with_etag(path, content, etag, compression_type));
135                        }
136                    }
137                } else {
138                    // No range header, return full content with compression
139                    Ok(self.serve_file_with_etag(path, content, etag, compression_type))
140                }
141            }
142            Err(Error::NotFound(_)) => Ok(self.serve_not_found()),
143            Err(Error::Forbidden(_)) => Ok(self.serve_forbidden()),
144            Err(_) => Ok(self.serve_internal_error()),
145        }
146    }
147
148    /// Generate ETag for a file based on size and modification time
149    fn generate_etag(&self, path: &PathBuf, file_size: u64) -> String {
150        let modified = fs::metadata(path)
151            .and_then(|m| m.modified())
152            .unwrap_or(SystemTime::UNIX_EPOCH);
153
154        let modified_secs = modified
155            .duration_since(SystemTime::UNIX_EPOCH)
156            .unwrap_or_default()
157            .as_secs();
158
159        format!(r#""{}-{}""#, modified_secs, file_size)
160    }
161
162    /// Generate Last-Modified header value
163    fn generate_last_modified(&self, path: &PathBuf) -> String {
164        let modified = fs::metadata(path)
165            .and_then(|m| m.modified())
166            .unwrap_or(SystemTime::UNIX_EPOCH);
167
168        let duration = modified
169            .duration_since(SystemTime::UNIX_EPOCH)
170            .unwrap_or_default();
171
172        let datetime = time::OffsetDateTime::from_unix_timestamp(duration.as_secs() as i64)
173            .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
174
175        // Manual formatting for RFC 2822
176        let format = time::format_description::parse("[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT")
177            .expect("Invalid format description");
178        datetime
179            .format(&format)
180            .unwrap_or_else(|_| "Thu, 01 Jan 1970 00:00:00 GMT".to_string())
181    }
182
183    fn serve_file_with_etag(&self, path: &PathBuf, content: Vec<u8>, etag: String, compression_type: CompressionType) -> Response<Full<Bytes>> {
184        let mime = MimeDetector::detect(path);
185        let content_type = mime.to_string();
186
187        // Determine if we should compress this content
188        let (final_content, content_encoding) = if compression_type != CompressionType::None
189            && !should_skip_compression(&content_type) {
190            match compress(&content, compression_type) {
191                Ok(compressed) => {
192                    // Use compressed content
193                    if compressed.len() < content.len() {
194                        // Compression was beneficial
195                        (compressed, Some(compression_type))
196                    } else {
197                        // Compression didn't help, use original
198                        (content, None)
199                    }
200                }
201                Err(_) => {
202                    // Compression failed, use original content
203                    (content, None)
204                }
205            }
206        } else {
207            // No compression or compression should be skipped
208            (content, None)
209        };
210
211        let last_modified = self.generate_last_modified(path);
212
213        // Build response
214        let mut builder = Response::builder()
215            .status(200)
216            .header("Content-Type", content_type)
217            .header("Content-Length", final_content.len().to_string())
218            .header("Accept-Ranges", "bytes")
219            .header("ETag", etag)
220            .header("Last-Modified", last_modified)
221            .header("Cache-Control", "public, max-age=86400");
222
223        // Add Content-Encoding header if compression was applied
224        if let Some(encoding) = content_encoding {
225            let encoding_value = match encoding {
226                CompressionType::Gzip => "gzip",
227                CompressionType::Brotli => "br",
228                CompressionType::None => unreachable!(),
229            };
230            builder = builder.header(header::CONTENT_ENCODING, encoding_value);
231            builder = builder.header(header::VARY, "Accept-Encoding");
232        }
233
234        builder.body(Full::new(Bytes::from(final_content))).unwrap()
235    }
236
237    fn serve_not_found(&self) -> Response<Full<Bytes>> {
238        Response::builder()
239            .status(404)
240            .header("Content-Type", "text/plain")
241            .body(Full::new(Bytes::from("404 Not Found")))
242            .unwrap()
243    }
244
245    fn serve_forbidden(&self) -> Response<Full<Bytes>> {
246        Response::builder()
247            .status(403)
248            .header("Content-Type", "text/plain")
249            .body(Full::new(Bytes::from("403 Forbidden")))
250            .unwrap()
251    }
252
253    fn serve_internal_error(&self) -> Response<Full<Bytes>> {
254        Response::builder()
255            .status(500)
256            .header("Content-Type", "text/plain")
257            .body(Full::new(Bytes::from("500 Internal Server Error")))
258            .unwrap()
259    }
260
261    fn serve_directory_index(&self, path: &PathBuf) -> Response<Full<Bytes>> {
262        let files = match FileService::list_directory(path) {
263            Ok(files) => files,
264            Err(_) => return self.serve_internal_error(),
265        };
266
267        let html = self.generate_directory_html(path, &files);
268        Response::builder()
269            .status(200)
270            .header("Content-Type", "text/html; charset=utf-8")
271            .body(Full::new(Bytes::from(html)))
272            .unwrap()
273    }
274
275    fn generate_directory_html(&self, path: &PathBuf, files: &[FileMetadata]) -> String {
276        let path_str = path.display();
277        let mut html = format!(r#"<!DOCTYPE html>
278<html>
279<head>
280    <title>Index of {}</title>
281    <style>
282        body {{ font-family: Arial, sans-serif; margin: 40px; }}
283        h1 {{ margin-bottom: 20px; }}
284        table {{ border-collapse: collapse; width: 100%; }}
285        th, td {{ text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }}
286        th {{ background-color: #f2f2f2; }}
287        a {{ color: #0066cc; text-decoration: none; }}
288        a:hover {{ text-decoration: underline; }}
289    </style>
290</head>
291<body>
292    <h1>Index of {}</h1>
293    <table>
294        <thead>
295            <tr>
296                <th>Name</th>
297                <th>Type</th>
298                <th>Size</th>
299            </tr>
300        </thead>
301        <tbody>
302"#, path_str, path_str);
303
304        // Add parent directory link if not at root
305        if path != &self.config.root {
306            html.push_str(&format!(
307                r#"<tr>
308                <td><a href="../">../</a></td>
309                <td>Directory</td>
310                <td>-</td>
311            </tr>"#
312            ));
313        }
314
315        for file in files {
316            let name = &file.name;
317            let file_type = if file.is_dir { "Directory" } else { "File" };
318            let size = if file.is_dir { "-" } else { &format_bytes(file.size) };
319
320            let link = if file.is_dir {
321                format!("{}/", name)
322            } else {
323                name.clone()
324            };
325
326            html.push_str(&format!(
327                r#"<tr>
328                <td><a href="{}">{}</a></td>
329                <td>{}</td>
330                <td>{}</td>
331            </tr>"#,
332                link, name, file_type, size
333            ));
334        }
335
336        html.push_str(r#"
337        </tbody>
338    </table>
339</body>
340</html>"#);
341
342        html
343    }
344}
345
346/// Handle incoming HTTP request (backward compatible function)
347pub async fn handle_request(
348    req: Request<Incoming>,
349) -> std::result::Result<Response<Full<Bytes>>, Infallible> {
350    let config = Arc::new(Config::default());
351    let handler = Handler::new(config);
352    handler.handle_request(req).await
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn test_handler_creation() {
361        let config = Arc::new(Config::default());
362        let handler = Handler::new(config);
363        // Handler should be created successfully
364        assert_eq!(handler.config.port, 8080);
365    }
366
367    #[test]
368    fn test_serve_not_found() {
369        let config = Arc::new(Config::default());
370        let handler = Handler::new(config);
371
372        let response = handler.serve_not_found();
373        assert_eq!(response.status(), 404);
374    }
375
376    #[test]
377    fn test_serve_forbidden() {
378        let config = Arc::new(Config::default());
379        let handler = Handler::new(config);
380
381        let response = handler.serve_forbidden();
382        assert_eq!(response.status(), 403);
383    }
384
385    #[test]
386    fn test_serve_internal_error() {
387        let config = Arc::new(Config::default());
388        let handler = Handler::new(config);
389
390        let response = handler.serve_internal_error();
391        assert_eq!(response.status(), 500);
392    }
393
394    #[test]
395    fn test_generate_etag() {
396        let config = Arc::new(Config::default());
397        let handler = Handler::new(config);
398
399        let temp_dir = tempfile::TempDir::new().unwrap();
400        let test_file = temp_dir.path().join("test.txt");
401        std::fs::write(&test_file, "test content").unwrap();
402
403        let file_size = std::fs::metadata(&test_file).unwrap().len();
404        let etag = handler.generate_etag(&test_file, file_size);
405
406        // ETag should be in format "timestamp-filesize"
407        assert!(etag.starts_with('"'));
408        assert!(etag.ends_with('"'));
409    }
410
411    #[test]
412    fn test_handler_with_custom_config() {
413        let config = Arc::new(Config {
414            port: 3000,
415            root: "/tmp".into(),
416            enable_indexing: true,
417            enable_compression: false,
418            log_level: "info".into(),
419            enable_tls: false,
420            tls_cert: None,
421            tls_key: None,
422            connection_timeout_secs: 30,
423            max_connections: 1000,
424            enable_health_check: true,
425            enable_cors: true,
426            cors_allowed_origins: vec!["*".to_string()],
427            cors_allowed_methods: vec!["GET".to_string()],
428            cors_allowed_headers: vec![],
429            cors_allow_credentials: false,
430            cors_exposed_headers: vec![],
431            cors_max_age: Some(86400),
432            enable_security: true,
433            rate_limit_max_requests: 100,
434            rate_limit_window_secs: 60,
435            ip_allowlist: vec![],
436            ip_blocklist: vec![],
437            max_body_size: 10 * 1024 * 1024,
438            max_headers: 100,
439            management: None,
440            auto_tls: None,
441        });
442        let handler = Handler::new(config);
443
444        assert_eq!(handler.config.port, 3000);
445        assert_eq!(handler.config.root, PathBuf::from("/tmp"));
446        assert!(handler.config.enable_indexing);
447        assert!(!handler.config.enable_compression);
448    }
449
450    #[test]
451    fn test_serve_file_with_etag() {
452        let config = Arc::new(Config::default());
453        let handler = Handler::new(config);
454
455        let temp_dir = tempfile::TempDir::new().unwrap();
456        let test_file = temp_dir.path().join("test.txt");
457        std::fs::write(&test_file, "test content").unwrap();
458
459        let content = std::fs::read(&test_file).unwrap();
460        let etag = handler.generate_etag(&test_file, content.len() as u64);
461        let etag_clone = etag.clone();
462        let response = handler.serve_file_with_etag(&test_file, content, etag, CompressionType::None);
463
464        assert_eq!(response.status(), 200);
465        assert_eq!(response.headers().get("ETag").unwrap().to_str().unwrap(), etag_clone);
466    }
467
468    #[test]
469    fn test_generate_etag_nonexistent_file() {
470        let config = Arc::new(Config::default());
471        let handler = Handler::new(config);
472
473        let temp_dir = tempfile::TempDir::new().unwrap();
474        let nonexistent_file = temp_dir.path().join("nonexistent.txt");
475
476        // Should still work even for nonexistent files (returns default time)
477        let etag = handler.generate_etag(&nonexistent_file, 100);
478
479        assert!(etag.starts_with('"'));
480        assert!(etag.ends_with('"'));
481    }
482
483    #[test]
484    fn test_generate_last_modified() {
485        let config = Arc::new(Config::default());
486        let handler = Handler::new(config);
487
488        let temp_dir = tempfile::TempDir::new().unwrap();
489        let test_file = temp_dir.path().join("test.txt");
490        std::fs::write(&test_file, "content").unwrap();
491
492        let last_modified = handler.generate_last_modified(&test_file);
493
494        assert!(last_modified.contains("GMT"));
495        assert!(last_modified.len() > 0);
496    }
497
498    #[test]
499    fn test_generate_directory_html_empty() {
500        let config = Arc::new(Config::default());
501        let handler = Handler::new(config);
502
503        let temp_dir = tempfile::TempDir::new().unwrap();
504        let files: Vec<FileMetadata> = vec![];
505
506        let html = handler.generate_directory_html(&temp_dir.path().to_path_buf(), &files);
507
508        assert!(html.contains("Index of"));
509        assert!(html.contains("</html>"));
510    }
511
512    #[test]
513    fn test_generate_directory_html_with_files() {
514        let config = Arc::new(Config::default());
515        let handler = Handler::new(config);
516
517        let temp_dir = tempfile::TempDir::new().unwrap();
518        let files = vec![
519            FileMetadata {
520                path: temp_dir.path().join("file1.txt").display().to_string(),
521                name: "file1.txt".to_string(),
522                size: 100,
523                is_dir: false,
524            },
525            FileMetadata {
526                path: temp_dir.path().join("dir1").display().to_string(),
527                name: "dir1".to_string(),
528                size: 0,
529                is_dir: true,
530            },
531        ];
532
533        let html = handler.generate_directory_html(&temp_dir.path().to_path_buf(), &files);
534
535        assert!(html.contains("file1.txt"));
536        assert!(html.contains("dir1"));
537        assert!(html.contains("File"));
538        assert!(html.contains("Directory"));
539        assert!(html.contains("100.00 B"));
540    }
541
542    #[test]
543    fn test_handler_root_path() {
544        let config = Arc::new(Config::default());
545        let handler = Handler::new(config);
546
547        assert_eq!(handler.config.root, PathBuf::from("."));
548    }
549
550    #[test]
551    fn test_serve_file_with_etag_includes_headers() {
552        let config = Arc::new(Config::default());
553        let handler = Handler::new(config);
554
555        let temp_dir = tempfile::TempDir::new().unwrap();
556        let test_file = temp_dir.path().join("test.html");
557        std::fs::write(&test_file, "<html>test</html>").unwrap();
558
559        let content = std::fs::read(&test_file).unwrap();
560        let etag = handler.generate_etag(&test_file, content.len() as u64);
561        let response = handler.serve_file_with_etag(&test_file, content, etag, CompressionType::None);
562
563        assert!(response.headers().get("Content-Type").is_some());
564        assert!(response.headers().get("Content-Length").is_some());
565        assert!(response.headers().get("ETag").is_some());
566        assert!(response.headers().get("Accept-Ranges").is_some());
567    }
568
569    #[test]
570    fn test_serve_file_compression_disabled() {
571        let temp_dir = tempfile::TempDir::new().unwrap();
572        let config = Arc::new(Config {
573            root: temp_dir.path().to_path_buf(),
574            enable_compression: false,
575            ..Default::default()
576        });
577        let handler = Handler::new(config);
578
579        // Create a large text file
580        let content = "Hello, World! ".repeat(100);
581        let test_file = temp_dir.path().join("test.txt");
582        std::fs::write(&test_file, &content).unwrap();
583
584        let file_content = std::fs::read(&test_file).unwrap();
585        let etag = handler.generate_etag(&test_file, file_content.len() as u64);
586        let response = handler.serve_file_with_etag(&test_file, file_content, etag, CompressionType::None);
587
588        assert_eq!(response.status(), 200);
589        // Content-Encoding header should not be present
590        assert!(response.headers().get("Content-Encoding").is_none());
591    }
592
593    #[test]
594    fn test_handler_clone() {
595        let config = Arc::new(Config::default());
596        let handler = Handler::new(config);
597        let _cloned = handler.clone();
598        // Handler should be clonable
599    }
600
601    #[test]
602    fn test_compression_type_none() {
603        let config = Arc::new(Config {
604            enable_compression: false,
605            ..Default::default()
606        });
607        let handler = Handler::new(config);
608
609        let temp_dir = tempfile::TempDir::new().unwrap();
610        let test_file = temp_dir.path().join("test.txt");
611        std::fs::write(&test_file, "test content for compression").unwrap();
612
613        let content = std::fs::read(&test_file).unwrap();
614        let etag = handler.generate_etag(&test_file, content.len() as u64);
615        let response = handler.serve_file_with_etag(&test_file, content, etag, CompressionType::None);
616
617        assert_eq!(response.status(), 200);
618        // No compression should be applied
619        assert!(response.headers().get("Content-Encoding").is_none());
620    }
621
622    #[test]
623    fn test_serve_file_with_gzip_compression() {
624        let config = Arc::new(Config {
625            enable_compression: true,
626            ..Default::default()
627        });
628        let handler = Handler::new(config);
629
630        let temp_dir = tempfile::TempDir::new().unwrap();
631        let test_file = temp_dir.path().join("test.txt");
632        // Create a file with repetitive content for better compression
633        let content = "Hello, World! ".repeat(100);
634        std::fs::write(&test_file, &content).unwrap();
635
636        let file_content = std::fs::read(&test_file).unwrap();
637        let etag = handler.generate_etag(&test_file, file_content.len() as u64);
638        let response = handler.serve_file_with_etag(&test_file, file_content, etag, CompressionType::Gzip);
639
640        assert_eq!(response.status(), 200);
641    }
642
643    #[test]
644    fn test_serve_file_with_brotli_compression() {
645        let config = Arc::new(Config {
646            enable_compression: true,
647            ..Default::default()
648        });
649        let handler = Handler::new(config);
650
651        let temp_dir = tempfile::TempDir::new().unwrap();
652        let test_file = temp_dir.path().join("test.txt");
653        let content = "Hello, World! ".repeat(100);
654        std::fs::write(&test_file, &content).unwrap();
655
656        let file_content = std::fs::read(&test_file).unwrap();
657        let etag = handler.generate_etag(&test_file, file_content.len() as u64);
658        let response = handler.serve_file_with_etag(&test_file, file_content, etag, CompressionType::Brotli);
659
660        assert_eq!(response.status(), 200);
661    }
662
663    #[test]
664    fn test_serve_file_skip_compression_binary() {
665        let config = Arc::new(Config {
666            enable_compression: true,
667            ..Default::default()
668        });
669        let handler = Handler::new(config);
670
671        let temp_dir = tempfile::TempDir::new().unwrap();
672        // Binary/image files should not be compressed
673        let test_file = temp_dir.path().join("test.png");
674        let content = vec![0u8; 1000]; // Binary content
675        std::fs::write(&test_file, &content).unwrap();
676
677        let file_content = std::fs::read(&test_file).unwrap();
678        let etag = handler.generate_etag(&test_file, file_content.len() as u64);
679        let response = handler.serve_file_with_etag(&test_file, file_content, etag, CompressionType::Gzip);
680
681        assert_eq!(response.status(), 200);
682    }
683
684    #[test]
685    fn test_generate_last_modified_nonexistent_file() {
686        let config = Arc::new(Config::default());
687        let handler = Handler::new(config);
688
689        let temp_dir = tempfile::TempDir::new().unwrap();
690        let nonexistent_file = temp_dir.path().join("nonexistent.txt");
691
692        // Should return default date for nonexistent files
693        let last_modified = handler.generate_last_modified(&nonexistent_file);
694        assert!(last_modified.contains("GMT"));
695    }
696
697    #[test]
698    fn test_compression_skipped_for_small_files() {
699        let config = Arc::new(Config {
700            enable_compression: true,
701            ..Default::default()
702        });
703        let handler = Handler::new(config);
704
705        let temp_dir = tempfile::TempDir::new().unwrap();
706        let test_file = temp_dir.path().join("test.txt");
707        // Very small content
708        let content = "Hi";
709        std::fs::write(&test_file, content).unwrap();
710
711        let file_content = std::fs::read(&test_file).unwrap();
712        let etag = handler.generate_etag(&test_file, file_content.len() as u64);
713        let response = handler.serve_file_with_etag(&test_file, file_content, etag, CompressionType::Gzip);
714
715        assert_eq!(response.status(), 200);
716    }
717
718    #[test]
719    fn test_serve_file_cache_control_headers() {
720        let config = Arc::new(Config::default());
721        let handler = Handler::new(config);
722
723        let temp_dir = tempfile::TempDir::new().unwrap();
724        let test_file = temp_dir.path().join("test.txt");
725        std::fs::write(&test_file, "test content").unwrap();
726
727        let content = std::fs::read(&test_file).unwrap();
728        let etag = handler.generate_etag(&test_file, content.len() as u64);
729        let response = handler.serve_file_with_etag(&test_file, content, etag, CompressionType::None);
730
731        // Verify cache control headers
732        let cache_control = response.headers().get("Cache-Control").unwrap().to_str().unwrap();
733        assert!(cache_control.contains("public"));
734        assert!(cache_control.contains("max-age"));
735    }
736
737    #[test]
738    fn test_serve_file_with_vary_header() {
739        let config = Arc::new(Config {
740            enable_compression: true,
741            ..Default::default()
742        });
743        let handler = Handler::new(config);
744
745        let temp_dir = tempfile::TempDir::new().unwrap();
746        let test_file = temp_dir.path().join("test.txt");
747        // Create compressible content
748        let content = "Hello, World! ".repeat(100);
749        std::fs::write(&test_file, &content).unwrap();
750
751        let file_content = std::fs::read(&test_file).unwrap();
752        let etag = handler.generate_etag(&test_file, file_content.len() as u64);
753        let response = handler.serve_file_with_etag(&test_file, file_content, etag, CompressionType::Gzip);
754
755        assert_eq!(response.status(), 200);
756        // When compression is applied, Vary header should be present
757        if let Some(vary) = response.headers().get("Vary") {
758            assert!(vary.to_str().unwrap().contains("Accept-Encoding"));
759        }
760    }
761
762    #[test]
763    fn test_generate_directory_html_with_parent_link() {
764        let temp_dir = tempfile::TempDir::new().unwrap();
765        let config = Arc::new(Config {
766            root: temp_dir.path().to_path_buf(),
767            ..Default::default()
768        });
769        let handler = Handler::new(config);
770
771        // Create a subdirectory
772        let subdir = temp_dir.path().join("subdir");
773        std::fs::create_dir(&subdir).unwrap();
774
775        let files = vec![
776            FileMetadata {
777                path: subdir.join("file1.txt").display().to_string(),
778                name: "file1.txt".to_string(),
779                size: 100,
780                is_dir: false,
781            },
782        ];
783
784        // Generate HTML for subdirectory (not root)
785        let html = handler.generate_directory_html(&subdir, &files);
786        
787        // Should contain parent directory link
788        assert!(html.contains("../"));
789        assert!(html.contains("Parent Directory") || html.contains("../"));
790    }
791
792    #[test]
793    fn test_serve_file_with_brotli_has_vary_header() {
794        let config = Arc::new(Config {
795            enable_compression: true,
796            ..Default::default()
797        });
798        let handler = Handler::new(config);
799
800        let temp_dir = tempfile::TempDir::new().unwrap();
801        let test_file = temp_dir.path().join("test.css");
802        let content = "body { color: red; } ".repeat(50);
803        std::fs::write(&test_file, &content).unwrap();
804
805        let file_content = std::fs::read(&test_file).unwrap();
806        let etag = handler.generate_etag(&test_file, file_content.len() as u64);
807        let response = handler.serve_file_with_etag(&test_file, file_content, etag, CompressionType::Brotli);
808
809        assert_eq!(response.status(), 200);
810        // Check for Content-Encoding header
811        if let Some(encoding) = response.headers().get("Content-Encoding") {
812            assert_eq!(encoding.to_str().unwrap(), "br");
813        }
814    }
815
816    #[test]
817    fn test_etag_format() {
818        let config = Arc::new(Config::default());
819        let handler = Handler::new(config);
820
821        let temp_dir = tempfile::TempDir::new().unwrap();
822        let test_file = temp_dir.path().join("test.txt");
823        std::fs::write(&test_file, "content").unwrap();
824
825        let metadata = std::fs::metadata(&test_file).unwrap();
826        let file_size = metadata.len();
827        let etag = handler.generate_etag(&test_file, file_size);
828
829        // ETag format: "timestamp-filesize"
830        assert!(etag.starts_with('"'));
831        assert!(etag.ends_with('"'));
832        assert!(etag.contains('-'));
833        
834        // Extract parts
835        let inner = &etag[1..etag.len()-1];
836        let parts: Vec<&str> = inner.split('-').collect();
837        assert_eq!(parts.len(), 2);
838        
839        // Both parts should be numeric
840        assert!(parts[0].parse::<u64>().is_ok());
841        assert!(parts[1].parse::<u64>().is_ok());
842    }
843
844    #[test]
845    fn test_generate_directory_html_special_chars() {
846        let config = Arc::new(Config::default());
847        let handler = Handler::new(config);
848
849        let temp_dir = tempfile::TempDir::new().unwrap();
850        
851        // Files with special characters
852        let files = vec![
853            FileMetadata {
854                path: temp_dir.path().join("file with spaces & symbols.txt").display().to_string(),
855                name: "file with spaces & symbols.txt".to_string(),
856                size: 100,
857                is_dir: false,
858            },
859        ];
860
861        let html = handler.generate_directory_html(&temp_dir.path().to_path_buf(), &files);
862        assert!(html.contains("file with spaces & symbols.txt"));
863    }
864
865    #[test]
866    fn test_serve_file_with_uncompressible_content_type() {
867        let config = Arc::new(Config {
868            enable_compression: true,
869            ..Default::default()
870        });
871        let handler = Handler::new(config);
872
873        let temp_dir = tempfile::TempDir::new().unwrap();
874        // Test with image file which should skip compression
875        let test_file = temp_dir.path().join("test.png");
876        let content = vec![0u8; 1000]; // Binary content
877        std::fs::write(&test_file, &content).unwrap();
878
879        let file_content = std::fs::read(&test_file).unwrap();
880        let etag = handler.generate_etag(&test_file, file_content.len() as u64);
881        let response = handler.serve_file_with_etag(&test_file, file_content, etag, CompressionType::Gzip);
882
883        assert_eq!(response.status(), 200);
884        // Image files should not have Content-Encoding header
885        assert!(response.headers().get("Content-Encoding").is_none());
886    }
887
888    #[test]
889    fn test_directory_listing_error_returns_500() {
890        let temp_dir = tempfile::TempDir::new().unwrap();
891        let config = Arc::new(Config {
892            root: temp_dir.path().to_path_buf(),
893            enable_indexing: true,
894            ..Default::default()
895        });
896        let handler = Handler::new(config);
897
898        // Create a file instead of directory to cause list_directory to fail
899        let not_a_dir = temp_dir.path().join("not_a_dir");
900        std::fs::write(&not_a_dir, "I'm a file").unwrap();
901
902        let response = handler.serve_directory_index(&not_a_dir);
903        // When list_directory fails on a file, it should return 500
904        assert_eq!(response.status(), 500);
905    }
906
907    #[test]
908    fn test_last_modified_format() {
909        let config = Arc::new(Config::default());
910        let handler = Handler::new(config);
911
912        let temp_dir = tempfile::TempDir::new().unwrap();
913        let test_file = temp_dir.path().join("test.txt");
914        std::fs::write(&test_file, "content").unwrap();
915
916        let last_modified = handler.generate_last_modified(&test_file);
917
918        // Format should be like: "Wed, 21 Oct 2015 07:28:00 GMT"
919        assert!(last_modified.contains("GMT"));
920        // Should have day, month, year format
921        let parts: Vec<&str> = last_modified.split_whitespace().collect();
922        assert!(parts.len() >= 5);
923    }
924
925    #[test]
926    fn test_handler_config_values() {
927        let config = Arc::new(Config {
928            port: 9090,
929            root: "/custom/path".into(),
930            enable_indexing: true,
931            enable_compression: false,
932            ..Default::default()
933        });
934        let handler = Handler::new(config.clone());
935
936        assert_eq!(handler.config.port, 9090);
937        assert_eq!(handler.config.root, PathBuf::from("/custom/path"));
938        assert!(handler.config.enable_indexing);
939        assert!(!handler.config.enable_compression);
940    }
941}