Skip to content

Commit 0bad38b

Browse files
committed
v0.41.74: HP-03 filter semantics and registration validation
Signed-off-by: Eli Ma <eli@patch.sh>
1 parent ddf38b1 commit 0bad38b

8 files changed

Lines changed: 782 additions & 15 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mega2"
3-
version = "0.41.73"
3+
version = "0.41.74"
44
edition = "2024"
55
license = "MIT OR Apache-2.0"
66
description = "Monorepo engine and Git hosting server for AI agents: trunk-based storage, Git Smart HTTP, Git LFS, OCI registry, and Agent Session Capture"

‎docs/plan/plan-20261002.md‎

Lines changed: 18 additions & 12 deletions
Large diffs are not rendered by default.

‎docs/refactoring/history-projection.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1251,7 +1251,7 @@ fn mark_ready_if_covered(F, tip) -> bool: # 只在持
12511251
- **过滤器规模上限【决策】。** 以下上限都是代码常量,不进配置;超限返回 400,并指明超了哪一项。它们只约束注册:worker 与 6.1 第 3 层加载已有定义时不复查,所以日后调低常量不会让已注册的视图失效。
12521252
- `filter_spec` 经 JSON 解码后不超过 16 KiB(UTF-8 字节);该路由另设 `DefaultBodyLimit::max(64 KiB)`。【代码】storage-only 的 `/api/v1` 没有单独的请求体上限,沿用 axum 默认的 2 MiB;`snapshot_router.rs` 约 L62、L76 以 `JSON_REQUEST_LIMIT = 131_072` 收紧,是先例。
12531253
- `:[` 的嵌套层数不超过 16。解析器在递归下降时计数,超限立即报错,早于规范化与 pull 计算。【推断】不设此限时,2 MiB 的 `:[:[:[…` 会让递归实现在 tokio 工作线程上栈溢出,进程直接中止。
1254-
- 规范化后,全部 Compose 的成员总数不超过 64,Exclude 选择器总数不超过 256,`k = |src_paths(F)|` 不超过 64。k 决定三处开销:逐源路径授权的次数(本节与 5.2 B0)、2.7 授权面复核的选择器数、P2 锁内的 1 + k 次候选查询(5.2 第 7 步)。成员数决定每个提交 O(深度 × 成员数) 的脊柱 tree(3.5)。
1254+
- 规范化后,全部 Compose 的成员总数不超过 64,Exclude 选择器总数不超过 256。注册期以 `k = 64` 有界物化全过滤器与每个 Compose 成员的最小源路径集:活动集合超过 k 时立即以 K 超限拒绝(`actual = 65` 是下界),不为求最终 `|src_paths(F)|` 而继续展开;这一步早于尚未完成的 Compose 相交检查。于是每个获准过滤器都满足 `|src_paths(F)| ≤ k`,但某些最终会归约到 k 以内的输入也会被保守拒绝。k 决定三处开销:逐源路径授权的次数(本节与 5.2 B0)、2.7 授权面复核的选择器数、P2 锁内的 1 + k 次候选查询(5.2 第 7 步)。成员数决定每个提交 O(深度 × 成员数) 的脊柱 tree(3.5)。
12551255
- 这组数值在 P0 基准中复核:用顶到上限的过滤器测单提交投影耗时与脊柱 tree 数,结果写入 7.2;超出预算就下调常量。
12561256
- **解析栈安全上限(HP-02)。** `parse` 与 §2.1「往返要求」第 3 项的加载复核受栈安全上限 `PARSE_MAX_NESTING = 64` 约束:`:[` 嵌套超过它的定义行复核为 `DefinitionCorrupt { failed: RoundTrip }`,不会让 worker 或协议处理线程栈溢出;它不是本节规模上限,不在注册时施加,经注册写入的定义嵌套不超过当时的 `REGISTER_MAX_NESTING`(当前 16),复核不会因它失败;它只能调高,且不得低于历次发布的 `REGISTER_MAX_NESTING` 最大值(当前 16),调低会让已注册的定义失效,与本节「日后调低常量不会让已注册的视图失效」相悖,只能按 §2.4 升到 `v2` 或强制重建受影响的视图。
12571257
- **注册准入【决策】。** 跨副本的计数与预留放在同一个 Postgres 短事务中,用 L_R(4.6)串行化。处理顺序为:规范化与 2.2 校验 → 逐源路径鉴权 → 准入事务。鉴权在准入之前,因此未通过鉴权的请求不会消耗任何 token 的配额。

‎src/ceres/view/filter/mod.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
mod canonical;
22
mod parse;
33

4+
pub mod semantics;
5+
pub mod validate;
6+
47
pub use canonical::{canonicalize, print};
58
use sha2::{Digest, Sha256};
69

