Skip to content

HTMLRewriter: pass text bytes through unchanged when a text handler only observes - #35933

Open
robobun wants to merge 5 commits into
mainfrom
farm/72e68030/htmlrewriter-text-passthrough
Open

HTMLRewriter: pass text bytes through unchanged when a text handler only observes#35933
robobun wants to merge 5 commits into
mainfrom
farm/72e68030/htmlrewriter-text-passthrough

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Repro

const bytes = Buffer.concat([Buffer.from("<p>"), Buffer.from([0xa9]), Buffer.from("</p>")]);
const r = new HTMLRewriter().on("p", { text() {} });
const out = Buffer.from(await r.transform(new Response(bytes)).arrayBuffer());
// before: <p> ef bf bd </p>
// after:  <p> a9 </p>        (identical to input)

Registering a no-op text handler (element or onDocument) rewrote every byte that is not valid UTF-8 in the covered text into U+FFFD in the output, while the same rewriter with only element/comments/end handlers (or none) emitted the input byte-for-byte. A legacy-encoded page, a mislabeled binary, or WTF-8 content was silently corrupted the moment a text callback was added, with no error.

Cause

lol-html's TextChunk path decodes the raw lexeme bytes into a &str via encoding_rs (lossy: invalid sequences become U+FFFD) so the handler can read .text, then serialize_self re-emits that decoded string. Comments, tags and doctypes keep the original raw bytes and write them back when the handler has not mutated them; text chunks did not, so the decode-then-reencode round-trip was always applied.

Fix

Patch the vendored lol-html (patches/lolhtml/text-chunk-raw-passthrough.patch):

  • TextChunk gains raw: Option<Cow<'i, [u8]>>; set_str/as_mut_str clear it, and serialize_self writes raw verbatim when present.
  • TextDecoder::feed_text passes each decode step's raw input slice alongside the decoded &str. For UTF-8 it computes exactly which tail bytes encoding_rs kept as internal state (an incomplete sequence at end-of-input, at most 3 bytes) via std::str::from_utf8's error_len() == None, which agrees with encoding_rs's WHATWG UTF-8 state machine on every lead/2nd-byte validity case. Those bytes are owned in TextDecoder::pending_raw and prefixed onto the next emitted chunk's raw, so each chunk's raw is exactly the bytes its text was decoded from and the set of chunks the handler observes is unchanged.

