Skip to content

Fix/utf8 validation prototype - #3

Open
fredbi wants to merge 7 commits into
masterfrom
fix/utf8-validation-prototype
Open

Fix/utf8 validation prototype#3
fredbi wants to merge 7 commits into
masterfrom
fix/utf8-validation-prototype

Conversation

@fredbi

@fredbi fredbi commented Jul 30, 2026

Copy link
Copy Markdown
Member

Change type

Please select: 🆕 New feature or enhancement|🔧 Bug fix'|📃 Documentation update

Short description

Fixes

Full description

Checklist

  • I have signed all my commits with my name and email (see DCO. This does not require a PGP-signed commit
  • I have rebased and squashed my work, so only one commit remains
  • I have added tests to cover my changes.
  • I have properly enriched go doc comments in code.
  • I have properly documented any breaking change.

fredbi and others added 6 commits July 30, 2026 20:45
Signed-off-by: Frédéric BIDON <fredbi@yahoo.com>
Signed-off-by: Frédéric BIDON <fredbi@yahoo.com>
Neither lexer validated UTF-8: the string scanners stop only on '"', '\' and
control chars, so every byte >= 0x80 was waved through and aliased straight into
the token value. Ten i_string_* JSONTestSuite documents carrying ill-formed
UTF-8 were silently ACCEPTED, and the corruption then became invisible rather
than absent (Go yields U+FFFD when such a value is iterated as a string).
RFC 8259 §8.1 requires JSON text to be UTF-8.

Supersedes the prototype in fb066a1. Plan: .claude/plans/utf8-validation.md.

API
---
One knob, WithUTF8Policy:

  UTF8Strict      (default) invalid UTF-8 -> ErrInvalidUTF8, a broken \u
                  surrogate pair -> ErrSurrogateEscape
  UTF8Replace     substitute U+FFFD: one per invalid byte, one per broken escape
  UTF8Passthrough no raw-byte validation (unsafe; the pre-fix behavior)

The prototype's fused/naive modes are gone: those were implementation
strategies, not policies, and are now chosen internally from the CPU.

The same policy governs \u escapes that do not denote a scalar value, which
removes a live L/VL divergence: L laundered a broken pair into a silent U+FFFD
(utf16.DecodeRune returns RuneError, and utf8.ValidRune(U+FFFD) is true) while
VL errored. L also used to swallow the second escape, so "\uD800\uD800" yielded
one replacement; it now yields two, matching token.Unescape.

Performance
-----------
Detection is fused into the scan already being performed: every SWAR word and
every AVX2 block is OR-accumulated, so "did this value hold a byte >= 0x80" is
answered with no second read and no call, and only values that actually do
reach the validator. The AVX2 string-stop kernel gained a second result for
this (VPMOVMSKB of the block already in the register, masked below the stop
lane) -- without it every string past guessLong was force-validated, which was
most of the prototype's cost.

Validation itself is a port of the simdutf/Keiser-Lemire lookup4 algorithm to
avo AVX2 (json/internal/utf8x), 32 B/iter, 5-12x the stdlib once engaged.

vs. no validation, benchstat 10x300ms: the four ASCII-dominated corpus
workloads are statistically indistinguishable; twitter_status (30% non-ASCII by
string bytes) costs 6.3% (L) / 1.7% (VL). Geomean -0.36%.

Zero-copy is preserved: validation only reads, so no policy adds a copy to a
well-formed document. Only an offending value is rewritten under UTF8Replace.

Also
----
- A leading UTF-8 BOM is now consumed instead of rejected (RFC 8259 §8.1 allows
  ignoring one on input). It is checked once on the buffer's opening bytes, so
  no scan loop is touched. It is NOT re-emitted by VL: the mark is consumed
  before any token exists, so it belongs to no token's leading space -- the one
  exception to VL's byte-exact round-trip on valid input, and the direction
  §8.1 asks for. A UTF-16 BOM now reports ErrNotUTF8 instead of a baffling
  "invalid JSON token".
- VL's mangling contract is spelled out: U+FFFD is three bytes replacing one, so
  a rewritten value's length is not its source width and an index into it cannot
  be mapped back to a source offset. Positions (Offset/Line/Column/LeadingSpace)
  stay exact under every policy -- they come from the scan cursor, never from
  the value.
- Shared primitives live in json/internal/utf8x, the one location reachable
  from both the lexers and the writers, which have the mirror-image bug.

Tests
-----
- Every policy x every one of the eight string paths (whole/stream x clean/
  escaped x L/VL), plus keys, long values and WithoutAVX2.
- Conformance: the ten cases flip to reject; added the VL/reader mode (VL's
  streaming string scanners had no conformance coverage) and an L/replace mode
  that pins replacement as relaxing nothing but ill-formed UTF-8.
- The implementation-defined (i_) verdict table is now snapshotted in
  testdata/conformance_i_behavior.golden. Nothing asserted it before, which is
  exactly how this bug survived from the day the zero-copy fast path landed.
- FuzzLexer gained the invariant that an emitted value is always utf8.Valid,
  across four lanes x two policies.
- The AVX2 kernel is checked against the stdlib exhaustively over all 65536
  byte pairs and over every lead byte C0-FF x all 2nd x all 3rd bytes, at
  several alignments, plus seam straddles, every length, and ~325M fuzzed
  validations. Mutation-tested: three single-bit table corruptions are each
  caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
Two statements in the default-lexer docs were written while BOM trimming was
still deferred and never updated when it landed:

- "A leading UTF-8 BOM is currently rejected as an invalid token rather than
  skipped" (README.md and doc.go) — it is now consumed before the first token.
  Both places now also say what follows from that: the mark belongs to no
  token's leading space, so VL does not reproduce it, which RFC 8259 §8.1 asks
  for anyway.
- The measured cost still quoted round 1's ~10% on twitter_status. With the
  AVX2 validator that is ~6% (L) / ~2% (VL).

Also:
- DESIGN.md §10.2 documented a single avo kernel; there are now two. Points at
  json/internal/utf8x, its separate CPUID detection, and its differential suite
  — a hand-written validator that wrongly ACCEPTS fails silently, so that suite
  is the safety net.
- The plan's §9 conformance table still claimed the BOM would be carried in
  VL.LeadingSpace(); it deliberately is not (see the round-2 outcome).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
The writers had the mirror image of the lexer bug, plus an inconsistency of
their own. Probed, not assumed:

  input to String*         before              Raw before
  invalid byte mid-string  silently -> U+FFFD  passed through
  overlong / surrogate     silently -> U+FFFD  passed through
  truncated rune at end    ERRORED             passed through

So a trailing incomplete rune errored while every other ill-formed sequence was
silently rewritten, and there was no way to ask for an error at all. RFC 8259
§8.1 requires JSON text to be UTF-8, so silently corrupting a caller's payload
is the worse half of that.

API
---
WithUTF8Policy(UTF8Strict|UTF8Replace|UTF8Passthrough), defaulting to Strict.
It is the SAME type the lexers use: UTF8Policy moved to json/internal/utf8x and
both packages alias it, so a document refused on the way out and one rejected on
the way in mean the same thing. Unbuffered takes WithUnbufferedUTF8Policy --
two names only because Go options are typed per writer; Indented and YAML get it
through their existing buffered-option wrappers.

UTF8Replace is exactly the old behavior, now explicit and complete: it is the
policy under which the output is always valid UTF-8. Note Go strings may legally
hold ill-formed UTF-8, so String()/StringBytes() can trip the new default on
data from a non-Unicode source; that is the intended change.

What is checked
---------------
Validated: String, StringBytes, StringRunes, StringCopy, Raw, RawCopy, and
VerbatimValue (which renders through the ordinary string path).

Trusted: Token and VerbatimToken. The lexers already validate string values as
they scan, so re-checking them would cost the token-copy path its reason for
existing. The loophole is deliberate and documented: a document lexed with
UTF8Passthrough can carry ill-formed bytes in, and then Token silently
substitutes U+FFFD (the escaper decodes as it goes, so the output stays valid
while differing from the input with no error) and VerbatimToken emits them
byte-for-byte, as verbatim requires.

Unchecked: NumberBytes/NumberCopy, which already document themselves so; a
number holding non-ASCII is a grammar problem, not a UTF-8 one.

Two bugs found by the new tests, both fixed
-------------------------------------------
1. The streaming paths validated each read independently, so a lead byte at the
   end of one chunk followed by a non-continuation at the start of the next was
   tolerated by both halves and emitted. RawCopy now holds the incomplete tail
   back and re-joins it with the next read -- reads land at an offset in the
   buffer that leaves room for the carry, so no second buffer and no allocation
   -- and StringCopy now validates the sequence its stitcher assembles, which
   had never passed through the chunk check.
2. Under UTF8Replace a truncated tail still errored instead of substituting, on
   every escaped path. The remainder is now policy-driven like everything else.

Cost
----
Escaped paths are within noise: the escaper already decodes every multi-byte
rune, so validation adds a pass the AVX2 kernel makes negligible (ascii 1767 vs
1841 MB/s, cjk 279 vs 291). Raw pays 5.9 vs 8.8 GB/s (-33%), being otherwise a
memcpy -- the honest price of Strict meaning what it says; callers who have
already validated can select UTF8Passthrough. The token-driven path is untouched
by construction.

Tests
-----
Three policies x five writer configurations (including a minimum-size buffer
that forces flushes mid-escape) x every entry point; split-sequence sweeps at
read sizes 1..8 for both streaming paths; the trust loophole pinned explicitly;
StringRunes rejecting surrogates and out-of-range values. And
TestWriterSubstitutionMatchesLexer cross-checks the writers' substitution
against utf8x.Sanitize, so "the lexer and the writer share one rule" is enforced
rather than asserted -- the two escapers still carry their escaping logic twice
(they differ in how they emit), so the rule is what is shared, not the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
@fredbi
fredbi force-pushed the fix/utf8-validation-prototype branch from 2677c00 to 16224b8 Compare July 30, 2026 18:46
@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
4489 1 4488 1
View the top 1 failed test(s) by shortest run time
github.com/go-openapi/core/json/lexers/default-lexer::TestConformanceParsing
Stack Traces | 7.05s run time
Failed

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

A mapping whose own "<<" merge key aliases itself

    e: &b
      <<: *b

made resolveMapping recurse until the stack overflowed. That is a FATAL
runtime error, not a Go panic: safeParse cannot recover it, and neither
WithMaxTokens nor WithMaxContainerStack applies, because the recursion happens
while flattening merge entries — before any token is emitted and outside the
container walk. The process simply dies, which is what CI reported as "fuzzing
process hung or terminated unexpectedly while minimizing".

mergeEntries did have an alias-cycle guard, but its scope was wrong. It spanned
only the resolution of the alias to a node list:

    l.expanding[name] = true
    entries := l.mergeEntries(target)   // a MappingNode just returns its values
    delete(l.expanding, name)           // released here...

and the expansion of those entries happens afterwards, back in resolveMapping's
add(), which walks them and meets the same "<<" entry with the guard already
released. So the guard covered the step that cannot recurse and left the one
that does unprotected.

The guard now spans the expansion, keyed on the merge SOURCE node rather than
the anchor name. Node identity also catches a cycle formed through a chain of
aliases or a merge sequence, and still lets the same anchor be merged in sibling
positions, which is redundant but legal.

Note on the reported input: the crasher CI recorded,

    []byte("e: &b\n  <<: *b\n  c ")

does NOT reproduce, because goccy rejects it at parse time ("non-map value is
specified") over the trailing "c ". The kill happened during minimization, on a
mutation that does parse — Go records the pre-minimization input, so the file
points next to the culprit rather than at it. Both are seeded now.

A multi-node merge cycle turns out to be unreachable: closing one needs a
forward reference, and an anchor must be defined before it is aliased, so it
fails earlier as ErrUnknownAnchor. Self-reference is the only shape that gets
there. TestMergeCycleNeedsSelfReference records that, so the narrow fix does not
look like an oversight later.

Tests: cycle shapes rejected with ErrAliasCycle; legitimate merges (repeated
anchors, sequences, 200-deep acyclic chains) still resolved, since a guard that
fails to unmark on the way out would break them; the pre-existing walkAlias
guard pinned as untouched. Each runs on a watchdog so a regression fails the
test rather than taking the suite down. 1M fuzz executions clean, where it
previously died in under 3 seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
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