use crate::server::{ServerState, build_reply};
use rusty_time_clock::{ClockRead, SystemClock};
use rusty_time_core::ntp::NtpTimestamp;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
const MAX_HEADER_BYTES: u64 = 8 * 1024;
const MAX_BODY_BYTES: usize = 4 * 1024;
const IO_TIMEOUT: Duration = Duration::from_secs(10);
pub fn serve(
bind: &str,
state: Arc<Mutex<ServerState>>,
asset_dir: Option<String>,
) -> Result<(), String> {
let listener = TcpListener::bind(bind).map_err(|e| format!("binding gateway {bind}: {e}"))?;
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let state = Arc::clone(&state);
let assets = asset_dir.clone();
std::thread::spawn(move || {
let _ = handle(stream, &state, assets.as_deref());
});
}
Ok(())
}
fn handle(
stream: TcpStream,
state: &Arc<Mutex<ServerState>>,
asset_dir: Option<&str>,
) -> Result<(), String> {
stream.set_read_timeout(Some(IO_TIMEOUT)).ok();
stream.set_write_timeout(Some(IO_TIMEOUT)).ok();
let peer = stream.peer_addr().map_err(|e| e.to_string())?;
let mut writer = stream.try_clone().map_err(|e| e.to_string())?;
let mut reader = BufReader::new(stream.take(MAX_HEADER_BYTES));
let mut request_line = String::new();
if reader
.read_line(&mut request_line)
.map_err(|e| e.to_string())?
== 0
{
return Ok(());
}
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let path = parts.next().unwrap_or("/").to_string();
let mut content_length = 0usize;
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line).map_err(|e| e.to_string())? == 0 {
break;
}
let line = line.trim_end();
if line.is_empty() {
break;
}
if let Some(value) = line
.split_once(':')
.filter(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.map(|(_, v)| v.trim())
{
content_length = value.parse().unwrap_or(0);
}
}
match (method.as_str(), path.as_str()) {
("OPTIONS", _) => respond(&mut writer, 204, "text/plain", &[]),
("POST", p) if p.starts_with("/time") => {
time_exchange(&mut reader, &mut writer, content_length, peer, state)
}
("GET", "/") | ("GET", "/index.html") => respond(
&mut writer,
200,
"text/html; charset=utf-8",
STATUS_PAGE.as_bytes(),
),
("GET", p) => serve_asset(&mut writer, p, asset_dir),
_ => respond(&mut writer, 405, "text/plain", b"method not allowed\n"),
}
}
fn time_exchange<R: BufRead>(
reader: &mut R,
writer: &mut TcpStream,
content_length: usize,
peer: SocketAddr,
state: &Arc<Mutex<ServerState>>,
) -> Result<(), String> {
if content_length == 0 || content_length > MAX_BODY_BYTES {
return respond(writer, 400, "text/plain", b"bad request body length\n");
}
let mut body = vec![0u8; content_length];
if reader.read_exact(&mut body).is_err() {
return respond(writer, 400, "text/plain", b"truncated body\n");
}
let clock = SystemClock;
let Ok(ns) = clock.wall_ns() else {
return respond(writer, 500, "text/plain", b"clock unavailable\n");
};
let recv_ts = NtpTimestamp::from_unix((ns / 1_000_000_000) as i64, (ns % 1_000_000_000) as u32);
match build_reply(&body, peer, recv_ts, state, &clock) {
Some(reply) => respond(
writer,
200,
"application/octet-stream",
reply.bytes.as_slice(),
),
None => respond(writer, 429, "text/plain", b"no reply\n"),
}
}
fn serve_asset(writer: &mut TcpStream, path: &str, asset_dir: Option<&str>) -> Result<(), String> {
let Some(dir) = asset_dir else {
return respond(writer, 404, "text/plain", b"not found\n");
};
let name = path.trim_start_matches('/');
if name.is_empty()
|| name.contains("..")
|| name.contains('/')
|| name.contains('\\')
|| name.contains(':')
{
return respond(writer, 404, "text/plain", b"not found\n");
}
let content_type = if name.ends_with(".wasm") {
"application/wasm"
} else if name.ends_with(".js") {
"text/javascript; charset=utf-8"
} else if name.ends_with(".html") {
"text/html; charset=utf-8"
} else {
"application/octet-stream"
};
match std::fs::read(std::path::Path::new(dir).join(name)) {
Ok(bytes) => respond(writer, 200, content_type, &bytes),
Err(_) => respond(writer, 404, "text/plain", b"not found\n"),
}
}
fn respond(
writer: &mut TcpStream,
status: u16,
content_type: &str,
body: &[u8],
) -> Result<(), String> {
let reason = match status {
200 => "OK",
204 => "No Content",
400 => "Bad Request",
404 => "Not Found",
405 => "Method Not Allowed",
429 => "Too Many Requests",
_ => "Internal Server Error",
};
let head = format!(
"HTTP/1.1 {status} {reason}\r\n\
Content-Type: {content_type}\r\n\
Content-Length: {}\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: POST, GET, OPTIONS\r\n\
Access-Control-Allow-Headers: content-type\r\n\
Cache-Control: no-store\r\n\
Connection: close\r\n\
\r\n",
body.len()
);
writer
.write_all(head.as_bytes())
.map_err(|e| e.to_string())?;
writer.write_all(body).map_err(|e| e.to_string())?;
writer.flush().map_err(|e| e.to_string())?;
Ok(())
}
const STATUS_PAGE: &str = include_str!("status_page.html");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn asset_names_that_escape_the_directory_are_refused() {
for bad in [
"../secret",
"..\\secret",
"a/b",
"a\\b",
"C:windows",
"",
"../../etc/passwd",
] {
let name = bad.trim_start_matches('/');
let refused = name.is_empty()
|| name.contains("..")
|| name.contains('/')
|| name.contains('\\')
|| name.contains(':');
assert!(refused, "{bad} would have been served");
}
for good in ["rusty_time_wasm.js", "rusty_time_wasm_bg.wasm", "demo.html"] {
let refused = good.is_empty()
|| good.contains("..")
|| good.contains('/')
|| good.contains('\\')
|| good.contains(':');
assert!(!refused, "{good} should be servable");
}
}
#[test]
fn the_status_page_is_present_and_self_contained() {
assert!(STATUS_PAGE.len() > 500, "status page looks empty");
assert!(STATUS_PAGE.contains("<!doctype html") || STATUS_PAGE.contains("<!DOCTYPE html"));
assert!(
!STATUS_PAGE.contains("http://") && !STATUS_PAGE.contains("https://"),
"status page must not reference external origins"
);
}
}