‎src/ceres/view/filter/semantics.rs‎

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
use super::{Filter, canonicalize};
2+
3+
pub fn invert(filter: &Filter) -> Filter {
4+
let inverted = match filter {
5+
Filter::Subdir(path) => Filter::Prefix(path.clone()),
6+
Filter::Prefix(path) => Filter::Subdir(path.clone()),
7+
Filter::Exclude(selectors) => Filter::Exclude(selectors.clone()),
8+
Filter::Compose(members) => Filter::Compose(members.iter().map(invert).collect()),
9+
Filter::Chain(ops) => Filter::Chain(ops.iter().rev().map(invert).collect()),
10+
Filter::Nop => Filter::Nop,
11+
Filter::Empty => Filter::Empty,
12+
};
13+
canonicalize(inverted)
14+
}
15+
16+
/// Computes the exact source paths for a filter already accepted for registration.
17+
///
18+
/// Callers handling request-supplied filters must validate them before calling this
19+
/// function, because the exact result itself can be large for an unvalidated AST.
20+
pub fn src_paths(filter: &Filter) -> Vec<String> {
21+
let mut paths = normalized_paths(pull(filter, vec![Vec::new()]))
22+
.into_iter()
23+
.map(|path| display_path(&path))
24+
.collect::<Vec<_>>();
25+
paths.sort();
26+
paths
27+
}
28+
29+
pub(crate) fn src_paths_limited(
30+
filter: &Filter,
31+
max_paths: usize,
32+
) -> Result<Vec<String>, SrcPathLimitExceeded> {
33+
let paths = pull_limited(filter, vec![Vec::new()], max_paths)?;
34+
let paths = normalized_paths(paths);
35+
if paths.len() > max_paths {
36+
return Err(SrcPathLimitExceeded);
37+
}
38+
let mut paths = paths
39+
.into_iter()
40+
.map(|path| display_path(&path))
41+
.collect::<Vec<_>>();
42+
paths.sort();
43+
Ok(paths)
44+
}
45+
46+
#[derive(Clone, Copy, Debug)]
47+
pub(crate) struct SrcPathLimitExceeded;
48+
49+
fn normalized_paths(mut paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
50+
paths.sort();
51+
paths.dedup();
52+
let mut minimal: Vec<Vec<String>> = Vec::new();
53+
for path in paths {
54+
if !minimal.iter().any(|ancestor| is_prefix(ancestor, &path)) {
55+
minimal.push(path);
56+
}
57+
}
58+
minimal
59+
}
60+
61+
fn pull_limited(
62+
filter: &Filter,
63+
paths: Vec<Vec<String>>,
64+
max_paths: usize,
65+
) -> Result<Vec<Vec<String>>, SrcPathLimitExceeded> {
66+
let paths = match filter {
67+
Filter::Subdir(path) => paths
68+
.into_iter()
69+
.map(|suffix| join(path.segments(), &suffix))
70+
.collect(),
71+
Filter::Prefix(path) => paths
72+
.into_iter()
73+
.filter_map(|path_at_output| pull_prefix(path.segments(), &path_at_output))
74+
.collect(),
75+
Filter::Exclude(_) | Filter::Nop => paths,
76+
Filter::Empty => Vec::new(),
77+
Filter::Compose(members) => {
78+
let mut output = Vec::new();
79+
for member in members {
80+
output.extend(pull_limited(member, paths.clone(), max_paths)?);
81+
output = normalized_paths(output);
82+
if output.len() > max_paths {
83+
return Err(SrcPathLimitExceeded);
84+
}
85+
}
86+
output
87+
}
88+
Filter::Chain(ops) => {
89+
let mut output = paths;
90+
for op in ops.iter().rev() {
91+
output = pull_limited(op, output, max_paths)?;
92+
output = normalized_paths(output);
93+
if output.len() > max_paths {
94+
return Err(SrcPathLimitExceeded);
95+
}
96+
}
97+
output
98+
}
99+
};
100+
let paths = normalized_paths(paths);
101+
if paths.len() > max_paths {
102+
return Err(SrcPathLimitExceeded);
103+
}
104+
Ok(paths)
105+
}
106+
107+
fn pull(filter: &Filter, paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
108+
let paths = match filter {
109+
Filter::Subdir(path) => paths
110+
.into_iter()
111+
.map(|suffix| join(path.segments(), &suffix))
112+
.collect(),
113+
Filter::Prefix(path) => paths
114+
.into_iter()
115+
.filter_map(|path_at_output| pull_prefix(path.segments(), &path_at_output))
116+
.collect(),
117+
Filter::Exclude(_) | Filter::Nop => paths,
118+
Filter::Empty => Vec::new(),
119+
Filter::Compose(members) => {
120+
let mut output = Vec::new();
121+
for member in members {
122+
output.extend(pull(member, paths.clone()));
123+
output = normalized_paths(output);
124+
}
125+
output
126+
}
127+
Filter::Chain(ops) => {
128+
let mut output = paths;
129+
for op in ops.iter().rev() {
130+
output = normalized_paths(pull(op, output));
131+
}
132+
output
133+
}
134+
};
135+
normalized_paths(paths)
136+
}
137+
138+
fn pull_prefix(prefix: &[String], output: &[String]) -> Option<Vec<String>> {
139+
if is_prefix(prefix, output) {
140+
return Some(output[prefix.len()..].to_vec());
141+
}
142+
if is_prefix(output, prefix) {
143+
return Some(Vec::new());
144+
}
145+
None
146+
}
147+
148+
fn join(prefix: &[String], suffix: &[String]) -> Vec<String> {
149+
prefix.iter().chain(suffix).cloned().collect::<Vec<_>>()
150+
}
151+
152+
pub(crate) fn is_prefix(ancestor: &[String], descendant: &[String]) -> bool {
153+
ancestor.len() <= descendant.len()
154+
&& ancestor
155+
.iter()
156+
.zip(descendant)
157+
.all(|(left, right)| left == right)
158+
}
159+
160+
pub(crate) fn display_path(path: &[String]) -> String {
161+
if path.is_empty() {
162+
"/".to_owned()
163+
} else {
164+
format!("/{}", path.join("/"))
165+
}
166+
}
167+
168+
pub(crate) fn path_segments(path: &str) -> Vec<String> {
169+
path.strip_prefix('/')
170+
.filter(|path| !path.is_empty())
171+
.map(|path| path.split('/').map(ToOwned::to_owned).collect())
172+
.unwrap_or_default()
173+
}
174+
175+
pub(crate) fn all_compose_source_paths_limited(
176+
filter: &Filter,
177+
max_paths: usize,
178+
) -> Result<Vec<Vec<Vec<String>>>, SrcPathLimitExceeded> {
179+
let mut groups = Vec::new();
180+
collect_compose_source_paths_limited(filter, max_paths, &mut groups)?;
181+
Ok(groups)
182+
}
183+
184+
fn collect_compose_source_paths_limited(
185+
filter: &Filter,
186+
max_paths: usize,
187+
groups: &mut Vec<Vec<Vec<String>>>,
188+
) -> Result<(), SrcPathLimitExceeded> {
189+
match filter {
190+
Filter::Compose(members) => {
191+
let mut paths = Vec::new();
192+
for member in members {
193+
paths.extend(
194+
src_paths_limited(member, max_paths)?
195+
.into_iter()
196+
.map(|path| path_segments(&path)),
197+
);
198+
}
199+
groups.push(paths);
200+
for member in members {
201+
collect_compose_source_paths_limited(member, max_paths, groups)?;
202+
}
203+
}
204+
Filter::Chain(ops) => {
205+
for op in ops {
206+
collect_compose_source_paths_limited(op, max_paths, groups)?;
207+
}
208+
}
209+
_ => {}
210+
}
211+
Ok(())
212+
}
213+
214+
#[cfg(test)]
215+
mod tests {
216+
use super::{
217+
super::{canonicalize, parse, print},
218+
*,
219+
};
220+
221+
fn filter(text: &str) -> Filter {
222+
canonicalize(parse(text).unwrap())
223+
}
224+
225+
#[test]
226+
fn invert_table() {
227+
for (input, expected) in [
228+
(":/p", ":prefix=p"),
229+
(":prefix=p", ":/p"),
230+
(":nop", ":nop"),
231+
(":empty", ":empty"),
232+
(":/a:prefix=x", ":/x:prefix=a"),
233+
(
234+
":[:/a:prefix=x,:/b:prefix=y]",
235+
":[:/x:prefix=a,:/y:prefix=b]",
236+
),
237+
(":exclude[::b/]", ":exclude[::b/]"),
238+
(":/a:exclude[::b/]", ":exclude[::b/]:prefix=a"),
239+
] {
240+
assert_eq!(print(&invert(&filter(input))), expected, "{input}");
241+
}
242+
}
243+
244+
#[test]
245+
fn src_paths_examples() {
246+
for (input, expected) in [
247+
(":exclude[::secret]", vec!["/"]),
248+
(":/a:exclude[::b/]", vec!["/a"]),
249+
(":prefix=x", vec!["/"]),
250+
(":/a:prefix=x", vec!["/a"]),
251+
(":/a:[:/b:prefix=x,:/c:prefix=y]", vec!["/a/b", "/a/c"]),
252+
(":[:/a:prefix=x,:/b:prefix=y]:/x", vec!["/a"]),
253+
(":prefix=x:[:/w:prefix=b,:/z:prefix=a]", Vec::<&str>::new()),
254+
(":exclude[::a]:/a", vec!["/a"]),
255+
(":nop", vec!["/"]),
256+
(":[:/B:prefix=b,:/a:prefix=a]", vec!["/B", "/a"]),
257+
(":[:/a/b:prefix=x,:/a.c:prefix=y]", vec!["/a.c", "/a/b"]),
258+
] {
259+
assert_eq!(src_paths(&filter(input)), expected, "{input}");
260+
}
261+
}
262+
}

0 commit comments

Comments
 (0)