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); +}