Skip to main content

coding_agent_search/pages/
preview.rs

1//! Local preview server for Pages exports.
2//!
3//! Provides a local HTTP server to preview exported archives before deployment.
4//! Features:
5//! - Static file serving with correct MIME types
6//! - COOP/COEP headers for full WebCrypto functionality
7//! - Auto-open browser on start
8//! - Graceful shutdown via Ctrl+C
9
10use std::net::SocketAddr;
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::time::Instant;
14
15use tracing::debug;
16
17/// Error type for preview server operations.
18#[derive(Debug, thiserror::Error)]
19pub enum PreviewError {
20    /// Failed to bind to the specified port.
21    #[error("Failed to bind to port {port}: {source}")]
22    BindFailed { port: u16, source: std::io::Error },
23    /// The site directory does not exist.
24    #[error("Site directory not found: {}", .0.display())]
25    SiteDirectoryNotFound(PathBuf),
26    /// Failed to read a file.
27    #[error("Failed to read file {}: {source}", path.display())]
28    FileReadError {
29        path: PathBuf,
30        source: std::io::Error,
31    },
32    /// Failed to open browser.
33    #[error("Failed to open browser: {0}")]
34    BrowserOpenFailed(String),
35    /// Server error.
36    #[error("Server error: {0}")]
37    ServerError(String),
38}
39
40/// Configuration for the preview server.
41#[derive(Debug, Clone)]
42pub struct PreviewConfig {
43    /// Directory containing the site to serve.
44    pub site_dir: PathBuf,
45    /// Port to listen on.
46    pub port: u16,
47    /// Whether to automatically open a browser.
48    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
61/// Resolve the deployable site directory from either a bundle root or site path.
62fn 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
70/// Guess MIME type from file extension.
71fn 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
105/// Build an HTTP response with the given status code, content type, and body.
106fn 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
110/// Build an HTTP response with an optional explicit Content-Length override.
111fn 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
152/// Resolve representation length for HEAD requests without eagerly reading file bytes.
153///
154/// Falls back to reading the file if metadata is unavailable or does not fit usize.
155fn 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
191/// Handle a single HTTP request against an already-canonicalized site root.
192fn handle_request_with_site_root(site_root_canonical: &std::path::Path, request: &str) -> Vec<u8> {
193    // Parse the request line
194    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    // Only support GET and HEAD
205    if method != "GET" && method != "HEAD" {
206        return build_response(405, MIME_TEXT_PLAIN, b"Method Not Allowed".to_vec());
207    }
208
209    // Strip query/fragment, then decode URL and sanitize path
210    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    // Prevent directory traversal
221    if request_path.contains("..") {
222        return build_response(400, MIME_TEXT_PLAIN, b"Invalid Path".to_vec());
223    }
224
225    // Determine the file path
226    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    // Canonicalize to prevent path traversal
233    let canonical = match file_path.canonicalize() {
234        Ok(p) => p,
235        Err(_) => {
236            // Try with index.html if it's a directory
237            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    // Ensure the path is within the site directory
248    if !canonical.starts_with(site_root_canonical) {
249        return build_response(400, MIME_TEXT_PLAIN, b"Invalid Path".to_vec());
250    }
251
252    // Check if it's a directory and append index.html if so
253    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/// Handle a single HTTP request.
346///
347/// This wrapper canonicalizes the provided site directory once per call and then
348/// delegates to the canonical-root hot path handler.
349#[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
360/// Handle a single TCP connection using blocking I/O.
361fn handle_connection(mut stream: std::net::TcpStream, site_dir: &std::path::Path) {
362    use std::io::{Read, Write};
363
364    // Set a reasonable read timeout so slow clients don't block the thread indefinitely
365    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    // Explicitly shutdown the connection to clean up resources promptly
380    let _ = stream.shutdown(std::net::Shutdown::Both);
381}
382
383/// Start the preview server.
384///
385/// This function will block until the server is shut down (via Ctrl+C).
386///
387/// # Arguments
388///
389/// * `config` - Server configuration
390///
391/// # Returns
392///
393/// Returns `Ok(())` on graceful shutdown, or an error if the server fails to start.
394pub 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    // Bind to the address (synchronous — preview is a simple dev server)
404    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    // Print startup message
411    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    // Open browser if requested
421    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    // Run blocking accept loop on the blocking pool. The process terminates
430    // on SIGINT via the default handler, which is fine for a dev preview server.
431    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
452/// Open the default browser to the given URL.
453fn 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        // Try xdg-open first, fall back to common browsers
465        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}