aboutsummaryrefslogtreecommitdiff
path: root/src/serve.rs
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2026-04-30 15:17:30 +0200
committerRuben Beltran del Rio <jj@r.bdr.sh>2026-04-30 15:37:23 +0200
commit8cc11c52a88ec78d175b3ec8de854916f631caab (patch)
tree4ad0bc1664a97418d0475b0b730c54d874953348 /src/serve.rs
Initial build
Diffstat (limited to 'src/serve.rs')
-rw-r--r--src/serve.rs152
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");
+ }
+}