diff --git a/Cargo.lock b/Cargo.lock index 297480a..184ee51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/crates/libremetaverse-voice-vivox/Cargo.toml b/crates/libremetaverse-voice-vivox/Cargo.toml index f7bf3b3..9e41b5b 100644 --- a/crates/libremetaverse-voice-vivox/Cargo.toml +++ b/crates/libremetaverse-voice-vivox/Cargo.toml @@ -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 diff --git a/crates/libremetaverse-voice-vivox/src/lib.rs b/crates/libremetaverse-voice-vivox/src/lib.rs index 5ec7f05..6136edc 100644 --- a/crates/libremetaverse-voice-vivox/src/lib.rs +++ b/crates/libremetaverse-voice-vivox/src/lib.rs @@ -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::*; diff --git a/crates/libremetaverse-voice-vivox/src/protocol.rs b/crates/libremetaverse-voice-vivox/src/protocol.rs new file mode 100644 index 0000000..0d8e226 --- /dev/null +++ b/crates/libremetaverse-voice-vivox/src/protocol.rs @@ -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 { + 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()") + } +} + +impl fmt::Display for VivoxSecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + +/// 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 { + 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, 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, + /// All devices reported by the daemon. + pub available: Vec, +} + +/// 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, + /// Session handle, kept opaque in diagnostics. + pub session_handle: Option, + /// Participant URI, kept opaque in diagnostics. + pub participant_uri: Option, + /// Participant account name, when supplied. + pub account_name: Option, + /// Participant display name, when supplied. + pub display_name: Option, + /// Whether the participant is speaking, when supplied. + pub is_speaking: Option, + /// Participant volume, when supplied. + pub volume: Option, + /// Participant audio energy, when supplied. + pub energy: Option, +} + +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 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, + writer: OwnedWriteHalf, + timeout: Duration, + next_request_id: u64, + events: VecDeque, + connector: Option, + account: Option, + sessions: HashSet, + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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!(""); + 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(""); + 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, ""); + } + } + return response.ensure_success(); + } + } + } + } + + async fn read_parsed_frame(&mut self) -> Result { + 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 { + if xml.contains(" { + 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, VivoxError> { + Document::parse(xml).map_err(|_| VivoxError::Protocol("XML is malformed")) +} + +fn descendant_text(root: Node<'_, '_>, name: &str) -> Option { + 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, 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, 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, VivoxError> { + descendant_text(root, name) + .map(VivoxSecret::new) + .transpose() +} + +fn required_secret_field(response: &VivoxResponse, name: &str) -> Result { + 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 { + 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("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '\'' => output.push_str("'"), + '"' => output.push_str("""), + _ => 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!(""); + 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, ""); + search_from = value_start + "".len() + closing.len(); + } + } + if output.starts_with("sip:") || output.starts_with("http://") || output.starts_with("https://") + { + return "".into(); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_response_and_participant_event_without_debug_leaks() { + let response = parse_frame("00OKaccount-secret").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("session-secretsip:token@example.testAlicetrue120.5").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, "<&>"'"); + let redacted = redact_sensitive( + "secrethandle", + ); + assert!(!redacted.contains("secret")); + assert!(!redacted.contains("handle")); + assert_eq!( + redact_sensitive("sip:user@example.test?token=secret"), + "" + ); + } + + #[test] + fn rejects_active_xml_and_invalid_control_fields() { + assert!(parse_frame("").is_err()); + assert!(validate_name("bad name").is_err()); + assert!(validate_text("bad\0text").is_err()); + } +} diff --git a/crates/libremetaverse/src/network_manager.rs b/crates/libremetaverse/src/network_manager.rs index 4c063d3..8a238d3 100644 --- a/crates/libremetaverse/src/network_manager.rs +++ b/crates/libremetaverse/src/network_manager.rs @@ -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, 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 } diff --git a/docs/vivox.md b/docs/vivox.md new file mode 100644 index 0000000..fef7416 --- /dev/null +++ b/docs/vivox.md @@ -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. diff --git a/programs/Cargo.toml b/programs/Cargo.toml index b99c100..380234a 100644 --- a/programs/Cargo.toml +++ b/programs/Cargo.toml @@ -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] diff --git a/programs/README.md b/programs/README.md index a43c43c..22af26b 100644 --- a/programs/README.md +++ b/programs/README.md @@ -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 diff --git a/programs/src/bin/vivox_test.rs b/programs/src/bin/vivox_test.rs index 55a7869..6e6abdb 100644 --- a/programs/src/bin/vivox_test.rs +++ b/programs/src/bin/vivox_test.rs @@ -1,3 +1,3 @@ fn main() -> std::process::ExitCode { - libremetaverse_programs::pending_program("VivoxTest") + libremetaverse_programs::vivox_test::main_entry() } diff --git a/programs/src/lib.rs b/programs/src/lib.rs index a032cc4..f135b26 100644 --- a/programs/src/lib.rs +++ b/programs/src/lib.rs @@ -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; diff --git a/programs/src/vivox_test.rs b/programs/src/vivox_test.rs new file mode 100644 index 0000000..667ebd0 --- /dev/null +++ b/programs/src/vivox_test.rs @@ -0,0 +1,1084 @@ +//! Credential-safe native port of the `VivoxTest` validation program. + +use clap::Parser; +use libremetaverse::types::compat::{CancellationToken, Uri}; +use libremetaverse::{GridClient, NetworkManager, Simulator}; +use libremetaverse_structured_data::{OSD, OSDFormat, OSDParser}; +use libremetaverse_voice_vivox::{VivoxControlClient, VivoxError}; +use roxmltree::Document; +use std::collections::HashMap; +use std::fmt; +use std::fs::File; +use std::io::{self, Read}; +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; + +pub const EXIT_SUCCESS: u8 = 0; +pub const EXIT_USAGE: u8 = 2; +pub const EXIT_INPUT: u8 = 3; +pub const EXIT_SERVICE: u8 = 4; + +const MAX_SCRIPT_BYTES: u64 = 1024 * 1024; +const MAX_SCRIPT_LINES: usize = 256; +const MAX_FIELD_BYTES: usize = 16 * 1024; +const MAX_CONTROL_FRAME_BYTES: usize = 1024 * 1024; +const LIVE_CONFIRMATION: &str = "LOGIN"; + +#[derive(Parser)] +#[command( + name = "vivox-test", + version, + about = "Validate native LibreMetaverse Vivox control and grid voice flows", + long_about = "Connects to an already-running proprietary Vivox SDK control service. MetaCrate does not bundle, locate, start, or invoke the Vivox daemon or the C# implementation.", + arg_required_else_help = true, + after_help = "Offline script directives (tab-separated): capture-device, current-capture, render-device, current-render, provision, parcel, participant, and reject. Live grid login requires --allow-live-login --confirm-live-login LOGIN. Joining parcel audio additionally requires --allow-session-audio. Prefer GRID_PASSWORD over a positional password so it is not exposed in process listings." +)] +struct Cli { + /// Avatar first name. May also be supplied as `GRID_FIRST_NAME`. + #[arg(value_name = "FIRSTNAME")] + first_name: Option, + /// Avatar last name. May also be supplied as `GRID_LAST_NAME`. + #[arg(value_name = "LASTNAME")] + last_name: Option, + /// Avatar password. Prefer `GRID_PASSWORD` to avoid process-list exposure. + #[arg(value_name = "PASSWORD")] + password: Option, + + /// Run the complete connector/account/session/participant flow against an in-process fake TCP service. + #[arg(long, value_name = "FILE")] + fake_script: Option, + /// Address of an already-running Vivox SDK control service. + #[arg(long, default_value = "127.0.0.1:44124", value_name = "IP:PORT")] + daemon_endpoint: SocketAddr, + /// Override the grid login endpoint; `GRID_LOGIN_URL` is the fallback. + #[arg(long, value_name = "URL")] + login_uri: Option, + /// Vivox account-management host used to create the connector. + #[arg(long, default_value = "bhr.vivox.com", value_name = "HOST")] + voice_server: String, + /// Maximum time for each daemon, grid, or capability operation. + #[arg(long, default_value_t = 45, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))] + timeout_seconds: u64, + /// Explicitly permit a live grid login. + #[arg(long)] + allow_live_login: bool, + /// Required literal confirmation for live login. + #[arg(long, value_name = "LOGIN")] + confirm_live_login: Option, + /// Join the parcel voice session and exercise participant controls in live mode. + #[arg(long, requires = "allow_live_login")] + allow_session_audio: bool, +} + +#[derive(Debug)] +enum ProgramError { + Usage(&'static str), + Input { + line: usize, + reason: &'static str, + }, + Io { + action: &'static str, + source: io::Error, + }, + Voice(VivoxError), + Grid(&'static str), + Timeout(&'static str), + FakeServer(&'static str), +} + +impl ProgramError { + const fn exit_code(&self) -> u8 { + match self { + Self::Usage(_) => EXIT_USAGE, + Self::Input { .. } | Self::Io { .. } => EXIT_INPUT, + Self::Voice(_) | Self::Grid(_) | Self::Timeout(_) | Self::FakeServer(_) => EXIT_SERVICE, + } + } +} + +impl fmt::Display for ProgramError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Usage(message) => formatter.write_str(message), + Self::Input { line, reason } => { + write!( + formatter, + "invalid Vivox fake script at line {line}: {reason}" + ) + } + Self::Io { action, source } => write!(formatter, "{action}: {source}"), + Self::Voice(error) => error.fmt(formatter), + Self::Grid(message) => write!(formatter, "grid voice validation failed: {message}"), + Self::Timeout(action) => write!(formatter, "{action} timed out"), + Self::FakeServer(message) => write!(formatter, "fake Vivox service failed: {message}"), + } + } +} + +impl From for ProgramError { + fn from(error: VivoxError) -> Self { + Self::Voice(error) + } +} + +#[derive(Clone)] +struct Provisioning { + account_name: String, + password: String, + voice_server: String, +} + +#[derive(Clone)] +struct ParcelVoice { + region_name: String, + local_id: i32, + channel_uri: String, +} + +#[derive(Clone)] +struct FakeParticipant { + account_name: String, + display_name: String, + uri: String, +} + +#[derive(Clone)] +struct Rejection { + return_code: i32, + status_code: i32, + status: String, +} + +#[derive(Clone)] +struct FakeConfig { + capture_devices: Vec, + current_capture: Option, + render_devices: Vec, + current_render: Option, + provision: Provisioning, + parcel: ParcelVoice, + participant: FakeParticipant, + rejections: HashMap, +} + +struct LiveConfig { + first_name: String, + last_name: String, + password: String, + login_uri: Option, + voice_server: String, + daemon_endpoint: SocketAddr, + timeout: Duration, + exercise_session: bool, +} + +/// Runs the `VivoxTest` command. +#[must_use] +pub fn main_entry() -> ExitCode { + let cli = Cli::parse(); + let Ok(runtime) = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + else { + eprintln!("vivox-test: could not initialize the async runtime"); + return ExitCode::from(EXIT_SERVICE); + }; + match runtime.block_on(run(cli)) { + Ok(()) => ExitCode::from(EXIT_SUCCESS), + Err(error) => { + eprintln!("vivox-test: {error}"); + ExitCode::from(error.exit_code()) + } + } +} + +async fn run(cli: Cli) -> Result<(), ProgramError> { + if let Some(script) = cli.fake_script { + if cli.first_name.is_some() + || cli.last_name.is_some() + || cli.password.is_some() + || cli.allow_live_login + || cli.confirm_live_login.is_some() + || cli.allow_session_audio + { + return Err(ProgramError::Usage( + "credentials and live opt-ins cannot be combined with --fake-script", + )); + } + let config = read_fake_script(&script)?; + return run_fake(config, Duration::from_secs(cli.timeout_seconds)).await; + } + let config = resolve_live(cli)?; + run_live(config).await +} + +fn resolve_live(cli: Cli) -> Result { + if !cli.allow_live_login || cli.confirm_live_login.as_deref() != Some(LIVE_CONFIRMATION) { + return Err(ProgramError::Usage( + "live mode requires --allow-live-login --confirm-live-login LOGIN", + )); + } + let first_name = required(cli.first_name, "GRID_FIRST_NAME", "FIRSTNAME is required")?; + let last_name = required(cli.last_name, "GRID_LAST_NAME", "LASTNAME is required")?; + let password = required(cli.password, "GRID_PASSWORD", "PASSWORD is required")?; + validate_field(&cli.voice_server, 0)?; + if cli.voice_server.contains('/') || cli.voice_server.chars().any(char::is_whitespace) { + return Err(ProgramError::Usage("--voice-server must be a host name")); + } + Ok(LiveConfig { + first_name, + last_name, + password, + login_uri: cli + .login_uri + .or_else(|| std::env::var("GRID_LOGIN_URL").ok()) + .filter(|value| !value.is_empty()), + voice_server: cli.voice_server, + daemon_endpoint: cli.daemon_endpoint, + timeout: Duration::from_secs(cli.timeout_seconds), + exercise_session: cli.allow_session_audio, + }) +} + +fn required( + value: Option, + environment: &str, + message: &'static str, +) -> Result { + value + .or_else(|| std::env::var(environment).ok()) + .filter(|value| !value.is_empty()) + .ok_or(ProgramError::Usage(message)) +} + +async fn run_fake(config: FakeConfig, operation_timeout: Duration) -> Result<(), ProgramError> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .map_err(|source| ProgramError::Io { + action: "binding fake Vivox service", + source, + })?; + let endpoint = listener.local_addr().map_err(|source| ProgramError::Io { + action: "reading fake Vivox endpoint", + source, + })?; + let server_config = config.clone(); + let server = tokio::spawn(async move { serve_fake(listener, server_config).await }); + let mut client = VivoxControlClient::connect(endpoint, operation_timeout).await?; + let workflow = + exercise_control_flow(&mut client, &config.provision, &config.parcel, true, true).await; + let teardown = client.shutdown().await; + let server_actions = tokio::time::timeout(operation_timeout, server) + .await + .map_err(|_| ProgramError::Timeout("fake Vivox service teardown"))? + .map_err(|_| ProgramError::FakeServer("service task panicked"))??; + workflow?; + teardown?; + let expected = [ + "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", + ]; + if server_actions != expected { + return Err(ProgramError::FakeServer( + "control action sequence was incomplete or out of order", + )); + } + println!( + "Fake Vivox validation complete; requests={} active-pipes=0 active-sessions=0 active-tasks=0", + server_actions.len() + ); + Ok(()) +} + +async fn run_live(mut config: LiveConfig) -> Result<(), ProgramError> { + println!( + "Connecting to the configured Vivox SDK control service (the proprietary daemon is not bundled or started)..." + ); + let mut voice = VivoxControlClient::connect(config.daemon_endpoint, config.timeout).await?; + print_devices(&mut voice).await?; + + let client = GridClient::new().map_err(|_| ProgramError::Grid("construct GridClient"))?; + let network = client.network(); + let mut login = network + .default_login_params( + std::mem::take(&mut config.first_name), + std::mem::take(&mut config.last_name), + std::mem::take(&mut config.password), + "VivoxTest".into(), + env!("CARGO_PKG_VERSION").into(), + ) + .map_err(|_| ProgramError::Grid("build login parameters"))?; + if let Some(uri) = config.login_uri.take() { + login.uri = uri; + } + println!("Logging into the grid with redacted credentials..."); + let login_result = tokio::time::timeout( + config.timeout, + network.login_with_login_params_cancellation_token(login, None), + ) + .await; + let logged_in = match login_result { + Ok(Ok(value)) => value, + Ok(Err(_)) => false, + Err(_) => { + let _ = network.abort_login(); + let _ = voice.shutdown().await; + return Err(ProgramError::Timeout("grid login")); + } + }; + if !logged_in { + let _ = voice.shutdown().await; + return Err(ProgramError::Grid("login was rejected")); + } + println!("Grid login succeeded; login endpoint and session credentials are redacted."); + + let operation = async { + wait_for_event_queue(&network, config.timeout).await?; + let provision = + request_provisioning(&client, &network, config.timeout, &config.voice_server).await?; + let parcel = request_parcel_voice(&client, &network, config.timeout).await?; + if provision.voice_server != config.voice_server { + println!( + "Provisioned voice host differs from the configured host; both values are redacted." + ); + } + exercise_control_flow( + &mut voice, + &provision, + &parcel, + config.exercise_session, + false, + ) + .await + } + .await; + let voice_teardown = voice.shutdown().await; + let _ = network.logout_with_method(); + operation?; + voice_teardown?; + println!("Live Vivox validation complete; grid, account, sessions, and control pipe closed."); + Ok(()) +} + +async fn exercise_control_flow( + client: &mut VivoxControlClient, + provision: &Provisioning, + parcel: &ParcelVoice, + exercise_session: bool, + enumerate_devices: bool, +) -> Result<(), ProgramError> { + if enumerate_devices { + print_devices(client).await?; + } + let management_server = format!("https://www.{}/api2/", provision.voice_server); + client.create_connector(&management_server).await?; + println!("Voice connector created: "); + client + .login(&provision.account_name, &provision.password) + .await?; + println!("Provisioned voice account logged in: "); + println!( + "Parcel voice info: region={} local-id={} channel=", + parcel.region_name, parcel.local_id + ); + if exercise_session { + let session = client + .create_session(&parcel.channel_uri, &parcel.region_name, None) + .await?; + client.connect_session(&session).await?; + println!("Voice session connected: "); + let mut participant_event = None; + for _ in 0..32 { + let event = client.next_event().await?; + if event.kind == "ParticipantStateChangeEvent" + || event.kind == "ParticipantPropertiesEvent" + { + participant_event = Some(event); + break; + } + } + let event = participant_event.ok_or(ProgramError::Grid( + "participant event was not observed within 32 daemon events", + ))?; + let participant = event.participant_uri.ok_or(ProgramError::FakeServer( + "participant event omitted its URI", + ))?; + client + .set_participant_volume(&session, &participant, 0) + .await?; + println!( + "Participant control validated for {} (URI redacted).", + event + .display_name + .as_deref() + .unwrap_or("unnamed participant") + ); + client.terminate_session(&session).await?; + println!("Voice session terminated."); + } else { + println!( + "Session audio not joined; pass --allow-session-audio for live participant validation." + ); + } + client.logout().await?; + println!("Voice account logged out."); + Ok(()) +} + +async fn print_devices(client: &mut VivoxControlClient) -> Result<(), ProgramError> { + let capture = client.capture_devices().await?; + println!("Capture devices (current marked '*'):"); + for device in capture.available { + let marker = if capture.current.as_deref() == Some(device.as_str()) { + '*' + } else { + ' ' + }; + println!(" {marker} {device}"); + } + let render = client.render_devices().await?; + println!("Render devices (current marked '*'):"); + for device in render.available { + let marker = if render.current.as_deref() == Some(device.as_str()) { + '*' + } else { + ' ' + }; + println!(" {marker} {device}"); + } + Ok(()) +} + +async fn wait_for_event_queue( + network: &NetworkManager, + operation_timeout: Duration, +) -> Result { + tokio::time::timeout(operation_timeout, async { + loop { + if let Some(simulator) = network.current_sim() + && simulator + .is_event_queue_running(Some(false)) + .unwrap_or(false) + { + return simulator; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .map_err(|_| ProgramError::Timeout("EventQueueRunning")) +} + +async fn request_provisioning( + client: &GridClient, + network: &NetworkManager, + operation_timeout: Duration, + fallback_voice_server: &str, +) -> Result { + let simulator = wait_for_event_queue(network, operation_timeout).await?; + let response = post_empty_capability( + client, + &simulator, + "ProvisionVoiceAccountRequest", + operation_timeout, + ) + .await?; + let OSD::Map(map) = response else { + return Err(ProgramError::Grid("provision response was not an LLSD map")); + }; + let account_name = osd_string(&map, "username")?; + let password = osd_string(&map, "password")?; + let voice_server = map + .get("voice_server") + .and_then(|value| value.as_string().ok()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback_voice_server.to_owned()); + println!("Provisional voice account created: username/password redacted."); + Ok(Provisioning { + account_name, + password, + voice_server, + }) +} + +async fn request_parcel_voice( + client: &GridClient, + network: &NetworkManager, + operation_timeout: Duration, +) -> Result { + let simulator = wait_for_event_queue(network, operation_timeout).await?; + let response = post_empty_capability( + client, + &simulator, + "ParcelVoiceInfoRequest", + operation_timeout, + ) + .await?; + let OSD::Map(map) = response else { + return Err(ProgramError::Grid( + "parcel voice response was not an LLSD map", + )); + }; + let region_name = osd_string(&map, "region_name")?; + let local_id = map + .get("parcel_local_id") + .and_then(|value| value.as_integer().ok()) + .ok_or(ProgramError::Grid("parcel response omitted local ID"))?; + let channel_uri = match map.get("voice_credentials") { + Some(OSD::Map(credentials)) => osd_string(credentials, "channel_uri")?, + _ => { + return Err(ProgramError::Grid( + "parcel response omitted voice credentials", + )); + } + }; + Ok(ParcelVoice { + region_name, + local_id, + channel_uri, + }) +} + +async fn post_empty_capability( + client: &GridClient, + simulator: &Simulator, + capability: &'static str, + operation_timeout: Duration, +) -> Result { + let uri = simulator + .native_capability_uri(capability) + .map_err(|_| ProgramError::Grid("capability lookup failed"))? + .ok_or(ProgramError::Grid("required voice capability is missing"))?; + let http = client.http_caps_client(); + let request = http.post_with_uri_osd_format_osd_cancellation_token_i_progress( + Uri(uri.0), + OSDFormat::Xml, + OSD::Map(HashMap::new()), + CancellationToken::default(), + None, + ); + let (response, bytes) = tokio::time::timeout(operation_timeout, request) + .await + .map_err(|_| ProgramError::Timeout(capability))? + .map_err(|_| ProgramError::Grid("voice capability request failed"))?; + if !response.is_success_status_code() + || u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_SCRIPT_BYTES + { + return Err(ProgramError::Grid("voice capability returned an error")); + } + OSDParser::deserialize_with_bytes(bytes) + .map_err(|_| ProgramError::Grid("voice capability returned malformed LLSD")) +} + +fn osd_string(map: &HashMap, key: &'static str) -> Result { + map.get(key) + .and_then(|value| value.as_string().ok()) + .filter(|value| !value.is_empty()) + .ok_or(ProgramError::Grid( + "voice capability response omitted a required string", + )) +} + +#[derive(Default)] +struct FakeConfigBuilder { + capture_devices: Vec, + current_capture: Option, + render_devices: Vec, + current_render: Option, + provision: Option, + parcel: Option, + participant: Option, + rejections: HashMap, +} + +fn read_fake_script(path: &Path) -> Result { + let file = File::open(path).map_err(|source| ProgramError::Io { + action: "opening Vivox fake script", + source, + })?; + if file + .metadata() + .map_err(|source| ProgramError::Io { + action: "reading Vivox fake script metadata", + source, + })? + .len() + > MAX_SCRIPT_BYTES + { + return Err(ProgramError::Input { + line: 0, + reason: "script exceeds 1 MiB", + }); + } + let mut text = String::new(); + file.take(MAX_SCRIPT_BYTES + 1) + .read_to_string(&mut text) + .map_err(|source| ProgramError::Io { + action: "reading Vivox fake script as UTF-8", + source, + })?; + if text.len() as u64 > MAX_SCRIPT_BYTES { + return Err(ProgramError::Input { + line: 0, + reason: "script exceeds 1 MiB", + }); + } + + let mut builder = FakeConfigBuilder::default(); + let mut directives = 0; + for (index, raw) in text.lines().enumerate() { + let line = index + 1; + let raw = raw.trim_end_matches('\r'); + if raw.trim().is_empty() || raw.trim_start().starts_with('#') { + continue; + } + directives += 1; + if directives > MAX_SCRIPT_LINES { + return Err(ProgramError::Input { + line, + reason: "script contains more than 256 directives", + }); + } + let fields: Vec<_> = raw.split('\t').collect(); + for field in &fields { + validate_field(field, line)?; + } + builder.parse_directive(&fields, line)?; + } + builder.finish() +} + +impl FakeConfigBuilder { + fn parse_directive(&mut self, fields: &[&str], line: usize) -> Result<(), ProgramError> { + match fields { + ["capture-device", name] => self.capture_devices.push((*name).into()), + ["current-capture", name] if self.current_capture.is_none() => { + self.current_capture = Some((*name).into()); + } + ["render-device", name] => self.render_devices.push((*name).into()), + ["current-render", name] if self.current_render.is_none() => { + self.current_render = Some((*name).into()); + } + ["provision", account_name, password, voice_server] if self.provision.is_none() => { + self.provision = Some(Provisioning { + account_name: (*account_name).into(), + password: (*password).into(), + voice_server: (*voice_server).into(), + }); + } + ["parcel", region_name, local_id, channel_uri] if self.parcel.is_none() => { + self.parcel = Some(ParcelVoice { + region_name: (*region_name).into(), + local_id: parse_script_i32(local_id, line, "parcel local ID")?, + channel_uri: (*channel_uri).into(), + }); + } + ["participant", account_name, display_name, uri] if self.participant.is_none() => { + self.participant = Some(FakeParticipant { + account_name: (*account_name).into(), + display_name: (*display_name).into(), + uri: (*uri).into(), + }); + } + ["reject", action, return_code, status_code, status] => { + let rejection = Rejection { + return_code: parse_script_i32(return_code, line, "reject return code")?, + status_code: parse_script_i32(status_code, line, "reject status code")?, + status: (*status).into(), + }; + if self + .rejections + .insert((*action).into(), rejection) + .is_some() + { + return Err(script_error(line, "reject action is duplicated")); + } + } + _ => { + return Err(script_error( + line, + "unknown, duplicated, or malformed directive", + )); + } + } + Ok(()) + } + + fn finish(self) -> Result { + let provision = self + .provision + .ok_or_else(|| script_error(0, "one provision directive is required"))?; + let parcel = self + .parcel + .ok_or_else(|| script_error(0, "one parcel directive is required"))?; + let participant = self + .participant + .ok_or_else(|| script_error(0, "one participant directive is required"))?; + if self.capture_devices.is_empty() || self.render_devices.is_empty() { + return Err(script_error( + 0, + "at least one capture-device and render-device are required", + )); + } + if self + .current_capture + .as_ref() + .is_some_and(|device| !self.capture_devices.contains(device)) + || self + .current_render + .as_ref() + .is_some_and(|device| !self.render_devices.contains(device)) + { + return Err(script_error( + 0, + "current device must also be listed as an available device", + )); + } + Ok(FakeConfig { + capture_devices: self.capture_devices, + current_capture: self.current_capture, + render_devices: self.render_devices, + current_render: self.current_render, + provision, + parcel, + participant, + rejections: self.rejections, + }) + } +} + +fn parse_script_i32(value: &str, line: usize, field: &'static str) -> Result { + value.parse().map_err(|_| ProgramError::Input { + line, + reason: match field { + "parcel local ID" => "parcel local ID must be an integer", + "reject return code" => "reject return code must be an integer", + _ => "reject status code must be an integer", + }, + }) +} + +const fn script_error(line: usize, reason: &'static str) -> ProgramError { + ProgramError::Input { line, reason } +} + +fn validate_field(field: &str, line: usize) -> Result<(), ProgramError> { + if field.is_empty() + || field.len() > MAX_FIELD_BYTES + || field.chars().any(|character| { + matches!( + character, + '\0'..='\x08' | '\x0b' | '\x0c' | '\x0e'..='\x1f' + ) + }) + { + Err(ProgramError::Input { + line, + reason: "fields must be non-empty, bounded UTF-8 without control characters", + }) + } else { + Ok(()) + } +} + +async fn serve_fake( + listener: TcpListener, + config: FakeConfig, +) -> Result, ProgramError> { + let (stream, _) = listener.accept().await.map_err(|source| ProgramError::Io { + action: "accepting fake Vivox connection", + source, + })?; + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let mut actions = Vec::new(); + loop { + let mut line = String::new(); + let read = reader + .read_line(&mut line) + .await + .map_err(|source| ProgramError::Io { + action: "reading fake Vivox request", + source, + })?; + if read == 0 { + return Err(ProgramError::FakeServer( + "client disconnected before connector shutdown", + )); + } + if line.len() > MAX_CONTROL_FRAME_BYTES { + return Err(ProgramError::FakeServer("request exceeded 1 MiB")); + } + let line = line.trim_matches(['\r', '\n', '\0']); + if line.is_empty() { + continue; + } + if line.contains("() + .map_err(|_| ProgramError::FakeServer("request ID invalid"))?; + let action = request + .attribute("action") + .ok_or(ProgramError::FakeServer("request action missing"))?; + actions.push(action.to_owned()); + + if let Some(rejection) = config.rejections.get(action) { + let response = response_xml( + request_id, + action, + rejection.return_code, + rejection.status_code, + &rejection.status, + "", + ); + writer + .write_all(response.as_bytes()) + .await + .map_err(|source| ProgramError::Io { + action: "writing fake Vivox rejection", + source, + })?; + continue; + } + + let results = fake_action_results(request, action, &config)?; + let response = response_xml(request_id, action, 0, 0, "OK", &results); + writer + .write_all(response.as_bytes()) + .await + .map_err(|source| ProgramError::Io { + action: "writing fake Vivox response", + source, + })?; + if action == "Session.Connect.1" { + let mut event = String::from( + "0OK2session-secret", + ); + push_xml(&mut event, "ParticipantURI", &config.participant.uri); + push_xml(&mut event, "AccountName", &config.participant.account_name); + push_xml(&mut event, "DisplayName", &config.participant.display_name); + event.push_str("\n\n\n"); + writer + .write_all(event.as_bytes()) + .await + .map_err(|source| ProgramError::Io { + action: "writing fake Vivox event", + source, + })?; + } + if action == "Connector.InitiateShutdown.1" { + writer.shutdown().await.map_err(|source| ProgramError::Io { + action: "closing fake Vivox connection", + source, + })?; + return Ok(actions); + } + } +} + +fn fake_action_results( + request: roxmltree::Node<'_, '_>, + action: &str, + config: &FakeConfig, +) -> Result { + match action { + "Aux.GetCaptureDevices.1" => Ok(devices_xml( + "CaptureDevices", + "CaptureDevice", + "CurrentCaptureDevice", + &config.capture_devices, + config.current_capture.as_deref(), + )), + "Aux.GetRenderDevices.1" => Ok(devices_xml( + "RenderDevices", + "RenderDevice", + "CurrentRenderDevice", + &config.render_devices, + config.current_render.as_deref(), + )), + "Connector.Create.1" => { + let account_server = format!("https://www.{}/api2/", config.provision.voice_server); + require_request_text(request, "AccountManagementServer", &account_server)?; + Ok("connector-secret".into()) + } + "Account.Login.1" => { + require_request_text(request, "ConnectorHandle", "connector-secret")?; + require_request_text(request, "AccountName", &config.provision.account_name)?; + require_request_text(request, "AccountPassword", &config.provision.password)?; + Ok("account-secret".into()) + } + "Session.Create.1" => { + require_request_text(request, "AccountHandle", "account-secret")?; + require_request_text(request, "URI", &config.parcel.channel_uri)?; + Ok("session-secret".into()) + } + "Session.Connect.1" | "Session.Terminate.1" => { + require_request_text(request, "SessionHandle", "session-secret")?; + Ok(String::new()) + } + "Session.SetParticipantVolumeForMe.1" => { + require_request_text(request, "SessionHandle", "session-secret")?; + require_request_text(request, "ParticipantURI", &config.participant.uri)?; + require_request_text(request, "Volume", "0")?; + Ok(String::new()) + } + "Account.Logout.1" => { + require_request_text(request, "AccountHandle", "account-secret")?; + Ok(String::new()) + } + "Connector.InitiateShutdown.1" => { + require_request_text(request, "ConnectorHandle", "connector-secret")?; + Ok(String::new()) + } + _ => Err(ProgramError::FakeServer( + "client sent an unsupported control action", + )), + } +} + +fn require_request_text( + request: roxmltree::Node<'_, '_>, + name: &str, + expected: &str, +) -> Result<(), ProgramError> { + let observed = request + .children() + .find(|node| node.has_tag_name(name)) + .and_then(|node| node.text()); + if observed == Some(expected) { + Ok(()) + } else { + Err(ProgramError::FakeServer( + "request omitted or changed a required control field", + )) + } +} + +fn response_xml( + request_id: &str, + action: &str, + return_code: i32, + status_code: i32, + status: &str, + results: &str, +) -> String { + let mut response = format!( + "{return_code}{status_code}" + ); + push_xml(&mut response, "StatusString", status); + response.push_str(results); + response.push_str("\n\n\n"); + response +} + +fn devices_xml( + collection: &str, + item: &str, + current: &str, + devices: &[String], + selected: Option<&str>, +) -> String { + let mut xml = format!("<{collection}>"); + for device in devices { + xml.push('<'); + xml.push_str(item); + xml.push('>'); + push_xml(&mut xml, "Device", device); + xml.push_str("'); + } + xml.push_str("'); + if let Some(selected) = selected { + xml.push('<'); + xml.push_str(current); + xml.push('>'); + push_xml(&mut xml, "Device", selected); + xml.push_str("'); + } + xml +} + +fn push_xml(output: &mut String, name: &str, value: &str) { + output.push('<'); + output.push_str(name); + output.push('>'); + for character in value.chars() { + match character { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '\'' => output.push_str("'"), + '"' => output.push_str("""), + _ => output.push(character), + } + } + output.push_str("'); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn script_parser_requires_complete_consistent_fixture() { + let path = std::env::temp_dir().join(format!( + "metacrate-vivox-unit-{}-{}.tsv", + std::process::id(), + std::thread::current().name().unwrap_or("parser") + )); + std::fs::write( + &path, + "capture-device\tMicrophone\ncurrent-capture\tMicrophone\nrender-device\tSpeakers\ncurrent-render\tSpeakers\nprovision\tvoice-user\tfake-password\tvoice.example.test\nparcel\tFake Region\t42\tsip:channel-token@example.test\nparticipant\tparticipant-user\tAlice Resident\tsip:participant-token@example.test\n", + ) + .unwrap(); + let config = read_fake_script(&path).unwrap(); + let _ = std::fs::remove_file(path); + assert_eq!(config.capture_devices, ["Microphone"]); + assert_eq!(config.parcel.local_id, 42); + assert_eq!(config.participant.display_name, "Alice Resident"); + } + + #[test] + fn response_builder_escapes_scripted_status() { + let response = response_xml("1", "Account.Login.1", 1, 403, "bad &", ""); + let document = Document::parse(response.trim()).unwrap(); + assert_eq!( + document + .descendants() + .find(|node| node.has_tag_name("StatusString")) + .and_then(|node| node.text()), + Some("bad &") + ); + } +} diff --git a/programs/tests/vivox_test_cli.rs b/programs/tests/vivox_test_cli.rs new file mode 100644 index 0000000..db03bbb --- /dev/null +++ b/programs/tests/vivox_test_cli.rs @@ -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: ", + "Provisioned voice account logged in: ", + "region=Scripted Region local-id=42 channel=", + "Voice session connected: ", + "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 "), "{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")); +} diff --git a/tests/PARITY.md b/tests/PARITY.md index 993056d..8227aaa 100644 --- a/tests/PARITY.md +++ b/tests/PARITY.md @@ -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` | diff --git a/tests/compat/Cargo.toml b/tests/compat/Cargo.toml index f2cb53a..e8320d6 100644 --- a/tests/compat/Cargo.toml +++ b/tests/compat/Cargo.toml @@ -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 diff --git a/tests/compat/tests/vivox_protocol_semantics.rs b/tests/compat/tests/vivox_protocol_semantics.rs new file mode 100644 index 0000000..925d954 --- /dev/null +++ b/tests/compat/tests/vivox_protocol_semantics.rs @@ -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!( + "00OK{result}\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" => { + "MicMic" + } + "Aux.GetRenderDevices.1" => { + "SpeakersSpeakers" + } + "Connector.Create.1" => "connector-secret", + "Account.Login.1" => "account-secret", + "Session.Create.1" => "session-secret", + "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"session-secretsip:participant-token@example.testAlice Residenttrue00.25\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); +} diff --git a/tests/upstream-tests.json b/tests/upstream-tests.json index 796aa87..371a0b9 100644 --- a/tests/upstream-tests.json +++ b/tests/upstream-tests.json @@ -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" },