diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-04-30 15:17:30 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-04-30 15:37:23 +0200 |
| commit | 8cc11c52a88ec78d175b3ec8de854916f631caab (patch) | |
| tree | 4ad0bc1664a97418d0475b0b730c54d874953348 /src/serve.rs | |
Initial build
Diffstat (limited to 'src/serve.rs')
| -rw-r--r-- | src/serve.rs | 152 |
1 files changed, 152 insertions, 0 deletions
diff --git a/src/serve.rs b/src/serve.rs new file mode 100644 index 0000000..c5d2efd --- /dev/null +++ b/src/serve.rs @@ -0,0 +1,152 @@ +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<String> { + 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<PathBuf> { + 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"); + } +} |