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)` |
|
| Unbalanced parentheses | `(tag:bug`, `tag:bug)` |
|
||||||
| Parentheses nested more than 64 levels deep | `((((…a…))))` with 65 `(` |
|
| 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` |
|
| An operator with a missing side | `bug AND`, `OR bug` |
|
||||||
| Consecutive operators | `a AND OR b` |
|
| Consecutive operators | `a AND OR b` |
|
||||||
| A qualifier without a value | `tag:` |
|
| A qualifier without a value | `tag:` |
|
||||||
|
|||||||
@@ -248,6 +248,28 @@ public class SearchQueryParserTests
|
|||||||
Assert.IsNotNull(SearchQueryParser.Parse(groups));
|
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]
|
[TestMethod]
|
||||||
public void VeryDeepNestingIsAnErrorRatherThanACrash()
|
public void VeryDeepNestingIsAnErrorRatherThanACrash()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -190,4 +190,20 @@ public class SearchSqlCompilerTests
|
|||||||
CollectionAssert.AreEqual(new[] { 5L }, fixture.Search("tag:äpfel"));
|
CollectionAssert.AreEqual(new[] { 5L }, fixture.Search("tag:äpfel"));
|
||||||
CollectionAssert.AreEqual(new[] { 5L }, fixture.Search("column:érable"));
|
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
|
/// phrases, invalid escapes, a backslash in a bare word, unknown qualifier
|
||||||
/// keys, qualifiers without a value, unquoted values containing ':' or equal
|
/// keys, qualifiers without a value, unquoted values containing ':' or equal
|
||||||
/// to AND/OR, id values that are not an unquoted positive integer with an
|
/// to AND/OR, id values that are not an unquoted positive integer with an
|
||||||
/// optional '#', and parentheses nested deeper than <see cref="MaxNestingDepth"/>)
|
/// optional '#', parentheses nested deeper than <see cref="MaxNestingDepth"/>,
|
||||||
/// raises <see cref="SearchSyntaxException"/>.
|
/// and more than <see cref="MaxAtomCount"/> atoms) raises <see cref="SearchSyntaxException"/>.
|
||||||
///
|
///
|
||||||
/// Only uppercase AND/OR are operators (lowercase ones are plain words); AND —
|
/// Only uppercase AND/OR are operators (lowercase ones are plain words); AND —
|
||||||
/// explicit or by adjacency — binds tighter than OR; parentheses group. Blank
|
/// explicit or by adjacency — binds tighter than OR; parentheses group. Blank
|
||||||
@@ -25,6 +25,14 @@ public static class SearchQueryParser
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const int MaxNestingDepth = 64;
|
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>
|
/// <summary>The recognized qualifier keys; add new entries here to extend the grammar.</summary>
|
||||||
private static readonly (string Key, QualifierKind Kind)[] QualifierTable =
|
private static readonly (string Key, QualifierKind Kind)[] QualifierTable =
|
||||||
[
|
[
|
||||||
@@ -122,7 +130,9 @@ public static class SearchQueryParser
|
|||||||
{
|
{
|
||||||
List<SearchToken> tokens = SearchLexer.Tokenize(text).ToList();
|
List<SearchToken> tokens = SearchLexer.Tokenize(text).ToList();
|
||||||
EnsureNestingWithinLimit(tokens);
|
EnsureNestingWithinLimit(tokens);
|
||||||
return Query.ParseOrThrow(tokens);
|
SearchNode query = Query.ParseOrThrow(tokens);
|
||||||
|
EnsureAtomCountWithinLimit(query);
|
||||||
|
return query;
|
||||||
}
|
}
|
||||||
catch (ParseException exception)
|
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
|
#region Atom construction
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,7 @@
|
|||||||
- 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录)
|
- 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录)
|
||||||
- 补充:导出转义规则、卡片列表虚拟化、模态窗口 FlowDirection、`Resources` 类名使用规则、CI osx-x64 运行器(§5、§7、§8)
|
- 补充:导出转义规则、卡片列表虚拟化、模态窗口 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)
|
- 依赖: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)
|
- 里程碑:标注 M0–M4 已完成,新增 **M4R(v1.9 回补)**(§9)
|
||||||
|
|
||||||
## 1. 项目概述
|
## 1. 项目概述
|
||||||
@@ -295,6 +295,7 @@ phrase := '"' 一个或多个字符 '"' (内部支持转义:\" → 字面
|
|||||||
- **严格校验**:非法表达式弹出错误对话框,不执行搜索。非法集合:
|
- **严格校验**:非法表达式弹出错误对话框,不执行搜索。非法集合:
|
||||||
- 括号不配对
|
- 括号不配对
|
||||||
- 括号嵌套超过 64 层(防止递归解析栈溢出;顺序并列的括号组不计入深度)
|
- 括号嵌套超过 64 层(防止递归解析栈溢出;顺序并列的括号组不计入深度)
|
||||||
|
- 原子(裸词、短语、限定符)超过 256 个(编译出的 SQL 把全部原子串成一个表达式,SQLite 表达式深度上限为 1000)
|
||||||
- 运算符悬空(`bug AND`)、连续运算符(`a AND OR b`)
|
- 运算符悬空(`bug AND`)、连续运算符(`a AND OR b`)
|
||||||
- 限定符无值(`tag:`)、限定符值为裸 `AND`/`OR`(`tag:AND`,须写 `tag:"AND"`)
|
- 限定符无值(`tag:`)、限定符值为裸 `AND`/`OR`(`tag:AND`,须写 `tag:"AND"`)
|
||||||
- 短语引号未闭合、无效转义(`tag:"a\x"`)、裸词中出现 `\`
|
- 短语引号未闭合、无效转义(`tag:"a\x"`)、裸词中出现 `\`
|
||||||
|
|||||||
Reference in new issue
Block a user