diff --git a/USAGE.md b/USAGE.md
index b793d85..4142950 100644
--- a/USAGE.md
+++ b/USAGE.md
@@ -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:` |
diff --git a/YKanBan.Tests/Search/SearchQueryParserTests.cs b/YKanBan.Tests/Search/SearchQueryParserTests.cs
index 87fa18f..87c77d6 100644
--- a/YKanBan.Tests/Search/SearchQueryParserTests.cs
+++ b/YKanBan.Tests/Search/SearchQueryParserTests.cs
@@ -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()
{
diff --git a/YKanBan/Search/SearchQueryParser.cs b/YKanBan/Search/SearchQueryParser.cs
index b020c72..f1880c2 100644
--- a/YKanBan/Search/SearchQueryParser.cs
+++ b/YKanBan/Search/SearchQueryParser.cs
@@ -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 .
+/// to AND/OR, id values that are not an unquoted positive integer with an
+/// optional '#', and parentheses nested deeper than )
+/// raises .
///
/// 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;
///
public static class SearchQueryParser
{
+ ///
+ /// Deepest accepted parenthesis nesting. The parser recurses once per level,
+ /// so an unbounded depth would overflow the stack and kill the process.
+ ///
+ public const int MaxNestingDepth = 64;
+
/// The recognized qualifier keys; add new entries here to extend the grammar.
private static readonly (string Key, QualifierKind Kind)[] QualifierTable =
[
@@ -114,6 +121,7 @@ public static class SearchQueryParser
try
{
List tokens = SearchLexer.Tokenize(text).ToList();
+ EnsureNestingWithinLimit(tokens);
return Query.ParseOrThrow(tokens);
}
catch (ParseException exception)
@@ -122,6 +130,28 @@ public static class SearchQueryParser
}
}
+ ///
+ /// Rejects parenthesis nesting deeper than
+ /// before the recursive parser sees it. Balance itself is left to the parser.
+ ///
+ /// The token stream.
+ /// The nesting is too deep.
+ private static void EnsureNestingWithinLimit(IEnumerable 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
///
diff --git a/YKanBan/Search/SearchSyntaxException.cs b/YKanBan/Search/SearchSyntaxException.cs
index 483d89a..a8f5b78 100644
--- a/YKanBan/Search/SearchSyntaxException.cs
+++ b/YKanBan/Search/SearchSyntaxException.cs
@@ -8,6 +8,15 @@ namespace YKanBan.Search;
///
public sealed class SearchSyntaxException : Exception
{
+ ///
+ /// Initializes the exception with a descriptive English message.
+ ///
+ /// The English diagnostic message.
+ public SearchSyntaxException(string message)
+ : base(message)
+ {
+ }
+
///
/// Initializes the exception with a descriptive English message.
///
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 3fc195a..4bfe3aa 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -291,6 +291,7 @@ phrase := '"' 任意文本 '"' (内部支持转义:\" → 字面引号,
- 短语内部转义:`\"` → 字面引号,`\\` → 字面反斜杠;搜索含空格/保留字符/字面 `AND` 等特殊标签名时,用(必要时转义的)引号值(写入文档)
- **严格校验**:非法表达式弹出错误对话框,不执行搜索。非法集合:
- 括号不配对
+ - 括号嵌套超过 64 层(防止递归解析栈溢出;顺序并列的括号组不计入深度)
- 运算符悬空(`bug AND`)、连续运算符(`a AND OR b`)
- 限定符无值(`tag:`)、限定符值为裸 `AND`/`OR`(`tag:AND`,须写 `tag:"AND"`)
- 短语引号未闭合、无效转义(`tag:"a\x"`)、裸词中出现 `\`