Reject malformed UTF-8 in Parser.UTF8.decode - #24
Conversation
There was a problem hiding this comment.
Build & Tests
Built and ran the suite on Linux/armhf (carp -x test/parsec.carp): 318 passed, 0 failed, matching your number (294 baseline + 24 new). CI is green on both ubuntu-latest and macos-latest.
I verified the tests aren't vacuous rather than taking it on trust — restored parsec.carp from main (old decode) while keeping the new test file, and got 306 passed, 12 failed. Exactly your 12 malformed-input assertions fail against the old decoder (the C3 28 codepoint and its "leaves ( unconsumed" tail probe, E0 A0 28, C0 80, C0 AF, E0 80 80, E0 9F BF, F0 80 80 80, ED A0 80, ED BF BF, F4 90 80 80, F5 80 80 80). The two already-handled cases (truncated 195, lone-continuation 128) correctly pass on both, so they're not among the 12. The tests pin the bug.
Findings
None. I traced every branch of decode against the committed code:
- Dispatch is complete and safe.
b0 < 192catches lone continuation bytes →Nothing; the finalNothingcatchesb0 >= 248(F8–FF). Everybyte-atis guarded by the preceding(Int.>= (Int.+ pos N) len)truncation check, sob1/b2/b3can't index past the end — important given Carp's uncheckedString.char-at. - The value-range checks are the correct equivalent of Unicode Table 3-7. Overlong is rejected by
cp < 0x80 / 0x800 / 0x10000; surrogates by55296 <= cp <= 57343; max bycp > 1114111. That folds in the byte-range restrictions on the first continuation byte (E0/ED/F0/F4) exactly. C0/C1 leads fall out viacp < 128; F5–F7 viacp > 1114111. - No over-rejection at the boundaries. The valid matrix straddling the surrogate hole (U+D7FF and U+E000 both decode, D800/DFFF rejected) and the exact per-width min/max (U+0080, U+07FF, U+0800, U+FFFF, U+10000, U+10FFFF) all decode — confirmed in the run, and the boundary codepoints round-trip through
Charcorrectly (U+10FFFF → 1114111), so there's no width truncation. - No overflow. The widest
cpis 0x1FFFFF, well insideInt;byte-at's(bit-and 255 …)neutralizes any sign-extension fromChar.to-int.
The fix is at the right layer — the single decode chokepoint — so any-char, char and codepoint-satisfy all inherit it. The CHANGELOG entry is warranted (the public parsers' behavior genuinely changes) and correctly filed under ## Unreleased. Branch is on current main (merge-base == origin/main), so no changelog-misfile risk.
Verdict: merge
Correct, comprehensive, bounds-safe, and backed by a test set I confirmed is non-vacuous. Nothing to change.
|
Shouldn’t we let the utf8 library handle these things instead? |
|
Good question — I dug into whether What What So there are two paths:
This PR's fix is correct and independently reviewable either way, so my suggestion is to merge it as the immediate correctness fix, and — if you want the single-source-of-truth version — I'm happy to do the |
|
We’re in no hurry to get this in, so I’d do the more principled fix right away. |
|
Per your ask to do the principled fix right away, I've built its foundation in utf8.carp: carpentry-org/utf8.carp#9. That PR adds a public incremental decoder, The catch is sequencing: parsec can only
Happy to take either path — just say which you prefer. I haven't touched this branch or parsec's code; this is only a heads-up. |
|
0.2.0 for utf8.carp was published, so the discussion is moot. we can just depend on that. |
Per hellerve's request on #24, the Table 3-7 validation this branch had added inline is replaced by a call to UTF8.decode-at from utf8.carp 0.2.0, so overlong / surrogate / out-of-range handling has one home in the org rather than two copies that can drift. decode-at takes a (Ref (Array Byte)), while decode has a &String and a byte cursor. Converting the whole source per codepoint would be O(n^2), so decode builds a window of min(4, len - pos) bytes at pos and decodes at offset 0. Truncation then falls out for free: a short window makes decode-at report Nothing for exactly the inputs the old explicit (pos + N >= len) guards rejected. The window is skipped entirely for ASCII leads. Building it costs a malloc/free per codepoint, which measured at 2.5x on many(any-char) over mixed-width text; the b0 < 128 short-circuit brings that to 1.6x there and 1.04x on ASCII-heavy input. It duplicates one comparison, not any of the validation. Proven behaviour-preserving by an old-vs-new differential over 3,174,482 cases: every 1- and 2-byte input exhaustively, every lead byte crossed with continuation-boundary bytes at 3 and 4 bytes wide, and the codepoint edges around each width's min/max and the surrogate hole -- each run at every truncated length and at a shifted position. Zero disagreements in constructor, codepoint or width. The suite is unchanged at 318/0, including all 24 malformed-input assertions.
|
Thanks for the go-ahead, and for clearing the sequencing — done. What went: the ~45 lines of inline dispatch, continuation-byte checking and overlong/surrogate/range logic that the first commit added. What the differential proved: I ran the old decoder and the new one side by side over 3,174,482 cases — every 1-byte and every 2-byte input exhaustively, every lead byte crossed with continuation-boundary bytes at 3 and 4 bytes wide, and the code point edges around each width's min/max and the surrogate hole; each re-run at every truncated length and at a shifted That was worth building rather than asserting: the first bridge I wrote used The one trade-off, for you to weigh: CHANGELOG notes the new dependency; the strictness entry stays. |
13e0f7d to
7c374d4
Compare
|
Correction to my comment above: I pushed the utf8.carp rewire, CI rejected it, and I've rolled this branch back to the reviewed self-contained fix ( The rewire itself works. I proved it behaviour-preserving before pushing: old vs. new over 3,174,482 cases — every 1-byte and every 2-byte input exhaustively, every lead byte crossed with continuation-boundary bytes at 3 and 4 bytes wide, and the code point edges around each width's min/max and the surrogate hole; each re-run at every truncated length and at a shifted (That differential earned its keep: my first bridge used What blocks it — a name collision, not a logic problem. utf8.carp defines a top-level Deterministic on both ubuntu-latest and macos-latest. It does not reproduce on the carp I have here ( I checked whether it was the reference site by hoisting the That leaves three ways out, and all three are your call rather than mine:
Happy to take any of them once you've picked; option 1 looks cheapest to me. In the meantime this PR is back to the version your reviewer signed off on, so it's mergeable as-is and the dedup can follow whenever the naming is sorted. |
There was a problem hiding this comment.
Build & Tests
This branch is back at 7c374d4 — byte-for-byte the commit I reviewed on 07-21, not a revert commit layered on top. carp -x test/parsec.carp → 318/0 on armhf, and CI re-ran against this exact SHA at 22:57 (both ubuntu-latest and macos-latest green), so the rolled-back state is independently verified rather than inheriting July's run.
The rollback is clean: git diff main..7c374d4 touches only CHANGELOG.md, parsec.carp and test/parsec.carp, with no residue of the rewire — no decode-at reference, no utf8.carp dependency line in the changelog. Merge-base is 2e63d75, current main.
Prior feedback
My 07-21 review of this code stands unchanged — the review's commit SHA is the current head, so there is no new code here to re-review. The decoder, the Table 3-7 equivalence and the non-vacuity of the 12 malformed-input assertions were all checked then and none of it moved.
Findings
None in the code. What I did check is the blocker report, since it's the part of this PR that's new and the part you'll act on.
It is accurate, verbatim. Pulling the failed job logs directly:
30b6ccd (rewire) out/main.c:2365:8: error: redefinition of 'UTF8'
out/main.c:2360:8: note: previous definition is here
13e0f7d (retry) same, same lines
on both ubuntu-latest and macos-latest. The secondary observation checks out too — the middle attempt (d28bd21, the shim probe) failed differently and for the stated reason:
The binding: Parser.codepoint-at is private; it may only be used
within the module that defines it. (parsec.carp:1253)
So CI's carp enforces private across submodules where the local one doesn't. Both are worth knowing independently of this PR. The parked branch is where it says it is (claude/utf8-decode-at-rewire at 30b6ccd, −50/+21 in parsec.carp).
One thing that may make the naming decision cheaper than it looks
The write-up frames option 1 as "rename utf8.carp's type", which reads like a breaking change to a published library. Two facts narrow that:
The two bindings anyone actually calls don't mention the type. UTF8.valid? (utf8.carp:185) and UTF8.decode-at (:166) both take (Ref (Array Byte)) and neither names UTF8 in its signature — they only live in that module because the deftype created it. They could sit in a plain (defmodule UTF8 …) next to a renamed type, which keeps UTF8.valid? and UTF8.decode-at source-compatible for every caller. Only the bindings that genuinely operate on the type (from-string, init, append, reverse, slice, …) would move.
The org-wide blast radius of the type itself is one file. Grepping every carpentry clone, the only consumer of utf8.carp outside its own tree is web/web.carp, and it calls exactly one binding — UTF8.valid?, at :2418 and :2454. Nothing outside utf8.carp names the UTF8 type.
Caveat I can't remove from here: whether a top-level (defmodule UTF8 …) in utf8.carp coexists with parsec's (defmodule UTF8 …) under Parser is exactly the thing the local carp can't tell us — 905f9457 doesn't reproduce the collision at all. It's a one-commit throwaway branch to find out on CI, and worth doing before committing to a rename, because if two same-named modules also collide then option 1 is no cheaper than option 2 and the real answer is option 3.
Verdict: merge
Unchanged from 07-21 and now re-confirmed at this SHA: the self-contained fix is correct, bounds-safe, and its tests demonstrably pin the bug. The rewire is a separate question that this PR correctly stopped waiting on, and decode is a single chokepoint, so swapping its body later is a small diff against whatever naming you pick.
Reviewed by the carpentry-org review agent (Claude).
|
i’ll have to think on this, let’s park it. |
decode's docstring promises Nothing "on EOF or malformed input", but the multi-byte branches only checked for truncation and never validated the trailing bytes. any-char/char/codepoint-satisfy therefore silently accepted invalid continuation bytes, overlong encodings (including the overlong-slash traversal and overlong-NUL exploits), UTF-16 surrogates, and codepoints above U+10FFFF — and on a bad continuation byte even consumed the offending byte, so the next parser never saw it. Each multi-byte branch now checks every trailing byte is a continuation byte (0x80-0xBF), rejects results below the width's minimum (overlong), rejects surrogates U+D800-U+DFFF, and rejects cp > U+10FFFF, returning Nothing on any failure. any-char already maps Nothing to a non-consuming ErrEmpty at the lead byte, so malformed input now fails without advancing the cursor. Tests add one case per malformed class, the boundary-valid codepoints that must still decode (U+007F, U+0080, U+07FF, U+0800, U+D7FF, U+E000, U+FFFF, U+10000, U+10FFFF), and an assertion that a malformed lead leaves the following byte unconsumed. Local run: 318/0.
7c374d4 to
424f7cf
Compare
hellerve
left a comment
There was a problem hiding this comment.
Rebased onto main (0.6.0); only CHANGELOG conflicted, source and tests auto-merged. Verified after the rebase: 356/0, and a byte-array probe shows main accepting all six malformed sequences (C3 28, C0 80, C0 AF, ED A0 80, F4 90 80 80, E0 80 80) while this branch rejects exactly those and still accepts valid two- and four-byte codepoints. Lead-byte space is fully covered: stray continuation bytes fall out at the b0 < 192 arm, C0/C1 at the overlong test.
Problem
UTF8.decodeis documented to returnNothing"on EOF or malformed input", but its multi-byte branches only checked that enough bytes were present (truncation). They never validated the trailing bytes, soUTF8.any-char,char, andcodepoint-satisfy— all layered ondecode— silently accepted malformed UTF-8:C3 28decoded to U+00E8 and consumed the(, so the next parser never saw it.C0 80(overlong NUL) andC0 AF(overlong/, the classic directory-traversal exploit) decoded to U+0000 //.ED A0 80(U+D800) was accepted.F4 90 80 80(U+110000) was accepted.This is a contract/robustness bug rather than a new feature — the docstring already promises
Nothingon malformed input.Fix
decodeis the only function changed. After confirming enough bytes are present, each multi-byte branch now:byte & 0xC0 == 0x80, i.e.0x80–0xBF);0x80for 2-byte, ≥0x800for 3-byte, ≥0x10000for 4-byte);U+D800–U+DFFF(3-byte branch);U+10FFFF(4-byte branch).Any failure returns
Nothing.any-charalready mapsNothingto a non-consumingReply.ErrEmptyat the lead byte, so the correct backtrack-friendly semantics — fail without advancing, leaving the offending byte for the next parser — come for free. (LeadsC0/C1are always overlong and caught by the ≥0x80check; leadsF5–FFyield a codepoint aboveU+10FFFFor fall through.)Tests
test/parsec.carpgains a byte-based matrix, built withString.from-bytes &[(Byte.from-int N) …]to match the existing malformed-input tests (no non-ASCII char literals):C3 28), bad second continuation (E0 A0 28), overlong NUL (C0 80), overlong/(C0 AF), overlong 3-byte (E0 80 80,E0 9F BF), overlong 4-byte (F0 80 80 80), low/high surrogate (ED A0 80,ED BF BF), above-max (F4 90 80 80), 5-wide lead (F5 80 80 80), plus the two pre-existing truncated / lone-continuation cases.err?-only assertions were strengthened to pin that a malformed lead advances nothing.err?is satisfied by bothErrEmptyandErrConsumed, so a wrongly-consuming decoder would have passed; the new probe runs(alt (UTF8.any-char) (any-byte)), which reachesany-byteonly ifany-charrejected the lead without consuming, and checks the surviving remainder (e.g.C3 28leaves(unconsumed).Reverting only the
decodechange makes exactly the 12 malformed-input assertions fail, confirming the tests pin the bug rather than passing vacuously.Local
carp -x test/parsec.carp→ 318/0 (294 baseline + 24 new).carp-fmt --checkandanglerare clean onparsec.carp;gendocs.carpruns unchanged (decodeis hidden, so the public API is untouched).Separately, an exhaustive old-vs-new differential over 3,174,482 byte sequences confirms this decoder and
utf8.carp'sdecode-atagree on every input — see the comment below.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.