use anyhow::{Context, Result};
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
pub const DEFAULT_PORT: u16 = 7365;
pub const PORT_ENV: &str = "SEMLITH_PORT";
pub const TOKEN_HEADER: &str = "Semlith-Token";
const TOKEN_HEADER_KEY: &str = "semlith-token";
pub const MCP_PATH: &str = "/mcp";
const WORKERS: usize = 8;
const MAX_HEADERS: usize = 16 * 1024;
const MAX_BODY: usize = 1024 * 1024;
const IO_TIMEOUT: Duration = Duration::from_secs(30);
const REQUEST_DEADLINE: Duration = Duration::from_secs(10);
const MAX_CONNECTIONS: usize = 32;
static WAKE: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Refusal {
Unauthorized,
ForeignHost,
Malformed,
CrossOrigin,
BadContentType,
OutsideBoundary,
DeniedPath,
PrivateAddress,
}
impl Refusal {
pub fn as_str(self) -> &'static str {
match self {
Refusal::Unauthorized => "unauthorized",
Refusal::ForeignHost => "foreign host",
Refusal::Malformed => "malformed",
Refusal::CrossOrigin => "cross-origin",
Refusal::BadContentType => "bad-content-type",
Refusal::OutsideBoundary => "outside-boundary",
Refusal::DeniedPath => "denied-path",
Refusal::PrivateAddress => "private-address",
}
}
}
pub struct Request {
pub method: String,
pub path: String,
pub query: BTreeMap<String, String>,
pub headers: BTreeMap<String, String>,
pub body: Vec<u8>,
}
impl Request {
pub fn query(&self, key: &str) -> Option<&str> {
self.query.get(key).map(String::as_str)
}
pub fn query_all(&self, key: &str) -> Vec<String> {
match self.query.get(key) {
Some(v) => v.split('\u{1}').map(str::to_string).collect(),
None => Vec::new(),
}
}
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).map(String::as_str)
}
pub fn json(&self) -> Result<serde_json::Value> {
anyhow::ensure!(
json_body(self.header("content-type")),
"this route reads JSON; send Content-Type: application/json"
);
Ok(serde_json::from_slice(&self.body)?)
}
}
fn json_body(content_type: Option<&str>) -> bool {
content_type
.map(|value| value.split(';').next().unwrap_or("").trim())
.is_some_and(|media| media.eq_ignore_ascii_case("application/json"))
}
pub struct Chunks<'a> {
out: &'a mut dyn Write,
}
impl Chunks<'_> {
pub fn send(&mut self, line: &str) -> std::io::Result<()> {
write!(self.out, "{:x}\r\n", line.len())?;
self.out.write_all(line.as_bytes())?;
self.out.write_all(b"\r\n")?;
self.out.flush()
}
}
type Streamer = Box<dyn FnOnce(&mut Chunks) -> std::io::Result<()> + Send>;
enum Body {
Bytes(Vec<u8>),
Stream(Streamer),
}
pub struct Response {
status: u16,
content_type: &'static str,
extra: Vec<(String, String)>,
body: Body,
}
impl Response {
pub fn new(status: u16, content_type: &'static str, body: Vec<u8>) -> Self {
Self {
status,
content_type,
extra: Vec::new(),
body: Body::Bytes(body),
}
}
pub fn json(value: &serde_json::Value) -> Self {
Self::new(
200,
"application/json; charset=utf-8",
serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec()),
)
}
pub fn error(status: u16, message: &str) -> Self {
Self::new(
status,
"application/json; charset=utf-8",
serde_json::to_vec(&serde_json::json!({ "error": message }))
.unwrap_or_else(|_| b"{}".to_vec()),
)
}
pub fn asset(content_type: &'static str, bytes: &'static [u8]) -> Self {
Self::new(200, content_type, bytes.to_vec())
}
pub fn stream(f: impl FnOnce(&mut Chunks) -> std::io::Result<()> + Send + 'static) -> Self {
Self {
status: 200,
content_type: "application/x-ndjson; charset=utf-8",
extra: Vec::new(),
body: Body::Stream(Box::new(f)),
}
}
pub fn header(mut self, name: &str, value: impl Into<String>) -> Self {
self.extra.push((name.to_string(), value.into()));
self
}
}
pub type Handler = Arc<dyn Fn(&Request) -> Response + Send + Sync>;
struct Late {
stream: TcpStream,
due: std::time::Instant,
_in_flight: InFlight,
}
struct InFlight(Arc<std::sync::atomic::AtomicUsize>);
impl Drop for InFlight {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
#[derive(Default)]
struct Agent {
current: String,
previous: Option<(String, std::time::Instant)>,
}
const KEY_GRACE: Duration = Duration::from_secs(15 * 60);
pub struct Server {
listener: TcpListener,
port: u16,
token: Arc<Mutex<String>>,
agent: Arc<Mutex<Agent>>,
mcp_open: Arc<AtomicBool>,
penalty: Arc<Mutex<Penalty>>,
}
impl Server {
pub fn bind(port: u16) -> Result<Self> {
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let listener = TcpListener::bind(addr).with_context(|| {
format!(
"port {port} is already in use — stop whatever is listening on it, \
or choose another with --port or {PORT_ENV}"
)
})?;
let port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
Ok(Self {
listener,
port,
token: Arc::new(Mutex::new(new_token())),
agent: Arc::new(Mutex::new(Agent::default())),
mcp_open: Arc::new(AtomicBool::new(true)),
penalty: Arc::new(Mutex::new(Penalty::default())),
})
}
pub fn set_agent_key(&self, key: &str) {
self.agent.lock().unwrap_or_else(|e| e.into_inner()).current = key.to_string();
}
pub fn agent_key(&self) -> String {
self.agent
.lock()
.unwrap_or_else(|e| e.into_inner())
.current
.clone()
}
pub fn rotate_agent(&self, key: &str, now: bool) {
let mut agent = self.agent.lock().unwrap_or_else(|e| e.into_inner());
let was = std::mem::replace(&mut agent.current, key.to_string());
agent.previous = if now || was.is_empty() {
None
} else {
Some((was, std::time::Instant::now() + KEY_GRACE))
};
}
pub fn key_grace(&self) -> Option<Duration> {
let agent = self.agent.lock().unwrap_or_else(|e| e.into_inner());
agent
.previous
.as_ref()
.map(|(_, until)| until.saturating_duration_since(std::time::Instant::now()))
.filter(|left| !left.is_zero())
}
pub fn mcp_open(&self) -> bool {
self.mcp_open.load(Ordering::Relaxed)
}
pub fn set_mcp_open(&self, open: bool) {
self.mcp_open.store(open, Ordering::Relaxed);
}
pub fn port(&self) -> u16 {
self.port
}
pub fn token(&self) -> String {
self.token.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
pub fn rotate(&self) -> String {
let fresh = new_token();
*self.token.lock().unwrap_or_else(|e| e.into_inner()) = fresh.clone();
fresh
}
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}/?token={}", self.port, self.token())
}
pub fn serve(
&self,
handler: Handler,
stop: &'static AtomicBool,
refused: impl Fn(Refusal) + Send + Sync + 'static,
) -> Result<()> {
let (tx, rx) = mpsc::channel::<(TcpStream, InFlight)>();
let rx = Arc::new(Mutex::new(rx));
let refused = Arc::new(refused);
let live = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (late_tx, late_rx) = mpsc::channel::<Late>();
let waiting_room = std::thread::spawn(move || {
while let Ok(late) = late_rx.recv() {
let now = std::time::Instant::now();
if late.due > now {
std::thread::sleep(late.due - now);
}
let mut stream = late.stream;
let _ = write_response(&mut stream, Response::new(401, "text/plain", Vec::new()));
}
});
let port = self.port;
let spawn_worker = {
let rx = Arc::clone(&rx);
let late_tx = late_tx.clone();
let penalty = Arc::clone(&self.penalty);
let handler = Arc::clone(&handler);
let token = Arc::clone(&self.token);
let agent = Arc::clone(&self.agent);
let mcp_open = Arc::clone(&self.mcp_open);
let refused = Arc::clone(&refused);
move || {
let rx = Arc::clone(&rx);
let late_tx = late_tx.clone();
let penalty = Arc::clone(&penalty);
let handler = Arc::clone(&handler);
let token = Arc::clone(&token);
let agent = Arc::clone(&agent);
let mcp_open = Arc::clone(&mcp_open);
let refused = Arc::clone(&refused);
std::thread::spawn(move || {
loop {
let next = {
let guard = rx.lock().unwrap_or_else(|e| e.into_inner());
guard.recv()
};
let Ok((stream, in_flight)) = next else {
return;
};
let hold = |stream, in_flight| {
let delay = penalty.lock().unwrap_or_else(|e| e.into_inner()).charge();
let _ = late_tx.send(Late {
stream,
due: std::time::Instant::now() + delay,
_in_flight: in_flight,
});
};
let auth = {
let agent = agent.lock().unwrap_or_else(|e| e.into_inner());
Auth {
token: token.lock().unwrap_or_else(|e| e.into_inner()).clone(),
agent: agent.current.clone(),
previous: agent.previous.clone(),
mcp_open: mcp_open.load(Ordering::Relaxed),
port,
}
};
if let Some(class) = answer(stream, in_flight, &handler, &auth, &hold) {
refused(class);
}
}
})
}
};
let mut workers: Vec<_> = (0..WORKERS).map(|_| spawn_worker()).collect();
let waker = {
let port = self.port;
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) && !WAKE.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(250));
}
let _ = TcpStream::connect(("127.0.0.1", port));
})
};
while !stop.load(Ordering::Relaxed) {
match self.listener.accept() {
Ok((mut stream, _)) => {
if stop.load(Ordering::Relaxed) {
break;
}
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
let taken = live.fetch_add(1, Ordering::Relaxed);
let in_flight = InFlight(Arc::clone(&live));
if taken >= MAX_CONNECTIONS {
let _ = write_response(
&mut stream,
Response::new(503, "text/plain", Vec::new()),
);
drop(in_flight);
continue;
}
if tx.send((stream, in_flight)).is_err() {
break;
}
for worker in &mut workers {
if worker.is_finished() {
*worker = spawn_worker();
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => {
WAKE.store(true, Ordering::Relaxed);
let _ = waker.join();
return Err(e).context("accepting a connection");
}
}
}
WAKE.store(true, Ordering::Relaxed);
let _ = waker.join();
drop(tx);
for worker in workers {
let _ = worker.join();
}
drop(spawn_worker);
drop(late_tx);
let _ = waiting_room.join();
Ok(())
}
}
struct Auth {
token: String,
agent: String,
previous: Option<(String, std::time::Instant)>,
mcp_open: bool,
port: u16,
}
impl Auth {
fn is_agent(&self, bearer: &str) -> bool {
if bearer.is_empty() {
return false;
}
if !self.agent.is_empty() && same(bearer, &self.agent) {
return true;
}
self.previous.as_ref().is_some_and(|(previous, until)| {
!previous.is_empty() && *until > std::time::Instant::now() && same(bearer, previous)
})
}
}
fn answer(
mut stream: TcpStream,
in_flight: InFlight,
handler: &Handler,
auth: &Auth,
hold: &impl Fn(TcpStream, InFlight),
) -> Option<Refusal> {
let started = std::time::Instant::now();
let mut reader = match stream.try_clone().map(BufReader::new) {
Ok(reader) => reader,
Err(_) => return Some(Refusal::Malformed),
};
let mut request = match read_head(&mut reader) {
Ok(Some(r)) => r,
Ok(None) => return None,
Err(_) => {
let _ = write_response(&mut stream, Response::new(400, "text/plain", Vec::new()));
return Some(Refusal::Malformed);
}
};
if !loopback_host(request.header("host")) {
drain_body(&mut reader, &request, started);
let _ = write_response(&mut stream, Response::new(400, "text/plain", Vec::new()));
return Some(Refusal::ForeignHost);
}
let want = auth.token.as_str();
let for_mcp = request.path == MCP_PATH;
if for_mcp && !auth.mcp_open {
drain_body(&mut reader, &request, started);
let _ = write_response(&mut stream, Response::error(404, "no such route"));
return None;
}
let head_only = request.method == "HEAD";
let reading = head_only || request.method == "GET";
if !reading {
if !same_origin(&request, auth.port) {
drain_body(&mut reader, &request, started);
let _ = write_response(&mut stream, Response::new(403, "text/plain", Vec::new()));
return Some(Refusal::CrossOrigin);
}
if !json_body(request.header("content-type")) {
drain_body(&mut reader, &request, started);
let _ = write_response(&mut stream, Response::new(403, "text/plain", Vec::new()));
return Some(Refusal::BadContentType);
}
}
if !public(&request, reading) {
let by_agent = for_mcp
&& request
.header("authorization")
.and_then(|value| value.strip_prefix("Bearer "))
.is_some_and(|bearer| auth.is_agent(bearer.trim()));
let by_header = request
.header(TOKEN_HEADER_KEY)
.is_some_and(|t| same(t.trim(), want));
if !by_header && !by_agent {
drain_body(&mut reader, &request, started);
hold(stream, in_flight);
return Some(Refusal::Unauthorized);
}
}
if read_body(&mut reader, &mut request).is_err() {
let _ = write_response(&mut stream, Response::new(400, "text/plain", Vec::new()));
return Some(Refusal::Malformed);
}
if started.elapsed() > REQUEST_DEADLINE {
let _ = write_response(&mut stream, Response::new(408, "text/plain", Vec::new()));
return Some(Refusal::Malformed);
}
let request = if head_only {
Request {
method: "GET".to_string(),
..request
}
} else {
request
};
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handler(&request)));
let mut response = match caught {
Ok(response) => response,
Err(_) => {
eprintln!(
"semlith: {} {} panicked; answered 500 and kept serving",
request.method, request.path
);
Response::new(500, "text/plain", Vec::new())
}
};
if head_only {
response.body = Body::Bytes(Vec::new());
}
if matches!(response.body, Body::Stream(_)) {
std::thread::spawn(move || {
let _in_flight = in_flight;
let _ = write_response(&mut stream, response);
});
return None;
}
let _ = write_response(&mut stream, response);
None
}
fn public(request: &Request, reading: bool) -> bool {
reading && (request.path == "/" || crate::portal::asset(&request.path).is_some())
}
fn same_origin(request: &Request, port: u16) -> bool {
if let Some(site) = request.header("sec-fetch-site") {
return site.trim().eq_ignore_ascii_case("same-origin");
}
match request.header("origin") {
Some(origin) => own_origin(origin.trim(), port),
None => true,
}
}
fn own_origin(origin: &str, port: u16) -> bool {
let Some(authority) = origin.strip_prefix("http://") else {
return false;
};
let Some((host, stated)) = authority.rsplit_once(':') else {
return false;
};
let host = host.trim_start_matches('[').trim_end_matches(']');
let loopback = host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1";
loopback && stated.parse::<u16>() == Ok(port)
}
fn loopback_host(host: Option<&str>) -> bool {
let Some(host) = host else { return false };
let name = host.rsplit_once(':').map_or(host, |(n, _)| n);
let name = name.trim_start_matches('[').trim_end_matches(']');
name.eq_ignore_ascii_case("localhost") || name == "127.0.0.1" || name == "::1"
}
fn same(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.bytes().zip(b.bytes()) {
diff |= x ^ y;
}
diff == 0
}
fn read_head(reader: &mut BufReader<TcpStream>) -> Result<Option<Request>> {
let mut head = String::new();
let mut read = 0;
loop {
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
return Ok(head.is_empty().then_some(None).flatten());
}
read += n;
anyhow::ensure!(read <= MAX_HEADERS, "request headers too large");
if line == "\r\n" || line == "\n" {
break;
}
head.push_str(&line);
}
let mut lines = head.lines();
let start = lines.next().context("empty request")?;
let mut parts = start.split_whitespace();
let method = parts.next().context("no method")?.to_string();
let target = parts.next().context("no target")?.to_string();
let mut headers = BTreeMap::new();
for line in lines {
if let Some((name, value)) = line.split_once(':') {
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
}
}
let (raw_path, raw_query) = match target.split_once('?') {
Some((p, q)) => (p, q),
None => (target.as_str(), ""),
};
Ok(Some(Request {
method,
path: percent_decode(raw_path),
query: parse_query(raw_query),
headers,
body: Vec::new(),
}))
}
fn drain_body(reader: &mut BufReader<TcpStream>, request: &Request, started: std::time::Instant) {
let Some(len) = request
.headers
.get("content-length")
.and_then(|v| v.parse::<usize>().ok())
else {
return;
};
let mut left = len.min(MAX_BODY);
let mut sink = [0u8; 8 * 1024];
while left > 0 && started.elapsed() <= REQUEST_DEADLINE {
let want = left.min(sink.len());
match reader.read(&mut sink[..want]) {
Ok(0) | Err(_) => return,
Ok(n) => left -= n,
}
}
}
fn read_body(reader: &mut BufReader<TcpStream>, request: &mut Request) -> Result<()> {
let Some(len) = request
.headers
.get("content-length")
.and_then(|v| v.parse::<usize>().ok())
else {
return Ok(());
};
anyhow::ensure!(len <= MAX_BODY, "request body too large");
request.body.resize(len, 0);
reader.read_exact(&mut request.body)?;
Ok(())
}
fn parse_query(raw: &str) -> BTreeMap<String, String> {
let mut out: BTreeMap<String, String> = BTreeMap::new();
for pair in raw.split('&').filter(|p| !p.is_empty()) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
let key = percent_decode(&key.replace('+', " "));
let value = percent_decode(&value.replace('+', " "));
out.entry(key)
.and_modify(|existing| {
existing.push('\u{1}');
existing.push_str(&value);
})
.or_insert(value);
}
out
}
fn percent_decode(raw: &str) -> String {
let bytes = raw.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
if let Ok(byte) = u8::from_str_radix(hex, 16) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn write_response(stream: &mut TcpStream, response: Response) -> std::io::Result<()> {
let Response {
status,
content_type,
extra,
body,
} = response;
let mut head = format!("HTTP/1.1 {status} {}\r\n", reason(status));
head.push_str(&format!("Content-Type: {content_type}\r\n"));
head.push_str(
"Content-Security-Policy: default-src 'self'; \
img-src 'self' data:; \
style-src 'self'; \
script-src 'self'; \
font-src 'self'; \
connect-src 'self'; \
form-action 'none'; \
frame-ancestors 'none'; \
base-uri 'none'\r\n",
);
head.push_str("X-Content-Type-Options: nosniff\r\n");
head.push_str("Referrer-Policy: no-referrer\r\n");
head.push_str("Cache-Control: no-store\r\n");
head.push_str("Connection: close\r\n");
for (name, value) in &extra {
head.push_str(&format!("{name}: {value}\r\n"));
}
match body {
Body::Bytes(bytes) => {
head.push_str(&format!("Content-Length: {}\r\n\r\n", bytes.len()));
stream.write_all(head.as_bytes())?;
stream.write_all(&bytes)?;
stream.flush()
}
Body::Stream(f) => {
head.push_str("Transfer-Encoding: chunked\r\n\r\n");
stream.write_all(head.as_bytes())?;
stream.flush()?;
let result = {
let mut chunks = Chunks { out: stream };
f(&mut chunks)
};
stream.write_all(b"0\r\n\r\n")?;
stream.flush()?;
result
}
}
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
400 => "Bad Request",
401 => "Unauthorized",
404 => "Not Found",
405 => "Method Not Allowed",
409 => "Conflict",
500 => "Internal Server Error",
_ => "OK",
}
}
fn new_token() -> String {
let mut bytes = [0u8; 32];
let filled = getrandom::fill(&mut bytes).map(|()| bytes);
token_from(filled)
}
fn token_from(filled: Result<[u8; 32], getrandom::Error>) -> String {
let bytes = filled
.expect("the OS random source refused; semlith will not mint a session token without it");
let mut out = String::with_capacity(64);
for byte in bytes {
use std::fmt::Write;
let _ = write!(out, "{byte:02x}");
}
out
}
#[derive(Default)]
struct Penalty {
recent: std::collections::VecDeque<std::time::Instant>,
}
const REFUSAL_DELAY: Duration = Duration::from_millis(250);
const MAX_REFUSAL_DELAY: Duration = Duration::from_secs(4);
const REFUSALS_PER_STEP: usize = 20;
impl Penalty {
fn charge(&mut self) -> Duration {
let now = std::time::Instant::now();
while self
.recent
.front()
.is_some_and(|t| now.duration_since(*t) > Duration::from_secs(60))
{
self.recent.pop_front();
}
self.recent.push_back(now);
let steps = (self.recent.len().saturating_sub(1)) / REFUSALS_PER_STEP;
let delay = REFUSAL_DELAY * 2u32.saturating_pow(steps.min(8) as u32);
delay.min(MAX_REFUSAL_DELAY)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_loopback_hosts_are_answered() {
assert!(loopback_host(Some("localhost")));
assert!(loopback_host(Some("localhost:7365")));
assert!(loopback_host(Some("127.0.0.1:7365")));
assert!(loopback_host(Some("LOCALHOST:7365")));
assert!(loopback_host(Some("[::1]:7365")));
assert!(!loopback_host(Some("evil.example")));
assert!(!loopback_host(Some("evil.example:7365")));
assert!(!loopback_host(Some("notlocalhost")));
assert!(!loopback_host(Some("localhost.evil.example")));
assert!(!loopback_host(Some("127.1")));
assert!(!loopback_host(None));
}
fn write(headers: &[(&str, &str)]) -> Request {
Request {
method: "POST".into(),
path: "/api/index".into(),
query: BTreeMap::new(),
headers: headers
.iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
.collect(),
body: Vec::new(),
}
}
#[test]
fn only_a_same_origin_write_is_accepted() {
assert!(same_origin(
&write(&[("Sec-Fetch-Site", "same-origin")]),
7365
));
assert!(!same_origin(
&write(&[("Sec-Fetch-Site", "same-site")]),
7365
));
assert!(!same_origin(
&write(&[("Sec-Fetch-Site", "cross-site")]),
7365
));
assert!(!same_origin(&write(&[("Sec-Fetch-Site", "none")]), 7365));
assert!(!same_origin(
&write(&[
("Sec-Fetch-Site", "same-site"),
("Origin", "http://127.0.0.1:7365"),
]),
7365
));
assert!(same_origin(&write(&[]), 7365));
assert!(same_origin(
&write(&[("Origin", "http://localhost:7365")]),
7365
));
assert!(!same_origin(
&write(&[("Origin", "http://127.0.0.1:9999")]),
7365
));
assert!(!same_origin(&write(&[("Origin", "null")]), 7365));
assert!(!same_origin(
&write(&[("Origin", "https://127.0.0.1:7365")]),
7365
));
}
#[test]
fn only_a_json_body_is_read() {
assert!(json_body(Some("application/json")));
assert!(json_body(Some("application/json; charset=utf-8")));
assert!(json_body(Some("Application/JSON")));
assert!(!json_body(Some("text/plain")));
assert!(!json_body(Some("application/x-www-form-urlencoded")));
assert!(!json_body(Some("multipart/form-data; boundary=x")));
assert!(!json_body(Some("application/json-patch+json")));
assert!(!json_body(None));
}
#[test]
fn only_the_page_and_its_assets_are_public() {
let get = |path: &str| Request {
method: "GET".into(),
path: path.into(),
query: BTreeMap::new(),
headers: BTreeMap::new(),
body: Vec::new(),
};
assert!(public(&get("/"), true));
assert!(public(&get("/app.js"), true));
assert!(public(&get("/style.css"), true));
assert!(!public(&get("/api/stores"), true));
assert!(!public(&get("/api/image"), true));
assert!(!public(&get(MCP_PATH), true));
assert!(!public(&get("/"), false));
}
#[test]
fn tokens_compare_by_value_and_by_length() {
assert!(same("abc", "abc"));
assert!(!same("abc", "abd"));
assert!(!same("abc", "abcd"));
assert!(!same("", "a"));
}
#[test]
fn a_repeated_query_key_keeps_every_value() {
let q = parse_query("lang=rust&lang=go&k=5");
let request = Request {
method: "GET".into(),
path: "/".into(),
query: q,
headers: BTreeMap::new(),
body: Vec::new(),
};
assert_eq!(request.query_all("lang"), vec!["rust", "go"]);
assert_eq!(request.query("k"), Some("5"));
assert!(request.query_all("ext").is_empty());
}
#[test]
fn percent_and_plus_both_decode() {
assert_eq!(percent_decode("a%20b"), "a b");
assert_eq!(percent_decode("%2Fsrc%2Flib.rs"), "/src/lib.rs");
assert_eq!(percent_decode("100%"), "100%");
let q = parse_query("query=Fleet%3A%3Awritable+opens");
assert_eq!(q.get("query").unwrap(), "Fleet::writable opens");
}
#[test]
fn every_token_is_new() {
let a = new_token();
let b = new_token();
assert_ne!(a, b);
assert_eq!(a.len(), 64, "256 bits, hex encoded");
assert!(
a.bytes()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"not lowercase hex: {a}"
);
}
#[test]
fn a_token_is_never_minted_without_the_os_random_source() {
let refused = std::panic::catch_unwind(|| token_from(Err(getrandom::Error::UNSUPPORTED)))
.expect_err("a failing random source minted a token anyway");
let message = refused
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| refused.downcast_ref::<&str>().copied())
.unwrap_or("");
assert!(
message.contains("the OS random source refused"),
"the panic does not say what refused: {message}"
);
assert_eq!(token_from(Ok([0u8; 32])), "0".repeat(64));
assert_eq!(&token_from(Ok([0xab; 32]))[..4], "abab");
}
#[test]
fn a_run_of_refusals_gets_slower() {
let mut penalty = Penalty::default();
assert_eq!(penalty.charge(), REFUSAL_DELAY, "the first refusal");
for _ in 1..REFUSALS_PER_STEP {
assert_eq!(penalty.charge(), REFUSAL_DELAY);
}
assert_eq!(penalty.charge(), REFUSAL_DELAY * 2);
for _ in 0..REFUSALS_PER_STEP {
penalty.charge();
}
assert_eq!(penalty.charge(), REFUSAL_DELAY * 4);
for _ in 0..200 {
penalty.charge();
}
assert_eq!(
penalty.charge(),
MAX_REFUSAL_DELAY,
"the delay is capped, because holding a connection forever is the \
other kind of denial of service"
);
let old = std::time::Instant::now() - Duration::from_secs(61);
penalty.recent = std::iter::repeat_n(old, 500).collect();
assert_eq!(penalty.charge(), REFUSAL_DELAY);
}
}