diff --git a/moli-renderer-v8/src/context_bootstrap/constructors/text_codecs.rs b/moli-renderer-v8/src/context_bootstrap/constructors/text_codecs.rs index 68eb022a0c..0de07f7d80 100644 --- a/moli-renderer-v8/src/context_bootstrap/constructors/text_codecs.rs +++ b/moli-renderer-v8/src/context_bootstrap/constructors/text_codecs.rs @@ -124,9 +124,9 @@ struct TextDecoderOptions { #[derive(webidl::WebIdlArgs)] #[webidl(prefix = "TextDecoder.decode")] -struct TextDecoderDecodeArgs { - #[webidl(converter = "buffer_source")] - input: Option, +struct TextDecoderDecodeArgs<'s> { + #[webidl(converter = "raw")] + input: Option>, #[webidl(index = 1, dictionary)] options: TextDecodeOptions, } @@ -327,7 +327,7 @@ fn text_decoder_decode_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let Some(parsed) = webidl::parse_args::(scope, &args) else { + let Some(parsed) = webidl::parse_args::>(scope, &args) else { rv.set_undefined(); return; }; @@ -339,7 +339,7 @@ fn text_decoder_decode_callback<'s>( let stream = parsed.options.stream; let bytes = parsed .input - .map(webidl::BufferSource::into_bytes) + .map(|input| input.to_vec(scope)) .unwrap_or_default(); let Some(decode_result) = decode_text_decoder_state(scope, decoder_id, &bytes, stream) else { diff --git a/moli-renderer-v8/src/script_vm/tests/webidl_fetch/webidl_and_transform_streams.rs b/moli-renderer-v8/src/script_vm/tests/webidl_fetch/webidl_and_transform_streams.rs index 5a67c99794..852a73355a 100644 --- a/moli-renderer-v8/src/script_vm/tests/webidl_fetch/webidl_and_transform_streams.rs +++ b/moli-renderer-v8/src/script_vm/tests/webidl_fetch/webidl_and_transform_streams.rs @@ -1634,3 +1634,26 @@ fn transform_stream_caught_enqueue_strategy_error_errors_stream_but_resolves_wri r#"["closed:RangeError","enqueue:RangeError","readable:RangeError","ready:RangeError","write:resolved"]"# ); } + +#[test] +fn text_decoder_snapshots_input_after_options_conversion() { + let mut vm = new_storage_test_vm("https://text-decoder-input-snapshot.test/"); + vm.eval(include_str!( + "../../../../tests/fixtures/text-decoder-input-snapshot.js" + )) + .expect("text decoder input snapshot contract should load"); + vm.eval("runTextDecoderInputSnapshotContract().then(result => globalThis.__textDecoderInputSnapshotResults = result)") + .expect("text decoder input snapshot contract should execute"); + let facts: serde_json::Value = serde_json::from_str( + &vm.eval("JSON.stringify(__textDecoderInputSnapshotResults)") + .expect("text decoder input snapshot results should serialize"), + ) + .expect("text decoder input snapshot results should be valid JSON"); + let checks = facts["checks"].as_array().expect("input snapshot checks"); + let failures = checks + .iter() + .filter(|check| check["passed"] != true) + .collect::>(); + assert!(failures.is_empty(), "{failures:#?}"); + assert_eq!(checks.len(), 33); +} diff --git a/moli-renderer-v8/src/worker/thread/tests/lifecycle/background_apis_and_worker_events.rs b/moli-renderer-v8/src/worker/thread/tests/lifecycle/background_apis_and_worker_events.rs index 4300000245..9cf1b74503 100644 --- a/moli-renderer-v8/src/worker/thread/tests/lifecycle/background_apis_and_worker_events.rs +++ b/moli-renderer-v8/src/worker/thread/tests/lifecycle/background_apis_and_worker_events.rs @@ -1784,3 +1784,37 @@ async fn worker_xmlhttprequest_loadstart_handles_upload_abort_and_reopen() { r#"{"aborted":[["xhr-start:0:0:false","upload-abort:4:0:0:false","upload-loadend:4:0:0:false","xhr-abort:4","xhr-loadend:4","after-send:0:0"],["xhr-start:0:0:false","upload-start:0:2:true","upload-abort:4:0:0:false","upload-loadend:4:0:0:false","xhr-abort:4","xhr-loadend:4","after-send:0:0"],["xhr-start:0:0:false","upload-start:0:0:false","upload-abort:4:0:0:false","upload-loadend:4:0:0:false","xhr-abort:4","xhr-loadend:4","after-send:0:0"]],"reopened":["xhr-start:1","upload-start:0:2","xhr-start:1","after-reopen:1","load:replacement","loadend:4"]}"# ); } + +#[tokio::test] +async fn worker_text_decoder_snapshots_input_after_options_conversion() { + ensure_v8(); + let source = [ + include_str!("../../../../../tests/fixtures/text-decoder-input-snapshot.js"), + r#" +runTextDecoderInputSnapshotContract().then( + result => { postMessage(result); close(); }, + error => { postMessage({ failure: String(error) }); close(); } +); +"#, + ] + .join("\n"); + let mut handle = spawn_worker( + source, + "https://text-decoder-input-snapshot.test/worker.js".into(), + ); + let message = timeout(TIMEOUT, handle.recv()) + .await + .expect("text decoder input snapshot contract should complete"); + handle.terminate_and_join(); + let facts: serde_json::Value = serde_json::from_str(&expect_post_json( + message.expect("worker result channel should stay open"), + )) + .expect("text decoder input snapshot results should be valid JSON"); + let checks = facts["checks"].as_array().expect("input snapshot checks"); + let failures = checks + .iter() + .filter(|check| check["passed"] != true) + .collect::>(); + assert!(failures.is_empty(), "{failures:#?}"); + assert_eq!(checks.len(), 33); +} diff --git a/moli-renderer-v8/tests/fixtures/text-decoder-input-snapshot.js b/moli-renderer-v8/tests/fixtures/text-decoder-input-snapshot.js new file mode 100644 index 0000000000..364e85680e --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/text-decoder-input-snapshot.js @@ -0,0 +1,138 @@ +globalThis.runTextDecoderInputSnapshotContract = async function () { + const checks = []; + function check(name, operation) { + try { checks.push({ name, passed: operation() === true }); } + catch (error) { checks.push({ name, passed: false, error: String(error) }); } + } + for (const [name, make] of [ + ["buffer", () => { + const bytes = Uint8Array.of(65); + return { input: bytes.buffer, mutate: () => bytes[0] = 66 }; + }], + ["view offset", () => { + const bytes = Uint8Array.of(0, 65, 0); + return { input: bytes.subarray(1, 2), mutate: () => bytes[1] = 66 }; + }], + ["DataView offset", () => { + const bytes = Uint8Array.of(0, 65, 0); + return { input: new DataView(bytes.buffer, 1, 1), mutate: () => bytes[1] = 66 }; + }], + ["shared buffer", () => { + const bytes = new Uint8Array(new SharedArrayBuffer(1)); + bytes[0] = 65; + return { input: bytes.buffer, mutate: () => Atomics.store(bytes, 0, 66) }; + }], + ["shared view offset", () => { + const bytes = new Uint8Array(new SharedArrayBuffer(3)); + bytes[1] = 65; + return { input: bytes.subarray(1, 2), mutate: () => Atomics.store(bytes, 1, 66) }; + }], + ]) { + check(`snapshot after options: ${name}`, () => { + const { input, mutate } = make(); + let reads = 0; + const output = new TextDecoder().decode(input, { + get stream() { reads++; mutate(); return false; }, + }); + return reads === 1 && output === "B"; + }); + } + for (const length of [1, 10000]) { + for (const kind of ["buffer", "view", "DataView"]) { + check(`detach during options: ${kind} length=${length}`, () => { + const bytes = new Uint8Array(length).fill(42); + const input = kind === "buffer" ? bytes.buffer : kind === "view" ? bytes : new DataView(bytes.buffer); + let reads = 0; + const output = new TextDecoder().decode(input, { + get stream() { reads++; bytes.buffer.transfer(0); return false; }, + }); + return reads === 1 && output === ""; + }); + } + } + const invalidSources = [ + ["null", () => null], + ["object", () => ({})], + ["array", () => []], + ["forged view", () => Object.create(Uint8Array.prototype)], + ["Proxy", () => new Proxy(Uint8Array.of(65), {})], + ["revoked Proxy", () => { + const proxy = Proxy.revocable(Uint8Array.of(65), {}); + proxy.revoke(); + return proxy.proxy; + }], + ]; + for (const shared of [false, true]) { + for (const kind of ["buffer", "view", "DataView"]) { + invalidSources.push([`${shared ? "growable shared" : "resizable"} ${kind}`, () => { + const buffer = shared ? new SharedArrayBuffer(1, { maxByteLength: 2 }) : + new ArrayBuffer(1, { maxByteLength: 2 }); + return kind === "buffer" ? buffer : kind === "view" ? new Uint8Array(buffer) : new DataView(buffer); + }]); + } + } + for (const kind of ["buffer", "view"]) { + invalidSources.push([`detached resizable ${kind}`, () => { + const buffer = new ArrayBuffer(1, { maxByteLength: 2 }); + const view = new Uint8Array(buffer); + buffer.transfer(0); + return kind === "buffer" ? buffer : view; + }]); + } + for (const [name, make] of invalidSources) { + check(`reject before options: ${name}`, () => { + const input = make(); + let reads = 0; + try { + new TextDecoder().decode(input, { get stream() { reads++; return false; } }); + return false; + } catch (error) { return error instanceof TypeError && reads === 0; } + }); + } + check("options exception identity", () => { + const sentinel = {}; + try { + new TextDecoder().decode(Uint8Array.of(65), { get stream() { throw sentinel; } }); + return false; + } catch (error) { return error === sentinel; } + }); + check("undefined converts options and flushes", () => { + const decoder = new TextDecoder(); + decoder.decode(Uint8Array.of(0xc9), { stream: true }); + let reads = 0; + const output = decoder.decode(undefined, { get stream() { reads++; return false; } }); + return reads === 1 && output === "\ufffd"; + }); + for (const kind of ["buffer", "view", "DataView", "shared buffer"]) { + check(`ignore author buffer getters: ${kind}`, () => { + const buffer = kind === "shared buffer" ? new SharedArrayBuffer(1) : new ArrayBuffer(1); + new Uint8Array(buffer)[0] = 65; + const input = kind === "view" ? new Uint8Array(buffer) : kind === "DataView" ? new DataView(buffer) : buffer; + let reads = 0; + for (const name of ["byteLength", "buffer", "byteOffset"]) { + Object.defineProperty(input, name, { get() { reads++; throw Error(name); } }); + } + return new TextDecoder().decode(input) === "A" && reads === 0; + }); + } + check("receiver check before options", () => { + let reads = 0; + try { + TextDecoder.prototype.decode.call({}, Uint8Array.of(65), { get stream() { reads++; return false; } }); + return false; + } catch (error) { return error instanceof TypeError && reads === 0; } + }); + check("reentrant options decode", () => { + const decoder = new TextDecoder(); + let reads = 0; + const output = decoder.decode(Uint8Array.of(0xac), { + get stream() { + reads++; + decoder.decode(Uint8Array.of(0xe2, 0x82), { stream: true }); + return false; + }, + }); + return reads === 1 && output === "\u20ac"; + }); + return { checks }; +}; diff --git a/moli-webidl/src/buffer_source.rs b/moli-webidl/src/buffer_source.rs index 199a5d70c0..3c15c81565 100644 --- a/moli-webidl/src/buffer_source.rs +++ b/moli-webidl/src/buffer_source.rs @@ -3,7 +3,7 @@ use crate::{Context, WebIdlConverter, WebIdlError}; use std::sync::atomic::{AtomicU8, Ordering}; -/// `[AllowShared] BufferSource`, retaining the native buffer identity instead +/// Web IDL `AllowSharedBufferSource`, retaining the native buffer identity instead /// of copying during conversion. Resizable and growable buffers require the /// separate `[AllowResizable]` annotation and are rejected here. pub enum AllowSharedBufferSource<'s> {