before/after/replace/remove are unaffected (they act on mutations, not on the chunk's own text). text.text still returns the lossy-decoded string to JS.

Verification

  • USE_SYSTEM_BUN=1 bun test test/js/workerd/html-rewriter.test.js: 28 of the 33 new tests fail (the 5 that pass are regression guards asserting mutation still works).
  • bun bd test test/js/workerd/html-rewriter.test.js: 102 pass, 0 fail.
  • bun bd test across the other four html-rewriter* test files: 11 pass, 0 fail.
  • cargo test --lib in vendor/lolhtml: 146 pass.

The 33 new tests cover: lone continuation bytes; multiple invalid bytes; mixed valid/invalid UTF-8; truncated 2/3/4-byte sequences immediately before a close tag (the bytes encoding_rs holds as state and flushes on the lastInTextNode chunk); a text node that is only a truncated lead byte; invalid bytes before and after the 1 KB fast-path cutoff; ScriptData text; multiple sibling text nodes; .text reads still seeing U+FFFD while output stays raw; before()/after() keeping the chunk body raw; replace()/remove() still dropping it; and the handler's observed chunk sequence pinned to today's behavior.

The fix lives in patches/ and scripts/build/deps/ (not src/) since the bug is in the vendored dependency, so the USE_SYSTEM_BUN=1 run above is the fail-before proof.


[stamp-90s] gate passed · iteration 1 · 3 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/workerd/html-rewriter.test.js'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/workerd/html-rewriter.test.js
bun test v1.4.0 (9c5e0c899)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [10.71ms]
(pass) HTMLRewriter > error inside element handler [6.47ms]
(pass) HTMLRewriter > error inside element handler (string) [4.74ms]
(pass) HTMLRewriter > fast async error inside element handler [21.59ms]
(pass) HTMLRewriter > slow async error inside element handler [17.49ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [127.67ms]
(pass) HTMLRewriter > HTMLRewriter handles Symbol invalid type error [12.54ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > control: .text() on the untransformed response rejects [357.97ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .text() on the transformed response rejects [70.25ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .arrayBuffer() on the transformed response rejects [47.38ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .body on the transformed response is an errored stream [49.99ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > a read already pending on .body when the upstream fails rejects [52.86ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .clone() of a failed transformed body is also failed [62.52ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > does not invoke onDocument end for a document that never completed [59.70ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > transform() of a body that already failed throws the upstream error [48.59ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > transform() of an aborted body throws the abort reason [58.79ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement using fetch + Bun.serve [177.29ms]
(pass) H
... (truncated)
Exit: 0
diff hotspot
patches/lolhtml/text-chunk-raw-passthrough.patch | 387 +++++++++++++++++++++++
 scripts/build/deps/lolhtml.ts                    |   2 +
 test/js/workerd/html-rewriter.test.js            | 122 +++++++
 3 files changed, 511 insertions(+)

gate history · 5 passed · 0 rejected · iteration 1

evidence per changed file
file                                              reads  edits  tests
patches/lolhtml/text-chunk-raw-passthrough.patch      1      0      0
scripts/build/deps/lolhtml.ts                         1      1      0
test/js/workerd/html-rewriter.test.js                 3      3      0

…nly observes

Registering a no-op text handler (on(sel, {text(){}}) or onDocument({text(){}}))
rewrote every byte that is not valid UTF-8 in the covered text into U+FFFD in
the output, while the same rewriter with only element/comments/end handlers
emitted the input byte-for-byte. A legacy-encoded page, a mislabeled binary, or
WTF-8 content was silently corrupted the moment a text callback was added.

lol-html's TextChunk path decodes the raw bytes to a &str (lossy) for the
handler, then re-emits the decoded string. Comments and tags keep the original
bytes and re-emit those when unmodified; text chunks did not.

Carry the raw input slice that each decoded chunk came from through to
TextChunk, and have serialize_self emit that slice when the handler has not
called set_str/as_mut_str. before/after/replace/remove continue to work since
they act on mutations, not on the chunk text.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Raw input bytes are carried through lolhtml text decoding and stored on TextChunk. Unmodified chunks serialize their original bytes, while text mutations clear the raw data. Dispatcher wiring, dependency patch registration, and non-UTF-8 HTMLRewriter tests are added.

Raw text byte passthrough

Layer / File(s) Summary
Decoder and chunk raw-byte handling
patches/lolhtml/text-chunk-raw-passthrough.patch
TextDecoder buffers incomplete raw bytes and passes them with decoded text; TextChunk serializes preserved bytes and clears them when text is mutated.
Dispatcher and dependency integration
patches/lolhtml/text-chunk-raw-passthrough.patch, scripts/build/deps/lolhtml.ts
The dispatcher forwards raw bytes into text chunks, and the lolhtml dependency registers the patch.
Non-UTF-8 passthrough coverage
test/js/workerd/html-rewriter.test.js
Tests cover observer-only byte preservation, streaming boundaries, truncated sequences, before()/after(), replacement, and removal.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: preserving raw text bytes when a text handler only observes them.
Description check ✅ Passed It covers the change, cause, fix, and verification steps, even though it uses custom headings instead of the template.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:42 AM PT - Jul 26th, 2026

@robobun, your commit 9c5e0c8 has 1 failures in Build #82465 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    bun-linux-x6472.47 MB71.95 MB+528.0 KB
    bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    bun-windows-x6480.26 MB79.70 MB+572.5 KB
    bun-windows-aarch6470.86 MB70.34 MB+534.0 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35933

That installs a local version of the PR into your bun-35933 executable, so you can run:

bun-35933 --bun

robobun and others added 2 commits July 26, 2026 11:39
…with text

The previous revision emitted an extra empty-text handler call for a text
node that is only an incomplete UTF-8 prefix (e.g. <p>\xE2</p>), and left
raw/text misaligned by up to 3 bytes when encoding_rs held an incomplete
sequence across a feed_text boundary.

TextDecoder now owns the <=3 raw bytes that encoding_rs has buffered and
attributes them to the next emitted chunk's raw, so the handler sees the
same chunks it always did and each chunk's raw is exactly the bytes its
text was decoded from. utf8_incomplete_tail_len computes the buffered
length via std from_utf8's error_len()==None, which matches encoding_rs's
WHATWG UTF-8 state machine bit-for-bit.

Adds a 26-test matrix covering truncated 2/3/4-byte prefixes immediately
before a close tag, a text node that is only a truncated lead byte, and
the >1KB fast-path/slow-path split, plus a test that pins the handler's
observed chunk sequence to today's behavior.
Comment thread patches/lolhtml/text-chunk-raw-passthrough.patch Outdated
Locks in that a conditional replace on one of two text chunks straddling
a UTF-8 code point produces valid UTF-8 either way, and that a write
carrying only an incomplete lead byte never fires the handler with an
empty non-last chunk.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@patches/lolhtml/text-chunk-raw-passthrough.patch`:
- Around line 103-116: Update the raw passthrough logic around consumed, tail,
and utf8_incomplete_tail_len so incomplete decoder sequences are handled for
every supported active encoding, not only UTF_8. Generalize tail-length
calculation using the current encoding’s decoder behavior, or disable raw
passthrough for non-UTF-8 encodings; preserve correct carry handling across
write boundaries and avoid emitting split sequence bytes.
- Around line 72-73: Update the UTF-8 fast path around split_utf8_start so it
cannot discard a non-empty pending_raw prefix: either prepend pending_raw to the
emitted raw slice before returning Some(Cow::Borrowed(...)), or bypass this fast
path until pending_raw has been emitted. Replace the debug_assert-only guard,
preserving all deferred bytes in release builds.

In `@test/js/workerd/html-rewriter.test.js`:
- Around line 742-751: Update the parameterized cases around transform to track
handler invocation counts for every observer setup except the “no handlers”
control. Increment the relevant counter inside each element, comments, text, and
onDocument handler, then assert each counter is greater than zero alongside the
existing byte-output assertion so the tests detect handlers being skipped.
- Around line 1305-1356: Add JavaScript coverage for cross-write UTF-8 buffering
by extending the rewrite test helpers around `rewrite` to transform a
`ReadableStream` whose chunks split a multi-byte sequence, rather than always
using `new Response(src)` with one buffer. Include a case where a neighboring
chunk is replaced or modified, and assert the streamed output matches the
expected replacement behavior while exercising `pending_raw` across the write
boundary.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 622c7c41-58f8-4806-8567-faced769e9d6

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and 23e2cc6.

📒 Files selected for processing (3)
  • patches/lolhtml/text-chunk-raw-passthrough.patch
  • scripts/build/deps/lolhtml.ts
  • test/js/workerd/html-rewriter.test.js

Comment thread patches/lolhtml/text-chunk-raw-passthrough.patch
Comment thread patches/lolhtml/text-chunk-raw-passthrough.patch Outdated
Comment thread test/js/workerd/html-rewriter.test.js Outdated
Comment thread test/js/workerd/html-rewriter.test.js
For non-UTF-8 document encodings the decoder slow path now passes raw=None
and falls back to re-encoding the decoded text, which is the pre-patch
behaviour: the tail accounting is UTF-8-specific and Bun only ever builds
the rewriter with UTF-8. The fast path's ASCII prefix is byte-identical in
every ASCII-compatible encoding so it keeps its borrowed raw slice.

The earlier 7-case observer block duplicated the larger matrix further down
without proving the handler fired; dropped it and moved its one unique case
(text outside the selector) into the matrix block.
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the only hard failure is the binary-size check, which compares against canary main #79916 (commit ae4b17de6d, 12 commits behind this PR's base). Every PR on current main shows the same deltas (e.g. #82438, a docs-only change, reports an identical +564.9 KB on darwin-aarch64, +544.5 KB on darwin-x64, etc.). The growth comes from #31823 and #34598 landing on main between the canary and this branch's base, not from this patch.

The two test annotations (webview-chrome.test.ts animation-stable click and terminal-platform-gaps.test.ts ConPTY padding) are marked flaky and passed on retry; neither touches HTMLRewriter.

The html-rewriter test suite itself passes on every lane that has finished. The diff is ready for review.

Comment thread test/js/workerd/html-rewriter.test.js

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

My earlier concern about utf8_incomplete_tail_len coverage is resolved — the Rust unit test in the patch (unmodified_chunk_raw_bytes_stay_code_point_aligned_across_writes) is the load-bearing check, and I confirmed BufferOutputSink::run_output_sink does a single write(bytes) so a JS-level split test would not reach the carry path. No new issues found; deferring to a human because this is a behavioral change to text serialization implemented as a vendored-dependency patch.

Checked: utf8_incomplete_tail_len agrees with encoding_rs's WHATWG UTF-8 state machine on the surrogate/overlong 2nd-byte edge cases (ED A0, F0 80 → tail=0); set_str/as_mut_str clear raw so mutated chunks re-encode; replace()/remove() go through mutations and skip serialize_self (test confirms); the patches: field follows the existing pattern used by libuv/boringssl/highway; non-UTF-8 encodings fall back to raw = None and Bun only builds with utf_8().

Extended reasoning...

Overview

The PR patches the vendored cloudflare/lol-html crate so that TextChunk carries the raw input bytes it was decoded from and serialize_self re-emits those bytes verbatim when the handler did not mutate the text. Previously, registering any text handler forced a lossy decode→re-encode round-trip that replaced non-UTF-8 bytes with U+FFFD even for pure observers. Three files: a new 387-line patch under patches/lolhtml/, a one-line patches: addition to scripts/build/deps/lolhtml.ts, and 122 lines of new tests in test/js/workerd/html-rewriter.test.js plus three Rust unit tests inside the patch itself.

Security risks

None identified. The change relaxes output transcoding to pass input bytes through unchanged; it does not parse, allocate from, or trust any new user-controlled quantity. pending_raw is bounded at ≤3 bytes for UTF-8 and cleared on every last_in_text_node. The serialize_self change writes raw bytes directly to the output sink, which is what every other token type (tags, comments, doctypes) already does when unmodified.

Level of scrutiny

This warrants human review. It patches a vendored dependency's serialization path with subtle UTF-8 boundary accounting: utf8_incomplete_tail_len must exactly agree with encoding_rs's internal decoder state on every lead/continuation validity case, and pending_raw must stay code-point-aligned with the decoded text across the fast path, slow-path loop, skipped-handler branch, and flush. The reasoning in the patch comments and PR description is careful and I did not find a counterexample, but this is the kind of change where an off-by-one produces mojibake only on specific byte patterns at specific chunk boundaries. It also changes user-visible behavior (a text handler that reads .text and does nothing now leaves invalid bytes in the output rather than sanitizing them), which is arguably more correct but is a semantic shift a maintainer should sign off on.

Other factors

All four prior review threads (mine on raw/text misalignment and on carry-mechanism coverage, coderabbit's on non-UTF-8 encodings and on handler-invocation proof) are resolved with code changes or verified explanations. The 33 JS tests fail on USE_SYSTEM_BUN=1 and pass on the debug build; cargo test --lib in the vendored crate passes (146 tests). The cross-write carry is only reachable at the lol-html layer because Bun buffers the whole body into one write(), so the Rust unit tests are the appropriate place for that coverage. The patches: wiring matches the established pattern used by libuv, boringssl, highway, and others.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants