From 0521e718e6b2c33d379211cb066b62136d5ff3cb Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 8 Sep 2026 05:42:15 +0800 Subject: [PATCH] fix(webidl): generate native receiver checks in declarative bindings --- .../src/context_bootstrap/canvas.rs | 2 +- .../context_bootstrap/css_fontface_runtime.rs | 1 + .../css_fontface_runtime/font_face.rs | 10 +- .../context_bootstrap/style_font_template.rs | 5 +- .../src/native_bridge/document.rs | 6 +- moli-renderer-v8/src/native_bridge/element.rs | 8 +- moli-renderer-v8/src/native_bridge/mod.rs | 1 + .../src/native_bridge/receivers.rs | 56 +++ moli-renderer-v8/src/observer_runtime/mod.rs | 88 ++-- .../src/script_vm/tests/browser_api/misc.rs | 24 +- moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + .../src/script_vm/tests/webidl_receivers.rs | 189 +++++++++ moli-webapi-declare-derive/src/attrs.rs | 64 ++- moli-webapi-declare-derive/src/expand.rs | 123 +++++- moli-webapi-declare/src/__private.rs | 1 + moli-webapi-declare/src/callback.rs | 42 ++ moli-webapi-declare/src/lib.rs | 23 ++ moli-webapi-declare/tests/receivers.rs | 386 ++++++++++++++++++ 18 files changed, 947 insertions(+), 83 deletions(-) create mode 100644 moli-renderer-v8/src/native_bridge/receivers.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/webidl_receivers.rs create mode 100644 moli-webapi-declare/src/callback.rs create mode 100644 moli-webapi-declare/tests/receivers.rs diff --git a/moli-renderer-v8/src/context_bootstrap/canvas.rs b/moli-renderer-v8/src/context_bootstrap/canvas.rs index 640f9b392..6aba557cf 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas.rs @@ -119,7 +119,7 @@ const WEBGL_SUPPORTED_EXTENSIONS: &[&str] = &[ ]; #[derive(WebApiFunctionTemplate)] -#[webapi(name = "HTMLCanvasElement")] +#[webapi(name = "HTMLCanvasElement", receiver = crate::native_bridge::receivers::html_canvas_element)] struct HtmlCanvasElementPrototypeAccessorsDeclaration { #[webapi( accessor_property, diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime.rs index f981336ef..849fc3edd 100644 --- a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime.rs @@ -41,6 +41,7 @@ pub(in crate::context_bootstrap) use events::{ }; pub(super) use font_face::{ font_face_constructor_callback, font_face_load_callback, install_font_face_template_accessors, + is_font_face, }; pub(super) use font_face_set::{ font_face_set_add_callback, font_face_set_add_event_listener_callback, diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/font_face.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/font_face.rs index 8b62a322e..25fea937c 100644 --- a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/font_face.rs +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/font_face.rs @@ -37,7 +37,7 @@ struct FontFaceObjectDeclaration<'s> { } #[derive(WebApiFunctionTemplate)] -#[webapi(name = "FontFace")] +#[webapi(name = "FontFace", receiver = is_font_face)] struct FontFacePrototypeAccessorsDeclaration { #[webapi( accessor_property, @@ -121,6 +121,7 @@ struct FontFacePrototypeAccessorsDeclaration { accessor_property, getter = font_face_readonly_attribute_getter_callback, data = callback_data_index_value(scope, 2), + returns_promise, enumerable )] loaded: (), @@ -344,6 +345,13 @@ pub(in crate::context_bootstrap) fn font_face_load_callback<'s>( } } +pub(in crate::context_bootstrap) fn is_font_face<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + get_private_value(scope, receiver, FONT_FACE_STATUS_SLOT).is_some() +} + fn descriptor_string_property( scope: &mut v8::PinScope<'_, '_>, object: Option>, diff --git a/moli-renderer-v8/src/context_bootstrap/style_font_template.rs b/moli-renderer-v8/src/context_bootstrap/style_font_template.rs index 36ed4bc0e..5473235c9 100644 --- a/moli-renderer-v8/src/context_bootstrap/style_font_template.rs +++ b/moli-renderer-v8/src/context_bootstrap/style_font_template.rs @@ -9,15 +9,16 @@ use super::{ font_face_set_values_callback, install_font_face_set_event_handler_accessors, install_font_face_set_load_event_template_accessors, install_font_face_set_template_accessors, install_font_face_template_accessors, + is_font_face, }, specs::ConstructorSpec, }; use moli_webapi_declare::WebApiFunctionTemplate; #[derive(WebApiFunctionTemplate)] -#[webapi(name = "FontFace", enumerable)] +#[webapi(name = "FontFace", enumerable, receiver = is_font_face)] struct FontFaceTemplateMethodsDeclaration { - #[webapi(method, length = 0, callback = font_face_load_callback)] + #[webapi(method, length = 0, callback = font_face_load_callback, returns_promise)] load: (), } diff --git a/moli-renderer-v8/src/native_bridge/document.rs b/moli-renderer-v8/src/native_bridge/document.rs index 946c9e932..bfdbe4276 100644 --- a/moli-renderer-v8/src/native_bridge/document.rs +++ b/moli-renderer-v8/src/native_bridge/document.rs @@ -324,7 +324,7 @@ struct DocumentMetadataPrototypeDeclaration { getter = document_last_modified_getter_function )] last_modified: (), - #[webapi(accessor_property, getter = document_referrer_getter_function)] + #[webapi(accessor_property, getter = document_referrer_getter_function, receiver = super::receivers::document)] referrer: (), } @@ -982,9 +982,9 @@ fn document_referrer_getter_function<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let Some((runtime_ptr, handle)) = document_receiver_runtime_and_handle(scope, args.this()) + let Ok((runtime_ptr, handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.this()) else { - rv.set_undefined(); return; }; let runtime = unsafe { &*runtime_ptr }; diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 3d4488073..3d2101ed9 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -1001,12 +1001,14 @@ struct HtmlElementGeometryPrototypeDeclaration { #[webapi( accessor_property = "offsetWidth", enumerable, + receiver = super::receivers::html_element, getter = node_offset_width_getter_function )] offset_width: (), #[webapi( accessor_property = "offsetHeight", enumerable, + receiver = super::receivers::html_element, getter = node_offset_height_getter_function )] offset_height: (), @@ -3247,7 +3249,6 @@ fn iframe_content_document_getter_function<'s>( let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object_or_detached(scope, receiver) else { - rv.set_null(); return; }; if iframe_is_inside_its_own_child_context_document(scope, runtime_ptr, handle) { @@ -3314,7 +3315,6 @@ fn iframe_content_window_getter_function<'s>( let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object_or_detached(scope, receiver) else { - rv.set_null(); return; }; if iframe_is_inside_its_own_child_context_document(scope, runtime_ptr, handle) { @@ -3984,9 +3984,9 @@ struct HtmlIFrameElementPrototypeDeclaration { setter_data = NullToEmptyDomStringReflection::IframeMarginWidth )] margin_width: (), - #[webapi(accessor_property, getter = iframe_content_document_getter_function)] + #[webapi(accessor_property, getter = iframe_content_document_getter_function, receiver = super::receivers::html_iframe_element)] content_document: (), - #[webapi(accessor_property, getter = iframe_content_window_getter_function)] + #[webapi(accessor_property, getter = iframe_content_window_getter_function, receiver = super::receivers::html_iframe_element)] content_window: (), } diff --git a/moli-renderer-v8/src/native_bridge/mod.rs b/moli-renderer-v8/src/native_bridge/mod.rs index a1f117330..ddd5a28e1 100644 --- a/moli-renderer-v8/src/native_bridge/mod.rs +++ b/moli-renderer-v8/src/native_bridge/mod.rs @@ -21,6 +21,7 @@ pub(super) mod identity; pub(crate) mod named_access; mod node; pub(crate) mod pointer_lock; +pub(crate) mod receivers; mod traversal; mod window; diff --git a/moli-renderer-v8/src/native_bridge/receivers.rs b/moli-renderer-v8/src/native_bridge/receivers.rs new file mode 100644 index 000000000..a45a91075 --- /dev/null +++ b/moli-renderer-v8/src/native_bridge/receivers.rs @@ -0,0 +1,56 @@ +//! Native DOM brand predicates for declarative bindings. Do not consult public +//! constructors or prototype chains: those are mutable, realm-specific JS state. + +use super::node::{node_is_document, node_runtime_and_handle_from_object_or_detached}; + +pub(crate) fn document<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + node_runtime_and_handle_from_object_or_detached(scope, receiver) + .ok() + .is_some_and(|(runtime, handle)| node_is_document(unsafe { &*runtime }, handle)) +} + +pub(crate) fn html_element<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + node_runtime_and_handle_from_object_or_detached(scope, receiver) + .ok() + .is_some_and(|(runtime, handle)| { + unsafe { &*runtime } + .dom_host() + .node(handle) + .and_then(|node| node.as_element()) + .is_some_and(|element| element.namespace() == super::document::XHTML_NS) + }) +} + +pub(crate) fn html_canvas_element<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + html_element_named(scope, receiver, "canvas") +} + +pub(crate) fn html_iframe_element<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + html_element_named(scope, receiver, "iframe") +} + +fn html_element_named<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, + local_name: &str, +) -> bool { + node_runtime_and_handle_from_object_or_detached(scope, receiver) + .ok() + .is_some_and(|(runtime, handle)| { + unsafe { &*runtime } + .dom_host() + .is_html_element_named(handle, local_name) + }) +} diff --git a/moli-renderer-v8/src/observer_runtime/mod.rs b/moli-renderer-v8/src/observer_runtime/mod.rs index d4300c632..f691dc6a2 100644 --- a/moli-renderer-v8/src/observer_runtime/mod.rs +++ b/moli-renderer-v8/src/observer_runtime/mod.rs @@ -43,7 +43,7 @@ use super::{ }, util::{ callback_data_index_value, callback_data_item, context_host_ptr_from_global_bridge, - get_private_object, global_constructor_prototype, serialize_v8_array, + get_private_object, get_private_value, global_constructor_prototype, serialize_v8_array, serialize_v8_iter_array, throw_range_error, throw_type_error, v8_string, v8str, }, window_webidl_callback::WindowWebIdlCallbackFunctionOutcome, @@ -97,31 +97,26 @@ struct MutationRecordDeclaration<'scope> { } #[derive(WebApiObject)] -#[webapi(interface = "IntersectionObserverEntry", data_properties, enumerable)] +#[webapi(interface = "IntersectionObserverEntry")] struct IntersectionObserverEntryDeclaration<'scope> { + #[webapi(slot = "__moliIntersectionEntryTarget")] target: v8::Local<'scope, v8::Value>, + #[webapi(slot = "__moliIntersectionEntryIntersecting")] is_intersecting: bool, + #[webapi(slot = "__moliIntersectionEntryVisible")] is_visible: bool, + #[webapi(slot = "__moliIntersectionEntryRatio")] intersection_ratio: f64, + #[webapi(slot = "__moliIntersectionEntryBoundingRect")] bounding_client_rect: v8::Local<'scope, v8::Value>, + #[webapi(slot = "__moliIntersectionEntryIntersectionRect")] intersection_rect: v8::Local<'scope, v8::Value>, + #[webapi(slot = "__moliIntersectionEntryRootBounds")] root_bounds: v8::Local<'scope, v8::Value>, + #[webapi(slot = "__moliIntersectionEntryTime")] time: f64, } -#[derive(WebApiObject)] -#[webapi(interface = "IntersectionObserverEntry", data_properties, enumerable)] -struct IntersectionObserverEntryInitDeclaration<'scope> { - time: f64, - root_bounds: v8::Local<'scope, v8::Value>, - bounding_client_rect: v8::Local<'scope, v8::Value>, - intersection_rect: v8::Local<'scope, v8::Value>, - target: v8::Local<'scope, v8::Value>, - is_intersecting: bool, - is_visible: bool, - intersection_ratio: f64, -} - #[derive(WebApiFunctionTemplate)] #[webapi(name = "IntersectionObserver", enumerable)] struct IntersectionObserverPrototypeAccessorsDeclaration { @@ -140,7 +135,7 @@ struct IntersectionObserverPrototypeAccessorsDeclaration { } #[derive(WebApiFunctionTemplate)] -#[webapi(name = "IntersectionObserverEntry", enumerable)] +#[webapi(name = "IntersectionObserverEntry", enumerable, receiver = is_intersection_observer_entry)] struct IntersectionObserverEntryPrototypeAccessorsDeclaration { #[webapi(accessor_property, getter = intersection_observer_entry_attribute_getter_callback, data = callback_data_index_value(scope, 0))] time: (), @@ -1483,7 +1478,7 @@ fn initialize_intersection_observer_entry_from_init<'s>( .number_value(scope) .unwrap_or(0.0); - let _ = IntersectionObserverEntryInitDeclaration { + let _ = IntersectionObserverEntryDeclaration { time, root_bounds, bounding_client_rect, @@ -2518,37 +2513,30 @@ fn intersection_observer_attribute_getter_callback( ); } -fn intersection_observer_entry_attribute_getter_callback( - scope: &mut v8::PinScope<'_, '_>, - args: v8::FunctionCallbackArguments<'_>, - mut rv: v8::ReturnValue<'_, v8::Value>, +fn is_intersection_observer_entry<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + get_private_value(scope, receiver, "__moliIntersectionEntryTime").is_some() +} + +fn intersection_observer_entry_attribute_getter_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, ) { - let Some(name) = callback_data_item( + let Some(slot) = callback_data_item( scope, &args, - INTERSECTION_OBSERVER_ENTRY_ATTRIBUTE_NAMES, - "IntersectionObserverEntry attribute names", + INTERSECTION_OBSERVER_ENTRY_ATTRIBUTE_SLOTS, + "IntersectionObserverEntry attribute slots", ) else { rv.set_undefined(); return; }; - // Runtime-created entries define their values as own data properties. The - // prototype getter exists for WebIDL shape and should read that own value - // without re-entering the same accessor through normal property lookup. - let key = v8str(scope, name); - let Some(descriptor) = args.this().get_own_property_descriptor(scope, key.into()) else { - rv.set_undefined(); - return; - }; - let Ok(descriptor) = v8::Local::::try_from(descriptor) else { - rv.set_undefined(); - return; - }; - rv.set( - descriptor - .get(scope, v8str(scope, "value").into()) - .unwrap_or_else(|| v8::undefined(scope).into()), - ); + let value = + get_private_value(scope, args.this(), slot).unwrap_or_else(|| v8::undefined(scope).into()); + rv.set(value); } const INTERSECTION_OBSERVER_ATTRIBUTE_NAMES: &[&str] = &[ @@ -2560,15 +2548,15 @@ const INTERSECTION_OBSERVER_ATTRIBUTE_NAMES: &[&str] = &[ "trackVisibility", ]; -const INTERSECTION_OBSERVER_ENTRY_ATTRIBUTE_NAMES: &[&str] = &[ - "time", - "rootBounds", - "boundingClientRect", - "intersectionRect", - "isIntersecting", - "isVisible", - "intersectionRatio", - "target", +const INTERSECTION_OBSERVER_ENTRY_ATTRIBUTE_SLOTS: &[&str] = &[ + "__moliIntersectionEntryTime", + "__moliIntersectionEntryRootBounds", + "__moliIntersectionEntryBoundingRect", + "__moliIntersectionEntryIntersectionRect", + "__moliIntersectionEntryIntersecting", + "__moliIntersectionEntryVisible", + "__moliIntersectionEntryRatio", + "__moliIntersectionEntryTarget", ]; fn timestamp_millis() -> f64 { diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs index 798ec67b4..6bd4546f9 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs @@ -1397,6 +1397,7 @@ fn font_face_declared_slots_ignore_prototype_spoofing() { face.__moliFontFaceStatus = 'error'; face.__moliFontFaceLoaded = Promise.resolve('ownBad'); const fake = Object.create(FontFace.prototype); + globalThis.fakeLoadedResult = 'not called'; return JSON.stringify({ values: [ face.family, @@ -1410,13 +1411,19 @@ fn font_face_declared_slots_ignore_prototype_spoofing() { face.status, typeof face.loaded.then ].join('|'), - fake: [ - fake.family, - fake.source, - fake.style, - fake.status, - fake.loaded - ].map(value => value === undefined ? 'undefined' : String(value)).join('|'), + fake: ['family', 'source', 'style', 'status', 'loaded'].map(name => { + try { + const value = fake[name]; + if (name === 'loaded' && value instanceof Promise) { + value.then( + () => fakeLoadedResult = 'resolved', + error => fakeLoadedResult = error instanceof TypeError ? 'rejected:TypeError' : error.name + ); + return 'Promise'; + } + return String(value); + } catch (error) { return error.name; } + }).join('|'), descriptors: [ 'family', 'style', @@ -1438,8 +1445,9 @@ fn font_face_declared_slots_ignore_prototype_spoofing() { assert_eq!( result, - r#"{"values":"Changed|url(demo.woff)|italic|700|condensed|small-caps|\"kern\"|swap|loaded|function","fake":"undefined|undefined|undefined|undefined|undefined","descriptors":["family:function:get family:0:function:set family:1:true:true:false","style:function:get style:0:function:set style:1:true:true:false","weight:function:get weight:0:function:set weight:1:true:true:false","stretch:function:get stretch:0:function:set stretch:1:true:true:false","variant:function:get variant:0:function:set variant:1:true:true:false","featureSettings:function:get featureSettings:0:function:set featureSettings:1:true:true:false","display:function:get display:0:function:set display:1:true:true:false","source:function:get source:0:undefined:undefined:undefined:true:true:false","status:function:get status:0:undefined:undefined:undefined:true:true:false","loaded:function:get loaded:0:undefined:undefined:undefined:true:true:false"],"ownSlots":[]}"# + r#"{"values":"Changed|url(demo.woff)|italic|700|condensed|small-caps|\"kern\"|swap|loaded|function","fake":"TypeError|TypeError|TypeError|TypeError|Promise","descriptors":["family:function:get family:0:function:set family:1:true:true:false","style:function:get style:0:function:set style:1:true:true:false","weight:function:get weight:0:function:set weight:1:true:true:false","stretch:function:get stretch:0:function:set stretch:1:true:true:false","variant:function:get variant:0:function:set variant:1:true:true:false","featureSettings:function:get featureSettings:0:function:set featureSettings:1:true:true:false","display:function:get display:0:function:set display:1:true:true:false","source:function:get source:0:undefined:undefined:undefined:true:true:false","status:function:get status:0:undefined:undefined:undefined:true:true:false","loaded:function:get loaded:0:undefined:undefined:undefined:true:true:false"],"ownSlots":[]}"# ); + assert_eq!(vm.eval("fakeLoadedResult").unwrap(), "rejected:TypeError"); } #[test] diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 9b8f68082..36e26bb2f 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -15163,6 +15163,7 @@ mod script_terminal_completion; mod streams; mod webidl_collections; mod webidl_fetch; +mod webidl_receivers; mod webidl_trusted_types; mod websocket; mod window_execution_context; diff --git a/moli-renderer-v8/src/script_vm/tests/webidl_receivers.rs b/moli-renderer-v8/src/script_vm/tests/webidl_receivers.rs new file mode 100644 index 000000000..da8cfff85 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/webidl_receivers.rs @@ -0,0 +1,189 @@ +use super::*; + +#[test] +fn webidl_receiver_checks_reject_prototypes_plain_objects_and_forged_instances() { + let mut vm = new_storage_test_vm("https://receiver-check.test/"); + let result = vm.eval(r#" +JSON.stringify((() => { + const failures = []; + const html = document.appendChild(document.createElement('html')); + html.appendChild(document.createElement('body')); + const groups = [ + [Document, ['referrer']], + [FontFace, ['family','status']], + [HTMLCanvasElement, ['width','height']], + [HTMLElement, ['offsetWidth','offsetHeight']], + [HTMLIFrameElement, ['contentDocument','contentWindow']], + [IntersectionObserverEntry, ['boundingClientRect','intersectionRect','rootBounds']], + ]; + for (const [C, keys] of groups) { + for (const key of keys) { + const get = Object.getOwnPropertyDescriptor(C.prototype, key).get; + for (const receiver of [C.prototype, {}, Object.create(C.prototype), new Proxy({}, {}), null]) { + try { get.call(receiver); failures.push(`${C.name}.${key}:accepted`); } + catch (e) { if (!(e instanceof TypeError)) failures.push(`${C.name}.${key}:${e.name}`); } + } + try { C.prototype[key]; failures.push(`${C.name}.${key}:prototype`); } + catch (e) { if (!(e instanceof TypeError)) failures.push(e.name); } + } + } + // A genuine DOM reflector of the wrong interface is also not a receiver. + for (const [C, key, receiver] of [ + [Document, 'referrer', document.body], + [HTMLCanvasElement, 'width', document.createElement('div')], + [HTMLIFrameElement, 'contentWindow', document.createElement('div')], + [HTMLElement, 'offsetHeight', document.createElementNS('http://www.w3.org/2000/svg', 'svg')], + ]) { + try { Object.getOwnPropertyDescriptor(C.prototype, key).get.call(receiver); failures.push(`${C.name}:wrong interface`); } + catch (e) { if (!(e instanceof TypeError)) failures.push(e.name); } + } + return failures; +})()) +"#).unwrap(); + assert_eq!(result, "[]"); +} + +#[test] +fn webidl_receiver_checks_preserve_native_values_and_cross_realm_receivers() { + let mut vm = new_storage_test_vm("https://receiver-check.test/"); + let result = vm.eval(r#" +JSON.stringify((() => { + const html = document.appendChild(document.createElement('html')); + html.appendChild(document.createElement('body')); + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const child = frame.contentWindow; + const canvas = child.document.createElement('canvas'); + canvas.width = 123; + const face = new child.FontFace('ReceiverTest', 'local("sans-serif")'); + const rect = new DOMRect(1,2,3,4); + const entry = new IntersectionObserverEntry({ + time: 17, rootBounds: rect, boundingClientRect: rect, intersectionRect: rect, + target: document.body, isIntersecting: true, intersectionRatio: 1, + }); + const get = (C, key, receiver) => Object.getOwnPropertyDescriptor(C.prototype, key).get.call(receiver); + const before = get(IntersectionObserverEntry, 'boundingClientRect', entry); + Object.defineProperty(entry, 'boundingClientRect', {value: 'shadow'}); + return [ + get(HTMLCanvasElement, 'width', canvas) === 123, + get(HTMLCanvasElement, 'height', canvas) === 150, + get(FontFace, 'family', face) === 'ReceiverTest', + typeof get(FontFace, 'status', face) === 'string', + typeof get(Document, 'referrer', child.document) === 'string', + typeof get(HTMLElement, 'offsetWidth', child.document.body) === 'number', + typeof get(HTMLElement, 'offsetHeight', child.document.body) === 'number', + get(HTMLIFrameElement, 'contentWindow', frame) === child, + get(HTMLIFrameElement, 'contentDocument', frame) === child.document, + before.width === 3 && get(IntersectionObserverEntry, 'boundingClientRect', entry) === before, + !Object.hasOwn(entry, 'time') && get(IntersectionObserverEntry, 'time', entry) === 17, + get(IntersectionObserverEntry, 'rootBounds', entry) === rect, + ]; +})()) +"#).unwrap(); + assert_eq!( + result, + "[true,true,true,true,true,true,true,true,true,true,true,true]" + ); +} + +#[test] +fn webidl_receiver_checks_precede_canvas_and_fontface_setter_conversion() { + let mut vm = new_storage_test_vm("https://receiver-check.test/"); + let result = vm.eval(r#" +JSON.stringify((() => { + let conversions = 0; + const value = {valueOf() { conversions++; return 1; }, toString() { conversions++; return 'x'; }}; + const errors = []; + for (const [C, key] of [[HTMLCanvasElement,'width'],[HTMLCanvasElement,'height'],[FontFace,'family']]) { + try { Object.getOwnPropertyDescriptor(C.prototype,key).set.call({},value); errors.push('accepted'); } + catch (e) { errors.push(e.name); } + } + return [conversions,...errors]; +})()) +"#).unwrap(); + assert_eq!(result, r#"[0,"TypeError","TypeError","TypeError"]"#); +} + +#[test] +fn webidl_receiver_fontface_load_rejects_its_promise_for_an_invalid_receiver() { + let mut vm = new_storage_test_vm("https://receiver-check.test/"); + vm.eval( + r#" +globalThis.loadResult = 'pending'; +FontFace.prototype.load.call({}).then( + () => loadResult = 'resolved', + error => loadResult = error instanceof TypeError ? 'rejected:TypeError' : error.name +); +"#, + ) + .unwrap(); + assert_eq!(vm.eval("loadResult").unwrap(), "rejected:TypeError"); +} + +#[test] +fn webidl_receiver_fontface_loaded_getter_rejects_instead_of_throwing() { + let mut vm = new_storage_test_vm("https://receiver-check.test/"); + vm.eval(r#" +globalThis.loadedFailures = []; +globalThis.loadedRejections = 0; +const getLoaded = Object.getOwnPropertyDescriptor(FontFace.prototype, 'loaded').get; +const face = new FontFace('ReceiverTest', 'local("sans-serif")'); +if (face.loaded !== face.loaded || getLoaded.call(face) !== face.loaded) { + loadedFailures.push('lost cached Promise identity'); +} +for (const receiver of [FontFace.prototype, {}, Object.create(FontFace.prototype), new Proxy(face, {}), null]) { + try { + const promise = getLoaded.call(receiver); + if (!(promise instanceof Promise)) loadedFailures.push('not a Promise'); + promise.then( + () => loadedFailures.push('resolved'), + error => { + loadedRejections++; + if (!(error instanceof TypeError)) loadedFailures.push(error.name); + } + ); + } catch (error) { + loadedFailures.push('synchronous ' + error.name); + } +} +"#).unwrap(); + assert_eq!( + vm.eval("JSON.stringify([loadedRejections, loadedFailures])") + .unwrap(), + "[5,[]]" + ); +} + +#[test] +fn webidl_receiver_fontface_promise_errors_use_the_callee_realm() { + let mut vm = new_storage_test_vm("https://receiver-check.test/"); + vm.eval(r#" +globalThis.realmFailures = []; +globalThis.realmRejections = 0; +const html = document.appendChild(document.createElement('html')); +html.appendChild(document.createElement('body')); +const frame = document.body.appendChild(document.createElement('iframe')); +const child = frame.contentWindow; +const getLoaded = Object.getOwnPropertyDescriptor(child.FontFace.prototype, 'loaded').get; +const getFamily = Object.getOwnPropertyDescriptor(child.FontFace.prototype, 'family').get; +try { getFamily.call({}); realmFailures.push('accepted receiver'); } +catch (error) { + if (!(error instanceof child.TypeError) || error instanceof TypeError) realmFailures.push('wrong exception realm'); +} +for (const promise of [getLoaded.call({}), child.FontFace.prototype.load.call({})]) { + if (!(promise instanceof child.Promise) || promise instanceof Promise) realmFailures.push('wrong Promise realm'); + promise.catch(error => { + realmRejections++; + if (!(error instanceof child.TypeError) || error instanceof TypeError) realmFailures.push('wrong rejection realm'); + }); +} +const face = new child.FontFace('ReceiverTest', 'local("sans-serif")'); +const parentGetLoaded = Object.getOwnPropertyDescriptor(FontFace.prototype, 'loaded').get; +if (parentGetLoaded.call(face) !== face.loaded) realmFailures.push('cross-realm identity'); +"#).unwrap(); + assert_eq!( + vm.eval("JSON.stringify([realmRejections, realmFailures])") + .unwrap(), + "[2,[]]" + ); +} diff --git a/moli-webapi-declare-derive/src/attrs.rs b/moli-webapi-declare-derive/src/attrs.rs index af1442dc1..c79e820f9 100644 --- a/moli-webapi-declare-derive/src/attrs.rs +++ b/moli-webapi-declare-derive/src/attrs.rs @@ -3,6 +3,7 @@ use syn::{Error, Expr, ExprLit, Field, Lit, LitInt, LitStr, Path, Token}; #[derive(Default)] pub(crate) struct InterfaceAttrs { + pub(crate) receiver: Option, pub(crate) name: Option, pub(crate) parent: Option, pub(crate) constructor: Option, @@ -45,6 +46,7 @@ pub(crate) enum RenameRule { #[derive(Default)] pub(crate) struct ObjectAttrs { + pub(crate) receiver: Option, pub(crate) interface: Option, pub(crate) prototype: Option, pub(crate) own_to_string_tag: Option, @@ -61,6 +63,7 @@ pub(crate) struct ObjectAttrs { #[derive(Default)] pub(crate) struct FunctionTemplateAttrs { + pub(crate) receiver: Option, pub(crate) name: Option, pub(crate) constructor: Option, pub(crate) constructor_length: Option, @@ -73,6 +76,8 @@ pub(crate) struct FunctionTemplateAttrs { #[derive(Clone, Default)] pub(crate) struct FieldAttrs { + pub(crate) receiver: Option, + pub(crate) returns_promise: bool, pub(crate) method: bool, pub(crate) static_method: bool, pub(crate) constant: bool, @@ -104,6 +109,21 @@ pub(crate) struct FieldAttrs { } impl FieldAttrs { + pub(crate) fn inherit_receiver(&mut self, receiver: Option<&Path>) -> Result<(), Error> { + if self.method || self.accessor_property { + self.receiver = self.receiver.take().or_else(|| receiver.cloned()); + } + if (self.receiver.is_some() || self.returns_promise) + && let Some(getter_value) = &self.getter_value + { + return Err(Error::new( + getter_value.span(), + "receiver and returns_promise require a Rust callback, not getter_value", + )); + } + Ok(()) + } + pub(crate) fn has_installation_kind(&self) -> bool { self.method || self.static_method @@ -120,7 +140,9 @@ impl FieldAttrs { } pub(crate) fn has_installation_attribute(&self) -> bool { - self.enumerable + self.receiver.is_some() + || self.returns_promise + || self.enumerable || self.readonly || self.dont_delete || self.alias.is_some() @@ -143,6 +165,10 @@ pub(crate) fn parse_interface_attrs(attrs: &[syn::Attribute]) -> Result Result Result { .filter(|attr| attr.path().is_ident("webapi")) { attr.parse_nested_meta(|meta| { + if meta.path.is_ident("receiver") { + if parsed.receiver.replace(meta.value()?.parse()?).is_some() { + return Err(meta.error("field receiver can only be specified once")); + } + return Ok(()); + } + if meta.path.is_ident("returns_promise") { + parsed.returns_promise = true; + return Ok(()); + } if meta.path.is_ident("method") { parsed.method = true; if meta.input.peek(Token![=]) { @@ -515,6 +559,24 @@ pub(crate) fn parse_field_attrs(field: &Field) -> Result { })?; } + if parsed.receiver.is_some() && !parsed.method && !parsed.accessor_property { + return Err(Error::new( + field.span(), + "receiver is only supported on instance methods and accessor_property fields", + )); + } + if parsed.returns_promise + && !parsed.method + && !parsed.static_method + && !parsed.accessor_property + { + return Err(Error::new( + field.span(), + "returns_promise is only supported on methods and accessor_property getters", + )); + } + parsed.inherit_receiver(None)?; + let kinds = [ parsed.method, parsed.static_method, diff --git a/moli-webapi-declare-derive/src/expand.rs b/moli-webapi-declare-derive/src/expand.rs index 19b14d3df..0705e5f94 100644 --- a/moli-webapi-declare-derive/src/expand.rs +++ b/moli-webapi-declare-derive/src/expand.rs @@ -46,7 +46,9 @@ pub(crate) fn expand_webapi_interface( let fields = named_fields(&input.data)?; let methods = fields .iter() - .filter_map(|field| expand_interface_field(field, attrs.rename_all)) + .filter_map(|field| { + expand_interface_field(field, attrs.rename_all, attrs.receiver.as_ref()) + }) .collect::, _>>()?; let body = quote! { @@ -560,6 +562,7 @@ fn expand_function_template_fields( let mut method_bindings = HashMap::new(); for (index, field) in fields.iter().enumerate() { let mut attrs = parse_field_attrs(field)?; + attrs.inherit_receiver(template_attrs.receiver.as_ref())?; if template_attrs.default_enumerable && attrs.symbol.is_none() && (attrs.method @@ -724,8 +727,9 @@ fn expand_function_template_static_method_field( }; let length = attrs.length.unwrap_or(0); let attributes = template_property_attributes(attrs); + let callback = expand_callback(callback, attrs, false); let member = expand_template_function_member( - callback, + &callback, length, attrs.data.as_ref(), template_name, @@ -773,8 +777,9 @@ fn expand_function_template_method_field( }; let length = attrs.length.unwrap_or(0); let attributes = template_property_attributes(attrs); + let callback = expand_callback(callback, attrs, false); let member = expand_template_function_member( - callback, + &callback, length, attrs.data.as_ref(), template_name, @@ -864,8 +869,9 @@ fn expand_function_template_accessor_property_field( "`accessor_property` field requires #[webapi(getter = path)]", )); }; + let getter = expand_callback(getter, attrs, false); let getter_member = expand_template_function_member( - getter, + &getter, 0, attrs.data.as_ref(), template_name, @@ -874,8 +880,9 @@ fn expand_function_template_accessor_property_field( ); let setter = attrs.setter.as_ref().map(|setter| { let setter_data = attrs.setter_data.as_ref().or(attrs.data.as_ref()); + let setter = expand_callback(setter, attrs, true); let setter_member = expand_template_function_member( - setter, + &setter, 1, setter_data, template_name, @@ -1074,11 +1081,15 @@ fn expand_function_template_alias_field( fn expand_interface_field( field: &Field, rename_all: RenameRule, + receiver: Option<&syn::Path>, ) -> Option> { - let attrs = match parse_field_attrs(field) { + let mut attrs = match parse_field_attrs(field) { Ok(attrs) => attrs, Err(error) => return Some(Err(error)), }; + if let Err(error) = attrs.inherit_receiver(receiver) { + return Some(Err(error)); + } if !attrs.method && !attrs.accessor_property { if attrs.has_installation_kind() || attrs.has_installation_attribute() { return Some(Err(Error::new( @@ -1110,8 +1121,9 @@ fn expand_interface_field( let enumerable = attrs.enumerable; let writable = !attrs.readonly; let configurable = !attrs.dont_delete; + let callback = expand_callback(callback, &attrs, false); let build_function = - expand_method_function_builder(callback, length, attrs.data.as_ref(), &name); + expand_method_function_builder(&callback, length, attrs.data.as_ref(), &name); let field_read = field .ident .as_ref() @@ -1179,6 +1191,9 @@ fn expand_object_field( Ok(attrs) => attrs, Err(error) => return Some(Err(error)), }; + if let Err(error) = attrs.inherit_receiver(object_attrs.receiver.as_ref()) { + return Some(Err(error)); + } // Struct-level `#[webapi(data_properties)]` is the only mode where an unannotated // field becomes part of the JavaScript surface by default. Without it, // unannotated fields are declaration-only inputs that can still be consumed @@ -1454,7 +1469,8 @@ fn expand_accessor_property_field( )); } let getter = if let Some(getter) = attrs.getter.as_ref() { - let getter = expand_accessor_function_builder(getter, 0, attrs.data.as_ref(), &name); + let getter = expand_callback(getter, attrs, false); + let getter = expand_accessor_function_builder(&getter, 0, attrs.data.as_ref(), &name); quote! { #getter.ok_or_else(|| { ::moli_webapi_declare::BindError::new( @@ -1472,7 +1488,8 @@ fn expand_accessor_property_field( }; let setter = attrs.setter.as_ref().map(|setter| { let setter_data = attrs.setter_data.as_ref().or(attrs.data.as_ref()); - expand_accessor_function_builder(setter, 1, setter_data, &name) + let setter = expand_callback(setter, attrs, true); + expand_accessor_function_builder(&setter, 1, setter_data, &name) }); let setter = match setter { Some(setter) => quote! { @@ -1589,8 +1606,9 @@ fn expand_object_method_field( let enumerable = attrs.enumerable; let writable = !attrs.readonly; let configurable = !attrs.dont_delete; + let callback = expand_callback(callback, attrs, false); let build_function = - expand_method_function_builder(callback, length, attrs.data.as_ref(), &name); + expand_method_function_builder(&callback, length, attrs.data.as_ref(), &name); let install_method = quote! { let function = #build_function.ok_or_else(|| { ::moli_webapi_declare::BindError::new( @@ -1722,8 +1740,56 @@ fn expand_alias_field( }) } -fn expand_accessor_function_builder( +/// Generate a native callback adapter, without changing callback data or +/// introducing a JavaScript wrapper. Receiver checks precede argument conversion. +fn expand_callback( callback: &syn::Path, + attrs: &crate::attrs::FieldAttrs, + is_setter: bool, +) -> proc_macro2::TokenStream { + let returns_promise = attrs.returns_promise && !is_setter; + if attrs.receiver.is_none() && !returns_promise { + return quote!(#callback); + } + let receiver_check = attrs.receiver.as_ref().map(|receiver| { + quote! { + if !#receiver(scope, args.this()) { + ::moli_webapi_declare::__private::throw_illegal_invocation(scope); + return; + } + } + }); + let adapter = if returns_promise { + quote! { + fn __webapi_promise_callback<'s>( + scope: &mut ::moli_webapi_declare::v8::PinScope<'s, '_>, + args: ::moli_webapi_declare::v8::FunctionCallbackArguments<'s>, + rv: ::moli_webapi_declare::v8::ReturnValue<'s>, + ) { + ::moli_webapi_declare::__private::invoke_promise_callback( + scope, args, rv, __webapi_callback, + ); + } + __webapi_promise_callback + } + } else { + quote!(__webapi_callback) + }; + quote! {{ + fn __webapi_callback<'s>( + scope: &mut ::moli_webapi_declare::v8::PinScope<'s, '_>, + args: ::moli_webapi_declare::v8::FunctionCallbackArguments<'s>, + rv: ::moli_webapi_declare::v8::ReturnValue<'s>, + ) { + #receiver_check + #callback(scope, args, rv); + } + #adapter + }} +} + +fn expand_accessor_function_builder( + callback: &proc_macro2::TokenStream, length: i32, data: Option<&syn::Expr>, display_name: &proc_macro2::TokenStream, @@ -1763,7 +1829,7 @@ fn expand_accessor_function_builder( } fn expand_method_function_builder( - callback: &syn::Path, + callback: &proc_macro2::TokenStream, length: i32, data: Option<&syn::Expr>, display_name: &proc_macro2::TokenStream, @@ -1807,7 +1873,7 @@ fn expand_method_function_builder( } fn expand_template_function_member( - callback: &syn::Path, + callback: &proc_macro2::TokenStream, length: i32, data: Option<&syn::Expr>, template_name: &LitStr, @@ -2226,6 +2292,37 @@ fn named_fields(data: &Data) -> Result, Error> { mod tests { use super::{expand_webapi_function_template, expand_webapi_interface, expand_webapi_object}; + #[test] + fn callback_policies_reject_fields_that_cannot_apply_them() { + let fields: Vec = vec![ + syn::parse_quote!(#[webapi(data_property, receiver = check)] value: ()), + syn::parse_quote!(#[webapi(static_method, callback = call, receiver = check)] value: ()), + syn::parse_quote!(#[webapi(native_data_property, getter = get, receiver = check)] value: ()), + syn::parse_quote!(#[webapi(alias = "other", receiver = check)] value: ()), + syn::parse_quote!(#[webapi(data_property, returns_promise)] value: ()), + syn::parse_quote!(#[webapi(native_data_property, getter = get, returns_promise)] value: ()), + syn::parse_quote!(#[webapi(accessor_property, getter_value = self.getter, receiver = check)] value: ()), + syn::parse_quote!(#[webapi(accessor_property, getter_value = self.getter, returns_promise)] value: ()), + syn::parse_quote!(#[webapi(method, callback = call, receiver = check, receiver = other)] value: ()), + ]; + for field in fields { + assert!(crate::attrs::parse_field_attrs(&field).is_err()); + } + } + + #[test] + fn inherited_receiver_cannot_silently_skip_an_already_built_getter() { + let input = syn::parse_quote! { + #[webapi(interface = "Object", receiver = check)] + struct Invalid { + #[webapi(accessor_property, getter_value = self.getter)] + getter: (), + } + }; + let error = expand_webapi_object(input).unwrap_err(); + assert!(error.to_string().contains("require a Rust callback")); + } + #[test] fn declaration_only_field_attributes_are_rejected() { let input = syn::parse_quote! { diff --git a/moli-webapi-declare/src/__private.rs b/moli-webapi-declare/src/__private.rs index 8285ee12d..616c2d005 100644 --- a/moli-webapi-declare/src/__private.rs +++ b/moli-webapi-declare/src/__private.rs @@ -1,3 +1,4 @@ +pub use crate::callback::{invoke_promise_callback, throw_illegal_invocation}; pub use moli_v8_util::{global_constructor_prototype, v8_string, v8str}; /// Complete description of one function installed by diff --git a/moli-webapi-declare/src/callback.rs b/moli-webapi-declare/src/callback.rs new file mode 100644 index 000000000..aca15e2d3 --- /dev/null +++ b/moli-webapi-declare/src/callback.rs @@ -0,0 +1,42 @@ +use moli_v8_util::v8str; + +type MemberCallback = for<'s, 'i> fn( + &mut v8::PinScope<'s, 'i>, + v8::FunctionCallbackArguments<'s>, + v8::ReturnValue<'s>, +); + +pub fn throw_illegal_invocation(scope: &mut v8::PinScope<'_, '_>) { + let message = v8str(scope, "Illegal invocation"); + let exception = v8::Exception::type_error(scope, message); + scope.throw_exception(exception); +} + +/// Like Blink's ExceptionToRejectPromiseScope, this covers both receiver checks +/// and exceptions thrown by the implementation (including argument conversion). +/// It does not wrap successful results, preserving cached Promise identity. +pub fn invoke_promise_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s>, + callback: MemberCallback, +) { + v8::tc_scope!(let scope, scope); + callback(scope, args, rv); + // Termination is not a catchable WebIDL exception. Leave it to V8. + if !scope.can_continue() { + return; + } + let Some(exception) = scope.exception() else { + return; + }; + scope.reset(); + // The callback's current realm supplies both TypeError and Promise, even + // when its receiver or caller belongs to a different realm. + let Some(resolver) = v8::PromiseResolver::new(scope) else { + return; + }; + if resolver.reject(scope, exception).is_some() { + rv.set(resolver.get_promise(scope).into()); + } +} diff --git a/moli-webapi-declare/src/lib.rs b/moli-webapi-declare/src/lib.rs index 206b4d53b..e8c89659e 100644 --- a/moli-webapi-declare/src/lib.rs +++ b/moli-webapi-declare/src/lib.rs @@ -98,6 +98,28 @@ //! `#[webapi(no_dynamic_constructor)]` when the declaration already has a //! hand-written constructor with narrower semantics. //! +//! # Receiver checks and Promise-returning members +//! +//! `receiver = path` on a method or `accessor_property` declares a native brand +//! predicate with signature `fn(&mut v8::PinScope, v8::Local) -> bool`. +//! The generated callback checks it before running the implementation, throwing +//! `TypeError("Illegal invocation")` on failure. The predicate must inspect native +//! identity or private slots, not public constructors, prototypes, or properties; +//! it must not execute JavaScript or throw. This preserves cross-realm receivers +//! without accepting forged prototypes or Proxy wrappers. +//! +//! Struct-level `receiver = path` supplies the default for instance methods and +//! accessor properties in all three derives; a field can override it. Static +//! methods, data properties, and holder-based native data properties do not +//! inherit this policy. Already-built `getter_value` functions cannot use it. +//! +//! `returns_promise` on a method (including a static method) or accessor getter +//! converts synchronous exceptions from both the receiver check and the callback +//! into rejected Promises in the callback's realm. Successful return values are +//! unchanged, so cached Promise identity is preserved. An accessor setter still +//! throws synchronously. Callback data and native function descriptors are +//! unchanged: these adapters are Rust callbacks, not JavaScript wrappers. +//! //! # Function-template declaration model //! //! A `#[derive(WebApiFunctionTemplate)]` struct describes a constructor-backed @@ -141,6 +163,7 @@ extern crate self as moli_webapi_declare; +mod callback; mod declaration; mod error; mod property; diff --git a/moli-webapi-declare/tests/receivers.rs b/moli-webapi-declare/tests/receivers.rs new file mode 100644 index 000000000..c17b59941 --- /dev/null +++ b/moli-webapi-declare/tests/receivers.rs @@ -0,0 +1,386 @@ +use std::pin::pin; + +use moli_v8_test_util::ensure_v8; +use moli_v8_util::{get_private_value, set_private_value, v8str}; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiInterface, WebApiObject}; + +const BRAND: &str = "__receiverTestBrand"; +const OTHER_BRAND: &str = "__receiverTestOtherBrand"; +const PROMISE: &str = "__receiverTestPromise"; + +fn has_brand<'s>(scope: &mut v8::PinScope<'s, '_>, receiver: v8::Local<'s, v8::Object>) -> bool { + get_private_value(scope, receiver, BRAND).is_some() +} + +fn has_other_brand<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + get_private_value(scope, receiver, OTHER_BRAND).is_some() +} + +fn constructor<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s>, +) { + let brand = v8::Boolean::new(scope, true); + set_private_value(scope, args.this(), BRAND, brand.into()); + let resolver = v8::PromiseResolver::new(scope).unwrap(); + resolver.resolve(scope, args.this().into()).unwrap(); + let promise = resolver.get_promise(scope); + set_private_value(scope, args.this(), PROMISE, promise.into()); +} + +fn callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s>, +) { + // This observable conversion must never precede the receiver check. + if args.length() > 0 && args.get(0).to_string(scope).is_none() { + return; + } + rv.set(args.data()); +} + +fn promise_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s>, +) { + if args.length() > 0 && args.get(0).to_string(scope).is_none() { + return; + } + rv.set(get_private_value(scope, args.this(), PROMISE).unwrap()); +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(name = "NativeSample", constructor_callback = constructor, receiver = has_brand, enumerable)] +struct NativeSample { + #[webapi(method, callback = callback, length = 1, data = 7)] + method: (), + #[webapi(alias = "method")] + alias: (), + #[webapi(accessor_property, getter = callback, setter = callback, data = 9, setter_data = 11)] + value: (), + #[webapi(method, callback = promise_callback, returns_promise)] + load: (), + #[webapi(accessor_property, getter = promise_callback, setter = callback, returns_promise)] + ready: (), + #[webapi(method, callback = callback, receiver = has_other_brand, data = 13)] + other: (), + #[webapi(static_method, callback = callback, data = 17)] + static_value: (), + #[webapi(static_method, callback = callback, returns_promise)] + static_promise: (), +} + +#[derive(WebApiInterface)] +#[webapi(name = "PlainInterface", receiver = has_brand)] +struct PlainInterface { + #[webapi(method, callback = callback, data = 7)] + method: (), + #[webapi(accessor_property, getter = callback, setter = callback, data = 9)] + value: (), + #[webapi(accessor_property, getter = promise_callback, returns_promise)] + ready: (), +} + +#[derive(WebApiObject)] +#[webapi(interface = "PlainInterface", receiver = has_brand)] +struct PlainObject { + #[webapi(slot = BRAND)] + brand: bool, + #[webapi(method, callback = callback, data = 7)] + method: (), + #[webapi(accessor_property, getter = callback, setter = callback, data = 9)] + value: (), + #[webapi(accessor_property, getter = promise_callback, returns_promise)] + ready: (), +} + +fn run_script<'s>(scope: &mut v8::PinScope<'s, '_>, source: &str) -> v8::Local<'s, v8::Value> { + let source = v8::String::new(scope, source).unwrap(); + v8::Script::compile(scope, source, None) + .unwrap() + .run(scope) + .unwrap() +} + +fn install<'s>(scope: &mut v8::PinScope<'s, '_>) { + let template = NativeSample::build(scope); + let constructor = template.get_function(scope).unwrap(); + let global = scope.get_current_context().global(scope); + global + .set( + scope, + v8str(scope, "NativeSample").into(), + constructor.into(), + ) + .unwrap(); + let key = v8::Boolean::new(scope, true); + let other = v8::Object::new(scope); + set_private_value(scope, other, OTHER_BRAND, key.into()); + global + .set(scope, v8str(scope, "other").into(), other.into()) + .unwrap(); +} + +const ASSERTIONS: &str = r#" + function assert(ok, message) { if (!ok) throw new Error(message); } + function throwsTypeError(fn) { + try { fn(); } catch (error) { return error instanceof TypeError; } + return false; + } +"#; + +#[test] +fn template_receiver_checks_preserve_data_descriptors_and_precede_conversion() { + ensure_v8(); + let mut isolate = v8::Isolate::new(Default::default()); + let scope = pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut scope.init(); + let context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, context); + install(scope); + run_script(scope, ASSERTIONS); + run_script( + scope, + r#" + const proto = NativeSample.prototype; + const descriptor = Object.getOwnPropertyDescriptor(proto, 'value'); + const real = new NativeSample(); + class Derived extends NativeSample {} + assert(new Derived().method() === 7, 'native subclass'); + assert(real.value === 9 && real.method() === 7, 'callback data'); + assert(descriptor.set.call(real, 'ok') === 11, 'separate setter data'); + assert(descriptor.get.name === 'get value' && descriptor.set.length === 1, 'accessor metadata'); + assert(descriptor.enumerable && descriptor.configurable, 'accessor flags'); + assert(proto.method.length === 1 && /\[native code\]/.test(proto.method.toString()), 'native method'); + assert(proto.alias === proto.method, 'aliases retain checked function identity'); + assert(NativeSample.staticValue.call({}) === 17, 'static receiver is unchecked'); + assert(proto.other.call(other) === 13, 'field receiver overrides default'); + assert(throwsTypeError(() => proto.other.call(real)), 'override rejects default brand'); + let conversions = 0; + const input = { toString() { conversions++; return 'x'; } }; + const bad = [proto, {}, Object.create(proto), new Proxy(real, {}), null, undefined, 7]; + const revoked = Proxy.revocable(real, {}); + revoked.revoke(); + bad.push(revoked.proxy); + for (const receiver of bad) { + assert(throwsTypeError(() => proto.method.call(receiver, input)), 'method rejects'); + assert(throwsTypeError(() => descriptor.get.call(receiver)), 'getter rejects'); + assert(throwsTypeError(() => descriptor.set.call(receiver, input)), 'setter rejects'); + } + assert(conversions === 0, 'brand before conversion'); + const original = new Error('conversion'); + let thrown; + try { descriptor.set.call(real, { toString() { throw original; } }); } catch (e) { thrown = e; } + assert(thrown === original, 'valid setter preserves conversion exception'); + Object.setPrototypeOf(real, null); + assert(proto.method.call(real) === 7, 'brand is independent of mutable prototype'); + let traps = 0; + const proxy = new Proxy(real, { + get() { traps++; }, getPrototypeOf() { traps++; }, has() { traps++; } + }); + assert(throwsTypeError(() => proto.method.call(proxy)), 'proxy is not the native receiver'); + assert(traps === 0, 'brand check does not execute page code'); + "#, + ); +} + +#[test] +fn promise_members_reject_all_synchronous_errors_and_preserve_success_identity() { + ensure_v8(); + let mut isolate = v8::Isolate::new(Default::default()); + let scope = pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut scope.init(); + let context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, context); + install(scope); + run_script(scope, ASSERTIONS); + let result = run_script( + scope, + r#"(async () => { + const proto = NativeSample.prototype; + const descriptor = Object.getOwnPropertyDescriptor(proto, 'ready'); + const real = new NativeSample(); + assert(real.ready === real.ready && real.load() === real.ready, 'cached Promise identity'); + for (const receiver of [{}, proto, Object.create(proto), new Proxy(real, {}), null]) { + for (const invoke of [() => descriptor.get.call(receiver), () => proto.load.call(receiver)]) { + const promise = invoke(); + assert(promise instanceof Promise, 'invalid receiver must not throw synchronously'); + const reason = await promise.then(() => null, e => e); + assert(reason instanceof TypeError, 'Promise rejects with TypeError'); + } + } + const original = new Error('conversion'); + const input = { toString() { throw original; } }; + for (const promise of [real.load(input), NativeSample.staticPromise(input)]) { + assert(promise instanceof Promise, 'callback exception becomes Promise'); + assert(await promise.then(() => null, e => e) === original, 'preserve exception identity'); + } + assert(throwsTypeError(() => descriptor.set.call({}, input)), 'Promise attribute setter still throws'); + assert(await real.ready === real, 'successful load result'); + return true; + })()"#, + ); + let promise = v8::Local::::try_from(result).unwrap(); + scope.perform_microtask_checkpoint(); + assert_eq!( + promise.state(), + v8::PromiseState::Fulfilled, + "{}", + promise + .result(scope) + .to_string(scope) + .unwrap() + .to_rust_string_lossy(scope) + ); + assert!(promise.result(scope).is_true()); +} + +#[test] +fn object_and_interface_declarations_use_the_same_receiver_policy() { + ensure_v8(); + let mut isolate = v8::Isolate::new(Default::default()); + let scope = pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut scope.init(); + let context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, context); + let global = context.global(scope); + PlainInterface { + method: (), + value: (), + ready: (), + } + .bind(scope, global) + .unwrap(); + let object = PlainObject::new(true).bind(scope).unwrap(); + global + .set(scope, v8str(scope, "object").into(), object.into()) + .unwrap(); + run_script(scope, ASSERTIONS); + run_script( + scope, + r#" + for (const surface of [object, PlainInterface.prototype]) { + assert(surface.method.call(object) === 7, 'valid receiver'); + assert(throwsTypeError(() => surface.method.call({})), 'invalid receiver'); + const value = Object.getOwnPropertyDescriptor(surface, 'value'); + assert(value.get.call(object) === 9, 'getter data'); + assert(throwsTypeError(() => value.set.call({})), 'invalid setter receiver'); + const promise = Object.getOwnPropertyDescriptor(surface, 'ready').get.call({}); + assert(promise instanceof Promise, 'invalid Promise getter receiver'); + promise.catch(() => {}); + } + "#, + ); + scope.perform_microtask_checkpoint(); +} + +#[test] +fn cross_realm_receiver_validation_uses_the_callee_error_and_promise_realm() { + ensure_v8(); + let mut isolate = v8::Isolate::new(Default::default()); + let scope = pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut scope.init(); + let child = v8::Context::new(scope, Default::default()); + let token = v8str(scope, "same-origin"); + child.set_security_token(token.into()); + { + let scope = &mut v8::ContextScope::new(scope, child); + install(scope); + run_script(scope, "globalThis.real = new NativeSample()"); + } + let context = v8::Context::new(scope, Default::default()); + context.set_security_token(token.into()); + let scope = &mut v8::ContextScope::new(scope, context); + install(scope); + let child_global = child.global(scope); + context + .global(scope) + .set(scope, v8str(scope, "child").into(), child_global.into()) + .unwrap(); + run_script(scope, ASSERTIONS); + let result = run_script( + scope, + r#"(async () => { + assert(NativeSample.prototype.method.call(child.real) === 7, 'cross-realm receiver'); + assert(child.NativeSample.prototype.method.call(new NativeSample()) === 7, 'reverse cross-realm receiver'); + let caught; + try { child.NativeSample.prototype.method.call({}); } catch (e) { caught = e; } + assert(caught instanceof child.TypeError && !(caught instanceof TypeError), 'callee TypeError realm'); + const ready = Object.getOwnPropertyDescriptor(child.NativeSample.prototype, 'ready').get; + for (const promise of [ready.call({}), child.NativeSample.prototype.load.call({})]) { + assert(promise instanceof child.Promise && !(promise instanceof Promise), 'callee Promise realm'); + const error = await promise.catch(e => e); + assert(error instanceof child.TypeError && !(error instanceof TypeError), 'rejection realm'); + } + assert(ready.call(new NativeSample()) instanceof Promise, 'successful Promise is not rewrapped'); + return true; + })()"#, + ); + let promise = v8::Local::::try_from(result).unwrap(); + scope.perform_microtask_checkpoint(); + assert_eq!( + promise.state(), + v8::PromiseState::Fulfilled, + "{}", + promise + .result(scope) + .to_string(scope) + .unwrap() + .to_rust_string_lossy(scope) + ); + assert!(promise.result(scope).is_true()); +} + +#[test] +fn promise_callbacks_do_not_convert_execution_termination_into_rejection() { + fn terminate( + scope: &mut v8::PinScope<'_, '_>, + _args: v8::FunctionCallbackArguments<'_>, + _rv: v8::ReturnValue<'_>, + ) { + scope.terminate_execution(); + } + + ensure_v8(); + let mut isolate = v8::Isolate::new(Default::default()); + let scope = pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut scope.init(); + let context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, context); + install(scope); + let terminate = v8::Function::new(scope, terminate).unwrap(); + context + .global(scope) + .set(scope, v8str(scope, "terminate").into(), terminate.into()) + .unwrap(); + let source = v8str( + scope, + // TerminateExecution requests an interrupt; reach a V8 interrupt check + // before the conversion returns, instead of relying on a short call to + // happen to consume the request. + "new NativeSample().load({toString() { terminate(); for (;;) {} }})", + ); + let script = v8::Script::compile(scope, source, None).unwrap(); + { + v8::tc_scope!(let scope, scope); + let result = script.run(scope); + assert!( + result.is_none(), + "promise={:?}, caught={}, terminated={}, terminating={}", + result.map(|value| value.is_promise()), + scope.has_caught(), + scope.has_terminated(), + scope.is_execution_terminating() + ); + assert!(scope.has_terminated()); + assert!(!scope.can_continue()); + } + scope.cancel_terminate_execution(); + assert!(run_script(scope, "new NativeSample().method() === 7").is_true()); +}