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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user