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"`)、裸词中出现 `\`