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.
This commit is contained in:
ldm0
2026-09-30 18:45:36 +08:00
committed by Donough Liu
parent 2b5657d6e6
commit ab6e81f399
10 changed files with 272 additions and 3 deletions
@@ -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",
@@ -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>(
@@ -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());
}
@@ -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,
@@ -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,
@@ -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);
})()
@@ -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 = '<iframe id=child></iframe>'")
.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<v8::Context> = &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");
}
@@ -2164,3 +2164,5 @@ mod navigation_timing_inheritance;
mod response_blob_mime;
mod intersection_target_order;
mod media_device_interfaces;
@@ -282,6 +282,8 @@ interfaces! {
Location;
MathMLElement: Element;
MediaCapabilities;
MediaDeviceInfo;
InputDeviceInfo: MediaDeviceInfo;
MediaDevices: EventTarget;
MediaError;
MediaList;
@@ -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}"#
);
}