fix(search): reject parentheses nested deeper than 64 levels

Deep nesting overflowed the stack of the recursive Pidgin parser (around
400-700 levels), an uncatchable crash. The depth is now checked on the token
stream before parsing and reported as a syntax error. Listed in PLAN 6.2 and
USAGE.
This commit is contained in:
doyaGu committed 2026-10-03 08:12:34 -04:00
1 parent 8ae9780c0e
commit d2a5d36bd2
5 files changed
+70 -2

No files matched your search

+1
View File
@@ -221,6 +221,7 @@ These are invalid:
| Problem | Example |
|---|---|
| Unbalanced parentheses | `(tag:bug`, `tag:bug)` |
| Parentheses nested more than 64 levels deep | `((((…a…))))` with 65 `(` |
| An operator with a missing side | `bug AND`, `OR bug` |
| Consecutive operators | `a AND OR b` |
| A qualifier without a value | `tag:` |
@@ -227,6 +227,33 @@ public class SearchQueryParserTests
AssertSyntaxError("a)");
}
[TestMethod]
public void NestingAtTheDepthLimitParses()
{
int depth = SearchQueryParser.MaxNestingDepth;
AssertParsesTo(new SearchNode.FreeText("a"), new string('(', depth) + "a" + new string(')', depth));
}
[TestMethod]
public void NestingBeyondTheDepthLimitIsAnError()
{
int depth = SearchQueryParser.MaxNestingDepth + 1;
AssertSyntaxError(new string('(', depth) + "a" + new string(')', depth));
}
[TestMethod]
public void SequentialGroupsDoNotCountTowardsTheDepthLimit()
{
string groups = string.Join(" ", Enumerable.Repeat("(a)", SearchQueryParser.MaxNestingDepth + 1));
Assert.IsNotNull(SearchQueryParser.Parse(groups));
}
[TestMethod]
public void VeryDeepNestingIsAnErrorRatherThanACrash()
{
AssertSyntaxError(new string('(', 100_000) + "a" + new string(')', 100_000));
}
[TestMethod]
public void EmptyParensAreAnError()
{
+32 -2
View File
@@ -8,8 +8,9 @@ namespace YKanBan.Search;
/// (unbalanced parentheses, dangling or consecutive operators, unterminated
/// phrases, invalid escapes, a backslash in a bare word, unknown qualifier
/// keys, qualifiers without a value, unquoted values containing ':' or equal
/// to AND/OR, and id values that are not an unquoted positive integer with an
/// optional '#') raises <see cref="SearchSyntaxException"/>.
/// to AND/OR, id values that are not an unquoted positive integer with an
/// optional '#', and parentheses nested deeper than <see cref="MaxNestingDepth"/>)
/// raises <see cref="SearchSyntaxException"/>.
///
/// Only uppercase AND/OR are operators (lowercase ones are plain words); AND —
/// explicit or by adjacency — binds tighter than OR; parentheses group. Blank
@@ -18,6 +19,12 @@ namespace YKanBan.Search;
/// </summary>
public static class SearchQueryParser
{
/// <summary>
/// Deepest accepted parenthesis nesting. The parser recurses once per level,
/// so an unbounded depth would overflow the stack and kill the process.
/// </summary>
public const int MaxNestingDepth = 64;
/// <summary>The recognized qualifier keys; add new entries here to extend the grammar.</summary>
private static readonly (string Key, QualifierKind Kind)[] QualifierTable =
[
@@ -114,6 +121,7 @@ public static class SearchQueryParser
try
{
List<SearchToken> tokens = SearchLexer.Tokenize(text).ToList();
EnsureNestingWithinLimit(tokens);
return Query.ParseOrThrow(tokens);
}
catch (ParseException exception)
@@ -122,6 +130,28 @@ public static class SearchQueryParser
}
}
/// <summary>
/// Rejects parenthesis nesting deeper than <see cref="MaxNestingDepth"/>
/// before the recursive parser sees it. Balance itself is left to the parser.
/// </summary>
/// <param name="tokens">The token stream.</param>
/// <exception cref="SearchSyntaxException">The nesting is too deep.</exception>
private static void EnsureNestingWithinLimit(IEnumerable<SearchToken> tokens)
{
int depth = 0;
foreach (SearchToken token in tokens)
{
if (token is SearchToken.OpenParen && ++depth > MaxNestingDepth)
{
throw new SearchSyntaxException($"Parentheses are nested more than {MaxNestingDepth} levels deep.");
}
if (token is SearchToken.CloseParen && depth > 0)
{
depth--;
}
}
}
#region Atom construction
/// <summary>
+9
View File
@@ -8,6 +8,15 @@ namespace YKanBan.Search;
/// </summary>
public sealed class SearchSyntaxException : Exception
{
/// <summary>
/// Initializes the exception with a descriptive English message.
/// </summary>
/// <param name="message">The English diagnostic message.</param>
public SearchSyntaxException(string message)
: base(message)
{
}
/// <summary>
/// Initializes the exception with a descriptive English message.
/// </summary>
+1
View File
@@ -291,6 +291,7 @@ phrase := '"' 任意文本 '"' (内部支持转义:\" → 字面引号,
- 短语内部转义:`\"` → 字面引号,`\\` → 字面反斜杠;搜索含空格/保留字符/字面 `AND` 等特殊标签名时,用(必要时转义的)引号值(写入文档)
- **严格校验**:非法表达式弹出错误对话框,不执行搜索。非法集合:
- 括号不配对
- 括号嵌套超过 64 层(防止递归解析栈溢出;顺序并列的括号组不计入深度)
- 运算符悬空(`bug AND`)、连续运算符(`a AND OR b`)
- 限定符无值(`tag:`)、限定符值为裸 `AND`/`OR`(`tag:AND`,须写 `tag:"AND"`)
- 短语引号未闭合、无效转义(`tag:"a\x"`)、裸词中出现 `\`