makepad/tools/web_server/tests/site_server.rs

1167 lines
44 KiB
Rust

use makepad_map_nav::{
geo::LonLat,
graph::GraphBuilder,
search::{Category, SearchIndexBuilder, SearchResult},
};
use makepad_web_server::{
api::{sample_along, AlongRequest, AlongResult, ApiFailure, NavBackend, RouteRequest, RouteResult, SearchRequest, ServiceRegistry},
static_files::{ReportRateLimiter, StaticHandler},
};
use makepad_geodata::{
mvt::AttrVal,
query::LayerDb,
sidecar::SidecarBuilder,
wkb::Geometry,
};
use makepad_mbtile_reader::MbtilesWriter;
use makepad_network::HttpServerHeaders;
use makepad_asset_data::*;
use makepad_asset_store::{
export_static, AssetAnnotation, AssetServerCore, Budgets, PublishBatchItem,
StaticExportOptions, Visibility,
};
use std::{
collections::HashMap,
fs,
io::{Read, Write},
net::{IpAddr, Ipv4Addr, Shutdown, SocketAddr, TcpListener, TcpStream},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Condvar, Mutex,
},
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
struct TempTree(PathBuf);
impl TempTree {
fn new(label: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"makepad-web-{label}-{}-{}",
std::process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for TempTree {
fn drop(&mut self) {
make_tree_writable(&self.0);
let _ = fs::remove_dir_all(&self.0);
}
}
#[cfg(unix)]
fn make_tree_writable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let Ok(metadata) = fs::symlink_metadata(path) else { return };
if metadata.is_dir() {
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o755));
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.flatten() {
make_tree_writable(&entry.path());
}
}
} else if !metadata.file_type().is_symlink() {
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o644));
}
}
#[cfg(not(unix))]
fn make_tree_writable(_path: &Path) {}
#[cfg(target_os = "linux")]
fn freeze_docroot(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if fs::symlink_metadata(path).unwrap().is_dir() {
for entry in fs::read_dir(path).unwrap() {
freeze_docroot(&entry.unwrap().path());
}
fs::set_permissions(path, fs::Permissions::from_mode(0o555)).unwrap();
} else {
fs::set_permissions(path, fs::Permissions::from_mode(0o444)).unwrap();
}
}
#[cfg(not(target_os = "linux"))]
fn freeze_docroot(_path: &Path) {}
struct ServerChild(Child);
impl Drop for ServerChild {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[derive(Debug)]
struct Response {
status: u16,
headers: String,
body: Vec<u8>,
}
fn free_address() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap()
}
fn wait_until_listening(address: SocketAddr) {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if TcpStream::connect_timeout(&address, Duration::from_millis(50)).is_ok() {
return;
}
thread::sleep(Duration::from_millis(20));
}
panic!("server did not listen on {address}");
}
fn request(address: SocketAddr, request: &[u8]) -> Response {
let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
stream.write_all(request).unwrap();
stream.shutdown(Shutdown::Write).unwrap();
let mut bytes = Vec::new();
if let Err(error) = stream.read_to_end(&mut bytes) {
assert_eq!(error.kind(), std::io::ErrorKind::ConnectionReset);
}
let split = bytes.windows(4).position(|window| window == b"\r\n\r\n").unwrap() + 4;
let headers = String::from_utf8(bytes[..split].to_vec()).unwrap();
let status = headers.split_whitespace().nth(1).unwrap().parse().unwrap();
Response { status, headers, body: bytes[split..].to_vec() }
}
fn get(address: SocketAddr, path: &str, headers: &str) -> Response {
request(address, format!("GET {path} HTTP/1.1\r\nHost: test\r\n{headers}\r\n").as_bytes())
}
fn post(address: SocketAddr, path: &str, body: &[u8]) -> Response {
let mut request_bytes = format!(
"POST {path} HTTP/1.1\r\nHost: test\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
body.len()
).into_bytes();
request_bytes.extend_from_slice(body);
request(address, &request_bytes)
}
fn slow_along_upload(address: SocketAddr) -> TcpStream {
let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap();
stream
.write_all(
b"POST /api/along HTTP/1.1\r\nHost: test\r\nContent-Type: application/json\r\nContent-Length: 2097152\r\n\r\n",
)
.unwrap();
stream
}
fn post_with_type(address: SocketAddr, path: &str, content_type: &str, body: &[u8]) -> Response {
let mut request_bytes = format!(
"POST {path} HTTP/1.1\r\nHost: test\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\r\n",
body.len()
)
.into_bytes();
request_bytes.extend_from_slice(body);
request(address, &request_bytes)
}
fn direct_headers(verb: &str, target: &str) -> HttpServerHeaders {
let (path, search) = target
.split_once('?')
.map(|(path, query)| (path, Some(query.to_string())))
.unwrap_or((target, None));
HttpServerHeaders {
addr: "127.0.0.1:12345".parse().unwrap(),
addr_text: "127.0.0.1:12345".into(),
lines: if verb == "POST" {
vec![
format!("{verb} {target} HTTP/1.1\r\n"),
"Content-Type: application/json\r\n".into(),
]
} else {
vec![format!("{verb} {target} HTTP/1.1\r\n")]
},
verb: verb.into(),
path: path.into(),
path_no_slash: path.trim_start_matches('/').into(),
search,
content_length: None,
accept_encoding: None,
sec_websocket_key: None,
}
}
fn direct_get(registry: &ServiceRegistry, target: &str) -> makepad_network::HttpServerResponse {
let (sender, receiver) = std::sync::mpsc::channel();
assert!(registry.handle_get(&direct_headers("GET", target), &sender));
receiver.recv_timeout(Duration::from_secs(2)).unwrap()
}
fn direct_status(response: &makepad_network::HttpServerResponse) -> u16 {
response.header.split_whitespace().nth(1).unwrap().parse().unwrap()
}
fn fixture_graph() -> Vec<u8> {
let mut builder = GraphBuilder::new();
builder.add_node(1, 4.8952, 52.3702);
builder.add_node(2, 4.9050, 52.3600);
builder.add_node(3, 4.9150, 52.3500);
let mut tags = HashMap::new();
tags.insert("highway".into(), "residential".into());
tags.insert("name".into(), "Fixture Street".into());
builder.add_way(1, vec![1, 2, 3], tags);
builder.build().serialize()
}
fn fixture_search() -> Vec<u8> {
let mut builder = SearchIndexBuilder::new();
builder.add("Fixture Museum", "Fixture City", LonLat::new(4.9050, 52.3600), Category::Museum, 220);
builder.add("Fixture City", "", LonLat::new(4.9000, 52.3650), Category::City, 255);
builder.build().serialize()
}
fn start_fixture_server(base: &Path) -> (ServerChild, SocketAddr) {
let root = base.join("site");
let data = base.join("private-data");
fs::create_dir_all(root.join("maps")).unwrap();
fs::create_dir_all(root.join("dir")).unwrap();
fs::create_dir_all(data.join("nav")).unwrap();
fs::write(root.join("index.html"), b"<h1>fixture</h1>").unwrap();
fs::write(root.join("app.0123456789abcdef.wasm"), b"0123456789").unwrap();
fs::write(root.join("app.0123456789abcdef.wasm.br"), b"BR").unwrap();
fs::write(root.join("plus+file.js"), b"plus").unwrap();
fs::write(root.join("dir/file.js"), b"nested").unwrap();
fs::write(root.join("maps/root.mkidx"), b"map-index").unwrap();
fs::write(root.join("maps/root.mkidx.br"), b"wrong-index-representation").unwrap();
fs::write(root.join("maps/tiles-001.mkshard"), b"map-shard").unwrap();
fs::write(data.join("nav/test.search"), fixture_search()).unwrap();
fs::write(data.join("nav/test.graph"), fixture_graph()).unwrap();
freeze_docroot(&root);
let address = free_address();
let child = Command::new(env!("CARGO_BIN_EXE_makepad-web-server"))
.args([
"--listen", &address.to_string(),
"--root", root.to_str().unwrap(),
"--data-dir", data.to_str().unwrap(),
"--nav-basename", "nav/test",
"--searchdb", "off",
"--places", "off",
"--major-graph", "off",
"--chargers", "off",
"--route-workers", "1",
"--route-queue", "1",
"--query-workers", "2",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
wait_until_listening(address);
(ServerChild(child), address)
}
fn start_snapshot_server(base: &Path) -> (ServerChild, SocketAddr, BlobId) {
let store_root = base.join("asset-store");
let core = AssetServerCore::open(&store_root, Budgets::default_v1()).unwrap();
let glb = b"SNAPSHOT-GLB";
let thumbnail = b"SNAPSHOT-PNG";
core.put_blob(glb, 1_700_000_000_000).unwrap();
core.put_blob(thumbnail, 1_700_000_000_000).unwrap();
let blob = BlobId::hash_of(glb);
let manifest = AssetManifest {
asset_id: AssetId::from_bytes([7; 16]),
kind: AssetKind::Prop,
files: vec![AssetFile {
role: FileRole::RenderGlb,
tier: DeviceTier::Any,
lod: 0,
media: MediaType::Glb,
blob,
byte_len: glb.len() as u64,
dims: None,
}],
dependencies: Vec::new(),
thumbnail: Some(ThumbnailMeta {
blob: BlobId::hash_of(thumbnail),
media: ThumbnailMedia::Png,
width: 512,
height: 512,
byte_len: thumbnail.len() as u64,
views: Vec::new(),
}),
metrics: Metrics {
total_bytes: (glb.len() + thumbnail.len()) as u64,
triangles: 1,
vertices: 3,
..Metrics::default()
},
coordinate_system: CoordinateSystem {
units_per_meter: 1.0,
up: Axis::YPos,
forward: Axis::ZNeg,
pivot: Pivot::Origin,
},
bounds: Bounds {
min: Vec3::new(-1.0, -1.0, -1.0),
max: Vec3::new(1.0, 1.0, 1.0),
},
anchors: Vec::new(),
capabilities: Capabilities::default(),
spawn_recipe: None,
provenance: None,
rights: Rights {
license: "CC0-1.0".into(),
license_revision: String::new(),
terms_digest: Some(sha256(b"CC0-1.0 legal text")),
terms_url: "https://creativecommons.org/publicdomain/zero/1.0/".into(),
credits: "fixture".into(),
source: String::new(),
source_archive: None,
redistribution: Redistribution::Allowed,
derivatives: DerivativePolicy::Allowed,
},
};
core.publish_batch(
&[PublishBatchItem {
namespace: "dj".into(),
manifest_bytes: manifest.to_canonical_bytes().unwrap(),
annotation: AssetAnnotation {
title: "DJ fixture".into(),
description: String::new(),
kind: Some(AssetKind::Prop),
categories: Vec::new(),
tags: Vec::new(),
creator: String::new(),
artist: String::new(),
artist_url: String::new(),
album: String::new(),
source_url: String::new(),
license: String::new(),
license_url: String::new(),
owner: None,
generator: String::new(),
backend: String::new(),
model: String::new(),
prompt: String::new(),
provenance: String::new(),
visibility: Visibility::Public,
},
alias: Some("dj/demo".parse().unwrap()),
}],
1_700_000_000_000,
)
.unwrap();
let root = base.join("site");
export_static(&core, &root, &StaticExportOptions::default()).unwrap();
fs::write(root.join("v1/unlisted"), b"private").unwrap();
drop(core);
freeze_docroot(&root);
let address = free_address();
let child = Command::new(env!("CARGO_BIN_EXE_makepad-web-server"))
.args([
"--listen",
&address.to_string(),
"--root",
root.to_str().unwrap(),
"--searchdb",
"off",
"--places",
"off",
"--major-graph",
"off",
"--chargers",
"off",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
wait_until_listening(address);
(ServerChild(child), address, blob)
}
fn check_exported_static_store_routes() {
let tree = TempTree::new("static-store");
let (_server, address, blob) = start_snapshot_server(&tree.0);
let health = get(address, "/v1/health", "");
assert_eq!(health.status, 200);
assert!(health.headers.contains("Content-Type: application/json\r\n"));
assert!(health.headers.contains("Cache-Control: private, no-cache"));
assert!(health.headers.contains("Cross-Origin-Opener-Policy: same-origin"));
assert!(health.headers.contains("Cross-Origin-Embedder-Policy: require-corp"));
assert!(makepad_strict_json::parse(&health.body).is_ok());
let compressed = get(address, "/v1/health", "Accept-Encoding: br\r\n");
assert_eq!(compressed.status, 200);
assert!(compressed.headers.contains("Content-Encoding: br"));
let etag = health
.headers
.lines()
.find_map(|line| line.strip_prefix("ETag: "))
.unwrap();
assert_eq!(
get(address, "/v1/health", &format!("If-None-Match: {etag}\r\n")).status,
304
);
let alias = get(address, "/v1/aliases/dj/demo", "");
assert_eq!(alias.status, 200);
assert!(alias.headers.contains("Content-Type: application/json\r\n"));
assert!(makepad_strict_json::parse(&alias.body).is_ok());
let blob_response = get(address, &format!("/v1/blobs/{blob}"), "");
assert_eq!(blob_response.status, 200);
assert_eq!(blob_response.body, b"SNAPSHOT-GLB");
assert!(blob_response
.headers
.contains("Content-Type: application/octet-stream\r\n"));
assert_eq!(get(address, "/v1/unlisted", "").status, 404);
}
fn check_static_and_navigation_contracts_work_end_to_end() {
let tree = TempTree::new("integration");
let (_server, address) = start_fixture_server(&tree.0);
let full = get(address, "/", "");
assert_eq!(full.status, 200);
assert_eq!(full.body, b"<h1>fixture</h1>");
assert!(full.headers.contains("Cross-Origin-Opener-Policy: same-origin"));
assert!(full.headers.contains("Cross-Origin-Embedder-Policy: require-corp"));
assert!(full.headers.contains("Cache-Control: private, no-cache"));
assert!(full.headers.contains("Vary: Accept-Encoding"));
assert!(full.headers.contains("Last-Modified: "));
let compressed = get(address, "/app.0123456789abcdef.wasm", "Accept-Encoding: gzip, br\r\n");
assert_eq!(compressed.status, 200);
assert_eq!(compressed.body, b"BR");
assert!(compressed.headers.contains("Content-Encoding: br"));
assert!(compressed.headers.contains("Vary: Accept-Encoding"));
assert!(compressed.headers.contains("Cache-Control: public, max-age=31536000, immutable"));
let compressed_etag = compressed
.headers
.lines()
.find_map(|line| line.strip_prefix("ETag: "))
.unwrap();
assert!(compressed_etag.ends_with("-br\""));
assert_eq!(
get(address, "/app.0123456789abcdef.wasm", "Accept-Encoding: *;q=1\r\n").body,
b"BR"
);
let invalid_quality = get(address, "/app.0123456789abcdef.wasm", "Accept-Encoding: br;q=garbage\r\n");
assert_eq!(invalid_quality.body, b"0123456789");
assert!(!invalid_quality.headers.contains("Content-Encoding: br"));
assert_eq!(get(address, "/plus+file.js", "").body, b"plus");
assert_eq!(get(address, "/dir//file.js", "").status, 400);
assert_eq!(get(address, "/dir/./file.js", "").status, 400);
let partial = get(address, "/app.0123456789abcdef.wasm", "Range: bytes=2-5\r\nAccept-Encoding: br\r\n");
assert_eq!(partial.status, 206);
assert_eq!(partial.body, b"2345");
assert!(partial.headers.contains("Content-Range: bytes 2-5/10"));
assert!(!partial.headers.contains("Content-Encoding: br"));
let unsatisfied = get(address, "/maps/root.mkidx", "Range: bytes=99-\r\n");
assert_eq!(unsatisfied.status, 416);
assert!(unsatisfied.headers.contains("Content-Range: bytes */9"));
let index = get(address, "/maps/root.mkidx", "Accept-Encoding: br\r\n");
assert_eq!(index.body, b"map-index");
assert!(!index.headers.contains("Content-Encoding: br"));
assert!(index.headers.contains("Cache-Control: public, max-age=31536000, immutable"));
let head = request(address, b"HEAD /index.html HTTP/1.1\r\nHost: test\r\n\r\n");
assert_eq!(head.status, 200);
assert!(head.body.is_empty());
assert!(head.headers.contains("Content-Length: 16"));
let etag_response = get(address, "/index.html", "");
let etag = etag_response
.headers
.lines()
.find_map(|line| line.strip_prefix("ETag: "))
.unwrap();
assert!(!etag.ends_with("-br\""));
let last_modified = etag_response
.headers
.lines()
.find_map(|line| line.strip_prefix("Last-Modified: "))
.unwrap();
let not_modified = get(address, "/index.html", &format!("If-None-Match: {etag}\r\n"));
assert_eq!(not_modified.status, 304);
assert!(not_modified.body.is_empty());
assert_eq!(
get(address, "/index.html", &format!("If-Modified-Since: {last_modified}\r\n")).status,
304
);
let if_range_match = get(
address,
"/index.html",
&format!("Range: bytes=0-2\r\nIf-Range: {etag}\r\n"),
);
assert_eq!(if_range_match.status, 206);
assert_eq!(if_range_match.body, b"<h1");
let if_range_date = get(
address,
"/index.html",
&format!("Range: bytes=0-2\r\nIf-Range: {last_modified}\r\n"),
);
assert_eq!(if_range_date.status, 206);
let if_range_miss = get(
address,
"/index.html",
"Range: bytes=0-2\r\nIf-Range: \"different\"\r\n",
);
assert_eq!(if_range_miss.status, 200);
assert_eq!(if_range_miss.body, b"<h1>fixture</h1>");
let options = request(address, b"OPTIONS /maps/root.mkidx HTTP/1.1\r\nHost: test\r\n\r\n");
assert_eq!(options.status, 204);
assert!(options.headers.contains("Access-Control-Allow-Origin: *"));
assert_eq!(get(address, "/missing.wasm", "").status, 404);
assert_eq!(
get(
address,
"/ws",
"Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: fixture\r\n",
)
.status,
404
);
assert_eq!(post(address, "/index.html", b"ignored").status, 405);
for (raw, allow) in [
(
&b"PUT /api/along HTTP/1.1\r\nHost: test\r\n\r\n"[..],
"POST, OPTIONS",
),
(
&b"POST /api/search HTTP/1.1\r\nHost: test\r\n\r\n"[..],
"GET, HEAD, OPTIONS",
),
(
&b"POST /index.html HTTP/1.1\r\nHost: test\r\n\r\n"[..],
"GET, HEAD, OPTIONS",
),
(
&b"BREW /api/search HTTP/1.1\r\nHost: test\r\n\r\n"[..],
"GET, HEAD, OPTIONS",
),
(
&b"POST /ws HTTP/1.1\r\nHost: test\r\nSec-WebSocket-Key: fixture\r\n\r\n"[..],
"GET, HEAD, OPTIONS",
),
] {
let response = request(address, raw);
assert_eq!(response.status, 405);
assert!(response.headers.contains(&format!("Allow: {allow}\r\n")));
}
let missing_api = request(address, b"DELETE /api/not-real HTTP/1.1\r\nHost: test\r\n\r\n");
assert_eq!(missing_api.status, 404);
assert!(String::from_utf8_lossy(&missing_api.body).contains("API endpoint not found"));
assert_eq!(get(address, "/../index.html", "").status, 400);
assert_eq!(get(address, "/$report_error?data=boom%0Aline", "").status, 204);
assert_eq!(post(address, "/$report_error", b"post error").status, 204);
assert_eq!(post(address, "/$report_error", &vec![b'x'; 8_193]).status, 413);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let health = get(address, "/api/healthz", "");
if String::from_utf8_lossy(&health.body).contains("\"ok\":true") {
assert_eq!(health.status, 200);
assert!(health.headers.contains("Cache-Control: no-store"));
break;
}
assert!(Instant::now() < deadline, "navigation fixture did not become ready");
thread::sleep(Duration::from_millis(20));
}
let search = get(address, "/api/search?q=Fixture%20Museum&near=4.9,52.36&limit=8", "");
assert_eq!(search.status, 200);
assert!(String::from_utf8_lossy(&search.body).contains("Fixture Museum"));
assert!(String::from_utf8_lossy(&search.body).contains("\"query\":\"Fixture Museum\""));
assert!(search.headers.contains("Cache-Control: private, no-store"));
assert!(!search.headers.contains("Access-Control-Allow-Origin"));
let api_options = request(address, b"OPTIONS /api/along HTTP/1.1\r\nHost: test\r\n\r\n");
assert_eq!(api_options.status, 204);
assert!(api_options.headers.contains("Allow: POST, OPTIONS"));
assert!(!api_options.headers.contains("Access-Control-Allow-Origin"));
let route = get(address, "/api/route?from=4.8952,52.3702&to=4.915,52.35&mode=car", "");
assert_eq!(route.status, 200, "{}", String::from_utf8_lossy(&route.body));
assert!(String::from_utf8_lossy(&route.body).contains("\"graph\":\"test\""));
let along_body = br#"{"polyline":[[4.8952,52.3702],[4.915,52.35]],"cum_dist_m":[0,3000],"kinds":["museum"],"max_detour_min":10,"min_kw":0,"limit":12}"#;
let along = post(address, "/api/along", along_body);
assert_eq!(along.status, 200, "{}", String::from_utf8_lossy(&along.body));
assert!(String::from_utf8_lossy(&along.body).contains("Fixture Museum"));
assert_eq!(post(address, "/api/along", br#"{"polyline":[]}"#).status, 400);
assert_eq!(
request(
address,
b"POST /api/along HTTP/1.1\r\nHost: test\r\nContent-Length: 2\r\n\r\n{}",
)
.status,
415
);
assert_eq!(
request(
address,
b"POST /api/along HTTP/1.1\r\nHost: test\r\nContent-Type: application/json\r\nContent-Encoding: gzip\r\nContent-Length: 2\r\n\r\n{}",
)
.status,
415
);
assert_eq!(get(address, "/api/search?q=x&near=999,52", "").status, 400);
}
fn check_unavailable_is_returned_before_navigation_is_ready() {
let tree = TempTree::new("unavailable");
let root = tree.0.join("site");
fs::create_dir(&root).unwrap();
fs::write(root.join("index.html"), b"ok").unwrap();
freeze_docroot(&root);
let address = free_address();
let child = Command::new(env!("CARGO_BIN_EXE_makepad-web-server"))
.args(["--listen", &address.to_string(), "--root", root.to_str().unwrap()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let _server = ServerChild(child);
wait_until_listening(address);
let health = get(address, "/api/healthz", "");
assert_eq!(health.status, 503);
assert!(String::from_utf8_lossy(&health.body).contains("\"along\":\"unavailable\""));
let response = get(address, "/api/search?q=Amsterdam", "");
assert_eq!(response.status, 503);
assert!(String::from_utf8_lossy(&response.body).contains("\"code\":\"unavailable\""));
assert_eq!(
get(address, "/api/route?from=4.9,52.3&to=5.0,52.2&mode=car", "").status,
503
);
let along = br#"{"polyline":[[4.9,52.3],[5.0,52.2]],"cum_dist_m":[0,10000],"kinds":["museum"]}"#;
assert_eq!(post(address, "/api/along", along).status, 503);
}
fn check_along_json_rejects_deep_trailing_and_huge_typed_inputs() {
let tree = TempTree::new("hostile-json");
let (_server, address) = start_fixture_server(&tree.0);
let deep = format!(
"{{\"polyline\":{},\"cum_dist_m\":[0,3000],\"kinds\":[\"museum\"]}}",
"[".repeat(2_000)
);
assert_eq!(post(address, "/api/along", deep.as_bytes()).status, 400);
let trailing = br#"{"polyline":[[4.9,52.3],[5.0,52.2]],"cum_dist_m":[0,13000],"kinds":["museum"]} []"#;
assert_eq!(post(address, "/api/along", trailing).status, 400);
let mut huge = String::from("{\"polyline\":[");
for index in 0..20_001 {
if index != 0 { huge.push(','); }
huge.push_str("[4.9,52.3]");
}
huge.push_str("],\"cum_dist_m\":[0,1],\"kinds\":[\"museum\"]}");
assert!(huge.len() < 2 * 1024 * 1024);
assert_eq!(post(address, "/api/along", huge.as_bytes()).status, 400);
}
fn check_slow_along_upload_never_occupies_compute_worker() {
let tree = TempTree::new("slow-along");
let (_server, address) = start_fixture_server(&tree.0);
let first = slow_along_upload(address);
thread::sleep(Duration::from_millis(50));
let valid = br#"{"polyline":[[4.8952,52.3702],[4.915,52.35]],"cum_dist_m":[0,3000],"kinds":["museum"]}"#;
let response = post(address, "/api/along", valid);
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
let second = slow_along_upload(address);
thread::sleep(Duration::from_millis(50));
let capped = post(address, "/api/along", valid);
assert_eq!(capped.status, 503, "per-client upload cap must reject promptly");
let _ = first.shutdown(Shutdown::Both);
let _ = second.shutdown(Shutdown::Both);
}
fn check_malformed_request_line_is_a_hardened_bad_request() {
let tree = TempTree::new("bad-line");
let (_server, address) = start_fixture_server(&tree.0);
for raw in [
&b"GET /x HTTP/1.1?q\r\nHost: test\r\n\r\n"[..],
&b"GET HTTP/1.1\r\nHost: test\r\n\r\n"[..],
&b"GET https://example.test/x HTTP/1.1\r\nHost: test\r\n\r\n"[..],
] {
let response = request(address, raw);
assert_eq!(response.status, 400);
assert!(response.headers.contains("Cross-Origin-Opener-Policy: same-origin"));
}
for raw in [
&b"POST /api/along HTTP/1.1\r\nContent-Length: 1\r\nContent-Length: 1\r\n\r\nx"[..],
&b"POST /api/along HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n"[..],
] {
assert_eq!(request(address, raw).status, 400);
}
}
#[cfg(unix)]
fn check_opened_docroot_refuses_symlink_replacement_escape() {
use std::os::unix::fs::symlink;
let tree = TempTree::new("symlink-race");
let root = tree.0.join("site");
fs::create_dir(&root).unwrap();
fs::write(root.join("index.html"), b"public").unwrap();
freeze_docroot(&root);
let handler = StaticHandler::new(&root).unwrap();
let outside = tree.0.join("outside.html");
fs::write(&outside, b"secret").unwrap();
assert_eq!(
root.join("index.html").canonicalize().unwrap(),
root.canonicalize().unwrap().join("index.html")
);
make_tree_writable(&root);
fs::remove_file(root.join("index.html")).unwrap();
symlink(&outside, root.join("index.html")).unwrap();
let (sender, receiver) = std::sync::mpsc::channel();
handler.handle_get(&direct_headers("GET", "/index.html"), &sender);
let response = receiver.recv_timeout(Duration::from_secs(2)).unwrap();
assert!(matches!(direct_status(&response), 400 | 404));
assert_ne!(response.body, b"secret");
}
fn check_api_options_has_allow_without_cors() {
let registry = ServiceRegistry::new(false);
let (sender, receiver) = std::sync::mpsc::channel();
assert!(registry.handle_get(&direct_headers("OPTIONS", "/api/along"), &sender));
let response = receiver.recv_timeout(Duration::from_secs(2)).unwrap();
assert_eq!(direct_status(&response), 204);
assert!(response.header.contains("Allow: POST, OPTIONS"));
assert!(!response.header.contains("Access-Control-Allow-Origin"));
}
struct PanicOnceBackend {
panic_next: AtomicBool,
}
impl NavBackend for PanicOnceBackend {
fn search(&self, _request: SearchRequest) -> Result<Vec<SearchResult>, ApiFailure> {
if self.panic_next.swap(false, Ordering::SeqCst) {
panic!("adversarial worker panic");
}
Ok(Vec::new())
}
fn route(&self, _request: RouteRequest) -> Result<RouteResult, ApiFailure> {
Err(ApiFailure { status: 422, code: "not_found", message: "fixture".into() })
}
fn along(&self, _request: AlongRequest) -> Result<Vec<AlongResult>, ApiFailure> {
Ok(Vec::new())
}
}
fn check_worker_panic_returns_error_recovers_and_degrades_health() {
let registry = ServiceRegistry::new(false);
registry.install(
Arc::new(PanicOnceBackend { panic_next: AtomicBool::new(true) }),
1,
1,
2,
false,
);
assert_eq!(direct_status(&direct_get(&registry, "/api/search?q=first")), 500);
assert_eq!(direct_status(&direct_get(&registry, "/api/search?q=second")), 200);
let health = direct_get(&registry, "/api/healthz");
assert!(String::from_utf8_lossy(&health.body).contains("\"ok\":false"));
assert!(String::from_utf8_lossy(&health.body).contains("\"search\":\"degraded\""));
assert_eq!(direct_status(&health), 503);
}
struct PanicAlongBackend;
impl NavBackend for PanicAlongBackend {
fn search(&self, _request: SearchRequest) -> Result<Vec<SearchResult>, ApiFailure> {
Ok(Vec::new())
}
fn route(&self, _request: RouteRequest) -> Result<RouteResult, ApiFailure> {
Err(ApiFailure { status: 422, code: "not_found", message: "fixture".into() })
}
fn along(&self, _request: AlongRequest) -> Result<Vec<AlongResult>, ApiFailure> {
panic!("adversarial along panic")
}
}
fn check_along_panic_degrades_along_not_search() {
let registry = ServiceRegistry::new(false);
registry.install(Arc::new(PanicAlongBackend), 2, 1, 2, false);
let (sender, receiver) = std::sync::mpsc::channel();
assert!(registry.handle_post(
&direct_headers("POST", "/api/along"),
br#"{"polyline":[[4.9,52.3],[5.0,52.2]],"cum_dist_m":[0,1],"kinds":["museum"]}"#.to_vec(),
&sender,
));
assert_eq!(direct_status(&receiver.recv_timeout(Duration::from_secs(2)).unwrap()), 500);
let health = direct_get(&registry, "/api/healthz");
let body = String::from_utf8_lossy(&health.body);
assert_eq!(direct_status(&health), 503);
assert!(body.contains("\"search\":\"ready\""), "{body}");
assert!(body.contains("\"along\":\"degraded\""), "{body}");
assert!(body.contains("\"chargers\":\"disabled\""), "{body}");
}
struct BlockingAlongBackend {
state: Arc<(Mutex<(bool, bool)>, Condvar)>,
}
impl NavBackend for BlockingAlongBackend {
fn search(&self, _request: SearchRequest) -> Result<Vec<SearchResult>, ApiFailure> {
Ok(Vec::new())
}
fn route(&self, _request: RouteRequest) -> Result<RouteResult, ApiFailure> {
Err(ApiFailure { status: 422, code: "not_found", message: "fixture".into() })
}
fn along(&self, _request: AlongRequest) -> Result<Vec<AlongResult>, ApiFailure> {
let (lock, changed) = &*self.state;
let mut state = lock.lock().unwrap();
state.0 = true;
changed.notify_all();
while !state.1 {
state = changed.wait(state).unwrap();
}
Ok(Vec::new())
}
}
fn check_along_admission_cannot_starve_search_capacity() {
let state = Arc::new((Mutex::new((false, false)), Condvar::new()));
let registry = ServiceRegistry::new(false);
registry.install(
Arc::new(BlockingAlongBackend { state: state.clone() }),
1,
1,
1,
false,
);
let along_registry = registry.clone();
let along = thread::spawn(move || {
let (sender, receiver) = std::sync::mpsc::channel();
assert!(along_registry.handle_post(
&direct_headers("POST", "/api/along"),
br#"{"polyline":[[4.9,52.3],[5.0,52.2]],"cum_dist_m":[0,13000],"kinds":["museum"]}"#.to_vec(),
&sender,
));
receiver.recv_timeout(Duration::from_secs(2)).unwrap()
});
{
let (lock, changed) = &*state;
let mut current = lock.lock().unwrap();
while !current.0 { current = changed.wait(current).unwrap(); }
}
let started = Instant::now();
assert_eq!(direct_status(&direct_get(&registry, "/api/search?q=ready")), 200);
assert!(started.elapsed() < Duration::from_secs(1));
{
let (lock, changed) = &*state;
let mut current = lock.lock().unwrap();
current.1 = true;
changed.notify_all();
}
assert_eq!(direct_status(&along.join().unwrap()), 200);
}
fn disconnected_graph() -> Vec<u8> {
let mut builder = GraphBuilder::new();
for (id, lon, lat) in [(1, 4.0, 52.0), (2, 4.01, 52.0), (3, 5.0, 52.0), (4, 5.01, 52.0)] {
builder.add_node(id, lon, lat);
}
let mut tags = HashMap::new();
tags.insert("highway".into(), "residential".into());
builder.add_way(1, vec![1, 2], tags.clone());
builder.add_way(2, vec![3, 4], tags);
builder.build().serialize()
}
fn major_graph() -> Vec<u8> {
let mut builder = GraphBuilder::new();
builder.add_node(1, 4.0, 52.0);
builder.add_node(2, 4.5, 52.0);
builder.add_node(3, 5.0, 52.0);
let mut tags = HashMap::new();
tags.insert("highway".into(), "primary".into());
builder.add_way(1, vec![1, 2, 3], tags);
builder.build().serialize()
}
fn check_route_falls_back_as_a_whole_to_major_graph() {
let tree = TempTree::new("major-fallback");
let root = tree.0.join("site");
let data = tree.0.join("private-data/nav");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&data).unwrap();
fs::write(root.join("index.html"), b"ok").unwrap();
fs::write(data.join("test.search"), fixture_search()).unwrap();
fs::write(data.join("test.graph"), disconnected_graph()).unwrap();
fs::write(data.join("major.graph"), major_graph()).unwrap();
freeze_docroot(&root);
let address = free_address();
let child = Command::new(env!("CARGO_BIN_EXE_makepad-web-server"))
.args([
"--listen", &address.to_string(), "--root", root.to_str().unwrap(),
"--data-dir", tree.0.join("private-data").to_str().unwrap(),
"--nav-basename", "nav/test", "--searchdb", "off", "--places", "off",
"--major-graph", "nav/major.graph", "--chargers", "off",
])
.stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap();
let _server = ServerChild(child);
wait_until_listening(address);
let deadline = Instant::now() + Duration::from_secs(5);
while !String::from_utf8_lossy(&get(address, "/api/healthz", "").body).contains("\"ok\":true") {
assert!(Instant::now() < deadline);
thread::sleep(Duration::from_millis(20));
}
let route = get(address, "/api/route?from=4.0,52.0&to=5.0,52.0&mode=car", "");
assert_eq!(route.status, 200, "{}", String::from_utf8_lossy(&route.body));
assert!(String::from_utf8_lossy(&route.body).contains("\"graph\":\"europe-major\""));
}
#[test]
fn live_server_adversarial_contracts() {
check_along_sampling_matches_trip_reference_policy();
check_report_rate_limiter_is_bounded_and_expires_entries();
check_dense_charger_query_obeys_shared_scan_budget_and_top_k_bound();
check_worker_panic_returns_error_recovers_and_degrades_health();
check_along_panic_degrades_along_not_search();
check_along_admission_cannot_starve_search_capacity();
check_full_route_queue_returns_busy();
check_api_options_has_allow_without_cors();
#[cfg(unix)]
check_opened_docroot_refuses_symlink_replacement_escape();
// Match the network crate's integration-test convention: managed runners
// may deny listener creation. The same checks run whenever loopback bind
// is available (and are also covered at the shared parser boundary).
let Ok(probe) = TcpListener::bind("127.0.0.1:0") else {
eprintln!("live site-server checks skipped: loopback bind unavailable");
return;
};
drop(probe);
check_crash_endpoint_contracts();
check_slow_crash_body_does_not_hold_dispatcher();
check_static_and_navigation_contracts_work_end_to_end();
check_exported_static_store_routes();
check_unavailable_is_returned_before_navigation_is_ready();
check_along_json_rejects_deep_trailing_and_huge_typed_inputs();
check_slow_along_upload_never_occupies_compute_worker();
check_malformed_request_line_is_a_hardened_bad_request();
check_route_falls_back_as_a_whole_to_major_graph();
}
fn check_crash_endpoint_contracts() {
let tree = TempTree::new("crash-endpoint");
let (_server, address) = start_fixture_server(&tree.0);
let data = tree.0.join("private-data");
let body = b"{\n\"kind\":\"panic\",\"data\":{\"message\":\"boom\"}\n}";
let stored = post(address, "/api/crash", body);
assert_eq!(stored.status, 204);
assert!(stored.headers.contains("Cache-Control: private, no-cache"));
assert_eq!(
post_with_type(address, "/api/crash", "text/plain;charset=UTF-8", b"plain").status,
204
);
let log = fs::read_to_string(data.join("crash.log")).unwrap();
let mut fields = log.lines().next().unwrap().splitn(3, ' ');
assert!(fields.next().unwrap().parse::<u128>().is_ok());
assert_eq!(fields.next(), Some("127.0.0.1"));
let escaped_body = String::from_utf8_lossy(body).replace('\n', "\\n");
assert_eq!(fields.next(), Some(escaped_body.as_str()));
let wrong_method = get(address, "/api/crash", "");
assert_eq!(wrong_method.status, 405);
assert!(wrong_method.headers.contains("Allow: POST, OPTIONS"));
let options = request(address, b"OPTIONS /api/crash HTTP/1.1\r\nHost: test\r\n\r\n");
assert_eq!(options.status, 204);
assert!(options.headers.contains("Allow: POST, OPTIONS"));
assert_eq!(post(address, "/api/crash", &[0xff]).status, 400);
assert_eq!(post(address, "/api/crash", &vec![b'x'; 64 * 1024 + 1]).status, 413);
assert_eq!(get(address, "/$report_error?data=old%0Aline", "").status, 204);
let log = fs::read_to_string(data.join("crash.log")).unwrap();
assert!(log.contains("{\"kind\":\"legacy-get\",\"data\":\"old\\nline\"}"));
for _ in 0..26 {
assert_eq!(post(address, "/api/crash", b"{}").status, 204);
}
assert_eq!(post(address, "/api/crash", b"{}").status, 429);
}
fn check_slow_crash_body_does_not_hold_dispatcher() {
let tree = TempTree::new("crash-slow-body");
let (_server, address) = start_fixture_server(&tree.0);
let mut slow = TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap();
slow.write_all(
b"POST /api/crash HTTP/1.1\r\nHost: test\r\nContent-Type: application/json\r\nContent-Length: 64\r\n\r\n",
)
.unwrap();
thread::sleep(Duration::from_millis(50));
assert_eq!(get(address, "/", "").status, 200);
drop(slow);
}
fn reference_trip_samples(line: &[LonLat], spacing_m: f64) -> Vec<(LonLat, f64)> {
let mut samples = vec![(line[0], 0.0)];
let mut cumulative = 0.0;
let mut next = spacing_m;
for segment in line.windows(2) {
let length = makepad_map_nav::geo::haversine_m(segment[0], segment[1]);
while next <= cumulative + length {
let fraction = (next - cumulative) / length;
samples.push((LonLat::new(
segment[0].lon + (segment[1].lon - segment[0].lon) * fraction,
segment[0].lat + (segment[1].lat - segment[0].lat) * fraction,
), next));
next += spacing_m;
}
cumulative += length;
}
if cumulative - samples.last().unwrap().1 > spacing_m * 0.5 {
samples.push((*line.last().unwrap(), cumulative));
}
samples
}
fn check_along_sampling_matches_trip_reference_policy() {
let line = [LonLat::new(0.0, 0.0), LonLat::new(0.04, 0.0), LonLat::new(0.09, 0.0)];
let total = makepad_map_nav::geo::haversine_m(line[0], line[1])
+ makepad_map_nav::geo::haversine_m(line[1], line[2]);
let spacing = (total / 48.0).max(3_000.0);
let actual = sample_along(&line);
let expected = reference_trip_samples(&line, spacing);
assert_eq!(actual.len(), expected.len());
for (actual, expected) in actual.iter().zip(expected.iter()) {
assert!((actual.0.lon - expected.0.lon).abs() < 1e-10);
assert!((actual.0.lat - expected.0.lat).abs() < 1e-10);
assert!((actual.1 - expected.1).abs() < 1e-6);
}
}
fn check_report_rate_limiter_is_bounded_and_expires_entries() {
let now = Instant::now();
let mut limiter = ReportRateLimiter::new(3);
for octet in 1..=3 {
assert!(limiter.allow_at(IpAddr::V4(Ipv4Addr::new(192, 0, 2, octet)), now));
}
assert_eq!(limiter.len(), 3);
assert!(!limiter.allow_at(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 4)), now));
assert!(limiter.allow_at(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), now));
assert!(limiter.allow_at(
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)),
now + Duration::from_secs(61),
));
assert_eq!(limiter.len(), 1);
}
fn check_dense_charger_query_obeys_shared_scan_budget_and_top_k_bound() {
let tree = TempTree::new("dense-chargers");
let path = tree.0.join("chargers.mbtiles");
let mut writer = MbtilesWriter::create(&path).unwrap();
let mut sidecar = SidecarBuilder::new();
for index in 0..1_000 {
sidecar.add(
"chargers",
&Geometry::Point(4.9 + f64::from(index) * 1e-8, 52.3),
&[("max_kw".into(), AttrVal::Int(150))],
false,
);
}
assert_eq!(sidecar.write(&mut writer).unwrap(), 1_000);
writer.finish().unwrap();
let mut database = LayerDb::open(&path).unwrap();
let mut budget = 17;
let hits = database
.query_radius_with_budget(4.9, 52.3, 1_000.0, 8, &mut budget)
.unwrap();
assert_eq!(budget, 0);
assert_eq!(hits.len(), 8);
}
struct BlockingBackend {
state: Arc<(Mutex<(usize, bool)>, Condvar)>,
}
impl NavBackend for BlockingBackend {
fn search(&self, _request: SearchRequest) -> Result<Vec<SearchResult>, ApiFailure> {
Ok(Vec::new())
}
fn route(&self, _request: RouteRequest) -> Result<RouteResult, ApiFailure> {
let (lock, changed) = &*self.state;
let mut state = lock.lock().unwrap();
state.0 += 1;
changed.notify_all();
while !state.1 {
state = changed.wait(state).unwrap();
}
Err(ApiFailure { status: 422, code: "not_found", message: "fixture".into() })
}
fn along(&self, _request: AlongRequest) -> Result<Vec<AlongResult>, ApiFailure> {
Ok(Vec::new())
}
}
fn check_full_route_queue_returns_busy() {
let state = Arc::new((Mutex::new((0usize, false)), Condvar::new()));
let registry = ServiceRegistry::new(false);
registry.install(
Arc::new(BlockingBackend { state: state.clone() }),
1,
1,
1,
false,
);
let path = "/api/route?from=4.9,52.3&to=5.0,52.2&mode=car";
let first_registry = registry.clone();
let first = thread::spawn(move || direct_get(&first_registry, path));
{
let (lock, changed) = &*state;
let mut current = lock.lock().unwrap();
while current.0 == 0 {
current = changed.wait(current).unwrap();
}
}
let (result_sender, result_receiver) = std::sync::mpsc::channel();
let mut contenders = Vec::new();
for _ in 0..3 {
let result_sender = result_sender.clone();
let registry = registry.clone();
contenders.push(thread::spawn(move || {
let response = direct_get(&registry, path);
let _ = result_sender.send(direct_status(&response));
}));
}
drop(result_sender);
let status = result_receiver.recv_timeout(Duration::from_secs(2)).unwrap();
assert_eq!(status, 429);
{
let (lock, changed) = &*state;
let mut current = lock.lock().unwrap();
current.1 = true;
changed.notify_all();
}
let _ = first.join();
for contender in contenders {
let _ = contender.join();
}
}