diff --git a/patches/lolhtml/text-chunk-raw-passthrough.patch b/patches/lolhtml/text-chunk-raw-passthrough.patch new file mode 100644 index 000000000000..57f808e1d2a4 --- /dev/null +++ b/patches/lolhtml/text-chunk-raw-passthrough.patch @@ -0,0 +1,387 @@ +--- a/src/rewritable_units/text_decoder.rs ++++ b/src/rewritable_units/text_decoder.rs +@@ -1,6 +1,7 @@ + use crate::base::{Bytes, SharedEncoding, SourceLocation, Spanned}; + use crate::rewriter::RewritingError; + use encoding_rs::{CoderResult, Decoder, Encoding, UTF_8}; ++use std::borrow::Cow; + + const DEFAULT_BUFFER_LEN: usize = if cfg!(test) { 13 } else { 1024 }; + +@@ -8,11 +9,23 @@ + encoding: SharedEncoding, + pending_source_location_bytes_start: usize, + pending_text_streaming_decoder: Option, ++ // Raw input bytes the decoder has consumed whose decoded characters have ++ // NOT yet been delivered to `output_handler`. For UTF-8 this is exactly ++ // the incomplete-sequence tail encoding_rs keeps in its own state (<= 3 ++ // bytes); it is owned here so the NEXT emitted chunk can expose those ++ // bytes as its `raw` prefix and preserve byte-identical passthrough. ++ pending_raw: Vec, + text_buffer: String, + } + +-pub(crate) type OutputHandlerCallback<'tmp> = +- dyn FnMut(&str, bool, &'static Encoding, SourceLocation) -> Result<(), RewritingError> + 'tmp; ++pub(crate) type OutputHandlerCallback<'tmp, 'i> = dyn FnMut( ++ &str, ++ Option>, ++ bool, ++ &'static Encoding, ++ SourceLocation, ++ ) -> Result<(), RewritingError> ++ + 'tmp; + + impl TextDecoder { + #[inline] +@@ -22,6 +35,7 @@ + pending_source_location_bytes_start: 0, + encoding, + pending_text_streaming_decoder: None, ++ pending_raw: Vec::new(), + // this will be later initialized to DEFAULT_BUFFER_LEN, + // because encoding_rs wants a slice + text_buffer: String::new(), +@@ -31,7 +45,7 @@ + #[inline] + pub fn flush_pending( + &mut self, +- output_handler: &mut OutputHandlerCallback<'_>, ++ output_handler: &mut OutputHandlerCallback<'_, '_>, + ) -> Result<(), RewritingError> { + if self.pending_text_streaming_decoder.is_some() { + self.feed_text( +@@ -44,11 +58,11 @@ + } + + #[inline(never)] +- pub fn feed_text( ++ pub fn feed_text<'i>( + &mut self, +- input_span: Spanned>, ++ input_span: Spanned>, + last_in_text_node: bool, +- output_handler: &mut OutputHandlerCallback<'_>, ++ output_handler: &mut OutputHandlerCallback<'_, 'i>, + ) -> Result<(), RewritingError> { + let mut raw_input = input_span.as_slice(); + let mut next_source_location_bytes_start = input_span.source_location().bytes().start; +@@ -56,6 +70,7 @@ + let encoding = self.encoding.get(); + + if let Some((utf8_text, rest)) = self.split_utf8_start(raw_input, encoding) { ++ debug_assert!(self.pending_raw.is_empty()); + raw_input = rest; + let really_last = last_in_text_node && rest.is_empty(); + +@@ -63,7 +78,13 @@ + SourceLocation::from_start_len(next_source_location_bytes_start, utf8_text.len()); + next_source_location_bytes_start = source_location.bytes().end; + +- (output_handler)(utf8_text, really_last, encoding, source_location)?; ++ (output_handler)( ++ utf8_text, ++ Some(Cow::Borrowed(utf8_text.as_bytes())), ++ really_last, ++ encoding, ++ source_location, ++ )?; + + if really_last { + debug_assert!(self.pending_text_streaming_decoder.is_none()); +@@ -89,6 +110,37 @@ + SourceLocation::from_start_len(next_source_location_bytes_start, read); + next_source_location_bytes_start = source_location.bytes().end; + ++ // Raw-byte accounting for this chunk (UTF-8 only): ++ // raw(chunk) = pending_raw ++ raw_input[..read - tail] ++ // pending_raw' = raw_input[read - tail .. read] ++ // where `tail` is the number of bytes encoding_rs kept as internal ++ // state (an incomplete sequence at end-of-input). OutputFull and ++ // last=true never leave state, so `tail` is only nonzero on the ++ // final InputEmpty of a non-last call. For non-UTF-8 document ++ // encodings `raw` is `None` and the serializer re-encodes the ++ // decoded text, which is the pre-patch behaviour: the tail-length ++ // computation is UTF-8-specific and other multi-byte encodings are ++ // not worth the extra state. ++ let (raw, carry): (Option>, &[u8]) = if encoding == UTF_8 { ++ let consumed = raw_input.get(..read).unwrap_or_default(); ++ let tail = if finished_decoding && !last_in_text_node { ++ utf8_incomplete_tail_len(consumed) ++ } else { ++ 0 ++ }; ++ let (emit, carry) = consumed.split_at(read - tail); ++ let raw = if self.pending_raw.is_empty() { ++ Cow::Borrowed(emit) ++ } else { ++ let mut v = std::mem::take(&mut self.pending_raw); ++ v.extend_from_slice(emit); ++ Cow::Owned(v) ++ }; ++ (Some(raw), carry) ++ } else { ++ (None, &[][..]) ++ }; ++ + if written > 0 || last_in_text_node { + // the last call to feed_text() may make multiple calls to output_handler, + // but only one call to output_handler can be *the* last one. +@@ -97,15 +149,25 @@ + (output_handler)( + // this will always be in bounds, but unwrap_or_default optimizes better + buffer.get(..written).unwrap_or_default(), ++ raw, + really_last, + encoding, + source_location, + )?; ++ self.pending_raw.clear(); ++ self.pending_raw.extend_from_slice(carry); ++ } else if let Some(raw) = raw { ++ // Handler call skipped: everything we would have emitted as raw ++ // (including any carried prefix) defers to the next chunk. ++ let mut deferred = raw.into_owned(); ++ deferred.extend_from_slice(carry); ++ self.pending_raw = deferred; + } + + if finished_decoding { + if last_in_text_node { + self.pending_text_streaming_decoder = None; ++ debug_assert!(self.pending_raw.is_empty()); + } else { + self.pending_source_location_bytes_start = next_source_location_bytes_start; + } +@@ -152,3 +214,20 @@ + } + } + } ++ ++/// Length of the incomplete-but-still-valid UTF-8 sequence at the end of ++/// `input`: the bytes a WHATWG UTF-8 decoder holds as state when fed `input` ++/// with `last=false`. At most 3 bytes. ++#[inline] ++fn utf8_incomplete_tail_len(input: &[u8]) -> usize { ++ let mut tail = &input[input.len().saturating_sub(3)..]; ++ loop { ++ match std::str::from_utf8(tail) { ++ Ok(_) => return 0, ++ Err(e) => match e.error_len() { ++ None => return tail.len() - e.valid_up_to(), ++ Some(n) => tail = &tail[e.valid_up_to() + n..], ++ }, ++ } ++ } ++} +--- a/src/rewritable_units/tokens/text_chunk.rs ++++ b/src/rewritable_units/tokens/text_chunk.rs +@@ -74,6 +74,9 @@ + /// [`last_in_text_node`]: #method.last_in_text_node + pub struct TextChunk<'i> { + text: Cow<'i, str>, ++ // Input bytes this chunk was decoded from. Cleared by any API that ++ // mutates `text` so `serialize_self` only uses it for true passthrough. ++ raw: Option>, + text_type: TextType, + last_in_text_node: bool, + encoding: &'static Encoding, +@@ -87,6 +90,7 @@ + #[must_use] + pub(crate) fn new( + text: &'i str, ++ raw: Option>, + text_type: TextType, + last_in_text_node: bool, + encoding: &'static Encoding, +@@ -94,6 +98,7 @@ + ) -> Self { + TextChunk { + text: text.into(), ++ raw, + text_type, + last_in_text_node, + encoding, +@@ -130,6 +135,7 @@ + /// It may be necessary to buffer the text. See [`TextChunk::last_in_text_node`]. + #[inline] + pub fn as_mut_str(&mut self) -> &mut String { ++ self.raw = None; + self.text.to_mut() + } + +@@ -139,6 +145,7 @@ + /// See [`TextChunk::text_type`]. + #[inline] + pub fn set_str(&mut self, text: String) { ++ self.raw = None; + self.text = Cow::Owned(text); + } + +@@ -354,7 +361,11 @@ + + #[inline] + fn serialize_self(&self, sink: &mut StreamingHandlerSink<'_>) -> Result<(), RewritingError> { +- if !self.text.is_empty() { ++ if let Some(raw) = &self.raw { ++ if !raw.is_empty() { ++ (sink.output_handler())(raw); ++ } ++ } else if !self.text.is_empty() { + // The "text" here is actually markup + sink.write_str(&self.text, ContentType::Html); + } +@@ -429,6 +440,7 @@ + let encoding = Encoding::for_label_no_replacement(b"utf-8").unwrap(); + let mut chunk = TextChunk::new( + "original text", ++ None, + TextType::PlainText, + true, + encoding, +@@ -444,6 +456,7 @@ + + mod serialization { + use super::*; ++ use crate::{ElementContentHandlers, HandlerResult, HtmlRewriter, Settings}; + + const HTML: &str = "Lorem ipsum dolor sit amet, cÔnsectetur adipiscing elit, sed do eiusmod tempor \ + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ +@@ -568,5 +581,87 @@ + }); + assert_eq!("

