fix(search): cap an expression at 256 atoms

The compiled SQL chains every atom into one expression, and SQLite rejects
expressions deeper than 1000 levels. A query of about 1000 words parsed but
then failed in SQL, outside the syntax-error handling, and crashed the app.
The parser now rejects more than 256 atoms as a syntax error.
This commit is contained in:
doyaGu committed 2026-10-03 08:49:57 -04:00
1 parent 1d744ba504
commit 23f7806e2d
5 files changed
+79 -4

No files matched your search

+1
View File
@@ -226,6 +226,7 @@ These are invalid:
|---|---|
| Unbalanced parentheses | `(tag:bug`, `tag:bug)` |
| Parentheses nested more than 64 levels deep | `((((…a…))))` with 65 `(` |
| More than 256 search terms (words, phrases and qualifiers) | 257 words |
| An operator with a missing side | `bug AND`, `OR bug` |
| Consecutive operators | `a AND OR b` |
| A qualifier without a value | `tag:` |
@@ -248,6 +248,28 @@ public class SearchQueryParserTests
Assert.IsNotNull(SearchQueryParser.Parse(groups));
}
[TestMethod]
public void AtomCountAtTheLimitParses()
{
Assert.IsNotNull(SearchQueryParser.Parse(string.Join(" ", Enumerable.Repeat("a", SearchQueryParser.MaxAtomCount))));
}
[TestMethod]
[DataRow(" ")]
[DataRow(" OR ")]
public void AtomCountBeyondTheLimitIsAnError(string separator)
{
AssertSyntaxError(string.Join(separator, Enumerable.Repeat("a", SearchQueryParser.MaxAtomCount + 1)));
}
[TestMethod]
public void QualifiersAndPhrasesCountTowardsTheAtomLimit()
{
int half = SearchQueryParser.MaxAtomCount / 2;
string text = string.Join(" ", Enumerable.Repeat("tag:\"a b\"", half).Concat(Enumerable.Repeat("\"c\"", half + 1)));
AssertSyntaxError(text);
}
[TestMethod]
public void VeryDeepNestingIsAnErrorRatherThanACrash()
{
@@ -190,4 +190,20 @@ public class SearchSqlCompilerTests
CollectionAssert.AreEqual(new[] { 5L }, fixture.Search("tag:äpfel"));
CollectionAssert.AreEqual(new[] { 5L }, fixture.Search("column:érable"));
}
[TestMethod]
public void LargestAcceptedQueriesRunWithinTheSqliteExpressionDepth()
{
using var fixture = new SearchFixture();
int count = SearchQueryParser.MaxAtomCount;
int depth = SearchQueryParser.MaxNestingDepth;
string nested = string.Concat(Enumerable.Repeat("(tag:x OR ", depth))
+ string.Join(" ", Enumerable.Repeat("apple", count - depth))
+ new string(')', depth);
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search(string.Join(" ", Enumerable.Repeat("apple", count))));
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search(string.Join(" OR ", Enumerable.Repeat("apple", count))));
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search(string.Join(" ", Enumerable.Repeat("title:apple", count))));
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search(nested));
}
}
+38 -3
View File
@@ -9,8 +9,8 @@ namespace YKanBan.Search;
/// phrases, invalid escapes, a backslash in a bare word, unknown qualifier
/// keys, qualifiers without a value, unquoted values containing ':' or equal
/// 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"/>.
/// optional '#', parentheses nested deeper than <see cref="MaxNestingDepth"/>,
/// and more than <see cref="MaxAtomCount"/> atoms) 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
@@ -25,6 +25,14 @@ public static class SearchQueryParser
/// </summary>
public const int MaxNestingDepth = 64;
/// <summary>
/// Most atoms (words, phrases and qualifiers) accepted in one expression.
/// The compiled SQL chains every atom into one expression, and SQLite
/// rejects expressions nested deeper than 1000 levels; together with
/// <see cref="MaxNestingDepth"/> this keeps every valid query well below that.
/// </summary>
public const int MaxAtomCount = 256;
/// <summary>The recognized qualifier keys; add new entries here to extend the grammar.</summary>
private static readonly (string Key, QualifierKind Kind)[] QualifierTable =
[
@@ -122,7 +130,9 @@ public static class SearchQueryParser
{
List<SearchToken> tokens = SearchLexer.Tokenize(text).ToList();
EnsureNestingWithinLimit(tokens);
return Query.ParseOrThrow(tokens);
SearchNode query = Query.ParseOrThrow(tokens);
EnsureAtomCountWithinLimit(query);
return query;
}
catch (ParseException exception)
{
@@ -152,6 +162,31 @@ public static class SearchQueryParser
}
}
/// <summary>
/// Rejects expressions with more than <see cref="MaxAtomCount"/> atoms.
/// </summary>
/// <param name="query">The parsed query.</param>
/// <exception cref="SearchSyntaxException">The expression has too many atoms.</exception>
private static void EnsureAtomCountWithinLimit(SearchNode query)
{
if (CountAtoms(query) > MaxAtomCount)
{
throw new SearchSyntaxException($"The expression has more than {MaxAtomCount} search terms.");
}
}
/// <summary>
/// Counts the atoms (leaves) of a parsed query.
/// </summary>
/// <param name="node">The node to count.</param>
/// <returns>The number of atoms under the node.</returns>
private static int CountAtoms(SearchNode node) => node switch
{
SearchNode.And and => and.Terms.Sum(CountAtoms),
SearchNode.Or or => or.Terms.Sum(CountAtoms),
_ => 1,
};
#region Atom construction
/// <summary>
+2 -1
View File
@@ -16,7 +16,7 @@
- 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录)
- 补充:导出转义规则、卡片列表虚拟化、模态窗口 FlowDirection、`Resources` 类名使用规则、CI osx-x64 运行器(§5、§7、§8)
- 依赖:Avalonia 11.3.6 → 11.3.22,Semi.Avalonia 11.2.1.10 → 11.3.22(§7)
- 实现回补:搜索括号嵌套上限 64 层,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4)
- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4)
- 里程碑:标注 M0–M4 已完成,新增 **M4R(v1.9 回补)**(§9)
## 1. 项目概述
@@ -295,6 +295,7 @@ phrase := '"' 一个或多个字符 '"' (内部支持转义:\" → 字面
- **严格校验**:非法表达式弹出错误对话框,不执行搜索。非法集合:
- 括号不配对
- 括号嵌套超过 64 层(防止递归解析栈溢出;顺序并列的括号组不计入深度)
- 原子(裸词、短语、限定符)超过 256 个(编译出的 SQL 把全部原子串成一个表达式,SQLite 表达式深度上限为 1000)
- 运算符悬空(`bug AND`)、连续运算符(`a AND OR b`)
- 限定符无值(`tag:`)、限定符值为裸 `AND`/`OR`(`tag:AND`,须写 `tag:"AND"`)
- 短语引号未闭合、无效转义(`tag:"a\x"`)、裸词中出现 `\`