Files
MetaCrate/programs/src/vivox_test.rs
Chili Palmer da4afec708
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
Implement native VivoxTest validation (#95)
2026-08-11 14:09:51 +00:00

1085 lines
37 KiB
Rust

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