1use std::net::SocketAddr;
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::time::Instant;
14
15use tracing::debug;
16
17#[derive(Debug, thiserror::Error)]
19pub enum PreviewError {
20 #[error("Failed to bind to port {port}: {source}")]
22 BindFailed { port: u16, source: std::io::Error },
23 #[error("Site directory not found: {}", .0.display())]
25 SiteDirectoryNotFound(PathBuf),
26 #[error("Failed to read file {}: {source}", path.display())]
28 FileReadError {
29 path: PathBuf,
30 source: std::io::Error,
31 },
32 #[error("Failed to open browser: {0}")]
34 BrowserOpenFailed(String),
35 #[error("Server error: {0}")]
37 ServerError(String),
38}
39
40#[derive(Debug, Clone)]
42pub struct PreviewConfig {
43 pub site_dir: PathBuf,
45 pub port: u16,
47 pub open_browser: bool,
49}
50
51impl Default for PreviewConfig {
52 fn default() -> Self {
53 Self {
54 site_dir: PathBuf::from("."),
55 port: 8080,
56 open_browser: true,
57 }
58 }
59}
60
61fn resolve_site_dir(path: &std::path::Path) -> Result<PathBuf, PreviewError> {
63 super::resolve_site_dir(path)
64 .map_err(|_| PreviewError::SiteDirectoryNotFound(path.to_path_buf()))
65}
66
67const MIME_APPLICATION_OCTET_STREAM: &str = "application/octet-stream";
68const MIME_TEXT_PLAIN: &str = "text/plain";
69
70fn guess_mime_type(path: &std::path::Path) -> &'static str {
72 match path.extension().and_then(|e| e.to_str()) {
73 Some("html") | Some("htm") => "text/html; charset=utf-8",
74 Some("js") | Some("mjs") => "application/javascript; charset=utf-8",
75 Some("css") => "text/css; charset=utf-8",
76 Some("json") => "application/json; charset=utf-8",
77 Some("wasm") => "application/wasm",
78 Some("png") => "image/png",
79 Some("jpg") | Some("jpeg") => "image/jpeg",
80 Some("gif") => "image/gif",
81 Some("webp") => "image/webp",
82 Some("svg") => "image/svg+xml",
83 Some("ico") => "image/x-icon",
84 Some("txt") => "text/plain; charset=utf-8",
85 Some("xml") => "application/xml",
86 Some("pdf") => "application/pdf",
87 Some("bin") => MIME_APPLICATION_OCTET_STREAM,
88 Some("woff") => "font/woff",
89 Some("woff2") => "font/woff2",
90 Some("ttf") => "font/ttf",
91 Some("otf") => "font/otf",
92 Some("eot") => "application/vnd.ms-fontobject",
93 Some("mp4") => "video/mp4",
94 Some("webm") => "video/webm",
95 Some("mp3") => "audio/mpeg",
96 Some("ogg") => "audio/ogg",
97 Some("wav") => "audio/wav",
98 Some("zip") => "application/zip",
99 Some("gz") => "application/gzip",
100 Some("tar") => "application/x-tar",
101 _ => MIME_APPLICATION_OCTET_STREAM,
102 }
103}
104
105fn build_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
107 build_response_with_content_length(status, content_type, body, None)
108}
109
110fn build_response_with_content_length(
112 status: u16,
113 content_type: &str,
114 body: Vec<u8>,
115 content_length_override: Option<usize>,
116) -> Vec<u8> {
117 let status_text = match status {
118 200 => "OK",
119 304 => "Not Modified",
120 400 => "Bad Request",
121 405 => "Method Not Allowed",
122 404 => "Not Found",
123 500 => "Internal Server Error",
124 _ => "Unknown",
125 };
126 let content_length = content_length_override.unwrap_or(body.len());
127
128 let headers = format!(
129 "HTTP/1.1 {} {}\r\n\
130 Content-Type: {}\r\n\
131 Content-Length: {}\r\n\
132 Cross-Origin-Opener-Policy: same-origin\r\n\
133 Cross-Origin-Embedder-Policy: require-corp\r\n\
134 Cross-Origin-Resource-Policy: same-origin\r\n\
135 Cache-Control: no-cache\r\n\
136 Connection: close\r\n\
137 \r\n",
138 status, status_text, content_type, content_length
139 );
140
141 let mut response = headers.into_bytes();
142 response.extend(body);
143 response
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147enum HeadLengthSource {
148 Metadata,
149 FallbackRead,
150}
151
152fn head_content_length_with_metadata_hint(
156 file_path: &std::path::Path,
157 metadata_length: std::io::Result<u64>,
158) -> std::io::Result<(usize, HeadLengthSource)> {
159 match metadata_length {
160 Ok(metadata_length) => match usize::try_from(metadata_length) {
161 Ok(length) => Ok((length, HeadLengthSource::Metadata)),
162 Err(_) => {
163 let bytes = std::fs::read(file_path)?;
164 Ok((bytes.len(), HeadLengthSource::FallbackRead))
165 }
166 },
167 Err(_) => {
168 let bytes = std::fs::read(file_path)?;
169 Ok((bytes.len(), HeadLengthSource::FallbackRead))
170 }
171 }
172}
173
174fn head_content_length(file_path: &std::path::Path) -> std::io::Result<(usize, HeadLengthSource)> {
175 let metadata_length = std::fs::metadata(file_path).map(|meta| meta.len());
176 head_content_length_with_metadata_hint(file_path, metadata_length)
177}
178
179fn head_content_length_from_hint_or_fs(
180 file_path: &std::path::Path,
181 metadata_length_hint: Option<u64>,
182) -> std::io::Result<(usize, HeadLengthSource)> {
183 match metadata_length_hint {
184 Some(metadata_length) => {
185 head_content_length_with_metadata_hint(file_path, Ok(metadata_length))
186 }
187 None => head_content_length(file_path),
188 }
189}
190
191fn handle_request_with_site_root(site_root_canonical: &std::path::Path, request: &str) -> Vec<u8> {
193 let request_line = request.lines().next().unwrap_or("");
195 let parts: Vec<&str> = request_line.split_whitespace().collect();
196
197 if parts.len() < 2 {
198 return build_response(400, MIME_TEXT_PLAIN, b"Bad Request".to_vec());
199 }
200
201 let method = parts[0];
202 let raw_path = parts[1];
203
204 if method != "GET" && method != "HEAD" {
206 return build_response(405, MIME_TEXT_PLAIN, b"Method Not Allowed".to_vec());
207 }
208
209 let path_only = raw_path
211 .split('?')
212 .next()
213 .unwrap_or(raw_path)
214 .split('#')
215 .next()
216 .unwrap_or(raw_path);
217 let decoded_path = urlencoding::decode(path_only).unwrap_or_else(|_| path_only.into());
218 let request_path = decoded_path.trim_start_matches('/');
219
220 if request_path.contains("..") {
222 return build_response(400, MIME_TEXT_PLAIN, b"Invalid Path".to_vec());
223 }
224
225 let file_path = if request_path.is_empty() || request_path == "/" {
227 site_root_canonical.join("index.html")
228 } else {
229 site_root_canonical.join(request_path)
230 };
231
232 let canonical = match file_path.canonicalize() {
234 Ok(p) => p,
235 Err(_) => {
236 let with_index = file_path.join("index.html");
238 match with_index.canonicalize() {
239 Ok(p) => p,
240 Err(_) => {
241 return build_response(404, MIME_TEXT_PLAIN, b"Not Found".to_vec());
242 }
243 }
244 }
245 };
246
247 if !canonical.starts_with(site_root_canonical) {
249 return build_response(400, MIME_TEXT_PLAIN, b"Invalid Path".to_vec());
250 }
251
252 let mut file_to_read = canonical.clone();
254 let mut metadata_length_hint = None;
255 if let Ok(meta) = std::fs::metadata(&canonical) {
256 if meta.is_dir() {
257 let index_path = canonical.join("index.html");
258 let index_canonical = match index_path.canonicalize() {
259 Ok(path) => path,
260 Err(_) => {
261 return build_response(404, MIME_TEXT_PLAIN, b"Not Found".to_vec());
262 }
263 };
264 if !index_canonical.starts_with(site_root_canonical) {
265 return build_response(400, MIME_TEXT_PLAIN, b"Invalid Path".to_vec());
266 }
267 match std::fs::metadata(&index_canonical) {
268 Ok(index_meta) if index_meta.is_file() => {
269 metadata_length_hint = Some(index_meta.len());
270 file_to_read = index_canonical;
271 }
272 _ => {
273 return build_response(404, MIME_TEXT_PLAIN, b"Not Found".to_vec());
274 }
275 }
276 } else {
277 metadata_length_hint = Some(meta.len());
278 }
279 }
280
281 let request_started = Instant::now();
282
283 if method == "HEAD" {
284 match head_content_length_from_hint_or_fs(&file_to_read, metadata_length_hint) {
285 Ok((content_length, length_source)) => {
286 let mime = guess_mime_type(&file_to_read);
287 debug!(
288 method = method,
289 request_path = %request_path,
290 file_path = %file_to_read.display(),
291 status = 200,
292 size_source = ?length_source,
293 content_length = content_length,
294 elapsed_ms = request_started.elapsed().as_millis(),
295 "Preview served HEAD request"
296 );
297 build_response_with_content_length(200, mime, Vec::new(), Some(content_length))
298 }
299 Err(err) => {
300 debug!(
301 method = method,
302 request_path = %request_path,
303 file_path = %file_to_read.display(),
304 status = 404,
305 error = %err,
306 elapsed_ms = request_started.elapsed().as_millis(),
307 "Preview HEAD request failed"
308 );
309 build_response(404, MIME_TEXT_PLAIN, b"Not Found".to_vec())
310 }
311 }
312 } else {
313 match std::fs::read(&file_to_read) {
314 Ok(contents) => {
315 let content_length = contents.len();
316 let mime = guess_mime_type(&file_to_read);
317 debug!(
318 method = method,
319 request_path = %request_path,
320 file_path = %file_to_read.display(),
321 status = 200,
322 size_source = "body_read",
323 content_length = content_length,
324 elapsed_ms = request_started.elapsed().as_millis(),
325 "Preview served GET request"
326 );
327 build_response(200, mime, contents)
328 }
329 Err(err) => {
330 debug!(
331 method = method,
332 request_path = %request_path,
333 file_path = %file_to_read.display(),
334 status = 404,
335 error = %err,
336 elapsed_ms = request_started.elapsed().as_millis(),
337 "Preview GET request failed"
338 );
339 build_response(404, MIME_TEXT_PLAIN, b"Not Found".to_vec())
340 }
341 }
342 }
343}
344
345#[cfg(test)]
350fn handle_request(site_dir: &std::path::Path, request: &str) -> Vec<u8> {
351 let site_root_canonical = match site_dir.canonicalize() {
352 Ok(p) => p,
353 Err(_) => {
354 return build_response(500, MIME_TEXT_PLAIN, b"Internal Server Error".to_vec());
355 }
356 };
357 handle_request_with_site_root(&site_root_canonical, request)
358}
359
360fn handle_connection(mut stream: std::net::TcpStream, site_dir: &std::path::Path) {
362 use std::io::{Read, Write};
363
364 let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));
366
367 let mut buf = vec![0u8; 8192];
368 let n = match stream.read(&mut buf) {
369 Ok(n) if n > 0 => n,
370 _ => return,
371 };
372
373 let request = String::from_utf8_lossy(&buf[..n]);
374 let response = handle_request_with_site_root(site_dir, &request);
375
376 let _ = stream.write_all(&response);
377 let _ = stream.flush();
378
379 let _ = stream.shutdown(std::net::Shutdown::Both);
381}
382
383pub async fn start_preview_server(config: PreviewConfig) -> Result<(), PreviewError> {
395 let resolved_site_dir = resolve_site_dir(&config.site_dir)?;
396
397 let site_dir = Arc::new(
398 resolved_site_dir
399 .canonicalize()
400 .map_err(|_| PreviewError::SiteDirectoryNotFound(config.site_dir.clone()))?,
401 );
402
403 let addr = SocketAddr::from(([127, 0, 0, 1], config.port));
405 let listener = std::net::TcpListener::bind(addr).map_err(|e| PreviewError::BindFailed {
406 port: config.port,
407 source: e,
408 })?;
409
410 eprintln!();
412 eprintln!(
413 "\x1b[1;32m\u{1F310}\x1b[0m Preview server running at \x1b[1;36mhttp://localhost:{}\x1b[0m",
414 config.port
415 );
416 eprintln!(" Serving: \x1b[33m{}\x1b[0m", site_dir.display());
417 eprintln!(" Press \x1b[1mCtrl+C\x1b[0m to stop");
418 eprintln!();
419
420 if config.open_browser {
422 let url = format!("http://localhost:{}", config.port);
423 if let Err(e) = open_browser(&url) {
424 eprintln!("\x1b[33mWarning:\x1b[0m Could not open browser: {}", e);
425 eprintln!(" Please open \x1b[1;36m{}\x1b[0m manually", url);
426 }
427 }
428
429 asupersync::runtime::spawn_blocking(move || {
432 for stream_result in listener.incoming() {
433 match stream_result {
434 Ok(stream) => {
435 let site_dir = Arc::clone(&site_dir);
436 std::thread::spawn(move || {
437 handle_connection(stream, &site_dir);
438 });
439 }
440 Err(e) => {
441 eprintln!("Accept error: {}", e);
442 }
443 }
444 }
445 })
446 .await;
447
448 eprintln!("\x1b[32mPreview server stopped.\x1b[0m");
449 Ok(())
450}
451
452fn open_browser(url: &str) -> Result<(), PreviewError> {
454 #[cfg(target_os = "macos")]
455 {
456 std::process::Command::new("open")
457 .arg(url)
458 .spawn()
459 .map_err(|e| PreviewError::BrowserOpenFailed(e.to_string()))?;
460 }
461
462 #[cfg(target_os = "linux")]
463 {
464 let browsers = [
466 "xdg-open",
467 "firefox",
468 "chromium",
469 "google-chrome",
470 "x-www-browser",
471 ];
472 let mut opened = false;
473
474 for browser in browsers {
475 if std::process::Command::new(browser).arg(url).spawn().is_ok() {
476 opened = true;
477 break;
478 }
479 }
480
481 if !opened {
482 return Err(PreviewError::BrowserOpenFailed(
483 "No browser found. Install xdg-open or a web browser.".to_string(),
484 ));
485 }
486 }
487
488 #[cfg(target_os = "windows")]
489 {
490 std::process::Command::new("cmd")
491 .args(["/C", "start", "", url])
492 .spawn()
493 .map_err(|e| PreviewError::BrowserOpenFailed(e.to_string()))?;
494 }
495
496 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
497 {
498 return Err(PreviewError::BrowserOpenFailed(
499 "Unsupported platform for auto-open".to_string(),
500 ));
501 }
502
503 Ok(())
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use tempfile::TempDir;
510
511 fn content_length(resp: &str) -> Option<usize> {
512 resp.lines().find_map(|line| {
513 let (name, value) = line.split_once(':')?;
514 if name.eq_ignore_ascii_case("Content-Length") {
515 value.trim().parse::<usize>().ok()
516 } else {
517 None
518 }
519 })
520 }
521
522 fn temp_site_with_index(contents: impl AsRef<[u8]>) -> TempDir {
523 let temp_dir = TempDir::new().expect("temp dir");
524 std::fs::write(temp_dir.path().join("index.html"), contents).expect("write index");
525 temp_dir
526 }
527
528 #[test]
529 fn test_guess_mime_type() {
530 assert_eq!(
531 guess_mime_type(std::path::Path::new("index.html")),
532 "text/html; charset=utf-8"
533 );
534 assert_eq!(
535 guess_mime_type(std::path::Path::new("app.js")),
536 "application/javascript; charset=utf-8"
537 );
538 assert_eq!(
539 guess_mime_type(std::path::Path::new("styles.css")),
540 "text/css; charset=utf-8"
541 );
542 assert_eq!(
543 guess_mime_type(std::path::Path::new("data.json")),
544 "application/json; charset=utf-8"
545 );
546 assert_eq!(
547 guess_mime_type(std::path::Path::new("module.wasm")),
548 "application/wasm"
549 );
550 assert_eq!(
551 guess_mime_type(std::path::Path::new("image.png")),
552 "image/png"
553 );
554 assert_eq!(
555 guess_mime_type(std::path::Path::new("unknown")),
556 "application/octet-stream"
557 );
558 }
559
560 #[test]
561 fn test_preview_config_default() {
562 let config = PreviewConfig::default();
563 assert_eq!(config.port, 8080);
564 assert!(config.open_browser);
565 }
566
567 #[test]
568 fn test_preview_error_display_and_source_are_preserved() {
569 let bind = PreviewError::BindFailed {
570 port: 8081,
571 source: std::io::Error::new(std::io::ErrorKind::AddrInUse, "busy"),
572 };
573 assert_eq!(bind.to_string(), "Failed to bind to port 8081: busy");
574 assert_eq!(
575 std::error::Error::source(&bind)
576 .expect("bind source")
577 .to_string(),
578 "busy"
579 );
580
581 let missing = PreviewError::SiteDirectoryNotFound(PathBuf::from("/tmp/missing-site"));
582 assert_eq!(
583 missing.to_string(),
584 "Site directory not found: /tmp/missing-site"
585 );
586 assert!(std::error::Error::source(&missing).is_none());
587
588 let read = PreviewError::FileReadError {
589 path: PathBuf::from("/tmp/site/app.js"),
590 source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
591 };
592 assert_eq!(
593 read.to_string(),
594 "Failed to read file /tmp/site/app.js: denied"
595 );
596 assert_eq!(
597 std::error::Error::source(&read)
598 .expect("file read source")
599 .to_string(),
600 "denied"
601 );
602
603 let browser = PreviewError::BrowserOpenFailed("missing opener".to_string());
604 assert_eq!(
605 browser.to_string(),
606 "Failed to open browser: missing opener"
607 );
608 assert!(std::error::Error::source(&browser).is_none());
609
610 let server = PreviewError::ServerError("worker stopped".to_string());
611 assert_eq!(server.to_string(), "Server error: worker stopped");
612 assert!(std::error::Error::source(&server).is_none());
613 }
614
615 #[test]
616 fn test_resolve_site_dir_accepts_bundle_root() {
617 let temp_dir = TempDir::new().expect("temp dir");
618 let bundle_root = temp_dir.path();
619 std::fs::create_dir(bundle_root.join("site")).expect("create site dir");
620
621 let resolved = resolve_site_dir(bundle_root).expect("resolve bundle root");
622 assert_eq!(resolved, bundle_root.join("site"));
623 }
624
625 #[test]
626 fn test_build_response_headers() {
627 let response = build_response(200, "text/html", b"<html></html>".to_vec());
628 let response_str = String::from_utf8_lossy(&response);
629
630 assert!(response_str.contains("HTTP/1.1 200 OK"));
631 assert!(response_str.contains("Content-Type: text/html"));
632 assert!(response_str.contains("Cross-Origin-Opener-Policy: same-origin"));
633 assert!(response_str.contains("Cross-Origin-Embedder-Policy: require-corp"));
634 assert!(response_str.contains("Cross-Origin-Resource-Policy: same-origin"));
635 }
636
637 #[test]
638 fn test_handle_request_bad_method() {
639 let site_dir = std::path::Path::new("/tmp");
640 let response = handle_request(site_dir, "POST / HTTP/1.1\r\n");
641 let response_str = String::from_utf8_lossy(&response);
642 assert!(response_str.contains("HTTP/1.1 405 Method Not Allowed"));
643 assert!(response_str.contains("Method Not Allowed"));
644 }
645
646 #[test]
647 fn test_handle_request_bad_path() {
648 let site_dir = std::path::Path::new("/tmp");
649 let response = handle_request(site_dir, "GET /../etc/passwd HTTP/1.1\r\n");
650 let response_str = String::from_utf8_lossy(&response);
651 assert!(response_str.contains("400") || response_str.contains("Invalid"));
652 }
653
654 #[test]
655 #[cfg(unix)]
656 fn test_handle_request_rejects_directory_index_symlink_escape() {
657 use std::os::unix::fs::symlink;
658
659 let temp_dir = temp_site_with_index("<html>root</html>");
660 let outside = TempDir::new().expect("outside dir");
661 let outside_file = outside.path().join("secret.html");
662 std::fs::write(&outside_file, "<html>outside secret</html>").expect("write outside file");
663 let nested = temp_dir.path().join("nested");
664 std::fs::create_dir(&nested).expect("create nested dir");
665 symlink(&outside_file, nested.join("index.html")).expect("symlink nested index");
666
667 let get_response = handle_request(temp_dir.path(), "GET /nested/ HTTP/1.1\r\n");
668 let get_str = String::from_utf8_lossy(&get_response);
669 assert!(get_str.contains("HTTP/1.1 400"));
670 assert!(!get_str.contains("outside secret"));
671
672 let head_response = handle_request(temp_dir.path(), "HEAD /nested/ HTTP/1.1\r\n");
673 let head_str = String::from_utf8_lossy(&head_response);
674 assert!(head_str.contains("HTTP/1.1 400"));
675 }
676
677 #[test]
678 fn test_handle_request_serves_index_with_coi_headers() {
679 let temp_dir = temp_site_with_index("<!doctype html><html>ok</html>");
680 let site_dir = temp_dir.path();
681
682 std::fs::write(
683 site_dir.join("sw.js"),
684 "self.addEventListener('install', () => {});",
685 )
686 .expect("write sw.js");
687
688 let index_response = handle_request(site_dir, "GET / HTTP/1.1\r\n");
689 let index_str = String::from_utf8_lossy(&index_response);
690
691 assert!(index_str.contains("HTTP/1.1 200 OK"));
692 assert!(index_str.contains("Content-Type: text/html; charset=utf-8"));
693 assert!(index_str.contains("Cross-Origin-Opener-Policy: same-origin"));
694 assert!(index_str.contains("Cross-Origin-Embedder-Policy: require-corp"));
695 assert!(index_str.contains("Cross-Origin-Resource-Policy: same-origin"));
696
697 let sw_response = handle_request(site_dir, "GET /sw.js HTTP/1.1\r\n");
698 let sw_str = String::from_utf8_lossy(&sw_response);
699 assert!(sw_str.contains("HTTP/1.1 200 OK"));
700 assert!(sw_str.contains("Content-Type: application/javascript; charset=utf-8"));
701 }
702
703 #[test]
704 fn test_handle_request_head_preserves_content_length() {
705 let body = "<!doctype html><html>head-check</html>";
706 let temp_dir = temp_site_with_index(body);
707 let site_dir = temp_dir.path();
708
709 let get_response = handle_request(site_dir, "GET / HTTP/1.1\r\n");
710 let head_response = handle_request(site_dir, "HEAD / HTTP/1.1\r\n");
711
712 let get_str = String::from_utf8_lossy(&get_response);
713 let head_str = String::from_utf8_lossy(&head_response);
714
715 let get_len = content_length(&get_str).expect("GET content-length");
716 let head_len = content_length(&head_str).expect("HEAD content-length");
717 assert_eq!(head_len, get_len);
718 assert!(head_str.ends_with("\r\n\r\n"));
719 assert!(!head_str.contains("head-check"));
720 }
721
722 #[test]
723 fn test_head_content_length_prefers_metadata() {
724 let temp_dir = TempDir::new().expect("temp dir");
725 let file_path = temp_dir.path().join("asset.bin");
726 let body = vec![b'x'; 4096];
727 std::fs::write(&file_path, &body).expect("write asset");
728
729 let (length, source) =
730 head_content_length_with_metadata_hint(&file_path, Ok(body.len() as u64))
731 .expect("metadata length");
732
733 assert_eq!(length, body.len());
734 assert_eq!(source, HeadLengthSource::Metadata);
735 }
736
737 #[test]
738 fn test_head_content_length_falls_back_when_metadata_missing() {
739 let temp_dir = TempDir::new().expect("temp dir");
740 let file_path = temp_dir.path().join("asset.bin");
741 let body = vec![b'y'; 8192];
742 std::fs::write(&file_path, &body).expect("write asset");
743
744 let (length, source) = head_content_length_with_metadata_hint(
745 &file_path,
746 Err(std::io::Error::new(
747 std::io::ErrorKind::NotFound,
748 "metadata unavailable",
749 )),
750 )
751 .expect("fallback length");
752
753 assert_eq!(length, body.len());
754 assert_eq!(source, HeadLengthSource::FallbackRead);
755 }
756
757 #[test]
758 fn test_handle_request_head_large_file_content_length() {
759 let body = vec![b'z'; 512 * 1024];
760 let temp_dir = temp_site_with_index(&body);
761 let site_dir = temp_dir.path();
762
763 let head_response = handle_request(site_dir, "HEAD / HTTP/1.1\r\n");
764 let head_str = String::from_utf8_lossy(&head_response);
765
766 assert_eq!(
767 content_length(&head_str).expect("HEAD content-length"),
768 body.len()
769 );
770 assert!(head_str.ends_with("\r\n\r\n"));
771 }
772
773 #[test]
774 fn test_head_content_length_from_hint_or_fs_with_hint_skips_fs_lookup() {
775 let missing_path = std::path::Path::new("/tmp/cass-preview-nonexistent-file-for-hint-test");
776 let (length, source) = head_content_length_from_hint_or_fs(missing_path, Some(777))
777 .expect("metadata hint should succeed without filesystem access");
778 assert_eq!(length, 777);
779 assert_eq!(source, HeadLengthSource::Metadata);
780 }
781
782 #[test]
783 fn test_handle_request_with_site_root_precanonicalized() {
784 let temp_dir = temp_site_with_index("<html>canonical</html>");
785 let site_dir = temp_dir.path();
786 let canonical_root = site_dir.canonicalize().expect("canonicalize root");
787
788 let response = handle_request_with_site_root(&canonical_root, "GET / HTTP/1.1\r\n");
789 let response_str = String::from_utf8_lossy(&response);
790 assert!(response_str.contains("HTTP/1.1 200 OK"));
791 assert!(response_str.contains("canonical"));
792 }
793
794 #[test]
795 fn test_handle_request_wrapper_accepts_uncanonicalized_site_dir() {
796 let temp_dir = temp_site_with_index("<html>wrapper</html>");
797 let site_dir = temp_dir.path();
798 let dotted = site_dir.join(".");
799
800 let response = handle_request(&dotted, "GET / HTTP/1.1\r\n");
801 let response_str = String::from_utf8_lossy(&response);
802 assert!(response_str.contains("HTTP/1.1 200 OK"));
803 assert!(response_str.contains("wrapper"));
804 }
805}