\u{fffd}\u{fffd}\u{fffd} last

", rewritten); + } ++ ++ fn rewrite_with_writes( ++ writes: &[&[u8]], ++ handler: impl FnMut(&mut TextChunk<'_>) -> HandlerResult + 'static, ++ ) -> Vec { ++ let mut out = Vec::new(); ++ let mut r = HtmlRewriter::new( ++ Settings { ++ element_content_handlers: vec![( ++ std::borrow::Cow::Owned("p".parse().unwrap()), ++ ElementContentHandlers::default().text(handler), ++ )], ++ ..Settings::new() ++ }, ++ |c: &[u8]| out.extend_from_slice(c), ++ ); ++ for w in writes { ++ r.write(w).unwrap(); ++ } ++ r.end().unwrap(); ++ out ++ } ++ ++ #[test] ++ fn unmodified_chunk_raw_bytes_stay_code_point_aligned_across_writes() { ++ // `

a😀b

` with the write boundary inside 😀 (F0 9F 98 80), ++ // and a handler that conditionally replaces only one of the two ++ // resulting chunks. Each unmodified chunk's raw bytes must begin ++ // and end on a code-point boundary, so the output stays valid ++ // UTF-8 regardless of which neighbour was replaced. ++ let writes: &[&[u8]] = &[b"

a\xF0\x9F", b"\x98\x80b

"]; ++ ++ assert_eq!( ++ rewrite_with_writes(writes, |t| { ++ if t.as_str().contains('\u{1F600}') { ++ t.replace(":)", ContentType::Text); ++ } ++ Ok(()) ++ }), ++ b"

a:)

", ++ ); ++ ++ assert_eq!( ++ rewrite_with_writes(writes, |t| { ++ if t.as_str() == "a" { ++ t.replace("X", ContentType::Text); ++ } ++ Ok(()) ++ }), ++ "

X\u{1F600}b

".as_bytes(), ++ ); ++ ++ assert_eq!( ++ rewrite_with_writes(writes, |_| Ok(())), ++ "

a\u{1F600}b

".as_bytes(), ++ ); ++ } ++ ++ #[test] ++ fn write_containing_only_an_incomplete_lead_does_not_emit_an_empty_chunk() { ++ // `

