mirror of
https://github.com/lexmount/moli.git
synced 2026-10-04 16:00:47 +00:00
feat(webrtc): implement RTCIceCandidate construction and serialization
This commit is contained in:
@@ -55,7 +55,7 @@ use super::super::{
|
||||
build_audio_context_constructor_template, build_audio_worklet_node_constructor_template,
|
||||
offline_audio_context_constructor_callback,
|
||||
},
|
||||
webrtc::rtc_peer_connection_constructor_callback,
|
||||
webrtc::{rtc_ice_candidate_constructor_callback, rtc_peer_connection_constructor_callback},
|
||||
websocket::{
|
||||
websocket_constructor_callback, websocket_error_constructor_callback,
|
||||
websocket_stream_constructor_callback,
|
||||
@@ -424,6 +424,11 @@ pub(in crate::context_bootstrap) fn build_constructor_template<'s>(
|
||||
.length(0)
|
||||
.build(scope)
|
||||
}
|
||||
ConstructorKind::RtcIceCandidate => {
|
||||
v8::FunctionTemplate::builder(rtc_ice_candidate_constructor_callback)
|
||||
.length(0)
|
||||
.build(scope)
|
||||
}
|
||||
ConstructorKind::Navigator
|
||||
| ConstructorKind::WorkerNavigator
|
||||
| ConstructorKind::Permissions
|
||||
|
||||
@@ -1826,6 +1826,7 @@ pub(crate) fn finish_context_bootstrap(
|
||||
("WebSocketError", "WebSocketError"),
|
||||
("WebSocketStream", "WebSocketStream"),
|
||||
("RTCPeerConnection", "RTCPeerConnection"),
|
||||
("RTCIceCandidate", "RTCIceCandidate"),
|
||||
("RTCRtpReceiver", "RTCRtpReceiver"),
|
||||
("RTCDataChannel", "RTCDataChannel"),
|
||||
("Blob", "Blob"),
|
||||
|
||||
@@ -1026,6 +1026,11 @@ const CONSTRUCTOR_SPECS_AFTER_STREAMS: &[ConstructorSpec] = &[
|
||||
parent: Some("EventTarget"),
|
||||
kind: ConstructorKind::RtcPeerConnection,
|
||||
},
|
||||
ConstructorSpec {
|
||||
name: "RTCIceCandidate",
|
||||
parent: None,
|
||||
kind: ConstructorKind::RtcIceCandidate,
|
||||
},
|
||||
ConstructorSpec {
|
||||
name: "RTCRtpReceiver",
|
||||
parent: None,
|
||||
|
||||
@@ -84,6 +84,7 @@ pub(in crate::context_bootstrap) enum ConstructorKind {
|
||||
SharedWorker,
|
||||
WebSocket,
|
||||
RtcPeerConnection,
|
||||
RtcIceCandidate,
|
||||
Navigator,
|
||||
WorkerNavigator,
|
||||
Permissions,
|
||||
|
||||
@@ -4,6 +4,10 @@ use crate::util::{
|
||||
};
|
||||
use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject};
|
||||
|
||||
mod ice_candidate;
|
||||
mod ice_candidate_parser;
|
||||
pub(in crate::context_bootstrap) use ice_candidate::rtc_ice_candidate_constructor_callback;
|
||||
|
||||
const RTC_PEER_CONNECTION_BRAND_SLOT: &str = "__moliRtcPeerConnectionBrand";
|
||||
const RTC_PEER_CONNECTION_CONFIGURATION_SLOT: &str = "__moliRtcPeerConnectionConfiguration";
|
||||
const RTC_PEER_CONNECTION_SIGNALING_STATE_SLOT: &str = "__moliRtcPeerConnectionSignalingState";
|
||||
@@ -202,6 +206,9 @@ pub(in crate::context_bootstrap) fn install_webrtc_template_bindings<'s>(
|
||||
) {
|
||||
let prototype = template.prototype_template(scope);
|
||||
match interface_name {
|
||||
"RTCIceCandidate" => {
|
||||
ice_candidate::install_ice_candidate_template_bindings(scope, template)
|
||||
}
|
||||
"RTCPeerConnection" => {
|
||||
RtcPeerConnectionPrototypeDeclaration::initialize_prototype_template(scope, prototype);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
use super::ice_candidate_parser::parse_ice_candidate;
|
||||
use crate::{
|
||||
util::{
|
||||
apply_webidl_constructor_prototype_fallback, callback_data_index_value, get_private_value,
|
||||
throw_type_error, v8str,
|
||||
},
|
||||
webidl::{self, WebIdlConverter},
|
||||
};
|
||||
use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject};
|
||||
|
||||
const CANDIDATE_VALUES_SLOT: &str = "__moliRtcIceCandidateValues";
|
||||
|
||||
#[derive(WebApiObject)]
|
||||
#[webapi(interface = "RTCIceCandidate")]
|
||||
struct IceCandidateObjectDeclaration<'scope> {
|
||||
#[webapi(slot = CANDIDATE_VALUES_SLOT)]
|
||||
values: v8::Local<'scope, v8::Array>,
|
||||
}
|
||||
|
||||
#[derive(WebApiFunctionTemplate)]
|
||||
#[webapi(name = "RTCIceCandidate", enumerable)]
|
||||
struct IceCandidatePrototypeDeclaration {
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 0))]
|
||||
candidate: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 1))]
|
||||
sdp_mid: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 2))]
|
||||
sdp_m_line_index: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 3))]
|
||||
foundation: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 4))]
|
||||
component: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 5))]
|
||||
priority: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 6))]
|
||||
address: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 7))]
|
||||
protocol: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 8))]
|
||||
port: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 9))]
|
||||
r#type: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 10))]
|
||||
tcp_type: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 11))]
|
||||
related_address: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 12))]
|
||||
related_port: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 13))]
|
||||
username_fragment: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 14))]
|
||||
relay_protocol: (),
|
||||
#[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 15))]
|
||||
url: (),
|
||||
#[webapi(method = "toJSON", length = 0, callback = candidate_to_json_callback)]
|
||||
to_json: (),
|
||||
}
|
||||
|
||||
#[derive(WebApiObject)]
|
||||
#[webapi(interface = "Object", data_properties, enumerable)]
|
||||
struct IceCandidateJsonDeclaration<'scope> {
|
||||
candidate: v8::Local<'scope, v8::Value>,
|
||||
sdp_mid: v8::Local<'scope, v8::Value>,
|
||||
sdp_m_line_index: v8::Local<'scope, v8::Value>,
|
||||
username_fragment: v8::Local<'scope, v8::Value>,
|
||||
}
|
||||
|
||||
pub(super) fn install_ice_candidate_template_bindings<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_, ()>,
|
||||
template: v8::Local<'s, v8::FunctionTemplate>,
|
||||
) {
|
||||
IceCandidatePrototypeDeclaration::initialize_prototype_template(
|
||||
scope,
|
||||
template.prototype_template(scope),
|
||||
);
|
||||
}
|
||||
|
||||
pub(in crate::context_bootstrap) fn rtc_ice_candidate_constructor_callback<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
args: v8::FunctionCallbackArguments<'s>,
|
||||
mut rv: v8::ReturnValue<'s, v8::Value>,
|
||||
) {
|
||||
if !args.is_construct_call() {
|
||||
throw_type_error(scope, "RTCIceCandidate constructor requires 'new'.");
|
||||
return;
|
||||
}
|
||||
let dictionary = match webidl::dictionary_value(
|
||||
args.get(0),
|
||||
webidl::Context::argument("RTCIceCandidate", 1),
|
||||
) {
|
||||
Ok(dictionary) => dictionary,
|
||||
Err(error) => {
|
||||
webidl::throw_error(scope, &error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(mut values) = parse_candidate_init(scope, dictionary) else {
|
||||
return;
|
||||
};
|
||||
if values[1].is_null() && values[2].is_null() {
|
||||
throw_type_error(scope, "RTCIceCandidate requires sdpMid or sdpMLineIndex.");
|
||||
return;
|
||||
}
|
||||
let candidate =
|
||||
v8::Local::<v8::String>::try_from(values[0]).expect("converted candidate string");
|
||||
let candidate = candidate.to_rust_string_lossy(scope);
|
||||
if let Some(parsed) = parse_ice_candidate(&candidate) {
|
||||
values[3] = v8::String::new(scope, parsed.foundation).unwrap().into();
|
||||
values[4] = v8str(scope, parsed.component).into();
|
||||
values[5] = v8::Integer::new_from_unsigned(scope, parsed.priority).into();
|
||||
values[6] = v8::String::new(scope, parsed.address).unwrap().into();
|
||||
values[7] = v8str(scope, parsed.protocol).into();
|
||||
values[8] = v8::Integer::new_from_unsigned(scope, parsed.port.into()).into();
|
||||
values[9] = v8str(scope, parsed.kind).into();
|
||||
if let Some(tcp_type) = parsed.tcp_type {
|
||||
values[10] = v8str(scope, tcp_type).into();
|
||||
}
|
||||
if let Some(address) = parsed.related_address {
|
||||
values[11] = v8::String::new(scope, address).unwrap().into();
|
||||
}
|
||||
if let Some(port) = parsed.related_port {
|
||||
values[12] = v8::Integer::new_from_unsigned(scope, port.into()).into();
|
||||
}
|
||||
}
|
||||
let values = v8::Array::new_with_elements(scope, &values);
|
||||
if IceCandidateObjectDeclaration::new(values)
|
||||
.initialize(scope, args.this())
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
apply_webidl_constructor_prototype_fallback(
|
||||
scope,
|
||||
args.this(),
|
||||
args.new_target(),
|
||||
"RTCIceCandidate",
|
||||
);
|
||||
rv.set(args.this().into());
|
||||
}
|
||||
|
||||
fn parse_candidate_init<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
dictionary: Option<v8::Local<'s, v8::Object>>,
|
||||
) -> Option<[v8::Local<'s, v8::Value>; 16]> {
|
||||
let mut values = [v8::null(scope).into(); 16];
|
||||
values[0] = v8str(scope, "").into();
|
||||
let Some(dictionary) = dictionary else {
|
||||
return Some(values);
|
||||
};
|
||||
// Convert base dictionary members in lexical order before derived members.
|
||||
// Keep DOMStrings in V8, preserving lone UTF-16 surrogates; only `url` is a USVString.
|
||||
for (name, index) in [
|
||||
("candidate", 0),
|
||||
("sdpMLineIndex", 2),
|
||||
("sdpMid", 1),
|
||||
("usernameFragment", 13),
|
||||
("relayProtocol", 14),
|
||||
("url", 15),
|
||||
] {
|
||||
let raw = dictionary.get(scope, v8str(scope, name).into())?;
|
||||
if raw.is_undefined() || (index != 0 && raw.is_null()) {
|
||||
continue;
|
||||
}
|
||||
let context = webidl::Context::member("RTCLocalIceCandidateInit", name);
|
||||
if index == 2 {
|
||||
let value = match webidl::UnsignedShort::convert(scope, raw, context, &()) {
|
||||
Ok(value) => value.0,
|
||||
Err(error) => {
|
||||
webidl::throw_error(scope, &error);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
values[index] = v8::Integer::new_from_unsigned(scope, value.into()).into();
|
||||
} else if index == 15 {
|
||||
let value = match webidl::UsvString::convert(scope, raw, context, &Default::default()) {
|
||||
Ok(value) => value.0,
|
||||
Err(error) => {
|
||||
webidl::throw_error(scope, &error);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
values[index] = v8::String::new(scope, &value)?.into();
|
||||
} else {
|
||||
if raw.is_symbol() {
|
||||
throw_type_error(scope, "Cannot convert a Symbol to a DOMString.");
|
||||
return None;
|
||||
}
|
||||
let value = raw.to_string(scope)?;
|
||||
if index == 14
|
||||
&& !matches!(
|
||||
value.to_rust_string_lossy(scope).as_str(),
|
||||
"udp" | "tcp" | "tls"
|
||||
)
|
||||
{
|
||||
throw_type_error(scope, "Invalid RTCIceServerTransportProtocol.");
|
||||
return None;
|
||||
}
|
||||
values[index] = value.into();
|
||||
}
|
||||
}
|
||||
Some(values)
|
||||
}
|
||||
|
||||
fn candidate_values<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
receiver: v8::Local<'s, v8::Object>,
|
||||
) -> Option<v8::Local<'s, v8::Array>> {
|
||||
let values = get_private_value(scope, receiver, CANDIDATE_VALUES_SLOT)
|
||||
.and_then(|value| v8::Local::<v8::Array>::try_from(value).ok());
|
||||
if values.is_none() {
|
||||
throw_type_error(scope, "Illegal invocation");
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn candidate_attribute_getter<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
args: v8::FunctionCallbackArguments<'s>,
|
||||
mut rv: v8::ReturnValue<'s, v8::Value>,
|
||||
) {
|
||||
let Some(values) = candidate_values(scope, args.this()) else {
|
||||
return;
|
||||
};
|
||||
let Some(index) = args.data().uint32_value(scope) else {
|
||||
return;
|
||||
};
|
||||
if let Some(value) = values.get_index(scope, index) {
|
||||
rv.set(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_to_json_callback<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
args: v8::FunctionCallbackArguments<'s>,
|
||||
mut rv: v8::ReturnValue<'s, v8::Value>,
|
||||
) {
|
||||
let Some(values) = candidate_values(scope, args.this()) else {
|
||||
return;
|
||||
};
|
||||
let Some(candidate) = values.get_index(scope, 0) else {
|
||||
return;
|
||||
};
|
||||
let Some(mid) = values.get_index(scope, 1) else {
|
||||
return;
|
||||
};
|
||||
let Some(line) = values.get_index(scope, 2) else {
|
||||
return;
|
||||
};
|
||||
let Some(fragment) = values.get_index(scope, 13) else {
|
||||
return;
|
||||
};
|
||||
if let Ok(result) = IceCandidateJsonDeclaration::new(candidate, mid, line, fragment).bind(scope)
|
||||
{
|
||||
rv.set(result.into());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) struct ParsedIceCandidate<'a> {
|
||||
pub(super) foundation: &'a str,
|
||||
pub(super) component: &'static str,
|
||||
pub(super) priority: u32,
|
||||
pub(super) address: &'a str,
|
||||
pub(super) protocol: &'static str,
|
||||
pub(super) port: u16,
|
||||
pub(super) kind: &'static str,
|
||||
pub(super) tcp_type: Option<&'static str>,
|
||||
pub(super) related_address: Option<&'a str>,
|
||||
pub(super) related_port: Option<u16>,
|
||||
}
|
||||
|
||||
// RFC 5245 section 15.1 and the TCP extension in RFC 6544 section 4.5.
|
||||
// Return no derived fields if parsing or an attribute's value is invalid;
|
||||
// RTCIceCandidate itself still retains the original candidate string.
|
||||
pub(super) fn parse_ice_candidate(input: &str) -> Option<ParsedIceCandidate<'_>> {
|
||||
let fields: Vec<_> = input.strip_prefix("candidate:")?.split(' ').collect();
|
||||
if fields.len() < 8 || fields.iter().any(|field| field.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let foundation = fields[0];
|
||||
if foundation.len() > 32
|
||||
|| !foundation
|
||||
.bytes()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, b'+' | b'/'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let component = match decimal(fields[1], 5)? {
|
||||
1 => "rtp",
|
||||
2 => "rtcp",
|
||||
_ => return None,
|
||||
};
|
||||
let protocol = if fields[2].eq_ignore_ascii_case("udp") {
|
||||
"udp"
|
||||
} else if fields[2].eq_ignore_ascii_case("tcp") {
|
||||
"tcp"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let priority = decimal(fields[3], 10)?;
|
||||
if !(1..=i32::MAX as u32).contains(&priority) || !address_is_valid(fields[4]) {
|
||||
return None;
|
||||
}
|
||||
let port = u16::try_from(decimal(fields[5], usize::MAX)?).ok()?;
|
||||
if !fields[6].eq_ignore_ascii_case("typ") {
|
||||
return None;
|
||||
}
|
||||
let kind = ["host", "srflx", "prflx", "relay"]
|
||||
.into_iter()
|
||||
.find(|kind| fields[7].eq_ignore_ascii_case(kind))?;
|
||||
let mut candidate = ParsedIceCandidate {
|
||||
foundation,
|
||||
component,
|
||||
priority,
|
||||
address: fields[4],
|
||||
protocol,
|
||||
port,
|
||||
kind,
|
||||
tcp_type: None,
|
||||
related_address: None,
|
||||
related_port: None,
|
||||
};
|
||||
let extensions = fields[8..].chunks_exact(2);
|
||||
if !extensions.remainder().is_empty() {
|
||||
return None;
|
||||
}
|
||||
for pair in extensions {
|
||||
if pair
|
||||
.iter()
|
||||
.any(|field| field.bytes().any(|ch| ch.is_ascii_control()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if pair[0].eq_ignore_ascii_case("raddr") {
|
||||
if !address_is_valid(pair[1]) || candidate.related_address.replace(pair[1]).is_some() {
|
||||
return None;
|
||||
}
|
||||
} else if pair[0].eq_ignore_ascii_case("rport") {
|
||||
let port = u16::try_from(decimal(pair[1], usize::MAX)?).ok()?;
|
||||
if candidate.related_port.replace(port).is_some() {
|
||||
return None;
|
||||
}
|
||||
} else if pair[0].eq_ignore_ascii_case("tcptype") && protocol == "tcp" {
|
||||
let tcp_type = ["active", "passive", "so"]
|
||||
.into_iter()
|
||||
.find(|kind| pair[1].eq_ignore_ascii_case(kind))?;
|
||||
if candidate.tcp_type.replace(tcp_type).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if protocol == "tcp" && candidate.tcp_type.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(candidate)
|
||||
}
|
||||
|
||||
fn decimal(input: &str, max_digits: usize) -> Option<u32> {
|
||||
if input.is_empty() || input.len() > max_digits || !input.bytes().all(|ch| ch.is_ascii_digit())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
input.parse().ok()
|
||||
}
|
||||
|
||||
fn address_is_valid(input: &str) -> bool {
|
||||
if input.parse::<IpAddr>().is_ok() {
|
||||
return true;
|
||||
}
|
||||
let hostname = input.strip_suffix('.').unwrap_or(input);
|
||||
!hostname.is_empty()
|
||||
&& hostname.len() <= 253
|
||||
&& hostname.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& label.len() <= 63
|
||||
&& label.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
|
||||
&& label
|
||||
.bytes()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'-')
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ice_candidate_parser_handles_udp_tcp_ipv6_and_mdns() {
|
||||
let udp = parse_ice_candidate(
|
||||
"candidate:ab+/12 1 UDP 2113937151 2001:db8::1 65535 typ host generation 0 ufrag test",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(udp.foundation, "ab+/12");
|
||||
assert_eq!(udp.component, "rtp");
|
||||
assert_eq!(udp.protocol, "udp");
|
||||
assert_eq!(udp.priority, 2113937151);
|
||||
assert_eq!(udp.address, "2001:db8::1");
|
||||
assert_eq!(udp.port, 65535);
|
||||
assert_eq!(udp.tcp_type, None);
|
||||
let tcp = parse_ice_candidate("candidate:123 2 tcp 123456 foo.local 9 typ srflx raddr www.example.com rport 22222 tcptype active").unwrap();
|
||||
assert_eq!(tcp.component, "rtcp");
|
||||
assert_eq!(tcp.tcp_type, Some("active"));
|
||||
assert_eq!(tcp.related_address, Some("www.example.com"));
|
||||
assert_eq!(tcp.related_port, Some(22222));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ice_candidate_parser_rejects_invalid_fields_without_partial_results() {
|
||||
for invalid in [
|
||||
"",
|
||||
"arbitrary candidate",
|
||||
"candidate:",
|
||||
"candidate:a 1 udp 0 127.0.0.1 9 typ host",
|
||||
"candidate:a 1 udp 2147483648 127.0.0.1 9 typ host",
|
||||
"candidate:a 1 udp 1 127.0.0.1 65536 typ host",
|
||||
"candidate:a 1 udp 1 127.0.0.1 -1 typ host",
|
||||
"candidate:a 3 udp 1 127.0.0.1 9 typ host",
|
||||
"candidate:a 1 sctp 1 127.0.0.1 9 typ host",
|
||||
"candidate:a 1 udp 1 127.0.0.1 9 typ unknown",
|
||||
"candidate:a 1 udp 1 [::1] 9 typ host",
|
||||
"candidate:a 1 udp 1 -invalid.local 9 typ host",
|
||||
"candidate:a 1 tcp 1 127.0.0.1 9 typ host",
|
||||
"candidate:a 1 tcp 1 127.0.0.1 9 typ host tcptype unknown",
|
||||
"candidate:a 1 udp 1 127.0.0.1 9 typ host rport 65536",
|
||||
"candidate:a 1 udp 1 127.0.0.1 9 typ host generation",
|
||||
"candidate:a 1 udp 1 127.0.0.1 9 typ host\r\n",
|
||||
"candidate:a 1 udp 1 127.0.0.1 9 typ host x y\n",
|
||||
] {
|
||||
assert!(
|
||||
parse_ice_candidate(invalid).is_none(),
|
||||
"accepted {invalid:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rtc_ice_candidate_has_branded_readonly_attributes_and_four_json_members() {
|
||||
let mut vm = new_storage_test_vm("https://ice-candidate.test/");
|
||||
let result = vm.eval(r#"
|
||||
(() => {
|
||||
const candidate = new RTCIceCandidate({sdpMid: 'audio', relayProtocol: 'tls', url: 'turn:example.com'});
|
||||
const names = ['candidate', 'sdpMid', 'sdpMLineIndex', 'foundation', 'component',
|
||||
'priority', 'address', 'protocol', 'port', 'type', 'tcpType', 'relatedAddress',
|
||||
'relatedPort', 'usernameFragment', 'relayProtocol', 'url'];
|
||||
const rejects = callback => {
|
||||
try { callback(); return false; } catch (error) { return error instanceof TypeError; }
|
||||
};
|
||||
const checks = [
|
||||
RTCIceCandidate.length === 0, candidate instanceof RTCIceCandidate,
|
||||
Object.prototype.toString.call(candidate) === '[object RTCIceCandidate]',
|
||||
Object.getOwnPropertyNames(candidate).length === 0,
|
||||
candidate.toJSON.length === 0,
|
||||
rejects(() => RTCIceCandidate({sdpMid: 'audio'})),
|
||||
rejects(() => new RTCIceCandidate()), rejects(() => new RTCIceCandidate(null)),
|
||||
rejects(() => new RTCIceCandidate({})), rejects(() => new RTCIceCandidate(true)),
|
||||
rejects(() => candidate.toJSON.call(Object.create(candidate)))
|
||||
];
|
||||
for (const name of names) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(RTCIceCandidate.prototype, name);
|
||||
if (!descriptor) return 'missing:' + name;
|
||||
checks.push(descriptor.get.name === 'get ' + name, descriptor.get.length === 0,
|
||||
descriptor.set === undefined, descriptor.enumerable, descriptor.configurable);
|
||||
const value = candidate[name];
|
||||
candidate[name] = 'changed';
|
||||
checks.push(candidate[name] === value, rejects(() => { 'use strict'; candidate[name] = 'changed'; }));
|
||||
for (const fake of [{}, RTCIceCandidate.prototype, Object.create(candidate)]) {
|
||||
checks.push(rejects(() => descriptor.get.call(fake)));
|
||||
}
|
||||
}
|
||||
const first = candidate.toJSON();
|
||||
const second = candidate.toJSON();
|
||||
checks.push(first !== second, JSON.stringify(first) ===
|
||||
'{"candidate":"","sdpMid":"audio","sdpMLineIndex":null,"usernameFragment":null}');
|
||||
const cloned = new RTCIceCandidate(candidate);
|
||||
const signaled = new RTCIceCandidate(first);
|
||||
checks.push(cloned.relayProtocol === 'tls', cloned.url === 'turn:example.com',
|
||||
signaled.relayProtocol === null, signaled.url === null);
|
||||
return checks.every(Boolean);
|
||||
})()
|
||||
"#).expect("RTCIceCandidate should expose the WebIDL surface without mutable own attributes");
|
||||
assert_eq!(result, "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtc_ice_candidate_converts_dictionary_members_in_order_and_preserves_dom_strings() {
|
||||
let mut vm = new_storage_test_vm("https://ice-candidate-conversion.test/");
|
||||
let result = vm.eval(r#"
|
||||
(() => {
|
||||
const reads = [];
|
||||
const raw = {
|
||||
candidate: '\ud800', sdpMid: '\udc00', sdpMLineIndex: -1.9,
|
||||
usernameFragment: '\ud800', relayProtocol: 'udp', url: 'turn:\ud800'
|
||||
};
|
||||
const candidate = new RTCIceCandidate(new Proxy(raw, {
|
||||
get(target, name) { reads.push(name); return target[name]; }
|
||||
}));
|
||||
const checks = [
|
||||
reads.join(',') === 'candidate,sdpMLineIndex,sdpMid,usernameFragment,relayProtocol,url',
|
||||
candidate.candidate === raw.candidate, candidate.sdpMid === raw.sdpMid,
|
||||
candidate.usernameFragment === raw.usernameFragment,
|
||||
candidate.sdpMLineIndex === 65535, candidate.url === 'turn:\ufffd',
|
||||
new RTCIceCandidate({sdpMLineIndex: 65536}).sdpMLineIndex === 0,
|
||||
new RTCIceCandidate({sdpMLineIndex: Infinity}).sdpMLineIndex === 0,
|
||||
new RTCIceCandidate({candidate: null, sdpMid: false}).candidate === 'null',
|
||||
new RTCIceCandidate({sdpMid: false}).sdpMid === 'false'
|
||||
];
|
||||
for (const bad of [{candidate: Symbol()}, {sdpMid: Symbol()}, {sdpMLineIndex: 1n},
|
||||
{sdpMLineIndex: Symbol()}, {usernameFragment: Symbol()}, {relayProtocol: 'UDP'}, {url: Symbol()}]) {
|
||||
try { new RTCIceCandidate({sdpMid: 'audio', ...bad}); checks.push(false); }
|
||||
catch (error) { checks.push(error instanceof TypeError); }
|
||||
}
|
||||
const error = new RangeError('conversion');
|
||||
try { new RTCIceCandidate({candidate: {toString() { throw error; }}, sdpMid: 'audio'}); checks.push(false); }
|
||||
catch (caught) { checks.push(caught === error); }
|
||||
return checks.every(Boolean);
|
||||
})()
|
||||
"#).expect("ICE dictionary conversions should preserve WebIDL ordering and original exceptions");
|
||||
assert_eq!(result, "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtc_ice_candidate_invalid_strings_preserve_raw_input_without_partial_parsing() {
|
||||
let mut vm = new_storage_test_vm("https://ice-candidate-parsing.test/");
|
||||
let result = vm.eval(r#"
|
||||
(() => {
|
||||
const derived = ['foundation', 'component', 'priority', 'address', 'protocol',
|
||||
'port', 'type', 'tcpType', 'relatedAddress', 'relatedPort'];
|
||||
return ['', 'arbitrary string', 'candidate:x 1 udp 1 127.0.0.1 65536 typ host',
|
||||
'candidate:x 1 udp 1 127.0.0.1 9 typ host rport 65536'].every(raw => {
|
||||
const candidate = new RTCIceCandidate({candidate: raw, sdpMid: 'video', usernameFragment: 'keep'});
|
||||
return candidate.candidate === raw && candidate.usernameFragment === 'keep' &&
|
||||
derived.every(name => candidate[name] === null);
|
||||
});
|
||||
})()
|
||||
"#).expect("invalid candidate text should not throw or expose partial derived attributes");
|
||||
assert_eq!(result, "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtc_ice_candidate_respects_new_target_and_accepts_genuine_foreign_receivers() {
|
||||
let mut vm = new_parsed_test_vm(
|
||||
"https://ice-candidate-realms.test/",
|
||||
"<!doctype html><iframe></iframe>",
|
||||
);
|
||||
let result = vm.eval(r#"
|
||||
(() => {
|
||||
const child = document.querySelector('iframe').contentWindow;
|
||||
class Derived extends RTCIceCandidate {}
|
||||
const derived = new Derived({sdpMid: 'audio'});
|
||||
const foreign = new child.RTCIceCandidate({sdpMid: 'foreign'});
|
||||
const newTarget = child.Function('');
|
||||
newTarget.prototype = 1;
|
||||
const fallback = Reflect.construct(RTCIceCandidate, [{sdpMid: 'fallback'}], newTarget);
|
||||
return [
|
||||
derived instanceof Derived, derived instanceof RTCIceCandidate,
|
||||
Object.getPrototypeOf(foreign) === child.RTCIceCandidate.prototype,
|
||||
Object.getPrototypeOf(fallback) === child.RTCIceCandidate.prototype,
|
||||
Object.getOwnPropertyDescriptor(RTCIceCandidate.prototype, 'sdpMid').get.call(foreign) === 'foreign',
|
||||
RTCIceCandidate.prototype.toJSON.call(foreign).sdpMid === 'foreign'
|
||||
].every(Boolean);
|
||||
})()
|
||||
"#).expect("ICE constructors should retain subclass and relevant NewTarget prototypes");
|
||||
assert_eq!(result, "true");
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod crypto_subtle_x25519;
|
||||
mod details;
|
||||
mod event_handlers;
|
||||
mod events_selection_storage;
|
||||
mod ice_candidate;
|
||||
mod idle_callbacks;
|
||||
mod idle_detection;
|
||||
mod images;
|
||||
|
||||
@@ -116,6 +116,58 @@ pub(crate) fn callable_relevant_context<'s>(
|
||||
.get_creation_context(scope)
|
||||
}
|
||||
|
||||
pub(crate) fn new_target_realm_constructor_prototype<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
new_target: v8::Local<'s, v8::Value>,
|
||||
constructor_name: &str,
|
||||
) -> Option<v8::Local<'s, v8::Object>> {
|
||||
let context = callable_relevant_context(scope, new_target)?;
|
||||
let prototype = {
|
||||
let context_scope = &mut v8::ContextScope::new(scope, context);
|
||||
let prototype = global_constructor_prototype(context_scope, constructor_name)?;
|
||||
v8::Global::new(context_scope, prototype)
|
||||
};
|
||||
Some(v8::Local::new(scope, &prototype))
|
||||
}
|
||||
|
||||
pub(crate) fn receiver_uses_new_target_realm_object_fallback<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
receiver: v8::Local<'s, v8::Object>,
|
||||
new_target: v8::Local<'s, v8::Value>,
|
||||
) -> bool {
|
||||
let Some(receiver_prototype) = receiver.get_prototype(scope) else {
|
||||
return false;
|
||||
};
|
||||
let Some(object_prototype) =
|
||||
new_target_realm_constructor_prototype(scope, new_target, "Object")
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
receiver_prototype.strict_equals(object_prototype.into())
|
||||
}
|
||||
|
||||
pub(crate) fn apply_webidl_constructor_prototype_fallback<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
receiver: v8::Local<'s, v8::Object>,
|
||||
new_target: v8::Local<'s, v8::Value>,
|
||||
default_constructor_name: &str,
|
||||
) {
|
||||
// V8 has already read `NewTarget.prototype` while preparing the native
|
||||
// constructor receiver. Do not read it again: a Proxy getter must run only
|
||||
// once. V8 represents a non-object result with NewTarget's realm-local
|
||||
// Object prototype, which lets us replace precisely that fallback with
|
||||
// the WebIDL interface's default prototype from the same realm.
|
||||
if !receiver_uses_new_target_realm_object_fallback(scope, receiver, new_target) {
|
||||
return;
|
||||
}
|
||||
let Some(prototype) =
|
||||
new_target_realm_constructor_prototype(scope, new_target, default_constructor_name)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = receiver.set_prototype(scope, prototype.into());
|
||||
}
|
||||
|
||||
pub(crate) fn define_v8_array_data_properties<'s, I, T>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
array: v8::Local<'s, v8::Array>,
|
||||
|
||||
Reference in New Issue
Block a user