Repository navigation
Keep an encoded replacement character when invalid bytes are dropped - #38
Merged
Merged
Conversation
A base64 run whose bytes held both an invalid byte and a genuine U+FFFD was decoded with replacement and then stripped of every U+FFFD, so the encoded one went too and the text on either side of it joined. The scanner drops only the invalid bytes (errors=ignore) and keeps the encoded character, so a text it blocked was allowed here. The fallback now walks the bytes and skips what is not well-formed UTF-8, which is what the scanner does. The strict decode is still tried first. A differential run of 60,001 random byte strings against the scanner's decode agrees on every one, and the 256 rows from the review now reach the scanner's decision and finding ids.
azrollin
marked this pull request as ready for review
October 14, 2026 14:13
azrollin
force-pushed
the
t10/invalid-bytes-w38
branch
from
October 14, 2026 14:13
1521b4f to
593bad1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Keeps a genuine replacement character when a base64 segment also holds invalid bytes, as the scanner does. Follows the review of #36 (the 256 rows it left open). The rule set is untouched. Stacked on #37; the diff here is this change only.
Cause
The scanner decodes a base64 run with
errors="ignore": every invalid byte is dropped and a replacement character that was encoded (the bytes EF BF BD) stays where it was written. The Worker tried a strict decode first, which is right when every byte is valid. When any byte was invalid it decoded with replacement and then deleted every U+FFFD, the ones the decoder had written and the encoded one alike. A decoder that replaces cannot tell the two apart. Deleting the encoded one joined the text on either side of it, so a phrase that the scanner reads as two words was one word here and the rule that needs the boundary did not fire. Example: "ignore all previous instructions", U+FFFD, "x", then a stray byte. The scanner blocks it, this runtime allowed it.Change
src/preprocessor.js: when the strict decode fails, the bytes are walked against the well-formed UTF-8 table (lead byte, range of the second byte, continuation count) and whatever is not well-formed is skipped. A valid sequence, including EF BF BD, is decoded where it stands. The strict decode is still tried first.LIMITATIONSstring no longer list this as open. The other difference in that entry (the printable table) is unchanged.Test, red on the base and green on this head
tests/invalid_bytes_beside_replacement_char.test.mjswithtests/fixtures/scan_corpus_invalid_bytes.json: 256 rows (32 texts across 8 channels) that the scanner blocks and the base allowed, with decision and finding ids from the scanner's own engine at 0.6.8, plus 7 direct decode cases.Differential
bytes.decode("utf-8", "ignore")on 60,001 byte strings (mostly lead, continuation and boundary bytes, plus a case with an encoded replacement character): 0 differences.What remains different
str.isprintable()is read from this runtime's Unicode tables, so a character assigned in one and not the other can decide a segment differently. Not changed here.This PR makes no claim that the hosted demo matches the scanner beyond the rows and checks listed here.
Gates on this head
Private checkout of the scanner at 0a3b4686. Every gate exited 0 on the code of this change (the last commit then changed only the README paragraph and the matching
LIMITATIONSstring; the disclosure gate, v-flag disclosure, metadata contract, engine parity andnode --testwere run again on head 1521b4f): disclosure gate; policy parity (14 cases, 0 mismatches); engine parity (137 cases, 0 verdict, 0 finding-set deltas); channel parity (2,488 cases, 0, 0); wide parity (1,555 pairs, 0, 0, no known entries); counterexample parity (0 deltas, 0 slower than the bound);parity_test.py(886 regexes, 0 misses; 13,024 benign checks, 0 disagreements; seed 3010664057, replay withPARITY_SEED); v-flag disclosure; metadata contract.node --test tests/*.test.mjs: 180 passed, 0 failed (172 on #37, plus the 8 new).python3 -m pyteston the dotless-i tests: 62 passed.