From 85908aea0bebb30d4f3ecd9c440beccb804e8d75 Mon Sep 17 00:00:00 2001 From: Adam Petro Date: Mon, 10 Aug 2026 16:04:44 -0400 Subject: [PATCH] Add lexer regression tests to the parser crate These tests prepare for an upgrade of the logos crate. The lexer gets untrusted input. A change in logos behavior can cause incorrect tokens, incorrect spans, out-of-bounds data access, or a denial of service. These tests pin the current behavior and make regressions visible. This change adds tests only. It does not change production code. Unit tests in src/lexer/logos_lexer.rs: - Test all punctuators, variable names, and ignored tokens, with exact spans. - Test edge cases in strings, block strings, and numbers. - Compare the full token stream for a document with all token types. - Test the LogosLexer iterator, the token count, and the max tokens limit. Safety tests in src/lexer/logos_lexer/safety_tests.rs: - Lex a corpus of hostile inputs. Assert that the lexer terminates, makes progress, consumes the full input, and returns spans that are in bounds and on char boundaries. - Lex each prefix and each suffix of a document to simulate unexpected end of input in each lexer state. - Lex large hostile inputs that must complete in linear-like time. - Pin the current stack usage for long numeric tokens and long runs of ignored characters. Integration tests in tests/lexer_adversarial_test.rs: - Parse hostile documents through the public API and convert the errors, which slices the source by span. - Show that the max tokens limit bounds the work for large documents. Known issue: with logos 0.15, the matchers for numeric tokens and for runs of ignored characters use stack space proportional to the run length in unoptimized builds. One numeric token with approximately 5,000 or more digits causes a stack overflow in unoptimized builds. Two ignored tests document this issue. Run `cargo test -p bluejay-parser -- --ignored` in a debug build after each logos upgrade. If the tests pass, remove the ignore attributes. Assisted-By: devx/a4890344-dddf-4733-a94e-71230a3953d1 --- bluejay-parser/src/lexer/logos_lexer.rs | 447 ++++++++++++++++++ .../src/lexer/logos_lexer/safety_tests.rs | 326 +++++++++++++ .../tests/lexer_adversarial_test.rs | 132 ++++++ 3 files changed, 905 insertions(+) create mode 100644 bluejay-parser/src/lexer/logos_lexer/safety_tests.rs create mode 100644 bluejay-parser/tests/lexer_adversarial_test.rs diff --git a/bluejay-parser/src/lexer/logos_lexer.rs b/bluejay-parser/src/lexer/logos_lexer.rs index b7eb104..371e0ff 100644 --- a/bluejay-parser/src/lexer/logos_lexer.rs +++ b/bluejay-parser/src/lexer/logos_lexer.rs @@ -7,6 +7,8 @@ use logos::Logos; use std::borrow::Cow; mod block_string_lexer; +#[cfg(test)] +mod safety_tests; mod string_lexer; #[derive(Default)] @@ -441,6 +443,451 @@ mod tests { ); } + #[test] + fn punctuator_test() { + assert_eq!( + vec![ + (Ok(Token::Bang), 0..1), + (Ok(Token::Ampersand), 1..2), + (Ok(Token::OpenRoundBracket), 2..3), + (Ok(Token::CloseRoundBracket), 3..4), + (Ok(Token::Ellipse), 4..7), + (Ok(Token::Colon), 7..8), + (Ok(Token::Equals), 8..9), + (Ok(Token::At), 9..10), + (Ok(Token::OpenSquareBracket), 10..11), + (Ok(Token::CloseSquareBracket), 11..12), + (Ok(Token::OpenBrace), 12..13), + (Ok(Token::CloseBrace), 13..14), + (Ok(Token::Pipe), 14..15), + ], + Token::lexer("!&()...:=@[]{}|") + .spanned() + .collect::>(), + ); + // One dot and two dots are not valid tokens + assert_eq!( + vec![(Err(LexError::UnrecognizedToken), 0..1)], + Token::lexer(".").spanned().collect::>(), + ); + assert_eq!( + vec![ + (Err(LexError::UnrecognizedToken), 0..1), + (Err(LexError::UnrecognizedToken), 1..2), + ], + Token::lexer("..").spanned().collect::>(), + ); + assert_eq!( + vec![ + (Ok(Token::Ellipse), 0..3), + (Err(LexError::UnrecognizedToken), 3..4), + ], + Token::lexer("....").spanned().collect::>(), + ); + } + + #[test] + fn variable_name_test() { + assert_eq!( + vec![(Ok(Token::VariableName("foo")), 0..4)], + Token::lexer("$foo").spanned().collect::>(), + ); + assert_eq!( + Some(Ok(Token::VariableName("_v1"))), + Token::lexer("$_v1").next(), + ); + assert_eq!( + Some(Ok(Token::VariableName("__typename"))), + Token::lexer("$__typename").next(), + ); + // A variable name must not start with a digit + assert_eq!( + vec![ + (Err(LexError::UnrecognizedToken), 0..1), + (Err(LexError::UnrecognizedToken), 1..5), + ], + Token::lexer("$1foo").spanned().collect::>(), + ); + // A dollar sign alone is not a valid token + assert_eq!( + vec![(Err(LexError::UnrecognizedToken), 0..1)], + Token::lexer("$").spanned().collect::>(), + ); + assert_eq!( + vec![ + (Err(LexError::UnrecognizedToken), 0..1), + (Ok(Token::Name("name")), 2..6), + ], + Token::lexer("$ name").spanned().collect::>(), + ); + assert_eq!( + vec![ + (Ok(Token::VariableName("foo")), 0..4), + (Ok(Token::Colon), 4..5), + ], + Token::lexer("$foo:").spanned().collect::>(), + ); + } + + #[test] + fn ignored_tokens_test() { + // A byte order mark is ignored + assert_eq!( + vec![(Ok(Token::Name("query")), 3..8)], + Token::lexer("\u{FEFF}query").spanned().collect::>(), + ); + // Commas and all white space characters are ignored + assert_eq!( + vec![ + (Ok(Token::Name("a")), 0..1), + (Ok(Token::Name("b")), 2..3), + (Ok(Token::Name("c")), 8..9), + ], + Token::lexer("a,b\t,,\r\nc").spanned().collect::>(), + ); + // Input with only ignored tokens gives no tokens + assert_eq!(None, Token::lexer("\u{FEFF} \t\r\n,,").next()); + // A comment ends at a newline + assert_eq!( + vec![(Ok(Token::Name("x")), 3..4)], + Token::lexer("#c\nx").spanned().collect::>(), + ); + } + + #[test] + fn string_edge_cases_test() { + // The empty string + assert_eq!( + vec![(Ok(Token::StringValue("".into())), 0..2)], + Token::lexer(r#""""#).spanned().collect::>(), + ); + // Two adjacent strings + assert_eq!( + vec![ + (Ok(Token::StringValue("".into())), 0..2), + (Ok(Token::StringValue("a".into())), 3..6), + ], + Token::lexer(r#""" "a""#).spanned().collect::>(), + ); + // All simple escape sequences + assert_eq!( + vec![( + Ok(Token::StringValue("\u{8}\u{c}\n\r\t/\\\"".into())), + 0..18, + )], + Token::lexer(r#""\b\f\n\r\t\/\\\"""#) + .spanned() + .collect::>(), + ); + // An invalid escape sequence + assert_eq!( + vec![( + Err(LexError::StringValueInvalid(vec![ + StringValueLexError::InvalidCharacters(Span::from(1..2)), + ])), + 0..4, + )], + Token::lexer(r#""\q""#).spanned().collect::>(), + ); + // A unicode escape sequence with too few digits + assert_eq!( + vec![( + Err(LexError::StringValueInvalid(vec![ + StringValueLexError::InvalidCharacters(Span::from(1..2)), + ])), + 0..6, + )], + Token::lexer(r#""\u12""#).spanned().collect::>(), + ); + // A unicode escape sequence with no digits + assert_eq!( + vec![( + Err(LexError::StringValueInvalid(vec![ + StringValueLexError::InvalidCharacters(Span::from(1..2)), + ])), + 0..6, + )], + Token::lexer(r#""\u{}""#).spanned().collect::>(), + ); + // Unterminated strings consume the full remainder + assert_eq!( + vec![(Err(LexError::UnrecognizedToken), 0..4)], + Token::lexer("\"abc").spanned().collect::>(), + ); + assert_eq!( + vec![(Err(LexError::UnrecognizedToken), 0..5)], + Token::lexer("\"abc\\").spanned().collect::>(), + ); + } + + #[test] + fn block_string_edge_cases_test() { + // Indentation is removed relative to the common indent + assert_eq!( + vec![( + Ok(Token::BlockStringValue("first\n second\nthird".into())), + 0..40, + )], + Token::lexer("\"\"\"\n first\n second\n third\n\"\"\"") + .spanned() + .collect::>(), + ); + // The first line keeps its indentation + assert_eq!( + vec![(Ok(Token::BlockStringValue("abc\ndef".into())), 0..15)], + Token::lexer("\"\"\"abc\n def\"\"\"") + .spanned() + .collect::>(), + ); + // A block string with only white space is empty + assert_eq!( + vec![(Ok(Token::BlockStringValue("".into())), 0..13)], + Token::lexer("\"\"\" \n \"\"\"") + .spanned() + .collect::>(), + ); + // Blank leading and trailing lines are removed + assert_eq!( + vec![(Ok(Token::BlockStringValue("abc".into())), 0..16)], + Token::lexer("\"\"\"\nabc\n\n \n\"\"\"") + .spanned() + .collect::>(), + ); + // An escaped triple quote at the start of the contents + assert_eq!( + vec![(Ok(Token::BlockStringValue("\"\"\"abc".into())), 1..14)], + Token::lexer(r#" """\"""abc""" "#) + .spanned() + .collect::>(), + ); + // Multi-byte characters at the start of lines + assert_eq!( + vec![(Ok(Token::BlockStringValue("é\n é\né".into())), 0..15)], + Token::lexer("\"\"\"é\n é\ré\"\"\"") + .spanned() + .collect::>(), + ); + // Runs of quotes: four and five quotes are unterminated block strings + assert_eq!( + vec![(Err(LexError::UnrecognizedToken), 0..4)], + Token::lexer("\"\"\"\"").spanned().collect::>(), + ); + assert_eq!( + vec![(Err(LexError::UnrecognizedToken), 0..5)], + Token::lexer("\"\"\"\"\"").spanned().collect::>(), + ); + // Six quotes are an empty block string + assert_eq!( + vec![(Ok(Token::BlockStringValue("".into())), 0..6)], + Token::lexer("\"\"\"\"\"\"").spanned().collect::>(), + ); + // Seven quotes are an empty block string and an unterminated string + assert_eq!( + vec![ + (Ok(Token::BlockStringValue("".into())), 0..6), + (Err(LexError::UnrecognizedToken), 6..7), + ], + Token::lexer("\"\"\"\"\"\"\"").spanned().collect::>(), + ); + // An escaped triple quote directly before the end of the input + assert_eq!( + vec![(Err(LexError::UnrecognizedToken), 0..7)], + Token::lexer("\"\"\"\\\"\"\"").spanned().collect::>(), + ); + } + + #[test] + fn token_spans_after_string_test() { + // String parsing extends the outer lexer manually. + // These tests make sure that subsequent spans stay correct. + assert_eq!( + vec![ + (Ok(Token::StringValue("abc".into())), 0..5), + (Ok(Token::Name("name")), 6..10), + ], + Token::lexer("\"abc\" name").spanned().collect::>(), + ); + assert_eq!( + vec![ + (Ok(Token::BlockStringValue("abc".into())), 0..9), + (Ok(Token::Name("name")), 10..14), + ], + Token::lexer("\"\"\"abc\"\"\" name") + .spanned() + .collect::>(), + ); + // Errors in strings also consume the correct number of bytes + assert_eq!( + vec![ + ( + Err(LexError::StringValueInvalid(vec![ + StringValueLexError::InvalidCharacters(Span::from(1..2)), + ])), + 0..4, + ), + (Ok(Token::Name("name")), 5..9), + ], + Token::lexer("\"\\q\" name").spanned().collect::>(), + ); + } + + #[test] + fn number_edge_cases_test() { + // Boundary values for 32-bit signed integers + assert_eq!( + Some(Ok(Token::IntValue(i32::MAX))), + Token::lexer("2147483647").next(), + ); + assert_eq!( + Some(Ok(Token::IntValue(i32::MIN))), + Token::lexer("-2147483648").next(), + ); + // A minus sign alone is not a valid token + assert_eq!( + vec![(Err(LexError::UnrecognizedToken), 0..1)], + Token::lexer("-").spanned().collect::>(), + ); + // An exponent must have digits + assert_eq!( + vec![(Err(LexError::UnrecognizedToken), 0..2)], + Token::lexer("1e").spanned().collect::>(), + ); + // A number must have at most one decimal point + assert_eq!( + vec![(Err(LexError::UnrecognizedToken), 0..5)], + Token::lexer("1.2.3").spanned().collect::>(), + ); + assert_eq!( + vec![(Err(LexError::UnrecognizedToken), 0..7)], + Token::lexer("1.2e3.4").spanned().collect::>(), + ); + // Hexadecimal notation is not valid + assert_eq!( + vec![(Err(LexError::UnrecognizedToken), 0..4)], + Token::lexer("0x10").spanned().collect::>(), + ); + // A comma terminates a number + assert_eq!( + vec![ + (Ok(Token::IntValue(1)), 0..1), + (Ok(Token::IntValue(2)), 2..3), + ], + Token::lexer("1,2").spanned().collect::>(), + ); + // A punctuator terminates a number + assert_eq!( + vec![ + (Ok(Token::IntValue(123)), 0..3), + (Ok(Token::CloseRoundBracket), 3..4), + ], + Token::lexer("123)").spanned().collect::>(), + ); + // Exponent variants + assert_eq!(Some(Ok(Token::FloatValue(1e5))), Token::lexer("1E5").next(),); + assert_eq!( + Some(Ok(Token::FloatValue(1e5))), + Token::lexer("1e+5").next(), + ); + assert_eq!( + Some(Ok(Token::FloatValue(1e-5))), + Token::lexer("1e-5").next(), + ); + } + + #[test] + fn kitchen_sink_token_stream_test() { + // Lex a document with all token types and all ignored token types, + // and compare the full token stream, with spans, to the expected stream. + let separators = [" ", ",", "\n", "\t", "\r\n", " # comment\n", "\u{FEFF}"]; + let parts = vec![ + ("query", Token::Name("query")), + ("MyQuery", Token::Name("MyQuery")), + ("(", Token::OpenRoundBracket), + ("$var", Token::VariableName("var")), + (":", Token::Colon), + ("[", Token::OpenSquareBracket), + ("Int", Token::Name("Int")), + ("!", Token::Bang), + ("]", Token::CloseSquareBracket), + ("=", Token::Equals), + ("-42", Token::IntValue(-42)), + (")", Token::CloseRoundBracket), + ("@", Token::At), + ("dir", Token::Name("dir")), + ("{", Token::OpenBrace), + ("...", Token::Ellipse), + ("on", Token::Name("on")), + ("&", Token::Ampersand), + ("|", Token::Pipe), + ("1.5e-3", Token::FloatValue(1.5e-3)), + ("0.25", Token::FloatValue(0.25)), + ("7e2", Token::FloatValue(7e2)), + ("\"str \\u0041\"", Token::StringValue("str A".into())), + ("\"\"\"block\"\"\"", Token::BlockStringValue("block".into())), + ("}", Token::CloseBrace), + ]; + let mut input = String::new(); + let mut expected = Vec::new(); + for (index, (text, token)) in parts.into_iter().enumerate() { + input.push_str(separators[index % separators.len()]); + let start = input.len(); + input.push_str(text); + expected.push((Ok(token), start..input.len())); + } + input.push_str(" # trailing comment"); + assert_eq!(expected, Token::lexer(&input).spanned().collect::>()); + } + + #[test] + fn logos_lexer_iterator_test() { + use crate::lexer::{Lexer, LogosLexer}; + use crate::lexical_token::{ + FloatValue, IntValue, LexicalToken, Name, Punctuator, PunctuatorType, StringValue, + Variable, + }; + use crate::Span; + + let input = "query $v 42 -3.5 \"s\" \"\"\"b\"\"\" @ {"; + let mut lexer = LogosLexer::new(input); + let tokens: Vec = (&mut lexer).map(Result::unwrap).collect(); + assert_eq!( + vec![ + LexicalToken::Name(Name::new("query", Span::new(0..5))), + LexicalToken::VariableName(Variable::new("v", Span::new(6..8))), + LexicalToken::IntValue(IntValue::new(42, Span::new(9..11))), + LexicalToken::FloatValue(FloatValue::new(-3.5, Span::new(12..16))), + LexicalToken::StringValue(StringValue::new("s".into(), Span::new(17..20))), + LexicalToken::StringValue(StringValue::new("b".into(), Span::new(21..28))), + LexicalToken::Punctuator(Punctuator::new(PunctuatorType::At, Span::new(29..30))), + LexicalToken::Punctuator(Punctuator::new( + PunctuatorType::OpenBrace, + Span::new(31..32), + )), + ], + tokens, + ); + assert_eq!(8, lexer.token_count()); + assert_eq!(Span::new(32..32), lexer.empty_span()); + } + + #[test] + fn logos_lexer_max_tokens_test() { + use crate::lexer::LogosLexer; + + let input = "a b c d"; + let results: Vec<_> = LogosLexer::new(input) + .with_max_tokens(Some(2)) + .map(|result| result.map_err(|(error, span)| (error, span.byte_range()))) + .collect(); + assert_eq!(3, results.len()); + assert!(results[0].is_ok()); + assert!(results[1].is_ok()); + assert_eq!( + Err((LexError::MaxTokensExceeded { limit: 2 }, 4..5)), + results[2], + ); + } + #[test] fn graphql_ruby_compatibility_test() { assert_eq!( diff --git a/bluejay-parser/src/lexer/logos_lexer/safety_tests.rs b/bluejay-parser/src/lexer/logos_lexer/safety_tests.rs new file mode 100644 index 0000000..310cd42 --- /dev/null +++ b/bluejay-parser/src/lexer/logos_lexer/safety_tests.rs @@ -0,0 +1,326 @@ +//! Safety tests for the lexer with adversarial inputs. +//! +//! The lexer operates on untrusted inputs. These tests make sure that, +//! for hostile or malformed inputs, the lexer: +//! - does not panic +//! - always terminates and makes progress +//! - consumes the full input +//! - only returns spans that are in bounds and on `char` boundaries +//! +//! Spans that are out of bounds or not on `char` boundaries can cause +//! panics or out-of-bounds reads in downstream error formatting, which +//! slices the source by span. + +use super::{Extras, Token}; +use crate::lexer::{LexError, Lexer as _, LogosLexer, StringValueLexError}; +use logos::Logos; + +/// Lex the full input and assert the safety properties. +/// Return the number of items that the lexer produced. +fn assert_lexes_safely_with_extras(input: &str, extras: Extras) -> usize { + let mut items = 0usize; + let mut previous_end = 0usize; + let mut lexer = Token::lexer_with_extras(input, extras); + while let Some(result) = lexer.next() { + let span = lexer.span(); + items += 1; + // Each item must consume at least one byte, so the item count + // bounded by the input length shows that the lexer makes progress. + assert!( + items <= input.len(), + "the lexer must make progress on {input:?}" + ); + assert!( + span.start <= span.end && span.end <= input.len(), + "span {span:?} is out of bounds on {input:?}" + ); + assert!( + input.is_char_boundary(span.start) && input.is_char_boundary(span.end), + "span {span:?} is not on a char boundary on {input:?}" + ); + assert!( + previous_end <= span.start, + "span {span:?} moves backwards on {input:?}" + ); + previous_end = span.end; + if let Err(LexError::StringValueInvalid(errors)) = result { + for error in errors { + let inner = match error { + StringValueLexError::InvalidUnicodeEscapeSequence(span) + | StringValueLexError::InvalidCharacters(span) => span.byte_range(), + }; + assert!( + inner.start <= inner.end && inner.end <= input.len(), + "inner span {inner:?} is out of bounds on {input:?}" + ); + assert!( + input.is_char_boundary(inner.start) && input.is_char_boundary(inner.end), + "inner span {inner:?} is not on a char boundary on {input:?}" + ); + } + } + } + // The lexer must consume the full input. A lexer that stops early + // makes the parser accept a document prefix and silently ignore + // the rest. + assert_eq!( + "", + lexer.remainder(), + "the lexer must consume the full input on {input:?}" + ); + items +} + +fn assert_lexes_safely(input: &str) -> usize { + assert_lexes_safely_with_extras(input, Extras::default()) +} + +/// A collection of hostile and malformed inputs. +fn adversarial_corpus() -> Vec { + let mut corpus: Vec = Vec::new(); + + // Runs of quotes of each length, alone and followed by other tokens + for n in 0..=13 { + corpus.push("\"".repeat(n)); + corpus.push(format!("{} name", "\"".repeat(n))); + corpus.push(format!("name {}", "\"".repeat(n))); + } + + // Backslash torture + corpus.push("\\".repeat(9)); + corpus.push(format!("\"{}\"", "\\".repeat(9))); + corpus.push(format!("\"{}", "\\".repeat(9))); + corpus.push("\"\\".into()); + corpus.push("\"\\\"".into()); + + // Block strings with escaped closers, terminated and unterminated + corpus.push(format!("\"\"\"{}", "\\\"\"\"".repeat(5))); + corpus.push(format!("\"\"\"{}\"\"\"", "\\\"\"\"".repeat(5))); + corpus.push("\"\"\"\\".into()); + corpus.push("\"\"\"\\\"".into()); + corpus.push("\"\"\"\\\"\"".into()); + corpus.push("\"\"\"a\"\"a\"\"\"".into()); + + // Unicode escape sequences + corpus.push(format!("\"\\u{{{}}}\"", "0".repeat(1_000))); + corpus.push("\"\\u".into()); + corpus.push("\"\\u{".into()); + corpus.push("\"\\u{12".into()); + corpus.push("\"\\uD800".into()); + corpus.push("\"\\uD800\"".into()); + corpus.push("\"\\uD800\\u0041\"".into()); + corpus.push("\"\\uD83D\\uD83D\"".into()); + corpus.push("\"\\uDC00\\uD800\"".into()); + corpus.push("\"\\uFFFF\\uFFFF\"".into()); + corpus.push("\"\\u{110000}\\u{FFFFFFFF}\"".into()); + + // Number torture + corpus.push("-".into()); + corpus.push("--1".into()); + corpus.push("-.5".into()); + corpus.push("+1".into()); + corpus.push("1e".into()); + corpus.push("1e+".into()); + corpus.push("1e-".into()); + corpus.push("1.".into()); + corpus.push("1..2".into()); + corpus.push("1.2.3.4".into()); + corpus.push("00".into()); + corpus.push("01".into()); + corpus.push("-0".into()); + corpus.push("9".repeat(200)); + corpus.push(format!("-{0}.{0}e-{0}", "9".repeat(100))); + corpus.push("123abc_.456".into()); + + // Control characters, raw and inside strings + let control_characters: String = (0u8..0x20).map(char::from).collect(); + corpus.push(control_characters.clone()); + corpus.push(format!("\"{control_characters}\"")); + corpus.push(format!("\"\"\"{control_characters}\"\"\"")); + corpus.push("\u{7F}".into()); + corpus.push("\0".into()); + corpus.push("#\0\u{7F}".into()); + + // Multi-byte characters in tricky positions + corpus.push("é".into()); + corpus.push("🔥".into()); + corpus.push("a\u{FEFF}b".into()); + corpus.push("1\u{FEFF}2".into()); + corpus.push("\"🔥".into()); + corpus.push("\"\"\"🔥".into()); + corpus.push("\"\"\"é\né\r🔥\r\n é\"\"\"".into()); + corpus.push("é123".into()); + corpus.push("123é".into()); + corpus.push("$é".into()); + corpus.push("#é".into()); + + // Carriage returns without line feeds + corpus.push("\"\"\"a\rb\r\"\"\"".into()); + corpus.push("\"a\rb\"".into()); + corpus.push("\r".into()); + + // Comments without terminating newlines + corpus.push("#".into()); + corpus.push(format!("#{}", "#".repeat(100))); + + // Punctuator fragments and dollar signs + corpus.push("$".repeat(100)); + corpus.push(".".repeat(100)); + corpus.push("..".into()); + corpus.push("....".into()); + corpus.push(".....!".into()); + + // Mixed garbage + corpus.push("{\"\\%^&*\0é\"\"\"}".into()); + corpus.push("query { field(arg: \"unterminated }".into()); + + corpus +} + +/// A well-formed document with all token types, comments, +/// and multi-byte characters. +fn kitchen_sink() -> String { + let mut source = String::from("\u{FEFF}"); + source.push_str( + "query Kitchen($sink: [Int!] = -0) @dir(a: 1.5e-3, b: \"\\u0041\\u{1F525}\\uD83D\\uDD25\") {\n", + ); + source.push_str( + " field(arg: \"\"\"\n block é\n indented\n \\\"\"\"\n \"\"\") # comment\r\n", + ); + source.push_str(" ... on Thing { a, b }\r"); + source.push_str(" \"string with escapes \\n \\t \\\" \\\\ /\"\n"); + source.push_str("}\n"); + source +} + +#[test] +fn adversarial_corpus_lexes_safely() { + for input in adversarial_corpus() { + assert_lexes_safely(&input); + assert_lexes_safely_with_extras( + &input, + Extras { + graphql_ruby_compatibility: true, + }, + ); + } +} + +#[test] +fn kitchen_sink_lexes_safely() { + let source = kitchen_sink(); + let items = assert_lexes_safely(&source); + assert!(items > 0); +} + +/// Truncation at each char boundary simulates unexpected end of input +/// in each lexer state. Suffixes simulate torn or corrupted inputs. +#[test] +fn truncated_documents_lex_safely() { + let source = kitchen_sink(); + for index in 0..=source.len() { + if source.is_char_boundary(index) { + assert_lexes_safely(&source[..index]); + assert_lexes_safely(&source[index..]); + } + } +} + +/// Large pathological inputs must complete in linear-like time and +/// without unbounded memory usage. A regression to super-linear +/// behavior makes this test very slow or makes it time out. +#[test] +fn large_pathological_inputs_terminate() { + let large_inputs = [ + "\"".repeat(50_000), + "\\".repeat(50_000), + format!("\"{}\"", "a".repeat(100_000)), + format!("\"{}\"", "\\n".repeat(50_000)), + format!("\"{}", "\\u{1F5".repeat(20_000)), + format!("\"\\u{{{}}}\"", "F".repeat(100_000)), + format!("\"\"\"{}\"\"\"", "x é\n".repeat(25_000)), + format!("\"\"\"{}", "\\\"\"\"".repeat(25_000)), + format!("\"\"\"{}\"\"\"", " \n".repeat(50_000)), + "$".repeat(50_000), + // Many small numeric tokens. See large_single_token_runs_terminate + // for why one large numeric token is not included here. + "9 ".repeat(50_000), + "0 ".repeat(50_000), + ".".repeat(50_000), + format!("#{}", "c".repeat(100_000)), + // Keep runs of ignored characters below the crash threshold for + // unoptimized builds. See large_single_token_runs_terminate. + "\u{FEFF}\t \r\n,".repeat(1_000), + ]; + for input in large_inputs { + assert_lexes_safely(&input); + } +} + +/// With logos 0.15, the generated matchers for numeric tokens and for +/// runs of ignored characters use stack space proportional to the run +/// length in unoptimized builds. Optimized builds compile the recursion +/// into loops, so release builds accept runs of all lengths. As a result +/// of this, one numeric token with approximately 5,000 or more digits, +/// or one run of approximately 50,000 or more ignored characters, +/// crashes unoptimized builds. +/// This test pins the current stack usage with some headroom. +/// If it starts to abort with a stack overflow after a logos upgrade, +/// then the stack usage per character became worse, which makes the +/// denial-of-service risk worse. +#[test] +fn long_token_stack_usage() { + std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(|| { + assert_lexes_safely(&"9".repeat(1_000)); + assert_lexes_safely(&format!("-1.{}e-9", "9".repeat(1_000))); + assert_lexes_safely(&" ".repeat(1_000)); + assert_lexes_safely(&"\u{FEFF}\t \r\n,".repeat(200)); + }) + .unwrap() + .join() + .unwrap(); +} + +/// One large numeric token or one large run of ignored characters must +/// lex safely. With logos 0.15, these inputs overflow the stack in +/// unoptimized builds, and the process aborts. Optimized builds are not +/// affected. See long_token_stack_usage for the details. +/// This test is ignored because a failure aborts the full test process. +/// Try to enable this test again after each logos upgrade: +/// run `cargo test -p bluejay-parser --lib -- --ignored` in a debug +/// build. If all tests pass, remove the ignore attribute. +#[test] +#[ignore = "logos 0.15 overflows the stack on large single tokens in unoptimized builds; try to re-enable after the next logos upgrade"] +fn large_single_token_runs_terminate() { + let large_inputs = [ + "9".repeat(100_000), + format!("-1.{0}e-{0}", "9".repeat(50_000)), + " ".repeat(100_000), + "\u{FEFF}\t \r\n,".repeat(20_000), + ]; + for input in large_inputs { + assert_lexes_safely(&input); + } +} + +/// The max tokens limit is a denial-of-service protection. +/// It must stop the lexer early for all inputs. +#[test] +fn max_tokens_bounds_adversarial_corpus() { + for input in adversarial_corpus() { + let mut lexer = LogosLexer::new(&input).with_max_tokens(Some(8)); + let items = (&mut lexer).count(); + // 8 tokens, plus possibly interleaved errors, plus the final + // max tokens error + assert!( + items <= input.len() + 1, + "the lexer must terminate on {input:?}" + ); + assert!( + lexer.token_count() <= 9, + "the lexer must stop counting after the limit on {input:?}" + ); + } +} diff --git a/bluejay-parser/tests/lexer_adversarial_test.rs b/bluejay-parser/tests/lexer_adversarial_test.rs new file mode 100644 index 0000000..ca5e968 --- /dev/null +++ b/bluejay-parser/tests/lexer_adversarial_test.rs @@ -0,0 +1,132 @@ +//! Adversarial input tests through the public parse API. +//! +//! The parser operates on untrusted inputs. These tests make sure that +//! hostile or malformed documents do not cause panics, out-of-bounds +//! access, or unbounded work in the lexer, the parser, or the error +//! formatting that slices the source by span. + +use bluejay_parser::{ + ast::{ + definition::{DefaultContext, DefinitionDocument}, + executable::ExecutableDocument, + Parse, ParseOptions, + }, + Error, +}; + +/// A collection of hostile and malformed documents. +fn adversarial_documents() -> Vec { + let mut documents: Vec = Vec::new(); + + // Runs of quotes of each length + for n in 0..=13 { + documents.push("\"".repeat(n)); + documents.push(format!("query {{ a(b: {}) }}", "\"".repeat(n))); + } + + // Unterminated strings and block strings + documents.push("query { field(arg: \"unterminated }".into()); + documents.push("query { field(arg: \"\"\"unterminated }".into()); + documents.push("\"\"\"unterminated description".into()); + documents.push("{ a(b: \"\\".into()); + documents.push(format!("{{ a(b: \"\"\"{}", "\\\"\"\"".repeat(5))); + + // Invalid escape sequences and lone surrogates + documents.push("{ a(b: \"\\q\\u12\\u{}\\uD800\\uDC00\\uD800\") }".into()); + documents.push(format!("{{ a(b: \"\\u{{{}}}\") }}", "0".repeat(1_000))); + + // Number torture + documents.push("{ a(b: 00, c: 1., d: 1e, e: 1.2.3, f: -, g: 123abc) }".into()); + documents.push(format!( + "{{ a(b: {0}, c: -{0}.{0}e-{0}) }}", + "9".repeat(100) + )); + + // Control characters and multi-byte characters + let control_characters: String = (0u8..0x20).map(char::from).collect(); + documents.push(control_characters.clone()); + documents.push(format!("{{ a(b: \"{control_characters}\") }}")); + documents.push("query { fiéld🔥 }".into()); + documents.push("\u{FEFF}query\u{FEFF}{ a }\u{FEFF}".into()); + documents.push("{ a(b: \"🔥".into()); + + // Punctuator fragments + documents.push("{ ... }".into()); + documents.push("{ .. . .... }".into()); + documents.push("$".repeat(100)); + + documents +} + +/// Truncation at each char boundary simulates unexpected end of input. +fn truncations(source: &str) -> impl Iterator { + (0..=source.len()) + .filter(|&index| source.is_char_boundary(index)) + .map(|index| &source[..index]) +} + +fn assert_parses_safely(source: &str) { + let executable = ExecutableDocument::parse(source); + if let Err(errors) = executable.result { + // Error conversion slices the source by span. + // It must not panic for any input. + let _ = Error::into_graphql_errors(source, errors); + } + let definition = DefinitionDocument::::parse(source); + if let Err(errors) = definition.result { + let _ = Error::into_graphql_errors(source, errors); + } +} + +#[test] +fn adversarial_documents_parse_safely() { + for source in adversarial_documents() { + assert_parses_safely(&source); + } +} + +#[test] +fn truncated_documents_parse_safely() { + let source = "query Kitchen($sink: [Int!] = -0) @dir(a: 1.5e-3, b: \"\\u0041\\uD83D\\uDD25\") {\n field(arg: \"\"\"\n block é\n \"\"\") # comment\r\n ... on Thing { a, b }\r \"string \\n \\t \\\" \\\\ /\"\n}\n"; + for truncated in truncations(source) { + assert_parses_safely(truncated); + } +} + +/// Documents with one large numeric token or one large run of ignored +/// characters must parse safely. With logos 0.15, these inputs overflow +/// the stack in unoptimized builds, and the process aborts. Optimized +/// builds are not affected. The max tokens limit does not protect +/// against this, because each input is a single token. +/// This test is ignored because a failure aborts the full test process. +/// Try to enable this test again after each logos upgrade: +/// run `cargo test -p bluejay-parser --test lexer_adversarial_test -- --ignored` +/// in a debug build. If all tests pass, remove the ignore attribute. +#[test] +#[ignore = "logos 0.15 overflows the stack on large single tokens in unoptimized builds; try to re-enable after the next logos upgrade"] +fn large_single_token_documents_parse_safely() { + let sources = [ + format!("{{ a(b: {}) }}", "9".repeat(100_000)), + format!("{{ a(b: -1.{0}e-{0}) }}", "9".repeat(50_000)), + format!("query {}{{ a }}", " ".repeat(100_000)), + ]; + for source in sources { + assert_parses_safely(&source); + } +} + +/// The max tokens limit is a denial-of-service protection. +/// It must bound the work for large hostile documents. +#[test] +fn max_tokens_bounds_large_documents() { + let source = "{ a } ".repeat(10_000); + let result = ExecutableDocument::parse_with_options( + source.as_str(), + ParseOptions { + max_tokens: Some(100), + ..Default::default() + }, + ); + assert!(result.result.is_err()); + assert_eq!(101, result.token_count); +}