fix(encoding): snapshot decoder input after options conversion

This commit is contained in:
ldm0
2026-10-18 20:03:00 +08:00
committed by Donough Liu
parent a46e654706
commit 80618fce6c
5 changed files with 201 additions and 6 deletions
@@ -124,9 +124,9 @@ struct TextDecoderOptions {
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "TextDecoder.decode")]
struct TextDecoderDecodeArgs {
#[webidl(converter = "buffer_source")]
input: Option<webidl::BufferSource>,
struct TextDecoderDecodeArgs<'s> {
#[webidl(converter = "raw")]
input: Option<webidl::AllowSharedBufferSource<'s>>,
#[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::<TextDecoderDecodeArgs>(scope, &args) else {
let Some(parsed) = webidl::parse_args::<TextDecoderDecodeArgs<'s>>(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 {
@@ -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::<Vec<_>>();
assert!(failures.is_empty(), "{failures:#?}");
assert_eq!(checks.len(), 33);
}
@@ -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::<Vec<_>>();
assert!(failures.is_empty(), "{failures:#?}");
assert_eq!(checks.len(), 33);
}
@@ -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 };
};
+1 -1
View File
@@ -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> {