fix(ui): take api.js error text from the language pack instead of CJK literals - #183
Merged
Merged
Conversation
… literals
`ui/js/api.js` is the request choke point (all `api.*` call sites flow through it)
and the only place that both builds error messages and calls `mapErr`. But it is
outside `src/i18n_pack.rs`'s input surface, so its matching form `mapErr("中文")`
escapes all three i18n assertions for three different reasons: the key-existence
gate sees both packs contain the key; the key-usage gate only recognises
`T("literal")`; the placeholder gate never opens the file.
Measured against real dev responses: the backend returns non-JSON (`400`/`415`
text/plain) and empty (`404`/`405`) bodies, so the fallback branch is reachable,
not defensive. Running the real files in EN mode over those byte-for-byte bodies
leaked Chinese in 6 of 9 shapes ("请求失败(HTTP 404/405/400/415/500)",
"登录已过期,请重新登录").
The 401 literal was not merely dead: its ZH value is byte-identical to the
existing key `login.session.expired`, which `__atpLogout` renders - so one
user-visible sentence was translated when reached via the status hook and
Chinese when reached via the message.
Fix: `api.js` now holds keys, not copy.
- `T("err.network")`, `T("login.session.expired")`, `T("err.http", { n })`
replace the three CJK literals; `mapErr` now applies only to the
backend-supplied message (which is what an error table is for).
- `T` is a hoisted function declaration, not `const T = window.t` as in
app.js: index.html loads api.js *before* i18n.js, so a module-level capture
would bind permanently to undefined. The name is reused deliberately so the
shared `T("literal")` scanner (pitfall 75) recognises these call sites.
- One new key `err.http` (ZH keeps today's exact wording, so zh output is
byte-identical); the 401 half reuses an existing key, so no second key.
- The fallback must not go through `ERR_MAP`: `mapErr` ends in `t(key)` with no
`vars`, so a value containing `{n}` would render a literal `{n}` forever.
New tests (test-only, `include_str!`, no production code, no new dependency):
- `api_client_error_text_is_key_based` - (a) no non-ASCII in api.js outside
comments, (b) every key literal resolves in both packs, (c) every placeholder
a used key needs is supplied by its call site.
- `api_js_checker_detects_injected_defects` - counting-neutral injected corpora
proving each assertion can fail; comments must stay exempt.
Verification: A/B over 9 response shapes x {en, zh}: en 6 leaks -> 0, zh
byte-identical, exactly 6 values changed; 3/3 positive controls still translate
before and after; a counting-neutral CJK injection reproduces the leak and fails
exactly the one new assertion. cargo test 167 passed, fmt/clippy clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ui/js/api.jsis the request choke point — everyapi.get/post/patch/delcall site flows through itsrequest(), and it is the only place that both builds error messages and feeds them tomapErr(). But it is not insrc/i18n_pack.rs's input surface (I18N_JS/INDEX_HTML/APP_JS), so a Chinese literal written there escapes all three i18n assertions for three different reasons:api.jsT("literal"), notmapErr("中文")scan_t_call_sites/scan_static_attributesnever open this fileConsequence, measured rather than inferred: in EN mode the user gets Chinese. The backend genuinely returns non-JSON bodies (
400/415withcontent-type: text/plain) and empty bodies (404/405— verified live against dev), so the fallback branch is reachable, not defensive. Running the realapi.js+ reali18n.jsover those byte-for-byte responses leaked CJK in 6 of 9 shapes:The 401 literal was not merely dead code: its ZH value is byte-identical to the existing key
login.session.expired, which__atpLogoutrenders (app.js:2932). So a single user-visible sentence was English when reached via the status hook and Chinese when reached via the thrown message — the defect was the divergence between two render paths.Provenance (
git log -S): the literals were introduced by8bd1063(#78, P2-A — which is whatapi.js:1'srant 2026-08-18T11:49:52citation points at).68f9f70(#86, Rant 2026-08-18T20:49:22, "i18n") wrapped 2 of the 3 literals feedingmapErrand left the third unwrapped while adding themapErr(message)line — a same-commit omission inside a real rant's scope, not an orphaned-by-a-sibling-commit case.Related Issue
None (no open issue covers this; the direction came from auditing which files the i18n gates actually read).
Changes
ui/js/api.js: the three CJK literals become key references —T("err.network"),T("login.session.expired"),T("err.http", { n: resp.status }).mapErrnow applies only to the backend-suppliedmessage(which is what an error table is for). After this change the file contains zero CJK string literals, so the new invariant needs no exemption list.ui/js/api.js: the translator is a hoisted function declaration, deliberately notconst T = window.tas inapp.js—index.htmlloadsapi.js(line 845) beforei18n.js(line 847), so a module-level capture would bind permanently toundefined. The nameTis reused on purpose so the gate's existingT("literal")recogniser applies to these call sites (adding a second recognition rule would make the two gates measure different sets — pitfall 75).ui/js/i18n.js: one new keyerr.httpin both packs (请求失败(HTTP {n})/Request failed (HTTP {n})). The ZH value keeps today's exact wording, which is what makes the zh-output byte-comparison below a meaningful control. The 401 half needs no new key becauselogin.session.expiredalready carries that literal.src/i18n_pack.rs:API_JSadded as a gate input + two new test-only assertions (no production code, no new dependency).ui/README.md: key count 785 → 786; documented the "api.js holds keys, not copy" convention.ui/index.html: cache-bust-4→-5.Why the fallback must not go through
ERR_MAPmapErrends int(ERR_MAP[best][1])with novarsargument, so a value containing{n}passed through it would render a literal{n}forever. The division of labour is therefore:mapErrfor backend prose, a directt(key, { n })forapi.js's own text. Verified:t("err.http")→"Request failed (HTTP {n})",t("err.http", {n:500})→"Request failed (HTTP 500)".Tests
cargo test全部通过 — 167 passed (165 before + 2 new)cargo fmt --check通过cargo clippy --all-targets -- -D warnings通过New assertions (both test-only,
include_str!, zero production code, zero new dependency):api_client_error_text_is_key_based— (a)api.jscontains no non-ASCII outside comments (comment-aware stripping, so a same-linecode; // 注释mix is still caught); (b) every key literal inapi.jsresolves in both packs; (c) every placeholder a used key declares is supplied by its call site.api_js_checker_detects_injected_defects— counting-neutral injected corpora proving each assertion can actually fail (a CJK literal, a same-line code+comment mix, a mistyped key, a placeholder withoutvars), plus the positive controls that comments must stay exempt and a real key must not be reported.Evidence (A/B over 9 response shapes × {en, zh}, real files,
vmsandbox,fetchstubbed with live-captured bodies):HEAD)The 3 positive controls (a mapped
400JSON, a mapped502JSON, and the fetch-rejection path) translate correctly before and after, so the probe discriminates rather than just reporting "0".Teeth proof: injecting a single CJK literal back into a temp copy of
api.js— a change that alters no counts — makes exactly the new assertion fail and the leak reappear in the probe (1/9, with 2/3 controls intact). The file was restored byte-identically afterwards and re-verified.Deliberate boundaries (not oversights)
err.loadFailremains on its 15 directT("err.loadFail")call sites and must not enterERR_MAP.服务器内部错误/内部错误producers have 0 production sources insrc/, so they are unreachable rather than missed.amount/name/reason(app.js:2638/3659/1224), so the backendamount 必须大于 0is client-unreachable.err.genericwas not added: the fallback branch always has aresp.status, so a status-less variant would be unreachable — adding a mapping nobody can reach is unverifiable work.Checklist
fix/)