Skip to content

feat: invalid UTF-8 bytes match as U+FFFD width 1 - #182

Merged
kolkov merged 14 commits into
mainfrom
feature/issue-179-invalid-utf8
Oct 9, 2026
Merged

kolkov merged 14 commits into
mainfrom
feature/issue-179-invalid-utf8

Conversation

@kolkov

@kolkov kolkov commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Invalid UTF-8 bytes now match as U+FFFD (replacement character) width 1, matching Go stdlib semantics.

  • New LookInvalidUTF8 assertion gates single-byte 0x80-0xFF transitions behind a rune-boundary + DecodeRune check
  • All four engines handle the assertion: PikeVM/BoundedBacktracker (runtime check), OnePass DFA (conditional transitions with runtime check), lazy DFA (temporary quit-to-PikeVM on bytes >= 0x80, phase 3 adds UTF-8 validator product)
  • Literal \x{FFFD} matches both valid encoding (EF BF BD) and invalid bytes
  • D-class fuzz whitelists removed, invalid UTF-8 seeds added to corpus

Test plan

  • 10/10 acceptance criteria from design doc (criteria 1-10)
  • fix: negated classes (\S, \W, \D, [^a]) match bytes, not codepoints #174 regression gate: \S{2} on "К" = no match
  • Rune-boundary invariant: [^К]b on "Кb" = no match
  • Full test suite green (11 packages)
  • -race clean on root + both DFA packages
  • Fuzz targets: 5/7 green without whitelists (remaining 2 = class B/C, not D)
  • OnePass: builds and runs for anchored patterns with ./[^x], no regression
  • regex-bench CI: 4/4 green (Benchmark, LangArena, DNA, Extreme)
  • Lint clean, gofmt clean

Fixes #179

kolkov added 14 commits October 9, 2026 10:26
Go 1.27.2 uses export data version 5, golangci-lint v2.13 supports
only version 4. CI failed on v0.12.27 merge commit.
Phase 1, steps 1+3: foundation for invalid UTF-8 handling.

- dfa/lazy/state.go: rename tagReserved (bit 29) to tagQuit, add QuitState
  constant and IsQuitTag() method. Signals DFA cannot handle the current
  byte — search must fall back to NFA.

- nfa/nfa.go: add LookInvalidUTF8 look-around assertion. Two-part
  semantics: position is at rune boundary AND utf8.DecodeRune returns
  (RuneError, 1). Fix lookNames array to include all 7 look kinds.

No behavioral changes — constants and types only.
Phase 1, step 2: add QuitState fallback to all 9 DFA search switch
statements. On QuitState, the DFA falls back to PikeVM for the current
find call (find-level fallback, not byte-level).

Forward searches: nfaFallback(haystack, startPos) or pikevm.SearchAt
Reverse searches: nfaFallbackReverse(haystack, start, end) or pikevm.Search

No behavioral changes yet — nothing produces QuitState transitions.
That comes in step 4 (temporary 3c quit on bytes >= 0x80).
Phase 1, step 4: temporary 3c fallback for invalid UTF-8 handling.

- DFA struct: add hasInvalidUTF8Look flag, scanned at build time
- Builder: add checkHasInvalidUTF8Look() (same pattern as word boundary)
- determinize: if hasInvalidUTF8Look && b >= 0x80, set QuitState in
  flatTrans and return NFAFallback error. Search loop falls back to PikeVM.

No behavioral changes yet — no NFA currently emits LookInvalidUTF8.
Activates when NFA compiler inserts Look-gated fallback (phase 2, step 6).

This is the temporary 3c approach: correct immediately on all inputs,
slow on non-ASCII text. Phase 3 replaces with UTF-8 validator product.
…179)

Phase 1 hardening after self-review:

- lazy.go: 4x-unrolled fast paths (searchEarliestMatch, searchAt) used
  `nextID != InvalidState && nextID != DeadState` which would treat
  QuitState as valid transition. Replaced with `!nextID.IsTagged()` —
  catches all tagged states (Invalid/Dead/Quit/future) in one check.

