feat(media): add closed MediaSource frontend and buffer lists

This commit is contained in:
ldm0
2026-10-11 18:06:49 +08:00
parent 3c282deb23
commit ca924d812a
14 changed files with 836 additions and 15 deletions
@@ -21,6 +21,7 @@ pub(in crate::context_bootstrap::media_queries::events::simple_event_target) fn
target: v8::Local<'s, v8::Object>,
slot: &str,
) -> Option<v8::Local<'s, v8::Value>> {
let target = moli_webapi_declare::web_api_object_target(scope, target).unwrap_or(target);
let mut current = Some(target);
for _ in 0..64 {
let object = current?;
@@ -1,11 +1,22 @@
use crate::web_api_interfaces;
use crate::webidl;
//! MediaSource's initial closed state. Attachment, source buffers and decoding
//! require a media backend; this frontend does not synthesize an open source.
use super::{constructors::throw_dom_exception_value, media_queries};
use crate::{
util::{get_private_object, get_private_value, set_private_value, throw_type_error, v8str},
web_api_interfaces, webidl,
};
use moli_web_mime::is_media_source_type_supported;
use moli_webapi_declare::WebApiFunctionTemplate;
pub(crate) mod source_buffer_list;
mod state;
pub(crate) use state::{MediaSourceObject, media_source_object};
const SOURCE_BUFFERS: &str = "__moliMediaSourceBuffers";
const ACTIVE_SOURCE_BUFFERS: &str = "__moliMediaSourceActiveBuffers";
const LISTENERS: &str = "__moliMediaSourceListeners";
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "MediaSource.isTypeSupported")]
struct MediaSourceIsTypeSupportedArgs {
@@ -25,11 +36,88 @@ struct MediaSourceTemplateDeclaration {
is_type_supported: (),
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::MediaSource, receiver, enumerable)]
struct MediaSourcePrototype {
#[webapi(accessor_property, getter = buffers, data = v8str(scope, SOURCE_BUFFERS))]
source_buffers: (),
#[webapi(accessor_property, getter = buffers, data = v8str(scope, ACTIVE_SOURCE_BUFFERS))]
active_source_buffers: (),
#[webapi(accessor_property, getter = ready_state)]
ready_state: (),
#[webapi(accessor_property, getter = duration, setter = set_duration)]
duration: (),
#[webapi(accessor_property, getter = handler_getter, setter = handler_setter, data = v8str(scope, "sourceopen"))]
onsourceopen: (),
#[webapi(accessor_property, getter = handler_getter, setter = handler_setter, data = v8str(scope, "sourceended"))]
onsourceended: (),
#[webapi(accessor_property, getter = handler_getter, setter = handler_setter, data = v8str(scope, "sourceclose"))]
onsourceclose: (),
#[webapi(method, length = 1, callback = add_source_buffer)]
add_source_buffer: (),
#[webapi(method, length = 1, callback = remove_source_buffer)]
remove_source_buffer: (),
#[webapi(method, length = 0, callback = end_of_stream)]
end_of_stream: (),
#[webapi(method, length = 2, callback = set_live_seekable_range)]
set_live_seekable_range: (),
#[webapi(method, length = 0, callback = clear_live_seekable_range)]
clear_live_seekable_range: (),
}
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "MediaSource.duration")]
struct DurationArgs {
#[webidl(converter = "unrestricted_double", default = f64::NAN)]
value: f64,
}
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "MediaSource.addSourceBuffer")]
struct AddArgs {
#[webidl(required, name = "type")]
media_type: String,
}
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "MediaSource.removeSourceBuffer")]
struct RemoveArgs<'s> {
#[webidl(required, name = "sourceBuffer", interface = web_api_interfaces::SourceBuffer)]
_source_buffer: v8::Local<'s, v8::Object>,
}
#[derive(webidl::WebIdlEnum)]
#[webidl(name = "EndOfStreamError", rename_all = "lowercase")]
enum EndOfStreamError {
Network,
Decode,
}
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "MediaSource.endOfStream")]
struct EndArgs {
#[webidl(name = "error", converter = "enum")]
_error: Option<EndOfStreamError>,
}
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "MediaSource.setLiveSeekableRange")]
struct RangeArgs {
#[webidl(required, name = "start", converter = "double")]
_start: f64,
#[webidl(required, name = "end", converter = "double")]
_end: f64,
}
pub(in crate::context_bootstrap) fn media_source_constructor_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
if !args.is_construct_call() {
throw_type_error(scope, "MediaSource constructor requires 'new'.");
return;
}
state::initialize(scope, args.this());
rv.set(args.this().into());
}
@@ -41,6 +129,12 @@ pub(in crate::context_bootstrap) fn install_media_source_template_bindings<'s>(
) {
if interface_name == "MediaSource" {
MediaSourceTemplateDeclaration::initialize_template(scope, template);
MediaSourcePrototype::initialize_prototype_template(
scope,
template.prototype_template(scope),
);
} else if interface_name == "SourceBufferList" {
source_buffer_list::install(scope, template);
}
}
@@ -54,3 +148,181 @@ fn media_source_is_type_supported_callback<'s>(
};
rv.set(v8::Boolean::new(scope, is_media_source_type_supported(&parsed.media_type)).into());
}
fn target<'s>(
scope: &mut v8::PinScope<'s, '_>,
receiver: v8::Local<'s, v8::Object>,
) -> v8::Local<'s, v8::Object> {
moli_webapi_declare::web_api_object_target(scope, receiver).expect("MSE receiver was validated")
}
fn buffers<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let source = target(scope, args.this());
let slot = args.data().to_rust_string_lossy(scope);
if let Some(list) = get_private_object(scope, source, &slot) {
rv.set(list.into());
return;
}
// A borrowed getter still returns a list from its owner's creation realm.
let context = source
.get_creation_context(scope)
.expect("MediaSource has a creation realm");
let scope = &mut v8::ContextScope::new(scope, context);
let values = v8::Array::new(scope, 0);
let list = source_buffer_list::build(scope, source, values);
set_private_value(scope, source, &slot, list.into());
rv.set(list.into());
}
fn ready_state<'s>(
scope: &mut v8::PinScope<'s, '_>,
_args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
rv.set(v8str(scope, "closed").into());
}
fn duration<'s>(
_scope: &mut v8::PinScope<'s, '_>,
_args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
rv.set_double(f64::NAN);
}
fn closed_error(scope: &mut v8::PinScope<'_, '_>) {
throw_dom_exception_value(scope, "The MediaSource is not open.", "InvalidStateError");
}
fn set_duration<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some(parsed) = webidl::parse_args::<DurationArgs>(scope, &args) else {
return;
};
if parsed.value.is_nan() || parsed.value < 0.0 {
throw_type_error(
scope,
"MediaSource duration must be nonnegative and not NaN.",
);
return;
}
closed_error(scope);
}
fn add_source_buffer<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some(parsed) = webidl::parse_args::<AddArgs>(scope, &args) else {
return;
};
if parsed.media_type.is_empty() {
throw_type_error(scope, "Source buffer type must not be empty.");
} else if !is_media_source_type_supported(&parsed.media_type) {
throw_dom_exception_value(
scope,
"Unsupported source buffer type.",
"NotSupportedError",
);
} else {
closed_error(scope);
}
}
fn remove_source_buffer<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
if webidl::parse_args::<RemoveArgs>(scope, &args).is_some() {
// Closed sources have no members, even if the argument is a genuine
// SourceBuffer from another source.
throw_dom_exception_value(
scope,
"Source buffer is not in this source.",
"NotFoundError",
);
}
}
fn end_of_stream<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
if webidl::parse_args::<EndArgs>(scope, &args).is_some() {
closed_error(scope);
}
}
fn set_live_seekable_range<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
if webidl::parse_args::<RangeArgs>(scope, &args).is_some() {
// Finite double conversion precedes the algorithm; closed-state
// rejection precedes its nonnegative/ordered range validation.
closed_error(scope);
}
}
fn clear_live_seekable_range<'s>(
scope: &mut v8::PinScope<'s, '_>,
_args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
closed_error(scope);
}
fn handler_getter<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let object = target(scope, args.this());
let event = args.data().to_rust_string_lossy(scope);
rv.set(
get_private_value(scope, object, handler_slot(&event))
.unwrap_or_else(|| v8::null(scope).into()),
);
}
fn handler_setter<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
let object = target(scope, args.this());
let event = args.data().to_rust_string_lossy(scope);
let slot = handler_slot(&event);
let active = args.get(0).is_object();
let handler = if active {
args.get(0)
} else {
v8::null(scope).into()
};
set_private_value(scope, object, slot, handler);
media_queries::simple_object_event_set_ordered_handler(
scope, object, LISTENERS, &event, slot, active,
);
}
fn handler_slot(event: &str) -> &'static str {
match event {
"sourceopen" => "__moliMediaSourceOnsourceopen",
"sourceended" => "__moliMediaSourceOnsourceended",
"sourceclose" => "__moliMediaSourceOnsourceclose",
"addsourcebuffer" => "__moliSourceBufferListOnaddsourcebuffer",
"removesourcebuffer" => "__moliSourceBufferListOnremovesourcebuffer",
_ => unreachable!("MSE event handler callback data"),
}
}
@@ -0,0 +1,244 @@
//! Native indexed SourceBufferList. The backing array is private; indexed writes
//! follow Web IDL's readonly indexed getter rules, including unsupported indices.
use super::{LISTENERS, handler_getter, handler_setter, target};
use crate::{
context_bootstrap::shared,
util::{get_private_object, v8str},
web_api_interfaces,
};
use moli_webapi_declare::{
DataPropertyDescriptorDeclaration, WebApiFunctionTemplate, WebApiObject,
};
const OWNER: &str = "__moliSourceBufferListOwner";
const VALUES: &str = "__moliSourceBufferListValues";
#[derive(WebApiObject)]
#[webapi(interface = web_api_interfaces::SourceBufferList, require_prototype)]
struct ListObject<'s> {
#[webapi(slot = OWNER)]
owner: v8::Local<'s, v8::Object>,
#[webapi(slot = VALUES)]
values: v8::Local<'s, v8::Array>,
#[webapi(slot = shared::SIMPLE_EVENT_TARGET_SLOT, value = LISTENERS)]
event_target: (),
#[webapi(slot = shared::SIMPLE_EVENT_TARGET_ORDERED_HANDLERS_SLOT, init = true)]
ordered_handlers: (),
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::SourceBufferList, receiver, enumerable)]
struct ListPrototype {
#[webapi(accessor_property, getter = length)]
length: (),
#[webapi(accessor_property, getter = handler_getter, setter = handler_setter, data = v8str(scope, "addsourcebuffer"))]
onaddsourcebuffer: (),
#[webapi(accessor_property, getter = handler_getter, setter = handler_setter, data = v8str(scope, "removesourcebuffer"))]
onremovesourcebuffer: (),
#[webapi(intrinsic_data_property = v8::Intrinsic::ArrayProtoValues, symbol = "iterator")]
iterator: (),
}
#[derive(WebApiObject)]
#[webapi(plain)]
struct ListHandler<'s> {
#[webapi(prototype, value = v8::null(scope))]
prototype: (),
reflect_set: v8::Local<'s, v8::Function>,
#[webapi(method, length = 4, callback = set, data = self.reflect_set)]
set: (),
}
pub(super) fn install<'s>(
scope: &mut v8::PinScope<'s, '_, ()>,
template: v8::Local<'s, v8::FunctionTemplate>,
) {
ListPrototype::initialize_prototype_template(scope, template.prototype_template(scope));
}
pub(crate) fn build<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
values: v8::Local<'s, v8::Array>,
) -> v8::Local<'s, v8::Object> {
let template = v8::ObjectTemplate::new(scope);
template.set_indexed_property_handler(
v8::IndexedPropertyHandlerConfiguration::new()
.getter(indexed_getter)
.query(indexed_query)
.deleter(indexed_deleter)
.definer(indexed_definer)
.enumerator(indexed_enumerator)
.descriptor(indexed_descriptor),
);
let object = template
.new_instance(scope)
.expect("SourceBufferList should allocate");
ListObject::new(owner, values)
.bind_into(scope, object)
.expect("SourceBufferList should bind");
// Interceptors expose their holder but not the original [[Set]] receiver.
// A registered native Proxy retains brand identity while providing it.
let handler = ListHandler::new(
moli_webapi_declare::web_api_reflect_set(scope).expect("Reflect.set was captured"),
)
.bind(scope)
.expect("SourceBufferList handler should bind");
let proxy =
v8::Proxy::new(scope, object, handler).expect("SourceBufferList Proxy should allocate");
moli_webapi_declare::register_web_api_proxy(scope, proxy)
.expect("SourceBufferList Proxy retains native identity");
proxy.into()
}
fn values<'s>(
scope: &mut v8::PinScope<'s, '_>,
receiver: v8::Local<'s, v8::Object>,
) -> v8::Local<'s, v8::Array> {
let object = target(scope, receiver);
get_private_object(scope, object, VALUES)
.and_then(|value| v8::Local::<v8::Array>::try_from(value).ok())
.expect("SourceBufferList retains its backing array")
}
fn item<'s>(
scope: &mut v8::PinScope<'s, '_>,
receiver: v8::Local<'s, v8::Object>,
index: u32,
) -> Option<v8::Local<'s, v8::Value>> {
let values = values(scope, receiver);
(index < values.length())
.then(|| values.get_index(scope, index))
.flatten()
}
fn length<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
rv.set_uint32(values(scope, args.this()).length());
}
fn indexed_getter<'s>(
scope: &mut v8::PinScope<'s, '_>,
index: u32,
args: v8::PropertyCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) -> v8::Intercepted {
let Some(value) = item(scope, args.holder(), index) else {
return v8::Intercepted::kNo;
};
rv.set(value);
v8::Intercepted::kYes
}
fn indexed_query<'s>(
scope: &mut v8::PinScope<'s, '_>,
index: u32,
args: v8::PropertyCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Integer>,
) -> v8::Intercepted {
if index >= values(scope, args.holder()).length() {
return v8::Intercepted::kNo;
}
rv.set_int32(v8::PropertyAttribute::READ_ONLY.as_u32() as i32);
v8::Intercepted::kYes
}
fn set<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let object = v8::Local::<v8::Object>::try_from(args.get(0)).expect("native Proxy target");
let key = args.get(1);
let index = key
.is_string()
.then(|| key.to_rust_string_lossy(scope))
.and_then(|key| {
key.parse::<u32>()
.ok()
.filter(|index| *index != u32::MAX && index.to_string() == key)
});
let target = if let Some(index) = index {
if index < values(scope, object).length() {
rv.set_bool(false);
return;
}
// Ordinary [[Set]] begins at the prototype for an unsupported index.
// Its eventual own creation uses this Proxy's [[DefineOwnProperty]],
// rather than V8's interceptor Set fast path which bypasses definer.
object
.get_prototype(scope)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
.unwrap_or_else(|| crate::util::new_null_prototype_object(scope))
} else {
object
};
let reflect_set =
v8::Local::<v8::Function>::try_from(args.data()).expect("captured Reflect.set");
if let Some(result) = reflect_set.call(
scope,
v8::undefined(scope).into(),
&[target.into(), key, args.get(2), args.get(3)],
) {
rv.set(result);
}
}
fn indexed_deleter<'s>(
scope: &mut v8::PinScope<'s, '_>,
index: u32,
args: v8::PropertyCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Boolean>,
) -> v8::Intercepted {
if index >= values(scope, args.holder()).length() {
return v8::Intercepted::kNo;
}
rv.set_bool(false);
v8::Intercepted::kYes
}
fn indexed_definer(
_scope: &mut v8::PinScope<'_, '_>,
index: u32,
_descriptor: &v8::PropertyDescriptor,
_args: v8::PropertyCallbackArguments<'_>,
mut rv: v8::ReturnValue<'_, v8::Boolean>,
) -> v8::Intercepted {
if index == u32::MAX {
return v8::Intercepted::kNo;
}
rv.set_bool(false);
v8::Intercepted::kYes
}
fn indexed_enumerator<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::PropertyCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Array>,
) {
let length = values(scope, args.holder()).length();
let keys = (0..length)
.map(|index| v8::Integer::new_from_unsigned(scope, index).into())
.collect::<Vec<_>>();
rv.set(v8::Array::new_with_elements(scope, &keys));
}
fn indexed_descriptor<'s>(
scope: &mut v8::PinScope<'s, '_>,
index: u32,
args: v8::PropertyCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) -> v8::Intercepted {
let Some(value) = item(scope, args.holder(), index) else {
return v8::Intercepted::kNo;
};
let descriptor = DataPropertyDescriptorDeclaration::new(value, false, true)
.bind(scope)
.expect("SourceBufferList indexed descriptor should allocate");
rv.set(descriptor.into());
v8::Intercepted::kYes
}
@@ -26,6 +26,10 @@ pub(crate) struct MediaSourceObject {
struct MediaSourceInstanceDeclaration<'scope> {
#[webapi(slot = MEDIA_SOURCE_ID_SLOT)]
id: v8::Local<'scope, v8::BigInt>,
#[webapi(slot = crate::context_bootstrap::shared::SIMPLE_EVENT_TARGET_SLOT, value = super::LISTENERS)]
event_target: (),
#[webapi(slot = crate::context_bootstrap::shared::SIMPLE_EVENT_TARGET_ORDERED_HANDLERS_SLOT, init = true)]
ordered_handlers: (),
}
type Store = Rc<RefCell<MediaSourceObjects>>;
@@ -27,6 +27,9 @@ pub(super) fn shared_target_owner<'s>(
scope: &mut v8::PinScope<'s, '_>,
target: v8::Local<'s, v8::Object>,
) -> v8::Local<'s, v8::Object> {
// Registered native Proxies share the backing object's listener registry.
// Keep the original wrapper separately for callback `this` and Event.target.
let target = moli_webapi_declare::web_api_object_target(scope, target).unwrap_or(target);
get_private_object(scope, target, OWNER).unwrap_or(target)
}
@@ -0,0 +1,64 @@
(() => {
const checks=[],diagnostics=[];
const check=(name,action)=>{try{checks.push({name,passed:action()===true})}catch(error){checks.push({name,passed:false,error:String(error)})}};
const throws=(C,action,name)=>{let error;try{action()}catch(caught){error=caught}return error instanceof C&&(name===undefined||error.name===name)};
const frame=document.createElement('iframe');document.body.appendChild(frame);const popup=window.open('about:blank');
try {
for(const [label,w] of [['main',window],['iframe',frame.contentWindow],['popup',popup]]) {
const P=w.MediaSource.prototype,L=w.SourceBufferList.prototype,source=new w.MediaSource();
const getters=['sourceBuffers','activeSourceBuffers','readyState','duration','onsourceopen','onsourceended','onsourceclose'];
const methods=[['addSourceBuffer',1],['removeSourceBuffer',1],['endOfStream',0],['setLiveSeekableRange',2],['clearLiveSeekableRange',0]];
for(const [name,C] of [['MediaSource',w.MediaSource],['SourceBufferList',w.SourceBufferList]]){
check(label+'/'+name+'/interface',()=>typeof C==='function'&&Object.getPrototypeOf(C.prototype)===w.EventTarget.prototype);
check(label+'/'+name+'/function call',()=>throws(w.TypeError,()=>C()));
}
check(label+'/SourceBufferList/illegal constructor',()=>throws(w.TypeError,()=>new w.SourceBufferList()));
for(const name of getters)check(label+'/MediaSource/descriptor/'+name,()=>{
const d=Object.getOwnPropertyDescriptor(P,name),set=name==='duration'||name.startsWith('on');
return d.enumerable&&d.configurable&&d.get.length===0&&d.get.name==='get '+name&&(set?d.set.length===1&&d.set.name==='set '+name:d.set===undefined);
});
for(const [name,length] of methods)check(label+'/MediaSource/method/'+name,()=>{const d=Object.getOwnPropertyDescriptor(P,name);return d.enumerable&&d.configurable&&d.writable&&d.value.length===length&&d.value.name===name});
for(const name of ['length','onaddsourcebuffer','onremovesourcebuffer'])check(label+'/SourceBufferList/descriptor/'+name,()=>{const d=Object.getOwnPropertyDescriptor(L,name);return d.enumerable&&d.configurable&&d.get.length===0&&(name==='length'?d.set===undefined:d.set.length===1)});
check(label+'/SourceBufferList/iterator descriptor',()=>{const d=Object.getOwnPropertyDescriptor(L,Symbol.iterator);return d.value===w.Array.prototype.values&&!d.enumerable&&d.configurable&&d.writable});
check(label+'/MediaSource/closed defaults',()=>source.readyState==='closed'&&Number.isNaN(source.duration)&&source.onsourceopen===null&&source.onsourceended===null&&source.onsourceclose===null);
for(const name of ['sourceBuffers','activeSourceBuffers'])check(label+'/MediaSource/native list/'+name,()=>{const list=source[name];return list instanceof w.SourceBufferList&&list instanceof w.EventTarget&&!w.Array.isArray(list)&&list===source[name]&&list.length===0&&list[0]===undefined&&Object.keys(list).length===0&&Object.prototype.toString.call(list)==='[object SourceBufferList]'&&[...list].length===0});
check(label+'/MediaSource/distinct lists',()=>source.sourceBuffers!==source.activeSourceBuffers&&source.sourceBuffers!==new w.MediaSource().sourceBuffers);
for(const [name,args,C,errorName] of [
['addSourceBuffer',[],w.TypeError],['addSourceBuffer',[''],w.TypeError],['addSourceBuffer',['video/moli'],w.DOMException,'NotSupportedError'],['addSourceBuffer',['video/mp4;codecs="avc1.4d001e"'],w.DOMException,'InvalidStateError'],
['removeSourceBuffer',[],w.TypeError],['removeSourceBuffer',[{}],w.TypeError],['endOfStream',[],w.DOMException,'InvalidStateError'],['endOfStream',['decode'],w.DOMException,'InvalidStateError'],['endOfStream',['network'],w.DOMException,'InvalidStateError'],['endOfStream',[undefined],w.DOMException,'InvalidStateError'],['endOfStream',[''],w.TypeError],['endOfStream',['other'],w.TypeError],
['setLiveSeekableRange',[],w.TypeError],['setLiveSeekableRange',[0],w.TypeError],['setLiveSeekableRange',[NaN,1],w.TypeError],['setLiveSeekableRange',[0,Infinity],w.TypeError],['setLiveSeekableRange',[-1,0],w.DOMException,'InvalidStateError'],['setLiveSeekableRange',[2,1],w.DOMException,'InvalidStateError'],['setLiveSeekableRange',[0,1],w.DOMException,'InvalidStateError'],['clearLiveSeekableRange',[],w.DOMException,'InvalidStateError']
])check(label+'/MediaSource/error/'+name+'/'+args.map(String).join(','),()=>throws(C,()=>Reflect.apply(P[name],source,args),errorName));
const setter=Object.getOwnPropertyDescriptor(P,'duration')?.set;
for(const [index,value] of [undefined,NaN,-1,-Infinity,0,-0,1,Infinity].entries())check(label+'/duration/value/'+index,()=>throws(value===undefined||Number.isNaN(value)||value<0?w.TypeError:w.DOMException,()=>setter.call(source,value),value!==undefined&&!Number.isNaN(value)&&value>=0?'InvalidStateError':undefined));
for(const kind of ['addSourceBuffer','endOfStream','duration','setLiveSeekableRange'])check(label+'/conversion/'+kind,()=>{
const order=[],marker={};const first={toString(){order.push('first');throw marker},valueOf(){order.push('first');throw marker}},second={valueOf(){order.push('second');return 1}};
let error;try{if(kind==='duration')setter.call(source,first);else P[kind].call(source,first,second)}catch(caught){error=caught}
return error===marker&&order.join()==='first';
});
const bad=[{},Object.create(P),Object.create(source),new Proxy(source,{})];const revoked=Proxy.revocable(source,{});revoked.revoke();bad.push(revoked.proxy);
for(const [index,receiver] of bad.entries()) {
for(const name of getters)check(label+'/invalid getter/'+index+'/'+name,()=>throws(w.TypeError,()=>Object.getOwnPropertyDescriptor(P,name).get.call(receiver)));
for(const name of ['duration','onsourceopen','onsourceended','onsourceclose'])check(label+'/invalid setter/'+index+'/'+name,()=>{let conversions=0;return throws(w.TypeError,()=>Object.getOwnPropertyDescriptor(P,name).set.call(receiver,{valueOf(){conversions++;throw Error()}}))&&conversions===0});
for(const [name] of methods)check(label+'/invalid method/'+index+'/'+name,()=>{let conversions=0;const argument={toString(){conversions++;throw Error()},valueOf(){conversions++;throw Error()}};return throws(w.TypeError,()=>P[name].call(receiver,argument,argument))&&conversions===0});
}
for(const name of ['onsourceopen','onsourceended','onsourceclose'])check(label+'/handler order/'+name,()=>{
const events=[],event=name.slice(2),handler=function(e){events.push(this===source&&e.target===source?'handler':'bad');return false};
source.addEventListener(event,()=>events.push('first'));source[name]=()=>events.push('old');source.addEventListener(event,()=>events.push('last'));source[name]=handler;
const canceled=source.dispatchEvent(new w.Event(event,{cancelable:true}));source[name]=null;
return canceled===false&&events.join()==='first,handler,last'&&source[name]===null;
});
for(const name of ['onaddsourcebuffer','onremovesourcebuffer'])check(label+'/list handler/'+name,()=>{
const list=source.sourceBuffers,events=[],event=name.slice(2);list.addEventListener(event,()=>events.push('first'));list[name]=()=>events.push('old');list.addEventListener(event,()=>events.push('last'));list[name]=function(e){events.push(this===list&&e.target===list?'handler':'bad');return false};
const result=list.dispatchEvent(new w.Event(event,{cancelable:true}));list[name]=null;return !result&&events.join()==='first,handler,last'&&list[name]===null;
});
for(const name of ['length','onaddsourcebuffer','onremovesourcebuffer'])for(const [index,receiver] of [{},Object.create(source.sourceBuffers ?? null),new Proxy(source.sourceBuffers ?? {},{})].entries())check(label+'/list invalid receiver/'+name+'/'+index,()=>throws(w.TypeError,()=>Object.getOwnPropertyDescriptor(L,name).get.call(receiver)));
for(const index of ['0','4294967294'])check(label+'/list readonly index/'+index,()=>{const list=source.sourceBuffers;return Reflect.set(list,index,{})===false&&!Object.hasOwn(list,index)&&Reflect.defineProperty(list,index,{value:{},configurable:true})===false&&Reflect.deleteProperty(list,index)===true});
for(const key of ['4294967295','01','-1'])check(label+'/list expando/'+key,()=>{const list=source.sourceBuffers;return Reflect.set(list,key,'value')&&list[key]==='value'&&Reflect.deleteProperty(list,key)});
check(label+'/list prevent extensions',()=>Reflect.preventExtensions(source.sourceBuffers)===false&&Object.isExtensible(source.sourceBuffers));
check(label+'/list intrinsic realm',()=>{const other=label==='main'?frame.contentWindow:window;return Object.getOwnPropertyDescriptor(other.MediaSource.prototype,'sourceBuffers').get.call(source) instanceof w.SourceBufferList});
check(label+'/list intrinsic survives author global',()=>{const intrinsic=w.SourceBufferList;try{Object.defineProperty(w,'SourceBufferList',{get(){throw Error('author constructor getter')},configurable:true});const list=new w.MediaSource().sourceBuffers;return list instanceof intrinsic}finally{Object.defineProperty(w,'SourceBufferList',{value:intrinsic,writable:true,configurable:true})}});
diagnostics.push({realm:label,sourceTag:Object.prototype.toString.call(source),state:source.readyState,listLength:source.sourceBuffers?.length});
}
} finally {frame.remove();popup?.close();}
globalThis.__uiEventResults={complete:true,checks,diagnostics,passed:checks.filter(row=>row.passed).length,total:checks.length};return checks.every(row=>row.passed);
})()
@@ -0,0 +1,150 @@
use super::*;
#[test]
fn media_source_closed_frontend_validates_receivers_realms_and_conversion_order() {
for url in ["https://mse-closed.test/", "http://mse-closed.test/"] {
let mut vm = new_storage_page_task_executor_test_vm(url);
vm.eval(include_str!("media_source_closed.js")).unwrap();
assert_eq!(vm.eval("__uiEventResults.complete").unwrap(), "true");
assert_eq!(
vm.eval("JSON.stringify(__uiEventResults.checks.filter(row => !row.passed))")
.unwrap(),
"[]"
);
assert_eq!(vm.eval("__uiEventResults.total").unwrap(), "477");
}
}
fn native_source_buffer_list_vm() -> crate::runtime::PageVmTaskExecutorTestHarness {
use moli_webapi_declare::WebApiObject;
#[derive(WebApiObject)]
#[webapi(interface = crate::web_api_interfaces::SourceBuffer, require_prototype)]
struct TestSourceBuffer {}
let mut vm = new_storage_page_task_executor_test_vm("https://mse-list.test/");
vm.eval("globalThis.source = new MediaSource(); document.body.innerHTML = '<iframe></iframe>'")
.unwrap();
let context_ptr = &vm.page_default_runtime.context as *const _;
vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| {
let global = scope.get_current_context().global(scope);
let owner = v8::Local::<v8::Object>::try_from(
global
.get(scope, crate::util::v8str(scope, "source").into())
.unwrap(),
)
.unwrap();
let first = TestSourceBuffer::new().bind(scope).unwrap();
let second = TestSourceBuffer::new().bind(scope).unwrap();
let backing = v8::Array::new_with_elements(scope, &[first.into(), second.into()]);
let list = crate::context_bootstrap::media_source::source_buffer_list::build(
scope, owner, backing,
);
assert!(list.is_proxy(), "native lists use a registered Proxy");
assert!(crate::web_api_interfaces::SourceBufferList::is_instance(
scope, list
));
for (name, value) in [
("backing", backing.into()),
("buffers", list.into()),
("nativeBuffers", list.into()),
("first", first.into()),
("second", second.into()),
] {
assert_eq!(
global.create_data_property(scope, crate::util::v8str(scope, name).into(), value),
Some(true)
);
}
Ok(())
})
.unwrap();
vm
}
#[test]
fn media_source_buffer_list_indexes_and_array_iterator_observe_native_backing() {
let mut vm = native_source_buffer_list_vm();
assert_eq!(vm.eval(r#"(() => {
const assert=(ok,name)=>{if(!ok)throw Error(name)};
assert(buffers.length===2&&buffers[0]===first&&buffers[1]===second,'native indexed identity');
assert(Object.keys(buffers).join()==='0,1','supported indices are enumerable');
const d=Object.getOwnPropertyDescriptor(buffers,'0');
assert(d.value===first&&d.writable===false&&d.enumerable&&d.configurable,'indexed descriptor');
for(const key of ['0','1','2','4294967294']) {
assert(Reflect.set(buffers,key,{})===false,'readonly array index set '+key);
assert(Reflect.defineProperty(buffers,key,{value:first,configurable:true})===false,'readonly define '+key);
assert(Reflect.defineProperty(buffers,key,{get(){throw Error('getter')}})===false,'readonly accessor define '+key);
}
assert(Reflect.deleteProperty(buffers,'0')===false&&Reflect.deleteProperty(buffers,'2')===true,'delete supported and unsupported indices');
const iterator=buffers[Symbol.iterator]();
assert(iterator.next().value===first,'ArrayProtoValues first member');
backing[1]=first;backing.push(second);
assert(iterator.next().value===first&&iterator.next().value===second&&iterator.next().done,'iterator observes the live backing');
backing.length=0;
assert(buffers.length===0&&!Object.hasOwn(buffers,0)&&Object.keys(buffers).length===0,'shrinking removes supported indices');
const p=Object.create(SourceBufferList.prototype);
Object.defineProperty(p,'0',{get(){return 'prototype'},set(value){this.expando=value},configurable:true});
Object.setPrototypeOf(buffers,p);
assert(buffers[0]==='prototype'&&Reflect.set(buffers,'0','setter')&&buffers.expando==='setter','unsupported index uses the prototype');
backing.push(first);
assert(buffers[0]===first&&!Reflect.set(buffers,'0',second),'supported index masks the prototype');
const other={};assert(Reflect.set(buffers,'4','ordinary',other)&&other[4]==='ordinary','ordinary set receiver');
const reflectSet=Reflect.set;
try {
Reflect.set=()=>{throw Error('author Reflect.set')};
assert(reflectSet(buffers,'4','ignored')===false,'captured intrinsic survives author replacement');
} finally {Reflect.set=reflectSet}
Object.setPrototypeOf(buffers,new Proxy({}, {set(target,key,value,receiver){
assert(key==='4'&&value==='primitive'&&receiver===42,'prototype Proxy receives the original arguments');return true;
}}));
assert(Reflect.set(buffers,'4','primitive',42),'prototype Proxy with a primitive receiver');
Object.setPrototypeOf(buffers,null);
assert(Reflect.set(buffers,'4',1)===false&&Reflect.set(buffers,'4',1,42)===false,'null prototype readonly creation');
assert(Reflect.preventExtensions(buffers)===false&&Object.isExtensible(buffers),'legacy object remains extensible');
return true;
})()"#).unwrap(), "true");
}
#[test]
fn media_source_buffer_list_native_proxies_and_foreign_source_buffers_use_brands() {
let mut vm = native_source_buffer_list_vm();
assert_eq!(vm.eval(r#"(() => {
const assert=(ok,name)=>{if(!ok)throw Error(name)};
const other=document.querySelector('iframe').contentWindow;
const log=[],controller=new AbortController();
const listener=function(e){assert(this===buffers&&e.target===buffers,'native wrapper event identity');log.push('listener')};
buffers.addEventListener('custom',listener,{once:true});
buffers.dispatchEvent(new Event('custom'));buffers.dispatchEvent(new Event('custom'));
assert(log.join()==='listener','once across the registered native Proxy');
buffers.addEventListener('custom',listener,{signal:controller.signal});controller.abort();
buffers.dispatchEvent(new Event('custom'));assert(log.length===1,'aborting removes the native proxy listener');
buffers.addEventListener('custom',listener);buffers.removeEventListener('custom',listener);
buffers.dispatchEvent(new Event('custom'));assert(log.length===1,'remove uses the same backing registry');
try {
Object.prototype.get=()=>{throw Error('polluted get trap')};
Object.prototype.ownKeys=()=>{throw Error('polluted ownKeys trap')};
assert(buffers[0]===first&&Object.keys(buffers).join()==='0,1','null prototype native handler');
} finally {delete Object.prototype.get;delete Object.prototype.ownKeys}
for(const w of [window,other]) {
const getLength=Object.getOwnPropertyDescriptor(w.SourceBufferList.prototype,'length').get;
assert(getLength.call(nativeBuffers)===2,'registered native Proxy');
let traps=0;
const proxy=new Proxy(nativeBuffers,{get(){traps++;throw Error('author trap')}});
const revoked=Proxy.revocable(buffers,{});revoked.revoke();
for(const receiver of [{},Object.create(buffers),proxy,revoked.proxy]) {
let error;try{getLength.call(receiver)}catch(caught){error=caught}
assert(error instanceof w.TypeError&&traps===0,'callee realm brand check without traps');
}
let error;try{w.MediaSource.prototype.removeSourceBuffer.call(source,first)}catch(caught){error=caught}
assert(error instanceof w.DOMException&&error.name==='NotFoundError','genuine buffer missing from closed source');
for(const argument of [Object.create(first),new Proxy(first,{})]) {
let error;try{w.MediaSource.prototype.removeSourceBuffer.call(source,argument)}catch(caught){error=caught}
assert(error instanceof w.TypeError,'buffer argument uses native interface conversion');
}
}
Object.setPrototypeOf(buffers,null);
assert(Object.getOwnPropertyDescriptor(other.SourceBufferList.prototype,'length').get.call(nativeBuffers)===2,'brand survives prototype changes');
return true;
})()"#).unwrap(), "true");
}
@@ -2308,6 +2308,7 @@ mod media_key_status_map;
mod media_metadata;
mod media_owner_playback_interfaces;
mod media_recorder;
mod media_source_closed;
mod media_streams;
mod midi_frontend;
mod midi_owner_interfaces;
@@ -207,6 +207,14 @@ const DIRECT_V8_CALL_ALLOWLIST: &[AllowedDirectCallFile] = &[
2,
DirectCallOwner::NativeForwardingOrScript,
),
// SourceBufferList's native Proxy forwards [[Set]] to Reflect.set captured
// before author script. Its private handler retains only that intrinsic;
// this is native forwarding, not a page-supplied Web IDL callback.
allowed(
"context_bootstrap/media_source/source_buffer_list.rs",
1,
DirectCallOwner::NativeForwardingOrScript,
),
allowed(
"context_bootstrap/runtime_state.rs",
1,
+23 -7
View File
@@ -9,6 +9,7 @@ use moli_v8_util::{
};
const REFLECT_CONSTRUCT: &str = "moli.webidl.reflectConstruct";
const REFLECT_SET: &str = "moli.webidl.reflectSet";
const INVOCATION: &str = "moli.webidl.constructorInvocation";
const NEW_TARGET: &str = "moli.webidl.originalNewTarget";
const SELECTED_PROTOTYPE: &str = "moli.webidl.selectedConstructorPrototype";
@@ -16,17 +17,32 @@ const SELECTED_PROTOTYPE: &str = "moli.webidl.selectedConstructorPrototype";
/// Capture before author script runs, including before lazy interface access.
pub fn capture_web_api_constructor_intrinsics<'s>(scope: &mut v8::PinScope<'s, '_>) -> Option<()> {
let global = scope.get_current_context().global(scope);
if get_private_value(scope, global, REFLECT_CONSTRUCT).is_some() {
if get_private_value(scope, global, REFLECT_CONSTRUCT).is_some()
&& get_private_value(scope, global, REFLECT_SET).is_some()
{
return Some(());
}
let reflect = global.get(scope, v8str(scope, "Reflect").into())?;
let reflect = v8::Local::<v8::Object>::try_from(reflect).ok()?;
let construct = reflect.get(scope, v8str(scope, "construct").into())?;
let construct = v8::Local::<v8::Function>::try_from(construct).ok()?;
let key = private_key(scope, REFLECT_CONSTRUCT)?;
global
.set_private(scope, key, construct.into())?
.then_some(())
for (name, slot) in [("construct", REFLECT_CONSTRUCT), ("set", REFLECT_SET)] {
let function = reflect.get(scope, v8str(scope, name).into())?;
let function = v8::Local::<v8::Function>::try_from(function).ok()?;
let key = private_key(scope, slot)?;
global
.set_private(scope, key, function.into())?
.then_some(())?;
}
Some(())
}
/// The realm's captured Reflect.set, including its original receiver semantics.
/// Native exotic objects must not consult a possibly replaced author global.
pub fn web_api_reflect_set<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> Option<v8::Local<'s, v8::Function>> {
let global = scope.get_current_context().global(scope);
get_private_value(scope, global, REFLECT_SET)
.and_then(|value| v8::Local::<v8::Function>::try_from(value).ok())
}
/// Keep the native function as the intrinsic constructor; publish this entry
+5
View File
@@ -64,6 +64,10 @@ pub trait WebApiObjectDeclaration<'s> {
#[derive(WebApiObject)]
#[webapi(plain, data_properties, enumerable, no_dynamic_constructor)]
pub struct DataPropertyDescriptorDeclaration<'scope> {
// V8 converts this internal record with ToPropertyDescriptor, which also
// reads inherited get/set fields. Author Object.prototype must not enter it.
#[webapi(prototype, value = v8::null(scope))]
prototype: (),
value: v8::Local<'scope, v8::Value>,
enumerable: bool,
configurable: bool,
@@ -73,6 +77,7 @@ pub struct DataPropertyDescriptorDeclaration<'scope> {
impl<'scope> DataPropertyDescriptorDeclaration<'scope> {
pub fn new(value: v8::Local<'scope, v8::Value>, writable: bool, enumerable: bool) -> Self {
Self {
prototype: (),
value,
enumerable,
configurable: true,
+1 -1
View File
@@ -253,7 +253,7 @@ pub mod __private;
pub use constructor::{
capture_web_api_constructor_intrinsics, initialize_web_api_constructor_receiver,
web_api_constructor_with_deferred_prototype,
web_api_constructor_with_deferred_prototype, web_api_reflect_set,
};
pub use interface::WebApiInterfaceDescriptor;
pub use moli_webapi_declare_derive::{WebApiFunctionTemplate, WebApiObject};
+6 -5
View File
@@ -68,11 +68,12 @@ where
let prototype = prototype
.to_v8_value(scope)
.ok_or_else(|| BindError::new("failed to convert declared prototype"))?;
let prototype = v8::Local::<v8::Object>::try_from(prototype)
.map_err(|_| BindError::new("declared prototype must be an object"))?;
let installed = object
.set_prototype(scope, prototype.into())
.unwrap_or(false);
if !prototype.is_object() && !prototype.is_null() {
return Err(BindError::new(
"declared prototype must be an object or null",
));
}
let installed = object.set_prototype(scope, prototype).unwrap_or(false);
if !installed {
return Err(BindError::new("failed to set declared prototype"));
}
+52
View File
@@ -3094,3 +3094,55 @@ mod interfaces {
pub(super) SampleTemplate;
}
}
#[test]
fn null_prototype_declarations_ignore_object_prototype_pollution() {
#[derive(WebApiObject)]
#[webapi(plain)]
struct NullPrototype {
#[webapi(prototype, value = v8::null(scope))]
prototype: (),
}
ensure_v8();
let mut isolate = v8::Isolate::new(v8::CreateParams::default());
let scope = pin!(v8::HandleScope::new(&mut isolate));
let scope = &mut scope.init();
let context = v8::Context::new(scope, Default::default());
let scope = &mut v8::ContextScope::new(scope, context);
let object = NullPrototype::new().bind(scope).unwrap();
assert!(object.get_prototype(scope).unwrap().is_null());
let invalid = v8::Integer::new(scope, 1);
assert!(moli_webapi_declare::set_declared_prototype(scope, object, &invalid).is_err());
assert!(object.get_prototype(scope).unwrap().is_null());
run_script(
scope,
"Object.prototype.get = () => { throw Error('inherited getter') };",
);
let value = v8::Integer::new(scope, 7);
let descriptor =
moli_webapi_declare::DataPropertyDescriptorDeclaration::new(value.into(), false, true)
.bind(scope)
.unwrap();
assert!(descriptor.get_prototype(scope).unwrap().is_null());
let global = context.global(scope);
assert_eq!(
global.create_data_property(
scope,
v8::String::new(scope, "descriptor").unwrap().into(),
descriptor.into()
),
Some(true)
);
assert!(
run_script(
scope,
r#"(() => {
const target={};Object.defineProperty(target,'value',descriptor);
const d=Object.getOwnPropertyDescriptor(target,'value');
return target.value===7 && d.writable===false && d.enumerable && d.configurable;
})()"#
)
.is_true()
);
}