From d573c09ec665a99fe6362f84f57cd77c35353238 Mon Sep 17 00:00:00 2001 From: argszero Date: Tue, 22 Sep 2026 09:25:06 +0800 Subject: [PATCH] fix(tx): order the whole transaction set, not just the loaded page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transactions table's column headers carry a sort arrow, and that arrow is a claim about the WHOLE dataset: the pager below it counts the backend's `total` (30 rows, "1 2 3 / 3 · 30 rows") and the summary card above it is a backend aggregate. The ordering, however, only ever applied to the page in hand. `#135` turned the list into a server-paged table (`serverPaging` = backend total, one page of rows per request, `LIMIT/OFFSET` over `ORDER BY t.id DESC`), while sorting stayed where it was born in the static prototype (`#7`, `d70e032`): client-side, over whatever rows the caller passed in -- if (state.sort.length) { data = data.slice().sort(...) } const pageRows = serverPaging ? data : data.slice(...) a page. So clicking "Points" ▲ reordered the ten rows on screen and painted ▲, while the row that actually holds the minimum sat on page 2/3 and was never fetched; the request never changed. The column FILTERS travelled the other half of that road long ago (`#232` `e7bfc6f` / C2114: "local filter of the current page" -> "backend full-dataset filter"); the sort half never did. Fix: one claim, three carriers, and a server that renders `ORDER BY` from a whitelist. * `ui/js/app.js` — `txSortParams()` projects the single sort state (`txTable.sort`) into the LIST request only (the trend endpoint buckets by time; row order means nothing to it). `txQuerySig()` now covers the sort, so the reload guard refetches when a header is clicked -- without that line the arrow would move over a list that does not, which is worse than the defect being fixed. The local sort steps aside when the call site declares `serverSort`: `if (state.sort.length && !(serverPaging && serverSort))`. * `src/routes/wallet.rs` — `TX_SORT_KEYS` (11 keys, exactly the sortable columns of `TX_COLUMNS`) + `tx_sort_expr()` + `tx_order_by()`. The user string never reaches SQL; the `ORDER BY` fragment is rendered from the whitelist, always with a trailing `, t.id DESC` (a non-unique sort makes `LIMIT/OFFSET` page boundaries indeterminate -- one row twice, another never), and an unknown key / mismatched key-direction count / bad direction is a 400. Validation runs before the DB lock, as `type` does. Expressions match what the cell shows (`pts` via `signed_pts_expr`, the four token columns on their raw values, `model`/`key` byte-identical to the filter expressions). Tests: * `state_gate::the_sort_indicator_and_the_order_by_share_one_source` — four rules, each with its own mutant leg, plus a `the_r164_rules_have_teeth` self-proof over self-contained mini sources and a `the_r164_roster_is_real` positive control. Roster == whitelist == match arms, `q.sort`/`q.dir` bound in exactly one place, the guard reads the WHOLE condition (a `[^)]*` reader cannot see `!(a && b)`) and a control rule keeps the filter half of the same road honest. * `src/routes/wallet.rs` — three behaviour tests: the whole set is ordered (not the page), ties are stable across pages, and anything outside the whitelist is rejected. * jsdom probe (7 legs, both directions): on the pre-fix tree the two global-extremum legs and the "did the request carry sort" leg fail; on the landed bytes all seven pass. The two competing "fixes" (keep sorting the page and caption the arrow " (this page)"; stop painting the arrow) are rejected by the same legs. Scope, stated honestly: the gate is lexical -- it proves the three carriers agree and that the whitelist IS the column roster; it does not prove that the rows on screen are globally ordered (that is the jsdom probe's job, and there is no JS runner in `cargo test`), nor the `ORDER BY` semantics themselves (those are the wallet behaviour tests). Recorded in `ui/README.md`. --- src/routes/wallet.rs | 274 +++++++++++++- src/state_gate.rs | 834 +++++++++++++++++++++++++++++++++++++++++++ ui/README.md | 27 ++ ui/index.html | 2 +- ui/js/app.js | 25 +- 5 files changed, 1156 insertions(+), 6 deletions(-) diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index 69b60ec..6c1c8fe 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -76,6 +76,95 @@ fn signed_pts_expr(prefix: &str) -> String { ) } +/// 交易列表可排序的列:**键 → SQL 表达式**白名单(键与前端 `TX_COLUMNS` 的 `key` 同名)。 +/// +/// ⛔ 用户串**永不**进入 `ORDER BY` —— 只能经本表取表达式(与 [`parse_tx_type`] 同款单真源)。 +/// 表达式必须与本列的**显示口径**一致(C2054/C2113 同族:排序是对「用户看到的数字/文字」的 +/// 主张):`model` / `key` 两行与 [`tx_where`] 的筛选表达式**逐字相同**(同一个显示口径的两个 +/// 投影),`pts` 复用 [`signed_pts_expr`](收入正、支出负),四列 Token 用与前端 `sortVal` +/// 同源的原始值。少一列,那一列的表头箭头就只是在描述**它自己那一页**。 +pub const TX_SORT_KEYS: [&str; 11] = [ + "time", "type", "user", "model", "key", "input", "cached", "output", "tokens", "pts", "status", +]; + +/// 排序方向白名单(前端只发这两个;其余 400,与键同一处置)。 +const TX_SORT_DIRS: [&str; 2] = ["asc", "desc"]; + +/// 排序键 → SQL 表达式;未知键 `None`(由 [`tx_order_by`] 翻成 400)。 +fn tx_sort_expr(key: &str) -> Option { + Some(match key { + "time" => "t.time".to_string(), + "type" => "t.type".to_string(), + "user" => "u.name".to_string(), + "model" => "COALESCE(NULLIF(t.model, ''), '—')".to_string(), + "key" => { + // 与 `tx_where` 的 Key 筛选表达式**逐字相同**(同一个显示口径的两个投影,C2113)。 + "COALESCE(NULLIF(ak.name, ''), NULLIF(CASE WHEN k.note <> '' THEN k.note \ + WHEN k.plan <> '' THEN k.provider || ' / ' || k.plan \ + ELSE k.provider END, ''), '—')" + .to_string() + } + "input" => "(t.tokens - t.cached_tokens - t.output_tokens)".to_string(), + "cached" => "t.cached_tokens".to_string(), + "output" => "t.output_tokens".to_string(), + "tokens" => "t.tokens".to_string(), + "pts" => signed_pts_expr("t"), + "status" => "t.status".to_string(), + _ => return None, + }) +} + +/// 解析 `sort` / `dir` 查询参数 → `ORDER BY` 片段(列表端点专用;用户串**永不**进入 SQL)。 +/// +/// - 缺省(或空串)⇒ `t.id DESC`:**今日行为不变**(最新在前)。 +/// - `sort` 是逗号分隔的键列表,`dir` 是**逐列对应**的方向列表(缺省全 `asc`)——仓内排序本 +/// 就是多列的(Shift 点击叠加),服务端只认第一列会让两支箭头里只有一支被兑现,那正是本轴 +/// 要修的病本身。 +/// - 未知键 / 长度不匹配 / 非法方向 ⇒ 400,文案由白名单**派生**;**不得**静默回退默认排序 +/// (回退会让「箭头 ⇔ 顺序」再次脱钩,也就是又回到缺陷本身)。 +/// - 尾部恒加 `, t.id DESC`:分页是 `LIMIT/OFFSET`,非唯一排序会让相邻页的边界不确定 +/// (同一行出现两次、另一行永不出现)。 +fn tx_order_by(sort: Option<&str>, dir: Option<&str>) -> Result { + let bad = |msg: String| { + ( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": msg })), + ) + }; + let split = |s: &str| -> Vec { + s.split(',') + .map(str::trim) + .filter(|x| !x.is_empty()) + .map(str::to_string) + .collect() + }; + let keys = sort.map(split).unwrap_or_default(); + if keys.is_empty() { + return Ok("t.id DESC".to_string()); + } + let dirs = dir.map(split).unwrap_or_default(); + if !dirs.is_empty() && dirs.len() != keys.len() { + return Err(bad(format!( + "sort 与 dir 必须逐列对应(sort 有 {} 个键、dir 有 {} 个方向)", + keys.len(), + dirs.len() + ))); + } + let mut parts: Vec = Vec::with_capacity(keys.len() + 1); + for (i, key) in keys.iter().enumerate() { + let expr = tx_sort_expr(key) + .ok_or_else(|| bad(format!("sort 必须为 {} 之一", TX_SORT_KEYS.join(" / "))))?; + let d = dirs.get(i).map(String::as_str).unwrap_or("asc"); + if !TX_SORT_DIRS.contains(&d) { + return Err(bad(format!("dir 必须为 {} 之一", TX_SORT_DIRS.join(" / ")))); + } + parts.push(format!("{expr} {}", d.to_uppercase())); + } + // 分页锚:唯一排序才能让 `LIMIT/OFFSET` 的页边界确定。 + parts.push("t.id DESC".to_string()); + Ok(parts.join(", ")) +} + /// 解析 `type` 查询参数:`""` / `"all"` → `None`(不筛),[`TX_FILTER_TYPES`] 成员 → `Some(成员)`, /// 其余 → 400(文案由 [`TX_FILTER_TYPES`] **派生**,因此不可能再与集合分叉)。 /// `/api/transactions` 与 `/api/transactions/trend` 共用此函数。 @@ -162,6 +251,10 @@ pub struct TxQuery { pub start: Option, /// 结束时间(ISO 8601,UTC,SQLite 可解析),time < end;缺省不限 pub end: Option, + /// 排序键(逗号分隔,取 [`TX_SORT_KEYS`] 成员;缺省按 `t.id DESC`),未知键 400 + pub sort: Option, + /// 排序方向(逗号分隔,与 `sort` 逐列对应;缺省全 `asc`),非 asc/desc 400 + pub dir: Option, /// 列筛选(model/user_name/key_name/status/pts_min/pts_max) #[serde(flatten)] pub filters: TxColFilters, @@ -320,6 +413,8 @@ pub async fn transactions( // 类型过滤:取值集合与 400 文案均来自 TX_FILTER_TYPES(列筛选 select 含 withdraw, // rant 2026-08-25T10:33.26:列筛选后端化后 UI 选项须全被 API 接受)。 let type_filter = parse_tx_type(&q.r#type)?; + // 排序(R164):与 `type` 同款「先校验、后取锁」—— 非法参数不该去动数据库。 + let order = tx_order_by(q.sort.as_deref(), q.dir.as_deref())?; let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; // start/end(rant 2026-08-22T10:50:00):RFC3339/ISO 8601 → 规范化为 UTC "YYYY-MM-DD HH:MM:SS" // (与库内 datetime('now') 一致,字符串比较即时间比较);非法格式 400。 @@ -407,7 +502,7 @@ pub async fn transactions( LEFT JOIN users u ON u.id = t.user_id \ LEFT JOIN api_keys ak ON ak.id = t.api_key_id \ WHERE {list_where} \ - ORDER BY t.id DESC LIMIT ?{} OFFSET ?{}", + ORDER BY {order} LIMIT ?{} OFFSET ?{}", n + 1, n + 2 ); @@ -2271,4 +2366,181 @@ mod tests { } } } + + /// R164:列头 ▲/▼ 是**整个数据集**的主张 —— 服务端必须按白名单渲染 `ORDER BY`,而不是只排当前页。 + /// + /// 夹具让 id 序与 pts 序**相反**(插入序 = id 升序,显示的 pts 值递减),并且**混入一条支出**: + /// 「点数」列显示的是**有符号值**(收入正 / 支出负,C2054/C2057 —— `signed_pts_expr` 与前端 + /// `signedPts()` 同口径),所以按显示值升序应是 -2 → +1 → +3。若实现按库内原始 `pts` 排 + /// (全为正数),顺序会变成 +1 → +2 → +3 ⇒ 首行是本用例里的第二条。两个口径、两个不同的首行: + /// 这条断言同时钉「服务端排整集」与「排的是用户看到的那个数」。 + #[tokio::test] + async fn tx_sort_orders_the_whole_set_not_the_page() { + let st = test_state("txsort"); + let key = login(st.clone()).await; + // 插入序 = id 升序。显示值:neg=-2(支出)→ one=+1 → three=+3 + for (model, ty, pts) in [ + ("neg", "consume", 2.0), + ("one", "topup", 1.0), + ("three", "topup", 3.0), + ] { + let conn = st.db.lock().unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) VALUES (1, 'x', NULL, ?1, 0, ?2, ?3, '成功')", + rusqlite::params![model, pts, ty], + ) + .unwrap(); + } + let first = |body: &str| -> String { + let v: serde_json::Value = serde_json::from_str(body).unwrap(); + v["items"][0]["model"] + .as_str() + .unwrap_or_default() + .to_string() + }; + // 默认(无排序参数):今日行为不变 —— id 降序 + let (s, body) = get( + st.clone(), + "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/api/transactions?type=all&page=1&page_size=10", + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + assert_eq!( + first(&body), + "three", + "无排序参数时必须仍是 id 降序: {body}" + ); + // 升序:首行必须是**显示值最小**的那一行,且它在第 1 页就出现(客户端排序只会重排这一页) + let (s, body) = get( + st.clone(), + "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/api/transactions?type=all&page=1&page_size=2&sort=pts&dir=asc", + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + assert_eq!( + first(&body), + "neg", + "pts 升序的首行应是显示值最小的 -2(不是库内原始 pts 最小的 +1): {body}" + ); + // 第二页接着同一顺序(跨页顺序一致 = 服务端排的是整个集合) + let (s, body) = get( + st.clone(), + "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/api/transactions?type=all&page=2&page_size=2&sort=pts&dir=asc", + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + assert_eq!( + first(&body), + "three", + "第二页应是显示值最大的 +3(跨页顺序一致): {body}" + ); + // 降序 + let (s, body) = get( + st.clone(), + "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/api/transactions?type=all&page=1&page_size=2&sort=pts&dir=desc", + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + assert_eq!( + first(&body), + "three", + "pts 降序的首行应是显示值最大的 +3: {body}" + ); + } + + /// R164 D5:并列时必须由 `t.id DESC` 收尾 —— 分页是 `LIMIT/OFFSET`,非唯一排序会让相邻页的 + /// 边界不确定(同一行出现两次、另一行永不出现)。 + #[tokio::test] + async fn tx_sort_is_stable_across_pages_when_keys_tie() { + let st = test_state("txstable"); + let key = login(st.clone()).await; + for model in ["a", "b", "c", "d"] { + let conn = st.db.lock().unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (1, 'x', NULL, ?1, 0, 1.0, 'consume', '成功')", + rusqlite::params![model], + ) + .unwrap(); + } + let models = |body: &str| -> Vec { + let v: serde_json::Value = serde_json::from_str(body).unwrap(); + v["items"] + .as_array() + .unwrap() + .iter() + .map(|r| r["model"].as_str().unwrap_or_default().to_string()) + .collect() + }; + // 四行 pts 全等 ⇒ 顺序只能来自 `t.id DESC`(插入序的逆序) + let (s, body) = get( + st.clone(), + "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/api/transactions?type=all&page=1&page_size=2&sort=pts&dir=asc", + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let p1 = models(&body); + let (s, body) = get( + st.clone(), + "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/api/transactions?type=all&page=2&page_size=2&sort=pts&dir=asc", + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let p2 = models(&body); + assert_eq!( + p1, + vec!["d".to_string(), "c".to_string()], + "并列时应按 id 降序: {p1:?}" + ); + assert_eq!( + p2, + vec!["b".to_string(), "a".to_string()], + "并列时应按 id 降序: {p2:?}" + ); + // 两页无重叠、并集就是全集(分页锚的直接后果) + let mut all = p1.clone(); + all.extend(p2.clone()); + all.sort(); + assert_eq!( + all, + vec!["a", "b", "c", "d"], + "两页必须无重叠地覆盖全集: {all:?}" + ); + } + + /// R164 D3/D6:未知排序键、非法方向、`sort`/`dir` 长度不匹配一律 400,且**不得**静默回退 + /// 默认排序(回退会让「箭头 ⇔ 顺序」再次脱钩 —— 那正是本轴要修的缺陷本身)。 + #[tokio::test] + async fn tx_sort_rejects_anything_outside_the_whitelist() { + let st = test_state("txsortbad"); + let key = login(st.clone()).await; + for uri in [ + "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/api/transactions?type=all&sort=nope", + "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/api/transactions?type=all&sort=pts&dir=sideways", + "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/api/transactions?type=all&sort=pts&dir=asc,desc", + "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/api/transactions?type=all&sort=pts,pts&dir=asc", + ] { + let (s, body) = get(st.clone(), uri, &key).await; + assert_eq!( + s, + axum::http::StatusCode::BAD_REQUEST, + "`{uri}` 应 400: {body}" + ); + } + // 阳性对照:白名单内的键与方向都 200(否则上面的 400 可能只是「排序参数一律被拒」) + for uri in [ + "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/api/transactions?type=all&sort=pts&dir=asc", + "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/api/transactions?type=all&sort=pts,status&dir=desc,asc", + "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/api/transactions?type=all&sort=time", + ] { + let (s, body) = get(st.clone(), uri, &key).await; + assert_eq!(s, axum::http::StatusCode::OK, "`{uri}` 应 200: {body}"); + } + } } diff --git a/src/state_gate.rs b/src/state_gate.rs index e035b59..c160e3b 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -3692,10 +3692,844 @@ fn pass_min_read(mod_rs: &str, app_js: &str, i18n: &str, proto: &str) -> PassMin } } +// ============================================================================================ +// R164 gate fragment -- splice into `src/state_gate.rs` +// +// PART A (`r164_*` helpers) -> insert BEFORE the line `mod tests {` +// PART B (the tests) -> insert INSIDE `mod tests {`, right after ` use super::*;` +// +// Splice/split is done by `r164_compile_gate.py`, which also COMPILES and RUNS this fragment +// against an explicitly materialized pre-fix tree (`git archive `) and against the landed +// tree -- the discrimination proof travels with the fragment. +// +// WHAT IT PINS (axis: the transactions table's column-header ▲/▼ is a claim about the WHOLE +// dataset; server paging means the ordering has to travel with the request): +// +// The arrow is ONE claim with THREE carriers. `txTable.sort` is the only sort state, and it +// must project into all three; any carrier that lags behind makes the arrow describe a口径 +// nobody executes: +// R1 the LIST request carries it (a projector that emits `&sort=`/`&dir=`, called from the +// `/api/transactions?` line and NOT from the trend line -- row order means nothing for +// time buckets) +// R2 the payload signature covers it (`txQuerySig`, which the reload guard compares: without +// it a header click changes the state, the signature does not, nothing refetches, and the +// arrow moves over a list that does not -- worse than the defect being fixed) +// R3 the local sort steps aside for a table that declares server sorting (the declared flag +// travels with the call site; the condition is read WHOLE -- a paren character class +// cannot see `!(serverPaging && serverSort)`, pitfall #460) +// R4 the server renders `ORDER BY` from a whitelist that IS the column roster, and the user +// string never reaches SQL: +// a. whitelist == the column keys derived from `TX_COLUMNS` (both directions) +// b. the match arms of `tx_sort_expr` == the whitelist (both directions) +// c. the declared array length == the number of literals (self-consistency) +// d. `ORDER BY {ident}` is a placeholder whose `ident` is bound by the whitelist-guarded +// builder, and every `q.sort` / `q.dir` mention lives in that binding +// R5 CONTROL (green on the base tree too): the FILTER half of the same road is real -- +// `serverFilter: true` on the filterable columns, the request calls `txFilterParams()`, +// `TxColFilters` is flattened into the query, and the reload guard compares the +// signature. A scanner whose positive reach is empty proves nothing. +// +// SCOPE, the honest half (#341): the rules are LEXICAL. They prove the SHAPE (three carriers +// agree, the whitelist IS the roster). They do NOT prove that the rows on screen really are +// globally ordered -- that belongs to the jsdom probe (`r164_probe.js`, legs S1-S7, re-run on the +// landed bytes), and the ORDER BY semantics themselves to `src/routes/wallet.rs`'s behaviour +// tests. Shape belongs to the gate; facts belong to the instruments. +// ============================================================================================ + +// ============================= R164 PART A: module-level helpers ============================== + +/// 列表端点的查询参数构造器所在的文件(本门禁的另一半:白名单与 `ORDER BY` 的渲染者)。 +const R164_WALLET: &str = include_str!("routes/wallet.rs"); + +/// 排序状态的**唯一真源**(三处载体都必须读它)。 +const R164_SORT_STATE: &str = "txTable.sort"; + +/// 列名册的**阳性对照**(#451):`TX_COLUMNS` 一漂移就响亮地失败,而不是让「白名单 == 名册」 +/// 这条规则在空集上静默恒真(坑 68 同族)。11 键与 `src/routes/wallet.rs::TX_SORT_KEYS` 同源。 +const R164_COLUMNS: [&str; 11] = [ + "time", "type", "user", "model", "key", "input", "cached", "output", "tokens", "pts", "status", +]; + +/// 一次扫描同时产出五条规则的判决**与它们的证据**(判词与取值两列 —— #339/#341:一个 +/// 期望藏在脚注里的仪器会报出一个自洽的谎)。 +struct R164Reading { + columns: Vec, + whitelist: Vec, + declared_len: usize, + arms: Vec, + projectors: Vec, + guard_flag: Option, + placeholder: Option, + binder: Option, + param_lines: Vec, + server_filters: usize, + r1: bool, + r2: bool, + r3: bool, + r4a: bool, + r4b: bool, + r4c: bool, + r4d: bool, + r5: bool, +} + +impl R164Reading { + /// 三处载体(R1–R4)是否全都就位 —— 轴那一半。 + fn carriers_agree(&self) -> bool { + self.r1 && self.r2 && self.r3 && self.r4a && self.r4b && self.r4c && self.r4d + } + + fn report(&self) -> String { + format!( + "r1={} r2={} r3={} r4a={} r4b={} r4c={} r4d={} r5={} | columns={:?} whitelist={:?} \ + arms={:?} projectors={:?} guard_flag={:?} placeholder={:?} binder={:?} \ + param_lines={:?} server_filters={}", + self.r1, + self.r2, + self.r3, + self.r4a, + self.r4b, + self.r4c, + self.r4d, + self.r5, + self.columns, + self.whitelist, + self.arms, + self.projectors, + self.guard_flag, + self.placeholder, + self.binder, + self.param_lines, + self.server_filters, + ) + } +} + +/// 逐行剥离注释后的整段代码(`code_text_by_line` 会 trim 每行;行数不变)。 +/// +/// 一切**标识符**判定都必须在这上面做:本轮的解释性注释里正写着 `serverSort` 与 +/// `txTable.sort`(坑 #296 的镜像 —— 注释是自己的修法最容易踩的假阳性)。 +fn r164_code(src: &str) -> String { + code_text_by_line(src).join("\n") +} + +/// `needle` 之后**第一个** `{` 起、按花括号配平的整段(含两端花括号)。 +/// +/// 不用 [`decl_spans`]:它只认 JS 的 `function NAME(` / `const NAME = (`,而本轴的另外两半是 +/// Rust(`fn tx_sort_expr` / `fn tx_order_by`);且它的收尾锚在**声明行**上,签名换行的 Rust +/// 函数会被截断。花括号配平对两种语言同样成立(本文件不解析字符串里的括号)。 +fn r164_body_after(src: &str, needle: &str) -> Option { + let at = src.find(needle)?; + let open = src[at..].find('{').map(|i| at + i)?; + let mut depth = 0i32; + for (i, c) in src[open..].char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Some(src[open..open + i + 1].to_string()); + } + } + _ => {} + } + } + None +} + +/// `const TX_COLUMNS = [` 起、按**方括号**配平的数组字面量(含两端方括号)。 +fn r164_columns_region(app: &str) -> Option { + let at = app.find("const TX_COLUMNS = [")?; + let open = app[at..].find('[').map(|i| at + i)?; + let mut depth = 0i32; + for (i, c) in app[open..].char_indices() { + match c { + '[' => depth += 1, + ']' => { + depth -= 1; + if depth == 0 { + return Some(app[open..open + i + 1].to_string()); + } + } + _ => {} + } + } + None +} + +/// 标识符字符(`[A-Za-z0-9_$]`)—— 左界判定与 `mentions_identifier` 同一把尺子。 +fn r164_is_word(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' || c == '$' +} + +/// `body` 里所有 `field: "…"` 的字符串值(**标识符左界**:`sortVal:` / `key_name` 不是它)。 +fn r164_keyed_literals(body: &str, field: &str) -> Vec { + let needle = format!("{field}: \""); + let mut out = Vec::new(); + let mut from = 0usize; + while let Some(rel) = body[from..].find(&needle) { + let at = from + rel; + let before_ok = at == 0 || !r164_is_word(body.as_bytes()[at - 1] as char); + let v_at = at + needle.len(); + let Some(close) = body[v_at..].find('"').map(|i| v_at + i) else { + break; + }; + if before_ok { + out.push(body[v_at..close].to_string()); + } + from = close + 1; + if from >= body.len() { + break; + } + } + out +} + +/// `pub const TX_SORT_KEYS: [&str; N] = [ … ];` ⇒ `(N, 字面量)`。 +fn r164_whitelist(wallet: &str) -> Option<(usize, Vec)> { + let head = "pub const TX_SORT_KEYS: [&str; "; + let at = wallet.find(head)? + head.len(); + let len_end = at + wallet[at..].find(']')?; + let declared: usize = wallet[at..len_end].trim().parse().ok()?; + let arr = wallet[len_end..].find("= [").map(|i| len_end + i)?; + let body = r164_body_between(wallet, arr, '[', ']')?; + Some((declared, r164_string_literals(&body))) +} + +/// 从 `at` 处的开括号起、按 `open`/`close` 配平的区间文本(含两端)。 +fn r164_body_between(src: &str, at: usize, open: char, close: char) -> Option { + let mut depth = 0i32; + for (i, c) in src[at..].char_indices() { + if c == open { + depth += 1; + } else if c == close { + depth -= 1; + if depth == 0 { + return Some(src[at..at + i + 1].to_string()); + } + } + } + None +} + +/// 文本里所有 `"…"` 字面量(本轴的两个表达式串里没有转义引号)。 +fn r164_string_literals(text: &str) -> Vec { + let mut out = Vec::new(); + let mut from = 0usize; + while let Some(rel) = text[from..].find('"') { + let v_at = from + rel + 1; + let Some(close) = text[v_at..].find('"').map(|i| v_at + i) else { + break; + }; + out.push(text[v_at..close].to_string()); + from = close + 1; + if from >= text.len() { + break; + } + } + out +} + +/// `fn tx_sort_expr` 的**匹配臂左值**(`"time" => …` 里的 `time`)。 +/// +/// 判据是「字符串字面量**紧跟** `=>`」:臂体里的表达式串(`"COALESCE(NULLIF(…))"`)后面是 +/// `.to_string()`,不会被算进来。 +fn r164_arm_keys(wallet: &str) -> Vec { + let Some(body) = r164_body_after(wallet, "fn tx_sort_expr(") else { + return Vec::new(); + }; + let mut out = Vec::new(); + let mut from = 0usize; + while let Some(rel) = body[from..].find('"') { + let v_at = from + rel + 1; + let Some(close) = body[v_at..].find('"').map(|i| v_at + i) else { + break; + }; + let rest = body[close + 1..].trim_start(); + if rest.starts_with("=>") { + out.push(body[v_at..close].to_string()); + } + from = close + 1; + if from >= body.len() { + break; + } + } + out +} + +/// 「排序投影器」:体内**同时**发出 `&sort=` 与 `&dir=` 的函数名(调用方要求恰好一个)。 +fn r164_projectors(app: &str) -> Vec { + let spans = decl_spans(app); + let mut out = Vec::new(); + for (name, s, e) in &spans { + let body = &app[*s..*e]; + if body.contains("\"&sort=\"") && body.contains("\"&dir=\"") { + out.push(name.clone()); + } + } + out +} + +/// 含 `needle` 的**代码行**(注释已剥离、逐行 trim)。 +fn r164_lines_with(src: &str, needle: &str) -> Vec { + src.lines() + .map(|l| l.trim().to_string()) + .filter(|l| l.contains(needle)) + .collect() +} + +/// `callee({ … })` 调用的实参对象字面量(花括号配平)。 +fn r164_call_region(app: &str, callee: &str) -> Option { + let at = app.find(&format!("{callee}({{"))?; + let open = at + callee.len() + 1; + r164_body_between(app, open, '{', '}') +} + +/// 对象字面量里所有 `field: true,` 的字段名(调用点**声明**了哪些开关)。 +fn r164_true_flags(region: &str) -> Vec { + let mut out = Vec::new(); + let mut from = 0usize; + while let Some(rel) = region[from..].find(": true,") { + let colon = from + rel; + let name = r164_ident_before(region, colon); + if !name.is_empty() { + out.push(name); + } + from = colon + ": true,".len(); + if from >= region.len() { + break; + } + } + out +} + +/// `at` 之前紧邻的标识符(左界只吃**一个**标识符的字符,遇非标识符字符即停)。 +fn r164_ident_before(text: &str, at: usize) -> String { + let mut start = at; + for (i, c) in text[..at].char_indices().rev() { + if r164_is_word(c) { + start = i; + } else { + break; + } + } + text[start..at].to_string() +} + +/// 含 `needle` 的那个 `if (` 的**完整条件文本**(从 `if (` 扫到配平的 `)`)。 +/// +/// 判据必须是「整条条件」,不是括号字符类的切片:本轴的修复条件**自带括号** +/// (`!(serverPaging && serverSort)`),用 `[^)]*` 取条件的规则会永远取不到它(坑 #460)。 +fn r164_condition_around(body: &str, needle: &str) -> Option { + let hit = body.find(needle)?; + let start = body[..hit].rfind("if (")?; + let open = start + 3; + let mut depth = 0i32; + for (i, c) in body[open..].char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Some(body[open..open + i + 1].to_string()); + } + } + _ => {} + } + } + None +} + +/// `ORDER BY {ident}` 里的 `ident`(字面量 `ORDER BY t.id DESC` ⇒ `None`,那正是旧实现)。 +fn r164_order_placeholder(wallet: &str) -> Option { + let at = wallet.find("ORDER BY {")? + "ORDER BY {".len(); + let close = wallet[at..].find('}').map(|i| at + i)?; + Some(wallet[at..close].to_string()) +} + +/// `let IDENT = CALLEE(` 的 `(IDENT, CALLEE)` —— 只认**含 `q.sort`** 的那一行。 +fn r164_binder(wallet: &str) -> Option<(String, String)> { + for line in wallet.lines() { + let t = line.trim(); + let Some(rest) = t.strip_prefix("let ") else { + continue; + }; + if !rest.contains("q.sort") { + continue; + } + let Some((lhs, rhs)) = rest.split_once('=') else { + continue; + }; + let Some((callee, _)) = rhs.trim().split_once('(') else { + continue; + }; + let ident = lhs.trim(); + let callee = callee.trim(); + if ident.is_empty() || callee.is_empty() { + continue; + } + return Some((ident.to_string(), callee.to_string())); + } + None +} + +/// 排序后的副本(集合比较:`TX_COLUMNS` 的键序与白名单的声明序不承诺一致)。 +fn r164_sorted(v: &[String]) -> Vec { + let mut out = v.to_vec(); + out.sort(); + out +} + +/// 一次读完五条规则(判词 + 证据)。 +fn r164_read(app: &str, wallet: &str) -> R164Reading { + let code = r164_code(app); + let columns = r164_columns_region(&code) + .map(|r| r164_keyed_literals(&r, "key")) + .unwrap_or_default(); + let (declared_len, whitelist) = r164_whitelist(wallet).unwrap_or((0, Vec::new())); + let arms = r164_arm_keys(wallet); + let projectors = r164_projectors(&code); + let projector = match projectors.len() { + 1 => Some(projectors[0].clone()), + _ => None, + }; + // ⚠️ 两行请求行必须**在 `loadTransactions` 体内**取:整文件里 `/api/transactions?` 还有 + // 仪表盘的 `page_size=1` 那一处(更早、且不含排序参数)—— 取「第一处」会把列表请求认成仪表盘。 + let load = r164_body_after(&code, "function loadTransactions(").unwrap_or_default(); + let list_req = r164_lines_with(&load, "\"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/api/transactions?") + .into_iter() + .next() + .unwrap_or_default(); + let trend_req = r164_lines_with(&load, "\"/api/transactions/trend") + .into_iter() + .next() + .unwrap_or_default(); + let sig = r164_body_after(&code, "function txQuerySig(").unwrap_or_default(); + let guard = r164_body_after(&code, "function buildDataTable(").unwrap_or_default(); + let cond = r164_condition_around(&guard, "state.sort.length").unwrap_or_default(); + let flags = r164_call_region(&code, "buildDataTable") + .map(|r| r164_true_flags(&r)) + .unwrap_or_default(); + let guard_flag = flags + .iter() + .find(|f| mentions_identifier(&cond, f)) + .cloned(); + let placeholder = r164_order_placeholder(wallet); + let binder = r164_binder(wallet); + let param_lines = r164_lines_with(wallet, "q.sort"); + let mut param_lines2 = param_lines.clone(); + param_lines2.extend(r164_lines_with(wallet, "q.dir")); + param_lines2.sort(); + param_lines2.dedup(); + let server_filters = r164_columns_region(&code) + .map(|r| r164_lines_with(&r, "serverFilter: true").len()) + .unwrap_or(0); + let reload = r164_body_after(&code, "function renderTransactions(").unwrap_or_default(); + + // R1:投影器存在且**唯一**、它读排序状态、列表请求调它、**趋势不调**它。 + let projector_state = projector + .as_ref() + .and_then(|p| r164_body_after(&code, &format!("function {p}("))) + .map(|b| mentions_identifier(&b, R164_SORT_STATE)) + .unwrap_or(false); + let call = projector.as_ref().map(|p| format!("{p}()")); + let r1 = projector_state + && call + .as_ref() + .map(|c| list_req.contains(c.as_str())) + .unwrap_or(false) + && call + .as_ref() + .map(|c| !trend_req.contains(c.as_str())) + .unwrap_or(false); + + // R2:载荷签名覆盖排序状态(少了它,点列头只改状态、签名不变 ⇒ 守卫不重拉)。 + let r2 = mentions_identifier(&sig, R164_SORT_STATE); + + // R3:本地排序被**调用点声明的**开关豁免(条件读整条)。 + let r3 = guard_flag.is_some() && cond.contains("state.sort.length"); + + // R4a/b/c/d。 + let r4a = !columns.is_empty() && r164_sorted(&columns) == r164_sorted(&whitelist); + let r4b = !arms.is_empty() && r164_sorted(&arms) == r164_sorted(&whitelist); + let r4c = declared_len == whitelist.len() && !whitelist.is_empty(); + let builder_guarded = binder + .as_ref() + .and_then(|(_, callee)| r164_body_after(wallet, &format!("fn {callee}("))) + .map(|b| b.contains("tx_sort_expr(")) + .unwrap_or(false); + let r4d = placeholder.is_some() + && binder + .as_ref() + .map(|(id, _)| *id == placeholder.clone().unwrap_or_default()) + .unwrap_or(false) + && builder_guarded + && param_lines2.len() == 1; + + // R5(对照):同一条路的前一半(列筛选后端化)必须**仍然**是真的。 + // ⚠️「请求带列筛选」的证据是**构造器被调用**(`const cols = txFilterParams()` 在请求行上一行), + // 不是「请求行里出现这六个字」—— 判据必须落在调用上,不能落在某一行的排版上。 + let r5 = server_filters >= 6 + && load.contains("txFilterParams()") + && list_req.contains("cols") + && wallet.contains("#[serde(flatten)]") + && wallet.contains("TxColFilters") + && reload.contains("txQuerySig()"); + + R164Reading { + columns, + whitelist, + declared_len, + arms, + projectors, + guard_flag, + placeholder, + binder: binder.map(|(id, callee)| format!("{id} = {callee}")), + param_lines: param_lines2, + server_filters, + r1, + r2, + r3, + r4a, + r4b, + r4c, + r4d, + r5, + } +} + +// ── 合成夹具:牙齿测试与鉴别力测试都跑在同一份**自足**的迷你源码上(#612:为树 A 写的 +// 声明表对树 B 无效 —— 所以变体树不从真树派生,而是自带一份)。真树由轴测试与 +// `r164_compile_gate.py` 的物化基线腿覆盖。 +// +// 迷你源码的形态与真树**同构**(同样的锚点:`const TX_COLUMNS = [`、`function txSortParams(`、 +// `"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/api/transactions?`、`let order = tx_order_by(q.sort…`、`ORDER BY {order}`), +// 只是把 11 列压到 6 列;`r164_compile_gate.py` 另有一条腿断言修复体的关键片段确实是真树的子串。 + +const R164_MINI_APP: &str = concat!( + "const TX_COLUMNS = [\n", + " { key: \"a\", title: () => T(\"a\"), serverFilter: true },\n", + " { key: \"b\", title: () => T(\"b\"), serverFilter: true },\n", + " { key: \"c\", title: () => T(\"c\"), serverFilter: true },\n", + " { key: \"d\", title: () => T(\"d\"), serverFilter: true },\n", + " { key: \"e\", title: () => T(\"e\"), serverFilter: true },\n", + " { key: \"f\", title: () => T(\"f\"), serverFilter: true }\n", + "];\n", + "const txTable = { sort: [], filters: {} };\n", + "function txFilterParams() {\n return \"\";\n}\n", + "function txSortParams() {\n", + " if (!txTable.sort || !txTable.sort.length) return \"\";\n", + " return \"&sort=\" + txTable.sort.map((s) => s.key) + \"&dir=\" + txTable.sort.map((s) => s.dir);\n", + "}\n", + "function txQuerySig() {\n", + " const srt = (txTable.sort || []).map((s) => s.key).join(\",\");\n", + " return \"x|\" + srt;\n", + "}\n", + "function renderTransactions() {\n", + " if (txTable.loadedQuerySig !== txQuerySig()) reloadTransactions();\n", + "}\n", + "function loadTransactions() {\n", + " const cols = txFilterParams();\n", + " const q = \"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/api/transactions?type=\" + type + (cols ? \"&\" + cols : \"\") + txSortParams();\n", + " const tq = \"/api/transactions/trend?type=\" + type + (cols ? \"&\" + cols : \"\");\n", + " return [q, tq];\n", + "}\n", + "function buildDataTable(cfg) {\n", + " const { container, columns, rows, state, onState, serverPaging, serverSort } = cfg;\n", + " if (state.sort.length && !(serverPaging && serverSort)) {\n", + " data = data.slice().sort(cmp);\n", + " }\n", + "}\n", + "function viewTx() {\n", + " buildDataTable({\n", + " container: $(\"#tx-table\"),\n", + " columns: TX_COLUMNS,\n", + " rows: list,\n", + " state: txTable,\n", + " onState: renderTransactions,\n", + " serverPaging: { total: 1 },\n", + " serverSort: true,\n", + " });\n", + "}\n", +); + +const R164_MINI_WALLET: &str = concat!( + "pub const TX_SORT_KEYS: [&str; 6] = [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\"];\n", + "\n", + "fn tx_sort_expr(key: &str) -> Option {\n", + " Some(match key {\n", + " \"a\" => \"t.a\".to_string(),\n", + " \"b\" => \"t.b\".to_string(),\n", + " \"c\" => \"t.c\".to_string(),\n", + " \"d\" => \"t.d\".to_string(),\n", + " \"e\" => \"t.e\".to_string(),\n", + " \"f\" => \"t.f\".to_string(),\n", + " _ => return None,\n", + " })\n", + "}\n", + "\n", + "fn tx_order_by(sort: Option<&str>, dir: Option<&str>) -> Result {\n", + " let keys = split_keys(sort);\n", + " if keys.is_empty() {\n", + " return Ok(\"t.id DESC\".to_string());\n", + " }\n", + " let mut parts: Vec = Vec::new();\n", + " for key in keys.iter() {\n", + " let expr = tx_sort_expr(key).ok_or_else(bad)?;\n", + " parts.push(format!(\"{expr} ASC\"));\n", + " }\n", + " parts.push(\"t.id DESC\".to_string());\n", + " Ok(parts.join(\", \"))\n", + "}\n", + "\n", + "#[derive(Debug, Deserialize)]\n", + "pub struct TxQuery {\n", + " pub sort: Option,\n", + " pub dir: Option,\n", + " #[serde(flatten)]\n", + " pub filters: TxColFilters,\n", + "}\n", + "\n", + "pub async fn transactions(q: TxQuery) -> Result {\n", + " let order = tx_order_by(q.sort.as_deref(), q.dir.as_deref())?;\n", + " let sql = format!(\"SELECT 1 WHERE {where_sql} ORDER BY {order} LIMIT ?{} OFFSET ?{}\", n + 1, n + 2);\n", + " Ok(sql)\n", + "}\n", +); + +/// 迷你**修复体**(阳性基线):五条规则必须全绿 —— 否则牙齿测试无从谈起。 +fn r164_mini_fixed() -> String { + R164_MINI_APP.to_string() +} + +fn r164_mini_fixed_wallet() -> String { + R164_MINI_WALLET.to_string() +} + +/// 把 `needle` 的**第一次**出现换成 `repl`;锚点不在即 panic(变体无从构造时不许静默失去射程)。 +fn r164_swap(src: &str, needle: &str, repl: &str) -> String { + assert!( + src.contains(needle), + "变体锚点 {needle:?} 不在给定的迷你源码里" + ); + src.replacen(needle, repl, 1) +} + #[cfg(test)] mod tests { use super::*; + /// R164 轴:三处载体(请求 / 载荷签名 / 本地排序守卫)+ 服务端白名单必须**同源**, + /// 且同一条路的前一半(列筛选后端化)必须仍然是真的。 + #[test] + fn the_sort_indicator_and_the_order_by_share_one_source() { + let r = r164_read(APP_JS, R164_WALLET); + println!("R164 real tree: {}", r.report()); + assert!( + r.r1, + "列表请求没有携带排序状态(或趋势请求也带了):projectors={:?}", + r.projectors + ); + assert!( + r.r2, + "载荷签名没覆盖排序状态 —— 点列头只改状态、守卫不重拉,箭头动了而列表不动" + ); + assert!( + r.r3, + "本地排序没有为「声明了服务端排序的表」让路:guard_flag={:?}", + r.guard_flag + ); + assert!( + r.r4a, + "白名单与列名册不是同一份名单:columns={:?} whitelist={:?}", + r.columns, r.whitelist + ); + assert!( + r.r4b, + "`tx_sort_expr` 的臂与白名单不同源:arms={:?} whitelist={:?}", + r.arms, r.whitelist + ); + assert!( + r.r4c, + "`TX_SORT_KEYS` 声明的长度({})与字面量个数({})不一致", + r.declared_len, + r.whitelist.len() + ); + assert!( + r.r4d, + "`ORDER BY` 不是由白名单守卫的构造器渲染:placeholder={:?} binder={:?} param_lines={:?}", + r.placeholder, r.binder, r.param_lines + ); + assert!( + r.r5, + "阳性对照失效:列筛选那一半(serverFilter / txFilterParams / TxColFilters / 签名守卫)不见了 \ + —— 扫描器的正射程为空时,四条规则都在说没有" + ); + } + + /// R164 阳性对照:列名册与白名单都必须是**真的**(声明的地面真值 —— 漂移时响亮失败, + /// 而不是让「白名单 == 名册」在空集上静默恒真)。 + #[test] + fn the_r164_roster_is_real() { + let app = r164_code(APP_JS); + let cols = r164_columns_region(&app).expect("`TX_COLUMNS = [` 必须还在(名册无从派生)"); + let keys = r164_keyed_literals(&cols, "key"); + let want: Vec = R164_COLUMNS.iter().map(|s| s.to_string()).collect(); + assert_eq!( + keys, want, + "`TX_COLUMNS` 的可排序列漂移了 —— 白名单与名册必须一起改(不是只改一边)" + ); + let (declared, list) = r164_whitelist(R164_WALLET).expect("`TX_SORT_KEYS` 必须还在"); + assert_eq!(declared, list.len(), "声明的长度必须等于字面量个数"); + assert_eq!( + r164_sorted(&list), + r164_sorted(&keys), + "白名单必须与列名册逐键相同" + ); + let arms = r164_arm_keys(R164_WALLET); + assert_eq!( + r164_sorted(&arms), + r164_sorted(&keys), + "匹配臂必须覆盖同一份白名单" + ); + } + + /// R164 牙齿:每一条规则都有一副**只翻它自己**的牙(合成迷你源码,自足于真树)。 + #[test] + fn the_r164_rules_have_teeth() { + // (0) 阳性基线:迷你修复体必须五条全绿,否则下面的「变红」什么也证明不了。 + let base = r164_read(&r164_mini_fixed(), &r164_mini_fixed_wallet()); + assert!( + base.carriers_agree() && base.r5, + "迷你修复体本身就不绿:{}", + base.report() + ); + + // (1) R1 —— 列表请求不再带排序参数。 + let app = r164_swap(&r164_mini_fixed(), " + txSortParams();", ";"); + let r = r164_read(&app, &r164_mini_fixed_wallet()); + assert!( + !r.r1 && !r.carriers_agree() && r.r2 && r.r3 && r.r4d && r.r5, + "R1 的牙不独立:{}", + r.report() + ); + + // (2) R1 —— 反向:趋势请求也带上了排序参数(行序对时间桶没有语义)。 + let app = r164_swap( + &r164_mini_fixed(), + "(cols ? \"&\" + cols : \"\");\n return [q, tq];", + "(cols ? \"&\" + cols : \"\") + txSortParams();\n return [q, tq];", + ); + let r = r164_read(&app, &r164_mini_fixed_wallet()); + assert!( + !r.r1 && r.r2 && r.r3 && r.r4a && r.r4d && r.r5, + "R1 没挡住「趋势也带排序」:{}", + r.report() + ); + + // (3) R2 —— 载荷签名丢掉排序状态。 + let app = r164_swap( + &r164_mini_fixed(), + " const srt = (txTable.sort || []).map((s) => s.key).join(\",\");\n return \"x|\" + srt;\n", + " return \"x\";\n", + ); + let r = r164_read(&app, &r164_mini_fixed_wallet()); + assert!( + !r.r2 && r.r1 && r.r3 && r.r4a && r.r4b && r.r4c && r.r4d && r.r5, + "R2 的牙不独立:{}", + r.report() + ); + + // (4) R3 —— 守卫回到「一律本地排序」。 + let app = r164_swap( + &r164_mini_fixed(), + "if (state.sort.length && !(serverPaging && serverSort)) {", + "if (state.sort.length) {", + ); + let r = r164_read(&app, &r164_mini_fixed_wallet()); + assert!( + !r.r3 && r.r1 && r.r2 && r.r4a && r.r4b && r.r4c && r.r4d && r.r5, + "R3 没读到完整条件:{}", + r.report() + ); + + // (5) R4a —— 只把**列名册**改掉(白名单与臂仍彼此一致)。 + let app = r164_swap(&r164_mini_fixed(), "{ key: \"f\"", "{ key: \"z\""); + let r = r164_read(&app, &r164_mini_fixed_wallet()); + assert!( + !r.r4a && r.r4b && r.r4c && r.r4d && r.r1 && r.r2 && r.r3 && r.r5, + "R4a 的牙不独立:{}", + r.report() + ); + + // (6) R4b —— 只给 `tx_sort_expr` 加一个白名单外的臂。 + let wallet = r164_swap( + &r164_mini_fixed_wallet(), + " _ => return None,", + " \"z\" => \"t.z\".to_string(),\n _ => return None,", + ); + let r = r164_read(&r164_mini_fixed(), &wallet); + assert!( + !r.r4b && r.r4a && r.r4c && r.r4d && r.r1 && r.r5, + "R4b 的牙不独立:{}", + r.report() + ); + + // (7) R4c —— 只把声明的长度改错。 + let wallet = r164_swap(&r164_mini_fixed_wallet(), "[&str; 6]", "[&str; 7]"); + let r = r164_read(&r164_mini_fixed(), &wallet); + assert!( + !r.r4c && r.r4a && r.r4b && r.r4d && r.r1 && r.r5, + "R4c 的牙不独立:{}", + r.report() + ); + + // (8) R4d —— `ORDER BY` 回到字面量(用户串不再经白名单守卫)。 + let wallet = r164_swap( + &r164_mini_fixed_wallet(), + "ORDER BY {order} LIMIT", + "ORDER BY t.id DESC LIMIT", + ); + let r = r164_read(&r164_mini_fixed(), &wallet); + assert!( + !r.r4d && r.r4a && r.r4b && r.r4c && r.r1 && r.r2 && r.r3 && r.r5, + "R4d 的牙不独立:{}", + r.report() + ); + + // (9) R4d —— 反向:查询参数在**绑定之外**又被采一次(白名单被绕过)。 + let wallet = r164_swap( + &r164_mini_fixed_wallet(), + " let sql = format!(\"SELECT 1 WHERE {where_sql} ORDER BY {order} LIMIT ?{} OFFSET ?{}\", n + 1, n + 2);", + " let sql = format!(\"SELECT 1 WHERE {where_sql} ORDER BY {order} LIMIT ?{} OFFSET ?{}\", n + 1, n + 2);\n let raw = format!(\"{}\", q.dir.as_deref().unwrap_or(\"\"));", + ); + let r = r164_read(&r164_mini_fixed(), &wallet); + assert!( + !r.r4d && r.r4a && r.r4b && r.r4c && r.r5, + "R4d 没挡住「参数在绑定之外又被采一次」:{}", + r.report() + ); + + // (10) R5(对照)—— 丢掉 `#[serde(flatten)]`:只有对照翻红,四条规则不受影响 + // (证明对照是**独立**的一条腿,不是四条规则的同义反复)。 + let wallet = r164_swap(&r164_mini_fixed_wallet(), " #[serde(flatten)]\n", ""); + let r = r164_read(&r164_mini_fixed(), &wallet); + assert!(!r.r5 && r.carriers_agree(), "R5 的牙不独立:{}", r.report()); + + // (11) 提取器的形态牙:`sortVal:`/`key_name` 不是 `key: "…"`,兄弟标识符不算证据。 + assert_eq!( + r164_keyed_literals(" { sortVal: (t) => t.x, key: \"k\" }", "key"), + vec!["k".to_string()], + "标识符左界失效(`sortVal` 被当成了 `key`)" + ); + assert!( + !mentions_identifier(" txTable.sortBy = [];", R164_SORT_STATE), + "兄弟标识符被当成了排序状态(#333)" + ); + } + + // =============================== END OF R164 GATE FRAGMENT ================================== + /// 轴:口令下限**用「位」宣告、用「字节」执行**(R96)。 /// /// 四条规则的含义见 `PassMinRead` 的文档注释。本测试只断言「四条同时成立」; diff --git a/ui/README.md b/ui/README.md index e153e59..4ea40d3 100644 --- a/ui/README.md +++ b/ui/README.md @@ -870,6 +870,33 @@ function txQuerySig() { // 只此一处定义「载荷是 也不证明 `URLSearchParams` 解出的跨度与请求里的 `now` 逐毫秒一致 —— 那些由 jsdom 探针 在它所跑的那棵树上覆盖。 +## 交易表的列头箭头:**一个主张、三处载体**(R164) + +**契约**:`txTable.sort` 是排序状态的**唯一真源**,而列头的 ▲/▼ 描述的是**整个数据集**的顺序 +—— 既然列表由服务端分页(`pageRows = data`),这个主张就必须同时投影到三处,缺一处箭头就在撒谎: + +1. **列表请求**带 `&sort=` / `&dir=`(`txSortParams()`)—— 且**只**接在列表请求上:趋势按时间桶 + 聚合,行序对它的语义没有意义(对趋势也发排序参数=又一个「口径被搬走」); +2. **载荷签名** `txQuerySig()` 覆盖它 —— 少了这一段,点列头只改状态、签名不变 ⇒ 守卫认定载荷没变、 + 不重拉 ⇒ **箭头动了而列表不动**(比原缺陷更坏,C2146 同族); +3. **本地排序为声明了 `serverSort` 的表让路**(`if (state.sort.length && !(serverPaging && serverSort))`) + —— 开关由**调用点**声明,条件必须**整条**读(`!(a && b)` 自带括号)。 + +**为什么**:`#135` 把交易列表改成服务端分页后,本地排序只排**传进来的那一页**,而箭头/`tx.sort.title` +说的是整张表 —— 排序停在它出生的地方(静态原型 #7),分页没有跟着走。 + +**服务端**:用户串**永不**进入 `ORDER BY`。`src/routes/wallet.rs::TX_SORT_KEYS` 是唯一白名单 +(11 键,与 `ui/js/app.js::TX_COLUMNS` 的 `key` **逐键相同**),`tx_sort_expr` 翻成表达式, +`tx_order_by` 渲染并**恒**在尾部加 `, t.id DESC`(分页锚:非唯一排序会让相邻页的边界不确定 —— +同一行出现两次、另一行永不出现);未知键 / 键与方向个数不匹配 / 非法方向 ⇒ 400,且与 `type` +同款「先校验、后取锁」。 + +**射程**(#341):门禁 `state_gate::the_sort_indicator_and_the_order_by_share_one_source` 是**词法**的 +—— 它证明三处载体都读同一个状态、白名单与列名册**精确相等**、`ORDER BY {…}` 由白名单守卫的构造器 +渲染、`q.sort`/`q.dir` **只**在那一处绑定里被采。它**不**证明运行期行真的按全局顺序排(那是 jsdom +探针 `r164_probe.js` 的职责,`cargo test` 里没有 JS 运行器),也不证明 `ORDER BY` 语义本身 +(那是 `src/routes/wallet.rs` 的行为测试)。 + ## 共享行的「动作」与「结局文案」必须由同一条目给出(C2153) 共享 key 有**三个**状态,三个都可达:`PATCH /api/sharings/:id` 接受 `on` / `paused` / `off` diff --git a/ui/index.html b/ui/index.html index 65c151e..1ee6e11 100644 --- a/ui/index.html +++ b/ui/index.html @@ -855,6 +855,6 @@

使用模型

- + diff --git a/ui/js/app.js b/ui/js/app.js index cc006b3..99ac8d3 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -1704,6 +1704,8 @@ onState: renderTransactions, // 真后端分页(rant 2026-08-24T10:51:57):总数显示后端 total;页码点击 → onState → renderTransactions 页码不一致自动重拉 serverPaging: Live.transactions ? { total: Live.transactions.total || 0 } : null, + // R164:排序交给服务端执行(请求带 sort/dir)—— 理由见 buildDataTable 里的本地排序守卫。 + serverSort: true, }); } @@ -1763,6 +1765,16 @@ const sel = document.querySelector('#tx-table select[data-filter-key="type"]'); if (sel) sel.value = txTable.filters.type; } + // 排序 → 请求参数(R164):与 `txFilterParams` / `txRangeParams` 同款「一份状态投影成查询串」。 + // 服务端只接受白名单键(`TX_SORT_KEYS`),未知键 400;这里只发状态里已有的键。 + // 只接在**列表**请求上:趋势按时间桶聚合,行序对它的语义没有意义。 + function txSortParams() { + if (!txTable.sort || !txTable.sort.length) return ""; + const keys = txTable.sort.map((s) => s.key).join(","); + const dirs = txTable.sort.map((s) => s.dir).join(","); + return "&sort=" + encodeURIComponent(keys) + "&dir=" + encodeURIComponent(dirs); + } + // 载荷签名(C2146):**决定请求体的全部状态** —— 列筛选 + 时间段(范围值 + 自定义起止)。 // 它必须覆盖每一个会改变载荷的输入,否则控件改完没人重拉,只能由调用方**补一次显式拉取**, // 而那个补丁会在「重拉触发条件也成立」时并发第二次请求(C2112 已为顶部 tab 记下这个坑)。 @@ -1771,7 +1783,10 @@ function txQuerySig() { const f = txTable.filters || {}; const cols = Object.keys(f).sort().map((k) => k + "=" + String(f[k] == null ? "" : f[k])).join("&"); - return cols + "|" + txRange + "|" + txCustomStart + "|" + txCustomEnd; + // R164:排序是**载荷的一部分**(随请求发出、由服务端执行)。少了这一段,点列头只改状态、 + // 签名不变 ⇒ 守卫认定载荷没变、不重拉 ⇒ 箭头变了而列表不动(比原缺陷更坏)。 + const srt = (txTable.sort || []).map((s) => s.key + ":" + s.dir).join(","); + return cols + "|" + txRange + "|" + txCustomStart + "|" + txCustomEnd + "|" + srt; } // 交易载荷的**唯一重拉触发器**(C2146):控件的状态一改就调它一次 —— 页码重置与重拉是同一个 @@ -1795,7 +1810,7 @@ const cols = txFilterParams(); // rant 2026-08-25T10:33:26:列筛选随请求发出,后端全量过滤 const page = Math.max(1, txTable.page || 1); const pageSize = Math.min(100, Math.max(1, txTable.pageSize || 10)); - const q = "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/api/transactions?type=" + type + "&page=" + page + "&page_size=" + pageSize + (range ? "&" + range : "") + (cols ? "&" + cols : ""); + const q = "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/api/transactions?type=" + type + "&page=" + page + "&page_size=" + pageSize + (range ? "&" + range : "") + (cols ? "&" + cols : "") + txSortParams(); // 趋势图数据(rant 2026-08-23T16:01:07 需求 2):同列筛选口径 const bucket = txTrendBucket(); const tq = "/api/transactions/trend?type=" + type + "&bucket=" + bucket + (range ? "&" + range : "") + (cols ? "&" + cols : ""); @@ -2026,13 +2041,15 @@ } function buildDataTable(cfg) { - const { container, columns, rows, state, onState, serverPaging } = cfg; + const { container, columns, rows, state, onState, serverPaging, serverSort } = cfg; // 1) 筛选 let data = filterRows(rows, columns, state.filters); // 2) 排序(多列:Shift 点击叠加) - if (state.sort.length) { + // R164:声明了 `serverSort` 的表由**服务端**排序(请求带 sort/dir,SQL 的 ORDER BY 由白名单 + // 渲染)⇒ 跳过本地排序。本地只排传进来的那一页,而列头的 ▲/▼ 说的是整张表 —— 两者必须同源。 + if (state.sort.length && !(serverPaging && serverSort)) { data = data.slice().sort((a, b) => { for (const sk of state.sort) { const col = columns.find((c) => c.key === sk.key);