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:
1 parent
1d744ba504
commit
23f7806e2d
5 files changed
+79
-4
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"`)、裸词中出现 `\`
|
||||
|
||||
Reference in new issue
Block a user