mod support {
pub mod parser_hardening;
}
use proptest::prelude::*;
use reddb_server::storage::query::ast::{QueryExpr, SearchCommand};
use reddb_server::storage::query::parser::{self, ParseError, ParserLimits};
use support::parser_hardening::{
self as harness, ask_grammar, assert_no_panic_on, corpus::ask_adversarial_inputs,
HardenedParser,
};
pub struct AskParser;
impl HardenedParser for AskParser {
type Error = ParseError;
fn parse(input: &str) -> Result<(), Self::Error> {
parser::parse(input).map(|_| ())
}
fn parse_with_limits(input: &str, limits: ParserLimits) -> Result<(), Self::Error> {
let mut p = parser::Parser::with_limits(input, limits)?;
p.parse().map(|_| ())
}
}
#[test]
fn ask_parser_does_not_panic_on_adversarial_corpus() {
let handle = std::thread::Builder::new()
.stack_size(8 * 1024 * 1024)
.spawn(|| {
for (name, input) in ask_adversarial_inputs() {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
assert_no_panic_on::<AskParser>(&input);
}));
if result.is_err() {
panic!("ask adversarial corpus entry {} panicked", name);
}
}
})
.expect("spawn corpus thread");
handle.join().expect("corpus thread panic");
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 256,
max_shrink_iters: 64,
..ProptestConfig::default()
})]
#[test]
fn proptest_ask_full_shape_roundtrips(s in ask_grammar::ask_stmt()) {
harness::roundtrip_property::<AskParser>(&s);
prop_assert!(
AskParser::parse(&s).is_ok(),
"ask full shape did not parse: {}", s
);
}
#[test]
fn proptest_ask_using_provider_roundtrips(
s in ask_grammar::ask_using_provider_stmt(),
) {
harness::roundtrip_property::<AskParser>(&s);
prop_assert!(
AskParser::parse(&s).is_ok(),
"ask USING <provider> did not parse: {}", s
);
}
#[test]
fn proptest_ask_model_ident_roundtrips(s in ask_grammar::ask_model_ident_stmt()) {
harness::roundtrip_property::<AskParser>(&s);
prop_assert!(
AskParser::parse(&s).is_ok(),
"ask MODEL '<name>' did not parse: {}", s
);
}
#[test]
fn proptest_search_context_roundtrips(s in ask_grammar::search_context_stmt()) {
harness::roundtrip_property::<AskParser>(&s);
prop_assert!(
AskParser::parse(&s).is_ok(),
"search context did not parse: {}", s
);
}
#[test]
fn proptest_ask_depth_scope_roundtrips(s in ask_grammar::ask_depth_scope_stmt()) {
harness::roundtrip_property::<AskParser>(&s);
prop_assert!(
AskParser::parse(&s).is_ok(),
"ask DEPTH/LIMIT did not parse: {}", s
);
}
#[test]
fn proptest_ask_arbitrary_suffix_no_panic(
prefix in prop_oneof![
Just("ASK ".to_string()),
Just("ASK 'q' ".to_string()),
Just("SEARCH CONTEXT ".to_string()),
Just("SEARCH CONTEXT 'q' ".to_string()),
],
suffix in ".{0,512}",
) {
let s = format!("{}{}", prefix, suffix);
harness::roundtrip_property::<AskParser>(&s);
}
#[test]
fn proptest_ask_input_size_limit_enforced(len in 200usize..2000) {
let limits = ParserLimits {
max_input_bytes: 64,
..ParserLimits::default()
};
let body = "x".repeat(len);
let input = format!("ASK '{}'", body);
let r = AskParser::parse_with_limits(&input, limits);
prop_assert!(r.is_err(), "oversized ASK question must error");
}
}
fn parse_query(input: &str) -> QueryExpr {
parser::parse(input)
.unwrap_or_else(|e| panic!("expected ok for {input:?}, got error: {e}"))
.query
}
#[test]
fn ask_minimal_question_parses() {
let q = parse_query("ASK 'why is the sky blue?'");
match q {
QueryExpr::Ask(ask) => {
assert_eq!(ask.question, "why is the sky blue?");
assert_eq!(ask.provider, None);
assert_eq!(ask.model, None);
assert_eq!(ask.depth, None);
assert_eq!(ask.limit, None);
assert_eq!(ask.collection, None);
}
other => panic!("expected Ask, got {other:?}"),
}
}
#[test]
fn ask_with_model_only_parses() {
let q = parse_query("ASK 'q' MODEL 'gpt-4o-mini'");
match q {
QueryExpr::Ask(ask) => {
assert_eq!(ask.question, "q");
assert_eq!(ask.model.as_deref(), Some("gpt-4o-mini"));
assert_eq!(ask.provider, None);
}
other => panic!("expected Ask, got {other:?}"),
}
}
#[test]
fn ask_with_depth_limit_collection_parses() {
let q = parse_query("ASK 'q' DEPTH 3 LIMIT 25 MIN_SCORE 0.7 COLLECTION docs");
match q {
QueryExpr::Ask(ask) => {
assert_eq!(ask.depth, Some(3));
assert_eq!(ask.limit, Some(25));
assert_eq!(ask.min_score, Some(0.7));
assert_eq!(ask.collection.as_deref(), Some("docs"));
assert_eq!(ask.provider, None);
}
other => panic!("expected Ask, got {other:?}"),
}
}
#[test]
fn ask_full_chain_without_using_parses() {
let q = parse_query(
"ASK 'what happened?' MODEL 'claude-3-5-sonnet' \
DEPTH 2 LIMIT 50 MIN_SCORE 0.7 COLLECTION events",
);
match q {
QueryExpr::Ask(ask) => {
assert_eq!(ask.question, "what happened?");
assert_eq!(ask.model.as_deref(), Some("claude-3-5-sonnet"));
assert_eq!(ask.depth, Some(2));
assert_eq!(ask.limit, Some(50));
assert_eq!(ask.min_score, Some(0.7));
assert_eq!(ask.collection.as_deref(), Some("events"));
assert_eq!(ask.provider, None);
}
other => panic!("expected Ask, got {other:?}"),
}
}
#[test]
fn ask_using_provider_parses() {
let q = parse_query("ASK 'who?' USING openai");
match q {
QueryExpr::Ask(ask) => {
assert_eq!(ask.question, "who?");
assert_eq!(ask.provider.as_deref(), Some("openai"));
}
other => panic!("expected Ask, got {other:?}"),
}
}
#[test]
fn search_context_minimal_parses_with_defaults() {
let q = parse_query("SEARCH CONTEXT 'find this'");
match q {
QueryExpr::SearchCommand(SearchCommand::Context {
query,
field,
collection,
limit,
depth,
..
}) => {
assert_eq!(query, "find this");
assert_eq!(field, None);
assert_eq!(collection, None);
assert_eq!(limit, 25);
assert_eq!(depth, 1);
}
other => panic!("expected SearchCommand::Context, got {other:?}"),
}
}
#[test]
fn search_context_full_clause_chain_parses() {
let q = parse_query(
"SEARCH CONTEXT '000.000.000-00' FIELD cpf COLLECTION customers LIMIT 50 DEPTH 2",
);
match q {
QueryExpr::SearchCommand(SearchCommand::Context {
query,
field,
collection,
limit,
depth,
..
}) => {
assert_eq!(query, "000.000.000-00");
assert_eq!(field.as_deref(), Some("cpf"));
assert_eq!(collection.as_deref(), Some("customers"));
assert_eq!(limit, 50);
assert_eq!(depth, 2);
}
other => panic!("expected SearchCommand::Context, got {other:?}"),
}
}
#[test]
fn ask_lowercase_keyword_parses() {
let q = parse_query("ask 'q' depth 3");
match q {
QueryExpr::Ask(ask) => {
assert_eq!(ask.question, "q");
assert_eq!(ask.depth, Some(3));
}
other => panic!("expected Ask, got {other:?}"),
}
}