- cache.go: safeOffset() and getState() now guard against QuitState
  alongside DeadState/InvalidState. Without this, QuitState would
  extract garbage offset from tag bits.

- builder.go: DetectAccelerationFromFlat skips QuitState transitions
  (same as DeadState — not a real transition to another state).

- quit_test.go: unit tests that inject QuitState into flatTrans and
  verify DFA falls back to PikeVM with correct results. Tests cover
  Find (forward), IsMatch, and tag bit semantics.
Phase 1 review fixes (3 must-fix):

1. SearchAtAnchored QuitState used unanchored nfaFallback — would return
   matches at wrong positions. Added nfaFallbackAnchored that verifies
   start == startPos. Test: `b+` at position 0 of "ab" with quit on 'a'
   must return -1, not 2.

2. nfaFallbackReverse sliced haystack (pikevm.Search(haystack[start:end]))
   which loses left context needed for \b, (?m)^, and LookInvalidUTF8
   rune-boundary check. Replaced with pikevm.SearchBetween(haystack, start,
   end) which preserves full context. IsMatchReverse QuitState handler
   also fixed.

3. gofmt alignment on state.go constants (tagQuit shorter than tagReserved).
Phase 2, step 6: three insertion points for LookInvalidUTF8.

- compileUnicodeClassLarge: coversAllNonASCII branch always gets fallback
  (U+FFFD is in any negated ASCII class). Non-covering branch checks
  containsRuneFFFD(ranges) before adding.

- compileUTF8Any: replaced direct 0x80-0xFF/0xC0-0xC1/0xF5-0xFF
  transitions with buildInvalidUTF8Fallback (Look-gated).

- compileUTF8AnySparse: removed inline invalid byte transitions from
  sparse state. Added separate Look-gated branch via split chain.

New helpers:
- buildInvalidUTF8Fallback: LookInvalidUTF8 -> ByteRange(0x80,0xFF) -> end
- containsRuneFFFD: checks if U+FFFD is in rune ranges

NFA now emits LookInvalidUTF8 for '.', '(?s).', '\S', '\D', '\W',
'[^x]', '\P{Han}' — any class containing U+FFFD. The DFA's temporary
3c quit (phase 1) will activate for these patterns.

