diff --git a/moli-webapi-declare-derive/src/expand.rs b/moli-webapi-declare-derive/src/expand.rs index e1b8060e9b..1a2d0400cf 100644 --- a/moli-webapi-declare-derive/src/expand.rs +++ b/moli-webapi-declare-derive/src/expand.rs @@ -15,6 +15,9 @@ struct DeclaredField { attrs: FieldAttrs, } +mod cross_origin; +pub(crate) use cross_origin::expand_webapi_cross_origin; + fn declared_fields( data: &Data, rename_all: RenameRule, diff --git a/moli-webapi-declare-derive/src/expand/cross_origin.rs b/moli-webapi-declare-derive/src/expand/cross_origin.rs new file mode 100644 index 0000000000..c65d2a0f40 --- /dev/null +++ b/moli-webapi-declare-derive/src/expand/cross_origin.rs @@ -0,0 +1,234 @@ +use super::*; + +pub(crate) fn expand_webapi_cross_origin( + input: DeriveInput, +) -> Result { + let name = &input.ident; + if !input.generics.params.is_empty() { + return Err(Error::new_spanned( + &input.generics, + "cross-origin declarations cannot carry runtime state", + )); + } + let attrs = parse_object_attrs(&input.attrs)?; + if !matches!(attrs.role, None | Some(ObjectRole::Plain)) + || attrs.prototype.is_some() + || attrs.own_to_string_tag.is_some() + || attrs.fallback_to_string_tag.is_some() + || attrs.scope_lifetime.is_some() + || attrs.default_data_properties + || attrs.default_enumerable + { + return Err(Error::new_spanned( + input, + "cross-origin declarations describe stateless property tables, not object initialization", + )); + } + let fields = declared_fields( + &input.data, + attrs.rename_all, + FieldDefaults { + receiver: attrs.receiver.as_ref(), + ..FieldDefaults::default() + }, + )?; + let mut helpers = Vec::new(); + let mut entries = Vec::new(); + for DeclaredField { field, attrs } in fields { + if !type_is_unit(&field.ty) + || attrs.data.is_some() + || attrs.setter_data.is_some() + || attrs.returns_promise + || attrs.symbol.is_some() + || attrs.getter_value.is_some() + || attrs.value.is_some() + || attrs.init.is_some() + || attrs.enumerable + || attrs.dont_delete + { + return Err(Error::new_spanned( + field, + "cross-origin members must be stateless string-keyed methods or accessors", + )); + } + let property_name = match attrs.name.as_ref() { + Some(syn::Expr::Lit(syn::ExprLit { + lit: Lit::Str(name), + .. + })) => name.clone(), + _ => { + return Err(Error::new_spanned( + &field, + "cross-origin member names must be string literals", + )); + } + }; + let ident = field.ident.as_ref().expect("named field"); + let receiver_check = attrs.receiver.as_ref().map(|receiver| { + quote! { + if !#receiver(__moli_scope, __moli_receiver) { + ::moli_webapi_declare::__private::throw_illegal_invocation(__moli_scope); + return None; + } + } + }); + let function = |suffix: &str, + callback: proc_macro2::TokenStream, + length: i32, + function_name: LitStr| { + let factory = format_ident!("__cross_origin_{}_{}_template", ident, suffix); + let helper = quote! { + fn #factory<'s>( + scope: &::moli_webapi_declare::v8::PinScope<'s, '_>, + ) -> ::moli_webapi_declare::v8::Local<'s, ::moli_webapi_declare::v8::FunctionTemplate> { + let template = ::moli_webapi_declare::v8::FunctionTemplate::builder(#callback) + .length(#length) + .constructor_behavior(::moli_webapi_declare::v8::ConstructorBehavior::Throw) + .build(scope); + template.remove_prototype(); + template.set_class_name(::moli_webapi_declare::__private::v8str(scope, #function_name)); + template + } + }; + ( + helper, + quote!(::moli_webapi_declare::CrossOriginFunction { build: Self::#factory }), + ) + }; + match attrs.kind { + FieldKind::Method => { + let callback = attrs + .callback + .as_ref() + .ok_or_else(|| Error::new_spanned(&field, "method requires callback"))?; + let callback = expand_callback(callback, &attrs, false); + let (helper, function) = function( + "method", + callback, + attrs.length.unwrap_or(0), + property_name.clone(), + ); + helpers.push(helper); + entries.push(quote! { + ::moli_webapi_declare::CrossOriginProperty::Method { name: #property_name, function: #function } + }); + } + FieldKind::AccessorProperty => { + if attrs.getter.is_none() && attrs.setter.is_none() { + return Err(Error::new_spanned( + field, + "cross-origin accessor requires a getter or setter", + )); + } + let mut accessors = Vec::new(); + for (suffix, callback, length) in [ + ("get", attrs.getter.as_ref(), 0), + ("set", attrs.setter.as_ref(), 1), + ] { + let Some(callback) = callback else { + accessors.push(quote!(None)); + continue; + }; + let value_helper = format_ident!("__cross_origin_{}_{}", ident, suffix); + let callback_helper = + format_ident!("__cross_origin_{}_{}_callback", ident, suffix); + let function_name = LitStr::new( + &format!("{suffix} {}", property_name.value()), + property_name.span(), + ); + let (helper, function) = function( + suffix, + quote!(Self::#callback_helper), + length, + function_name, + ); + helpers.push(helper); + if length == 0 { + helpers.push(quote! { + fn #value_helper<'s>( + __moli_scope: &mut ::moli_webapi_declare::v8::PinScope<'s, '_>, + __moli_receiver: ::moli_webapi_declare::v8::Local<'s, ::moli_webapi_declare::v8::Object>, + ) -> Option<::moli_webapi_declare::v8::Local<'s, ::moli_webapi_declare::v8::Value>> { + #receiver_check + #callback(__moli_scope, __moli_receiver) + } + fn #callback_helper<'s>( + __moli_scope: &mut ::moli_webapi_declare::v8::PinScope<'s, '_>, + args: ::moli_webapi_declare::v8::FunctionCallbackArguments<'s>, + mut rv: ::moli_webapi_declare::v8::ReturnValue<'s>, + ) { + if let Some(value) = Self::#value_helper(__moli_scope, args.this()) { rv.set(value); } + } + }); + } else { + helpers.push(quote! { + fn #value_helper<'s>( + __moli_scope: &mut ::moli_webapi_declare::v8::PinScope<'s, '_>, + __moli_receiver: ::moli_webapi_declare::v8::Local<'s, ::moli_webapi_declare::v8::Object>, + value: ::moli_webapi_declare::v8::Local<'s, ::moli_webapi_declare::v8::Value>, + ) -> Option<()> { + #receiver_check + #callback(__moli_scope, __moli_receiver, value) + } + fn #callback_helper<'s>( + __moli_scope: &mut ::moli_webapi_declare::v8::PinScope<'s, '_>, + args: ::moli_webapi_declare::v8::FunctionCallbackArguments<'s>, + _rv: ::moli_webapi_declare::v8::ReturnValue<'s>, + ) { + let _ = Self::#value_helper(__moli_scope, args.this(), args.get(0)); + } + }); + } + accessors.push(quote!(Some((Self::#value_helper, #function)))); + } + let getter = &accessors[0]; + let setter = &accessors[1]; + entries.push(quote! { + ::moli_webapi_declare::CrossOriginProperty::Accessor { + name: #property_name, getter: #getter, setter: #setter, + } + }); + } + _ => { + return Err(Error::new_spanned( + &field, + "cross-origin declarations only support method and accessor_property", + )); + } + } + } + Ok(quote! { + impl #name { #(#helpers)* } + impl ::moli_webapi_declare::WebApiCrossOriginDeclaration for #name { + fn cross_origin_properties() -> &'static [::moli_webapi_declare::CrossOriginProperty] { + &[#(#entries),*] + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_declarations_that_would_capture_runtime_state() { + let invalid: [DeriveInput; 3] = [ + syn::parse_quote! { + #[webapi(plain)] + struct Bad { #[webapi(method, callback = callback)] operation: T } + }, + syn::parse_quote! { + #[webapi(plain)] + struct Bad { #[webapi(method, callback = callback, data = self.state)] operation: (), state: usize } + }, + syn::parse_quote! { + #[webapi(plain)] + struct Bad { #[webapi(method, name = runtime_name(), callback = callback)] operation: () } + }, + ]; + for input in invalid { + assert!(expand_webapi_cross_origin(input).is_err()); + } + } +} diff --git a/moli-webapi-declare-derive/src/lib.rs b/moli-webapi-declare-derive/src/lib.rs index 71f4e95a41..e1c401f156 100644 --- a/moli-webapi-declare-derive/src/lib.rs +++ b/moli-webapi-declare-derive/src/lib.rs @@ -4,6 +4,17 @@ mod expand; use proc_macro::TokenStream; use syn::{DeriveInput, parse_macro_input}; +/// Declares native cross-origin methods and accessors without an intermediate +/// JavaScript object. Accessor callbacks return Option / Option<()> and +/// are also adapted to realm-correct descriptor functions. +#[proc_macro_derive(WebApiCrossOrigin, attributes(webapi))] +pub fn derive_webapi_cross_origin(input: TokenStream) -> TokenStream { + match expand::expand_webapi_cross_origin(parse_macro_input!(input as DeriveInput)) { + Ok(tokens) => tokens.into(), + Err(error) => error.to_compile_error().into(), + } +} + /// Derives a Web API function-template declaration binder. /// /// The generated implementation creates a V8 `FunctionTemplate`, sets its class diff --git a/moli-webapi-declare/src/cross_origin.rs b/moli-webapi-declare/src/cross_origin.rs new file mode 100644 index 0000000000..b0bbc0af93 --- /dev/null +++ b/moli-webapi-declare/src/cross_origin.rs @@ -0,0 +1,174 @@ +//! Native cross-origin member declarations. Templates contain callback metadata +//! only; V8 owns each context's unique function instance and its lifetime. + +use std::collections::HashMap; + +use moli_v8_util::v8str; + +pub type CrossOriginGetter = for<'s, 'i> fn( + &mut v8::PinScope<'s, 'i>, + v8::Local<'s, v8::Object>, +) -> Option>; +pub type CrossOriginSetter = for<'s, 'i> fn( + &mut v8::PinScope<'s, 'i>, + v8::Local<'s, v8::Object>, + v8::Local<'s, v8::Value>, +) -> Option<()>; + +pub struct CrossOriginFunction { + pub build: for<'s, 'i> fn(&v8::PinScope<'s, 'i>) -> v8::Local<'s, v8::FunctionTemplate>, +} + +#[derive(Default)] +struct FunctionTemplates(HashMap>); + +impl CrossOriginFunction { + pub fn get<'s>( + &'static self, + scope: &mut v8::PinScope<'s, '_>, + ) -> Option> { + // The key identifies an immutable declaration member, never a Window + // or a realm. No callback data can retain a target or an observer. + let key = self as *const Self as usize; + let cached = scope + .get_slot::() + .and_then(|templates| templates.0.get(&key)) + .map(|template| v8::Local::new(scope, template)); + let template = match cached { + Some(template) => template, + None => { + let template = (self.build)(scope); + let retained = v8::Global::new(scope, template); + if scope.get_slot::().is_none() { + scope.set_slot(FunctionTemplates::default()); + } + scope + .get_slot_mut::() + .expect("template store was initialized") + .0 + .insert(key, retained); + template + } + }; + template.get_function(scope) + } +} + +pub enum CrossOriginProperty { + Method { + name: &'static str, + function: CrossOriginFunction, + }, + Accessor { + name: &'static str, + getter: Option<(CrossOriginGetter, CrossOriginFunction)>, + setter: Option<(CrossOriginSetter, CrossOriginFunction)>, + }, +} + +impl CrossOriginProperty { + pub const fn name(&self) -> &'static str { + match self { + Self::Method { name, .. } | Self::Accessor { name, .. } => name, + } + } + + pub fn get<'s>( + &'static self, + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, + ) -> Option> { + match self { + Self::Method { function, .. } => function.get(scope).map(Into::into), + Self::Accessor { + getter: Some((getter, _)), + .. + } => getter(scope, receiver), + _ => None, + } + } + + pub fn set<'s>( + &'static self, + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, + value: v8::Local<'s, v8::Value>, + ) -> Option<()> { + match self { + Self::Accessor { + setter: Some((setter, _)), + .. + } => setter(scope, receiver, value), + _ => None, + } + } + + pub const fn has_getter(&self) -> bool { + matches!( + self, + Self::Method { .. } + | Self::Accessor { + getter: Some(_), + .. + } + ) + } + + pub const fn has_setter(&self) -> bool { + matches!( + self, + Self::Accessor { + setter: Some(_), + .. + } + ) + } + + pub fn descriptor<'s>( + &'static self, + scope: &mut v8::PinScope<'s, '_>, + ) -> Option> { + let descriptor = v8::Object::new(scope); + let fields: [(&str, v8::Local); 2] = match self { + Self::Method { function, .. } => [ + ("value", function.get(scope)?.into()), + ("writable", v8::Boolean::new(scope, false).into()), + ], + Self::Accessor { getter, setter, .. } => { + let get = match getter { + Some((_, function)) => function.get(scope)?.into(), + None => v8::undefined(scope).into(), + }; + let set = match setter { + Some((_, function)) => function.get(scope)?.into(), + None => v8::undefined(scope).into(), + }; + [("get", get), ("set", set)] + } + }; + for (name, value) in fields.into_iter().chain([ + ("enumerable", v8::Boolean::new(scope, false).into()), + ("configurable", v8::Boolean::new(scope, true).into()), + ]) { + descriptor + .define_own_property( + scope, + v8str(scope, name).into(), + value, + v8::PropertyAttribute::NONE, + ) + .filter(|defined| *defined)?; + } + Some(descriptor) + } +} + +pub trait WebApiCrossOriginDeclaration { + fn cross_origin_properties() -> &'static [CrossOriginProperty]; + + fn cross_origin_property(name: &str) -> Option<&'static CrossOriginProperty> { + Self::cross_origin_properties() + .iter() + .find(|property| property.name() == name) + } +} diff --git a/moli-webapi-declare/src/lib.rs b/moli-webapi-declare/src/lib.rs index a1c8a09c31..0574a1ee75 100644 --- a/moli-webapi-declare/src/lib.rs +++ b/moli-webapi-declare/src/lib.rs @@ -5,6 +5,14 @@ //! `moli-webidl` and focuses on constructing V8 interface objects, //! prototypes, and branded object instances. //! +//! `#[derive(WebApiCrossOrigin)]` describes a stateless cross-origin property +//! table for native access-check handlers. Methods use ordinary V8 callbacks; +//! accessors use native value callbacks returning `Option`, with receiver +//! predicates checked before either invocation. Function templates are cached +//! per isolate and produce a unique function in each accessing context. The +//! table and templates never capture a target Window or an observer context. +//! Runtime V8 failures propagate to the script execution boundary. +//! //! # Object declaration model //! //! A `#[derive(WebApiObject)]` struct describes the JavaScript surface that must @@ -241,6 +249,7 @@ extern crate self as moli_webapi_declare; mod brand; mod callback; +mod cross_origin; mod declaration; mod error; mod interface; @@ -251,7 +260,7 @@ mod value; pub mod __private; pub use interface::WebApiInterfaceDescriptor; -pub use moli_webapi_declare_derive::{WebApiFunctionTemplate, WebApiObject}; +pub use moli_webapi_declare_derive::{WebApiCrossOrigin, WebApiFunctionTemplate, WebApiObject}; pub use v8; /// Adapts a manual native constructor to the same identity initialization used @@ -282,6 +291,10 @@ pub use brand::{ register_web_api_proxy, web_api_object_target, web_api_object_type, }; +pub use cross_origin::{ + CrossOriginFunction, CrossOriginGetter, CrossOriginProperty, CrossOriginSetter, + WebApiCrossOriginDeclaration, +}; pub use declaration::{ DataPropertyDescriptorDeclaration, ObjectLiteralDeclaration, WebApiFunctionTemplateDeclaration, WebApiObjectDeclaration, diff --git a/moli-webapi-declare/tests/cross_origin.rs b/moli-webapi-declare/tests/cross_origin.rs new file mode 100644 index 0000000000..595d69603e --- /dev/null +++ b/moli-webapi-declare/tests/cross_origin.rs @@ -0,0 +1,182 @@ +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::{WebApiCrossOrigin, WebApiCrossOriginDeclaration}; + +const VALUE: &str = "__crossOriginTestValue"; + +fn receiver<'s>(scope: &mut v8::PinScope<'s, '_>, object: v8::Local<'s, v8::Object>) -> bool { + get_private_value(scope, object, VALUE).is_some() +} + +fn get<'s>( + scope: &mut v8::PinScope<'s, '_>, + object: v8::Local<'s, v8::Object>, +) -> Option> { + get_private_value(scope, object, VALUE) +} + +fn set<'s>( + scope: &mut v8::PinScope<'s, '_>, + object: v8::Local<'s, v8::Object>, + value: v8::Local<'s, v8::Value>, +) -> Option<()> { + let value = value.to_string(scope)?; + set_private_value(scope, object, VALUE, value.into()); + Some(()) +} + +fn method<'s>( + _scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s>, +) { + rv.set(args.this().into()); +} + +// Fields are declaration metadata; no runtime object carries this state. +#[allow(dead_code)] +#[derive(WebApiCrossOrigin)] +#[webapi(plain, receiver = receiver)] +struct Members { + #[webapi(method, length = 1, callback = method)] + operation: (), + #[webapi(accessor_property, getter = get, setter = set)] + value: (), + #[webapi(accessor_property, setter = set)] + write_only: (), +} + +fn eval<'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() +} + +#[test] +fn native_members_share_templates_but_keep_functions_in_the_accessing_context() { + ensure_v8(); + let mut isolate = v8::Isolate::new(Default::default()); + let scope = pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut scope.init(); + let a = v8::Context::new(scope, Default::default()); + let b = v8::Context::new(scope, Default::default()); + let member = Members::cross_origin_property("operation").unwrap(); + let function_a = { + let scope = &mut v8::ContextScope::new(scope, a); + let object = v8::Object::new(scope); + let value = v8str(scope, "initial"); + set_private_value(scope, object, VALUE, value.into()); + let function = member.get(scope, object).unwrap(); + assert!(function.strict_equals(member.get(scope, object).unwrap())); + let global = a.global(scope); + global + .set(scope, v8str(scope, "object").into(), object.into()) + .unwrap(); + for (name, value) in [ + ("method", function), + ("methodDescriptor", member.descriptor(scope).unwrap().into()), + ( + "descriptor", + Members::cross_origin_property("value") + .unwrap() + .descriptor(scope) + .unwrap() + .into(), + ), + ( + "writeOnly", + Members::cross_origin_property("writeOnly") + .unwrap() + .descriptor(scope) + .unwrap() + .into(), + ), + ] { + global.set(scope, v8str(scope, name).into(), value).unwrap(); + } + assert!(eval(scope, r#" + (() => { + function throwsTypeError(callback) { try { callback(); } catch (e) { return e instanceof TypeError; } return false; } + if (Object.getPrototypeOf(method) !== Function.prototype || method.name !== 'operation' || method.length !== 1 || Object.hasOwn(method, 'prototype')) return false; + if (method.call(object) !== object || !throwsTypeError(() => method.call({})) || !throwsTypeError(() => new method())) return false; + if (methodDescriptor.writable || methodDescriptor.enumerable || !methodDescriptor.configurable) return false; + if (descriptor.get.name !== 'get value' || descriptor.get.length !== 0 || descriptor.set.name !== 'set value' || descriptor.set.length !== 1 || descriptor.enumerable || !descriptor.configurable) return false; + if (descriptor.get.call(object) !== 'initial' || !throwsTypeError(() => descriptor.get.call({}))) return false; + let conversions = 0; + if (!throwsTypeError(() => descriptor.set.call({}, {toString() { conversions++; return 'bad'; }})) || conversions !== 0) return false; + const exception = new Error('conversion'); + try { descriptor.set.call(object, {toString() { throw exception; }}); return false; } catch (e) { if (e !== exception) return false; } + descriptor.set.call(object, 'updated'); + return descriptor.get.call(object) === 'updated' && writeOnly.get === undefined && writeOnly.set.name === 'set writeOnly'; + })() + "#).is_true()); + v8::Global::new(scope, function) + }; + let scope = &mut v8::ContextScope::new(scope, b); + let object = v8::Object::new(scope); + let function_b = member.get(scope, object).unwrap(); + let function_a = v8::Local::new(scope, &function_a); + assert!(!function_a.strict_equals(function_b)); + let function_b = v8::Local::::try_from(function_b).unwrap(); + assert!( + function_b + .get_prototype(scope) + .unwrap() + .strict_equals(eval(scope, "Function.prototype")) + ); +} + +#[test] +fn native_member_failure_propagates_script_termination() { + 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 member = Members::cross_origin_property("value").unwrap(); + let object = v8::Object::new(scope); + let value = v8str(scope, "initial"); + set_private_value(scope, object, VALUE, value.into()); + let input = v8::Object::new(scope); + { + v8::tc_scope!(let scope, scope); + scope.terminate_execution(); + assert!(member.set(scope, object, input.into()).is_none()); + assert!( + scope.has_terminated(), + "termination must reach the execution boundary" + ); + assert!(!scope.can_continue()); + scope.cancel_terminate_execution(); + } + assert!(member.set(scope, object, input.into()).is_some()); +} + +#[test] +fn isolate_template_cache_does_not_retain_an_accessing_context() { + ensure_v8(); + let mut isolate = v8::Isolate::new(Default::default()); + let weak = { + 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 object = v8::Object::new(scope); + assert!( + Members::cross_origin_property("operation") + .unwrap() + .get(scope, object) + .is_some() + ); + v8::Weak::new(scope, context) + }; + isolate.low_memory_notification(); + let scope = pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut scope.init(); + assert!(weak.to_local(scope).is_none()); +}