Implement native VivoxTest validation (#95)
Some checks failed
Native code generation / deterministic (push) Failing after 2m19s
Imaging and meshing gate / native (push) Failing after 4m24s
JPEG 2000 feature / linux (push) Successful in 2m50s
Native Rust workspace compile / compile (push) Failing after 15m31s
Skia feature / linux (push) Successful in 31m51s
Some checks failed
Native code generation / deterministic (push) Failing after 2m19s
Imaging and meshing gate / native (push) Failing after 4m24s
JPEG 2000 feature / linux (push) Successful in 2m50s
Native Rust workspace compile / compile (push) Failing after 15m31s
Skia feature / linux (push) Successful in 31m51s
This commit is contained in:
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
935
crates/libremetaverse-voice-vivox/src/protocol.rs
Normal file
935
crates/libremetaverse-voice-vivox/src/protocol.rs
Normal file
@@ -0,0 +1,935 @@
|
||||
//! Native async client for the Vivox SDK control protocol.
|
||||
//!
|
||||
//! This module connects to an already-running Vivox control service. It never
|
||||
//! locates, starts, or bundles the proprietary Vivox daemon or SDK.
|
||||
|
||||
use roxmltree::{Document, Node};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
|
||||
use tokio::time::timeout;
|
||||
|
||||
const REQUEST_TERMINATOR: &str = "\n\n\n";
|
||||
const MAX_FRAME_BYTES: usize = 1024 * 1024;
|
||||
const MAX_QUEUED_EVENTS: usize = 256;
|
||||
|
||||
/// A handle or URI that must not accidentally appear in diagnostics.
|
||||
#[derive(Clone, Eq, Hash, PartialEq)]
|
||||
pub struct VivoxSecret(String);
|
||||
|
||||
impl VivoxSecret {
|
||||
fn new(value: String) -> Result<Self, VivoxError> {
|
||||
if value.is_empty() || value.len() > 16 * 1024 || value.chars().any(char::is_control) {
|
||||
return Err(VivoxError::Protocol(
|
||||
"invalid empty or oversized Vivox secret",
|
||||
));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
/// Exposes this value for use in another authenticated protocol operation.
|
||||
/// Callers must not log or persist the returned string.
|
||||
#[must_use]
|
||||
pub fn expose_secret(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for VivoxSecret {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("VivoxSecret(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VivoxSecret {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("<redacted>")
|
||||
}
|
||||
}
|
||||
|
||||
/// A successful Vivox request response.
|
||||
pub struct VivoxResponse {
|
||||
/// Request ID copied through the daemon response.
|
||||
pub request_id: u64,
|
||||
/// Vivox action, for example `Connector.Create.1`.
|
||||
pub action: String,
|
||||
/// Vivox return code.
|
||||
pub return_code: i32,
|
||||
/// Action-specific status code.
|
||||
pub status_code: i32,
|
||||
/// Action-specific, credential-redacted diagnostic.
|
||||
pub status: String,
|
||||
document: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for VivoxResponse {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("VivoxResponse")
|
||||
.field("request_id", &self.request_id)
|
||||
.field("action", &self.action)
|
||||
.field("return_code", &self.return_code)
|
||||
.field("status_code", &self.status_code)
|
||||
.field("status", &self.status)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl VivoxResponse {
|
||||
fn ensure_success(self) -> Result<Self, VivoxError> {
|
||||
if self.return_code == 0 && self.status_code == 0 {
|
||||
Ok(self)
|
||||
} else {
|
||||
Err(VivoxError::Rejected {
|
||||
action: self.action,
|
||||
return_code: self.return_code,
|
||||
status_code: self.status_code,
|
||||
status: self.status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn field(&self, name: &str) -> Result<Option<String>, VivoxError> {
|
||||
let document = parse_document(&self.document)?;
|
||||
Ok(descendant_text(document.root_element(), name))
|
||||
}
|
||||
}
|
||||
|
||||
/// One capture or render device enumeration.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VivoxDevices {
|
||||
/// Device selected by the daemon.
|
||||
pub current: Option<String>,
|
||||
/// All devices reported by the daemon.
|
||||
pub available: Vec<String>,
|
||||
}
|
||||
|
||||
/// A parsed asynchronous Vivox control event.
|
||||
#[derive(Clone)]
|
||||
pub struct VivoxEvent {
|
||||
/// Event type, such as `ParticipantStateChangeEvent`.
|
||||
pub kind: String,
|
||||
/// Event status code.
|
||||
pub status_code: i32,
|
||||
/// Credential-redacted event status.
|
||||
pub status: String,
|
||||
/// Numeric Vivox state value, when supplied.
|
||||
pub state: Option<i32>,
|
||||
/// Session handle, kept opaque in diagnostics.
|
||||
pub session_handle: Option<VivoxSecret>,
|
||||
/// Participant URI, kept opaque in diagnostics.
|
||||
pub participant_uri: Option<VivoxSecret>,
|
||||
/// Participant account name, when supplied.
|
||||
pub account_name: Option<String>,
|
||||
/// Participant display name, when supplied.
|
||||
pub display_name: Option<String>,
|
||||
/// Whether the participant is speaking, when supplied.
|
||||
pub is_speaking: Option<bool>,
|
||||
/// Participant volume, when supplied.
|
||||
pub volume: Option<i32>,
|
||||
/// Participant audio energy, when supplied.
|
||||
pub energy: Option<f32>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for VivoxEvent {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("VivoxEvent")
|
||||
.field("kind", &self.kind)
|
||||
.field("status_code", &self.status_code)
|
||||
.field("status", &self.status)
|
||||
.field("state", &self.state)
|
||||
.field("session_handle", &self.session_handle)
|
||||
.field("participant_uri", &self.participant_uri)
|
||||
.field("account_name", &self.account_name)
|
||||
.field("display_name", &self.display_name)
|
||||
.field("is_speaking", &self.is_speaking)
|
||||
.field("volume", &self.volume)
|
||||
.field("energy", &self.energy)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure from the native Vivox control protocol.
|
||||
#[derive(Debug)]
|
||||
pub enum VivoxError {
|
||||
/// TCP or stream I/O failed.
|
||||
Io(io::Error),
|
||||
/// A bounded operation exceeded its deadline.
|
||||
Timeout(&'static str),
|
||||
/// The daemon disconnected before a complete response.
|
||||
Disconnected,
|
||||
/// The daemon returned malformed or inconsistent XML.
|
||||
Protocol(&'static str),
|
||||
/// The daemon rejected an action.
|
||||
Rejected {
|
||||
/// Rejected Vivox action.
|
||||
action: String,
|
||||
/// Vivox return code.
|
||||
return_code: i32,
|
||||
/// Action-specific status code.
|
||||
status_code: i32,
|
||||
/// Redacted status text.
|
||||
status: String,
|
||||
},
|
||||
/// The operation is not valid in the client's current state.
|
||||
InvalidState(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for VivoxError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "Vivox control I/O failed: {error}"),
|
||||
Self::Timeout(action) => write!(formatter, "Vivox {action} timed out"),
|
||||
Self::Disconnected => formatter.write_str("Vivox control service disconnected"),
|
||||
Self::Protocol(reason) => write!(formatter, "invalid Vivox control frame: {reason}"),
|
||||
Self::Rejected {
|
||||
action,
|
||||
return_code,
|
||||
status_code,
|
||||
status,
|
||||
} => write!(
|
||||
formatter,
|
||||
"Vivox action {action} failed (return {return_code}, status {status_code}): {status}"
|
||||
),
|
||||
Self::InvalidState(reason) => write!(formatter, "invalid Vivox client state: {reason}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VivoxError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(error) => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for VivoxError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stateful native client for an already-running Vivox SDK control service.
|
||||
pub struct VivoxControlClient {
|
||||
reader: BufReader<OwnedReadHalf>,
|
||||
writer: OwnedWriteHalf,
|
||||
timeout: Duration,
|
||||
next_request_id: u64,
|
||||
events: VecDeque<VivoxEvent>,
|
||||
connector: Option<VivoxSecret>,
|
||||
account: Option<VivoxSecret>,
|
||||
sessions: HashSet<VivoxSecret>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl fmt::Debug for VivoxControlClient {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("VivoxControlClient")
|
||||
.field("timeout", &self.timeout)
|
||||
.field("next_request_id", &self.next_request_id)
|
||||
.field("queued_events", &self.events.len())
|
||||
.field("has_connector", &self.connector.is_some())
|
||||
.field("has_account", &self.account.is_some())
|
||||
.field("session_count", &self.sessions.len())
|
||||
.field("closed", &self.closed)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl VivoxControlClient {
|
||||
/// Connects to an already-running Vivox control service.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the timeout is invalid or the TCP connection fails.
|
||||
pub async fn connect(
|
||||
endpoint: SocketAddr,
|
||||
operation_timeout: Duration,
|
||||
) -> Result<Self, VivoxError> {
|
||||
if operation_timeout.is_zero() {
|
||||
return Err(VivoxError::InvalidState(
|
||||
"operation timeout must be positive",
|
||||
));
|
||||
}
|
||||
let stream = timeout(operation_timeout, TcpStream::connect(endpoint))
|
||||
.await
|
||||
.map_err(|_| VivoxError::Timeout("connect"))??;
|
||||
stream.set_nodelay(true)?;
|
||||
let (reader, writer) = stream.into_split();
|
||||
Ok(Self {
|
||||
reader: BufReader::new(reader),
|
||||
writer,
|
||||
timeout: operation_timeout,
|
||||
next_request_id: 0,
|
||||
events: VecDeque::new(),
|
||||
connector: None,
|
||||
account: None,
|
||||
sessions: HashSet::new(),
|
||||
closed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the active connector handle without exposing it in formatting.
|
||||
#[must_use]
|
||||
pub fn connector_handle(&self) -> Option<&VivoxSecret> {
|
||||
self.connector.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the active account handle without exposing it in formatting.
|
||||
#[must_use]
|
||||
pub fn account_handle(&self) -> Option<&VivoxSecret> {
|
||||
self.account.as_ref()
|
||||
}
|
||||
|
||||
/// Enumerates daemon capture devices.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for I/O, timeout, malformed, or rejected responses.
|
||||
pub async fn capture_devices(&mut self) -> Result<VivoxDevices, VivoxError> {
|
||||
let response = self.request("Aux.GetCaptureDevices.1", &[]).await?;
|
||||
devices_from_response(&response, "CaptureDevice", "CurrentCaptureDevice")
|
||||
}
|
||||
|
||||
/// Enumerates daemon render devices.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for I/O, timeout, malformed, or rejected responses.
|
||||
pub async fn render_devices(&mut self) -> Result<VivoxDevices, VivoxError> {
|
||||
let response = self.request("Aux.GetRenderDevices.1", &[]).await?;
|
||||
devices_from_response(&response, "RenderDevice", "CurrentRenderDevice")
|
||||
}
|
||||
|
||||
/// Creates the single connector used by subsequent account requests.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if a connector exists or the request fails.
|
||||
pub async fn create_connector(
|
||||
&mut self,
|
||||
account_management_server: &str,
|
||||
) -> Result<VivoxSecret, VivoxError> {
|
||||
if self.connector.is_some() {
|
||||
return Err(VivoxError::InvalidState("connector already exists"));
|
||||
}
|
||||
validate_text(account_management_server)?;
|
||||
let response = self
|
||||
.request(
|
||||
"Connector.Create.1",
|
||||
&[
|
||||
("ClientName", "MetaCrate VivoxTest"),
|
||||
("AccountManagementServer", account_management_server),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let handle = required_secret_field(&response, "ConnectorHandle")?;
|
||||
self.connector = Some(handle.clone());
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Logs a provisioned voice account into the active connector.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if connector state is invalid or login fails.
|
||||
pub async fn login(
|
||||
&mut self,
|
||||
account_name: &str,
|
||||
account_password: &str,
|
||||
) -> Result<VivoxSecret, VivoxError> {
|
||||
if self.account.is_some() {
|
||||
return Err(VivoxError::InvalidState("account already logged in"));
|
||||
}
|
||||
validate_text(account_name)?;
|
||||
validate_text(account_password)?;
|
||||
let connector = self
|
||||
.connector
|
||||
.as_ref()
|
||||
.ok_or(VivoxError::InvalidState("create a connector before login"))?
|
||||
.expose_secret()
|
||||
.to_owned();
|
||||
let response = self
|
||||
.request(
|
||||
"Account.Login.1",
|
||||
&[
|
||||
("ConnectorHandle", &connector),
|
||||
("AccountName", account_name),
|
||||
("AccountPassword", account_password),
|
||||
("AudioSessionAnswerMode", "VerifyAnswer"),
|
||||
("AccountURI", ""),
|
||||
("ParticipantPropertyFrequency", "10"),
|
||||
("EnableBuddiesAndPresence", "false"),
|
||||
("BuddyManagementMode", "Application"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let handle = required_secret_field(&response, "AccountHandle")?;
|
||||
self.account = Some(handle.clone());
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Creates and joins a voice session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if account state or input is invalid, or creation fails.
|
||||
pub async fn create_session(
|
||||
&mut self,
|
||||
channel_uri: &str,
|
||||
name: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<VivoxSecret, VivoxError> {
|
||||
validate_text(channel_uri)?;
|
||||
validate_text(name)?;
|
||||
if let Some(password) = password {
|
||||
validate_text(password)?;
|
||||
}
|
||||
let account = self
|
||||
.account
|
||||
.as_ref()
|
||||
.ok_or(VivoxError::InvalidState("log in before creating a session"))?
|
||||
.expose_secret()
|
||||
.to_owned();
|
||||
let mut fields = vec![
|
||||
("AccountHandle", account.as_str()),
|
||||
("URI", channel_uri),
|
||||
("Name", name),
|
||||
];
|
||||
if let Some(password) = password {
|
||||
fields.push(("Password", password));
|
||||
fields.push(("PasswordHashAlgorithm", "ClearText"));
|
||||
}
|
||||
fields.extend([
|
||||
("ConnectAudio", "true"),
|
||||
("ConnectText", "false"),
|
||||
("JoinAudio", "true"),
|
||||
("JoinText", "false"),
|
||||
("VoiceFontID", "0"),
|
||||
]);
|
||||
let response = self.request("Session.Create.1", &fields).await?;
|
||||
let handle = required_secret_field(&response, "SessionHandle")?;
|
||||
self.sessions.insert(handle.clone());
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Accepts/connects an existing session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the session is unknown or the request fails.
|
||||
pub async fn connect_session(&mut self, session: &VivoxSecret) -> Result<(), VivoxError> {
|
||||
if !self.sessions.contains(session) {
|
||||
return Err(VivoxError::InvalidState(
|
||||
"session is not owned by this client",
|
||||
));
|
||||
}
|
||||
self.request(
|
||||
"Session.Connect.1",
|
||||
&[
|
||||
("SessionHandle", session.expose_secret()),
|
||||
("AudioMedia", "default"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets the local volume for one participant in an active session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for an unknown session, invalid volume, or failed request.
|
||||
pub async fn set_participant_volume(
|
||||
&mut self,
|
||||
session: &VivoxSecret,
|
||||
participant_uri: &VivoxSecret,
|
||||
volume: i32,
|
||||
) -> Result<(), VivoxError> {
|
||||
if !(-100..=100).contains(&volume) {
|
||||
return Err(VivoxError::InvalidState(
|
||||
"participant volume must be -100..=100",
|
||||
));
|
||||
}
|
||||
if !self.sessions.contains(session) {
|
||||
return Err(VivoxError::InvalidState(
|
||||
"session is not owned by this client",
|
||||
));
|
||||
}
|
||||
let volume = volume.to_string();
|
||||
self.request(
|
||||
"Session.SetParticipantVolumeForMe.1",
|
||||
&[
|
||||
("SessionHandle", session.expose_secret()),
|
||||
("ParticipantURI", participant_uri.expose_secret()),
|
||||
("Volume", &volume),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Waits for the next asynchronous daemon event.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the stream fails or an unsolicited response arrives.
|
||||
pub async fn next_event(&mut self) -> Result<VivoxEvent, VivoxError> {
|
||||
if let Some(event) = self.events.pop_front() {
|
||||
return Ok(event);
|
||||
}
|
||||
match self.read_parsed_frame().await? {
|
||||
ParsedFrame::Event(event) => Ok(event),
|
||||
ParsedFrame::Response(_) => Err(VivoxError::Protocol("unsolicited response")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminates one active session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the termination request fails.
|
||||
pub async fn terminate_session(&mut self, session: &VivoxSecret) -> Result<(), VivoxError> {
|
||||
if !self.sessions.contains(session) {
|
||||
return Ok(());
|
||||
}
|
||||
self.request(
|
||||
"Session.Terminate.1",
|
||||
&[("SessionHandle", session.expose_secret())],
|
||||
)
|
||||
.await?;
|
||||
self.sessions.remove(session);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Logs out the active account.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if sessions remain active or the request fails.
|
||||
pub async fn logout(&mut self) -> Result<(), VivoxError> {
|
||||
let Some(account) = self.account.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self.sessions.is_empty() {
|
||||
return Err(VivoxError::InvalidState("terminate sessions before logout"));
|
||||
}
|
||||
self.request(
|
||||
"Account.Logout.1",
|
||||
&[("AccountHandle", account.expose_secret())],
|
||||
)
|
||||
.await?;
|
||||
self.account = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reverses all active state and closes the TCP pipe. This method is idempotent.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the first teardown error after attempting every cleanup stage.
|
||||
pub async fn shutdown(&mut self) -> Result<(), VivoxError> {
|
||||
if self.closed {
|
||||
return Ok(());
|
||||
}
|
||||
let mut first_error = None;
|
||||
for session in self.sessions.clone() {
|
||||
if let Err(error) = self.terminate_session(&session).await {
|
||||
first_error.get_or_insert(error);
|
||||
}
|
||||
}
|
||||
self.sessions.clear();
|
||||
if let Err(error) = self.logout().await {
|
||||
first_error.get_or_insert(error);
|
||||
self.account = None;
|
||||
}
|
||||
if let Some(connector) = self.connector.clone() {
|
||||
if let Err(error) = self
|
||||
.request(
|
||||
"Connector.InitiateShutdown.1",
|
||||
&[("ConnectorHandle", connector.expose_secret())],
|
||||
)
|
||||
.await
|
||||
{
|
||||
first_error.get_or_insert(error);
|
||||
}
|
||||
self.connector = None;
|
||||
}
|
||||
if let Err(error) = self.writer.shutdown().await {
|
||||
first_error.get_or_insert(VivoxError::Io(error));
|
||||
}
|
||||
self.closed = true;
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
async fn request(
|
||||
&mut self,
|
||||
action: &str,
|
||||
fields: &[(&str, &str)],
|
||||
) -> Result<VivoxResponse, VivoxError> {
|
||||
if self.closed {
|
||||
return Err(VivoxError::InvalidState("control pipe is closed"));
|
||||
}
|
||||
validate_name(action)?;
|
||||
let request_id = self.next_request_id;
|
||||
self.next_request_id = self
|
||||
.next_request_id
|
||||
.checked_add(1)
|
||||
.ok_or(VivoxError::Protocol("request ID space exhausted"))?;
|
||||
let mut xml = format!("<Request requestId=\"{request_id}\" action=\"{action}\">");
|
||||
for (name, value) in fields {
|
||||
validate_name(name)?;
|
||||
validate_text(value)?;
|
||||
xml.push('<');
|
||||
xml.push_str(name);
|
||||
xml.push('>');
|
||||
escape_xml_into(value, &mut xml);
|
||||
xml.push_str("</");
|
||||
xml.push_str(name);
|
||||
xml.push('>');
|
||||
}
|
||||
xml.push_str("</Request>");
|
||||
xml.push_str(REQUEST_TERMINATOR);
|
||||
if xml.len() > MAX_FRAME_BYTES {
|
||||
return Err(VivoxError::Protocol("request exceeds 1 MiB"));
|
||||
}
|
||||
timeout(self.timeout, self.writer.write_all(xml.as_bytes()))
|
||||
.await
|
||||
.map_err(|_| VivoxError::Timeout("write"))??;
|
||||
loop {
|
||||
match self.read_parsed_frame().await? {
|
||||
ParsedFrame::Event(event) => {
|
||||
if self.events.len() == MAX_QUEUED_EVENTS {
|
||||
return Err(VivoxError::Protocol("event queue capacity exceeded"));
|
||||
}
|
||||
self.events.push_back(event);
|
||||
}
|
||||
ParsedFrame::Response(mut response) => {
|
||||
if response.request_id != request_id || response.action != action {
|
||||
return Err(VivoxError::Protocol("response did not match request"));
|
||||
}
|
||||
for (name, value) in fields {
|
||||
if is_sensitive_field(name) && !value.is_empty() {
|
||||
response.status = response.status.replace(value, "<redacted>");
|
||||
}
|
||||
}
|
||||
return response.ensure_success();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_parsed_frame(&mut self) -> Result<ParsedFrame, VivoxError> {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let read = timeout(self.timeout, self.reader.read_line(&mut line))
|
||||
.await
|
||||
.map_err(|_| VivoxError::Timeout("response"))??;
|
||||
if read == 0 {
|
||||
return Err(VivoxError::Disconnected);
|
||||
}
|
||||
if line.len() > MAX_FRAME_BYTES {
|
||||
return Err(VivoxError::Protocol("response exceeds 1 MiB"));
|
||||
}
|
||||
let line = line.trim_matches(['\r', '\n', '\0']);
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
return parse_frame(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ParsedFrame {
|
||||
Response(VivoxResponse),
|
||||
Event(VivoxEvent),
|
||||
}
|
||||
|
||||
fn parse_frame(xml: &str) -> Result<ParsedFrame, VivoxError> {
|
||||
if xml.contains("<!DOCTYPE") || xml.contains("<!ENTITY") {
|
||||
return Err(VivoxError::Protocol(
|
||||
"DTD and entity declarations are forbidden",
|
||||
));
|
||||
}
|
||||
let document = parse_document(xml)?;
|
||||
let root = document.root_element();
|
||||
match root.tag_name().name() {
|
||||
"Response" => {
|
||||
let action = root
|
||||
.attribute("action")
|
||||
.ok_or(VivoxError::Protocol("response action is missing"))?
|
||||
.to_owned();
|
||||
validate_name(&action)?;
|
||||
let request_id = root
|
||||
.attribute("requestId")
|
||||
.or_else(|| {
|
||||
root.descendants()
|
||||
.find(|node| node.has_tag_name("Request"))
|
||||
.and_then(|node| node.attribute("requestId"))
|
||||
})
|
||||
.ok_or(VivoxError::Protocol("response request ID is missing"))?
|
||||
.parse()
|
||||
.map_err(|_| VivoxError::Protocol("response request ID is invalid"))?;
|
||||
Ok(ParsedFrame::Response(VivoxResponse {
|
||||
request_id,
|
||||
action,
|
||||
return_code: descendant_i32(root, "ReturnCode")?.unwrap_or(0),
|
||||
status_code: descendant_i32(root, "StatusCode")?.unwrap_or(0),
|
||||
status: redact_sensitive(
|
||||
&descendant_text(root, "StatusString").unwrap_or_else(|| "OK".into()),
|
||||
),
|
||||
document: xml.to_owned(),
|
||||
}))
|
||||
}
|
||||
"Event" => Ok(ParsedFrame::Event(VivoxEvent {
|
||||
kind: root
|
||||
.attribute("type")
|
||||
.ok_or(VivoxError::Protocol("event type is missing"))?
|
||||
.to_owned(),
|
||||
status_code: descendant_i32(root, "StatusCode")?.unwrap_or(0),
|
||||
status: redact_sensitive(
|
||||
&descendant_text(root, "StatusString").unwrap_or_else(|| "OK".into()),
|
||||
),
|
||||
state: descendant_i32(root, "State")?,
|
||||
session_handle: optional_secret_field(root, "SessionHandle")?,
|
||||
participant_uri: optional_secret_field(root, "ParticipantURI")?
|
||||
.or(optional_secret_field(root, "URI")?),
|
||||
account_name: descendant_text(root, "AccountName"),
|
||||
display_name: descendant_text(root, "DisplayName"),
|
||||
is_speaking: descendant_bool(root, "IsSpeaking")?,
|
||||
volume: descendant_i32(root, "Volume")?,
|
||||
energy: descendant_text(root, "Energy")
|
||||
.map(|value| value.parse())
|
||||
.transpose()
|
||||
.map_err(|_| VivoxError::Protocol("event energy is invalid"))?,
|
||||
})),
|
||||
_ => Err(VivoxError::Protocol("expected Response or Event root")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_document(xml: &str) -> Result<Document<'_>, VivoxError> {
|
||||
Document::parse(xml).map_err(|_| VivoxError::Protocol("XML is malformed"))
|
||||
}
|
||||
|
||||
fn descendant_text(root: Node<'_, '_>, name: &str) -> Option<String> {
|
||||
root.descendants()
|
||||
.find(|node| node.has_tag_name(name))
|
||||
.and_then(|node| node.text())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn descendant_i32(root: Node<'_, '_>, name: &str) -> Result<Option<i32>, VivoxError> {
|
||||
descendant_text(root, name)
|
||||
.map(|value| {
|
||||
value
|
||||
.parse()
|
||||
.map_err(|_| VivoxError::Protocol("numeric response field is invalid"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn descendant_bool(root: Node<'_, '_>, name: &str) -> Result<Option<bool>, VivoxError> {
|
||||
descendant_text(root, name)
|
||||
.map(|value| match value.as_str() {
|
||||
"true" | "1" => Ok(true),
|
||||
"false" | "0" => Ok(false),
|
||||
_ => Err(VivoxError::Protocol("boolean event field is invalid")),
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn optional_secret_field(
|
||||
root: Node<'_, '_>,
|
||||
name: &str,
|
||||
) -> Result<Option<VivoxSecret>, VivoxError> {
|
||||
descendant_text(root, name)
|
||||
.map(VivoxSecret::new)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn required_secret_field(response: &VivoxResponse, name: &str) -> Result<VivoxSecret, VivoxError> {
|
||||
response
|
||||
.field(name)?
|
||||
.ok_or(VivoxError::Protocol("required response handle is missing"))
|
||||
.and_then(VivoxSecret::new)
|
||||
}
|
||||
|
||||
fn devices_from_response(
|
||||
response: &VivoxResponse,
|
||||
item_name: &str,
|
||||
current_name: &str,
|
||||
) -> Result<VivoxDevices, VivoxError> {
|
||||
let document = parse_document(&response.document)?;
|
||||
let root = document.root_element();
|
||||
let available = root
|
||||
.descendants()
|
||||
.filter(|node| node.has_tag_name(item_name))
|
||||
.filter_map(|node| {
|
||||
node.descendants()
|
||||
.find(|child| child.has_tag_name("Device"))
|
||||
.and_then(|child| child.text())
|
||||
.or_else(|| node.text())
|
||||
})
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let current = root
|
||||
.descendants()
|
||||
.find(|node| node.has_tag_name(current_name))
|
||||
.and_then(|node| {
|
||||
node.descendants()
|
||||
.find(|child| child.has_tag_name("Device"))
|
||||
.and_then(|child| child.text())
|
||||
.or_else(|| node.text())
|
||||
})
|
||||
.map(str::to_owned);
|
||||
Ok(VivoxDevices { current, available })
|
||||
}
|
||||
|
||||
fn validate_name(value: &str) -> Result<(), VivoxError> {
|
||||
if !value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(VivoxError::Protocol("XML name or action is invalid"))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_sensitive_field(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"AccountPassword"
|
||||
| "Password"
|
||||
| "ConnectorHandle"
|
||||
| "AccountHandle"
|
||||
| "SessionHandle"
|
||||
| "ParticipantURI"
|
||||
| "ChannelURI"
|
||||
| "URI"
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_text(value: &str) -> Result<(), VivoxError> {
|
||||
if value.len() <= 64 * 1024
|
||||
&& !value
|
||||
.chars()
|
||||
.any(|ch| matches!(ch, '\0'..='\x08' | '\x0b' | '\x0c' | '\x0e'..='\x1f'))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(VivoxError::Protocol("XML text is invalid or oversized"))
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_xml_into(value: &str, output: &mut String) {
|
||||
for character in value.chars() {
|
||||
match character {
|
||||
'&' => output.push_str("&"),
|
||||
'<' => 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!("</{tag}>");
|
||||
let mut search_from = 0;
|
||||
while let Some(relative_start) = output[search_from..].find(&opening) {
|
||||
let value_start = search_from + relative_start + opening.len();
|
||||
let Some(relative_end) = output[value_start..].find(&closing) else {
|
||||
break;
|
||||
};
|
||||
let value_end = value_start + relative_end;
|
||||
output.replace_range(value_start..value_end, "<redacted>");
|
||||
search_from = value_start + "<redacted>".len() + closing.len();
|
||||
}
|
||||
}
|
||||
if output.starts_with("sip:") || output.starts_with("http://") || output.starts_with("https://")
|
||||
{
|
||||
return "<redacted-uri>".into();
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_response_and_participant_event_without_debug_leaks() {
|
||||
let response = parse_frame("<Response requestId=\"7\" action=\"Account.Login.1\"><ReturnCode>0</ReturnCode><Results><StatusCode>0</StatusCode><StatusString>OK</StatusString><AccountHandle>account-secret</AccountHandle></Results></Response>").unwrap();
|
||||
let ParsedFrame::Response(response) = response else {
|
||||
panic!("response")
|
||||
};
|
||||
assert_eq!(response.request_id, 7);
|
||||
assert_eq!(
|
||||
required_secret_field(&response, "AccountHandle")
|
||||
.unwrap()
|
||||
.expose_secret(),
|
||||
"account-secret"
|
||||
);
|
||||
assert!(!format!("{response:?}").contains("account-secret"));
|
||||
|
||||
let event = parse_frame("<Event type=\"ParticipantPropertiesEvent\"><SessionHandle>session-secret</SessionHandle><ParticipantURI>sip:token@example.test</ParticipantURI><DisplayName>Alice</DisplayName><IsSpeaking>true</IsSpeaking><Volume>12</Volume><Energy>0.5</Energy></Event>").unwrap();
|
||||
let ParsedFrame::Event(event) = event else {
|
||||
panic!("event")
|
||||
};
|
||||
assert_eq!(event.display_name.as_deref(), Some("Alice"));
|
||||
assert_eq!(event.is_speaking, Some(true));
|
||||
let debug = format!("{event:?}");
|
||||
assert!(!debug.contains("session-secret"));
|
||||
assert!(!debug.contains("sip:token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_escaping_and_redaction_cover_control_secrets() {
|
||||
let mut escaped = String::new();
|
||||
escape_xml_into("<&>\"'", &mut escaped);
|
||||
assert_eq!(escaped, "<&>"'");
|
||||
let redacted = redact_sensitive(
|
||||
"<AccountPassword>secret</AccountPassword><SessionHandle>handle</SessionHandle>",
|
||||
);
|
||||
assert!(!redacted.contains("secret"));
|
||||
assert!(!redacted.contains("handle"));
|
||||
assert_eq!(
|
||||
redact_sensitive("sip:user@example.test?token=secret"),
|
||||
"<redacted-uri>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_active_xml_and_invalid_control_fields() {
|
||||
assert!(parse_frame("<!DOCTYPE x><Event type=\"x\" />").is_err());
|
||||
assert!(validate_name("bad name").is_err());
|
||||
assert!(validate_text("bad\0text").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1265,6 +1265,17 @@ impl Simulator {
|
||||
.map(|inner| Caps::native_from_inner(inner, self.native_clone_without_caps()))
|
||||
}
|
||||
|
||||
/// Looks up one capability without exposing the simulator's mutable caps state.
|
||||
///
|
||||
/// Capability URLs are bearer credentials. Callers must not log or persist
|
||||
/// the returned URI.
|
||||
pub fn native_capability_uri(&self, capability: &str) -> Result<Option<Uri>, Error> {
|
||||
let Some(caps) = self.native_caps() else {
|
||||
return Ok(None);
|
||||
};
|
||||
caps.capability_uri(capability.to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn native_id(&self) -> UUID {
|
||||
self.data.id
|
||||
}
|
||||
|
||||
35
docs/vivox.md
Normal file
35
docs/vivox.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Native Vivox validation
|
||||
|
||||
MetaCrate's Vivox support is a cross-platform Rust implementation of the SDK
|
||||
control protocol. `VivoxControlClient` connects to a caller-supplied TCP socket,
|
||||
writes bounded XML requests terminated by the Vivox three-newline delimiter,
|
||||
correlates responses by request ID and action, and queues asynchronous login,
|
||||
session, and participant events. It does not start or ship proprietary code.
|
||||
|
||||
The client owns connector, account, and session state. Invalid ordering is
|
||||
rejected locally. `shutdown()` is idempotent and reverses active state in this
|
||||
order: sessions, account, connector, TCP writer. Response failures preserve the
|
||||
action and numeric return/status codes while passwords, handles, and URIs remain
|
||||
opaque. XML is escaped on output; input is capped at 1 MiB and rejects DTD or
|
||||
entity declarations.
|
||||
|
||||
`vivox-test --fake-script FILE` launches a deterministic fake control service on
|
||||
IPv4 loopback. It validates the same wire requests used with a real daemon,
|
||||
scripts device responses and participant events, and waits for both client and
|
||||
server teardown. Live mode additionally logs into a grid, waits for the event
|
||||
queue, requests `ProvisionVoiceAccountRequest` and `ParcelVoiceInfoRequest`, and
|
||||
then exercises the control service. Live login requires both
|
||||
`--allow-live-login` and `--confirm-live-login LOGIN`; joining audio also requires
|
||||
`--allow-session-audio`.
|
||||
|
||||
Use environment variables for live credentials so they are not copied into a
|
||||
shell history or process argument list:
|
||||
|
||||
```sh
|
||||
GRID_FIRST_NAME=... GRID_LAST_NAME=... GRID_PASSWORD=... \
|
||||
cargo run -p libremetaverse-programs --bin vivox-test -- \
|
||||
--allow-live-login --confirm-live-login LOGIN
|
||||
```
|
||||
|
||||
The Vivox daemon/SDK is proprietary and must be installed, configured, and
|
||||
started independently. MetaCrate never discovers or invokes it.
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
fn main() -> std::process::ExitCode {
|
||||
libremetaverse_programs::pending_program("VivoxTest")
|
||||
libremetaverse_programs::vivox_test::main_entry()
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ pub mod packet_dump;
|
||||
pub mod prim_inspector;
|
||||
pub mod simple_bot;
|
||||
pub mod test_client;
|
||||
pub mod vivox_test;
|
||||
|
||||
pub use libremetaverse::shim::pending_program;
|
||||
|
||||
1084
programs/src/vivox_test.rs
Normal file
1084
programs/src/vivox_test.rs
Normal file
File diff suppressed because it is too large
Load Diff
207
programs/tests/vivox_test_cli.rs
Normal file
207
programs/tests/vivox_test_cli.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
const EXIT_USAGE: i32 = 2;
|
||||
const EXIT_INPUT: i32 = 3;
|
||||
const EXIT_SERVICE: i32 = 4;
|
||||
|
||||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl TestDir {
|
||||
fn new(name: &str) -> Self {
|
||||
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"metacrate-vivox-test-{name}-{}-{id}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&path).expect("create test directory");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn write(&self, contents: &str) -> PathBuf {
|
||||
let path = self.0.join("vivox.tsv");
|
||||
fs::write(&path, contents).expect("write fake Vivox script");
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn path_text(path: &Path) -> &str {
|
||||
path.to_str().expect("UTF-8 path")
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_vivox-test"))
|
||||
.args(args)
|
||||
.env_remove("GRID_FIRST_NAME")
|
||||
.env_remove("GRID_LAST_NAME")
|
||||
.env_remove("GRID_PASSWORD")
|
||||
.env_remove("GRID_LOGIN_URL")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.expect("run vivox-test")
|
||||
}
|
||||
|
||||
fn utf8(bytes: &[u8]) -> &str {
|
||||
std::str::from_utf8(bytes).expect("UTF-8 process output")
|
||||
}
|
||||
|
||||
fn assert_exit(output: &Output, expected: i32) {
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(expected),
|
||||
"stdout:\n{}\nstderr:\n{}",
|
||||
utf8(&output.stdout),
|
||||
utf8(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn complete_script(extra: &str) -> String {
|
||||
format!(
|
||||
"capture-device\tFake Microphone\n\
|
||||
capture-device\tBackup Microphone\n\
|
||||
current-capture\tFake Microphone\n\
|
||||
render-device\tFake Speakers\n\
|
||||
current-render\tFake Speakers\n\
|
||||
provision\tvoice-user-secret\tfake-password-secret\tvoice.example.test\n\
|
||||
parcel\tScripted Region\t42\tsip:channel-token-secret@example.test\n\
|
||||
participant\tparticipant-secret\tAlice Resident\tsip:participant-token-secret@example.test\n\
|
||||
{extra}"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_documents_prerequisites_gates_and_scripted_controls() {
|
||||
let output = run(&["--help"]);
|
||||
assert!(output.status.success());
|
||||
let help = utf8(&output.stdout);
|
||||
for marker in [
|
||||
"[FIRSTNAME]",
|
||||
"[LASTNAME]",
|
||||
"[PASSWORD]",
|
||||
"--fake-script",
|
||||
"--daemon-endpoint",
|
||||
"--allow-live-login",
|
||||
"--confirm-live-login",
|
||||
"--allow-session-audio",
|
||||
"proprietary Vivox SDK control service",
|
||||
"does not bundle, locate, start, or invoke",
|
||||
"capture-device",
|
||||
"participant",
|
||||
] {
|
||||
assert!(help.contains(marker), "help omitted {marker}:\n{help}");
|
||||
}
|
||||
let output = run(&[]);
|
||||
assert_exit(&output, EXIT_USAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_tcp_service_exercises_full_flow_and_tears_down_without_secret_leaks() {
|
||||
let directory = TestDir::new("complete");
|
||||
let script = directory.write(&complete_script(""));
|
||||
let output = run(&[
|
||||
"--fake-script",
|
||||
path_text(&script),
|
||||
"--timeout-seconds",
|
||||
"5",
|
||||
]);
|
||||
assert!(output.status.success(), "{}", utf8(&output.stderr));
|
||||
assert!(output.stderr.is_empty(), "{}", utf8(&output.stderr));
|
||||
let stdout = utf8(&output.stdout);
|
||||
for expected in [
|
||||
"* Fake Microphone",
|
||||
" Backup Microphone",
|
||||
"* Fake Speakers",
|
||||
"Voice connector created: <redacted>",
|
||||
"Provisioned voice account logged in: <redacted>",
|
||||
"region=Scripted Region local-id=42 channel=<redacted-uri>",
|
||||
"Voice session connected: <redacted>",
|
||||
"Participant control validated for Alice Resident (URI redacted).",
|
||||
"Voice session terminated.",
|
||||
"Voice account logged out.",
|
||||
"requests=10 active-pipes=0 active-sessions=0 active-tasks=0",
|
||||
] {
|
||||
assert!(stdout.contains(expected), "missing {expected}:\n{stdout}");
|
||||
}
|
||||
for secret in [
|
||||
"voice-user-secret",
|
||||
"fake-password-secret",
|
||||
"channel-token-secret",
|
||||
"participant-secret",
|
||||
"connector-secret",
|
||||
"account-secret",
|
||||
"session-secret",
|
||||
] {
|
||||
assert!(
|
||||
!stdout.contains(secret),
|
||||
"stdout leaked {secret}:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!utf8(&output.stderr).contains(secret),
|
||||
"stderr leaked {secret}:\n{}",
|
||||
utf8(&output.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejection_is_reported_by_codes_redacts_echoed_password_and_still_shuts_down() {
|
||||
let directory = TestDir::new("rejection");
|
||||
let script = directory.write(&complete_script(
|
||||
"reject\tAccount.Login.1\t1\t403\tdenied fake-password-secret\n",
|
||||
));
|
||||
let output = run(&["--fake-script", path_text(&script)]);
|
||||
assert_exit(&output, EXIT_SERVICE);
|
||||
let stderr = utf8(&output.stderr);
|
||||
assert!(stderr.contains("Account.Login.1"), "{stderr}");
|
||||
assert!(stderr.contains("return 1, status 403"), "{stderr}");
|
||||
assert!(stderr.contains("denied <redacted>"), "{stderr}");
|
||||
assert!(!stderr.contains("fake-password-secret"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_scripts_and_ungated_live_inputs_fail_before_network_access() {
|
||||
let directory = TestDir::new("invalid");
|
||||
let missing = directory.write("capture-device\tMicrophone\n");
|
||||
let output = run(&["--fake-script", path_text(&missing)]);
|
||||
assert_exit(&output, EXIT_INPUT);
|
||||
assert!(utf8(&output.stderr).contains("provision directive"));
|
||||
|
||||
let output = run(&["First", "Last", "do-not-echo"]);
|
||||
assert_exit(&output, EXIT_USAGE);
|
||||
assert!(utf8(&output.stderr).contains("--allow-live-login"));
|
||||
assert!(!utf8(&output.stderr).contains("do-not-echo"));
|
||||
|
||||
let output = run(&[
|
||||
"First",
|
||||
"Last",
|
||||
"do-not-echo",
|
||||
"--allow-live-login",
|
||||
"--confirm-live-login",
|
||||
"WRONG",
|
||||
]);
|
||||
assert_exit(&output, EXIT_USAGE);
|
||||
assert!(!utf8(&output.stderr).contains("do-not-echo"));
|
||||
|
||||
let valid = directory.write(&complete_script(""));
|
||||
let output = run(&[
|
||||
"First",
|
||||
"Last",
|
||||
"do-not-echo",
|
||||
"--fake-script",
|
||||
path_text(&valid),
|
||||
]);
|
||||
assert_exit(&output, EXIT_USAGE);
|
||||
assert!(!utf8(&output.stderr).contains("do-not-echo"));
|
||||
}
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
117
tests/compat/tests/vivox_protocol_semantics.rs
Normal file
117
tests/compat/tests/vivox_protocol_semantics.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use libremetaverse_voice_vivox::VivoxControlClient;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const EXPECTED_ACTIONS: [&str; 10] = [
|
||||
"Aux.GetCaptureDevices.1",
|
||||
"Aux.GetRenderDevices.1",
|
||||
"Connector.Create.1",
|
||||
"Account.Login.1",
|
||||
"Session.Create.1",
|
||||
"Session.Connect.1",
|
||||
"Session.SetParticipantVolumeForMe.1",
|
||||
"Session.Terminate.1",
|
||||
"Account.Logout.1",
|
||||
"Connector.InitiateShutdown.1",
|
||||
];
|
||||
|
||||
fn response(id: &str, action: &str, result: &str) -> String {
|
||||
format!(
|
||||
"<Response requestId=\"{id}\" action=\"{action}\"><ReturnCode>0</ReturnCode><Results><StatusCode>0</StatusCode><StatusString>OK</StatusString>{result}</Results></Response>\n\n\n"
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_vivox_protocol_preserves_control_order_events_and_teardown() {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
let endpoint = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut actions = Vec::new();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
assert_ne!(reader.read_line(&mut line).await.unwrap(), 0);
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let id = line
|
||||
.split("requestId=\"")
|
||||
.nth(1)
|
||||
.and_then(|value| value.split('"').next())
|
||||
.unwrap();
|
||||
let action = line
|
||||
.split("action=\"")
|
||||
.nth(1)
|
||||
.and_then(|value| value.split('"').next())
|
||||
.unwrap();
|
||||
actions.push(action.to_owned());
|
||||
let result = match action {
|
||||
"Aux.GetCaptureDevices.1" => {
|
||||
"<CaptureDevices><CaptureDevice><Device>Mic</Device></CaptureDevice></CaptureDevices><CurrentCaptureDevice><Device>Mic</Device></CurrentCaptureDevice>"
|
||||
}
|
||||
"Aux.GetRenderDevices.1" => {
|
||||
"<RenderDevices><RenderDevice><Device>Speakers</Device></RenderDevice></RenderDevices><CurrentRenderDevice><Device>Speakers</Device></CurrentRenderDevice>"
|
||||
}
|
||||
"Connector.Create.1" => "<ConnectorHandle>connector-secret</ConnectorHandle>",
|
||||
"Account.Login.1" => "<AccountHandle>account-secret</AccountHandle>",
|
||||
"Session.Create.1" => "<SessionHandle>session-secret</SessionHandle>",
|
||||
"Session.Connect.1"
|
||||
| "Session.SetParticipantVolumeForMe.1"
|
||||
| "Session.Terminate.1"
|
||||
| "Account.Logout.1"
|
||||
| "Connector.InitiateShutdown.1" => "",
|
||||
_ => panic!("unexpected Vivox action {action}"),
|
||||
};
|
||||
writer
|
||||
.write_all(response(id, action, result).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
if action == "Session.Connect.1" {
|
||||
writer.write_all(b"<Event type=\"ParticipantPropertiesEvent\"><SessionHandle>session-secret</SessionHandle><ParticipantURI>sip:participant-token@example.test</ParticipantURI><DisplayName>Alice Resident</DisplayName><IsSpeaking>true</IsSpeaking><Volume>0</Volume><Energy>0.25</Energy></Event>\n\n\n").await.unwrap();
|
||||
}
|
||||
if action == "Connector.InitiateShutdown.1" {
|
||||
writer.shutdown().await.unwrap();
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut client = VivoxControlClient::connect(endpoint, Duration::from_secs(2))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(client.capture_devices().await.unwrap().available, ["Mic"]);
|
||||
assert_eq!(
|
||||
client.render_devices().await.unwrap().available,
|
||||
["Speakers"]
|
||||
);
|
||||
client
|
||||
.create_connector("https://www.voice.example.test/api2/")
|
||||
.await
|
||||
.unwrap();
|
||||
client.login("voice-user", "voice-password").await.unwrap();
|
||||
let session = client
|
||||
.create_session("sip:channel-token@example.test", "Parity Region", None)
|
||||
.await
|
||||
.unwrap();
|
||||
client.connect_session(&session).await.unwrap();
|
||||
let event = client.next_event().await.unwrap();
|
||||
assert_eq!(event.kind, "ParticipantPropertiesEvent");
|
||||
assert_eq!(event.display_name.as_deref(), Some("Alice Resident"));
|
||||
assert_eq!(event.is_speaking, Some(true));
|
||||
let participant = event.participant_uri.unwrap();
|
||||
client
|
||||
.set_participant_volume(&session, &participant, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
client.terminate_session(&session).await.unwrap();
|
||||
client.logout().await.unwrap();
|
||||
client.shutdown().await.unwrap();
|
||||
client.shutdown().await.unwrap();
|
||||
let actions = server.await.unwrap();
|
||||
assert_eq!(actions, EXPECTED_ACTIONS);
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user