Implement native VivoxTest validation (#95)
Some checks failed
Native code generation / deterministic (push) Failing after 2m19s
Imaging and meshing gate / native (push) Failing after 4m24s
JPEG 2000 feature / linux (push) Successful in 2m50s
Native Rust workspace compile / compile (push) Failing after 15m31s
Skia feature / linux (push) Successful in 31m51s

This commit is contained in:
2026-08-11 14:09:51 +00:00
parent 21f85a1b58
commit da4afec708
16 changed files with 2462 additions and 21 deletions

5
Cargo.lock generated
View File

@@ -1001,6 +1001,7 @@ dependencies = [
"libremetaverse-utilities",
"libremetaverse-voice-vivox",
"libremetaverse-voice-webrtc",
"tokio",
]
[[package]]
@@ -1056,7 +1057,9 @@ dependencies = [
"libremetaverse-imaging",
"libremetaverse-imaging-skia",
"libremetaverse-structured-data",
"libremetaverse-voice-vivox",
"regex",
"roxmltree",
"tokio",
]
@@ -1130,6 +1133,8 @@ dependencies = [
"libremetaverse",
"libremetaverse-structured-data",
"libremetaverse-types",
"roxmltree",
"tokio",
]
[[package]]

View File

@@ -11,6 +11,8 @@ description = "Vivox voice shims for the MetaCrate LibreMetaverse rewrite"
libremetaverse = { path = "../libremetaverse" }
libremetaverse-structured-data = { path = "../libremetaverse-structured-data" }
libremetaverse-types = { path = "../libremetaverse-types" }
roxmltree = "0.21.1"
tokio = { version = "1.47.1", features = ["io-util", "net", "time"] }
[lints]
workspace = true

View File

@@ -3,9 +3,11 @@
extern crate self as libremetaverse_voice_vivox;
mod generated;
mod protocol;
pub use generated::*;
pub use libremetaverse as core;
pub use libremetaverse_structured_data as structured_data;
pub use libremetaverse_types as types;
pub use libremetaverse_types::Error;
pub use protocol::*;

View File

@@ -0,0 +1,935 @@
//! Native async client for the Vivox SDK control protocol.
//!
//! This module connects to an already-running Vivox control service. It never
//! locates, starts, or bundles the proprietary Vivox daemon or SDK.
use roxmltree::{Document, Node};
use std::collections::{HashSet, VecDeque};
use std::fmt;
use std::io;
use std::net::SocketAddr;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::time::timeout;
const REQUEST_TERMINATOR: &str = "\n\n\n";
const MAX_FRAME_BYTES: usize = 1024 * 1024;
const MAX_QUEUED_EVENTS: usize = 256;
/// A handle or URI that must not accidentally appear in diagnostics.
#[derive(Clone, Eq, Hash, PartialEq)]
pub struct VivoxSecret(String);
impl VivoxSecret {
fn new(value: String) -> Result<Self, VivoxError> {
if value.is_empty() || value.len() > 16 * 1024 || value.chars().any(char::is_control) {
return Err(VivoxError::Protocol(
"invalid empty or oversized Vivox secret",
));
}
Ok(Self(value))
}
/// Exposes this value for use in another authenticated protocol operation.
/// Callers must not log or persist the returned string.
#[must_use]
pub fn expose_secret(&self) -> &str {
&self.0
}
}
impl fmt::Debug for VivoxSecret {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("VivoxSecret(<redacted>)")
}
}
impl fmt::Display for VivoxSecret {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("<redacted>")
}
}
/// A successful Vivox request response.
pub struct VivoxResponse {
/// Request ID copied through the daemon response.
pub request_id: u64,
/// Vivox action, for example `Connector.Create.1`.
pub action: String,
/// Vivox return code.
pub return_code: i32,
/// Action-specific status code.
pub status_code: i32,
/// Action-specific, credential-redacted diagnostic.
pub status: String,
document: String,
}
impl fmt::Debug for VivoxResponse {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("VivoxResponse")
.field("request_id", &self.request_id)
.field("action", &self.action)
.field("return_code", &self.return_code)
.field("status_code", &self.status_code)
.field("status", &self.status)
.finish_non_exhaustive()
}
}
impl VivoxResponse {
fn ensure_success(self) -> Result<Self, VivoxError> {
if self.return_code == 0 && self.status_code == 0 {
Ok(self)
} else {
Err(VivoxError::Rejected {
action: self.action,
return_code: self.return_code,
status_code: self.status_code,
status: self.status,
})
}
}
fn field(&self, name: &str) -> Result<Option<String>, VivoxError> {
let document = parse_document(&self.document)?;
Ok(descendant_text(document.root_element(), name))
}
}
/// One capture or render device enumeration.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VivoxDevices {
/// Device selected by the daemon.
pub current: Option<String>,
/// All devices reported by the daemon.
pub available: Vec<String>,
}
/// A parsed asynchronous Vivox control event.
#[derive(Clone)]
pub struct VivoxEvent {
/// Event type, such as `ParticipantStateChangeEvent`.
pub kind: String,
/// Event status code.
pub status_code: i32,
/// Credential-redacted event status.
pub status: String,
/// Numeric Vivox state value, when supplied.
pub state: Option<i32>,
/// Session handle, kept opaque in diagnostics.
pub session_handle: Option<VivoxSecret>,
/// Participant URI, kept opaque in diagnostics.
pub participant_uri: Option<VivoxSecret>,
/// Participant account name, when supplied.
pub account_name: Option<String>,
/// Participant display name, when supplied.
pub display_name: Option<String>,
/// Whether the participant is speaking, when supplied.
pub is_speaking: Option<bool>,
/// Participant volume, when supplied.
pub volume: Option<i32>,
/// Participant audio energy, when supplied.
pub energy: Option<f32>,
}
impl fmt::Debug for VivoxEvent {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("VivoxEvent")
.field("kind", &self.kind)
.field("status_code", &self.status_code)
.field("status", &self.status)
.field("state", &self.state)
.field("session_handle", &self.session_handle)
.field("participant_uri", &self.participant_uri)
.field("account_name", &self.account_name)
.field("display_name", &self.display_name)
.field("is_speaking", &self.is_speaking)
.field("volume", &self.volume)
.field("energy", &self.energy)
.finish()
}
}
/// Failure from the native Vivox control protocol.
#[derive(Debug)]
pub enum VivoxError {
/// TCP or stream I/O failed.
Io(io::Error),
/// A bounded operation exceeded its deadline.
Timeout(&'static str),
/// The daemon disconnected before a complete response.
Disconnected,
/// The daemon returned malformed or inconsistent XML.
Protocol(&'static str),
/// The daemon rejected an action.
Rejected {
/// Rejected Vivox action.
action: String,
/// Vivox return code.
return_code: i32,
/// Action-specific status code.
status_code: i32,
/// Redacted status text.
status: String,
},
/// The operation is not valid in the client's current state.
InvalidState(&'static str),
}
impl fmt::Display for VivoxError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(formatter, "Vivox control I/O failed: {error}"),
Self::Timeout(action) => write!(formatter, "Vivox {action} timed out"),
Self::Disconnected => formatter.write_str("Vivox control service disconnected"),
Self::Protocol(reason) => write!(formatter, "invalid Vivox control frame: {reason}"),
Self::Rejected {
action,
return_code,
status_code,
status,
} => write!(
formatter,
"Vivox action {action} failed (return {return_code}, status {status_code}): {status}"
),
Self::InvalidState(reason) => write!(formatter, "invalid Vivox client state: {reason}"),
}
}
}
impl std::error::Error for VivoxError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(error) => Some(error),
_ => None,
}
}
}
impl From<io::Error> for VivoxError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
/// Stateful native client for an already-running Vivox SDK control service.
pub struct VivoxControlClient {
reader: BufReader<OwnedReadHalf>,
writer: OwnedWriteHalf,
timeout: Duration,
next_request_id: u64,
events: VecDeque<VivoxEvent>,
connector: Option<VivoxSecret>,
account: Option<VivoxSecret>,
sessions: HashSet<VivoxSecret>,
closed: bool,
}
impl fmt::Debug for VivoxControlClient {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("VivoxControlClient")
.field("timeout", &self.timeout)
.field("next_request_id", &self.next_request_id)
.field("queued_events", &self.events.len())
.field("has_connector", &self.connector.is_some())
.field("has_account", &self.account.is_some())
.field("session_count", &self.sessions.len())
.field("closed", &self.closed)
.finish_non_exhaustive()
}
}
impl VivoxControlClient {
/// Connects to an already-running Vivox control service.
///
/// # Errors
///
/// Returns an error if the timeout is invalid or the TCP connection fails.
pub async fn connect(
endpoint: SocketAddr,
operation_timeout: Duration,
) -> Result<Self, VivoxError> {
if operation_timeout.is_zero() {
return Err(VivoxError::InvalidState(
"operation timeout must be positive",
));
}
let stream = timeout(operation_timeout, TcpStream::connect(endpoint))
.await
.map_err(|_| VivoxError::Timeout("connect"))??;
stream.set_nodelay(true)?;
let (reader, writer) = stream.into_split();
Ok(Self {
reader: BufReader::new(reader),
writer,
timeout: operation_timeout,
next_request_id: 0,
events: VecDeque::new(),
connector: None,
account: None,
sessions: HashSet::new(),
closed: false,
})
}
/// Returns the active connector handle without exposing it in formatting.
#[must_use]
pub fn connector_handle(&self) -> Option<&VivoxSecret> {
self.connector.as_ref()
}
/// Returns the active account handle without exposing it in formatting.
#[must_use]
pub fn account_handle(&self) -> Option<&VivoxSecret> {
self.account.as_ref()
}
/// Enumerates daemon capture devices.
///
/// # Errors
///
/// Returns an error for I/O, timeout, malformed, or rejected responses.
pub async fn capture_devices(&mut self) -> Result<VivoxDevices, VivoxError> {
let response = self.request("Aux.GetCaptureDevices.1", &[]).await?;
devices_from_response(&response, "CaptureDevice", "CurrentCaptureDevice")
}
/// Enumerates daemon render devices.
///
/// # Errors
///
/// Returns an error for I/O, timeout, malformed, or rejected responses.
pub async fn render_devices(&mut self) -> Result<VivoxDevices, VivoxError> {
let response = self.request("Aux.GetRenderDevices.1", &[]).await?;
devices_from_response(&response, "RenderDevice", "CurrentRenderDevice")
}
/// Creates the single connector used by subsequent account requests.
///
/// # Errors
///
/// Returns an error if a connector exists or the request fails.
pub async fn create_connector(
&mut self,
account_management_server: &str,
) -> Result<VivoxSecret, VivoxError> {
if self.connector.is_some() {
return Err(VivoxError::InvalidState("connector already exists"));
}
validate_text(account_management_server)?;
let response = self
.request(
"Connector.Create.1",
&[
("ClientName", "MetaCrate VivoxTest"),
("AccountManagementServer", account_management_server),
],
)
.await?;
let handle = required_secret_field(&response, "ConnectorHandle")?;
self.connector = Some(handle.clone());
Ok(handle)
}
/// Logs a provisioned voice account into the active connector.
///
/// # Errors
///
/// Returns an error if connector state is invalid or login fails.
pub async fn login(
&mut self,
account_name: &str,
account_password: &str,
) -> Result<VivoxSecret, VivoxError> {
if self.account.is_some() {
return Err(VivoxError::InvalidState("account already logged in"));
}
validate_text(account_name)?;
validate_text(account_password)?;
let connector = self
.connector
.as_ref()
.ok_or(VivoxError::InvalidState("create a connector before login"))?
.expose_secret()
.to_owned();
let response = self
.request(
"Account.Login.1",
&[
("ConnectorHandle", &connector),
("AccountName", account_name),
("AccountPassword", account_password),
("AudioSessionAnswerMode", "VerifyAnswer"),
("AccountURI", ""),
("ParticipantPropertyFrequency", "10"),
("EnableBuddiesAndPresence", "false"),
("BuddyManagementMode", "Application"),
],
)
.await?;
let handle = required_secret_field(&response, "AccountHandle")?;
self.account = Some(handle.clone());
Ok(handle)
}
/// Creates and joins a voice session.
///
/// # Errors
///
/// Returns an error if account state or input is invalid, or creation fails.
pub async fn create_session(
&mut self,
channel_uri: &str,
name: &str,
password: Option<&str>,
) -> Result<VivoxSecret, VivoxError> {
validate_text(channel_uri)?;
validate_text(name)?;
if let Some(password) = password {
validate_text(password)?;
}
let account = self
.account
.as_ref()
.ok_or(VivoxError::InvalidState("log in before creating a session"))?
.expose_secret()
.to_owned();
let mut fields = vec![
("AccountHandle", account.as_str()),
("URI", channel_uri),
("Name", name),
];
if let Some(password) = password {
fields.push(("Password", password));
fields.push(("PasswordHashAlgorithm", "ClearText"));
}
fields.extend([
("ConnectAudio", "true"),
("ConnectText", "false"),
("JoinAudio", "true"),
("JoinText", "false"),
("VoiceFontID", "0"),
]);
let response = self.request("Session.Create.1", &fields).await?;
let handle = required_secret_field(&response, "SessionHandle")?;
self.sessions.insert(handle.clone());
Ok(handle)
}
/// Accepts/connects an existing session.
///
/// # Errors
///
/// Returns an error if the session is unknown or the request fails.
pub async fn connect_session(&mut self, session: &VivoxSecret) -> Result<(), VivoxError> {
if !self.sessions.contains(session) {
return Err(VivoxError::InvalidState(
"session is not owned by this client",
));
}
self.request(
"Session.Connect.1",
&[
("SessionHandle", session.expose_secret()),
("AudioMedia", "default"),
],
)
.await?;
Ok(())
}
/// Sets the local volume for one participant in an active session.
///
/// # Errors
///
/// Returns an error for an unknown session, invalid volume, or failed request.
pub async fn set_participant_volume(
&mut self,
session: &VivoxSecret,
participant_uri: &VivoxSecret,
volume: i32,
) -> Result<(), VivoxError> {
if !(-100..=100).contains(&volume) {
return Err(VivoxError::InvalidState(
"participant volume must be -100..=100",
));
}
if !self.sessions.contains(session) {
return Err(VivoxError::InvalidState(
"session is not owned by this client",
));
}
let volume = volume.to_string();
self.request(
"Session.SetParticipantVolumeForMe.1",
&[
("SessionHandle", session.expose_secret()),
("ParticipantURI", participant_uri.expose_secret()),
("Volume", &volume),
],
)
.await?;
Ok(())
}
/// Waits for the next asynchronous daemon event.
///
/// # Errors
///
/// Returns an error if the stream fails or an unsolicited response arrives.
pub async fn next_event(&mut self) -> Result<VivoxEvent, VivoxError> {
if let Some(event) = self.events.pop_front() {
return Ok(event);
}
match self.read_parsed_frame().await? {
ParsedFrame::Event(event) => Ok(event),
ParsedFrame::Response(_) => Err(VivoxError::Protocol("unsolicited response")),
}
}
/// Terminates one active session.
///
/// # Errors
///
/// Returns an error if the termination request fails.
pub async fn terminate_session(&mut self, session: &VivoxSecret) -> Result<(), VivoxError> {
if !self.sessions.contains(session) {
return Ok(());
}
self.request(
"Session.Terminate.1",
&[("SessionHandle", session.expose_secret())],
)
.await?;
self.sessions.remove(session);
Ok(())
}
/// Logs out the active account.
///
/// # Errors
///
/// Returns an error if sessions remain active or the request fails.
pub async fn logout(&mut self) -> Result<(), VivoxError> {
let Some(account) = self.account.clone() else {
return Ok(());
};
if !self.sessions.is_empty() {
return Err(VivoxError::InvalidState("terminate sessions before logout"));
}
self.request(
"Account.Logout.1",
&[("AccountHandle", account.expose_secret())],
)
.await?;
self.account = None;
Ok(())
}
/// Reverses all active state and closes the TCP pipe. This method is idempotent.
///
/// # Errors
///
/// Returns the first teardown error after attempting every cleanup stage.
pub async fn shutdown(&mut self) -> Result<(), VivoxError> {
if self.closed {
return Ok(());
}
let mut first_error = None;
for session in self.sessions.clone() {
if let Err(error) = self.terminate_session(&session).await {
first_error.get_or_insert(error);
}
}
self.sessions.clear();
if let Err(error) = self.logout().await {
first_error.get_or_insert(error);
self.account = None;
}
if let Some(connector) = self.connector.clone() {
if let Err(error) = self
.request(
"Connector.InitiateShutdown.1",
&[("ConnectorHandle", connector.expose_secret())],
)
.await
{
first_error.get_or_insert(error);
}
self.connector = None;
}
if let Err(error) = self.writer.shutdown().await {
first_error.get_or_insert(VivoxError::Io(error));
}
self.closed = true;
first_error.map_or(Ok(()), Err)
}
async fn request(
&mut self,
action: &str,
fields: &[(&str, &str)],
) -> Result<VivoxResponse, VivoxError> {
if self.closed {
return Err(VivoxError::InvalidState("control pipe is closed"));
}
validate_name(action)?;
let request_id = self.next_request_id;
self.next_request_id = self
.next_request_id
.checked_add(1)
.ok_or(VivoxError::Protocol("request ID space exhausted"))?;
let mut xml = format!("<Request requestId=\"{request_id}\" action=\"{action}\">");
for (name, value) in fields {
validate_name(name)?;
validate_text(value)?;
xml.push('<');
xml.push_str(name);
xml.push('>');
escape_xml_into(value, &mut xml);
xml.push_str("</");
xml.push_str(name);
xml.push('>');
}
xml.push_str("</Request>");
xml.push_str(REQUEST_TERMINATOR);
if xml.len() > MAX_FRAME_BYTES {
return Err(VivoxError::Protocol("request exceeds 1 MiB"));
}
timeout(self.timeout, self.writer.write_all(xml.as_bytes()))
.await
.map_err(|_| VivoxError::Timeout("write"))??;
loop {
match self.read_parsed_frame().await? {
ParsedFrame::Event(event) => {
if self.events.len() == MAX_QUEUED_EVENTS {
return Err(VivoxError::Protocol("event queue capacity exceeded"));
}
self.events.push_back(event);
}
ParsedFrame::Response(mut response) => {
if response.request_id != request_id || response.action != action {
return Err(VivoxError::Protocol("response did not match request"));
}
for (name, value) in fields {
if is_sensitive_field(name) && !value.is_empty() {
response.status = response.status.replace(value, "<redacted>");
}
}
return response.ensure_success();
}
}
}
}
async fn read_parsed_frame(&mut self) -> Result<ParsedFrame, VivoxError> {
loop {
let mut line = String::new();
let read = timeout(self.timeout, self.reader.read_line(&mut line))
.await
.map_err(|_| VivoxError::Timeout("response"))??;
if read == 0 {
return Err(VivoxError::Disconnected);
}
if line.len() > MAX_FRAME_BYTES {
return Err(VivoxError::Protocol("response exceeds 1 MiB"));
}
let line = line.trim_matches(['\r', '\n', '\0']);
if line.is_empty() {
continue;
}
return parse_frame(line);
}
}
}
enum ParsedFrame {
Response(VivoxResponse),
Event(VivoxEvent),
}
fn parse_frame(xml: &str) -> Result<ParsedFrame, VivoxError> {
if xml.contains("<!DOCTYPE") || xml.contains("<!ENTITY") {
return Err(VivoxError::Protocol(
"DTD and entity declarations are forbidden",
));
}
let document = parse_document(xml)?;
let root = document.root_element();
match root.tag_name().name() {
"Response" => {
let action = root
.attribute("action")
.ok_or(VivoxError::Protocol("response action is missing"))?
.to_owned();
validate_name(&action)?;
let request_id = root
.attribute("requestId")
.or_else(|| {
root.descendants()
.find(|node| node.has_tag_name("Request"))
.and_then(|node| node.attribute("requestId"))
})
.ok_or(VivoxError::Protocol("response request ID is missing"))?
.parse()
.map_err(|_| VivoxError::Protocol("response request ID is invalid"))?;
Ok(ParsedFrame::Response(VivoxResponse {
request_id,
action,
return_code: descendant_i32(root, "ReturnCode")?.unwrap_or(0),
status_code: descendant_i32(root, "StatusCode")?.unwrap_or(0),
status: redact_sensitive(
&descendant_text(root, "StatusString").unwrap_or_else(|| "OK".into()),
),
document: xml.to_owned(),
}))
}
"Event" => Ok(ParsedFrame::Event(VivoxEvent {
kind: root
.attribute("type")
.ok_or(VivoxError::Protocol("event type is missing"))?
.to_owned(),
status_code: descendant_i32(root, "StatusCode")?.unwrap_or(0),
status: redact_sensitive(
&descendant_text(root, "StatusString").unwrap_or_else(|| "OK".into()),
),
state: descendant_i32(root, "State")?,
session_handle: optional_secret_field(root, "SessionHandle")?,
participant_uri: optional_secret_field(root, "ParticipantURI")?
.or(optional_secret_field(root, "URI")?),
account_name: descendant_text(root, "AccountName"),
display_name: descendant_text(root, "DisplayName"),
is_speaking: descendant_bool(root, "IsSpeaking")?,
volume: descendant_i32(root, "Volume")?,
energy: descendant_text(root, "Energy")
.map(|value| value.parse())
.transpose()
.map_err(|_| VivoxError::Protocol("event energy is invalid"))?,
})),
_ => Err(VivoxError::Protocol("expected Response or Event root")),
}
}
fn parse_document(xml: &str) -> Result<Document<'_>, VivoxError> {
Document::parse(xml).map_err(|_| VivoxError::Protocol("XML is malformed"))
}
fn descendant_text(root: Node<'_, '_>, name: &str) -> Option<String> {
root.descendants()
.find(|node| node.has_tag_name(name))
.and_then(|node| node.text())
.map(str::to_owned)
}
fn descendant_i32(root: Node<'_, '_>, name: &str) -> Result<Option<i32>, VivoxError> {
descendant_text(root, name)
.map(|value| {
value
.parse()
.map_err(|_| VivoxError::Protocol("numeric response field is invalid"))
})
.transpose()
}
fn descendant_bool(root: Node<'_, '_>, name: &str) -> Result<Option<bool>, VivoxError> {
descendant_text(root, name)
.map(|value| match value.as_str() {
"true" | "1" => Ok(true),
"false" | "0" => Ok(false),
_ => Err(VivoxError::Protocol("boolean event field is invalid")),
})
.transpose()
}
fn optional_secret_field(
root: Node<'_, '_>,
name: &str,
) -> Result<Option<VivoxSecret>, VivoxError> {
descendant_text(root, name)
.map(VivoxSecret::new)
.transpose()
}
fn required_secret_field(response: &VivoxResponse, name: &str) -> Result<VivoxSecret, VivoxError> {
response
.field(name)?
.ok_or(VivoxError::Protocol("required response handle is missing"))
.and_then(VivoxSecret::new)
}
fn devices_from_response(
response: &VivoxResponse,
item_name: &str,
current_name: &str,
) -> Result<VivoxDevices, VivoxError> {
let document = parse_document(&response.document)?;
let root = document.root_element();
let available = root
.descendants()
.filter(|node| node.has_tag_name(item_name))
.filter_map(|node| {
node.descendants()
.find(|child| child.has_tag_name("Device"))
.and_then(|child| child.text())
.or_else(|| node.text())
})
.map(str::to_owned)
.collect();
let current = root
.descendants()
.find(|node| node.has_tag_name(current_name))
.and_then(|node| {
node.descendants()
.find(|child| child.has_tag_name("Device"))
.and_then(|child| child.text())
.or_else(|| node.text())
})
.map(str::to_owned);
Ok(VivoxDevices { current, available })
}
fn validate_name(value: &str) -> Result<(), VivoxError> {
if !value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
Ok(())
} else {
Err(VivoxError::Protocol("XML name or action is invalid"))
}
}
fn is_sensitive_field(name: &str) -> bool {
matches!(
name,
"AccountPassword"
| "Password"
| "ConnectorHandle"
| "AccountHandle"
| "SessionHandle"
| "ParticipantURI"
| "ChannelURI"
| "URI"
)
}
fn validate_text(value: &str) -> Result<(), VivoxError> {
if value.len() <= 64 * 1024
&& !value
.chars()
.any(|ch| matches!(ch, '\0'..='\x08' | '\x0b' | '\x0c' | '\x0e'..='\x1f'))
{
Ok(())
} else {
Err(VivoxError::Protocol("XML text is invalid or oversized"))
}
}
fn escape_xml_into(value: &str, output: &mut String) {
for character in value.chars() {
match character {
'&' => output.push_str("&amp;"),
'<' => output.push_str("&lt;"),
'>' => output.push_str("&gt;"),
'\'' => output.push_str("&apos;"),
'"' => output.push_str("&quot;"),
_ => output.push(character),
}
}
}
/// Redacts common Vivox secret XML fields, URI values, and URI query strings.
#[must_use]
pub fn redact_sensitive(input: &str) -> String {
let mut output = input.to_owned();
for tag in [
"AccountPassword",
"Password",
"ConnectorHandle",
"AccountHandle",
"SessionHandle",
"ParticipantURI",
"ChannelURI",
"URI",
] {
let opening = format!("<{tag}>");
let closing = format!("</{tag}>");
let mut search_from = 0;
while let Some(relative_start) = output[search_from..].find(&opening) {
let value_start = search_from + relative_start + opening.len();
let Some(relative_end) = output[value_start..].find(&closing) else {
break;
};
let value_end = value_start + relative_end;
output.replace_range(value_start..value_end, "<redacted>");
search_from = value_start + "<redacted>".len() + closing.len();
}
}
if output.starts_with("sip:") || output.starts_with("http://") || output.starts_with("https://")
{
return "<redacted-uri>".into();
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_response_and_participant_event_without_debug_leaks() {
let response = parse_frame("<Response requestId=\"7\" action=\"Account.Login.1\"><ReturnCode>0</ReturnCode><Results><StatusCode>0</StatusCode><StatusString>OK</StatusString><AccountHandle>account-secret</AccountHandle></Results></Response>").unwrap();
let ParsedFrame::Response(response) = response else {
panic!("response")
};
assert_eq!(response.request_id, 7);
assert_eq!(
required_secret_field(&response, "AccountHandle")
.unwrap()
.expose_secret(),
"account-secret"
);
assert!(!format!("{response:?}").contains("account-secret"));
let event = parse_frame("<Event type=\"ParticipantPropertiesEvent\"><SessionHandle>session-secret</SessionHandle><ParticipantURI>sip:token@example.test</ParticipantURI><DisplayName>Alice</DisplayName><IsSpeaking>true</IsSpeaking><Volume>12</Volume><Energy>0.5</Energy></Event>").unwrap();
let ParsedFrame::Event(event) = event else {
panic!("event")
};
assert_eq!(event.display_name.as_deref(), Some("Alice"));
assert_eq!(event.is_speaking, Some(true));
let debug = format!("{event:?}");
assert!(!debug.contains("session-secret"));
assert!(!debug.contains("sip:token"));
}
#[test]
fn xml_escaping_and_redaction_cover_control_secrets() {
let mut escaped = String::new();
escape_xml_into("<&>\"'", &mut escaped);
assert_eq!(escaped, "&lt;&amp;&gt;&quot;&apos;");
let redacted = redact_sensitive(
"<AccountPassword>secret</AccountPassword><SessionHandle>handle</SessionHandle>",
);
assert!(!redacted.contains("secret"));
assert!(!redacted.contains("handle"));
assert_eq!(
redact_sensitive("sip:user@example.test?token=secret"),
"<redacted-uri>"
);
}
#[test]
fn rejects_active_xml_and_invalid_control_fields() {
assert!(parse_frame("<!DOCTYPE x><Event type=\"x\" />").is_err());
assert!(validate_name("bad name").is_err());
assert!(validate_text("bad\0text").is_err());
}
}

View File

@@ -1265,6 +1265,17 @@ impl Simulator {
.map(|inner| Caps::native_from_inner(inner, self.native_clone_without_caps()))
}
/// Looks up one capability without exposing the simulator's mutable caps state.
///
/// Capability URLs are bearer credentials. Callers must not log or persist
/// the returned URI.
pub fn native_capability_uri(&self, capability: &str) -> Result<Option<Uri>, Error> {
let Some(caps) = self.native_caps() else {
return Ok(None);
};
caps.capability_uri(capability.to_owned())
}
pub(crate) fn native_id(&self) -> UUID {
self.data.id
}

35
docs/vivox.md Normal file
View File

@@ -0,0 +1,35 @@
# Native Vivox validation
MetaCrate's Vivox support is a cross-platform Rust implementation of the SDK
control protocol. `VivoxControlClient` connects to a caller-supplied TCP socket,
writes bounded XML requests terminated by the Vivox three-newline delimiter,
correlates responses by request ID and action, and queues asynchronous login,
session, and participant events. It does not start or ship proprietary code.
The client owns connector, account, and session state. Invalid ordering is
rejected locally. `shutdown()` is idempotent and reverses active state in this
order: sessions, account, connector, TCP writer. Response failures preserve the
action and numeric return/status codes while passwords, handles, and URIs remain
opaque. XML is escaped on output; input is capped at 1 MiB and rejects DTD or
entity declarations.
`vivox-test --fake-script FILE` launches a deterministic fake control service on
IPv4 loopback. It validates the same wire requests used with a real daemon,
scripts device responses and participant events, and waits for both client and
server teardown. Live mode additionally logs into a grid, waits for the event
queue, requests `ProvisionVoiceAccountRequest` and `ParcelVoiceInfoRequest`, and
then exercises the control service. Live login requires both
`--allow-live-login` and `--confirm-live-login LOGIN`; joining audio also requires
`--allow-session-audio`.
Use environment variables for live credentials so they are not copied into a
shell history or process argument list:
```sh
GRID_FIRST_NAME=... GRID_LAST_NAME=... GRID_PASSWORD=... \
cargo run -p libremetaverse-programs --bin vivox-test -- \
--allow-live-login --confirm-live-login LOGIN
```
The Vivox daemon/SDK is proprietary and must be installed, configured, and
started independently. MetaCrate never discovers or invokes it.

View File

@@ -12,7 +12,9 @@ libremetaverse = { path = "../crates/libremetaverse", default-features = false }
libremetaverse-imaging = { path = "../crates/libremetaverse-imaging", features = ["jpeg2000"] }
libremetaverse-imaging-skia = { path = "../crates/libremetaverse-imaging-skia", features = ["skia"] }
libremetaverse-structured-data = { path = "../crates/libremetaverse-structured-data" }
libremetaverse-voice-vivox = { path = "../crates/libremetaverse-voice-vivox" }
regex = "1.12"
roxmltree = "0.21.1"
tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
[lints]

View File

@@ -14,9 +14,48 @@ here so a source entry is never mistaken for a completed port.
| `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures |
| `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts |
| `test-client` | TestClient | All non-voice command groups implemented with live and deterministic fake-grid backends; voice adapters tracked by #95 and #96 |
| `vivox-test` | VivoxTest | Pending milestone 11 issue #95 |
| `vivox-test` | VivoxTest | Implemented with gated live validation and a scripted fake TCP/control service |
| `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 |
## VivoxTest
`vivox-test` is a native async client of the Vivox SDK XML control protocol. It
connects to an already-running service; it does not bundle, locate, start, or
invoke the proprietary Vivox daemon, an SDK binary, a CLR, or the upstream C#
program. Connector, provisional-account login, session, participant-volume,
termination, account logout, connector shutdown, device enumeration, request
correlation, and daemon events all use the public
`libremetaverse-voice-vivox::VivoxControlClient` API.
Live validation is credential-safe and explicitly gated:
```text
GRID_FIRST_NAME=... GRID_LAST_NAME=... GRID_PASSWORD=... \
vivox-test --allow-live-login --confirm-live-login LOGIN
```
The service endpoint defaults to `127.0.0.1:44124` and may be changed with
`--daemon-endpoint IP:PORT`. The daemon and its proprietary SDK prerequisites
must be installed and started separately. Capability URLs, provisioned account
credentials, connector/account/session handles, and voice URIs never appear in
diagnostics. Live parcel audio is a separate operation and is skipped unless
`--allow-session-audio` is supplied.
Offline CI uses `--fake-script FILE`. The bounded, tab-separated file provides
`capture-device`, `current-capture`, `render-device`, `current-render`,
`provision`, `parcel`, and `participant` directives. An optional `reject`
directive scripts a daemon failure. Fake mode binds an ephemeral IPv4 loopback
port and performs the complete ten-request control flow over TCP before awaiting
the service task and proving that no pipes, sessions, or tasks remain.
Run the issue-focused validation with:
```sh
cargo test -p libremetaverse-voice-vivox
cargo test -p libremetaverse-programs --test vivox_test_cli
cargo test --manifest-path tests/compat/Cargo.toml --test vivox_protocol_semantics
```
## OSDInspector
`osd-inspector` is a bounded, offline command-line client of the public native

View File

@@ -1,3 +1,3 @@
fn main() -> std::process::ExitCode {
libremetaverse_programs::pending_program("VivoxTest")
libremetaverse_programs::vivox_test::main_entry()
}

View File

@@ -8,5 +8,6 @@ pub mod packet_dump;
pub mod prim_inspector;
pub mod simple_bot;
pub mod test_client;
pub mod vivox_test;
pub use libremetaverse::shim::pending_program;

1084
programs/src/vivox_test.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
const EXIT_USAGE: i32 = 2;
const EXIT_INPUT: i32 = 3;
const EXIT_SERVICE: i32 = 4;
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
struct TestDir(PathBuf);
impl TestDir {
fn new(name: &str) -> Self {
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"metacrate-vivox-test-{name}-{}-{id}",
std::process::id()
));
fs::create_dir(&path).expect("create test directory");
Self(path)
}
fn write(&self, contents: &str) -> PathBuf {
let path = self.0.join("vivox.tsv");
fs::write(&path, contents).expect("write fake Vivox script");
path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn path_text(path: &Path) -> &str {
path.to_str().expect("UTF-8 path")
}
fn run(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_vivox-test"))
.args(args)
.env_remove("GRID_FIRST_NAME")
.env_remove("GRID_LAST_NAME")
.env_remove("GRID_PASSWORD")
.env_remove("GRID_LOGIN_URL")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("run vivox-test")
}
fn utf8(bytes: &[u8]) -> &str {
std::str::from_utf8(bytes).expect("UTF-8 process output")
}
fn assert_exit(output: &Output, expected: i32) {
assert_eq!(
output.status.code(),
Some(expected),
"stdout:\n{}\nstderr:\n{}",
utf8(&output.stdout),
utf8(&output.stderr)
);
}
fn complete_script(extra: &str) -> String {
format!(
"capture-device\tFake Microphone\n\
capture-device\tBackup Microphone\n\
current-capture\tFake Microphone\n\
render-device\tFake Speakers\n\
current-render\tFake Speakers\n\
provision\tvoice-user-secret\tfake-password-secret\tvoice.example.test\n\
parcel\tScripted Region\t42\tsip:channel-token-secret@example.test\n\
participant\tparticipant-secret\tAlice Resident\tsip:participant-token-secret@example.test\n\
{extra}"
)
}
#[test]
fn help_documents_prerequisites_gates_and_scripted_controls() {
let output = run(&["--help"]);
assert!(output.status.success());
let help = utf8(&output.stdout);
for marker in [
"[FIRSTNAME]",
"[LASTNAME]",
"[PASSWORD]",
"--fake-script",
"--daemon-endpoint",
"--allow-live-login",
"--confirm-live-login",
"--allow-session-audio",
"proprietary Vivox SDK control service",
"does not bundle, locate, start, or invoke",
"capture-device",
"participant",
] {
assert!(help.contains(marker), "help omitted {marker}:\n{help}");
}
let output = run(&[]);
assert_exit(&output, EXIT_USAGE);
}
#[test]
fn fake_tcp_service_exercises_full_flow_and_tears_down_without_secret_leaks() {
let directory = TestDir::new("complete");
let script = directory.write(&complete_script(""));
let output = run(&[
"--fake-script",
path_text(&script),
"--timeout-seconds",
"5",
]);
assert!(output.status.success(), "{}", utf8(&output.stderr));
assert!(output.stderr.is_empty(), "{}", utf8(&output.stderr));
let stdout = utf8(&output.stdout);
for expected in [
"* Fake Microphone",
" Backup Microphone",
"* Fake Speakers",
"Voice connector created: <redacted>",
"Provisioned voice account logged in: <redacted>",
"region=Scripted Region local-id=42 channel=<redacted-uri>",
"Voice session connected: <redacted>",
"Participant control validated for Alice Resident (URI redacted).",
"Voice session terminated.",
"Voice account logged out.",
"requests=10 active-pipes=0 active-sessions=0 active-tasks=0",
] {
assert!(stdout.contains(expected), "missing {expected}:\n{stdout}");
}
for secret in [
"voice-user-secret",
"fake-password-secret",
"channel-token-secret",
"participant-secret",
"connector-secret",
"account-secret",
"session-secret",
] {
assert!(
!stdout.contains(secret),
"stdout leaked {secret}:\n{stdout}"
);
assert!(
!utf8(&output.stderr).contains(secret),
"stderr leaked {secret}:\n{}",
utf8(&output.stderr)
);
}
}
#[test]
fn rejection_is_reported_by_codes_redacts_echoed_password_and_still_shuts_down() {
let directory = TestDir::new("rejection");
let script = directory.write(&complete_script(
"reject\tAccount.Login.1\t1\t403\tdenied fake-password-secret\n",
));
let output = run(&["--fake-script", path_text(&script)]);
assert_exit(&output, EXIT_SERVICE);
let stderr = utf8(&output.stderr);
assert!(stderr.contains("Account.Login.1"), "{stderr}");
assert!(stderr.contains("return 1, status 403"), "{stderr}");
assert!(stderr.contains("denied <redacted>"), "{stderr}");
assert!(!stderr.contains("fake-password-secret"), "{stderr}");
}
#[test]
fn malformed_scripts_and_ungated_live_inputs_fail_before_network_access() {
let directory = TestDir::new("invalid");
let missing = directory.write("capture-device\tMicrophone\n");
let output = run(&["--fake-script", path_text(&missing)]);
assert_exit(&output, EXIT_INPUT);
assert!(utf8(&output.stderr).contains("provision directive"));
let output = run(&["First", "Last", "do-not-echo"]);
assert_exit(&output, EXIT_USAGE);
assert!(utf8(&output.stderr).contains("--allow-live-login"));
assert!(!utf8(&output.stderr).contains("do-not-echo"));
let output = run(&[
"First",
"Last",
"do-not-echo",
"--allow-live-login",
"--confirm-live-login",
"WRONG",
]);
assert_exit(&output, EXIT_USAGE);
assert!(!utf8(&output.stderr).contains("do-not-echo"));
let valid = directory.write(&complete_script(""));
let output = run(&[
"First",
"Last",
"do-not-echo",
"--fake-script",
path_text(&valid),
]);
assert_exit(&output, EXIT_USAGE);
assert!(!utf8(&output.stderr).contains("do-not-echo"));
}

View File

@@ -172,7 +172,7 @@ Reviewed Rust tests live outside `generated_parity.rs` and carry a `parity-case`
| `LibreMetaverse.Tests/ConstructorNullArgumentTests.cs::ConstructorNullArgumentTests.ObjectManager_Null_Throws::test` | `ConstructorNullArgumentTests.ObjectManager_Null_Throws` | `` | `LibreMetaverse.Tests/ConstructorNullArgumentTests.cs:33` | `` | `` | `tests/compat/tests/world_constructor_semantics.rs:13 (`object_manager_null_throws`)` | `translated` | `8a11feba0edfb35e5dd99f681182e8f5a5cf8a3f738211b45a683a8a2ad45ddb` |
| `LibreMetaverse.Tests/ConstructorNullArgumentTests.cs::ConstructorNullArgumentTests.AgentManager_Null_Throws::test` | `ConstructorNullArgumentTests.AgentManager_Null_Throws` | `` | `LibreMetaverse.Tests/ConstructorNullArgumentTests.cs:39` | `` | `` | `tests/compat/tests/world_constructor_semantics.rs:19 (`agent_manager_null_throws`)` | `translated` | `d86e41b6cdd03063685aa330c31b9a274c1d400b9a461abf3065fe185fe0bbf0` |
| `LibreMetaverse.Tests/ConstructorNullArgumentTests.cs::ConstructorNullArgumentTests.AvatarManager_Null_Throws::test` | `ConstructorNullArgumentTests.AvatarManager_Null_Throws` | `` | `LibreMetaverse.Tests/ConstructorNullArgumentTests.cs:45` | `` | `` | `tests/compat/tests/social_constructor_semantics.rs:16 (`avatar_manager_null_throws`)` | `translated` | `54f23cbf0ba87b0525c2914e78d75808e93fd1c460fc0bec4d15f09575b1431c` |
| `LibreMetaverse.Tests/ConstructorNullArgumentTests.cs::ConstructorNullArgumentTests.AssetManager_Null_Throws::test` | `ConstructorNullArgumentTests.AssetManager_Null_Throws` | `` | `LibreMetaverse.Tests/ConstructorNullArgumentTests.cs:51` | `` | `` | `tests/compat/tests/asset_capability_semantics.rs:821 (`asset_manager_none_client_is_argument_null`)` | `translated` | `4fa1be8b233dacf6a5955359f2197ff77ae85ffbf2c3c68fcd8cbe672c2c5f91` |
| `LibreMetaverse.Tests/ConstructorNullArgumentTests.cs::ConstructorNullArgumentTests.AssetManager_Null_Throws::test` | `ConstructorNullArgumentTests.AssetManager_Null_Throws` | `` | `LibreMetaverse.Tests/ConstructorNullArgumentTests.cs:51` | `` | `` | `tests/compat/tests/asset_capability_semantics.rs:823 (`asset_manager_none_client_is_argument_null`)` | `translated` | `4fa1be8b233dacf6a5955359f2197ff77ae85ffbf2c3c68fcd8cbe672c2c5f91` |
| `LibreMetaverse.Tests/CurrentOutfitFolderTests.cs::CurrentOutfitFolderTests.Constructor_WithNullClient_ThrowsArgumentNullException::test` | `CurrentOutfitFolderTests.Constructor_WithNullClient_ThrowsArgumentNullException` | `` | `LibreMetaverse.Tests/CurrentOutfitFolderTests.cs:60` | `` | `` | `tests/compat/tests/current_outfit_folder_semantics.rs:22 (`constructor_with_null_client_throws_argument_null_exception`)` | `translated` | `243079029ec81f92ffbc587007a2421389963f72c7843786c5b9a8634a7ddca3` |
| `LibreMetaverse.Tests/CurrentOutfitFolderTests.cs::CurrentOutfitFolderTests.Dispose_CalledOnce_DoesNotThrow::test` | `CurrentOutfitFolderTests.Dispose_CalledOnce_DoesNotThrow` | `` | `LibreMetaverse.Tests/CurrentOutfitFolderTests.cs:67` | `` | `` | `tests/compat/tests/current_outfit_folder_semantics.rs:31 (`dispose_called_once_does_not_throw`)` | `translated` | `1d0daac0bfa091bc3c3bffddc32a1511295dd7101f8ab5dfbf305b13b9ac7405` |
| `LibreMetaverse.Tests/CurrentOutfitFolderTests.cs::CurrentOutfitFolderTests.Dispose_CalledMultipleTimes_DoesNotThrow::test` | `CurrentOutfitFolderTests.Dispose_CalledMultipleTimes_DoesNotThrow` | `` | `LibreMetaverse.Tests/CurrentOutfitFolderTests.cs:77` | `` | `` | `tests/compat/tests/current_outfit_folder_semantics.rs:37 (`dispose_called_multiple_times_does_not_throw`)` | `translated` | `124b41c342a295f02e9d5991d1dead0ff3e9a780f96424b88f75117ca206460b` |
@@ -233,9 +233,9 @@ Reviewed Rust tests live outside `generated_parity.rs` and carry a `parity-case`
| `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.Deserialize_ReadsAllowedListFromExperiencesKey::test` | `ExperiencePreferencesMessageTests.Deserialize_ReadsAllowedListFromExperiencesKey` | `` | `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs:44` | `Experience` | `` | `tests/compat/tests/world_message_semantics.rs:110 (`experience_preferences_deserialize_reads_experiences_and_blocked`)` | `translated` | `9bab78dbe142a3da6ad21a278f25d7da90776fce3861c7d602203c1e1842c9af` |
| `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.Deserialize_IgnoresLegacyAllowedKey::test` | `ExperiencePreferencesMessageTests.Deserialize_IgnoresLegacyAllowedKey` | `` | `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs:63` | `Experience` | `` | `tests/compat/tests/world_message_semantics.rs:127 (`experience_preferences_deserialize_ignores_legacy_allowed_key`)` | `translated` | `ec6d5e7c81214ea4e3d99740fdd33961578f895a90a42082067ca9819d93d2b0` |
| `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.Serialize_WritesAllowedListUnderExperiencesKey::test` | `ExperiencePreferencesMessageTests.Serialize_WritesAllowedListUnderExperiencesKey` | `` | `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs:80` | `Experience` | `` | `tests/compat/tests/world_message_semantics.rs:141 (`experience_preferences_serialize_writes_experiences_not_allowed`)` | `translated` | `45a0bffe39ab7bf0734ec5fce4435dbdda7ae94cf4e933210669e91dc45b1baf` |
| `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.ExtractExperiencePermission_Allowed_ReturnsAllow::test` | `ExperiencePreferencesMessageTests.ExtractExperiencePermission_Allowed_ReturnsAllow` | `` | `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs:92` | `Experience` | `` | `crates/libremetaverse/src/world_internal_semantics.rs:147 (`extract_experience_permission_allowed_returns_allow`)` | `translated` | `cf095ab428713004ebac6e1915a47ae0c03bd2958addbba2a39fd2f0edf51a74` |
| `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.ExtractExperiencePermission_Blocked_ReturnsBlock::test` | `ExperiencePreferencesMessageTests.ExtractExperiencePermission_Blocked_ReturnsBlock` | `` | `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs:101` | `Experience` | `` | `crates/libremetaverse/src/world_internal_semantics.rs:157 (`extract_experience_permission_blocked_returns_block`)` | `translated` | `af084d1ab7a11079e6251865e8fd5aecfde38d2df359ebc1e208bc74925935c0` |
| `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.ExtractExperiencePermission_NeitherList_ReturnsForget::test` | `ExperiencePreferencesMessageTests.ExtractExperiencePermission_NeitherList_ReturnsForget` | `` | `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs:110` | `Experience` | `` | `crates/libremetaverse/src/world_internal_semantics.rs:167 (`extract_experience_permission_neither_list_returns_forget`)` | `translated` | `93204469ecfec2f3f4eb6838c99f6e2baf09bf76a075958d1ed369ba03767158` |
| `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.ExtractExperiencePermission_Allowed_ReturnsAllow::test` | `ExperiencePreferencesMessageTests.ExtractExperiencePermission_Allowed_ReturnsAllow` | `` | `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs:92` | `Experience` | `` | `crates/libremetaverse/src/world_internal_semantics.rs:197 (`extract_experience_permission_allowed_returns_allow`)` | `translated` | `cf095ab428713004ebac6e1915a47ae0c03bd2958addbba2a39fd2f0edf51a74` |
| `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.ExtractExperiencePermission_Blocked_ReturnsBlock::test` | `ExperiencePreferencesMessageTests.ExtractExperiencePermission_Blocked_ReturnsBlock` | `` | `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs:101` | `Experience` | `` | `crates/libremetaverse/src/world_internal_semantics.rs:207 (`extract_experience_permission_blocked_returns_block`)` | `translated` | `af084d1ab7a11079e6251865e8fd5aecfde38d2df359ebc1e208bc74925935c0` |
| `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.ExtractExperiencePermission_NeitherList_ReturnsForget::test` | `ExperiencePreferencesMessageTests.ExtractExperiencePermission_NeitherList_ReturnsForget` | `` | `LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs:110` | `Experience` | `` | `crates/libremetaverse/src/world_internal_semantics.rs:217 (`extract_experience_permission_neither_list_returns_forget`)` | `translated` | `93204469ecfec2f3f4eb6838c99f6e2baf09bf76a075958d1ed369ba03767158` |
| `LibreMetaverse.Tests/GLTFMaterialOverrideCapTests.cs::GLTFMaterialOverrideCapTests.SetMaterialOverrideAsync_HappyPath_PostsBareArrayWithGltfJson::test` | `GLTFMaterialOverrideCapTests.SetMaterialOverrideAsync_HappyPath_PostsBareArrayWithGltfJson` | `` | `LibreMetaverse.Tests/GLTFMaterialOverrideCapTests.cs:64` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:179 (`set_material_override_posts_bare_array_with_gltf_json`)` | `translated` | `0c51b7b793111f5433e52057f26fa5a98d983e7230fefeb61a7b3408e97f28ee` |
| `LibreMetaverse.Tests/GLTFMaterialOverrideCapTests.cs::GLTFMaterialOverrideCapTests.ClearMaterialOverrideAsync_SendsEmptyGltfJson::test` | `GLTFMaterialOverrideCapTests.ClearMaterialOverrideAsync_SendsEmptyGltfJson` | `` | `LibreMetaverse.Tests/GLTFMaterialOverrideCapTests.cs:86` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:215 (`clear_material_override_sends_empty_gltf_json`)` | `translated` | `8394d063ea71447d9ce2b76fd00be1d10967f9193e1eb909c5b9f2da56eec812` |
| `LibreMetaverse.Tests/GLTFMaterialOverrideCapTests.cs::GLTFMaterialOverrideCapTests.ApplyMaterialAsync_WithoutOverride_SendsAssetIdOnly::test` | `GLTFMaterialOverrideCapTests.ApplyMaterialAsync_WithoutOverride_SendsAssetIdOnly` | `` | `LibreMetaverse.Tests/GLTFMaterialOverrideCapTests.cs:100` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:237 (`apply_material_without_override_sends_asset_id_only`)` | `translated` | `1db03ab6710ecf532644398d1fef5e82520e1206058d66fb15c8885fdc8f8363` |
@@ -1137,8 +1137,8 @@ Reviewed Rust tests live outside `generated_parity.rs` and carry a `parity-case`
| `LibreMetaverse.Tests/SendPostcardTests.cs::SendPostcardTests.SendPostcardAsync_UploaderMissingFromResponse_ReturnsFalseWithoutSecondRequest::test` | `SendPostcardTests.SendPostcardAsync_UploaderMissingFromResponse_ReturnsFalseWithoutSecondRequest` | `` | `LibreMetaverse.Tests/SendPostcardTests.cs:100` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:586 (`postcard_missing_uploader_returns_false_after_one_request`)` | `translated` | `2f9498d57d7a512399f9b108765e6e8da8e4c937402f9671d580c66332630432` |
| `LibreMetaverse.Tests/SendPostcardTests.cs::SendPostcardTests.SendPostcardAsync_UploadDoesNotComplete_ReturnsFalse::test` | `SendPostcardTests.SendPostcardAsync_UploadDoesNotComplete_ReturnsFalse` | `` | `LibreMetaverse.Tests/SendPostcardTests.cs:112` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:608 (`postcard_incomplete_upload_returns_false`)` | `translated` | `937b682b406506ec9fba9570852bbf8689450a11cbecb85979af97609b1794f9` |
| `LibreMetaverse.Tests/SendPostcardTests.cs::SendPostcardTests.SendPostcardAsync_NoCapability_ReturnsFalseWithoutRequest::test` | `SendPostcardTests.SendPostcardAsync_NoCapability_ReturnsFalseWithoutRequest` | `` | `LibreMetaverse.Tests/SendPostcardTests.cs:125` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:635 (`postcard_without_capability_makes_no_request`)` | `translated` | `8d3ef84e873096d9388bb7db3eaeeb79c661fd9951e338f71bbf3251ae6580e8` |
| `LibreMetaverse.Tests/SimConsoleTests.cs::SimConsoleTests.SendSimConsoleCommandAsync_IgnoresPostBody_UsesSimConsoleResponseEvent::test` | `SimConsoleTests.SendSimConsoleCommandAsync_IgnoresPostBody_UsesSimConsoleResponseEvent` | `` | `LibreMetaverse.Tests/SimConsoleTests.cs:81` | `Estate` | `` | `crates/libremetaverse/src/world_internal_semantics.rs:182 (`send_sim_console_command_ignores_post_body_and_uses_response_event`)` | `translated` | `df6eb48241f71c34074be66869c9ae1d5980186583259568728261b5eedd7c48` |
| `LibreMetaverse.Tests/SimConsoleTests.cs::SimConsoleTests.SendSimConsoleCommandAsync_NoResponseEvent_ReturnsNullAfterTimeout::test` | `SimConsoleTests.SendSimConsoleCommandAsync_NoResponseEvent_ReturnsNullAfterTimeout` | `` | `LibreMetaverse.Tests/SimConsoleTests.cs:111` | `Estate` | `` | `crates/libremetaverse/src/world_internal_semantics.rs:203 (`send_sim_console_command_no_response_event_returns_none_after_timeout`)` | `translated` | `2ac5a6c96f3acec341056387d727288fd3661565bd2a7c385078602efe8661da` |
| `LibreMetaverse.Tests/SimConsoleTests.cs::SimConsoleTests.SendSimConsoleCommandAsync_IgnoresPostBody_UsesSimConsoleResponseEvent::test` | `SimConsoleTests.SendSimConsoleCommandAsync_IgnoresPostBody_UsesSimConsoleResponseEvent` | `` | `LibreMetaverse.Tests/SimConsoleTests.cs:81` | `Estate` | `` | `crates/libremetaverse/src/world_internal_semantics.rs:232 (`send_sim_console_command_ignores_post_body_and_uses_response_event`)` | `translated` | `df6eb48241f71c34074be66869c9ae1d5980186583259568728261b5eedd7c48` |
| `LibreMetaverse.Tests/SimConsoleTests.cs::SimConsoleTests.SendSimConsoleCommandAsync_NoResponseEvent_ReturnsNullAfterTimeout::test` | `SimConsoleTests.SendSimConsoleCommandAsync_NoResponseEvent_ReturnsNullAfterTimeout` | `` | `LibreMetaverse.Tests/SimConsoleTests.cs:111` | `Estate` | `` | `crates/libremetaverse/src/world_internal_semantics.rs:253 (`send_sim_console_command_no_response_event_returns_none_after_timeout`)` | `translated` | `2ac5a6c96f3acec341056387d727288fd3661565bd2a7c385078602efe8661da` |
| `LibreMetaverse.Tests/SimConsoleTests.cs::SimConsoleTests.SimConsoleResponseMessage_Deserialize_ReadsBodyField::test` | `SimConsoleTests.SimConsoleResponseMessage_Deserialize_ReadsBodyField` | `` | `LibreMetaverse.Tests/SimConsoleTests.cs:133` | `Estate` | `` | `tests/compat/tests/world_capability_semantics.rs:475 (`sim_console_response_message_deserialize_reads_body_field`)` | `translated` | `4d367bc3fd012d1579847fd9837bf883c5ea95452776a086bbf856899d7c2099` |
| `LibreMetaverse.Tests/SlurlParserTests.cs::SlurlParserTests.ParseSimpleLocation_ParsesCorrectly::test` | `SlurlParserTests.ParseSimpleLocation_ParsesCorrectly` | `` | `LibreMetaverse.Tests/SlurlParserTests.cs:36` | `LocationParser` | `` | `tests/compat/tests/slurl_semantics.rs:23 (`parse_simple_location_parses_correctly`)` | `translated` | `4ca1200596cd99959db205bd40050c9ed62ac68fcd58079d52882e3b24af3fdf` |
| `LibreMetaverse.Tests/SlurlParserTests.cs::SlurlParserTests.ParseSlurl_ParsesCorrectly::test` | `SlurlParserTests.ParseSlurl_ParsesCorrectly` | `` | `LibreMetaverse.Tests/SlurlParserTests.cs:50` | `LocationParser` | `` | `tests/compat/tests/slurl_semantics.rs:33 (`parse_slurl_parses_correctly`)` | `translated` | `b389e823a365884bb10813a587c5922416dc8d0737f36bfde99c56cc4e4accda` |
@@ -1203,9 +1203,9 @@ Reviewed Rust tests live outside `generated_parity.rs` and carry a `parity-case`
| `LibreMetaverse.Tests/UploadThumbnailTests.cs::UploadThumbnailTests.UploadThumbnailAsync_AgentInventoryItem_SendsBareItemIdAndReturnsNewAsset::test` | `UploadThumbnailTests.UploadThumbnailAsync_AgentInventoryItem_SendsBareItemIdAndReturnsNewAsset` | `` | `LibreMetaverse.Tests/UploadThumbnailTests.cs:73` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:670 (`thumbnail_agent_item_sends_bare_item_id`)` | `translated` | `40ca874a37dade3bfbb391a2b4a75c684cfd7509b269286ccc14d747340d095d` |
| `LibreMetaverse.Tests/UploadThumbnailTests.cs::UploadThumbnailTests.UploadThumbnailAsync_TaskInventoryItem_SendsItemIdAndTaskId::test` | `UploadThumbnailTests.UploadThumbnailAsync_TaskInventoryItem_SendsItemIdAndTaskId` | `` | `LibreMetaverse.Tests/UploadThumbnailTests.cs:92` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:699 (`thumbnail_task_item_sends_item_and_task_ids`)` | `translated` | `dae80f9a7ba6222a651165f779c123d8472f88a17490e69a1112acc3cec6d108` |
| `LibreMetaverse.Tests/UploadThumbnailTests.cs::UploadThumbnailTests.UploadThumbnailAsync_KnownLocalFolder_SendsCategoryId::test` | `UploadThumbnailTests.UploadThumbnailAsync_KnownLocalFolder_SendsCategoryId` | `` | `LibreMetaverse.Tests/UploadThumbnailTests.cs:110` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:725 (`thumbnail_known_local_folder_sends_category_id`)` | `translated` | `1ccca44bbead54fd5041c41fc6560478ba17f9e5d9bacff21096b254d2f526db` |
| `LibreMetaverse.Tests/UploadThumbnailTests.cs::UploadThumbnailTests.UploadThumbnailAsync_NoCapability_ReturnsNullWithoutRequest::test` | `UploadThumbnailTests.UploadThumbnailAsync_NoCapability_ReturnsNullWithoutRequest` | `` | `LibreMetaverse.Tests/UploadThumbnailTests.cs:128` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:756 (`thumbnail_without_capability_makes_no_request`)` | `translated` | `2bc1ad18b159c5d62a18d2208ed3cf3c88d3f666a0e0dad8730778cd22b59eda` |
| `LibreMetaverse.Tests/UploadThumbnailTests.cs::UploadThumbnailTests.UploadThumbnailAsync_MetadataResponseMissingUploader_ReturnsNullWithoutSecondRequest::test` | `UploadThumbnailTests.UploadThumbnailAsync_MetadataResponseMissingUploader_ReturnsNullWithoutSecondRequest` | `` | `LibreMetaverse.Tests/UploadThumbnailTests.cs:145` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:773 (`thumbnail_missing_uploader_returns_none_after_one_request`)` | `translated` | `8db3eddf1204ad795e9d5ef15365f422f96191a8b4c24ef3393b03c10d92d662` |
| `LibreMetaverse.Tests/UploadThumbnailTests.cs::UploadThumbnailTests.UploadThumbnailAsync_UploadDoesNotComplete_ReturnsNull::test` | `UploadThumbnailTests.UploadThumbnailAsync_UploadDoesNotComplete_ReturnsNull` | `` | `LibreMetaverse.Tests/UploadThumbnailTests.cs:156` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:793 (`thumbnail_incomplete_upload_returns_none`)` | `translated` | `e870f6913e3f45676ce490069930427e6c625072fc1d6ebbf16f503be51df4ae` |
| `LibreMetaverse.Tests/UploadThumbnailTests.cs::UploadThumbnailTests.UploadThumbnailAsync_NoCapability_ReturnsNullWithoutRequest::test` | `UploadThumbnailTests.UploadThumbnailAsync_NoCapability_ReturnsNullWithoutRequest` | `` | `LibreMetaverse.Tests/UploadThumbnailTests.cs:128` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:758 (`thumbnail_without_capability_makes_no_request`)` | `translated` | `2bc1ad18b159c5d62a18d2208ed3cf3c88d3f666a0e0dad8730778cd22b59eda` |
| `LibreMetaverse.Tests/UploadThumbnailTests.cs::UploadThumbnailTests.UploadThumbnailAsync_MetadataResponseMissingUploader_ReturnsNullWithoutSecondRequest::test` | `UploadThumbnailTests.UploadThumbnailAsync_MetadataResponseMissingUploader_ReturnsNullWithoutSecondRequest` | `` | `LibreMetaverse.Tests/UploadThumbnailTests.cs:145` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:775 (`thumbnail_missing_uploader_returns_none_after_one_request`)` | `translated` | `8db3eddf1204ad795e9d5ef15365f422f96191a8b4c24ef3393b03c10d92d662` |
| `LibreMetaverse.Tests/UploadThumbnailTests.cs::UploadThumbnailTests.UploadThumbnailAsync_UploadDoesNotComplete_ReturnsNull::test` | `UploadThumbnailTests.UploadThumbnailAsync_UploadDoesNotComplete_ReturnsNull` | `` | `LibreMetaverse.Tests/UploadThumbnailTests.cs:156` | `` | `LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs` | `tests/compat/tests/asset_capability_semantics.rs:795 (`thumbnail_incomplete_upload_returns_none`)` | `translated` | `e870f6913e3f45676ce490069930427e6c625072fc1d6ebbf16f503be51df4ae` |
| `LibreMetaverse.Tests/UtilUnitTests.cs::UtilUnitTests.FileHelper_SanitizeAndSafeNames::test` | `UtilUnitTests.FileHelper_SanitizeAndSafeNames` | `` | `LibreMetaverse.Tests/UtilUnitTests.cs:12` | `Utilities` | `` | `tests/compat/tests/types_utilities.rs:218 (`file_helper_sanitize_and_safe_names`)` | `translated` | `770d1cb751c4b8290c9443bcb4a91d17703628a157cc428c607a7d36ae0af985` |
| `LibreMetaverse.Tests/UtilUnitTests.cs::UtilUnitTests.ObservableDictionary_Events_FireOnAddRemoveAndClear::test` | `UtilUnitTests.ObservableDictionary_Events_FireOnAddRemoveAndClear` | `` | `LibreMetaverse.Tests/UtilUnitTests.cs:33` | `Utilities` | `` | `tests/compat/tests/types_utilities.rs:255 (`observable_dictionary_events_fire_on_add_remove_and_clear`)` | `translated` | `579412c3c839e1fe5b44cf956f8d329a15d4e05c05778b4e6e3ff9b7d2fb7607` |
| `LibreMetaverse.Tests/UtilUnitTests.cs::UtilUnitTests.EventSubscriptionHelper_WaitForEventAndAsync::test` | `UtilUnitTests.EventSubscriptionHelper_WaitForEventAndAsync` | `` | `LibreMetaverse.Tests/UtilUnitTests.cs:62` | `Utilities` | `` | `tests/compat/tests/types_utilities.rs:320 (`event_subscription_helper_wait_for_event_and_async`)` | `translated` | `bab59e0caf1630edda9a2a6daecca95cd1a28f189fdde01b1b6afee4cd96d8cb` |

View File

@@ -20,6 +20,7 @@ libremetaverse-types = { path = "../../crates/libremetaverse-types" }
libremetaverse-utilities = { path = "../../crates/libremetaverse-utilities" }
libremetaverse-voice-vivox = { path = "../../crates/libremetaverse-voice-vivox" }
libremetaverse-voice-webrtc = { path = "../../crates/libremetaverse-voice-webrtc" }
tokio = { version = "1.47.1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
[lints]
workspace = true

View File

@@ -0,0 +1,117 @@
use libremetaverse_voice_vivox::VivoxControlClient;
use std::net::Ipv4Addr;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
const EXPECTED_ACTIONS: [&str; 10] = [
"Aux.GetCaptureDevices.1",
"Aux.GetRenderDevices.1",
"Connector.Create.1",
"Account.Login.1",
"Session.Create.1",
"Session.Connect.1",
"Session.SetParticipantVolumeForMe.1",
"Session.Terminate.1",
"Account.Logout.1",
"Connector.InitiateShutdown.1",
];
fn response(id: &str, action: &str, result: &str) -> String {
format!(
"<Response requestId=\"{id}\" action=\"{action}\"><ReturnCode>0</ReturnCode><Results><StatusCode>0</StatusCode><StatusString>OK</StatusString>{result}</Results></Response>\n\n\n"
)
}
#[tokio::test]
async fn native_vivox_protocol_preserves_control_order_events_and_teardown() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
let endpoint = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut actions = Vec::new();
loop {
let mut line = String::new();
assert_ne!(reader.read_line(&mut line).await.unwrap(), 0);
let line = line.trim();
if line.is_empty() {
continue;
}
let id = line
.split("requestId=\"")
.nth(1)
.and_then(|value| value.split('"').next())
.unwrap();
let action = line
.split("action=\"")
.nth(1)
.and_then(|value| value.split('"').next())
.unwrap();
actions.push(action.to_owned());
let result = match action {
"Aux.GetCaptureDevices.1" => {
"<CaptureDevices><CaptureDevice><Device>Mic</Device></CaptureDevice></CaptureDevices><CurrentCaptureDevice><Device>Mic</Device></CurrentCaptureDevice>"
}
"Aux.GetRenderDevices.1" => {
"<RenderDevices><RenderDevice><Device>Speakers</Device></RenderDevice></RenderDevices><CurrentRenderDevice><Device>Speakers</Device></CurrentRenderDevice>"
}
"Connector.Create.1" => "<ConnectorHandle>connector-secret</ConnectorHandle>",
"Account.Login.1" => "<AccountHandle>account-secret</AccountHandle>",
"Session.Create.1" => "<SessionHandle>session-secret</SessionHandle>",
"Session.Connect.1"
| "Session.SetParticipantVolumeForMe.1"
| "Session.Terminate.1"
| "Account.Logout.1"
| "Connector.InitiateShutdown.1" => "",
_ => panic!("unexpected Vivox action {action}"),
};
writer
.write_all(response(id, action, result).as_bytes())
.await
.unwrap();
if action == "Session.Connect.1" {
writer.write_all(b"<Event type=\"ParticipantPropertiesEvent\"><SessionHandle>session-secret</SessionHandle><ParticipantURI>sip:participant-token@example.test</ParticipantURI><DisplayName>Alice Resident</DisplayName><IsSpeaking>true</IsSpeaking><Volume>0</Volume><Energy>0.25</Energy></Event>\n\n\n").await.unwrap();
}
if action == "Connector.InitiateShutdown.1" {
writer.shutdown().await.unwrap();
return actions;
}
}
});
let mut client = VivoxControlClient::connect(endpoint, Duration::from_secs(2))
.await
.unwrap();
assert_eq!(client.capture_devices().await.unwrap().available, ["Mic"]);
assert_eq!(
client.render_devices().await.unwrap().available,
["Speakers"]
);
client
.create_connector("https://www.voice.example.test/api2/")
.await
.unwrap();
client.login("voice-user", "voice-password").await.unwrap();
let session = client
.create_session("sip:channel-token@example.test", "Parity Region", None)
.await
.unwrap();
client.connect_session(&session).await.unwrap();
let event = client.next_event().await.unwrap();
assert_eq!(event.kind, "ParticipantPropertiesEvent");
assert_eq!(event.display_name.as_deref(), Some("Alice Resident"));
assert_eq!(event.is_speaking, Some(true));
let participant = event.participant_uri.unwrap();
client
.set_participant_volume(&session, &participant, 0)
.await
.unwrap();
client.terminate_session(&session).await.unwrap();
client.logout().await.unwrap();
client.shutdown().await.unwrap();
client.shutdown().await.unwrap();
let actions = server.await.unwrap();
assert_eq!(actions, EXPECTED_ACTIONS);
}

View File

@@ -3171,7 +3171,7 @@
"rust_body_sha256": "9f0ce3e52e8e0d01e8b7193b9bb6cf4bd52c91e67f8aea6c423f4a2de7bedda6",
"status": "translated",
"rust_file": "tests/compat/tests/asset_capability_semantics.rs",
"rust_line": 821,
"rust_line": 823,
"rust_test": "asset_manager_none_client_is_argument_null",
"semantic_review": "reviewed"
},
@@ -4365,7 +4365,7 @@
"rust_body_sha256": "087a1cc81387f983606ead95cc1404569a4485913300b78ff1516c8354dcfeba",
"status": "translated",
"rust_file": "crates/libremetaverse/src/world_internal_semantics.rs",
"rust_line": 147,
"rust_line": 197,
"rust_test": "extract_experience_permission_allowed_returns_allow",
"semantic_review": "reviewed"
},
@@ -4386,7 +4386,7 @@
"rust_body_sha256": "ec5bd9d05cace272e4781676c19f9d15bd380e26d735c5dc5dd2b9b9c003c909",
"status": "translated",
"rust_file": "crates/libremetaverse/src/world_internal_semantics.rs",
"rust_line": 157,
"rust_line": 207,
"rust_test": "extract_experience_permission_blocked_returns_block",
"semantic_review": "reviewed"
},
@@ -4407,7 +4407,7 @@
"rust_body_sha256": "0d36077de4963d763d3b9fc93bbf18b627489a8ee6090e45a5d9ebfe8b5922f7",
"status": "translated",
"rust_file": "crates/libremetaverse/src/world_internal_semantics.rs",
"rust_line": 167,
"rust_line": 217,
"rust_test": "extract_experience_permission_neither_list_returns_forget",
"semantic_review": "reviewed"
},
@@ -23268,7 +23268,7 @@
"rust_body_sha256": "1c71caac9cf1a0b73e09633c9797de3ede96999436bf9d5850956f3269cd6d7b",
"status": "translated",
"rust_file": "crates/libremetaverse/src/world_internal_semantics.rs",
"rust_line": 182,
"rust_line": 232,
"rust_test": "send_sim_console_command_ignores_post_body_and_uses_response_event",
"semantic_review": "reviewed"
},
@@ -23289,7 +23289,7 @@
"rust_body_sha256": "b7a6f5619d95eae3a180c004b740c70e9c7a40688c54e1dc859fc6fa52f6562b",
"status": "translated",
"rust_file": "crates/libremetaverse/src/world_internal_semantics.rs",
"rust_line": 203,
"rust_line": 253,
"rust_test": "send_sim_console_command_no_response_event_returns_none_after_timeout",
"semantic_review": "reviewed"
},
@@ -24612,7 +24612,7 @@
"fixture_dependencies": [
"LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs"
],
"rust_body_sha256": "870b905720ea2808d94c1a4e9edb4223e96106aced73cf6c11be278d75af364a",
"rust_body_sha256": "13cc3de00a7329189ef6ade5e4e6ef20f583ad8ccf044621eb000ed53ec4bf02",
"status": "translated",
"rust_file": "tests/compat/tests/asset_capability_semantics.rs",
"rust_line": 725,
@@ -24636,7 +24636,7 @@
"rust_body_sha256": "b51eed29dd185877215f9449e9fb3c96473da976867079383b5c128a9c4e65fa",
"status": "translated",
"rust_file": "tests/compat/tests/asset_capability_semantics.rs",
"rust_line": 756,
"rust_line": 758,
"rust_test": "thumbnail_without_capability_makes_no_request",
"semantic_review": "reviewed"
},
@@ -24657,7 +24657,7 @@
"rust_body_sha256": "5dfa939c54d3b4076543699758cd0099cd159788f68470bf73b8e06c13f01dcb",
"status": "translated",
"rust_file": "tests/compat/tests/asset_capability_semantics.rs",
"rust_line": 773,
"rust_line": 775,
"rust_test": "thumbnail_missing_uploader_returns_none_after_one_request",
"semantic_review": "reviewed"
},
@@ -24678,7 +24678,7 @@
"rust_body_sha256": "7da1d569acebcebe6f5208074db0ff5ac09416e46e0daa0964a7d1688ca73082",
"status": "translated",
"rust_file": "tests/compat/tests/asset_capability_semantics.rs",
"rust_line": 793,
"rust_line": 795,
"rust_test": "thumbnail_incomplete_upload_returns_none",
"semantic_review": "reviewed"
},