a€b

` with the € split as `E2 82` / `AC`. The first text ++ // write (lexeme "a") is followed by one whose decoded text would ++ // be empty (just the buffered `E2 82`): the handler must not be ++ // invoked for that empty chunk, and a later `before()` must not ++ // land between the lead bytes and the continuation. ++ let writes: &[&[u8]] = &[b"

a", b"\xE2\x82", b"\xACb

"]; ++ let out = rewrite_with_writes(writes, |t| { ++ assert!(t.last_in_text_node() || !t.as_str().is_empty()); ++ t.before("|", ContentType::Text); ++ Ok(()) ++ }); ++ assert_eq!(std::str::from_utf8(&out).unwrap(), "

|a|\u{20AC}b|

"); ++ } ++ ++ #[test] ++ fn non_utf8_bytes_pass_through_when_unmodified() { ++ let input: &[u8] = b"

a\x93\xE9\x94\xC0\xAF\xFF\xED\xA0\x80b

c\xE9d"; ++ assert_eq!( ++ rewrite_with_writes(&[input], |_| Ok(())), ++ input, ++ ); ++ } + } + } +--- a/src/transform_stream/dispatcher.rs ++++ b/src/transform_stream/dispatcher.rs +@@ -11,6 +11,7 @@ + use crate::rewritable_units::{DocumentEnd, Serialize, ToToken, Token, TokenCaptureFlags}; + use crate::rewriter::RewritingError; + use encoding_rs::Encoding; ++use std::borrow::Cow; + + pub(crate) struct AuxStartTagInfo<'i> { + pub input: &'i Bytes<'i>, +@@ -149,6 +150,7 @@ + fn text_token_produced( + &mut self, + text: &str, ++ raw: Option>, + encoding: &'static Encoding, + text_type: TextType, + is_last_in_node: bool, +@@ -156,6 +158,7 @@ + ) -> Result<(), RewritingError> { + let mut token = Token::TextChunk(TextChunk::new( + text, ++ raw, + text_type, + is_last_in_node, + encoding, +@@ -219,9 +222,10 @@ + self.text_decoder.feed_text( + lexeme.spanned(), + false, +- &mut |text, is_last, encoding, source_location| { ++ &mut |text, raw, is_last, encoding, source_location| { + self.delegate.text_token_produced( + text, ++ raw, + encoding, + self.last_text_type, + is_last, +@@ -331,9 +335,10 @@ + #[inline] + fn flush_pending_captured_text(&mut self) -> Result<(), RewritingError> { + self.text_decoder +- .flush_pending(&mut |text, is_last, encoding, source_location| { ++ .flush_pending(&mut |text, raw, is_last, encoding, source_location| { + self.delegate.text_token_produced( + text, ++ raw, + encoding, + self.last_text_type, + is_last, diff --git a/scripts/build/deps/lolhtml.ts b/scripts/build/deps/lolhtml.ts index c4edb18e5362..a43be6e2b906 100644 --- a/scripts/build/deps/lolhtml.ts +++ b/scripts/build/deps/lolhtml.ts @@ -33,6 +33,8 @@ export const lolhtml: Dependency = { commit: LOLHTML_COMMIT, }), + patches: ["patches/lolhtml/text-chunk-raw-passthrough.patch"], + // No separate build — compiled as part of the workspace cargo build via // `bun_runtime`/`bun_bundler`'s path dep on `vendor/lolhtml`. build: () => ({ kind: "none" }), diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index 6ffc837062ed..efce3b281e08 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -1261,3 +1261,125 @@ describe("tagName, endTag.name, and comment.text setters", () => { expect(savedComment.text).toBeNull(); }); }); + +describe("text handler does not transcode unmodified non-UTF-8 bytes", () => { + // A text handler forces every text token through encoding_rs's lossy decoder + // so the callback can observe `t.text` as a JS string. Previously the + // serializer then wrote that decoded string back, replacing every non-UTF-8 + // input byte with U+FFFD even when the handler was a pure observer. The + // rewriter now re-emits the original input bytes for any chunk whose text + // was not replaced, so a no-op text handler is byte-identical to no handler. + + const wrap = body => Buffer.concat([Buffer.from("

"), Buffer.from(body), Buffer.from("

")]); + const rewrite = async (src, spec) => + Buffer.from(await new HTMLRewriter().on("*", spec).transform(new Response(src)).arrayBuffer()); + + // - lone continuation byte, multiple invalid bytes, invalid/valid UTF-8 mixed + // - truncated 2/3/4-byte sequence immediately before the closing tag (these + // are the bytes encoding_rs holds as decoder state and flushes on the + // lastInTextNode chunk) + // - text node that is ONLY a truncated sequence (handler call for the body + // is skipped entirely; only the flush chunk fires) + // - invalid byte past 1 KB (fast-path prefix then slow path for the rest) + // and before 1 KB (slow path loops past one decode-buffer fill) + // - ScriptData text-type and multiple sibling text nodes + const cases = [ + ["lone continuation byte", wrap([0xa9])], + ["multiple invalid bytes", wrap([0xa9, 0xff, 0x80, 0xc0])], + [ + "mixed valid and invalid UTF-8", + Buffer.concat([Buffer.from("

héllo"), Buffer.from([0xa9]), Buffer.from("wörld

")]), + ], + [ + "truncated 3-byte lead before close tag", + Buffer.concat([Buffer.from("

aa"), Buffer.from([0xe2]), Buffer.from("

")]), + ], + [ + "truncated 3-byte prefix before close tag", + Buffer.concat([Buffer.from("

aa"), Buffer.from([0xe2, 0x82]), Buffer.from("

")]), + ], + ["truncated 4-byte prefix before close tag", wrap([0xf0, 0x9f, 0x98])], + ["text node that is only a truncated lead byte", wrap([0xe2])], + [ + "invalid byte after the 1 KB fast-path cutoff", + Buffer.concat([Buffer.from("

"), Buffer.alloc(2000, 0x61), Buffer.from([0xa9]), Buffer.from("z

")]), + ], + [ + "invalid byte before the 1 KB fast-path cutoff", + Buffer.concat([Buffer.from("

"), Buffer.from([0xa9]), Buffer.alloc(3000, 0x62), Buffer.from("

")]), + ], + ["script text", Buffer.concat([Buffer.from("")])], + [ + "multiple text nodes", + Buffer.concat([ + Buffer.from("

"), + Buffer.from([0xa9]), + Buffer.from(""), + Buffer.from([0xff]), + Buffer.from(""), + Buffer.from([0xc0]), + Buffer.from("

"), + ]), + ], + ]; + + describe.each(cases)("%s", (name, src) => { + it("no-op element text handler", async () => { + expect(await rewrite(src, { text() {} })).toEqual(src); + }); + it("no-op onDocument text handler", async () => { + const out = Buffer.from( + await new HTMLRewriter() + .onDocument({ text() {} }) + .transform(new Response(src)) + .arrayBuffer(), + ); + expect(out).toEqual(src); + }); + }); + + it("reading .text observes U+FFFD but the output is still the raw bytes", async () => { + const src = Buffer.concat([Buffer.from("

x"), Buffer.from([0xa9]), Buffer.from("y

")]); + let seen = ""; + const out = await rewrite(src, { text: t => void (seen += t.text) }); + expect(seen).toBe("x\uFFFDy"); + expect(out).toEqual(src); + }); + + it("a truncated lead byte is reported as U+FFFD on the lastInTextNode chunk and still round-trips", async () => { + const src = wrap([0xe2]); + const chunks = []; + const out = await rewrite(src, { text: t => chunks.push({ text: t.text, last: t.lastInTextNode }) }); + expect(out).toEqual(src); + expect(chunks).toEqual([{ text: "\uFFFD", last: true }]); + }); + + it("before()/after() keep the chunk body as raw bytes", async () => { + const src = wrap([0xa9]); + const out = await rewrite(src, { + text(t) { + if (!t.lastInTextNode) t.before("[", { html: true }); + if (t.lastInTextNode) t.after("]", { html: true }); + }, + }); + expect(out).toEqual(Buffer.concat([Buffer.from("

["), Buffer.from([0xa9]), Buffer.from("]

")])); + }); + + it("replace() and remove() still drop the raw bytes", async () => { + const src = wrap([0xa9]); + expect(await rewrite(src, { text: t => t.replace("X", { html: true }) })).toEqual(Buffer.from("

XX

")); + expect(await rewrite(src, { text: t => t.remove() })).toEqual(Buffer.from("

")); + }); + + it("text outside the selector passes through even when matched text is replaced", async () => { + //

a\xa9b

c\xe9d + const src = Buffer.concat([wrap([0x61, 0xa9, 0x62]), Buffer.from([0x63, 0xe9, 0x64])]); + const out = Buffer.from( + await new HTMLRewriter() + .on("p", { text: t => t.replace("x", { html: true }) }) + .transform(new Response(src)) + .arrayBuffer(), + ); + expect(out).toEqual(Buffer.concat([Buffer.from("

xx

c"), Buffer.from([0xe9, 0x64])])); + }); +});