diff --git a/moli-renderer-v8/src/context_bootstrap/webrtc/configuration.rs b/moli-renderer-v8/src/context_bootstrap/webrtc/configuration.rs index f7bca4c656..ea23f1f9d7 100644 --- a/moli-renderer-v8/src/context_bootstrap/webrtc/configuration.rs +++ b/moli-renderer-v8/src/context_bootstrap/webrtc/configuration.rs @@ -6,10 +6,11 @@ use super::{ RTC_PEER_CONNECTION_SIGNALING_STATE_SLOT, }; use crate::{ - util::{get_private_value, new_null_prototype_object, set_private_value, v8str}, + util::{get_private_value, set_private_value, v8str}, web_api_interfaces, webidl, }; use moli_webapi_declare::{WebApiObject, WebApiValue}; +use webidl::{SnapshotKind, SnapshotPrototype, WebIdlDictionarySnapshot, WebIdlSnapshotValue}; #[derive(Clone, Copy, webidl::WebIdlEnum)] #[webidl(name = "RTCBundlePolicy")] @@ -67,6 +68,10 @@ impl<'s> WebApiValue<'s> for RtcpMuxPolicy { struct PoolSize(u8); +impl WebIdlSnapshotValue for PoolSize { + const SNAPSHOT_KIND: SnapshotKind = SnapshotKind::Identity; +} + impl<'s> webidl::WebIdlConverter<'s> for PoolSize { type Options = (); @@ -91,6 +96,10 @@ impl<'s> WebApiValue<'s> for PoolSize { struct Text(webidl::DomString16); +impl WebIdlSnapshotValue for Text { + const SNAPSHOT_KIND: SnapshotKind = SnapshotKind::Identity; +} + impl<'s> webidl::WebIdlConverter<'s> for Text { type Options = (); @@ -112,6 +121,11 @@ impl<'s> WebApiValue<'s> for Text { struct IceUrls(Vec); +impl WebIdlSnapshotValue for IceUrls { + // The union converter always binds its canonical value as a URL sequence. + const SNAPSHOT_KIND: SnapshotKind = SnapshotKind::Sequence(&SnapshotKind::Identity); +} + impl<'s> webidl::WebIdlConverter<'s> for IceUrls { type Options = (); @@ -142,41 +156,32 @@ impl<'s> WebApiValue<'s> for IceUrls { } #[derive(webidl::WebIdlDictionary, WebApiObject)] -#[webidl(prefix = "RTCIceServer")] -#[webapi(plain, enumerable)] +#[webidl(prefix = "RTCIceServer", snapshot)] +#[webapi(plain, data_properties, enumerable)] struct IceServer { #[webidl(converter = "raw")] - #[webapi(data_property)] credential: Option, #[webidl(required, converter = "raw")] - #[webapi(data_property)] urls: IceUrls, #[webidl(converter = "raw")] - #[webapi(data_property)] username: Option, } #[derive(webidl::WebIdlDictionary, WebApiObject)] -#[webidl(prefix = "RTCConfiguration")] -#[webapi(plain, enumerable)] +#[webidl(prefix = "RTCConfiguration", snapshot)] +#[webapi(plain, data_properties, enumerable)] struct Configuration<'s> { #[webidl(converter = "enum", default = BundlePolicy::Balanced)] - #[webapi(data_property)] bundle_policy: BundlePolicy, #[webidl(sequence, interface = web_api_interfaces::RTCCertificate, default = Vec::new())] - #[webapi(data_property)] certificates: Vec>, #[webidl(converter = "raw", default = PoolSize(0))] - #[webapi(data_property)] ice_candidate_pool_size: PoolSize, #[webidl(sequence, converter = "dictionary", default = Vec::new())] - #[webapi(data_property)] ice_servers: Vec, #[webidl(converter = "enum", default = IceTransportPolicy::All)] - #[webapi(data_property)] ice_transport_policy: IceTransportPolicy, #[webidl(converter = "enum", default = RtcpMuxPolicy::Require)] - #[webapi(data_property)] rtcp_mux_policy: RtcpMuxPolicy, } @@ -197,7 +202,10 @@ pub(super) fn constructor_configuration<'s>( .configuration .bind(scope) .expect("converted RTCConfiguration"); - Some(copy_configuration(scope, object, true)) + Some( + Configuration::to_js_snapshot(scope, object, SnapshotPrototype::Null) + .expect("converted RTCConfiguration snapshot"), + ) } fn target<'s>( @@ -224,7 +232,9 @@ pub(super) fn get_configuration<'s>( ) { let target = target(scope, args.this()); let stored = configuration(scope, target); - rv.set(copy_configuration(scope, stored, false).into()); + let snapshot = Configuration::to_js_snapshot(scope, stored, SnapshotPrototype::Ordinary) + .expect("native RTCConfiguration snapshot"); + rv.set(snapshot.into()); } pub(super) fn set_configuration<'s>( @@ -271,7 +281,8 @@ pub(super) fn set_configuration<'s>( if validate_servers(scope, &parsed.configuration).is_none() { return; } - let snapshot = copy_configuration(scope, next, true); + let snapshot = Configuration::to_js_snapshot(scope, next, SnapshotPrototype::Null) + .expect("converted RTCConfiguration snapshot"); set_private_value( scope, target, @@ -381,78 +392,3 @@ fn same_certificates<'s>( .strict_equals(next.get_index(scope, index).expect("certificate")) }) } - -fn copy_configuration<'s>( - scope: &mut v8::PinScope<'s, '_>, - source: v8::Local<'s, v8::Object>, - internal: bool, -) -> v8::Local<'s, v8::Object> { - let output = if internal { - new_null_prototype_object(scope) - } else { - v8::Object::new(scope) - }; - for name in [ - "bundlePolicy", - "certificates", - "iceCandidatePoolSize", - "iceServers", - "iceTransportPolicy", - "rtcpMuxPolicy", - ] { - let mut value = property(scope, source, name); - if matches!(name, "certificates" | "iceServers") { - let array = v8::Local::::try_from(value).expect("private sequence"); - let mut items = Vec::new(); - for index in 0..array.length() { - let mut item = array - .get_index(scope, index) - .expect("private sequence item"); - if name == "iceServers" { - let server = - v8::Local::::try_from(item).expect("private ICE server"); - item = copy_server(scope, server, internal).into(); - } - items.push(item); - } - value = v8::Array::new_with_elements(scope, &items).into(); - } - assert_eq!( - output.create_data_property(scope, v8str(scope, name).into(), value), - Some(true) - ); - } - output -} - -fn copy_server<'s>( - scope: &mut v8::PinScope<'s, '_>, - source: v8::Local<'s, v8::Object>, - internal: bool, -) -> v8::Local<'s, v8::Object> { - let output = if internal { - new_null_prototype_object(scope) - } else { - v8::Object::new(scope) - }; - for name in ["credential", "urls", "username"] { - let key = v8str(scope, name); - // Optional members are absent, not inherited from a polluted prototype. - if source.has_own_property(scope, key.into()) != Some(true) { - continue; - } - let mut value = property(scope, source, name); - if name == "urls" { - let array = v8::Local::::try_from(value).expect("private URL sequence"); - let items: Vec<_> = (0..array.length()) - .map(|index| array.get_index(scope, index).expect("private URL")) - .collect(); - value = v8::Array::new_with_elements(scope, &items).into(); - } - assert_eq!( - output.create_data_property(scope, key.into(), value), - Some(true) - ); - } - output -} diff --git a/moli-renderer-v8/src/script_vm/tests/rtc_configuration.rs b/moli-renderer-v8/src/script_vm/tests/rtc_configuration.rs index ae20df7cd8..fd89d1dc39 100644 --- a/moli-renderer-v8/src/script_vm/tests/rtc_configuration.rs +++ b/moli-renderer-v8/src/script_vm/tests/rtc_configuration.rs @@ -63,6 +63,51 @@ fn rtc_configuration_uses_registered_native_proxy_identity() { })()"#).unwrap(), "true"); } +#[test] +fn rtc_configuration_snapshots_preserve_certificate_identity_and_immutability() { + let mut vm = new_storage_page_task_executor_test_vm("https://rtc-configuration.test/"); + let context_ptr = &vm.page_default_runtime.context as *const _; + vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| { + let certificate = v8::Object::new(scope); + crate::web_api_interfaces::RTCCertificate::DESCRIPTOR + .initialize(scope, certificate) + .unwrap(); + let handler = crate::util::new_null_prototype_object(scope); + let proxy = v8::Proxy::new(scope, certificate, handler).unwrap(); + moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap(); + let global = scope.get_current_context().global(scope); + for (name, value) in [ + ("certificate", v8::Local::::from(certificate)), + ("nativeCertificate", proxy.into()), + ] { + assert_eq!( + global.create_data_property(scope, crate::util::v8str(scope, name).into(), value), + Some(true) + ); + } + Ok(()) + }) + .unwrap(); + assert_eq!(vm.eval(r#"(() => { + const input = [certificate, nativeCertificate]; + const pc = new RTCPeerConnection({certificates:input}); + input.length = 0; + const first = pc.getConfiguration(); + if (first.certificates.length !== 2 || first.certificates[0] !== certificate || first.certificates[1] !== nativeCertificate) return false; + first.certificates.reverse(); + pc.setConfiguration({certificates:[certificate, nativeCertificate], iceTransportPolicy:'relay'}); + const next = pc.getConfiguration(); + if (first.certificates === next.certificates || next.certificates[0] !== certificate || next.certificates[1] !== nativeCertificate || next.iceTransportPolicy !== 'relay') return false; + for (const certificates of [[], [certificate], [nativeCertificate, certificate]]) { + try {pc.setConfiguration({certificates}); return false;} + catch (error) {if (error.name !== 'InvalidModificationError') return false;} + } + const unchanged = pc.getConfiguration(); + pc.close(); + return unchanged.certificates[0] === certificate && unchanged.certificates[1] === nativeCertificate && unchanged.iceTransportPolicy === 'relay'; + })()"#).unwrap(), "true"); +} + #[test] fn rtc_configuration_applies_reentrant_updates_before_validating_the_outer_call() { let mut vm = new_storage_page_task_executor_test_vm("https://rtc-configuration.test/"); diff --git a/moli-webidl-derive/src/attrs.rs b/moli-webidl-derive/src/attrs.rs index 4be1bf1190..9a3961a2dd 100644 --- a/moli-webidl-derive/src/attrs.rs +++ b/moli-webidl-derive/src/attrs.rs @@ -7,12 +7,20 @@ pub(crate) struct ContainerAttrs { pub(crate) prefix: Option, pub(crate) scope_lifetime: Option, pub(crate) rename_all: Option, + pub(crate) snapshot: bool, } pub(crate) fn parse_container_attrs(attrs: &[syn::Attribute]) -> Result { let mut parsed = ContainerAttrs::default(); for attr in attrs.iter().filter(|attr| attr.path().is_ident("webidl")) { attr.parse_nested_meta(|meta| { + if meta.path.is_ident("snapshot") { + if parsed.snapshot { + return Err(meta.error("duplicate snapshot attribute")); + } + parsed.snapshot = true; + return Ok(()); + } if meta.path.is_ident("prefix") { parsed.prefix = Some(meta.value()?.parse()?); return Ok(()); diff --git a/moli-webidl-derive/src/converter.rs b/moli-webidl-derive/src/converter.rs index 05df405d79..20ba3a29d4 100644 --- a/moli-webidl-derive/src/converter.rs +++ b/moli-webidl-derive/src/converter.rs @@ -247,7 +247,7 @@ impl ConverterKind { /// A field conversion, optionally collecting an iterable of converted items. #[derive(Clone, Copy)] pub(crate) struct FieldConverter { - kind: ConverterKind, + pub(crate) kind: ConverterKind, sequence: bool, } diff --git a/moli-webidl-derive/src/expand.rs b/moli-webidl-derive/src/expand.rs index a1280c6f22..aeda11c46e 100644 --- a/moli-webidl-derive/src/expand.rs +++ b/moli-webidl-derive/src/expand.rs @@ -119,9 +119,17 @@ pub(crate) fn expand_webidl_dictionary( } } } - let bindings = inherited + let ordered_fields = inherited .into_iter() .chain(members.into_values()) + .collect::>(); + let snapshot = if attrs.snapshot { + crate::snapshot::expand_snapshot(&struct_name, &generics, &ordered_fields, rename_all)? + } else { + quote! {} + }; + let bindings = ordered_fields + .into_iter() .map(|field| expand_dictionary_field(field, &prefix, rename_all, &scope_lifetime)) .collect::, _>>()?; let idents = fields @@ -130,6 +138,8 @@ pub(crate) fn expand_webidl_dictionary( .collect::, _>>()?; Ok(quote! { + #snapshot + impl #impl_generics ::moli_webidl::WebIdlDictionary<#scope_lifetime> for #struct_name #ty_generics #where_clause { fn parse_dictionary( scope: &mut v8::PinScope<#scope_lifetime, '_>, @@ -668,6 +678,25 @@ mod tests { use super::{expand_webidl_args, expand_webidl_dictionary, impl_parts_for_scope}; use syn::parse_quote; + #[test] + fn snapshots_reject_unsupported_ownership_and_member_policies() { + for input in [ + parse_quote! { #[webidl(snapshot)] struct Options { #[webidl(inherit)] base: Base } }, + parse_quote! { #[webidl(snapshot)] struct Options { #[webidl(nullable)] value: Option } }, + parse_quote! { #[webidl(snapshot)] struct Options { #[webidl(with = parse)] value: Custom } }, + parse_quote! { #[webidl(snapshot)] struct Options { value: BufferSource } }, + parse_quote! { #[webidl(snapshot)] struct Options { value: WebIdlCallbackFunction } }, + parse_quote! { #[webidl(snapshot)] struct Options { value: WebIdlCallbackInterface } }, + ] { + assert!( + expand_webidl_dictionary(input) + .unwrap_err() + .to_string() + .starts_with("dictionary snapshots") + ); + } + } + #[test] fn dictionary_member_names_must_be_unique_after_renaming() { for input in [ diff --git a/moli-webidl-derive/src/lib.rs b/moli-webidl-derive/src/lib.rs index 8f82373567..ee6e74ccb1 100644 --- a/moli-webidl-derive/src/lib.rs +++ b/moli-webidl-derive/src/lib.rs @@ -1,6 +1,7 @@ mod attrs; mod converter; mod expand; +mod snapshot; use proc_macro::TokenStream; use syn::{DeriveInput, parse_macro_input}; @@ -48,6 +49,10 @@ pub fn derive_webidl_args(input: TokenStream) -> TokenStream { /// Nested members support `dictionary`: null parses an empty dictionary while /// `Option` skips missing/undefined and `nullable` also skips null. /// Iterable members support `sequence` with `Vec` or `Option>`. +/// `#[webidl(snapshot)]` additionally derives `WebIdlDictionarySnapshot` for +/// copying already-converted, native-owned dictionary objects. Raw converters +/// must supply a `WebIdlSnapshotValue` policy. This opt-in pilot does not support +/// inheritance, nullable members, custom member parsers, buffers, or callbacks. #[proc_macro_derive(WebIdlDictionary, attributes(webidl))] pub fn derive_webidl_dictionary(input: TokenStream) -> TokenStream { match expand::expand_webidl_dictionary(parse_macro_input!(input as DeriveInput)) { diff --git a/moli-webidl-derive/src/snapshot.rs b/moli-webidl-derive/src/snapshot.rs new file mode 100644 index 0000000000..c8f81fe406 --- /dev/null +++ b/moli-webidl-derive/src/snapshot.rs @@ -0,0 +1,87 @@ +use quote::quote; +use syn::spanned::Spanned; +use syn::{Error, Field, Generics, Ident}; + +use crate::attrs::{RenameRule, field_member_name, parse_field_attrs}; +use crate::converter::{ + ConverterKind, converter_kind, inner_type_for_field, is_option_type, vec_inner_type, +}; + +pub(crate) fn expand_snapshot( + name: &Ident, + generics: &Generics, + fields: &[&Field], + rename_all: RenameRule, +) -> Result { + let mut members = Vec::with_capacity(fields.len()); + for field in fields { + let attrs = parse_field_attrs(field)?; + if attrs.inherit || attrs.nullable || attrs.with.is_some() { + return Err(Error::new( + field.span(), + "dictionary snapshots do not yet support inherit, nullable, or with", + )); + } + let converter = converter_kind(field, &attrs)?; + let mut ty = inner_type_for_field(field); + if attrs.sequence { + ty = vec_inner_type(ty).expect("validated sequence field"); + } + let kind = match converter.kind { + ConverterKind::Dictionary => quote! { + ::moli_webidl::SnapshotKind::Dictionary(<#ty as ::moli_webidl::WebIdlDictionarySnapshot>::SNAPSHOT_MEMBERS) + }, + ConverterKind::Raw => quote! { + <#ty as ::moli_webidl::WebIdlSnapshotValue>::SNAPSHOT_KIND + }, + ConverterKind::BufferSource + | ConverterKind::CallbackFunction + | ConverterKind::CallbackInterface => { + return Err(Error::new( + field.span(), + "dictionary snapshots require an explicit raw policy for buffers and callbacks", + )); + } + ConverterKind::DomString + | ConverterKind::DomString16 + | ConverterKind::UsvString + | ConverterKind::ByteString + | ConverterKind::Octet + | ConverterKind::Long + | ConverterKind::LongLong + | ConverterKind::EnforceRangeLong + | ConverterKind::Short + | ConverterKind::UnsignedShort + | ConverterKind::EnforceRangeUnsignedShort + | ConverterKind::ClampedUnsignedShort + | ConverterKind::UnsignedLong + | ConverterKind::EnforceRangeUnsignedLong + | ConverterKind::EnforceRangeLongLong + | ConverterKind::UnsignedLongLong + | ConverterKind::EnforceRangeUnsignedLongLong + | ConverterKind::Float + | ConverterKind::UnrestrictedFloat + | ConverterKind::Double + | ConverterKind::UnrestrictedDouble + | ConverterKind::Boolean + | ConverterKind::Enum + | ConverterKind::Interface => quote!(::moli_webidl::SnapshotKind::Identity), + }; + let kind = if attrs.sequence { + quote!(::moli_webidl::SnapshotKind::Sequence(&#kind)) + } else { + kind + }; + let member_name = field_member_name(field, &attrs, rename_all)?; + let optional = is_option_type(&field.ty); + members.push(quote! { + ::moli_webidl::SnapshotMember { name: #member_name, kind: #kind, optional: #optional } + }); + } + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + Ok(quote! { + impl #impl_generics ::moli_webidl::WebIdlDictionarySnapshot for #name #ty_generics #where_clause { + const SNAPSHOT_MEMBERS: &'static [::moli_webidl::SnapshotMember] = &[#(#members),*]; + } + }) +} diff --git a/moli-webidl/src/lib.rs b/moli-webidl/src/lib.rs index 3d70fd7bcd..dcfd8191f7 100644 --- a/moli-webidl/src/lib.rs +++ b/moli-webidl/src/lib.rs @@ -27,8 +27,10 @@ //! `#[derive(WebIdlArgs)]` generates positional argument parsing for a V8 //! native binding's `FunctionCallbackArguments`. `#[derive(WebIdlDictionary)]` //! generates named property parsing for dictionary objects. Both derives use -//! the converter wrappers in `types` and only produce Rust values; object shape -//! declaration and Web API wrapper allocation remain outside this crate. +//! the converter wrappers in `types` and produce Rust values. The opt-in +//! `#[webidl(snapshot)]` also generates copy metadata for already-bound native +//! dictionary snapshots; object shape declaration and Web API wrapper allocation +//! remain outside this crate. //! Dictionary members are converted in lexicographical order of their final //! JavaScript names. `#[webidl(inherit)]` on one dictionary field converts the //! ancestor's members from the same object first; it does not read a nested @@ -66,6 +68,7 @@ mod convert; pub use buffer_source::{AllowSharedBufferSource, NonSharedBufferSource}; mod error; mod helpers; +mod snapshot; mod traits; mod types; @@ -91,6 +94,9 @@ pub use moli_webidl_callback::{ WebIdlCallbackInterface, }; pub use moli_webidl_derive::{WebIdlArgs, WebIdlDictionary, WebIdlEnum}; +pub use snapshot::{ + SnapshotKind, SnapshotMember, SnapshotPrototype, WebIdlDictionarySnapshot, WebIdlSnapshotValue, +}; pub use traits::{ParseOutcome, WebIdlArguments, WebIdlConverter, WebIdlDictionary, WebIdlEnum}; pub use types::{ Boolean, BufferSource, ByteString, ClampedUnsignedShort, Dictionary, DomString, DomString16, diff --git a/moli-webidl/src/snapshot.rs b/moli-webidl/src/snapshot.rs new file mode 100644 index 0000000000..621ecff229 --- /dev/null +++ b/moli-webidl/src/snapshot.rs @@ -0,0 +1,129 @@ +//! Snapshot copies of already-converted, native-owned dictionaries. +//! +//! This pilot deliberately keeps ownership in V8 private slots. Dictionary and +//! sequence containers are copied, while interface leaves keep their identity +//! and remain traced by V8. It does not turn scoped handles into Rust-owned +//! state: doing that needs an explicit rooting/teardown policy and cannot be +//! inferred from a raw converter or an interface's WebIDL name. +//! +//! RTCConfiguration therefore stays attached to its receiver, including when a +//! method from another Realm is borrowed. A CommonRuntimeState map is useful +//! for native payloads such as codecs, but a map holding Global leaves +//! would root certificate -> connection cycles. Moving these dictionaries there +//! would also require receiver-owner lookup, weak/finalizer cleanup, and isolate +//! teardown ordering. Native certificate handles need a real native payload and +//! wrapper identity policy first; this pilot adds neither a store nor globals. +//! +//! `to_js_snapshot` copies canonical binding output, not arbitrary JS input or +//! Rust values. Binding remains the declaration layer's job. An `into_native` +//! derive is deferred until field ownership policies (including custom raw +//! converters) can be expressed without guessing or generating a parallel Rust +//! type/lifetime graph. Only RTCConfiguration and its nested ICE server opt in. + +use moli_v8_util::{new_null_prototype_object, v8str}; + +/// Prototype policy for every dictionary in a snapshot, including nested ones. +#[derive(Clone, Copy)] +pub enum SnapshotPrototype { + /// Internal records must not consult author-modifiable Object.prototype. + Null, + /// Public snapshots use the current (callee) Realm's Object.prototype. + Ordinary, +} + +/// Copy policy for the canonical JS representation of a converted member. +#[derive(Clone, Copy)] +pub enum SnapshotKind { + /// Immutable scalar values or interface objects whose identity is retained. + Identity, + Sequence(&'static SnapshotKind), + Dictionary(&'static [SnapshotMember]), +} + +/// A member generated from the same metadata as dictionary argument conversion. +pub struct SnapshotMember { + pub name: &'static str, + pub kind: SnapshotKind, + pub optional: bool, +} + +/// Explicit snapshot policy for custom raw converters. +/// +/// Raw conversion can produce a scalar, an interface, or a canonical container; +/// its representation cannot safely be inferred by the dictionary derive. +pub trait WebIdlSnapshotValue { + const SNAPSHOT_KIND: SnapshotKind; +} + +/// Opt-in dictionary copy metadata, generated by `#[webidl(snapshot)]`. +/// +/// Sources must be fresh native binding output or private snapshots, with own +/// data properties and dense arrays. This is not WebIDL input conversion and +/// must never be applied directly to an author-supplied dictionary. It ignores +/// absent optional members and unknown properties, without consulting iterators +/// or prototypes. Missing non-optional members fail instead of silently losing +/// a field when a binding declaration is incomplete. +pub trait WebIdlDictionarySnapshot { + const SNAPSHOT_MEMBERS: &'static [SnapshotMember]; + + fn to_js_snapshot<'s>( + scope: &mut v8::PinScope<'s, '_>, + source: v8::Local<'s, v8::Object>, + prototype: SnapshotPrototype, + ) -> Option> { + copy_dictionary(scope, source, Self::SNAPSHOT_MEMBERS, prototype) + } +} + +fn copy_dictionary<'s>( + scope: &mut v8::PinScope<'s, '_>, + source: v8::Local<'s, v8::Object>, + members: &'static [SnapshotMember], + prototype: SnapshotPrototype, +) -> Option> { + let output = match prototype { + SnapshotPrototype::Null => new_null_prototype_object(scope), + SnapshotPrototype::Ordinary => v8::Object::new(scope), + }; + for member in members { + let key = v8str(scope, member.name); + if !source.has_own_property(scope, key.into())? { + if member.optional { + continue; + } + return None; + } + let value = source.get(scope, key.into())?; + let value = member.kind.copy(scope, value, prototype)?; + if !output.create_data_property(scope, key.into(), value)? { + return None; + } + } + Some(output) +} + +impl SnapshotKind { + fn copy<'s>( + &self, + scope: &mut v8::PinScope<'s, '_>, + source: v8::Local<'s, v8::Value>, + prototype: SnapshotPrototype, + ) -> Option> { + match self { + Self::Identity => Some(source), + Self::Sequence(item_kind) => { + let source = v8::Local::::try_from(source).ok()?; + let mut items = Vec::with_capacity(source.length() as usize); + for index in 0..source.length() { + let item = source.get_index(scope, index)?; + items.push(item_kind.copy(scope, item, prototype)?); + } + Some(v8::Array::new_with_elements(scope, &items).into()) + } + Self::Dictionary(members) => { + let source = v8::Local::::try_from(source).ok()?; + copy_dictionary(scope, source, members, prototype).map(Into::into) + } + } + } +} diff --git a/moli-webidl/tests/dictionary_snapshots.rs b/moli-webidl/tests/dictionary_snapshots.rs new file mode 100644 index 0000000000..026d251a08 --- /dev/null +++ b/moli-webidl/tests/dictionary_snapshots.rs @@ -0,0 +1,188 @@ +use moli_webapi_declare::WebApiObject; +use moli_webidl::{SnapshotPrototype, WebIdlDictionary, WebIdlDictionarySnapshot}; + +mod interfaces { + moli_webapi_declare::declare_web_api_interfaces! { + pub Certificate; + } +} + +#[derive(WebIdlDictionary, WebApiObject)] +#[webidl(snapshot)] +#[webapi(plain, data_properties, enumerable)] +struct Server { + #[webidl(sequence, required)] + urls: Vec, + username: Option, +} + +#[derive(WebIdlDictionary, WebApiObject)] +#[webidl(snapshot)] +#[webapi(plain, data_properties, enumerable)] +struct Options<'s> { + #[webidl(default = 7, name = "renamedNumber")] + #[webapi(data_property = "renamedNumber")] + number: u32, + #[webidl(sequence, converter = "dictionary", default = Vec::new())] + servers: Vec, + #[webidl(sequence, interface = interfaces::Certificate, default = Vec::new())] + certificates: Vec>, + // Adding a field needs no separate snapshot list or copy implementation. + #[webidl(default = "new member")] + added_later: String, +} + +fn snapshot<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s>, +) { + let stored = v8::Local::::try_from(args.data()).unwrap(); + let snapshot = Options::to_js_snapshot(scope, stored, SnapshotPrototype::Ordinary).unwrap(); + rv.set(snapshot.into()); +} + +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 snapshots_follow_dictionary_metadata_and_copy_only_containers() { + moli_v8_test_util::ensure_v8(); + let mut isolate = v8::Isolate::new(Default::default()); + let scope = std::pin::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); + let certificate = v8::Object::new(scope); + interfaces::Certificate::DESCRIPTOR + .initialize(scope, certificate) + .unwrap(); + let key = v8::String::new(scope, "certificate").unwrap(); + assert_eq!( + global.create_data_property(scope, key.into(), certificate.into()), + Some(true) + ); + let source = eval( + scope, + "({servers:[{urls:['stun:host.example']}], certificates:[certificate], unknown:123})", + ); + let parsed = + moli_webidl::parse_dictionary_object::(scope, source.try_into().unwrap()).unwrap(); + let bound = parsed.bind(scope).unwrap(); + let stored = Options::to_js_snapshot(scope, bound, SnapshotPrototype::Null).unwrap(); + let key = v8::String::new(scope, "stored").unwrap(); + assert_eq!( + global.create_data_property(scope, key.into(), stored.into()), + Some(true) + ); + let snapshot = v8::Function::builder(snapshot) + .data(stored.into()) + .build(scope) + .unwrap(); + let key = v8::String::new(scope, "snapshot").unwrap(); + assert_eq!( + global.create_data_property(scope, key.into(), snapshot.into()), + Some(true) + ); + assert!(eval(scope, r#"(() => { + const assert = (ok, message) => { if (!ok) throw Error(message); }; + assert(Object.getPrototypeOf(stored) === null, 'private root'); + assert(Object.getPrototypeOf(stored.servers[0]) === null, 'private nested dictionary'); + const first = snapshot(); + assert(Object.keys(first).join() === 'addedLater,certificates,renamedNumber,servers', 'renamed and newly added members'); + assert(first.addedLater === 'new member' && first.renamedNumber === 7, 'member defaults'); + assert(Object.getPrototypeOf(first) === Object.prototype, 'public root'); + assert(Object.getPrototypeOf(first.servers[0]) === Object.prototype, 'public nested dictionary'); + assert(first.servers instanceof Array && first.servers[0].urls instanceof Array, 'public arrays'); + assert(first.certificates !== stored.certificates && first.certificates[0] === certificate, 'interface identity'); + first.servers[0].urls[0] = 'stun:changed.example'; + first.servers[0].username = 'changed'; + first.certificates.length = 0; + first.addedLater = 'changed'; + Object.defineProperty(Object.prototype, 'username', {configurable:true, get(){throw Error('inherited member');}}); + Object.defineProperty(Object.prototype, 'servers', {configurable:true, set(){throw Error('inherited setter');}}); + const iterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const index = Object.getOwnPropertyDescriptor(Array.prototype, '0'); + Object.defineProperty(Array.prototype, Symbol.iterator, {configurable:true, value(){throw Error('iterator');}}); + Object.defineProperty(Array.prototype, '0', {configurable:true, set(){throw Error('indexed setter');}}); + try { + const next = snapshot(); + assert(next.servers[0].urls[0] === 'stun:host.example', 'deep copy'); + assert(!Object.hasOwn(next.servers[0], 'username'), 'optional member stays absent'); + assert(next.certificates.length === 1 && next.certificates[0] === certificate, 'interface array retained'); + assert(next.addedLater === 'new member', 'scalar copy'); + const descriptor = Object.getOwnPropertyDescriptor(next, 'servers'); + assert(descriptor.writable && descriptor.enumerable && descriptor.configurable, 'data property descriptors'); + } finally { + delete Object.prototype.username; + delete Object.prototype.servers; + Object.defineProperty(Array.prototype, Symbol.iterator, iterator); + if (index) Object.defineProperty(Array.prototype, '0', index); else delete Array.prototype[0]; + } + return true; + })()"#).is_true()); + + // An incomplete binding is an invariant violation, not a partial snapshot. + let key = v8::String::new(scope, "addedLater").unwrap(); + assert_eq!(stored.delete(scope, key.into()), Some(true)); + assert!(Options::to_js_snapshot(scope, stored, SnapshotPrototype::Ordinary).is_none()); +} + +#[test] +fn private_snapshots_trace_interfaces_without_rooting_unreachable_cycles() { + moli_v8_test_util::ensure_v8(); + let mut isolate = v8::Isolate::new(Default::default()); + let (owner, weak_owner, weak_certificate) = { + let scope = std::pin::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 certificate = v8::Object::new(scope); + interfaces::Certificate::DESCRIPTOR + .initialize(scope, certificate) + .unwrap(); + let bound = Options { + number: 7, + servers: Vec::new(), + certificates: vec![certificate], + added_later: "new member".into(), + } + .bind(scope) + .unwrap(); + let stored = Options::to_js_snapshot(scope, bound, SnapshotPrototype::Null).unwrap(); + let owner = v8::Object::new(scope); + let slot = v8::Private::new(scope, None); + assert_eq!(owner.set_private(scope, slot, stored.into()), Some(true)); + let key = v8::String::new(scope, "owner").unwrap(); + assert_eq!( + certificate.create_data_property(scope, key.into(), owner.into()), + Some(true) + ); + ( + v8::Global::new(scope, owner), + v8::Weak::new(scope, owner), + v8::Weak::new(scope, certificate), + ) + }; + isolate.low_memory_notification(); + assert!( + !weak_certificate.is_empty(), + "the live owner traces its certificate" + ); + drop(owner); + isolate.low_memory_notification(); + assert!( + weak_owner.is_empty(), + "the owner/certificate cycle is collectible" + ); + assert!( + weak_certificate.is_empty(), + "no native global roots the certificate" + ); +}