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

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

View File

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

View File

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

View File

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

View File

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