use std::sync::Arc;
use std::time::Duration;
use proptest::prelude::*;
use serde::{Deserialize, Serialize};
use typeway_core::*;
use typeway_macros::*;
use typeway_server::*;
typeway_path!(type HelloPath = "hello");
typeway_path!(type UsersPath = "users");
typeway_path!(type UserByIdPath = "users" / u32);
typeway_path!(type EchoPath = "echo");
typeway_path!(type SearchPath = "search");
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct User {
id: u32,
name: String,
}
#[derive(Debug, Deserialize)]
struct CreateUser {
name: String,
}
#[derive(Debug, Deserialize)]
struct SearchParams {
q: Option<String>,
page: Option<u32>,
limit: Option<u32>,
}
async fn hello() -> &'static str {
"hello"
}
async fn get_user(path: Path<UserByIdPath>) -> Result<Json<User>, http::StatusCode> {
let (id,) = path.0;
Ok(Json(User {
id,
name: "Test".into(),
}))
}
async fn create_user(body: Json<CreateUser>) -> (http::StatusCode, Json<User>) {
(
http::StatusCode::CREATED,
Json(User {
id: 1,
name: body.0.name,
}),
)
}
async fn echo_body(body: String) -> String {
body
}
async fn search(query: Query<SearchParams>) -> String {
format!(
"q={:?} page={:?} limit={:?}",
query.0.q, query.0.page, query.0.limit
)
}
type FuzzAPI = (
GetEndpoint<HelloPath, String>,
GetEndpoint<UserByIdPath, User>,
PostEndpoint<UsersPath, CreateUser, User>,
PostEndpoint<EchoPath, String, String>,
GetEndpoint<SearchPath, String>,
);
async fn start_fuzz_server() -> u16 {
let server = Server::<FuzzAPI>::new((
bind::<_, _, _>(hello),
bind::<_, _, _>(get_user),
bind::<_, _, _>(create_user),
bind::<_, _, _>(echo_body),
bind::<_, _, _>(search),
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let router = Arc::new(server.into_router());
loop {
let (stream, _) = listener.accept().await.unwrap();
let io = hyper_util::rt::TokioIo::new(stream);
let svc = RouterService::new(router.clone());
let hyper_svc = hyper_util::service::TowerToHyperService::new(svc);
tokio::spawn(async move {
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(io, hyper_svc)
.await;
});
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
port
}
fn is_valid_status(status: u16) -> bool {
(200..=599).contains(&status)
}
fn arb_path() -> impl Strategy<Value = String> {
prop_oneof![
"\\PC{0,200}",
prop::collection::vec(
prop_oneof![
Just("".to_string()),
Just("..".to_string()),
Just(".".to_string()),
Just("%00".to_string()),
Just("%2F".to_string()),
Just("%2f".to_string()),
Just("%0a".to_string()),
Just("%0d%0a".to_string()),
Just("~".to_string()),
Just("\\".to_string()),
Just("<script>".to_string()),
Just("{{template}}".to_string()),
Just("users".to_string()),
Just("hello".to_string()),
Just("\u{200b}".to_string()), Just("\u{feff}".to_string()), Just("\u{202e}".to_string()), Just("\u{ffff}".to_string()),
"[a-z]{1,500}",
"\\PC{1,50}",
],
0..20,
)
.prop_map(|segments| format!("/{}", segments.join("/"))),
prop::collection::vec(
prop_oneof![Just("".to_string()), Just("".to_string()), "\\PC{0,30}",],
0..15,
)
.prop_map(|segments| format!("/{}", segments.join("/"))),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn path_parsing_never_panics(path in arb_path()) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let port = start_fuzz_server().await;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let url = format!("http://127.0.0.1:{port}{path}");
if let Ok(resp) = client.get(&url).send().await {
let status = resp.status().as_u16();
prop_assert!(
is_valid_status(status),
"GET {path} returned invalid status {status}"
);
}
if let Ok(resp) = client.post(&url).body("test").send().await {
let status = resp.status().as_u16();
prop_assert!(
is_valid_status(status),
"POST {path} returned invalid status {status}"
);
}
Ok(())
})?;
}
}
fn arb_body() -> impl Strategy<Value = Vec<u8>> {
prop_oneof![
Just(vec![]),
prop::collection::vec(any::<u8>(), 0..1024),
Just(b"{".to_vec()),
Just(b"{}".to_vec()),
Just(b"{\"name\":}".to_vec()),
Just(b"{\"name\": null}".to_vec()),
Just(b"{\"name\": 42}".to_vec()),
Just(b"{\"name\": true}".to_vec()),
Just(b"{\"name\": []}".to_vec()),
Just(b"[1, 2, 3]".to_vec()),
Just(b"null".to_vec()),
Just(b"\"string\"".to_vec()),
Just(b"42".to_vec()),
Just(b"{\"name\": \"valid\"}".to_vec()),
Just("{\"name\": \"\u{0000}\"}".as_bytes().to_vec()),
Just("{\"name\": \"\u{ffff}\"}".as_bytes().to_vec()),
Just(format!("{}\"a\":0{}", "{".repeat(64), "}".repeat(64)).into_bytes()),
"\\PC{0,500}".prop_map(|s| s.into_bytes()),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn json_body_deserialization_never_panics(body in arb_body()) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let port = start_fuzz_server().await;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.post(format!("http://127.0.0.1:{port}/users"))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
let status = resp.status().as_u16();
prop_assert!(
is_valid_status(status),
"POST /users with fuzzed JSON body returned invalid status {status}"
);
prop_assert!(
status == 201 || status == 400,
"POST /users expected 201 or 400 but got {status}"
);
Ok(())
})?;
}
#[test]
fn raw_body_deserialization_never_panics(body in prop::collection::vec(any::<u8>(), 0..2048)) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let port = start_fuzz_server().await;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.post(format!("http://127.0.0.1:{port}/echo"))
.body(body)
.send()
.await
.unwrap();
let status = resp.status().as_u16();
prop_assert!(
is_valid_status(status),
"POST /echo with fuzzed body returned invalid status {status}"
);
Ok(())
})?;
}
}
fn arb_query_string() -> impl Strategy<Value = String> {
prop_oneof![
Just("".to_string()),
prop::collection::vec(("\\PC{0,50}", "\\PC{0,50}",), 0..20,).prop_map(|pairs| {
pairs
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&")
}),
Just("&&&".to_string()),
Just("===".to_string()),
Just("q=".to_string()),
Just("=value".to_string()),
Just("key".to_string()),
Just("q=hello&q=world".to_string()), Just("q=%00%01%02".to_string()), Just("q=%zz".to_string()), Just("q=a%2".to_string()), "[a-z]{1,100}".prop_map(|s| format!("q={}", s.repeat(50))),
Just("q=\u{200b}\u{feff}\u{202e}".to_string()),
Just("a[b][c][d][e]=1".to_string()),
"\\PC{0,300}",
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn query_string_extraction_never_panics(qs in arb_query_string()) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let port = start_fuzz_server().await;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let url = if qs.is_empty() {
format!("http://127.0.0.1:{port}/search")
} else {
format!("http://127.0.0.1:{port}/search?{qs}")
};
if let Ok(resp) = client.get(&url).send().await {
let status = resp.status().as_u16();
prop_assert!(
is_valid_status(status),
"GET /search?{qs} returned invalid status {status}"
);
}
Ok(())
})?;
}
}