Skip to content

fix(auth): count the password minimum in characters, not UTF-8 bytes - #283

Merged
argszero merged 1 commit into
mainfrom
fix/password-minimum-in-characters
Sep 22, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/password-minimum-in-characters

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The password minimum is announced in characters in four places but was enforced in UTF-8
bytes
on the server. This PR makes one unit everywhere.

One rule, four carriers, all naming characters (none of them is touched — rewording them to
"bytes" would write the defect into the documentation):

carrier text
design baseline docs/prototype/aitokenpool-console.html placeholder 至少 8 位
ui/js/i18n.js zh pack err.weakPassword = 密码至少 8 位
ui/js/i18n.js en pack err.weakPassword = Password must be at least 8 characters
ui/js/i18n.js ERR_MAP literal 密码至少 8 位 → err.weakPassword (the wording the server returns)

One implementation counted something else: src/routes/mod.rs used String::len() — the byte
length — at three request-validation sites, so the effective minimum was 8 bytes ≈ 2–3 CJK
characters. Concretely, 密码abc (5 characters / 9 bytes) was accepted by the API while the
API's own error message promises 8 characters, and while the app's own forgot-password form
refused the very same password.

Related Issue

No issue: this is a defect found by the task's own recon, tracked in this project's ledger. The
linked-issue field is expected to stay empty.

Changes

  • src/routes/mod.rs
    • new single source of truth MIN_PASSWORD_CHARS + password_too_short(pw) -> bool, counting
      pw.chars().count();
    • three sites (register / reset-password / change-password) now go through it instead of
      pw.len() < 8;
    • two boundary tests in mod tests:
      • password_minimum_is_counted_in_characters_not_bytes — pins the unit; three of its five
        samples are passwords the byte-measured guard would have let through, and it reads the
        announced N out of the two language packs instead of hardcoding it;
      • register_rejects_a_password_short_in_characters_long_in_bytes — pins the end-to-end
        behaviour at the register endpoint (密码abc ⇒ 400 密码至少 8 位).
  • ui/js/app.js — the forgot-password guard now counts Unicode scalar values
    (Array.from(pw).length < MIN_PW_CHARS). .length is UTF-16 code units, which splits an astral
    character in two: 😀😀😀😀 reads 8 there and would have been accepted, while the server now
    counts 4.
  • src/state_gate.rs — a new lexical gate,
    the_password_minimum_is_counted_in_the_unit_its_message_names, with four rules that each have
    their own teeth:
    1. every password-shaped site in the production region goes through the helper, and the helper's
      own counting expression must be the unit the carriers announce;
    2. the client guard must count with Array.from(...) — no bare pw.length <;
    3. the four carriers must agree with each other on both N and unit, and N must equal the
      Rust constant (read from the source, never written down in the gate);
    4. the site shape must stay 3 server + 1 client, so deleting a site is not a fix.
  • ui/README.md — the convention, its four carriers, and the honest scope of the gate.
  • ui/index.html — cache-bust token bump (js/app.js?v= 20260922-5 → 20260922-6).

Tests

  • cargo test — 336 passed, 0 failed (baseline on main: 331; +3 gate tests, +2 boundary tests)
  • cargo fmt --check — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • New unit tests added (the two boundary tests above; the gate ships its own self-proof)

