use std::env; use std::fs; use std::io::{self, Write}; use std::path::{Component, Path, PathBuf}; use std::process::ExitCode; use crate::render; pub fn run() -> ExitCode { let Ok(document_root) = env::var("DOCUMENT_ROOT") else { return respond_error(500, "DOCUMENT_ROOT is not set"); }; let Some(raw_request) = request_path() else { return respond_error(500, "no request path available"); }; let request = strip_query(&raw_request); let Some(target) = safe_join(Path::new(&document_root), request) else { return respond_error(404, "not found"); }; let Ok(metadata) = fs::metadata(&target) else { return respond_error(404, "not found"); }; if !metadata.is_file() { return respond_error(404, "not found"); } let Ok(bytes) = fs::read(&target) else { return respond_error(500, "read error"); }; let Ok(text) = String::from_utf8(bytes) else { return respond_error(404, "not found"); }; let html = render::page(&target, &text); let body = html.as_bytes(); let header = format!( "Status: 200 OK\nContent-Type: text/html; charset=utf-8\nContent-Length: {}\n\n", body.len() ); let stdout = io::stdout(); let mut handle = stdout.lock(); if handle.write_all(header.as_bytes()).is_err() { return ExitCode::from(1); } if handle.write_all(body).is_err() { return ExitCode::from(1); } ExitCode::SUCCESS } fn request_path() -> Option { env::var("DOCUMENT_URI") .or_else(|_| env::var("PATH_INFO")) .ok() } fn strip_query(s: &str) -> &str { let s = s.split('?').next().unwrap_or(s); s.split('#').next().unwrap_or(s) } // Only top-level, non-dotfile names are servable: rejects paths with // `..`, with subdirectories, and with names starting with `.`. fn safe_join(root: &Path, request: &str) -> Option { let mut name = None; for component in Path::new(request).components() { match component { Component::Normal(c) => { if name.is_some() { return None; } if c.as_encoded_bytes().first() == Some(&b'.') { return None; } name = Some(c); } Component::RootDir | Component::CurDir => {} Component::ParentDir | Component::Prefix(_) => return None, } } name.map(|n| root.join(n)) } fn respond_error(code: u16, message: &str) -> ExitCode { let status = match code { 400 => "400 Bad Request", 403 => "403 Forbidden", 404 => "404 Not Found", _ => "500 Internal Server Error", }; println!("Status: {status}"); println!("Content-Type: text/plain; charset=utf-8"); println!(); println!("{message}"); ExitCode::SUCCESS } #[cfg(test)] mod tests { use super::*; #[test] fn safe_join_normal() { let r = safe_join(Path::new("/srv/p"), "/x.txt").unwrap(); assert_eq!(r, PathBuf::from("/srv/p/x.txt")); } #[test] fn safe_join_rejects_subdirectories() { assert!(safe_join(Path::new("/srv/p"), "/sub/x.txt").is_none()); assert!(safe_join(Path::new("/srv/p"), "/a/b/c").is_none()); } #[test] fn safe_join_rejects_dotfiles() { assert!(safe_join(Path::new("/srv/p"), "/.ssh").is_none()); assert!(safe_join(Path::new("/srv/p"), "/.bashrc").is_none()); assert!(safe_join(Path::new("/srv/p"), ".env").is_none()); } #[test] fn safe_join_rejects_parent() { assert!(safe_join(Path::new("/srv/p"), "/../etc/passwd").is_none()); assert!(safe_join(Path::new("/srv/p"), "../etc/passwd").is_none()); assert!(safe_join(Path::new("/srv/p"), "/sub/../../etc").is_none()); } #[test] fn safe_join_rejects_empty() { assert!(safe_join(Path::new("/srv/p"), "").is_none()); assert!(safe_join(Path::new("/srv/p"), "/").is_none()); } #[test] fn strip_query_removes_query_and_fragment() { assert_eq!(strip_query("/x.txt?foo=bar"), "/x.txt"); assert_eq!(strip_query("/x.txt#frag"), "/x.txt"); assert_eq!(strip_query("/x.txt?a=b#frag"), "/x.txt"); assert_eq!(strip_query("/x.txt"), "/x.txt"); } }