fix(pointerevents): validate capture arguments and query pending targets

This commit is contained in:
ldm0
2026-10-04 08:58:57 +08:00
parent 7106a4e4b6
commit 21394e198e
6 changed files with 240 additions and 28 deletions
@@ -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<DomHandle> {
self.pointer_capture_targets
.get(&pointer_id)
@@ -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: (),
@@ -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);
}
@@ -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;
@@ -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};
};
@@ -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 = '<head></head><body></body>'; 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::<v8::Object>::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#"<html><body><div id="first" style="position:absolute;left:40px;top:40px;width:120px;height:120px">first</div><div id="second" style="position:absolute;left:220px;top:40px;width:120px;height:120px">second</div></body></html>"#,
);
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"
);
}