Skip to content

Add UTF8.valid?, a byte-level UTF-8 validator#4

Merged
hellerve merged 1 commit into
masterfrom
claude/add-valid-predicate
Jul 5, 2026
Merged

Add UTF8.valid?, a byte-level UTF-8 validator#4
hellerve merged 1 commit into
masterfrom
claude/add-valid-predicate

Conversation

@carpentry-agent

Copy link
Copy Markdown

What

Adds UTF8.valid? (Fn [(Ref (Array Byte))] Bool) — a byte-level check for whether an array is well-formed UTF-8 per RFC 3629. It rejects:

  • overlong encodings (e.g. C0 80, E0 9F BF)
  • UTF-16 surrogate code points U+D800..U+DFFF (e.g. ED A0 80)
  • code points beyond U+10FFFF (e.g. F4 90 80 80)
  • lone continuation bytes, truncated sequences, and out-of-range lead bytes (F5..FF)

The scan is allocation-free and pure Carp. The lead-byte classification maps directly to the Unicode standard's Table 3-7: each lead byte fixes how many continuation bytes follow and the tightened valid range of the first one (E0A0..BF, ED80..9F, F090..BF, F480..8F), which is what kills overlongs, surrogates, and over-max in a single pass.

Why

Follow-up to carpentry-org/web#40 (WebSocket text-frame UTF-8 validation). The review there was: "We should use the utf8 library for that." — but this library had no validator to reuse. This PR adds one so web (and anyone else) can depend on it instead of hand-rolling the check. from-string and String.from-bytes both assume well-formed input, so a standalone guard for untrusted bytes is a natural fit here.

The API is a predicate on raw bytes rather than on a UTF8/String value on purpose: validity is a property of bytes, and callers (like web, whose WebSocket payloads are (Array Byte)) can check before constructing anything — no allocation, no throwaway string on the reject path.

Tests

Adds 18 asserts to test/utf8.carp: ASCII and empty input; valid 2/3/4-byte sequences (U+00A9, U+20AC, U+1F600); the boundaries either side of the surrogate gap (U+D7FF, U+E000) and of the 4-byte range (U+10000 min, U+10FFFF max); and malformed input — overlong 2- and 3-byte, lone/truncated continuations, both surrogate ends (U+D800, U+DFFF), >U+10FFFF, and F5/FF leads.

Verification

  • carp -x test/utf8.carp35/35 pass (17 existing + 18 new), run locally.
  • carp-fmt --check and angler clean on utf8.carp and test/utf8.carp.
  • docs/UTF8.html regenerated via gendocs.carp.

Once this is released, web#40 can drop its own WebSocket.valid-utf8? and call UTF8.valid?.


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

Validates a byte array as well-formed UTF-8 per RFC 3629, rejecting
overlong encodings, UTF-16 surrogate code points (U+D800..U+DFFF), and
code points beyond U+10FFFF. The scan is allocation-free and pure Carp,
mapping the lead-byte classification directly to Unicode Table 3-7.

Adds 18 asserts covering ASCII, 2/3/4-byte sequences, the boundaries
around the surrogate gap (U+D7FF/U+E000) and the 4-byte range
(U+10000 min, U+10FFFF max), and malformed input (overlongs, lone and
truncated continuations, both surrogate ends, >U+10FFFF, F5/FF leads).
Regenerates docs.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Checked out claude/add-valid-predicate locally and ran the suite:
carp -x test/utf8.carp35/35 pass (17 existing + 18 new). CI is green on both ubuntu-latest and macos-latest; the single test job also runs angler lint, carp-fmt --check, and gendocs.carp, so those passed too.

Findings

I read the whole function, checked it against Unicode Table 3-7 / RFC 3629, then went well beyond the suite — 20 extra adversarial cases, all of which behave correctly:

  • Lead-byte classification (utf8.carp:164-174) is exhaustive and correct. 0xC0/0xC1, the lone-continuation range 0x80-0xBF, and 0xF5-0xFF all fall through to (set! ok false). The tightened first-continuation ranges for E0 (≥A0), ED (≤9F), F0 (≥90), F4 (≤8F) correctly kill overlongs, surrogates, and >U+10FFFF in a single pass. I confirmed each boundary empirically (E0 9F→reject / E0 A0→accept; F4 90→reject / F4 8F→accept).
  • Continuation handling (utf8.carp:175-183) is sound. The truncation guard (> (+ i need) (- len 1)) is correct, and every unsafe-nth is provably in bounds (all indices ≤ len-1 after that guard). The first continuation is checked against the tightened [lo,hi]; the rest against [0x80,0xBF] via the for loop — I verified a bad 2nd/3rd continuation byte in 3- and 4-byte sequences is rejected (E2 82 00, F0 9F 98 00), which the suite doesn't cover.
  • Multi-codepoint advancement works. aé€😀 and back-to-back 3-byte sequences validate; interior faults after a valid prefix (61 FF 62, abc 80) are caught — it's not just checking the first character.
  • The unsigned-byte assumption holds. Byte.to-int returns 0-255 (confirmed 255→255), which the ≥194 comparisons depend on.
  • Style fits. This let-do/for/set! imperative idiom already appears in to-runes (utf8.carp:80-89), so it's not a new pattern for the file.
  • Docs/changelog: docs/UTF8.html was regenerated; the repo has no CHANGELOG and the README is a narrative tutorial with no API list, so nothing else to update.
  • API shape — a predicate over (Ref (Array Byte)) rather than a UTF8/String — is the right call: validity is a byte-level property, and callers like web#40 can check before allocating.

Found no bugs, no missing edge cases, no style drift.

Verdict: merge

Correct, well-tested, and green across the full CI pipeline; I built it and ran 20 additional adversarial cases and it held up on every one. Merge is of course the maintainer's decision.

@hellerve hellerve merged commit 43a9e12 into master Jul 5, 2026
2 checks passed
@hellerve hellerve deleted the claude/add-valid-predicate branch July 5, 2026 11:11
@carpentry-agent carpentry-agent Bot mentioned this pull request Jul 9, 2026
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