PikeVM/BT evaluation of the Look assertion is next (tasks #7, #8).
Phase 2, steps 7+8: add LookInvalidUTF8 case to checkLookAssertion.

Both PikeVM and BoundedBacktracker use checkLookAssertion, so one
change covers both engines. Two-part semantics:

1. Rune boundary: isInsideValidSequence() looks back up to 3 bytes
   for a valid UTF-8 lead byte covering this position. If found,
   the assertion fails (byte is inside a valid multi-byte sequence).
   Cost: only on continuation bytes (0x80-0xBF).

2. Invalid sequence: utf8.DecodeRune(haystack[pos:]) returns size==1
   (RuneError). This catches orphan continuations, overlong leads
   (C0-C1), out-of-range bytes (F5-FF), and truncated sequences.

New helpers: isInvalidUTF8Position, isInsideValidSequence, utf8LeadByteLen.

Smoke test: all 7 D-cases now match stdlib. #174 and rune-boundary
regression gates hold.
Phase 2, steps 7-9: complete invalid UTF-8 handling.

NFA engines:
- checkLookAssertion: add LookInvalidUTF8 case with two-part semantics:
  rune boundary check (isInsideValidSequence) + utf8.DecodeRune == RuneError
- New helpers: isInvalidUTF8Position, isInsideValidSequence, utf8LeadByteLen
- Both PikeVM and BoundedBacktracker covered (shared checkLookAssertion)

NFA compiler:
- compileLiteral: U+FFFD compiles as (valid EF BF BD) OR (Look-gated 0x80-0xFF)
- compileRuneFFFD helper for the alternation

Acceptance tests (all 10 criteria from design doc):
- Criteria 1-4: negated classes match invalid bytes
- Criterion 5: literal U+FFFD matches invalid byte
- Criterion 6: literal U+FFFD matches valid encoding
- Criterion 7: \pL does NOT match invalid byte
- Criterion 9: dot matches each invalid byte as width 1
- Criterion 10: rune-boundary invariant ([^К]b on Кb = no match)

Removed t.Skip("#179") from fuzz_stdlib_test.go and regex_unicode_test.go.
Phase 2, step 10: fuzz hardening.

- hasUTF8CodepointDifference: removed D-class patterns (dot, \D, \W,
  \S, [^x], .+, (.)+) from whitelist — all fixed by #174/#179. Only
  empty-match stepping patterns remain (class B/C: #175, #176).

- seedInputs: added 7 invalid UTF-8 inputs (\xff, \x80, \xc0,
  embedded invalid, valid U+FFFD encoding, mixed valid+invalid).

- TestInvalidUTF8NegatedCharClass: removed t.Skip, re-enabled.

All 9 fuzz targets pass on seed corpus with invalid UTF-8 inputs.
Phase 2 review fix: OnePass regression.

Problem: LookInvalidUTF8 in NFA caused OnePass builder to see overlapping
byte ranges (0x80-0xFF from FFFD path vs multi-byte UTF-8 paths) → rejected
as not-one-pass → 9-17x regression on FindSubmatch for log-parsing patterns.

Fix: OnePass builder skips LookInvalidUTF8 in epsilon closure (avoids
ambiguity), records FFFD targets separately. buildTransitions fills dead
byte classes >= 0x80 with conditional FFFD transitions flagged via
lookInvalidUTF8Bit in the look-around field. Search loop checks
IsInvalidUTF8Position at runtime before taking conditional transitions.

- builder.go: skip LookInvalidUTF8 in closure, record fffdTargets, fill
  dead byte classes with conditional FFFD transitions
- transition.go: add lookInvalidUTF8Bit, NeedsInvalidUTF8Check()
- search.go: runtime check before IsDead
- onepass.go: runtime check in IsMatch
- pikevm.go: export IsInvalidUTF8Position for cross-package use
- gofmt: fix formatting on fuzz_stdlib_test.go, nfa/compile.go
@codecov

codecov Bot commented Oct 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 59.49367% with 64 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
dfa/onepass/builder.go 22.58% 24 Missing ⚠️
nfa/compile.go 43.24% 21 Missing ⚠️
dfa/lazy/lazy.go 54.16% 11 Missing ⚠️
nfa/pikevm.go 83.87% 5 Missing ⚠️
dfa/lazy/builder.go 95.65% 1 Missing ⚠️
dfa/onepass/onepass.go 66.66% 1 Missing ⚠️
dfa/onepass/search.go 50.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Benchmark Comparison

Comparing main → PR #182

Summary: geomean 106.6n 103.5n -2.95%

⚠️ Potential regressions detected:

geomean                               ³                +0.00%               ³
geomean                               ³                +0.00%               ³
OnePassSearch-4      47.29n ± ∞ ¹   53.78n ± ∞ ¹  +13.72% (p=0.008 n=5)
OnePassIsMatch-4     20.02n ± ∞ ¹   22.65n ± ∞ ¹  +13.14% (p=0.008 n=5)
geomean              30.77n         34.90n        +13.43%
geomean                         ³                +0.00%               ³
geomean                         ³                +0.00%               ³
MatchAnchoredLiteral/short_match-4                      5.945n ± ∞ ¹    7.829n ± ∞ ¹   +31.69% (p=0.008 n=5)
MatchAnchoredLiteral/medium_match-4                     7.534n ± ∞ ¹    9.680n ± ∞ ¹   +28.48% (p=0.008 n=5)
MatchAnchoredLiteral/long_match-4                       7.540n ± ∞ ¹    9.662n ± ∞ ¹   +28.14% (p=0.008 n=5)

Full results available in workflow artifacts. CI runners have ~10-20% variance.
For accurate benchmarks, run locally: ./scripts/bench.sh --compare

@kolkov
kolkov merged commit d437696 into main Oct 9, 2026
8 of 9 checks passed
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.

fix: invalid UTF-8 bytes silently skipped instead of matching as U+FFFD

1 participant