From 21394e198e63d223d32e98c150bdb604f2bfee9f Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 4 Oct 2026 08:58:57 +0800 Subject: [PATCH] fix(pointerevents): validate capture arguments and query pending targets --- .../context_host/pointer_capture.rs | 8 - .../native_bridge/element/base_prototypes.rs | 3 + .../native_bridge/element/pointer_capture.rs | 38 +++-- .../src/script_vm/tests/browser_api/mod.rs | 1 + .../tests/browser_api/pointer_capture.js | 139 ++++++++++++++++++ .../tests/browser_api/pointer_capture.rs | 79 ++++++++++ 6 files changed, 240 insertions(+), 28 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/pointer_capture.js create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/pointer_capture.rs diff --git a/moli-renderer-v8/src/native_bridge/context_host/pointer_capture.rs b/moli-renderer-v8/src/native_bridge/context_host/pointer_capture.rs index ca4a9cd746..da171376f0 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/pointer_capture.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/pointer_capture.rs @@ -36,14 +36,6 @@ impl JsContextHost { .is_some_and(|current| *current == target) } - pub(crate) fn has_pointer_capture_target(&self, pointer_id: i32, target: DomHandle) -> bool { - self.has_pending_pointer_capture_target(pointer_id, target) - || self - .pointer_capture_targets - .get(&pointer_id) - .is_some_and(|current| *current == target && self.dom_host().is_connected(target)) - } - pub(crate) fn active_pointer_capture_target(&self, pointer_id: i32) -> Option { self.pointer_capture_targets .get(&pointer_id) diff --git a/moli-renderer-v8/src/native_bridge/element/base_prototypes.rs b/moli-renderer-v8/src/native_bridge/element/base_prototypes.rs index 4309bb9666..3cc7cac43d 100644 --- a/moli-renderer-v8/src/native_bridge/element/base_prototypes.rs +++ b/moli-renderer-v8/src/native_bridge/element/base_prototypes.rs @@ -121,6 +121,7 @@ pub(super) struct ElementPrototypeReflectionDeclaration { method = "setPointerCapture", length = 1, enumerable, + receiver = web_api_interfaces::Element::is_instance, callback = node_set_pointer_capture_callback )] set_pointer_capture: (), @@ -128,6 +129,7 @@ pub(super) struct ElementPrototypeReflectionDeclaration { method = "releasePointerCapture", length = 1, enumerable, + receiver = web_api_interfaces::Element::is_instance, callback = node_release_pointer_capture_callback )] release_pointer_capture: (), @@ -135,6 +137,7 @@ pub(super) struct ElementPrototypeReflectionDeclaration { method = "hasPointerCapture", length = 1, enumerable, + receiver = web_api_interfaces::Element::is_instance, callback = node_has_pointer_capture_callback )] has_pointer_capture: (), diff --git a/moli-renderer-v8/src/native_bridge/element/pointer_capture.rs b/moli-renderer-v8/src/native_bridge/element/pointer_capture.rs index 300173ee5a..dfafaa73df 100644 --- a/moli-renderer-v8/src/native_bridge/element/pointer_capture.rs +++ b/moli-renderer-v8/src/native_bridge/element/pointer_capture.rs @@ -1,13 +1,12 @@ -use super::super::node::{node_is_element, node_runtime_and_handle_from_args_or_detached}; -use super::super::{throw_dom_exception, webidl_long_from_number}; +use super::super::node::node_runtime_and_handle_from_args_or_detached; +use super::super::throw_dom_exception; +use crate::webidl; -fn pointer_id_arg( - scope: &mut v8::PinScope<'_, '_>, - args: &v8::FunctionCallbackArguments<'_>, -) -> i32 { - let value = args.get(0); - let number = value.number_value(scope).unwrap_or(0.0); - webidl_long_from_number(number) +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "Element pointer capture")] +struct PointerCaptureArgs { + #[webidl(required, name = "pointerId")] + pointer_id: i32, } pub(super) fn node_set_pointer_capture_callback<'s>( @@ -19,11 +18,10 @@ pub(super) fn node_set_pointer_capture_callback<'s>( else { return; }; - let pointer_id = pointer_id_arg(scope, &args); - let runtime = unsafe { &mut *runtime_ptr }; - if !node_is_element(runtime, handle) { + let Some(PointerCaptureArgs { pointer_id }) = webidl::parse_args(scope, &args) else { return; - } + }; + let runtime = unsafe { &mut *runtime_ptr }; if !pointer_capture_receiver_has_frame(scope, runtime, runtime_ptr, handle, args.this()) { return; } @@ -52,11 +50,10 @@ pub(super) fn node_release_pointer_capture_callback<'s>( else { return; }; - let pointer_id = pointer_id_arg(scope, &args); - let runtime = unsafe { &mut *runtime_ptr }; - if !node_is_element(runtime, handle) { + let Some(PointerCaptureArgs { pointer_id }) = webidl::parse_args(scope, &args) else { return; - } + }; + let runtime = unsafe { &mut *runtime_ptr }; if !pointer_capture_receiver_has_frame(scope, runtime, runtime_ptr, handle, args.this()) { return; } @@ -84,14 +81,15 @@ pub(super) fn node_has_pointer_capture_callback<'s>( rv.set_bool(false); return; }; - let pointer_id = pointer_id_arg(scope, &args); + let Some(PointerCaptureArgs { pointer_id }) = webidl::parse_args(scope, &args) else { + return; + }; let runtime = unsafe { &*runtime_ptr }; if !pointer_capture_receiver_has_frame(scope, runtime, runtime_ptr, handle, args.this()) { rv.set_bool(false); return; } - let has_capture = - node_is_element(runtime, handle) && runtime.has_pointer_capture_target(pointer_id, handle); + let has_capture = runtime.has_pending_pointer_capture_target(pointer_id, handle); rv.set_bool(has_capture); } diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 54094e49d9..3d5174a1ea 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -39,6 +39,7 @@ mod navigation; mod performance; mod performance_memory; mod platform_identity; +mod pointer_capture; mod pointer_lock; mod popup_hyperlinks; mod promise_rejection; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/pointer_capture.js b/moli-renderer-v8/src/script_vm/tests/browser_api/pointer_capture.js new file mode 100644 index 0000000000..f0d8761b02 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/pointer_capture.js @@ -0,0 +1,139 @@ +globalThis.__pointerCaptureProbe = realms => { + const rows = [], errors = []; + const record = (label, checks, observed = null) => rows.push({label, checks, observed}); + const run = (label, work) => { try { work(); } catch (error) { errors.push({label, error: String(error.stack || error)}); } }; + const names = ['setPointerCapture', 'releasePointerCapture', 'hasPointerCapture']; + const outcome = work => { try { return {value: work()}; } catch (error) { return {error}; } }; + for (const [r, realm] of realms.entries()) for (const name of names) { + const prefix = `${r}/${name}`, method = realm.Element.prototype[name]; + run(prefix + '/metadata', () => { + const d = Object.getOwnPropertyDescriptor(realm.Element.prototype, name); + record(prefix + '/metadata', {name: method.name === name, length: method.length === 1, + writable: d.writable, enumerable: d.enumerable, configurable: d.configurable}); + }); + for (const [owner, window] of realms.entries()) { + const document = window.document; + const detachedDocument = document.implementation.createHTMLDocument(''); + const element = document.createElement('div'); + document.body.append(element); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const receivers = [['live', element], ['detached', document.createElement('div')], + ['svg', svg], ['windowless', detachedDocument.createElement('div')], + ['native-proxy', detachedDocument.createElement('select')]]; + for (const [kind, receiver] of receivers) { + const label = `${prefix}/${owner}/${kind}`; + run(label + '/required', () => { + const result = outcome(() => method.call(receiver)); + record(label + '/required', {typeError: result.error instanceof realm.TypeError}, result.error?.name || 'returned'); + }); + for (const [n, value] of [undefined, null, false, 0, -0, NaN, Infinity, -Infinity, 2 ** 32, '0'].entries()) run(label + '/number-' + n, () => { + const result = outcome(() => method.call(receiver, value)); + const noFrame = kind === 'windowless' || kind === 'native-proxy'; + const expectedValue = name === 'hasPointerCapture' ? false : undefined; + record(label + '/number-' + n, {result: noFrame || name === 'hasPointerCapture' + ? !result.error && result.value === expectedValue + : result.error instanceof realm.DOMException && result.error.name === 'NotFoundError' && result.error.code === 8}, result.error?.name || String(result.value)); + }); + for (const [n, value] of [Symbol('id'), 1n, Object(1n)].entries()) run(label + '/type-' + n, () => { + const result = outcome(() => method.call(receiver, value)); + record(label + '/type-' + n, {typeError: result.error instanceof realm.TypeError}, result.error?.name || 'returned'); + }); + for (const hook of ['symbol', 'valueOf', 'toString']) run(label + '/throw-' + hook, () => { + const sentinel = {}, trace = []; + const value = hook === 'symbol' ? {[Symbol.toPrimitive](hint) {trace.push(hint); throw sentinel;}} + : hook === 'valueOf' ? {valueOf() {trace.push('valueOf'); throw sentinel;}} + : {valueOf() {trace.push('valueOf'); return {};}, toString() {trace.push('toString'); throw sentinel;}}; + const result = outcome(() => method.call(receiver, value)); + record(label + '/throw-' + hook, {identity: result.error === sentinel, + order: JSON.stringify(trace) === JSON.stringify(hook === 'symbol' ? ['number'] : hook === 'valueOf' ? ['valueOf'] : ['valueOf', 'toString'])}, trace); + }); + run(label + '/author-number-proxy', () => { + const trace = []; + const value = new Proxy({}, {get(_, key) {trace.push(String(key)); return key === Symbol.toPrimitive ? hint => {trace.push(hint); return 0;} : undefined;}}); + const result = outcome(() => method.call(receiver, value)); + record(label + '/author-number-proxy', {converted: JSON.stringify(trace) === JSON.stringify(['Symbol(Symbol.toPrimitive)', 'number']), + result: name === 'hasPointerCapture' ? !result.error && result.value === false : (kind === 'windowless' || kind === 'native-proxy') ? !result.error && result.value === undefined : result.error?.name === 'NotFoundError'}, trace); + }); + } + const revoked = Proxy.revocable(element, {}); revoked.revoke(); + const invalid = [['plain', {}], ['prototype', Object.create(window.Element.prototype)], + ['inherited-native', Object.create(element)], ['author-proxy', new Proxy(element, {})], + ['revoked', revoked.proxy], ['text', document.createTextNode('x')], ['document', document], + ['window', window], ['null', null], ['undefined', undefined], ['number', 1]]; + for (const [kind, receiver] of invalid) run(`${prefix}/${owner}/invalid-${kind}`, () => { + let conversions = 0, traps = 0; + const value = {[Symbol.toPrimitive]() {conversions++; return 0;}}; + const result = outcome(() => method.call(receiver, value)); + const missing = outcome(() => method.call(receiver)); + const proxy = new Proxy(element, {get() {traps++;}, getPrototypeOf() {traps++;}}); + const proxyResult = outcome(() => method.call(proxy, value)); + record(`${prefix}/${owner}/invalid-${kind}`, {typeError: result.error instanceof realm.TypeError, + missing: missing.error instanceof realm.TypeError, conversion: conversions === 0, + proxy: proxyResult.error instanceof realm.TypeError, traps: traps === 0}); + }); + element.remove(); + } + } + const checks = rows.flatMap(row => Object.values(row.checks)); + return {complete: errors.length === 0 && checks.every(value => value === true), + passed: checks.filter(value => value === true).length, total: checks.length, rows, errors}; +}; + +globalThis.__installPointerCaptureStateProbe = (first, second, realm = globalThis) => { + const rows = [], events = [], errors = [], state = {phase: 0, pointerId: null}; + const set = realm.Element.prototype.setPointerCapture, release = realm.Element.prototype.releasePointerCapture; + const has = realm.Element.prototype.hasPointerCapture; + const record = (label, checks, observed = null) => rows.push({label, checks, observed}); + const snapshot = id => [has.call(first, id), has.call(second, id)]; + const protect = (label, work) => {try {work();} catch(error) {errors.push({label, error: String(error.stack || error)});}}; + const failureChecks = id => { + for (const [name, method] of [['set', set], ['release', release], ['has', has]]) { + let missing; try {method.call(first);} catch(error) {missing = error;} + record(name + '/required-active', {typeError: missing instanceof realm.TypeError, state: has.call(first, id)}); + for (const [n, value] of [Symbol('id'), 1n].entries()) { + let error; try {method.call(first, value);} catch(e) {error = e;} + record(name + '/type-active-' + n, {typeError: error instanceof realm.TypeError, state: has.call(first, id)}); + } + const sentinel = {}; let error, conversions = 0; + try {method.call(first, {[Symbol.toPrimitive](hint) {conversions++; if(hint !== 'number') throw new Error('hint'); throw sentinel;}});} catch(e) {error = e;} + record(name + '/throw-active', {identity: error === sentinel, conversions: conversions === 1, state: has.call(first, id)}); + } + }; + for (const [label, element] of [['first', first], ['second', second]]) { + for (const type of ['pointerdown', 'gotpointercapture', 'pointermove', 'pointerup', 'lostpointercapture']) { + element.addEventListener(type, event => events.push(`${type}@${label}`)); + } + } + first.addEventListener('pointerdown', event => protect('down', () => { + state.pointerId = event.pointerId; + set.call(first, event.pointerId + 2 ** 32); + record('set/long-wrap', {first: has.call(first, event.pointerId), second: !has.call(second, event.pointerId)}); + let conversions = 0; + const value = {[Symbol.toPrimitive](hint) {conversions++; return String(event.pointerId + 2 ** 32 + 0.75);}}; + record('has/long-wrap', {has: has.call(first, value), conversions: conversions === 1}); + failureChecks(event.pointerId); + state.phase = 1; + })); + first.addEventListener('pointermove', event => protect('switch', () => { + if (state.phase !== 1) return; + state.phase = 2; + const id = event.pointerId; + record('before-switch', {first: has.call(first, id), second: !has.call(second, id)}); + set.call(second, id); + const after = snapshot(id); + record('pending-switch', {old: after[0] === false, next: after[1] === true}, after); + release.call(first, id); + record('release-other', {old: !has.call(first, id), next: has.call(second, id)}, snapshot(id)); + })); + second.addEventListener('pointermove', event => protect('release', () => { + if (state.phase !== 2) return; + state.phase = 3; + release.call(second, event.pointerId + 2 ** 32 + 0.5); + const after = snapshot(event.pointerId); + record('pending-release', {old: after[0] === false, next: after[1] === false}, after); + })); + first.addEventListener('pointerup', event => protect('up', () => { + record('finished', {phase: state.phase === 3, first: !has.call(first, event.pointerId), second: !has.call(second, event.pointerId)}); + })); + return {rows, events, errors, state}; +}; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/pointer_capture.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/pointer_capture.rs new file mode 100644 index 0000000000..61dcc2ec31 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/pointer_capture.rs @@ -0,0 +1,79 @@ +use super::*; + +#[tokio::test] +async fn pointer_capture_arguments_use_native_receiver_and_webidl_conversion() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = new_storage_page_task_executor_test_vm_with_loader( + "https://pointer-capture-arguments.test/", + &loader, + ); + vm.eval( + r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + const frame = document.body.appendChild(document.createElement('iframe')); + frame.id = 'child'; frame.srcdoc = ''; void frame.contentWindow; + globalThis.nativeCaptureReceiver = document.implementation.createHTMLDocument('').createElement('select'); + 'ready' + "#, + ) + .unwrap(); + assert!( + vm.run_one_child_frame_task_executor_turn( + ChildFrameSemanticTurnKind::RealmMaterialization, + &loader + ) + .await + .unwrap() + ); + vm.drain_ready_page_task_executor_turns_for_setup(&loader, 128) + .await + .unwrap(); + vm.with_default_context_scope_and_checkpoint_for_test(|scope, _host_ptr| { + let global = scope.get_current_context().global(scope); + let key = v8::String::new(scope, "nativeCaptureReceiver").unwrap(); + let value = global.get(scope, key.into()).unwrap(); + assert!(value.is_proxy(), "fixture must exercise a native Proxy"); + let object = v8::Local::::try_from(value).unwrap(); + assert!(crate::web_api_interfaces::Element::is_instance( + scope, object + )); + Ok(()) + }) + .unwrap(); + let source = format!( + "{}; globalThis.__captureResults = __pointerCaptureProbe([globalThis, document.getElementById('child').contentWindow]); __captureResults.complete", + include_str!("pointer_capture.js"), + ); + assert_eq!(vm.eval(&source).unwrap(), "true", "{}", + vm.eval("JSON.stringify({errors:__captureResults.errors,failures:__captureResults.rows.filter(row=>Object.values(row.checks).some(value=>value!==true))})").unwrap()); +} + +#[test] +fn pointer_capture_queries_pending_target_before_native_dispatch() { + let mut vm = new_rendered_test_vm( + "https://pointer-capture-pending-query.test/", + r#"
first
second
"#, + ); + let source = format!( + "{}; globalThis.__captureResults = __installPointerCaptureStateProbe(document.getElementById('first'), document.getElementById('second')); 'ready'", + include_str!("pointer_capture.js"), + ); + vm.eval(&source).unwrap(); + vm.publish_layout_for_test().unwrap(); + for (kind, x, button) in [ + ("mousedown", 80.0, 0), + ("mousemove", 82.0, -1), + ("mousemove", 84.0, -1), + ("mousemove", 86.0, -1), + ("mouseup", 86.0, 0), + ] { + vm.dispatch_mouse_event_at_point(x, 80.0, kind, button, None, 0.0, 0.0) + .unwrap(); + } + assert_eq!(vm.eval("__captureResults.errors.length === 0 && __captureResults.rows.every(row=>Object.values(row.checks).every(value=>value===true))").unwrap(), "true", "{}", vm.eval("JSON.stringify(__captureResults)").unwrap()); + assert_eq!( + vm.eval("__captureResults.events.join('|')").unwrap(), + "pointerdown@first|gotpointercapture@first|pointermove@first|lostpointercapture@first|gotpointercapture@second|pointermove@second|lostpointercapture@second|pointermove@first|pointerup@first" + ); +}