use axum::body::Body;
use axum::http::Request;
use axum::Router;
use core_api::SharedDb;
use proptest::prelude::*;
use server::router;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::PathBuf;
use std::sync::OnceLock;
use tower::ServiceExt;
fn fuzz_dir(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("graphdb-http-fuzz-{}-{}", tag, std::process::id()))
}
static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
fn rt() -> &'static tokio::runtime::Runtime {
RUNTIME.get_or_init(|| tokio::runtime::Runtime::new().unwrap())
}
static QUERY_ROUTER: OnceLock<Router> = OnceLock::new();
static INGEST_ROUTER: OnceLock<Router> = OnceLock::new();
fn query_app() -> Router {
QUERY_ROUTER
.get_or_init(|| {
let db = SharedDb::open(&fuzz_dir("query")).unwrap();
router(db)
})
.clone()
}
fn ingest_app() -> Router {
INGEST_ROUTER
.get_or_init(|| {
let db = SharedDb::open(&fuzz_dir("ingest")).unwrap();
router(db)
})
.clone()
}
fn json_leaf() -> impl Strategy<Value = serde_json::Value> {
prop_oneof![
Just(serde_json::Value::Null),
any::<bool>().prop_map(serde_json::Value::Bool),
any::<i64>().prop_map(|n| serde_json::Value::Number(serde_json::Number::from(n))),
proptest::collection::vec(any::<u8>(), 0..32)
.prop_map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).into_owned())),
]
}
fn json_value() -> BoxedStrategy<serde_json::Value> {
json_leaf()
.prop_recursive(3, 16, 4, |inner| {
prop_oneof![
proptest::collection::vec(inner.clone(), 0..4).prop_map(serde_json::Value::Array),
proptest::collection::vec(
(
proptest::collection::vec(any::<u8>(), 0..16)
.prop_map(|b| String::from_utf8_lossy(&b).into_owned()),
inner,
),
0..4
)
.prop_map(|pairs| serde_json::Value::Object(pairs.into_iter().collect())),
]
})
.boxed()
}
fn body_strategy() -> BoxedStrategy<Vec<u8>> {
prop_oneof![
4 => json_value().prop_map(|v| v.to_string().into_bytes()),
1 => proptest::collection::vec(any::<u8>(), 0..512),
]
.boxed()
}
#[test]
fn liveness_probe_catch_unwind_catches_panics() {
let caught = catch_unwind(AssertUnwindSafe(|| panic!("deliberate probe")));
assert!(caught.is_err(), "catch_unwind must catch deliberate panics");
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn query_handler_never_panics_on_arbitrary_body(body_bytes in body_strategy()) {
let app = query_app();
let req = Request::builder()
.method("POST")
.uri("/query")
.header("content-type", "application/json")
.body(Body::from(body_bytes.clone()))
.unwrap();
let outcome = catch_unwind(AssertUnwindSafe(|| {
rt().block_on(async move {
let _ = app.oneshot(req).await;
})
}));
prop_assert!(
outcome.is_ok(),
"POST /query panicked on body ({} bytes): {}",
body_bytes.len(),
String::from_utf8_lossy(&body_bytes)
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn ingest_handler_never_panics_on_arbitrary_body(body_bytes in body_strategy()) {
let app = ingest_app();
let req = Request::builder()
.method("POST")
.uri("/ingest")
.header("content-type", "application/json")
.body(Body::from(body_bytes.clone()))
.unwrap();
let outcome = catch_unwind(AssertUnwindSafe(|| {
rt().block_on(async move {
let _ = app.oneshot(req).await;
})
}));
prop_assert!(
outcome.is_ok(),
"POST /ingest panicked on body ({} bytes): {}",
body_bytes.len(),
String::from_utf8_lossy(&body_bytes)
);
}
}