diff --git a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs index 1ef379f8ae..e5d4cd7610 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs @@ -16,6 +16,7 @@ enum RealmDependentFinalizer { GlobalEventHandlersSecureContextSurface, CryptoSecureContextSurface, BaseAudioContextSecureContextSurface, + NavigatorSecureContextSurface, XmlHttpRequestEventTargetState, NotificationPermission, PointerEventSecureContextSurface, @@ -46,6 +47,10 @@ const REALM_DEPENDENT_FINALIZER_ALLOWLIST: &[(&str, RealmDependentFinalizer)] = "BaseAudioContext", RealmDependentFinalizer::BaseAudioContextSecureContextSurface, ), + ( + "Navigator", + RealmDependentFinalizer::NavigatorSecureContextSurface, + ), ( "Crypto", RealmDependentFinalizer::CryptoSecureContextSurface, @@ -123,7 +128,12 @@ pub(super) fn finalize_materialized_interface<'s>( RealmDependentFinalizer::GlobalEventHandlersSecureContextSurface => { crate::context_bootstrap::window_events::finalize_secure_global_event_handler_realm_bindings( scope, prototype, - ) + )?; + } + RealmDependentFinalizer::NavigatorSecureContextSurface => { + crate::context_bootstrap::navigator_runtime::finalize_navigator_realm_bindings( + scope, prototype, + )?; } RealmDependentFinalizer::CryptoSecureContextSurface => { finalize_crypto_realm_bindings(scope, prototype) @@ -193,6 +203,7 @@ mod tests { "DocumentType", "CharacterData", "BaseAudioContext", + "Navigator", "Crypto", "XMLHttpRequestEventTarget", "Notification", diff --git a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs index 307152dab8..2db5db694e 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs @@ -201,8 +201,8 @@ const SECURE_CONTEXT_ONLY_INTERFACE_NAMES: &[&str] = &[ "MediaDeviceInfo", "InputDeviceInfo", "MediaDevices", - "MediaDeviceInfo", - "InputDeviceInfo", + "WakeLock", + "WakeLockSentinel", "SubtleCrypto", "CryptoKey", "PaymentResponse", diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs index f1632c9bba..fc69dc15b4 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs @@ -10,12 +10,14 @@ mod navigator_subobjects; mod screen; mod user_activation; mod visual_viewport; +mod wake_lock; mod window_state; pub(crate) use geolocation::notify_geolocation_override_changed; pub(in crate::context_bootstrap) use self::clipboard::clipboard_item_constructor_callback; pub(crate) use self::navigator::build_lightweight_popup_window_navigator_object; +pub(super) use self::navigator::finalize_navigator_realm_bindings; pub(super) use self::navigator::install_navigator_template_bindings; pub(crate) use self::navigator::install_worker_navigator_runtime_state; pub(in crate::context_bootstrap) use self::navigator::{ diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs index 6d5fa34908..5b0a19cc41 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs @@ -75,6 +75,7 @@ const NAVIGATOR_RUNTIME_DATA_KEYS: &[&str] = &[ "storageBuckets", "geolocation", "mediaCapabilities", + "wakeLock", ]; const WORKER_NAVIGATOR_INSTALLED_SLOT: &str = "__moliWorkerNavigatorInstalled"; const WORKER_NAVIGATOR_MATERIALIZING_SLOT: &str = "__moliWorkerNavigatorMaterializing"; @@ -194,6 +195,8 @@ struct NavigatorRuntimeDataPrototypeDeclaration { geolocation: (), #[webapi(accessor_property, getter = navigator_runtime_data_getter_callback, data = callback_data_index_value(scope, 32))] media_capabilities: (), + #[webapi(accessor_property, getter = navigator_runtime_data_getter_callback, data = callback_data_index_value(scope, 33))] + wake_lock: (), #[webapi(accessor_property, getter = navigator_cookie_enabled_getter_callback)] cookie_enabled: (), } @@ -585,6 +588,9 @@ struct WindowNavigatorBackingDeclaration<'scope, 'profile> { #[webapi(data_property, enumerable)] media_capabilities: v8::Local<'scope, v8::Value>, + + #[webapi(data_property, enumerable)] + wake_lock: v8::Local<'scope, v8::Value>, } #[derive(WebApiObject)] @@ -877,6 +883,7 @@ pub(in crate::context_bootstrap) fn install_navigator_template_bindings<'s>( super::gamepad::install(scope, template, interface_name); install_media_capabilities_template_bindings(scope, template, interface_name); super::credentials::install(scope, template, interface_name); + super::wake_lock::install(scope, template, interface_name); super::user_activation::install_user_activation_template_bindings( scope, template, @@ -918,14 +925,17 @@ pub(in crate::context_bootstrap) fn install_navigator_template_bindings<'s>( } } -fn filter_navigator_secure_context_exposure<'s>( +pub(in crate::context_bootstrap) fn finalize_navigator_realm_bindings<'s>( scope: &mut v8::PinScope<'s, '_>, prototype: v8::Local<'s, v8::Object>, - secure_context: bool, ) -> Result<()> { + let window = scope.get_current_context().global(scope); + let secure_context = + super::super::runtime_state::window_realm_secure_context_available(scope, window); if !secure_context { delete_object_property(scope, prototype, "clipboard")?; delete_object_property(scope, prototype, "mediaDevices")?; + delete_object_property(scope, prototype, "wakeLock")?; delete_object_property(scope, prototype, "storage")?; delete_object_property(scope, prototype, "storageBuckets")?; delete_object_property(scope, prototype, "serviceWorker")?; @@ -1088,6 +1098,7 @@ pub(super) fn build_lazy_navigator_subobject_in_current_realm<'s>( || navigator_geolocation_secure_context_available(scope, owner_child, owner_popup); build_media_capabilities_object(scope, secure_context, worker)?.into() } + NavigatorSubobject::WakeLock => super::wake_lock::build(scope)?.into(), }; Ok(value) } @@ -1261,6 +1272,7 @@ fn build_window_navigator_backing_for_owner<'s>( storage_buckets: v8::undefined(scope).into(), geolocation: v8::undefined(scope).into(), media_capabilities: v8::undefined(scope).into(), + wake_lock: v8::undefined(scope).into(), } .bind(scope) .map_err(|error| anyhow!("failed to bind Navigator backing object: {error}")) @@ -1289,15 +1301,19 @@ pub(super) fn build_window_navigator_object_for_owner<'s>( scope: &mut v8::PinScope<'s, '_>, owner_child: Option, identity: Option<&BrowserIdentityProfile>, - storage_apis_available: bool, ) -> Result> { let backing = build_window_navigator_backing_for_owner(scope, owner_child, None, identity)?; + bind_window_navigator(scope, backing) +} + +fn bind_window_navigator<'s>( + scope: &mut v8::PinScope<'s, '_>, + backing: v8::Local<'s, v8::Object>, +) -> Result> { + let _ = ensure_intrinsic_interface_prototype(scope, "Navigator")?; let navigator = NavigatorObjectDeclaration::new(backing) .bind(scope) .map_err(|error| anyhow!("failed to bind Navigator object: {error}"))?; - if let Some(prototype) = global_constructor_prototype(scope, "Navigator") { - filter_navigator_secure_context_exposure(scope, prototype, storage_apis_available)?; - } Ok(navigator) } @@ -1353,9 +1369,7 @@ pub(crate) fn build_lightweight_popup_window_navigator_object<'s>( owner_popup: u64, ) -> Result> { let backing = build_window_navigator_backing_for_owner(scope, None, Some(owner_popup), None)?; - NavigatorObjectDeclaration::new(backing) - .bind(scope) - .map_err(|error| anyhow!("failed to bind lightweight popup Navigator object: {error}")) + bind_window_navigator(scope, backing) } fn build_worker_navigator_backing<'s>( diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs index 76e3def923..6ca6812aaa 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs @@ -22,11 +22,12 @@ pub(super) enum NavigatorSubobject { StorageBuckets, Geolocation, MediaCapabilities, + WakeLock, } impl NavigatorSubobject { #[cfg(test)] - pub(super) const ALL: [Self; 16] = [ + pub(super) const ALL: [Self; 17] = [ Self::Languages, Self::MimeTypes, Self::Plugins, @@ -43,6 +44,7 @@ impl NavigatorSubobject { Self::StorageBuckets, Self::Geolocation, Self::MediaCapabilities, + Self::WakeLock, ]; pub(super) fn from_key(key: &str) -> Option { @@ -63,6 +65,7 @@ impl NavigatorSubobject { "storageBuckets" => Some(Self::StorageBuckets), "geolocation" => Some(Self::Geolocation), "mediaCapabilities" => Some(Self::MediaCapabilities), + "wakeLock" => Some(Self::WakeLock), _ => None, } } @@ -85,6 +88,7 @@ impl NavigatorSubobject { Self::StorageBuckets => "storageBuckets", Self::Geolocation => "geolocation", Self::MediaCapabilities => "mediaCapabilities", + Self::WakeLock => "wakeLock", } } } diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/wake_lock.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/wake_lock.rs new file mode 100644 index 0000000000..4a86df06ab --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/wake_lock.rs @@ -0,0 +1,108 @@ +//! Secure Window wake-lock bindings for a UA without a platform wake-lock backend. +//! +//! The specification permits implementation-specific denial. Convert arguments +//! before rejecting valid requests; never manufacture an acquired sentinel. + +use anyhow::Result; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; + +use crate::{native_bridge::throw_dom_exception, web_api_interfaces, webidl}; + +#[derive(WebApiObject)] +#[webapi(interface = web_api_interfaces::WakeLock)] +struct WakeLockObjectDeclaration<'s> { + #[webapi(prototype)] + prototype: v8::Local<'s, v8::Object>, +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::WakeLock, enumerable, receiver)] +struct WakeLockPrototypeDeclaration { + #[webapi(method, returns_promise, length = 0, callback = request)] + request: (), +} + +// request() cannot return a sentinel until platform acquisition is implemented. +// Keep the native interface surface and receiver validation without inventing +// acquired/released state or dispatching a synthetic release event. +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::WakeLockSentinel, enumerable, receiver)] +struct WakeLockSentinelPrototypeDeclaration { + #[webapi(accessor_property, getter = sentinel_backend_unavailable)] + released: (), + #[webapi(accessor_property = "type", getter = sentinel_backend_unavailable)] + lock_type: (), + #[webapi(method, returns_promise, length = 0, callback = sentinel_backend_unavailable)] + release: (), + #[webapi(accessor_property, getter = sentinel_backend_unavailable, setter = sentinel_backend_unavailable)] + onrelease: (), +} + +#[derive(Clone, Copy, webidl::WebIdlEnum)] +#[webidl(name = "WakeLockType")] +enum WakeLockType { + Screen, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "WakeLock.request")] +struct RequestArgs { + #[webidl(converter = "enum", default = WakeLockType::Screen)] + lock_type: WakeLockType, +} + +pub(super) fn install<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, + interface_name: &str, +) { + let prototype = template.prototype_template(scope); + match interface_name { + "WakeLock" => { + WakeLockPrototypeDeclaration::initialize_prototype_template(scope, prototype); + } + "WakeLockSentinel" => { + WakeLockSentinelPrototypeDeclaration::initialize_prototype_template(scope, prototype); + } + _ => {} + } +} + +pub(super) fn build<'s>(scope: &mut v8::PinScope<'s, '_>) -> Result> { + let prototype = + crate::context_bootstrap::ensure_intrinsic_interface_prototype(scope, "WakeLock")?; + WakeLockObjectDeclaration::new(prototype) + .bind(scope) + .map_err(Into::into) +} + +fn request<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s>, +) { + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + match parsed.lock_type { + WakeLockType::Screen => throw_dom_exception( + scope, + "NotAllowedError", + 0, + "Screen wake locks are unavailable on this platform.", + ), + } +} + +fn sentinel_backend_unavailable<'s>( + scope: &mut v8::PinScope<'s, '_>, + _args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s>, +) { + throw_dom_exception( + scope, + "NotSupportedError", + 9, + "Platform wake-lock sentinels are not implemented.", + ); +} diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/window_state.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/window_state.rs index cfa37d3804..684ec02af0 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/window_state.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/window_state.rs @@ -12,8 +12,6 @@ use crate::{ use anyhow::{Result, anyhow}; use moli_browser_profile::BrowserIdentityProfile; -const WINDOW_NAVIGATOR_STORAGE_APIS_AVAILABLE_SLOT: &str = - "__moliWindowNavigatorStorageApisAvailable"; const WINDOW_NAVIGATOR_USER_AGENT_SLOT: &str = "__moliWindowNavigatorUserAgent"; const WINDOW_NAVIGATOR_ACCEPT_LANGUAGE_SLOT: &str = "__moliWindowNavigatorAcceptLanguage"; @@ -32,18 +30,7 @@ pub(in crate::context_bootstrap) fn build_window_navigator_for_receiver<'s>( let identity = navigator_identity_profile(scope, receiver).or_else(|| { user_agent.map(|user_agent| BrowserIdentityProfile::new(user_agent, accept_language)) }); - let storage_apis_available = get_private_value( - scope, - receiver, - WINDOW_NAVIGATOR_STORAGE_APIS_AVAILABLE_SLOT, - ) - .is_none_or(|value| value.boolean_value(scope)); - build_window_navigator_object_for_owner( - scope, - owner_child, - identity.as_ref(), - storage_apis_available, - ) + build_window_navigator_object_for_owner(scope, owner_child, identity.as_ref()) } pub(in crate::context_bootstrap) fn install_navigator_runtime_state<'s>( @@ -52,13 +39,6 @@ pub(in crate::context_bootstrap) fn install_navigator_runtime_state<'s>( storage_apis_available: bool, ) -> Result<()> { super::user_activation::initialize_window_user_activation(scope, global)?; - let storage_apis_available_value = v8::Boolean::new(scope, storage_apis_available); - set_private_value( - scope, - global, - WINDOW_NAVIGATOR_STORAGE_APIS_AVAILABLE_SLOT, - storage_apis_available_value.into(), - ); if storage_apis_available { GlobalCachesAccessorDeclaration::new(true) .initialize(scope, global) diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index 701ddedab6..c0f8d53e9b 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -856,6 +856,14 @@ const CONSTRUCTOR_SPECS_BEFORE_STREAMS: &[ConstructorSpec] = &[ interface: web_api_interfaces::MediaDevices::DESCRIPTOR, kind: ConstructorKind::Illegal, }, + ConstructorSpec { + interface: web_api_interfaces::WakeLock::DESCRIPTOR, + kind: ConstructorKind::Illegal, + }, + ConstructorSpec { + interface: web_api_interfaces::WakeLockSentinel::DESCRIPTOR, + kind: ConstructorKind::Illegal, + }, ConstructorSpec { interface: web_api_interfaces::Clipboard::DESCRIPTOR, kind: ConstructorKind::Illegal, diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index fa1a7a52aa..9355555df3 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2320,6 +2320,7 @@ mod time_ranges; mod url_components; mod video_codecs_shell; mod web_lock_interface_exposure_interfaces; +mod wake_lock_interfaces; mod webgl_interfaces; mod webidl_collections; mod webidl_fetch; diff --git a/moli-renderer-v8/src/script_vm/tests/wake_lock_interfaces.js b/moli-renderer-v8/src/script_vm/tests/wake_lock_interfaces.js new file mode 100644 index 0000000000..eac11255f6 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/wake_lock_interfaces.js @@ -0,0 +1,176 @@ +(async () => { + const rows = [], platformRequests = []; + const assert = (condition, message) => { if (!condition) throw Error(message); }; + const check = async (name, callback) => { + try { await callback(); rows.push({name, pass: true}); } + catch (error) { rows.push({name, pass: false, message: String(error)}); } + }; + const thrown = callback => { + try { callback(); } catch (error) { return error; } + throw Error('expected synchronous exception'); + }; + const rejected = async (w, callback) => { + const promise = callback(); + assert(promise instanceof w.Promise, 'Promise belongs to the callee realm'); + try { await promise; } catch (error) { return error; } + throw Error('expected rejected Promise'); + }; + const popup = open(); + const realms = [['main', window], ['child', document.getElementById('child').contentWindow], ['popup', popup]]; + const mainGetter = isSecureContext && Object.getOwnPropertyDescriptor(Navigator.prototype, 'wakeLock')?.get; + try { + for (const [label, w] of realms) { + if (!w.isSecureContext) { + await check(label + '/navigator/insecure prototype before materialization', () => { + assert(!Object.hasOwn(w.Navigator.prototype, 'wakeLock'), 'secure prototype member hidden before Navigator getter'); + }); + for (const name of ['WakeLock', 'WakeLockSentinel']) { + await check(label + '/' + name + '/insecure', () => assert(!(name in w), 'secure interface hidden')); + } + await check(label + '/navigator/insecure', () => assert(!('wakeLock' in w.navigator), 'secure Navigator member hidden')); + await check(label + '/navigator/author properties after finalization', () => { + Object.defineProperty(w.Navigator.prototype, 'wakeLock', {configurable: true, value: 42}); + try { assert(w.navigator.wakeLock === 42, 'author property remains observable'); } + finally { delete w.Navigator.prototype.wakeLock; } + }); + continue; + } + for (const name of ['WakeLock', 'WakeLockSentinel']) { + await check(label + '/' + name + '/interface', () => { + const C = w[name], parent = name === 'WakeLock' ? w.Object : w.EventTarget; + const d = Object.getOwnPropertyDescriptor(w, name); + assert(typeof C === 'function' && C.name === name && C.length === 0, 'interface constructor metadata'); + assert(d.writable && d.configurable && !d.enumerable, 'global descriptor'); + assert(Object.getPrototypeOf(C.prototype) === parent.prototype, 'prototype inheritance'); + assert(Object.getPrototypeOf(C) === (name === 'WakeLock' ? w.Function.prototype : parent), 'constructor inheritance'); + assert(C.prototype.constructor === C, 'prototype constructor'); + const tag = Object.getOwnPropertyDescriptor(C.prototype, Symbol.toStringTag); + assert(tag.value === name && !tag.writable && !tag.enumerable && tag.configurable, 'interface tag'); + assert(thrown(() => C()) instanceof w.TypeError && thrown(() => new C()) instanceof w.TypeError, 'illegal constructor realm'); + }); + } + let lock; + await check(label + '/navigator/SameObject intrinsic and relevant realm', () => { + const C = w.WakeLock, original = Object.getOwnPropertyDescriptor(w, 'WakeLock'); + const inherited = Object.getOwnPropertyDescriptor(w.Object.prototype, 'wakeLock'); + let constructorReads = 0, inheritedReads = 0; + Object.defineProperty(w, 'WakeLock', {configurable: true, get() { constructorReads++; throw Error('author constructor'); }}); + Object.defineProperty(w.Object.prototype, 'wakeLock', {configurable: true, get() { inheritedReads++; throw Error('inherited cache hook'); }}); + try { lock = mainGetter.call(w.navigator); } + finally { + if (original) Object.defineProperty(w, 'WakeLock', original); else delete w.WakeLock; + if (inherited) Object.defineProperty(w.Object.prototype, 'wakeLock', inherited); else delete w.Object.prototype.wakeLock; + } + assert(constructorReads === 0 && inheritedReads === 0, 'native materialization ignores public hooks'); + assert(lock === w.navigator.wakeLock && Object.getPrototypeOf(lock) === C.prototype, 'SameObject in Navigator relevant realm'); + assert(lock instanceof C && Object.prototype.toString.call(lock) === '[object WakeLock]', 'native WakeLock identity'); + assert(!Object.hasOwn(w.navigator, 'wakeLock') && !Object.hasOwn(lock, 'request'), 'shared prototype surface'); + const d = Object.getOwnPropertyDescriptor(w.Navigator.prototype, 'wakeLock'); + assert(d.enumerable && d.configurable && d.set === undefined && d.get.length === 0 && d.get.name === 'get wakeLock', 'readonly Navigator accessor'); + assert(Reflect.set(w.navigator, 'wakeLock', {}) === false && w.navigator.wakeLock === lock, 'readonly SameObject'); + }); + await check(label + '/navigator/receiver brands', () => { + const get = Object.getOwnPropertyDescriptor(w.Navigator.prototype, 'wakeLock').get; + let traps = 0; const trap = () => { traps++; throw Error('receiver hook'); }; + const revoked = Proxy.revocable(w.navigator, {}); revoked.revoke(); + for (const value of [null, {}, w.Navigator.prototype, Object.create(w.navigator), new Proxy(w.navigator, {get: trap, getPrototypeOf: trap}), revoked.proxy]) { + assert(thrown(() => get.call(value)) instanceof w.TypeError, 'callee realm rejects unbranded Navigator'); + } + assert(traps === 0, 'receiver validation does not run author hooks'); + }); + await check(label + '/request/descriptor', () => { + const d = Object.getOwnPropertyDescriptor(w.WakeLock.prototype, 'request'); + assert(d.value.length === 0 && d.value.name === 'request' && d.writable && d.enumerable && d.configurable, 'request operation metadata'); + }); + await check(label + '/request/brand before conversion', async () => { + const request = w.WakeLock.prototype.request; + let conversions = 0, traps = 0; + const argument = {toString() { conversions++; throw Error('argument conversion'); }}; + const trap = () => { traps++; throw Error('receiver hook'); }; + const revoked = Proxy.revocable(lock, {}); revoked.revoke(); + for (const receiver of [null, {}, w.WakeLock.prototype, Object.create(lock), new Proxy(lock, {get: trap, getPrototypeOf: trap}), revoked.proxy]) { + assert(await rejected(w, () => request.call(receiver, argument)) instanceof w.TypeError, 'reject forged and author proxy receivers'); + } + assert(conversions === 0 && traps === 0, 'native receiver checks precede argument conversion'); + }); + await check(label + '/request/enum rejection', async () => { + for (const value of [null, '', 'system', 'SCREEN', 0, true, Symbol(), {}]) { + assert(await rejected(w, () => lock.request(value)) instanceof w.TypeError, 'invalid WakeLockType rejects Promise'); + } + }); + await check(label + '/request/conversion exceptions and order', async () => { + const sentinel = {}, order = []; + const argument = { + get [Symbol.toPrimitive]() { order.push('get primitive'); return () => { order.push('call primitive'); throw sentinel; }; }, + toString() { throw Error('unexpected string fallback'); } + }; + assert(await rejected(w, () => lock.request(argument)) === sentinel, 'preserve author conversion exception identity'); + assert(order.join() === 'get primitive,call primitive', 'enum string conversion runs exactly once'); + }); + await check(label + '/request/valid default and platform outcome', async () => { + const request = w.WakeLock.prototype.request; + const promises = [request.call(lock), request.call(lock, undefined), request.call(lock, 'screen')]; + for (let i = 0; i < promises.length; i++) { + const promise = promises[i]; + assert(promise instanceof w.Promise && promises.filter(value => value === promise).length === 1, 'fresh Promise per request'); + try { + const sentinel = await promise; + assert(sentinel instanceof w.WakeLockSentinel && sentinel instanceof w.EventTarget && sentinel.type === 'screen', 'native sentinel if platform permits request'); + platformRequests.push({realm: label, call: i, result: 'acquired'}); + await sentinel.release(); + } catch (error) { + assert(error instanceof w.DOMException && error.name === 'NotAllowedError' && error.code === 0, 'platform denial uses NotAllowedError'); + platformRequests.push({realm: label, call: i, result: 'denied', name: error.name, code: error.code}); + } + } + }); + await check(label + '/request/native identity independent of public prototype', async () => { + const request = w.WakeLock.prototype.request, prototype = Object.getPrototypeOf(lock); + Object.setPrototypeOf(lock, null); + try { + assert(await rejected(w, () => request.call(lock, 'invalid')) instanceof w.TypeError, 'native brand retained after prototype removal'); + } finally { Object.setPrototypeOf(lock, prototype); } + }); + const entries = [['released', false], ['type', false], ['onrelease', true]]; + for (const [name, writable] of entries) { + await check(label + '/sentinel/' + name, () => { + const d = Object.getOwnPropertyDescriptor(w.WakeLockSentinel.prototype, name); + assert(d.enumerable && d.configurable && d.get.name === 'get ' + name && d.get.length === 0, 'shared sentinel getter metadata'); + assert(writable ? d.set.name === 'set ' + name && d.set.length === 1 : d.set === undefined, 'sentinel attribute writability'); + let conversions = 0, traps = 0; + const value = {toString() { conversions++; throw Error('unexpected conversion'); }}; + const trap = () => { traps++; throw Error('receiver hook'); }; + const fake = Object.create(w.WakeLockSentinel.prototype), revoked = Proxy.revocable(fake, {}); revoked.revoke(); + for (const receiver of [null, {}, w.WakeLockSentinel.prototype, fake, new Proxy(fake, {get: trap, getPrototypeOf: trap}), revoked.proxy, lock]) { + assert(thrown(() => d.get.call(receiver)) instanceof w.TypeError, 'sentinel getter rejects unbranded receiver'); + if (writable) assert(thrown(() => d.set.call(receiver, value)) instanceof w.TypeError, 'sentinel setter checks receiver first'); + } + assert(conversions === 0 && traps === 0, 'sentinel validation ignores author hooks'); + }); + } + await check(label + '/sentinel/release', async () => { + const d = Object.getOwnPropertyDescriptor(w.WakeLockSentinel.prototype, 'release'); + assert(d.value.name === 'release' && d.value.length === 0 && d.enumerable && d.configurable && d.writable, 'release operation metadata'); + const fake = Object.create(w.WakeLockSentinel.prototype), revoked = Proxy.revocable(fake, {}); revoked.revoke(); + for (const receiver of [null, {}, fake, new Proxy(fake, {}), revoked.proxy, lock]) { + assert(await rejected(w, () => d.value.call(receiver)) instanceof w.TypeError, 'release rejects Promise for illegal receiver'); + } + }); + await check(label + '/request/cross-realm callee Promise and errors', async () => { + const other = w === window ? document.getElementById('child').contentWindow : window; + const error = await rejected(w, () => w.WakeLock.prototype.request.call(other.navigator.wakeLock, 'invalid')); + assert(error instanceof w.TypeError && !(error instanceof other.TypeError), 'TypeError belongs to binding realm'); + }); + await check(label + '/request/immutable Promise intrinsic', async () => { + const original = w.Promise; + w.Promise = function() { throw Error('author Promise constructor'); }; + let promise; + try { promise = w.WakeLock.prototype.request.call(lock, 'invalid'); } + finally { w.Promise = original; } + assert(promise instanceof original && await rejected(w, () => promise) instanceof w.TypeError, 'Promise wrapper ignores public constructor'); + }); + } + } finally { popup.close(); } + globalThis.__nodeReplacementResults = {rows, platformRequests, failures: rows.filter(row => !row.pass), passed: rows.filter(row => row.pass).length, total: rows.length}; + return rows.every(row => row.pass); +})() diff --git a/moli-renderer-v8/src/script_vm/tests/wake_lock_interfaces.rs b/moli-renderer-v8/src/script_vm/tests/wake_lock_interfaces.rs new file mode 100644 index 0000000000..e506e09685 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/wake_lock_interfaces.rs @@ -0,0 +1,30 @@ +use super::*; + +#[test] +fn wake_lock_interfaces_validate_receivers_and_secure_realm_exposure() { + for url in ["https://wake-lock.test/", "http://wake-lock.test/"] { + let mut vm = new_storage_page_task_executor_test_vm(url); + vm.eval("document.body.innerHTML = ''") + .unwrap(); + vm.eval(&format!( + "({}).then(value => globalThis.__wakeLockDone = value, error => globalThis.__wakeLockDone = String(error));", + include_str!("wake_lock_interfaces.js") + )) + .unwrap(); + assert_eq!( + vm.eval_after_selected_page_tasks("JSON.stringify(__nodeReplacementResults.failures)") + .unwrap(), + "[]", + "{url}" + ); + assert_eq!(vm.eval("__wakeLockDone").unwrap(), "true", "{url}"); + if url.starts_with("https:") { + assert_eq!( + vm.eval("__nodeReplacementResults.platformRequests.length === 9 && __nodeReplacementResults.platformRequests.every(row => row.result === 'denied' && row.name === 'NotAllowedError' && row.code === 0)") + .unwrap(), + "true", + "No platform backend must not produce an acquired sentinel" + ); + } + } +} diff --git a/moli-renderer-v8/src/web_api_interfaces.rs b/moli-renderer-v8/src/web_api_interfaces.rs index 70670f7765..8bf5f0ead7 100644 --- a/moli-renderer-v8/src/web_api_interfaces.rs +++ b/moli-renderer-v8/src/web_api_interfaces.rs @@ -342,6 +342,8 @@ interfaces! { MediaStreamTrack: EventTarget; CanvasCaptureMediaStreamTrack: MediaStreamTrack; MediaDevices: EventTarget; + WakeLock; + WakeLockSentinel: EventTarget; MediaError; MediaKeySession: EventTarget; MediaKeyStatusMap;