From ab6e81f39945e91dc92da80e2bc21bf6629c75ec Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 30 Sep 2026 17:12:19 +0800 Subject: [PATCH] feat(media): expose native device info interfaces Expose MediaDeviceInfo and InputDeviceInfo through the secure Window intrinsic registry, with the required inheritance, illegal constructors, readonly attributes and shared prototype methods. Generated receiver validation rejects forged and author Proxy receivers before running author hooks. Serialize private native fields into a fresh callee-realm object with data properties, preserving UTF-16 and ignoring shadowed public getters or inherited setters. Tests cover main/iframe/popup interfaces, insecure contexts, Worker exclusion and native field serialization. This is an interface shim over the current media backend: enumerateDevices() still returns an empty list, and getCapabilities() returns a fresh empty dictionary. Device discovery, permission state and real capture capabilities remain unimplemented. --- .../exposed_interfaces/metadata.rs | 2 + .../navigator_runtime/media_devices.rs | 13 ++- .../navigator_runtime/media_devices/info.rs | 87 ++++++++++++++++++ .../navigator_runtime/navigator.rs | 4 +- .../src/context_bootstrap/specs/registry.rs | 8 ++ .../tests/media_device_interfaces.js | 54 +++++++++++ .../tests/media_device_interfaces.rs | 89 +++++++++++++++++++ moli-renderer-v8/src/script_vm/tests/mod.rs | 2 + moli-renderer-v8/src/web_api_interfaces.rs | 2 + .../src/worker/thread/tests/postmessage.rs | 14 +++ 10 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices/info.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/media_device_interfaces.js create mode 100644 moli-renderer-v8/src/script_vm/tests/media_device_interfaces.rs 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 b425c75eec..9e0b5f9279 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs @@ -81,6 +81,8 @@ pub(in crate::context_bootstrap) const WORKER_SHARED_INTERFACE_NAMES: &[&str] = ]; const SECURE_CONTEXT_ONLY_INTERFACE_NAMES: &[&str] = &[ + "MediaDeviceInfo", + "InputDeviceInfo", "MediaDevices", "SubtleCrypto", "CryptoKey", diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs index ce2b444165..bdab11f975 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs @@ -4,6 +4,8 @@ use crate::util::{get_private_value, set_private_value, throw_type_error}; use crate::web_api_interfaces; use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; +mod info; + const MEDIA_DEVICES_LISTENERS_SLOT: &str = "__moliMediaDevicesListeners"; const MEDIA_DEVICES_ONDEVICECHANGE_SLOT: &str = "__moliMediaDevicesOndevicechange"; @@ -42,9 +44,16 @@ pub(super) fn build_media_devices_object<'s>( pub(super) fn install_media_devices_template_bindings<'s>( scope: &mut v8::PinScope<'s, '_, ()>, template: v8::Local<'s, v8::FunctionTemplate>, + name: &str, ) { - let prototype = template.prototype_template(scope); - MediaDevicesPrototypeDeclaration::initialize_prototype_template(scope, prototype); + if name == "MediaDevices" { + MediaDevicesPrototypeDeclaration::initialize_prototype_template( + scope, + template.prototype_template(scope), + ); + } else { + info::install(scope, template, name); + } } fn receiver_is_media_devices<'s>( diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices/info.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices/info.rs new file mode 100644 index 0000000000..9003e1cee2 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices/info.rs @@ -0,0 +1,87 @@ +//! Device info interface shims. The current media backend enumerates no devices; +//! exposing these types does not manufacture a camera, microphone or permission. + +use super::*; +use moli_webapi_declare::ObjectLiteralDeclaration; + +const DEVICE_ID: &str = "__moliMediaDeviceId"; +const KIND: &str = "__moliMediaDeviceKind"; +const LABEL: &str = "__moliMediaDeviceLabel"; +const GROUP_ID: &str = "__moliMediaDeviceGroupId"; + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::MediaDeviceInfo, enumerable, receiver)] +struct DeviceInfoAttributes { + #[webapi(accessor_property, getter = info_getter, data = v8str(scope, DEVICE_ID))] + device_id: (), + #[webapi(accessor_property, getter = info_getter, data = v8str(scope, KIND))] + kind: (), + #[webapi(accessor_property, getter = info_getter, data = v8str(scope, LABEL))] + label: (), + #[webapi(accessor_property, getter = info_getter, data = v8str(scope, GROUP_ID))] + group_id: (), + #[webapi(method = "toJSON", length = 0, callback = to_json)] + to_json: (), +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::InputDeviceInfo, enumerable, receiver)] +struct InputDeviceInfoMethods { + #[webapi(method, length = 0, callback = get_capabilities)] + get_capabilities: (), +} + +pub(super) fn install<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, + name: &str, +) { + let prototype = template.prototype_template(scope); + match name { + "MediaDeviceInfo" => DeviceInfoAttributes::initialize_prototype_template(scope, prototype), + "InputDeviceInfo" => { + InputDeviceInfoMethods::initialize_prototype_template(scope, prototype) + } + _ => {} + } +} + +fn info_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let slot = args.data().to_rust_string_lossy(scope); + if let Some(value) = get_private_value(scope, args.this(), &slot) { + rv.set(value); + } +} + +fn to_json<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let object = ObjectLiteralDeclaration::bind(scope); + for (name, slot) in [ + ("deviceId", DEVICE_ID), + ("kind", KIND), + ("label", LABEL), + ("groupId", GROUP_ID), + ] { + let value = get_private_value(scope, args.this(), slot) + .unwrap_or_else(|| v8::undefined(scope).into()); + object.set_string_property(scope, name, value); + } + rv.set(object.into_value()); +} + +fn get_capabilities<'s>( + scope: &mut v8::PinScope<'s, '_>, + _args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + // There is no capture backend or device permission grant yet. An empty + // capabilities dictionary also represents privacy-filtered input devices. + rv.set(ObjectLiteralDeclaration::bind(scope).into_value()); +} 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 c28a02738d..43348dce65 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs @@ -896,7 +896,9 @@ pub(in crate::context_bootstrap) fn install_navigator_template_bindings<'s>( install_media_capabilities_template_bindings(scope, template, interface_name); let prototype = template.prototype_template(scope); match interface_name { - "MediaDevices" => install_media_devices_template_bindings(scope, template), + "MediaDevices" | "MediaDeviceInfo" | "InputDeviceInfo" => { + install_media_devices_template_bindings(scope, template, interface_name) + } "Navigator" => { NavigatorRuntimeDataPrototypeDeclaration::initialize_prototype_template( scope, prototype, diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index 1822a1e6f3..ddd10dad42 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -524,6 +524,14 @@ const CONSTRUCTOR_SPECS_BEFORE_STREAMS: &[ConstructorSpec] = &[ interface: web_api_interfaces::FileSystemSyncAccessHandle::DESCRIPTOR, kind: ConstructorKind::Illegal, }, + ConstructorSpec { + interface: web_api_interfaces::MediaDeviceInfo::DESCRIPTOR, + kind: ConstructorKind::Illegal, + }, + ConstructorSpec { + interface: web_api_interfaces::InputDeviceInfo::DESCRIPTOR, + kind: ConstructorKind::Illegal, + }, ConstructorSpec { interface: web_api_interfaces::MediaDevices::DESCRIPTOR, kind: ConstructorKind::Illegal, diff --git a/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.js b/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.js new file mode 100644 index 0000000000..aa16370f95 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.js @@ -0,0 +1,54 @@ +(async () => { + const rows = [], assert = (ok, message) => { if (!ok) throw Error(message); }; + const check = async (name, run) => { try { await run(); rows.push({name, pass:true}); } catch(error) {rows.push({name, pass:false, message:String(error)});} }; + const popup = open(), realms = [['main', window], ['child', document.getElementById('child').contentWindow], ['popup', popup]]; + try { + for (const [label, w] of realms) { + if (!w.isSecureContext) { + await check(label + '/insecure', () => assert(!('MediaDeviceInfo' in w) && !('InputDeviceInfo' in w), 'secure globals hidden')); + continue; + } + for (const name of ['MediaDeviceInfo', 'InputDeviceInfo']) { + await check(label + '/' + name, () => { + const C=w[name], parent=name==='InputDeviceInfo'?w.MediaDeviceInfo:w.Object; + const d=Object.getOwnPropertyDescriptor(w,name); + assert(typeof C==='function' && C.name===name && C.length===0,'constructor metadata'); + assert(d.writable && d.configurable && !d.enumerable,'global descriptor'); + assert(Object.getPrototypeOf(C.prototype)===parent.prototype && Object.getPrototypeOf(C)===(parent===w.Object?w.Function.prototype:parent),'native inheritance'); + assert(C.prototype.constructor===C,'prototype constructor'); + const tag=Object.getOwnPropertyDescriptor(C.prototype,Symbol.toStringTag); + assert(tag.value===name && tag.configurable && !tag.writable && !tag.enumerable,'prototype tag'); + for(const call of [()=>C(),()=>new C()]){let error;try{call();}catch(e){error=e;}assert(error instanceof w.TypeError,'illegal constructor realm');} + }); + } + const entries=[['MediaDeviceInfo','deviceId',true],['MediaDeviceInfo','kind',true],['MediaDeviceInfo','label',true],['MediaDeviceInfo','groupId',true],['MediaDeviceInfo','toJSON',false],['InputDeviceInfo','getCapabilities',false]]; + for(const [owner,name,attribute] of entries) { + await check(label+'/'+name,()=>{ + const C=w[owner],d=Object.getOwnPropertyDescriptor(C.prototype,name),fn=attribute?d.get:d.value; + assert(typeof fn==='function' && fn.length===0 && fn.name===(attribute?'get ':'')+name,'member metadata'); + assert(d.enumerable && d.configurable && (attribute?d.set===undefined:d.writable),'member descriptor'); + let traps=0, conversions=0;const trap=()=>{traps++;throw Error('author trap');}; + const revoked=Proxy.revocable({},{});revoked.revoke(); + const ignored={toString(){conversions++;throw Error('ignored argument');}}; + for(const receiver of [null,{},C.prototype,Object.create(C.prototype),new Proxy({}, {get:trap,getPrototypeOf:trap}),revoked.proxy]){ + let error;try{fn.call(receiver,ignored);}catch(e){error=e;} + assert(error instanceof w.TypeError,'callee TypeError for unbranded receiver'); + } + assert(traps===0 && conversions===0,'native brand validation ignores author hooks'); + }); + } + await check(label+'/enumeration',async()=>{ + const first=await w.navigator.mediaDevices.enumerateDevices(),second=await w.navigator.mediaDevices.enumerateDevices(); + assert(first instanceof w.Array && first!==second,'fresh native device list'); + for(const device of first){ + const parent=device.kind==='audiooutput'?w.MediaDeviceInfo:w.InputDeviceInfo; + assert(device instanceof parent,'returned device brand'); + const json=device.toJSON(); + assert(Object.keys(json).join()==='deviceId,kind,label,groupId','default JSON fields'); + for(const key of Object.keys(json))assert(json[key]===device[key],'JSON device values'); + } + }); + } + } finally {popup.close();} + globalThis.__nodeReplacementResults={rows,failures:rows.filter(r=>!r.pass),passed:rows.filter(r=>r.pass).length,total:rows.length};return rows.every(r=>r.pass); +})() diff --git a/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.rs b/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.rs new file mode 100644 index 0000000000..921c394f25 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.rs @@ -0,0 +1,89 @@ +use super::*; + +#[test] +fn media_device_interfaces_preserve_inheritance_brands_and_secure_exposure() { + for url in [ + "https://media-device-interfaces.test/", + "http://media-device-interfaces.test/", + ] { + let mut vm = new_storage_page_task_executor_test_vm(url); + vm.eval("document.body.innerHTML = ''") + .unwrap(); + vm.eval(&format!("({}).then(value => globalThis.__mediaDeviceDone = value, error => globalThis.__mediaDeviceDone = String(error));", include_str!("media_device_interfaces.js"))).unwrap(); + assert_eq!( + vm.eval_after_selected_page_tasks("JSON.stringify(__nodeReplacementResults.failures)") + .unwrap(), + "[]", + "{url}" + ); + assert_eq!(vm.eval("__mediaDeviceDone").unwrap(), "true", "{url}"); + } +} + +#[test] +fn media_device_serialization_reads_native_fields_and_preserves_utf16() { + let mut vm = new_storage_page_task_executor_test_vm("https://media-device-values.test/"); + let context_ptr: *const v8::Global = &vm.page_default_context as *const _; + vm.renderer_document_isolate + .with_entered_renderer_document_isolate(move |isolate| { + let scope = std::pin::pin!(v8::HandleScope::new(isolate)); + let scope = &mut scope.init(); + let context = unsafe { v8::Local::new(scope, &*context_ptr) }; + let scope = &mut v8::ContextScope::new(scope, context); + let prototype = crate::context_bootstrap::ensure_intrinsic_interface_prototype( + scope, + "InputDeviceInfo", + )?; + // A native test fixture, not a fake device published by enumerateDevices. + let object = v8::Object::new(scope); + assert_eq!(object.set_prototype(scope, prototype.into()), Some(true)); + moli_webapi_declare::initialize_web_api_object(scope, object, "InputDeviceInfo") + .unwrap(); + for (slot, value) in [ + ("__moliMediaDeviceId", "native-id"), + ("__moliMediaDeviceKind", "audioinput"), + ("__moliMediaDeviceGroupId", "native-group"), + ] { + let value = crate::util::v8str(scope, value); + crate::util::set_private_value(scope, object, slot, value.into()); + } + let label = + v8::String::new_from_two_byte(scope, &[0xd800], v8::NewStringType::Normal).unwrap(); + crate::util::set_private_value(scope, object, "__moliMediaDeviceLabel", label.into()); + assert_eq!( + context.global(scope).create_data_property( + scope, + crate::util::v8str(scope, "nativeDevice").into(), + object.into() + ), + Some(true) + ); + Ok(()) + }) + .unwrap(); + assert_eq!(vm.eval(r#"(() => { + const assert = (ok, message) => {if (!ok) throw Error(message);}; + const device = nativeDevice, prototype = MediaDeviceInfo.prototype; + assert(device instanceof InputDeviceInfo && device instanceof MediaDeviceInfo, 'native inherited brand'); + assert(device.deviceId === 'native-id' && device.kind === 'audioinput' && device.groupId === 'native-group' && device.label.charCodeAt(0) === 0xd800, 'native values'); + const expected = device.toJSON(); + let reads = 0, writes = 0; + for (const name of ['deviceId','kind','label','groupId']) { + Object.defineProperty(device,name,{configurable:true,get(){reads++;throw Error('author getter');}}); + Object.defineProperty(Object.prototype,name,{configurable:true,set(){writes++;throw Error('inherited setter');}}); + } + let json; + try {json = prototype.toJSON.call(device);} finally {for (const name of ['deviceId','kind','label','groupId']) delete Object.prototype[name];} + assert(reads === 0 && writes === 0 && JSON.stringify(json) === JSON.stringify(expected), 'private native serialization and data properties'); + assert(json !== expected && Object.getPrototypeOf(json) === Object.prototype, 'fresh ordinary JSON object'); + const first = device.getCapabilities(), second = device.getCapabilities(); + assert(first !== second && Object.keys(first).length === 0, 'fresh empty capabilities shim'); + Object.setPrototypeOf(device,null); + assert(prototype.toJSON.call(device).deviceId === 'native-id', 'brand independent of public prototype'); + for (const receiver of [Object.create(device), new Proxy(device,{})]) { + let error;try {prototype.toJSON.call(receiver);} catch(e){error=e;} + assert(error instanceof TypeError, 'author objects do not acquire native brand'); + } + return true; + })()"#).unwrap(), "true"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 2ade78dee5..9e9f31148f 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2164,3 +2164,5 @@ mod navigation_timing_inheritance; mod response_blob_mime; mod intersection_target_order; + +mod media_device_interfaces; diff --git a/moli-renderer-v8/src/web_api_interfaces.rs b/moli-renderer-v8/src/web_api_interfaces.rs index e8181b3372..6b2aab0717 100644 --- a/moli-renderer-v8/src/web_api_interfaces.rs +++ b/moli-renderer-v8/src/web_api_interfaces.rs @@ -282,6 +282,8 @@ interfaces! { Location; MathMLElement: Element; MediaCapabilities; + MediaDeviceInfo; + InputDeviceInfo: MediaDeviceInfo; MediaDevices: EventTarget; MediaError; MediaList; diff --git a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs index bdb53b404a..a1f3879499 100644 --- a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs +++ b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs @@ -2959,3 +2959,17 @@ async fn worker_fallback_message_events_are_native_platform_objects() { r#"{"data":"ping","cloneResult":"DataCloneError"}"# ); } + +#[tokio::test] +async fn worker_does_not_expose_window_media_device_interfaces() { + ensure_v8(); + let mut handle = spawn_worker("postMessage({MediaDeviceInfo:'MediaDeviceInfo' in self,InputDeviceInfo:'InputDeviceInfo' in self});close();".to_owned(), "https://media-device-worker.test/worker.js".into()); + let message = timeout(TIMEOUT, handle.recv()) + .await + .expect("timed out") + .expect("channel closed"); + assert_eq!( + expect_post_json(message), + r#"{"MediaDeviceInfo":false,"InputDeviceInfo":false}"# + ); +}