fix(ui): quote a CSV cell that carries a bare CR (RFC 4180 §2.6) - #275
Conversation
`exportTxCsv` writes an RFC 4180 file whose record separator is `\r\n`,
but its cell escaper's special-character class was `/[",\n]/` — CR was
missing. A field carrying a bare CR was therefore written unquoted, and a
reader that treats a bare CR as a line break (Excel's universal-newline
reading) splits one record into two: the row count grows, the widths go
ragged and the cell's tail is lost. Only a client talking to the API
directly can put a CR in `user_name` / `key_name` — there is no form that
accepts one — which is the same class of input as C2043.
- `ui/js/app.js`: `/[",\r\n]/`, citing RFC 4180 §2.6. Still the only
quoting implementation, still conditional (quote on demand) — the
quote-doubling half and the ternary are unchanged.
- `src/state_gate.rs`: `the_csv_cell_escaper_quotes_every_rfc4180_special`
(four rules, each with its own isolating A/B arm) plus
`the_csv_escaper_scanners_have_teeth` (synthetic self-check: the five
malformed shapes must report *different* errors, and the classifier
compares element tokens rather than substrings — `\r\n` contains `\n`).
- `ui/index.html`: cache-bust bump for the app.js change.
- `ui/README.md`: name CR among the escaped specials and record the
gate's lexical scope.
Verification. Gate instrument: all 7 arms as declared (base reddens on
the axis rule; comment-only, verdict-discarded, second-implementation,
always-quote and tail-`|` mutations all rejected; clippy clean on both
trees). jsdom probe driving the real `#tx-export-btn`: 10/10 on the fixed
tree; on the unfixed tree 10/10 as declared with {A1,A2,A3,A4} red
(4 records, widths [11,11,3,9], cell truncated to `Bob`); the
"verdict discarded" competing fix is rejected (axis green, shape leg red).
`cargo test` 310 -> 312 passed, `cargo fmt --check` clean,
`cargo clippy --all-targets -- -D warnings` clean.
Self-review (author is the committer on this repo)Verified before merging, on the pushed head Gate (lexical). Spliced
Probe (runtime). jsdom, real
Local. Issue linkage. |
Summary
The transactions CSV exporter escapes a cell only when it contains
,,"or LF — but the exporterjoins records with CRLF (
"\r\n"). A field carrying a bare CR therefore goes out unquoted, andone record becomes two: the exported file no longer round-trips.
This adds CR to the escape class (
/[",\n]/→/[",\r\n]/, the RFC 4180 §2.6 set), names CR in theui/README.mdexport convention, and adds a gate so the four special characters cannot drift apart again.Related Issue
Changes
ui/js/app.js(exportTxCsv, thecell()helper): add CR to the escape class, with a commentexplaining why CR is part of the set. The ternary shape ("quote on demand") is deliberately kept.
ui/README.md: the CSV export convention now names CR (\r) explicitly instead of only "newline",and records the new gate's lexical scope next to it.
ui/index.html: cache-bust forapp.js(procedural — read the live token, write a strictly greater one).src/state_gate.rs: new invariantthe_csv_cell_escaper_quotes_every_rfc4180_specialplus itsnegative control
the_csv_escaper_scanners_have_teeth.Why this is a real defect and not a style choice
ui/js/app.js:1819builds the document with…join("\r\n"), so CR is a record separator. RFC 4180§2.6 requires a field containing
,,", CR or LF to be quoted; the escaper lists three of the four.Two columns can carry a bare CR:
usert.userusers.name≤POST /api/auth/register'sreq.name, which only.trim()skeyt.key_name || t.key_label || "—"api_keys.name≤POST /api/api-keys/PATCH /api/api-keys/:id(no field validation at the route layer), and thekey_labelfallback comes from another user's sharing noteHonest boundary (same nature as the admin model-price fix): the UI cannot produce a bare CR —
single-line
<input>elements and browsers normalise CR/LF — so this is reachable only from a clientthat talks to the API directly. It is a data-integrity/interoperability defect, not something a UI user
will hit by accident. It is still worth fixing: the exporter's own contract is "what you see in the table,
you get in the file", and a mangled row is silent.
Tests
cargo test— 312 passed / 0 failed (basemainis 310; this change adds two tests:the gate and its negative control)
cargo fmt --checkpassescargo clippy --all-targets -- -D warningspassesNew unit tests added (2)
Gate verified before landing against a mirror of the real tree (compile + run the real
src/state_gate.rstest binary, one arm per rule) — 7 arms, all as declared:base(unfixed)fixm_cosmetic_commentm_result_discarded.test()verdict is caughtm_tail_escape(/[",\r\n]|/)m_second_implm_always_quoteThe two errors are deliberately distinguishable, so a reader can tell "there is no escape class"
from "the class is incomplete".
Gate scope is documented in
ui/README.md: the gate is lexical — it proves the four charactersare written into the class and that the
.test()verdict drives the branch. It does not prove JS regexsemantics; that is covered by the DOM probe below.
Invariants after the edit (measured before → after):
/[",\n]/-shaped classes 1 → 0;/[",\r\n]/0 → 1;[",occurrences across the whole UI corpus 1 → 1 (the escaper is still the only quoting implementation);.replace(/"/g1 → 1 (the doubling half was not touched by accident); README "newline" wording 1 → 0;exactly one
js/app.jsscript tag inindex.html(shape pinned, token value procedural)DOM probe (jsdom, real
index.html+ the four real scripts,fetchstubbed and accounted; every legdeclares its expectation, printed beside PASS/FAIL). It drives the real
#tx-export-btn, captures theBlobhanded toURL.createObjectURL, asserts the UTF-8 BOM, and reads the file back with an RFC 4180reader (CRLF, bare CR and bare LF all terminate a record outside quotes — the reading Excel uses):
[11,11,11], the cell round-trips asBob\rCarol--base){A1,A2,A3,A4}red — 4 records, widths[11,11,3,9], the cell truncated toBob{B2}red (44 quote characters on an all-plain fixture)The ordinary-row leg (
B2) pins the shape the work order asks for: the escaper quotes on demand, so afixture with no special characters must contain no quotes at all. A comma/quote field is still quoted and
its quotes still doubled (
C1).Checklist
fix/…)fix(ui): …)main)