fix(resource): 统一 ResourceQuery 路径匹配契约

This commit is contained in:
2026-09-19 07:59:31 +08:00
parent 045d598400
commit 56ad014199
5 changed files with 367 additions and 85 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ pub mod translation_repository;
pub use cas_repository::CasRepository;
pub use glossary_repository::GlossaryRepository;
pub use resource_repository::ResourceRepository;
pub use resource_repository::{ResourcePathPattern, ResourceQuery, ResourceRepository};
pub use translation_memory_repository::TranslationMemoryRepository;
pub use translation_repository::TranslationRepository;
+170 -9
View File
@@ -35,6 +35,120 @@
use crate::domain::{Resource, ResourceType};
use async_trait::async_trait;
/// Compiled path pattern used by [`ResourceQuery::path_pattern`].
///
/// The matcher operates on Unicode scalar values rather than UTF-8 bytes:
///
/// - `*` matches zero or more non-`/` scalar values.
/// - `**` matches zero or more scalar values, including `/`.
/// - `?` matches exactly one non-`/` scalar value.
/// - `/` and every other character are literals.
///
/// There is no escape syntax. Matching is case-sensitive and uses a bounded
/// dynamic-programming table, so wildcard-heavy input cannot trigger
/// exponential backtracking.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourcePathPattern {
tokens: Vec<ResourcePathToken>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ResourcePathToken {
Literal(char),
Single,
Star,
GlobStar,
RecursiveDirectory,
}
impl ResourcePathPattern {
/// Compiles a resource path pattern.
///
/// `**/` is treated as a recursive directory prefix so that patterns such
/// as `**/*.json` also match a root-level `a.json`.
pub fn new(pattern: &str) -> Self {
let chars = pattern.chars().collect::<Vec<_>>();
let mut tokens = Vec::with_capacity(chars.len());
let mut index = 0;
while index < chars.len() {
match chars[index] {
'*' if chars.get(index + 1) == Some(&'*') => {
index += 2;
if chars.get(index) == Some(&'/') {
tokens.push(ResourcePathToken::RecursiveDirectory);
index += 1;
} else {
tokens.push(ResourcePathToken::GlobStar);
}
}
'*' => {
tokens.push(ResourcePathToken::Star);
index += 1;
}
'?' => {
tokens.push(ResourcePathToken::Single);
index += 1;
}
literal => {
tokens.push(ResourcePathToken::Literal(literal));
index += 1;
}
}
}
Self { tokens }
}
/// Returns whether `path` satisfies this pattern.
pub fn matches(&self, path: &str) -> bool {
let path = path.chars().collect::<Vec<_>>();
let token_count = self.tokens.len();
let path_count = path.len();
let mut table = vec![false; (token_count + 1) * (path_count + 1)];
let cell = |token: usize, value: usize| token * (path_count + 1) + value;
table[cell(token_count, path_count)] = true;
for token in (0..token_count).rev() {
for value in (0..=path_count).rev() {
table[cell(token, value)] = match self.tokens[token] {
ResourcePathToken::Literal(literal) => {
value < path_count
&& path[value] == literal
&& table[cell(token + 1, value + 1)]
}
ResourcePathToken::Single => {
value < path_count
&& path[value] != '/'
&& table[cell(token + 1, value + 1)]
}
ResourcePathToken::Star => {
table[cell(token + 1, value)]
|| (value < path_count
&& path[value] != '/'
&& table[cell(token, value + 1)])
}
ResourcePathToken::GlobStar => {
table[cell(token + 1, value)]
|| (value < path_count && table[cell(token, value + 1)])
}
ResourcePathToken::RecursiveDirectory => {
if table[cell(token + 1, value)] {
true
} else {
(value..path_count)
.any(|index| path[index] == '/' && table[cell(token, index + 1)])
}
}
};
}
}
table[cell(0, 0)]
}
}
/// 资源查询条件
///
/// 用于构建灵活的资源查询。支持按类型、Hash、路径、官方 release 和解析摘要过滤。
@@ -81,9 +195,12 @@ pub struct ResourceQuery {
/// 如果为 `None`,不过滤路径。
///
/// 支持通配符:
/// - `*` 匹配任意字符(不包括 `/`
/// - `**` 匹配任意字符(包括 `/`
/// - `?` 匹配单个字符
/// - `*` 匹配零个或多个 Unicode scalar value(不包括 `/`
/// - `**` 匹配零个或多个 Unicode scalar value(包括 `/`
/// - `?` 匹配恰好一个 Unicode scalar value(不包括 `/`
/// - `/` 是字面量分隔符;`%`、`_`、`\` 没有特殊含义
///
/// 匹配区分大小写,不存在转义语法。空模式只匹配空路径。
///
/// # 示例
///
@@ -192,12 +309,13 @@ impl ResourceQuery {
}
}
/// 是否包含通用仓储需要读取完整 `Resource` 后才能判断的条件。
/// 是否包含通用仓储需要读取完整 `Resource` 后才能最终判断的条件。
///
/// 具体后端可以把这些 metadata 条件下推到自身索引;内存实现仍用完整
/// `Resource` 过滤来保持 `list()` 与 `count()` 的语义一致。
/// 具体后端可以把 metadata 条件下推到自身索引,但路径模式和这些条件都必须
/// 经过同一最终过滤逻辑,以保持 `list()` 与 `count()` 的语义一致。
pub fn requires_resource_scan(&self) -> bool {
self.official_release_id.is_some()
self.path_pattern.is_some()
|| self.official_release_id.is_some()
|| self.platform.is_some()
|| self.bundle_path.is_some()
|| self.archive_entry.is_some()
@@ -355,7 +473,7 @@ pub trait ResourceRepository: Send + Sync {
///
/// # 实现建议
///
/// - 对于路径模式匹配使用 `glob` crate
/// - 路径模式匹配使用 [`ResourcePathPattern`],不要把 SQL `LIKE` 当作正式语义
/// - 考虑结果缓存以提高性能
/// - 对于超大结果集,返回迭代器(未来版本)
async fn list(&self, query: ResourceQuery) -> crate::Result<Vec<Resource>>;
@@ -429,7 +547,8 @@ pub trait ResourceRepository: Send + Sync {
///
/// # 性能
///
/// 此方法应该比 `list()` 更快,因为不需要返回实际数据。
/// 无路径模式或 metadata 后置过滤时可以使用索引/SQL COUNT 快速路径;存在
/// 后置过滤时必须与 `list(query).len()` 完全一致。
///
/// # 示例
///
@@ -532,4 +651,46 @@ mod tests {
assert_eq!(query2.resource_type, Some(ResourceType::Manifest));
}
#[test]
fn resource_path_pattern_matches_unicode_and_separators() {
let cases = [
("", "", true),
("", "a", false),
("*", "", true),
("*", "abc", true),
("*", "a/b", false),
("?", "", true),
("?", "", true),
("?", "😀", true),
("?", "/", false),
("?", "ab", false),
("assets/*.json", "assets/a.json", true),
("assets/*.json", "assets/nested/a.json", false),
("assets/**/*.json", "assets/a.json", true),
("assets/**/*.json", "assets/nested/a.json", true),
("assets/**/*.json", "assets/nested/deep/a.json", true),
("**/*.json", "a.json", true),
("**/*.json", "nested/a.json", true),
("**/*.json", "nested/deep/a.json", true),
("**/*.json", "a.txt", false),
("A*", "abc", false),
("A*", "Abc", true),
("100%", "100%", true),
("100%", "1000", false),
("a_b", "a_b", true),
("a_b", "axb", false),
(r"a\b", r"a\b", true),
(r"a\b", "a/b", false),
("***", "nested/path", true),
];
for (pattern, path, expected) in cases {
assert_eq!(
ResourcePathPattern::new(pattern).matches(path),
expected,
"pattern={pattern:?} path={path:?}"
);
}
}
}