From 84a93f2faaa68687d3d253d1a4c44804de3aa242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emilio=20Cobos=20=C3=81lvarez?= Date: Mon, 10 Aug 2026 13:40:35 +0200 Subject: [PATCH] parser: Introduce a nested block limit. And make it default to a reasonable value, but still disable-able. --- src/parser.rs | 29 ++++++++++++++++++++++++++++ src/size_of_tests.rs | 2 +- src/tests.rs | 45 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/parser.rs b/src/parser.rs index d7df9a69..c68120f5 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -74,11 +74,16 @@ pub enum BasicParseErrorKind<'i> { AtRuleBodyInvalid, /// A qualified rule was encountered that was invalid. QualifiedRuleInvalid, + /// We've gone over the nesting limit. + TooManyNestedBlocks, } impl fmt::Display for BasicParseErrorKind<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + BasicParseErrorKind::TooManyNestedBlocks => { + write!(f, "nesting block limit reached") + } BasicParseErrorKind::UnexpectedToken(token) => { write!(f, "unexpected token: {token:?}") } @@ -230,6 +235,8 @@ impl std::error::Error for ParseError<'_, E> {} pub struct ParserInput<'i> { tokenizer: Tokenizer<'i>, cached_token: Option>, + current_block_depth: u8, + nested_block_limit: u8, } struct CachedToken<'i> { @@ -239,14 +246,26 @@ struct CachedToken<'i> { } impl<'i> ParserInput<'i> { + /// 75 nested blocks seems reasonable enough. + const REASONABLE_NESTED_BLOCK_LIMIT: u8 = 75; + /// Create a new input for a parser. pub fn new(input: &'i str) -> ParserInput<'i> { ParserInput { tokenizer: Tokenizer::new(input), + nested_block_limit: Self::REASONABLE_NESTED_BLOCK_LIMIT, + current_block_depth: 0, cached_token: None, } } + /// Sets a limit for how many nested blocks we're allowed to parse. This is useful to avoid + /// running out of stack space. By default, it's set to `REASONABLE_NESTED_BLOCK_LIMIT`, but it + /// can be overridden or cleared. A limit of 0 will be equivalent to no limit at all. + pub fn set_nested_block_limit(&mut self, limit: u8) { + self.nested_block_limit = limit; + } + #[inline] fn cached_token_ref(&self) -> &Token<'i> { &self.cached_token.as_ref().unwrap().token @@ -1133,6 +1152,14 @@ where token was just consumed.\ ", ); + if parser.input.current_block_depth >= parser.input.nested_block_limit + && parser.input.nested_block_limit != 0 + { + return Err(parser.new_error(BasicParseErrorKind::TooManyNestedBlocks)); + } + // Fine to use wrapping addition, overflow can only occur without a limit. + parser.input.current_block_depth = parser.input.current_block_depth.wrapping_add(1); + let closing_delimiter = match block_type { BlockType::CurlyBracket => ClosingDelimiter::CloseCurlyBracket, BlockType::SquareBracket => ClosingDelimiter::CloseSquareBracket, @@ -1152,6 +1179,8 @@ where } } consume_until_end_of_block(block_type, &mut parser.input.tokenizer); + // See above. + parser.input.current_block_depth = parser.input.current_block_depth.wrapping_sub(1); result } diff --git a/src/size_of_tests.rs b/src/size_of_tests.rs index 7f4b85fa..70ffc5cf 100644 --- a/src/size_of_tests.rs +++ b/src/size_of_tests.rs @@ -43,7 +43,7 @@ size_of_test!(std_cow_str, std::borrow::Cow<'static, str>, 24, 32); size_of_test!(cow_rc_str, CowRcStr, 16); size_of_test!(tokenizer, crate::tokenizer::Tokenizer, 96); -size_of_test!(parser_input, crate::parser::ParserInput, 160); +size_of_test!(parser_input, crate::parser::ParserInput, 168); size_of_test!(parser, crate::parser::Parser, 16); size_of_test!(source_position, crate::SourcePosition, 8); size_of_test!(parser_state, crate::ParserState, 24); diff --git a/src/tests.rs b/src/tests.rs index 5845cdd1..cd82b77b 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -848,6 +848,51 @@ fn no_stack_overflow_multiple_nested_blocks() { while input.next().is_ok() {} } +#[cfg_attr(all(miri, feature = "skip_long_tests"), ignore)] +#[test] +fn nested_block_limit() { + // Recursively descends into `calc(calc(calc(…1…)))`, which is the shape of expression that + // would blow the stack without a nesting limit. + fn parse_calc<'i>(input: &mut Parser<'i, '_>) -> Result<(), ParseError<'i, ()>> { + if input.try_parse(|input| input.expect_number()).is_ok() { + return Ok(()); + } + input.expect_function_matching("calc")?; + input.parse_nested_block(parse_calc) + } + + // Returns `Err(())` if (and only if) parsing bailed out due to the nesting limit. + fn parse(depth: usize, limit: Option) -> Result<(), ()> { + let css = format!("{}1{}", "calc(".repeat(depth), ")".repeat(depth)); + let mut input = ParserInput::new(&css); + if let Some(limit) = limit { + input.set_nested_block_limit(limit); + } + Parser::new(&mut input) + .parse_entirely(parse_calc) + .map_err(|e| match e.kind { + ParseErrorKind::Basic(BasicParseErrorKind::TooManyNestedBlocks) => (), + other => panic!( + "Unexpected error parsing {} nested blocks: {:?}", + depth, other + ), + }) + } + + // The default limit is 75 nested blocks. + assert_eq!(parse(75, None), Ok(())); + assert_eq!(parse(76, None), Err(())); + assert_eq!(parse(10_000, None), Err(())); + + // The limit is configurable... + assert_eq!(parse(3, Some(3)), Ok(())); + assert_eq!(parse(4, Some(3)), Err(())); + assert_eq!(parse(100, Some(255)), Ok(())); + + // ...and a limit of zero means no limit at all. + assert_eq!(parse(1000, Some(0)), Ok(())); +} + impl<'i> DeclarationParser<'i> for JsonParser { type Declaration = Value; type Error = ();