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
1085 lines
37 KiB
Rust
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("&"),
|
|
'<' => output.push_str("<"),
|
|
'>' => output.push_str(">"),
|
|
'\'' => output.push_str("'"),
|
|
'"' => output.push_str("""),
|
|
_ => 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>&")
|
|
);
|
|
}
|
|
}
|