Two independent instruments were run against the bytes that ship, not against a paraphrase of them:

  • Compiler gate — splices the state_gate fragment into a materialized baseline
    (git archive of main) and compiles/runs it twice: the un-fixed tree must fail exactly the
    axis test and the teeth test (all other 42 pass), the sheet-applied tree must be fully green.
    23/23 legs as declared, including a clippy-driver -D warnings leg and a leg that fails loudly if
    that driver is missing. Disarming the site-shape rule inside the gate flips exactly the test it
    should (negative control), 21/21 legs as declared.
  • jsdom probe on the landed tree (the app's real scripts, real #forgot-link, real submit):
    5/5 legs as declared; mutating the guard back to pw.length < 8 on the shipped bytes turns
    exactly the axis leg red (😀😀😀😀 gets past the form and a reset request leaves the client),
    and a different spelling of the same contract ([...pw].length) passes all of them — the legs
    test the contract, not the spelling.

Checklist

  • branch name follows the convention (fix/…)
  • Conventional Commits (fix(auth): …)
  • single responsibility, minimal diff (no new i18n key, no schema or config change)

The product announces one rule in four places — "the password must be at
least 8 characters" (the register placeholder in `ui/index.html`, both
the zh and en `err.weakPassword` packs, and the `ERR_MAP` literal that
carries the server's own wording) — but the server enforced it with
`String::len()`, i.e. UTF-8 BYTES, at three request-validation sites
(register / reset-password / change-password).

So a 5-character password such as `密码abc` (5 characters, 9 bytes) was
accepted by the API while the API's own error message promises 8
characters, and while the app's own forgot-password form refused the very
same password.

Fix: one unit everywhere.

  * `src/routes/mod.rs` — a single source of truth, `MIN_PASSWORD_CHARS`
    + `password_too_short()`, used by all three sites. No new i18n key:
    the four carriers were already correct and are deliberately left
    untouched — rewording them to "bytes" would write the defect into the
    documentation.
  * `ui/js/app.js` — the form guard now counts Unicode scalar values
    (`Array.from(pw).length`), matching Rust `chars().count()` instead of
    `.length`, which is UTF-16 code units and splits an astral character
    in two.

Tests: two boundary tests in `src/routes/mod.rs` — one pins the unit
(including the emoji case that only the byte-measured guard lets
through) and reads the announced N from the two packs instead of
hardcoding it; one pins the end-to-end behaviour at the register
endpoint. A `state_gate` rule derives the four carriers, the two
implementations and the 3+1 site shape from the tree.
@argszero

Copy link
Copy Markdown
Owner Author

Self-review (the task's own role forbids self-approval, so this is a plain comment).

Reviewed the diff against the tree it ships in:

  • src/routes/mod.rs — three validation sites replaced 1-for-1; no other .len() shape is left in
    the production region (len() < count = 0). No schema, config or i18n key moved: the four
    carriers were already correct, so the diff is 3 sites + 1 helper + 1 const + 2 tests.
  • ui/js/app.js — only the forgot-password guard changed; the register form keeps its !pw check
    (by design: it surfaces the server's own message through ERR_MAP).
  • src/state_gate.rs — gate fragment appended; rules 1–4 derive their inputs from the tree
    (carriers, const values, site lists), and rule 3 reads N from the packs instead of writing it
    down, so a future pack edit cannot silently agree with a hardcoded number.
  • ui/README.md — records the convention and states the gate's scope honestly: it is lexical
    (unit vs unit), and the runtime facts belong to the boundary tests / the jsdom probe.

Evidence gathered before opening this PR (all against the shipped bytes, head 7caa379):

  • cargo test 336 passed / 0 failed (baseline main = 331).
  • cargo fmt --check clean; cargo clippy --all-targets -- -D warnings clean.
  • compiler gate on a materialized main baseline: 23/23 legs as declared — the un-fixed tree fails
    exactly the axis test and the teeth test, the sheet-applied tree is fully green; the rule-4
    disarm (negative control) flips exactly the teeth test, 21/21 legs as declared.
  • jsdom probe on the landed tree: 5/5 legs as declared; reverting the guard to pw.length < 8 on
    the shipped bytes turns exactly the axis leg red, while a different spelling of the same contract
    ([...pw].length) passes every leg.

Nothing is left open, and no acceptance item depends on host-side work.

@argszero
argszero merged commit 52aec72 into main Sep 22, 2026
1 check passed
@argszero
argszero deleted the fix/password-minimum-in-characters branch September 22, 2026 00:26
@argszero argszero mentioned this pull request Sep 24, 2026
10 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant