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