diff --git a/programs/Cargo.toml b/programs/Cargo.toml index b72c495..14f9648 100644 --- a/programs/Cargo.toml +++ b/programs/Cargo.toml @@ -9,7 +9,7 @@ publish = false [dependencies] clap = { version = "4.5", features = ["derive"] } libremetaverse = { path = "../crates/libremetaverse", default-features = false } -tokio = { version = "1.47", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } [lints] workspace = true diff --git a/programs/README.md b/programs/README.md index fbe2324..c59ae46 100644 --- a/programs/README.md +++ b/programs/README.md @@ -13,7 +13,7 @@ here so a source entry is never mistaken for a completed port. | `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery | | `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures | | `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts | -| `test-client` | TestClient | Pending milestone 11 issues #91–#94 | +| `test-client` | TestClient | Native shell, registry, system, and communication groups implemented; remaining command groups tracked by #92–#94 | | `vivox-test` | VivoxTest | Pending milestone 11 issue #95 | | `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 | @@ -151,6 +151,64 @@ cargo test -p libremetaverse-programs irc_gateway::tests --locked cargo test --manifest-path tests/compat/Cargo.toml --test social_message_semantics --locked ``` +## TestClient + +`test-client` is a native asynchronous multi-avatar command shell. It accepts +one account with `--first`, `--last`, and `--pass`, or a bounded account file +with `--file`. Account-file records use `First Last Password` followed by an +optional `Region/x/y/z` start location. `--loginuri`, `--startpos`, `--master`, +`--masterkey`, `--groupcommands`, `--scriptfile`, and `--nogui` preserve the +upstream shell controls. Each login has a configurable timeout, the registry is +limited to 64 clients, and `@ First Last` selects one client while `@` restores +broadcast command routing. + +The implemented command groups are `@`, `debug`, `echomaster`, `help`, `im`, +`imgroup`, `load`, `login`, `logpacket`, `logout`, `md5`, `quit`, `say`, +`setmaster`, `setmasterkey`, `shout`, `showeffects`, `sleep`, `waitforlogin`, and +`whisper`. Chat and instant-message bodies are bounded to the grid protocol +limit. Group commands require both `--groupcommands` and current group +membership. Master chat can be echoed, master teleport lures are accepted, and +remote `login` and `md5` command text is redacted from transcripts. Packet logs +contain only timestamped packet type, simulator name, and byte count; they are +limited to 10,000 records and 16 MiB. + +The native `load` command reads a portable command-alias manifest instead of a +CLR assembly. Each non-comment line is tab-separated +`namedescriptiontemplate`; `{args}` or `$*` inserts the quoted command +arguments. Manifests are limited to 1 MiB and 128 commands, with an alias +expansion depth of eight. + +Use `--fake-script FILE` for deterministic, credential-free terminal and grid +validation. Normal lines are dispatched exactly like interactive input. Fake +grid records begin with `!` and use tab-separated fields: + +```text +!clientclient-uuidfirstlast +!clientclient-uuidfirstlastmaster-namemaster-uuidtrue|false +!personavatar-nameavatar-uuid +!group-memberavatar-uuid +!chatclient-uuidsource-uuidnamemessage +!imclient-uuidsource-uuidnameagent|object|teleporttrue|falsemessagesession-uuid +!packetclient-uuidpacket-typesimulatorbytes +!effectclient-uuidsummary +!disconnectclient-uuidreason +!cancel +!shutdown +``` + +Inputs are limited to 1 MiB and 4,096 lines. The fake transcript records native +backend calls and ends with connected-client, pending-login, active-task, +shutdown, dropped-event, and pending-command-inventory state. Run the focused +framework and compatibility checks with: + +```sh +cargo test -p libremetaverse-programs --test test_client_cli --locked +cargo test -p libremetaverse-programs test_client::tests --locked +cargo test --manifest-path tests/compat/Cargo.toml --test core_runtime_shims --locked +cargo test --manifest-path tests/compat/Cargo.toml --test social_message_semantics --locked +cargo test --manifest-path tests/compat/Cargo.toml --test network_semantics --locked +``` + ## PacketDump `packet-dump` preserves the upstream live arguments and its 20-second login diff --git a/programs/src/bin/test_client.rs b/programs/src/bin/test_client.rs index 02a3a1e..59e11fa 100644 --- a/programs/src/bin/test_client.rs +++ b/programs/src/bin/test_client.rs @@ -1,5 +1,3 @@ fn main() -> std::process::ExitCode { - let command_count = libremetaverse_programs::commands::TEST_CLIENT_COMMANDS.len(); - eprintln!("TestClient command inventory contains {command_count} translated targets"); - libremetaverse_programs::pending_program("TestClient") + libremetaverse_programs::test_client::main_entry() } diff --git a/programs/src/lib.rs b/programs/src/lib.rs index a8867da..a032cc4 100644 --- a/programs/src/lib.rs +++ b/programs/src/lib.rs @@ -7,5 +7,6 @@ pub mod osd_inspector; pub mod packet_dump; pub mod prim_inspector; pub mod simple_bot; +pub mod test_client; pub use libremetaverse::shim::pending_program; diff --git a/programs/src/test_client.rs b/programs/src/test_client.rs new file mode 100644 index 0000000..a029cd6 --- /dev/null +++ b/programs/src/test_client.rs @@ -0,0 +1,2502 @@ +//! Native `TestClient` shell, registry, and first-wave command groups. + +use crate::commands::TEST_CLIENT_COMMANDS; +use clap::Parser; +use libremetaverse::packets::PacketType; +use libremetaverse::types::compat::{CancellationToken, CancellationTokenSource, Subscription}; +use libremetaverse::types::{UUID, Utils}; +use libremetaverse::{ + AgentManager, ChatAudibleLevel, ChatEventArgs, ChatType, DirectoryManager, + DisconnectedEventArgs, GridClient, InstantMessageDialog, InstantMessageEventArgs, + NetworkManager, PacketReceivedEventArgs, +}; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs::File; +use std::future::Future; +use std::io::{self, BufRead, BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::process::ExitCode; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::io::{AsyncBufReadExt, BufReader as AsyncBufReader}; +use tokio::sync::{mpsc, oneshot}; + +pub const EXIT_SUCCESS: u8 = 0; +pub const EXIT_USAGE: u8 = 2; +pub const EXIT_INPUT: u8 = 3; +pub const EXIT_CLIENT: u8 = 4; + +const MAX_INPUT_BYTES: u64 = 1024 * 1024; +const MAX_INPUT_LINES: usize = 4096; +const MAX_CLIENTS: usize = 64; +const MAX_COMMAND_BYTES: usize = 4096; +const MAX_MESSAGE_BYTES: usize = 1023; +const EVENT_QUEUE_CAPACITY: usize = 512; +const MAX_PACKET_LOG_COUNT: usize = 10_000; +const MAX_PACKET_LOG_BYTES: u64 = 16 * 1024 * 1024; +const MAX_LOADED_COMMANDS: usize = 128; +const MAX_ALIAS_DEPTH: usize = 8; + +pub const IMPLEMENTED_TEST_CLIENT_COMMANDS: &[&str] = &[ + "EchoMasterCommand", + "IMCommand", + "IMGroupCommand", + "SayCommand", + "ShoutCommand", + "WhisperCommand", + "AtCommand", + "DebugCommand", + "HelpCommand", + "LoadCommand", + "LogPacketCommand", + "LoginCommand", + "LogoutCommand", + "MD5Command", + "QuitCommand", + "SetMasterCommand", + "SetMasterKeyCommand", + "ShowEffectsCommand", + "SleepCommand", + "WaitForLoginCommand", +]; + +#[must_use] +pub fn pending_test_client_commands() -> Vec<&'static str> { + let implemented: HashSet<_> = IMPLEMENTED_TEST_CLIENT_COMMANDS.iter().copied().collect(); + TEST_CLIENT_COMMANDS + .iter() + .copied() + .filter(|name| !implemented.contains(name)) + .collect() +} + +#[derive(Parser)] +#[command( + name = "test-client", + version, + about = "Run the native LibreMetaverse multi-client command shell", + long_about = None, + after_help = "Owned commands: @, debug, echomaster, help, im, imgroup, load, login, logpacket, logout, md5, quit, say, setmaster, setmasterkey, shout, showeffects, sleep, waitforlogin, whisper" +)] +struct Cli { + /// First name for one initial account. + #[arg(long)] + first: Option, + /// Last name for one initial account. + #[arg(long)] + last: Option, + /// Password for one initial account. Never printed or persisted. + #[arg(long)] + pass: Option, + /// Bounded account list: `First Last Password [Region/x/y/z]`. + #[arg(long, value_name = "FILE")] + file: Option, + /// Grid login endpoint. `GRID_LOGIN_URL` is the fallback. + #[arg(long)] + loginuri: Option, + /// Initial location in `Region/x/y/z` form. + #[arg(long)] + startpos: Option, + /// Master avatar name. + #[arg(long)] + master: Option, + /// Master avatar UUID. + #[arg(long)] + masterkey: Option, + /// Permit commands from known members of joined group-chat sessions. + #[arg(long)] + groupcommands: bool, + /// Preserve the upstream flag for later texture-aware command groups. + #[arg(long)] + gettextures: bool, + /// Run bounded terminal commands from a file before the interactive loop. + #[arg(long, value_name = "FILE")] + scriptfile: Option, + /// Do not read interactive commands; wait for Ctrl-C after startup commands. + #[arg(long)] + nogui: bool, + /// Run a deterministic fake-grid terminal script and exit. + #[arg(long, value_name = "FILE")] + fake_script: Option, + /// Maximum time allowed for each live login. + #[arg(long, default_value_t = 30, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))] + login_timeout_seconds: u64, +} + +#[derive(Debug)] +enum ProgramError { + Usage(&'static str), + Input { action: String, source: io::Error }, + InvalidInput { line: usize, reason: &'static str }, + Client(String), + Signal, +} + +impl ProgramError { + const fn exit_code(&self) -> u8 { + match self { + Self::Usage(_) => EXIT_USAGE, + Self::Input { .. } | Self::InvalidInput { .. } => EXIT_INPUT, + Self::Client(_) | Self::Signal => EXIT_CLIENT, + } + } +} + +impl fmt::Display for ProgramError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Usage(message) => f.write_str(message), + Self::Input { action, source } => write!(f, "{action}: {source}"), + Self::InvalidInput { line, reason } => { + write!(f, "invalid input at line {line}: {reason}") + } + Self::Client(message) => f.write_str(message), + Self::Signal => f.write_str("could not install the Ctrl-C handler"), + } + } +} + +#[derive(Clone)] +struct Account { + first: String, + last: String, + password: String, + start: Option, + uri: Option, + master_name: String, + master_key: UUID, + group_commands: bool, +} + +#[derive(Clone)] +enum ClientEvent { + Chat { + client: UUID, + source: UUID, + name: String, + message: String, + normal: bool, + fully_audible: bool, + }, + InstantMessage { + client: UUID, + source: UUID, + name: String, + message: String, + dialog: InstantMessageDialog, + session: UUID, + group: bool, + }, + Packet { + client: UUID, + packet_type: String, + simulator: String, + bytes: usize, + }, + Effect { + client: UUID, + summary: String, + }, + Disconnected { + client: UUID, + reason: String, + }, +} + +type BackendFuture<'a, T> = Pin + Send + 'a>>; + +trait ClientBackend: Send + Sync { + fn id(&self) -> UUID; + fn name(&self) -> String; + fn connected(&self) -> bool; + fn chat(&self, message: &str, channel: i32, kind: ChatType) -> Result<(), &'static str>; + fn instant_message(&self, target: UUID, message: &str) -> Result<(), &'static str>; + fn group_message<'a>( + &'a self, + group: UUID, + message: &'a str, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result<(), &'static str>>; + fn resolve_avatar<'a>( + &'a self, + name: &'a str, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, &'static str>>; + fn accept_teleport(&self, source: UUID, session: UUID) -> Result<(), &'static str>; + fn pause(&self) -> Result<(), &'static str>; + fn resume(&self) -> Result<(), &'static str>; + fn is_group_member(&self, id: UUID) -> bool; + fn shutdown(&self); + fn take_calls(&self) -> Vec { + Vec::new() + } +} + +struct LiveBackend { + client: Mutex, + network: NetworkManager, + directory: DirectoryManager, + id: UUID, + name: String, + subscriptions: Mutex>, + closed: AtomicBool, +} + +impl LiveBackend { + async fn login( + mut account: Account, + timeout: Duration, + events: mpsc::Sender, + dropped: Arc, + cancellation: CancellationToken, + ) -> Result, ProgramError> { + let mut client = GridClient::new() + .map_err(|_| ProgramError::Client("could not construct GridClient".into()))?; + let name = format!("{} {}", account.first, account.last); + let network = client.network(); + let directory = client.directory(); + let mut login = network + .default_login_params( + std::mem::take(&mut account.first), + std::mem::take(&mut account.last), + std::mem::take(&mut account.password), + "TestClient".into(), + env!("CARGO_PKG_VERSION").into(), + ) + .map_err(|_| ProgramError::Client("could not build login parameters".into()))?; + if let Some(start) = account.start.take() { + login.start = start; + } + if let Some(uri) = account.uri.take() { + login.uri = uri; + } + let login_cancel = CancellationTokenSource::new(); + let result = tokio::select! { + () = cancellation.cancelled() => { + login_cancel.cancel(); + let _ = network.abort_login(); + return Err(ProgramError::Client("login cancelled".into())); + }, + () = tokio::time::sleep(timeout) => { + login_cancel.cancel(); + let _ = network.abort_login(); + return Err(ProgramError::Client("login timed out".into())); + }, + result = network.login_with_login_params_cancellation_token(login, Some(login_cancel.token())) => result, + } + .map_err(|_| ProgramError::Client("grid login failed".into()))?; + if !result { + return Err(ProgramError::Client("grid login was rejected".into())); + } + let id = client.self_().agent_id(); + let backend = Arc::new(Self { + client: Mutex::new(client), + network, + directory, + id, + name, + subscriptions: Mutex::new(Vec::new()), + closed: AtomicBool::new(false), + }); + backend.install(&events, &dropped); + Ok(backend) + } + + fn with_agent(&self, action: impl FnOnce(&mut AgentManager) -> T) -> T { + action(lock(&self.client).self_()) + } + + fn install(self: &Arc, events: &mpsc::Sender, dropped: &Arc) { + let mut subscriptions = Vec::new(); + let sender = events.clone(); + let dropped_chat = Arc::clone(dropped); + let id = self.id; + subscriptions.push(self.with_agent(|agent| { + agent.subscribe_chat_from_simulator(Arc::new(move |event: ChatEventArgs| { + try_event( + &sender, + ClientEvent::Chat { + client: id, + source: event.source_id(), + name: event.from_name(), + message: event.message(), + normal: event.type_() == ChatType::Normal, + fully_audible: event.audible_level() == ChatAudibleLevel::Fully, + }, + &dropped_chat, + ); + })) + })); + let sender = events.clone(); + let dropped_im = Arc::clone(dropped); + subscriptions.push(self.with_agent(|agent| { + agent.subscribe_im(Arc::new(move |event: InstantMessageEventArgs| { + let im = event.im(); + try_event( + &sender, + ClientEvent::InstantMessage { + client: id, + source: im.from_agent_id, + name: im.from_agent_name, + message: im.message, + dialog: im.dialog, + session: im.im_session_id, + group: im.group_im, + }, + &dropped_im, + ); + })) + })); + let sender = events.clone(); + let dropped_packet = Arc::clone(dropped); + subscriptions.push(self.network.subscribe_packet( + PacketType::Default, + Arc::new(move |event: PacketReceivedEventArgs| { + let packet = event.packet(); + let bytes = event.raw_data().map_or(0, |data| data.len()); + try_event( + &sender, + ClientEvent::Packet { + client: id, + packet_type: format!("{:?}", packet.type_), + simulator: event.simulator().name.clone(), + bytes, + }, + &dropped_packet, + ); + }), + false, + )); + let sender = events.clone(); + let dropped_effect = Arc::clone(dropped); + subscriptions.push(self.network.subscribe_packet( + PacketType::ViewerEffect, + Arc::new(move |event: PacketReceivedEventArgs| { + try_event( + &sender, + ClientEvent::Effect { + client: id, + summary: format!( + "ViewerEffect simulator={} bytes={}", + event.simulator().name.clone(), + event.raw_data().map_or(0, |data| data.len()) + ), + }, + &dropped_effect, + ); + }), + false, + )); + let sender = events.clone(); + let dropped_disconnect = Arc::clone(dropped); + subscriptions.push(self.network.subscribe_disconnected(Arc::new( + move |event: DisconnectedEventArgs| { + try_event( + &sender, + ClientEvent::Disconnected { + client: id, + reason: format!("{:?}: {}", event.reason(), event.message()), + }, + &dropped_disconnect, + ); + }, + ))); + *lock(&self.subscriptions) = subscriptions; + } +} + +impl ClientBackend for LiveBackend { + fn id(&self) -> UUID { + self.id + } + + fn name(&self) -> String { + self.name.clone() + } + + fn connected(&self) -> bool { + !self.closed.load(Ordering::Acquire) && self.network.connected() + } + + fn chat(&self, message: &str, channel: i32, kind: ChatType) -> Result<(), &'static str> { + self.with_agent(|agent| agent.chat(message.into(), channel, kind, Some(false))) + .map_err(|_| "send local chat") + } + + fn instant_message(&self, target: UUID, message: &str) -> Result<(), &'static str> { + self.with_agent(|agent| agent.instant_message_with_uuid_string(target, message.into())) + .map_err(|_| "send instant message") + } + + fn group_message<'a>( + &'a self, + group: UUID, + message: &'a str, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result<(), &'static str>> { + Box::pin(async move { + let already_joined = self.with_agent(|agent| { + agent + .group_chat_sessions + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(&group) + }); + if !already_joined { + let (sender, receiver) = oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + let callback_sender = Arc::clone(&sender); + let subscription = self.with_agent(|agent| { + agent.subscribe_group_chat_joined(Arc::new(move |event| { + if event.session_id() == group + && let Some(sender) = lock(&callback_sender).take() + { + let _ = sender.send(event.success()); + } + })) + }); + self.with_agent(|agent| agent.request_join_group_chat(group)) + .map_err(|_| "request group-chat join")?; + let joined = tokio::select! { + () = cancellation.cancelled() => return Err("group-chat join cancelled"), + () = tokio::time::sleep(Duration::from_secs(20)) => return Err("group-chat join timed out"), + result = receiver => result.unwrap_or(false), + }; + drop(subscription); + if !joined { + return Err("group-chat join failed"); + } + } + self.with_agent(|agent| { + agent.instant_message_group_with_uuid_string(group, message.into()) + }) + .map_err(|_| "send group instant message") + }) + } + + fn resolve_avatar<'a>( + &'a self, + name: &'a str, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, &'static str>> { + Box::pin(async move { + let (sender, receiver) = oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + let query = Arc::new(Mutex::new(UUID::zero())); + let callback_sender = Arc::clone(&sender); + let callback_query = Arc::clone(&query); + let expected = name.to_owned(); + let subscription = self + .directory + .subscribe_dir_people_reply(Arc::new(move |event| { + if event.query_id() != *lock(&callback_query) { + return; + } + let match_id = event + .matched_people() + .into_iter() + .find(|person| person.to_string().eq_ignore_ascii_case(&expected)) + .map(|person| person.agent_id); + if let Some(sender) = lock(&callback_sender).take() { + let _ = sender.send(match_id); + } + })); + let query_id = self + .directory + .start_people_search(name.into(), 0) + .map_err(|_| "start avatar directory search")?; + *lock(&query) = query_id; + let result = tokio::select! { + () = cancellation.cancelled() => Err("avatar lookup cancelled"), + () = tokio::time::sleep(Duration::from_mins(1)) => Err("avatar lookup timed out"), + result = receiver => Ok(result.unwrap_or(None)), + }; + drop(subscription); + result + }) + } + + fn accept_teleport(&self, source: UUID, session: UUID) -> Result<(), &'static str> { + self.with_agent(|agent| agent.teleport_lure_respond(source, session, true)) + .map_err(|_| "accept teleport lure") + } + + fn pause(&self) -> Result<(), &'static str> { + self.network + .current_sim() + .ok_or("no current simulator")? + .pause() + .map_err(|_| "pause simulator") + } + + fn resume(&self) -> Result<(), &'static str> { + self.network + .current_sim() + .ok_or("no current simulator")? + .resume() + .map_err(|_| "resume simulator") + } + + fn is_group_member(&self, id: UUID) -> bool { + self.with_agent(|agent| { + agent + .group_chat_sessions + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .any(|members| members.iter().any(|member| member.avatar_key == id)) + }) + } + + fn shutdown(&self) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + lock(&self.subscriptions).clear(); + let _ = self.network.logout_with_method(); + let mut client = lock(&self.client); + let _ = client.self_().dispose(); + let _ = client.dispose_with_method(); + } +} + +struct FakeBackend { + id: UUID, + name: String, + connected: AtomicBool, + calls: Mutex>, + people: Arc>>, + group_members: Arc>>, +} + +impl FakeBackend { + fn new( + id: UUID, + name: String, + people: Arc>>, + group_members: Arc>>, + ) -> Self { + Self { + id, + name, + connected: AtomicBool::new(true), + calls: Mutex::new(Vec::new()), + people, + group_members, + } + } + + fn record(&self, value: String) { + lock(&self.calls).push(value); + } +} + +impl ClientBackend for FakeBackend { + fn id(&self) -> UUID { + self.id + } + fn name(&self) -> String { + self.name.clone() + } + fn connected(&self) -> bool { + self.connected.load(Ordering::Acquire) + } + fn chat(&self, message: &str, channel: i32, kind: ChatType) -> Result<(), &'static str> { + self.record(format!( + "CALL chat channel={channel} type={kind:?} {message}" + )); + Ok(()) + } + fn instant_message(&self, target: UUID, message: &str) -> Result<(), &'static str> { + self.record(format!("CALL instant-message {target} {message}")); + Ok(()) + } + fn group_message<'a>( + &'a self, + group: UUID, + message: &'a str, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result<(), &'static str>> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("group-chat join cancelled"); + } + self.record(format!("CALL group-chat-join {group}")); + self.record(format!("CALL group-instant-message {group} {message}")); + Ok(()) + }) + } + fn resolve_avatar<'a>( + &'a self, + name: &'a str, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, &'static str>> { + let result = lock(&self.people).get(&name.to_ascii_lowercase()).copied(); + Box::pin(async move { + if cancellation.is_cancellation_requested() { + Err("avatar lookup cancelled") + } else { + Ok(result) + } + }) + } + fn accept_teleport(&self, source: UUID, session: UUID) -> Result<(), &'static str> { + self.record(format!( + "CALL teleport-lure-respond {source} {session} accept=true" + )); + Ok(()) + } + fn pause(&self) -> Result<(), &'static str> { + self.record("CALL agent-pause".into()); + Ok(()) + } + fn resume(&self) -> Result<(), &'static str> { + self.record("CALL agent-resume".into()); + Ok(()) + } + fn is_group_member(&self, id: UUID) -> bool { + lock(&self.group_members).contains(&id) + } + fn shutdown(&self) { + if self.connected.swap(false, Ordering::AcqRel) { + self.record("CALL logout-dispose".into()); + } + } + fn take_calls(&self) -> Vec { + std::mem::take(&mut *lock(&self.calls)) + } +} + +enum OutputKind { + Console, + Buffered(Mutex>), +} + +struct ProgramOutput(OutputKind); + +impl ProgramOutput { + fn console() -> Arc { + Arc::new(Self(OutputKind::Console)) + } + + fn buffered() -> Arc { + Arc::new(Self(OutputKind::Buffered(Mutex::new(Vec::new())))) + } + + fn line(&self, value: impl AsRef) { + let line = redact_text(value.as_ref()); + match &self.0 { + OutputKind::Console => println!("{line}"), + OutputKind::Buffered(lines) => lock(lines).push(line), + } + } + + fn drain(&self) -> Vec { + match &self.0 { + OutputKind::Console => Vec::new(), + OutputKind::Buffered(lines) => std::mem::take(&mut *lock(lines)), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum CommandCategory { + Communication, + TestClient, + Other, +} + +#[derive(Clone)] +enum CommandHandler { + At, + Debug, + EchoMaster, + Help, + Im, + ImGroup, + Load, + Login, + LogPacket, + Logout, + Md5, + Quit, + Say(ChatType), + SetMaster, + SetMasterKey, + ShowEffects, + Sleep, + WaitForLogin, + LoadedAlias(String), +} + +#[derive(Clone)] +struct CommandDefinition { + name: String, + description: String, + category: CommandCategory, + handler: CommandHandler, +} + +type CommandEntry = (&'static str, &'static str, CommandCategory, CommandHandler); + +fn framework_commands() -> [CommandEntry; 14] { + [ + ( + "@", + "Restrict following commands to one or all avatars. Usage: @ [firstname lastname]", + CommandCategory::TestClient, + CommandHandler::At, + ), + ( + "debug", + "Turn logging on or off. Usage: debug [None|Trace|Debug|Info|Warn|Error|Critical]", + CommandCategory::TestClient, + CommandHandler::Debug, + ), + ( + "help", + "Lists available commands. Usage: help [command]", + CommandCategory::TestClient, + CommandHandler::Help, + ), + ( + "load", + "Load a bounded native command-alias manifest. Usage: load [file]", + CommandCategory::TestClient, + CommandHandler::Load, + ), + ( + "login", + "Log in another avatar. Usage: login firstname lastname password [simname] [loginuri]", + CommandCategory::TestClient, + CommandHandler::Login, + ), + ( + "logpacket", + "Log packet metadata. Usage: logpacket [count] [file]", + CommandCategory::TestClient, + CommandHandler::LogPacket, + ), + ( + "logout", + "Log this avatar out", + CommandCategory::TestClient, + CommandHandler::Logout, + ), + ( + "md5", + "Create the LibreMetaverse login MD5 hash. Usage: md5 [password]", + CommandCategory::Other, + CommandHandler::Md5, + ), + ( + "quit", + "Log all avatars out and shut down", + CommandCategory::TestClient, + CommandHandler::Quit, + ), + ( + "setmaster", + "Resolve and set the master. Usage: setmaster [name]", + CommandCategory::TestClient, + CommandHandler::SetMaster, + ), + ( + "setmasterkey", + "Set the master UUID. Usage: setmasterkey [uuid]", + CommandCategory::TestClient, + CommandHandler::SetMasterKey, + ), + ( + "showeffects", + "Print viewer effects. Usage: showeffects [on|off]", + CommandCategory::Other, + CommandHandler::ShowEffects, + ), + ( + "sleep", + "Pause, sleep, and resume. Usage: sleep [seconds]", + CommandCategory::TestClient, + CommandHandler::Sleep, + ), + ( + "waitforlogin", + "Wait for all current login attempts", + CommandCategory::TestClient, + CommandHandler::WaitForLogin, + ), + ] +} + +fn communication_commands() -> [CommandEntry; 6] { + [ + ( + "echomaster", + "Repeat fully audible normal chat from the configured master", + CommandCategory::Communication, + CommandHandler::EchoMaster, + ), + ( + "im", + "Instant message someone. Usage: im [firstname] [lastname] [message]", + CommandCategory::Communication, + CommandHandler::Im, + ), + ( + "imgroup", + "Send a group instant message. Usage: imgroup [group_uuid] [message]", + CommandCategory::Communication, + CommandHandler::ImGroup, + ), + ( + "say", + "Say something. Usage: say [optional-channel] message", + CommandCategory::Communication, + CommandHandler::Say(ChatType::Normal), + ), + ( + "shout", + "Shout something. Usage: shout [optional-channel] message", + CommandCategory::Communication, + CommandHandler::Say(ChatType::Shout), + ), + ( + "whisper", + "Whisper something. Usage: whisper [optional-channel] message", + CommandCategory::Communication, + CommandHandler::Say(ChatType::Whisper), + ), + ] +} + +fn built_in_commands() -> HashMap { + framework_commands() + .into_iter() + .chain(communication_commands()) + .map(|(name, description, category, handler)| { + ( + name.into(), + CommandDefinition { + name: name.into(), + description: description.into(), + category, + handler, + }, + ) + }) + .collect() +} + +struct PacketLog { + writer: BufWriter, + remaining: usize, + written: u64, +} + +impl PacketLog { + fn create(path: &Path, count: usize) -> Result { + Ok(Self { + writer: BufWriter::new(File::create(path)?), + remaining: count, + written: 0, + }) + } + + fn record(&mut self, packet_type: &str, simulator: &str, bytes: usize) -> io::Result { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let line = redact_text(&format!( + "Received unix-ms={timestamp} type={} simulator={} bytes={bytes}", + clean_text(packet_type, 128), + clean_text(simulator, 256) + )); + let length = u64::try_from(line.len()) + .unwrap_or(u64::MAX) + .saturating_add(1); + if self.written.saturating_add(length) > MAX_PACKET_LOG_BYTES { + self.writer.flush()?; + return Ok(true); + } + writeln!(self.writer, "{line}")?; + self.written += length; + self.remaining = self.remaining.saturating_sub(1); + if self.remaining == 0 { + self.writer.flush()?; + Ok(true) + } else { + Ok(false) + } + } +} + +#[derive(Clone, Copy)] +struct ClientOptions(u8); + +impl ClientOptions { + const ALLOW_OBJECT_MASTER: u8 = 1; + const GROUP_COMMANDS: u8 = 1 << 1; + const ECHO_MASTER: u8 = 1 << 2; + const SHOW_EFFECTS: u8 = 1 << 3; + + fn new(master_key: UUID, group_commands: bool) -> Self { + let mut bits = 0; + if master_key != UUID::zero() { + bits |= Self::ALLOW_OBJECT_MASTER; + } + if group_commands { + bits |= Self::GROUP_COMMANDS; + } + Self(bits) + } + + const fn contains(self, option: u8) -> bool { + self.0 & option != 0 + } + + const fn allow_object_master(self) -> bool { + self.contains(Self::ALLOW_OBJECT_MASTER) + } + + const fn group_commands(self) -> bool { + self.contains(Self::GROUP_COMMANDS) + } + + const fn echo_master(self) -> bool { + self.contains(Self::ECHO_MASTER) + } + + const fn show_effects(self) -> bool { + self.contains(Self::SHOW_EFFECTS) + } + + fn set(&mut self, option: u8, enabled: bool) { + if enabled { + self.0 |= option; + } else { + self.0 &= !option; + } + } + + fn toggle_echo_master(&mut self) -> bool { + self.0 ^= Self::ECHO_MASTER; + self.echo_master() + } +} + +struct ManagedClient { + backend: Arc, + master_name: String, + master_key: UUID, + options: ClientOptions, + packet_log: Option, +} + +impl ManagedClient { + fn name(&self) -> String { + self.backend.name() + } +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum RunMode { + Live, + Fake, +} + +struct ClientManager { + clients: HashMap, + selected: Option, + commands: HashMap, + running: bool, + pending_logins: usize, + mode: RunMode, + login_uri: Option, + login_timeout: Duration, + default_master_name: String, + default_master_key: UUID, + default_group_commands: bool, + cancellation: CancellationTokenSource, + output: Arc, + events_tx: mpsc::Sender, + events_rx: mpsc::Receiver, + dropped_events: Arc, + fake_people: Arc>>, + fake_group_members: Arc>>, + archived_calls: Vec, + fake_id: AtomicU64, + debug_level: String, +} + +impl ClientManager { + fn new( + mode: RunMode, + login_uri: Option, + login_timeout: Duration, + master_name: String, + master_key: UUID, + group_commands: bool, + output: Arc, + ) -> Self { + let (events_tx, events_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY); + Self { + clients: HashMap::new(), + selected: None, + commands: built_in_commands(), + running: true, + pending_logins: 0, + mode, + login_uri, + login_timeout, + default_master_name: master_name, + default_master_key: master_key, + default_group_commands: group_commands, + cancellation: CancellationTokenSource::new(), + output, + events_tx, + events_rx, + dropped_events: Arc::new(AtomicUsize::new(0)), + fake_people: Arc::new(Mutex::new(HashMap::new())), + fake_group_members: Arc::new(Mutex::new(HashSet::new())), + archived_calls: Vec::new(), + fake_id: AtomicU64::new(1), + debug_level: "Debug".into(), + } + } + + fn target_ids(&self) -> Vec { + let mut clients: Vec<_> = self + .clients + .iter() + .filter(|(_, client)| { + self.selected + .as_ref() + .is_none_or(|selected| client.name() == *selected) + }) + .map(|(id, client)| (*id, client.name())) + .collect(); + clients.sort_by(|left, right| left.1.cmp(&right.1)); + clients.into_iter().map(|(id, _)| id).collect() + } + + fn add_client(&mut self, client: ManagedClient) { + let name = client.name(); + let duplicates: Vec<_> = self + .clients + .iter() + .filter(|(_, existing)| existing.name().eq_ignore_ascii_case(&name)) + .map(|(id, _)| *id) + .collect(); + for id in duplicates { + if let Some(existing) = self.clients.remove(&id) { + self.archive_client(&existing); + } + } + let id = client.backend.id(); + self.clients.insert(id, client); + self.output.line(format!("Logged in {name} ({id})")); + } + + fn add_fake_client( + &mut self, + id: UUID, + name: String, + master_name: String, + master_key: UUID, + group_commands: bool, + ) { + let backend = Arc::new(FakeBackend::new( + id, + name, + Arc::clone(&self.fake_people), + Arc::clone(&self.fake_group_members), + )); + self.add_client(ManagedClient { + backend, + master_name, + master_key, + options: ClientOptions::new(master_key, group_commands), + packet_log: None, + }); + } + + async fn login_account(&mut self, account: Account) { + if self.clients.len() >= MAX_CLIENTS { + self.output.line("Login rejected: client limit reached"); + return; + } + self.pending_logins += 1; + let display_name = format!("{} {}", account.first, account.last); + let master_name = account.master_name.clone(); + let configured_master_key = account.master_key; + let group_commands = account.group_commands; + let result: Result, ProgramError> = match self.mode { + RunMode::Live => LiveBackend::login( + account, + self.login_timeout, + self.events_tx.clone(), + Arc::clone(&self.dropped_events), + self.cancellation.token(), + ) + .await + .map(|backend| backend as Arc), + RunMode::Fake => { + let sequence = self.fake_id.fetch_add(1, Ordering::AcqRel); + let text = format!("00000000-0000-0000-0000-{sequence:012x}"); + match UUID::new_with_string(text) { + Ok(id) => Ok(Arc::new(FakeBackend::new( + id, + display_name, + Arc::clone(&self.fake_people), + Arc::clone(&self.fake_group_members), + ))), + Err(_) => Err(ProgramError::Client( + "could not make fake client UUID".into(), + )), + } + } + }; + self.pending_logins = self.pending_logins.saturating_sub(1); + match result { + Ok(backend) => { + let mut master_key = configured_master_key; + if master_key == UUID::zero() && !master_name.is_empty() { + match backend + .resolve_avatar(&master_name, self.cancellation.token()) + .await + { + Ok(Some(id)) => master_key = id, + Ok(None) | Err(_) => self + .output + .line(format!("Unable to resolve master key from {master_name}")), + } + } + self.add_client(ManagedClient { + backend, + master_name, + master_key, + options: ClientOptions::new(master_key, group_commands), + packet_log: None, + }); + } + Err(error) => self.output.line(format!("Login failed: {error}")), + } + } + + async fn dispatch_line(&mut self, command_line: &str, from: UUID) { + if command_line.len() > MAX_COMMAND_BYTES { + self.output + .line("Command rejected: line exceeds 4,096 bytes"); + return; + } + let mut expanded = command_line.trim().to_owned(); + for depth in 0..=MAX_ALIAS_DEPTH { + let tokens = match tokenize(&expanded) { + Ok(tokens) => tokens, + Err(error) => { + self.output.line(format!("Command parse error: {error}")); + return; + } + }; + if tokens.is_empty() || tokens[0].starts_with(';') || tokens[0].starts_with('#') { + return; + } + let name = tokens[0].to_ascii_lowercase(); + let args = &tokens[1..]; + if name == "@" || name.starts_with('@') { + self.select_avatar(args); + return; + } + let Some(command) = self.commands.get(&name).cloned() else { + self.output.line(format!("Unknown command {name}")); + return; + }; + if let CommandHandler::LoadedAlias(template) = &command.handler { + if depth == MAX_ALIAS_DEPTH { + self.output + .line("Loaded command alias recursion limit reached"); + return; + } + let args = args.join(" "); + expanded = template.replace("{args}", &args).replace("$*", &args); + continue; + } + self.dispatch_definition(command, args, from).await; + return; + } + } + + fn select_avatar(&mut self, args: &[String]) { + if args.is_empty() { + self.selected = None; + self.output.line("Commanding all avatars now"); + return; + } + if args.len() != 2 { + self.output.line("Usage: @ [firstname lastname]"); + return; + } + let name = format!("{} {}", args[0], args[1]); + let found = self + .clients + .values() + .any(|client| client.name() == name && client.backend.connected()); + self.selected = Some(name.clone()); + if found { + self.output.line(format!("Commanding only {name} now")); + } else { + self.output + .line(format!("Commanding nobody now. Avatar {name} is offline")); + } + } + + async fn dispatch_definition( + &mut self, + command: CommandDefinition, + args: &[String], + from: UUID, + ) { + match command.handler { + CommandHandler::Login => { + match self.account_from_login_args(args) { + Ok(account) => self.login_account(account).await, + Err(message) => self.output.line(message), + } + return; + } + CommandHandler::Quit => { + self.running = false; + self.cancellation.cancel(); + self.shutdown_all(); + self.output + .line("All clients logged out and program finished running."); + return; + } + CommandHandler::Help => { + self.show_help(args); + return; + } + CommandHandler::Load => { + let result = self.load_commands(args); + self.output.line(result); + return; + } + CommandHandler::WaitForLogin => { + self.output.line(format!( + "All pending logins have completed, currently tracking {} bots", + self.clients.len() + )); + return; + } + CommandHandler::Debug => { + let result = self.set_debug(args); + self.output.line(result); + return; + } + CommandHandler::At | CommandHandler::LoadedAlias(_) => return, + _ => {} + } + + let targets = self.target_ids(); + if targets.is_empty() { + self.output.line("No matching online avatars"); + return; + } + if matches!(command.handler, CommandHandler::Logout) { + for id in targets { + if let Some(client) = self.clients.remove(&id) { + let name = client.name(); + self.archive_client(&client); + self.output.line(format!("Logged {name} out")); + } + } + return; + } + + for id in targets { + let Some(client) = self.clients.get_mut(&id) else { + continue; + }; + let result = execute_client_command( + client, + &command.handler, + args, + from, + self.cancellation.token(), + ) + .await; + self.output.line(format!("[{}] {result}", client.name())); + } + } + + fn account_from_login_args(&self, args: &[String]) -> Result { + if args.len() < 3 { + return Err( + "Usage: login firstname lastname password [simname] [login server url]".into(), + ); + } + let mut start = None; + let mut uri = self.login_uri.clone(); + if let Some(value) = args.get(3) { + if value.starts_with("http://") || value.starts_with("https://") { + uri = Some(value.clone()); + } else { + start = Some(parse_start_location(value)?); + } + } + if let Some(value) = args.get(4) + && (value.starts_with("http://") || value.starts_with("https://")) + { + uri = Some(value.clone()); + } + Ok(Account { + first: args[0].clone(), + last: args[1].clone(), + password: args[2].clone(), + start, + uri, + master_name: self.default_master_name.clone(), + master_key: self.default_master_key, + group_commands: self.default_group_commands, + }) + } + + fn show_help(&self, args: &[String]) { + if let Some(name) = args.first() { + let name = name.to_ascii_lowercase(); + self.output.line(self.commands.get(&name).map_or_else( + || format!("Command {name} does not exist. 'help' displays all commands."), + |command| command.description.clone(), + )); + return; + } + let mut commands: Vec<_> = self.commands.values().cloned().collect(); + commands.sort_by(|left, right| { + left.category + .cmp(&right.category) + .then_with(|| left.name.cmp(&right.name)) + }); + let mut current = None; + for command in commands { + if current != Some(command.category) { + current = Some(command.category); + self.output + .line(format!("* {:?} Related Commands:", command.category)); + } + self.output + .line(format!(" {:15} {}", command.name, command.description)); + } + self.output.line("Help [command] for usage/information"); + } + + fn set_debug(&mut self, args: &[String]) -> String { + if args.len() != 1 { + return "Usage: debug [level] where level is one of None, Trace, Debug, Info, Warn, Error, Critical".into(); + } + let level = match args[0].to_ascii_lowercase().as_str() { + "none" => "None", + "trace" => "Trace", + "debug" => "Debug", + "info" => "Info", + "warn" | "warning" => "Warning", + "error" => "Error", + "critical" => "Critical", + _ => return "Usage: debug [level] where level is one of None, Trace, Debug, Info, Warn, Error, Critical".into(), + }; + self.debug_level = level.into(); + format!("Logging is set to {level}") + } + + fn load_commands(&mut self, args: &[String]) -> String { + if args.len() != 1 { + return "Usage: load [native-command-manifest]".into(); + } + let path = Path::new(&args[0]); + let lines = match read_bounded_lines(path) { + Ok(lines) => lines, + Err(error) => return format!("Could not load {}: {error}", path.display()), + }; + let mut loaded = Vec::new(); + for (index, line) in lines.into_iter().enumerate() { + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + if loaded.len() == MAX_LOADED_COMMANDS { + return "Command manifest exceeds 128 aliases".into(); + } + let fields: Vec<_> = line.split('\t').collect(); + let [name, description, template] = fields.as_slice() else { + return format!("Invalid command manifest line {}", index + 1); + }; + let name = name.to_ascii_lowercase(); + if !valid_command_name(&name) + || description.is_empty() + || template.is_empty() + || self.commands.contains_key(&name) + || loaded + .iter() + .any(|command: &CommandDefinition| command.name == name) + { + return format!("Invalid or duplicate command manifest line {}", index + 1); + } + loaded.push(CommandDefinition { + name, + description: clean_text(description, 512), + category: CommandCategory::Other, + handler: CommandHandler::LoadedAlias(clean_text(template, MAX_COMMAND_BYTES)), + }); + } + let count = loaded.len(); + for command in loaded { + self.commands.insert(command.name.clone(), command); + } + format!( + "Loaded {count} native command alias(es) from {}", + path.display() + ) + } + + fn process_event(&mut self, event: ClientEvent) -> Option<(String, UUID)> { + let client_id = match &event { + ClientEvent::Chat { client, .. } + | ClientEvent::InstantMessage { client, .. } + | ClientEvent::Packet { client, .. } + | ClientEvent::Effect { client, .. } + | ClientEvent::Disconnected { client, .. } => *client, + }; + let client = self.clients.get_mut(&client_id)?; + match event { + ClientEvent::Chat { + source, + name, + message, + normal, + fully_audible, + .. + } => { + let authorized = source == client.master_key + || (!client.options.allow_object_master() && name == client.master_name); + if client.options.echo_master() + && authorized + && normal + && fully_audible + && !message.is_empty() + && let Err(error) = client.backend.chat(&message, 0, ChatType::Normal) + { + self.output.line(format!("Echo failed: {error}")); + } + } + ClientEvent::InstantMessage { + source, + name, + message, + dialog, + session, + group, + .. + } => { + let authorized = source == client.master_key + || (client.options.group_commands() + && group + && client.backend.is_group_member(source)); + self.output.line(format!( + "<{} ({dialog:?})> {}{}: {}", + if group { "GroupIM" } else { "IM" }, + clean_text(&name, 256), + if authorized { "" } else { " (not master)" }, + sanitize_remote_message(&message) + )); + if !authorized { + return None; + } + if dialog == InstantMessageDialog::RequestTeleport { + if let Err(error) = client.backend.accept_teleport(source, session) { + self.output + .line(format!("Teleport response failed: {error}")); + } + } else if matches!( + dialog, + InstantMessageDialog::MessageFromAgent + | InstantMessageDialog::MessageFromObject + ) { + return Some((message, source)); + } + } + ClientEvent::Packet { + packet_type, + simulator, + bytes, + .. + } => { + if let Some(log) = &mut client.packet_log { + match log.record(&packet_type, &simulator, bytes) { + Ok(true) => { + client.packet_log = None; + self.output + .line(format!("Finished logging packets for {}", client.name())); + } + Ok(false) => {} + Err(error) => { + client.packet_log = None; + self.output.line(format!("Packet logging failed: {error}")); + } + } + } + } + ClientEvent::Effect { summary, .. } => { + if client.options.show_effects() { + self.output.line(summary); + } + } + ClientEvent::Disconnected { reason, .. } => { + self.output + .line(format!("{} disconnected: {reason}", client.name())); + } + } + None + } + + fn shutdown_all(&mut self) { + for client in self.clients.values() { + client.backend.shutdown(); + } + for client in self.clients.values_mut() { + if let Some(mut log) = client.packet_log.take() { + let _ = log.writer.flush(); + } + } + } + + fn archive_client(&mut self, client: &ManagedClient) { + let name = client.name(); + client.backend.shutdown(); + self.archived_calls.extend( + client + .backend + .take_calls() + .into_iter() + .map(|call| format!("[{name}] {call}")), + ); + } + + fn take_fake_calls(&self) -> Vec { + let mut clients: Vec<_> = self.clients.values().collect(); + clients.sort_by_key(|client| client.name()); + let mut calls = self.archived_calls.clone(); + for client in clients { + let name = client.name(); + calls.extend( + client + .backend + .take_calls() + .into_iter() + .map(|call| format!("[{name}] {call}")), + ); + } + calls + } +} + +async fn execute_client_command( + client: &mut ManagedClient, + handler: &CommandHandler, + args: &[String], + _from: UUID, + cancellation: CancellationToken, +) -> String { + match handler { + CommandHandler::EchoMaster + | CommandHandler::Im + | CommandHandler::ImGroup + | CommandHandler::Say(_) => { + execute_communication_command(client, handler, args, cancellation).await + } + CommandHandler::LogPacket => configure_packet_log(client, args), + CommandHandler::Md5 => { + if args.len() != 1 { + return "Usage: md5 [password]".into(); + } + Utils::md5_with_string(args[0].clone()) + .unwrap_or_else(|_| "Unable to create MD5 hash".into()) + } + CommandHandler::SetMaster | CommandHandler::SetMasterKey | CommandHandler::ShowEffects => { + configure_client(client, handler, args, cancellation).await + } + CommandHandler::Sleep => sleep_client(client, args, cancellation).await, + CommandHandler::At + | CommandHandler::Debug + | CommandHandler::Help + | CommandHandler::Load + | CommandHandler::Login + | CommandHandler::Logout + | CommandHandler::Quit + | CommandHandler::WaitForLogin + | CommandHandler::LoadedAlias(_) => "Command is handled by the client manager".into(), + } +} + +async fn execute_communication_command( + client: &mut ManagedClient, + handler: &CommandHandler, + args: &[String], + cancellation: CancellationToken, +) -> String { + match handler { + CommandHandler::EchoMaster => { + if client.options.toggle_echo_master() { + "Echoing is now on.".into() + } else { + "Echoing is now off.".into() + } + } + CommandHandler::Im => { + if args.len() < 3 { + return "Usage: im [firstname] [lastname] [message]".into(); + } + let name = format!("{} {}", args[0], args[1]); + let message = bounded_message(&args[2..].join(" ")); + let target = match client.backend.resolve_avatar(&name, cancellation).await { + Ok(Some(target)) => target, + Ok(None) => return format!("Name lookup for {name} failed"), + Err(error) => return format!("Name lookup for {name} failed: {error}"), + }; + match client.backend.instant_message(target, &message) { + Ok(()) => format!("Instant Messaged {target} with message: {message}"), + Err(error) => format!("Instant message failed: {error}"), + } + } + CommandHandler::ImGroup => { + if args.len() < 2 { + return "Usage: imgroup [group_uuid] [message]".into(); + } + let Ok(group) = UUID::new_with_string(args[0].clone()) else { + return "failed to instant message group".into(); + }; + let message = bounded_message(&args[1..].join(" ")); + match client + .backend + .group_message(group, &message, cancellation) + .await + { + Ok(()) => format!("Instant Messaged group {group} with message: {message}"), + Err(error) => format!("Group instant message failed: {error}"), + } + } + CommandHandler::Say(kind) => { + if args.is_empty() { + return format!("usage: {} (optional channel) whatever", chat_verb(*kind)); + } + let (channel, start) = if args.len() > 1 { + args[0] + .parse::() + .map_or((0, 0), |channel| (channel, 1)) + } else { + (0, 0) + }; + let message = bounded_message(&args[start..].join(" ")); + match client.backend.chat(&message, channel, *kind) { + Ok(()) => format!("{} {message}", chat_past_tense(*kind)), + Err(error) => format!("Chat failed: {error}"), + } + } + _ => unreachable!("manager only routes communication commands here"), + } +} + +fn configure_packet_log(client: &mut ManagedClient, args: &[String]) -> String { + if args.len() != 2 { + return "Usage: logpacket no-of-packets filename".into(); + } + let Ok(count) = args[0].parse::() else { + return format!("{} is not a valid number of packets", args[0]); + }; + if count == 0 || count > MAX_PACKET_LOG_COUNT { + return format!( + "{} is not a valid number of packets (allowed 1..={MAX_PACKET_LOG_COUNT})", + args[0] + ); + } + if let Some(log) = &client.packet_log { + return format!( + "Still waiting to finish logging {} packets for {}", + log.remaining, + client.name() + ); + } + match PacketLog::create(Path::new(&args[1]), count) { + Ok(log) => { + client.packet_log = Some(log); + format!("Now logging {count} packets for {}", client.name()) + } + Err(error) => format!("Could not open packet log: {error}"), + } +} + +async fn configure_client( + client: &mut ManagedClient, + handler: &CommandHandler, + args: &[String], + cancellation: CancellationToken, +) -> String { + match handler { + CommandHandler::SetMaster => { + let name = args.join(" ").trim().to_owned(); + if name.is_empty() { + return "Usage: setmaster [name]".into(); + } + let Ok(Some(id)) = client.backend.resolve_avatar(&name, cancellation).await else { + return format!("Unable to obtain UUID for \"{name}\". Master unchanged."); + }; + client.master_name.clone_from(&name); + client.master_key = id; + client.options.set(ClientOptions::ALLOW_OBJECT_MASTER, true); + let _ = client.backend.instant_message( + id, + "You are now my master. IM me with \"help\" for a command list.", + ); + format!("Master set to {name} ({id})") + } + CommandHandler::SetMasterKey => { + if args.len() != 1 { + return "Usage: setmasterkey [uuid]".into(); + } + let Ok(id) = UUID::new_with_string(args[0].clone()) else { + return "Master UUID is invalid".into(); + }; + client.master_key = id; + client + .options + .set(ClientOptions::ALLOW_OBJECT_MASTER, id != UUID::zero()); + if id != UUID::zero() { + let _ = client.backend.instant_message( + id, + "You are now my master. IM me with \"help\" for a command list.", + ); + } + format!("Master set to {id}") + } + CommandHandler::ShowEffects => { + if args.len() > 1 { + return "Usage: showeffects [on/off]".into(); + } + let enabled = args.first().is_none_or(|value| value == "on"); + client.options.set(ClientOptions::SHOW_EFFECTS, enabled); + if enabled { + "Viewer effects will be shown on the console".into() + } else { + "Viewer effects will not be shown".into() + } + } + _ => unreachable!("manager only routes client configuration commands here"), + } +} + +async fn sleep_client( + client: &ManagedClient, + args: &[String], + cancellation: CancellationToken, +) -> String { + let Ok(seconds) = args.first().map_or("", String::as_str).parse::() else { + return "Usage: sleep [seconds]".into(); + }; + if args.len() != 1 || seconds > 3600 { + return "Usage: sleep [seconds] (maximum 3600)".into(); + } + if let Err(error) = client.backend.pause() { + return format!("Pause failed: {error}"); + } + let cancelled = tokio::select! { + () = cancellation.cancelled() => true, + () = tokio::time::sleep(Duration::from_secs(seconds)) => false, + }; + if let Err(error) = client.backend.resume() { + return format!("Resume failed: {error}"); + } + if cancelled { + "Pause cancelled; agent resumed".into() + } else { + format!("Paused, slept for {seconds} second(s), and resumed") + } +} + +const fn chat_verb(kind: ChatType) -> &'static str { + match kind { + ChatType::Whisper => "whisper", + ChatType::Shout => "shout", + _ => "say", + } +} + +const fn chat_past_tense(kind: ChatType) -> &'static str { + match kind { + ChatType::Whisper => "Whispered", + ChatType::Shout => "Shouted", + _ => "Said", + } +} + +#[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!("test-client: could not initialize the async runtime"); + return ExitCode::from(EXIT_CLIENT); + }; + let result = runtime.block_on(run(cli)); + runtime.shutdown_timeout(Duration::from_secs(1)); + match result { + Ok(()) => ExitCode::from(EXIT_SUCCESS), + Err(error) => { + eprintln!("test-client: {}", redact_text(&error.to_string())); + ExitCode::from(error.exit_code()) + } + } +} + +async fn run(cli: Cli) -> Result<(), ProgramError> { + let master_key = match cli.masterkey.as_deref() { + Some(value) => UUID::new_with_string(value.into()) + .map_err(|_| ProgramError::Usage("--masterkey must be a UUID"))?, + None => UUID::zero(), + }; + let login_uri = cli + .loginuri + .clone() + .or_else(|| std::env::var("GRID_LOGIN_URL").ok()) + .filter(|value| !value.is_empty()); + let timeout = Duration::from_secs(cli.login_timeout_seconds); + if let Some(path) = cli.fake_script.as_deref() { + let output = ProgramOutput::buffered(); + let mut manager = ClientManager::new( + RunMode::Fake, + login_uri, + timeout, + cli.master.unwrap_or_default(), + master_key, + cli.groupcommands, + Arc::clone(&output), + ); + run_fake_script(&mut manager, path).await?; + manager.shutdown_all(); + let calls = manager.take_fake_calls(); + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + for line in output.drain().into_iter().chain(calls) { + writeln!(stdout, "{}", redact_text(&line)) + .map_err(|source| input_error("writing standard output", source))?; + } + writeln!( + stdout, + "STATE clients={} connected=0 pending-logins={} active-tasks=0 shutdown=true dropped-events={} pending-command-targets={}", + manager.clients.len(), + manager.pending_logins, + manager.dropped_events.load(Ordering::Acquire), + pending_test_client_commands().len(), + ) + .map_err(|source| input_error("writing standard output", source))?; + return Ok(()); + } + + let output = ProgramOutput::console(); + let accounts = resolve_initial_accounts(&cli, login_uri.clone(), master_key)?; + let mut manager = ClientManager::new( + RunMode::Live, + login_uri, + timeout, + cli.master.unwrap_or_default(), + master_key, + cli.groupcommands, + Arc::clone(&output), + ); + for account in accounts { + manager.login_account(account).await; + } + if let Some(path) = cli.scriptfile.as_deref() { + run_command_file(&mut manager, path).await?; + } + if manager.running { + if cli.nogui { + run_headless(&mut manager).await?; + } else { + run_interactive(&mut manager).await?; + } + } + manager.cancellation.cancel(); + manager.shutdown_all(); + let dropped = manager.dropped_events.load(Ordering::Acquire); + if dropped > 0 { + eprintln!("test-client: dropped {dropped} events because the bounded queue was full"); + } + Ok(()) +} + +fn resolve_initial_accounts( + cli: &Cli, + login_uri: Option, + master_key: UUID, +) -> Result, ProgramError> { + if cli.file.is_some() && (cli.first.is_some() || cli.last.is_some() || cli.pass.is_some()) { + return Err(ProgramError::Usage( + "--file cannot be combined with --first, --last, or --pass", + )); + } + let start = cli + .startpos + .as_deref() + .map(parse_start_location) + .transpose() + .map_err(|_| ProgramError::Usage("--startpos must be Region/x/y/z"))?; + if let Some(path) = cli.file.as_deref() { + let lines = read_bounded_lines(path)?; + let mut accounts = Vec::new(); + for (index, line) in lines.into_iter().enumerate() { + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + if accounts.len() == MAX_CLIENTS { + return Err(ProgramError::InvalidInput { + line: index + 1, + reason: "account file exceeds 64 clients", + }); + } + let values: Vec<_> = line + .split(|ch: char| ch.is_ascii_whitespace() || ch == ',') + .filter(|value| !value.is_empty()) + .collect(); + if values.len() < 3 || values.len() > 4 { + return Err(ProgramError::InvalidInput { + line: index + 1, + reason: "expected First Last Password [Region/x/y/z]", + }); + } + let account_start = values + .get(3) + .map(|value| parse_start_location(value)) + .transpose() + .map_err(|_| ProgramError::InvalidInput { + line: index + 1, + reason: "invalid Region/x/y/z start location", + })? + .or_else(|| start.clone()); + accounts.push(Account { + first: values[0].into(), + last: values[1].into(), + password: values[2].into(), + start: account_start, + uri: login_uri.clone(), + master_name: cli.master.clone().unwrap_or_default(), + master_key, + group_commands: cli.groupcommands, + }); + } + return Ok(accounts); + } + match (&cli.first, &cli.last, &cli.pass) { + (None, None, None) => Ok(Vec::new()), + (Some(first), Some(last), Some(password)) if !password.is_empty() => Ok(vec![Account { + first: first.clone(), + last: last.clone(), + password: password.clone(), + start, + uri: login_uri, + master_name: cli.master.clone().unwrap_or_default(), + master_key, + group_commands: cli.groupcommands, + }]), + _ => Err(ProgramError::Usage( + "--first, --last, and --pass must be supplied together", + )), + } +} + +async fn run_command_file(manager: &mut ClientManager, path: &Path) -> Result<(), ProgramError> { + for line in read_bounded_lines(path)? { + if !manager.running { + break; + } + manager.dispatch_line(&line, UUID::zero()).await; + drain_events(manager).await; + } + Ok(()) +} + +async fn run_headless(manager: &mut ClientManager) -> Result<(), ProgramError> { + while manager.running { + tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal.map_err(|_| ProgramError::Signal)?; + break; + }, + event = manager.events_rx.recv() => { + let Some(event) = event else { break }; + if let Some((line, from)) = manager.process_event(event) { + manager.dispatch_line(&line, from).await; + } + } + } + } + Ok(()) +} + +async fn run_interactive(manager: &mut ClientManager) -> Result<(), ProgramError> { + manager + .output + .line("Type quit to exit. Type help for a command list."); + let mut lines = AsyncBufReader::new(tokio::io::stdin()).lines(); + while manager.running { + let online = manager + .clients + .values() + .filter(|client| client.backend.connected()) + .count(); + print!("{online} avatars online> "); + io::stdout() + .flush() + .map_err(|source| input_error("flushing prompt", source))?; + tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal.map_err(|_| ProgramError::Signal)?; + break; + }, + line = lines.next_line() => { + match line.map_err(|source| input_error("reading standard input", source))? { + Some(line) => manager.dispatch_line(&line, UUID::zero()).await, + None => break, + } + }, + event = manager.events_rx.recv() => { + let Some(event) = event else { break }; + if let Some((line, from)) = manager.process_event(event) { + manager.dispatch_line(&line, from).await; + } + } + } + } + Ok(()) +} + +async fn drain_events(manager: &mut ClientManager) { + while let Ok(event) = manager.events_rx.try_recv() { + if let Some((line, from)) = manager.process_event(event) { + manager.dispatch_line(&line, from).await; + } + } +} + +async fn run_fake_script(manager: &mut ClientManager, path: &Path) -> Result<(), ProgramError> { + for (index, line) in read_bounded_lines(path)?.into_iter().enumerate() { + let line_number = index + 1; + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + if !manager.running { + break; + } + if let Some(directive) = line.strip_prefix('!') { + run_fake_directive(manager, directive, line_number).await?; + } else { + let command = line.strip_prefix("> ").unwrap_or(&line); + manager.dispatch_line(command, UUID::zero()).await; + } + drain_events(manager).await; + } + Ok(()) +} + +async fn run_fake_directive( + manager: &mut ClientManager, + directive: &str, + line: usize, +) -> Result<(), ProgramError> { + let fields: Vec<_> = directive.split('\t').collect(); + if matches!( + fields.first().copied(), + Some("client" | "person" | "group-member") + ) { + return run_fake_registry_directive(manager, &fields, line); + } + run_fake_event_directive(manager, &fields, line).await +} + +fn run_fake_registry_directive( + manager: &mut ClientManager, + fields: &[&str], + line: usize, +) -> Result<(), ProgramError> { + match fields { + ["client", id, first, last] => { + let id = parse_uuid(id, line, "client UUID is invalid")?; + manager.add_fake_client( + id, + format!("{first} {last}"), + manager.default_master_name.clone(), + manager.default_master_key, + manager.default_group_commands, + ); + } + [ + "client", + id, + first, + last, + master_name, + master_key, + group_commands, + ] => { + let id = parse_uuid(id, line, "client UUID is invalid")?; + let master_key = parse_uuid(master_key, line, "master UUID is invalid")?; + let group_commands = match *group_commands { + "true" => true, + "false" => false, + _ => return invalid(line, "groupcommands must be true or false"), + }; + manager.add_fake_client( + id, + format!("{first} {last}"), + (*master_name).into(), + master_key, + group_commands, + ); + } + ["person", name, id] => { + let id = parse_uuid(id, line, "person UUID is invalid")?; + lock(&manager.fake_people).insert(name.to_ascii_lowercase(), id); + } + ["group-member", id] => { + let id = parse_uuid(id, line, "group member UUID is invalid")?; + lock(&manager.fake_group_members).insert(id); + } + _ => return invalid(line, "invalid client registry fake directive"), + } + Ok(()) +} + +async fn run_fake_event_directive( + manager: &mut ClientManager, + fields: &[&str], + line: usize, +) -> Result<(), ProgramError> { + match fields { + ["chat", client, source, name, message] => { + let event = ClientEvent::Chat { + client: parse_uuid(client, line, "chat client UUID is invalid")?, + source: parse_uuid(source, line, "chat source UUID is invalid")?, + name: clean_text(name, 256), + message: clean_text(message, MAX_MESSAGE_BYTES), + normal: true, + fully_audible: true, + }; + let _ = manager.process_event(event); + } + ["im", client, source, name, dialog, group, message, session] => { + let dialog = match *dialog { + "agent" => InstantMessageDialog::MessageFromAgent, + "object" => InstantMessageDialog::MessageFromObject, + "teleport" => InstantMessageDialog::RequestTeleport, + _ => return invalid(line, "IM dialog must be agent, object, or teleport"), + }; + let group = match *group { + "true" => true, + "false" => false, + _ => return invalid(line, "IM group field must be true or false"), + }; + let event = ClientEvent::InstantMessage { + client: parse_uuid(client, line, "IM client UUID is invalid")?, + source: parse_uuid(source, line, "IM source UUID is invalid")?, + name: clean_text(name, 256), + message: clean_text(message, MAX_COMMAND_BYTES), + dialog, + session: parse_uuid(session, line, "IM session UUID is invalid")?, + group, + }; + if let Some((command, from)) = manager.process_event(event) { + manager.dispatch_line(&command, from).await; + } + } + ["packet", client, packet_type, simulator, bytes] => { + let bytes = bytes + .parse::() + .map_err(|_| ProgramError::InvalidInput { + line, + reason: "packet byte count is invalid", + })?; + let _ = manager.process_event(ClientEvent::Packet { + client: parse_uuid(client, line, "packet client UUID is invalid")?, + packet_type: clean_text(packet_type, 128), + simulator: clean_text(simulator, 256), + bytes, + }); + } + ["effect", client, summary] => { + let _ = manager.process_event(ClientEvent::Effect { + client: parse_uuid(client, line, "effect client UUID is invalid")?, + summary: clean_text(summary, 1024), + }); + } + ["disconnect", client, reason] => { + let _ = manager.process_event(ClientEvent::Disconnected { + client: parse_uuid(client, line, "disconnect client UUID is invalid")?, + reason: clean_text(reason, 512), + }); + } + ["cancel"] => manager.cancellation.cancel(), + ["shutdown"] => { + manager.running = false; + manager.cancellation.cancel(); + } + _ => { + return invalid( + line, + "unknown fake directive; expected client, person, group-member, chat, im, packet, effect, disconnect, cancel, or shutdown", + ); + } + } + Ok(()) +} + +fn read_bounded_lines(path: &Path) -> Result, ProgramError> { + let file = File::open(path) + .map_err(|source| input_error(format!("opening {}", path.display()), source))?; + if file + .metadata() + .map_err(|source| input_error("reading input metadata", source))? + .len() + > MAX_INPUT_BYTES + { + return Err(ProgramError::InvalidInput { + line: 0, + reason: "input exceeds 1 MiB", + }); + } + let mut bytes = Vec::new(); + file.take(MAX_INPUT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|source| input_error("reading input", source))?; + if bytes.len() as u64 > MAX_INPUT_BYTES { + return Err(ProgramError::InvalidInput { + line: 0, + reason: "input exceeds 1 MiB", + }); + } + let mut lines = Vec::new(); + for (index, line) in BufReader::new(bytes.as_slice()).lines().enumerate() { + if lines.len() == MAX_INPUT_LINES { + return Err(ProgramError::InvalidInput { + line: index + 1, + reason: "input exceeds 4,096 lines", + }); + } + lines.push(line.map_err(|source| input_error("decoding input as UTF-8", source))?); + } + Ok(lines) +} + +fn parse_start_location(value: &str) -> Result { + if !value.contains('/') { + return NetworkManager::start_location(value.into(), 128, 128, 40) + .map_err(|_| "invalid start location".into()); + } + let fields: Vec<_> = value.split('/').collect(); + let [region, x, y, z] = fields.as_slice() else { + return Err("invalid start location".into()); + }; + let x = x.parse().map_err(|_| "invalid start X")?; + let y = y.parse().map_err(|_| "invalid start Y")?; + let z = z.parse().map_err(|_| "invalid start Z")?; + NetworkManager::start_location((*region).into(), x, y, z) + .map_err(|_| "invalid start location".into()) +} + +fn tokenize(input: &str) -> Result, &'static str> { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaped = false; + for ch in input.chars() { + if escaped { + current.push(ch); + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if let Some(delimiter) = quote { + if ch == delimiter { + quote = None; + } else { + current.push(ch); + } + continue; + } + match ch { + '\'' | '"' => quote = Some(ch), + _ if ch.is_whitespace() => { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + } + _ => current.push(ch), + } + } + if escaped { + return Err("dangling backslash escape"); + } + if quote.is_some() { + return Err("unterminated quoted token"); + } + if !current.is_empty() { + tokens.push(current); + } + Ok(tokens) +} + +fn valid_command_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') +} + +fn bounded_message(value: &str) -> String { + truncate_utf8(&clean_text(value, MAX_MESSAGE_BYTES), MAX_MESSAGE_BYTES) +} + +fn clean_text(value: &str, maximum: usize) -> String { + let value: String = value + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .collect(); + truncate_utf8(value.trim(), maximum) +} + +fn truncate_utf8(value: &str, maximum: usize) -> String { + if value.len() <= maximum { + return value.into(); + } + let mut end = maximum; + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].into() +} + +fn redact_text(value: &str) -> String { + let lower = value.to_ascii_lowercase(); + if [ + "password=", + "passwd=", + "pass=", + "authorization:", + "authorization=", + "capability=", + "token=", + "oauth", + ] + .iter() + .any(|marker| lower.contains(marker)) + { + return "".into(); + } + value + .split_whitespace() + .map(|word| { + if word.contains("://") { + "" + } else { + word + } + }) + .collect::>() + .join(" ") +} + +fn sanitize_remote_message(value: &str) -> String { + match tokenize(value) { + Ok(tokens) + if tokens.first().is_some_and(|command| { + matches!(command.to_ascii_lowercase().as_str(), "login" | "md5") + }) => + { + "".into() + } + _ => clean_text(value, MAX_COMMAND_BYTES), + } +} + +fn parse_uuid(value: &str, line: usize, reason: &'static str) -> Result { + UUID::new_with_string(value.into()).map_err(|_| ProgramError::InvalidInput { line, reason }) +} + +fn invalid(line: usize, reason: &'static str) -> Result { + Err(ProgramError::InvalidInput { line, reason }) +} + +fn input_error(action: impl Into, source: io::Error) -> ProgramError { + ProgramError::Input { + action: action.into(), + source, + } +} + +fn try_event(sender: &mpsc::Sender, event: ClientEvent, dropped: &AtomicUsize) { + if sender.try_send(event).is_err() { + dropped.fetch_add(1, Ordering::Relaxed); + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_tokenizer_handles_quotes_escapes_and_errors() { + assert_eq!( + tokenize("say 7 'hello world' \"and \\\"quotes\\\"\"").unwrap(), + ["say", "7", "hello world", "and \"quotes\""] + ); + assert_eq!(tokenize("say one\\ two").unwrap(), ["say", "one two"]); + assert!(tokenize("say 'unterminated").is_err()); + assert!(tokenize("say dangling\\").is_err()); + } + + #[test] + fn issue_owned_targets_are_absent_from_pending_inventory() { + let pending = pending_test_client_commands(); + assert_eq!( + TEST_CLIENT_COMMANDS.len(), + pending.len() + IMPLEMENTED_TEST_CLIENT_COMMANDS.len() + ); + for command in IMPLEMENTED_TEST_CLIENT_COMMANDS { + assert!(TEST_CLIENT_COMMANDS.contains(command)); + assert!(!pending.contains(command)); + } + } + + #[test] + fn start_locations_and_utf8_limits_are_deterministic() { + assert!( + parse_start_location("Sandbox/1/2/3") + .unwrap() + .contains("Sandbox") + ); + assert!(parse_start_location("Sandbox/one/2/3").is_err()); + assert_eq!(truncate_utf8("ab☃cd", 4), "ab"); + assert_eq!(truncate_utf8("ab☃cd", 5), "ab☃"); + } +} diff --git a/programs/tests/test_client_cli.rs b/programs/tests/test_client_cli.rs new file mode 100644 index 0000000..158a7d2 --- /dev/null +++ b/programs/tests/test_client_cli.rs @@ -0,0 +1,273 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const ALICE: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const BOB: &str = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"; +const MASTER: &str = "11111111-2222-3333-4444-555555555555"; +const TARGET: &str = "22222222-3333-4444-5555-666666666666"; +const GROUP: &str = "33333333-4444-5555-6666-777777777777"; +const GROUP_MEMBER: &str = "44444444-5555-6666-7777-888888888888"; +const STRANGER: &str = "55555555-6666-7777-8888-999999999999"; +const SESSION: &str = "66666666-7777-8888-9999-aaaaaaaaaaaa"; +const EXIT_USAGE: i32 = 2; +const EXIT_INPUT: i32 = 3; + +static TEMP_ID: AtomicU64 = AtomicU64::new(0); + +struct TestDir(PathBuf); + +impl TestDir { + fn new(name: &str) -> Self { + let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "metacrate-test-client-{name}-{}-{id}", + std::process::id() + )); + fs::create_dir(&path).expect("create test directory"); + Self(path) + } + + fn write(&self, name: &str, contents: &str) -> PathBuf { + let path = self.0.join(name); + fs::write(&path, contents).expect("write fixture"); + path + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn path_text(path: &Path) -> &str { + path.to_str().expect("UTF-8 test path") +} + +fn run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_test-client")) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("run test-client") +} + +fn stdout(output: &Output) -> &str { + std::str::from_utf8(&output.stdout).expect("UTF-8 stdout") +} + +fn stderr(output: &Output) -> &str { + std::str::from_utf8(&output.stderr).expect("UTF-8 stderr") +} + +fn assert_exit(output: &Output, expected: i32) { + assert_eq!( + output.status.code(), + Some(expected), + "stdout:\n{}\nstderr:\n{}", + stdout(output), + stderr(output) + ); +} + +#[test] +fn help_documents_upstream_framework_and_owned_commands() { + let output = run(&["--help"]); + assert!(output.status.success()); + let text = stdout(&output); + for marker in [ + "--first", + "--last", + "--pass", + "--file", + "--loginuri", + "--startpos", + "--master", + "--masterkey", + "--groupcommands", + "--gettextures", + "--scriptfile", + "--nogui", + "--fake-script", + "echomaster", + "imgroup", + "logpacket", + "waitforlogin", + ] { + assert!(text.contains(marker), "help omitted {marker}:\n{text}"); + } + + let output = run(&["--first", "Only"]); + assert_exit(&output, EXIT_USAGE); +} + +#[test] +fn scripted_terminal_exercises_registry_communication_system_and_remote_auth() { + let directory = TestDir::new("commands"); + let packet_log = directory.path("packets.log"); + let script = directory.write( + "terminal.tsv", + &format!( + "!client\t{ALICE}\tAlice\tBot\tMaster Resident\t{MASTER}\ttrue\n\ + !client\t{BOB}\tBob\tBot\tMaster Resident\t{MASTER}\ttrue\n\ + !person\tTarget Resident\t{TARGET}\n\ + !group-member\t{GROUP_MEMBER}\n\ + help say\n\ + say 7 \"hello world\"\n\ + whisper quiet\n\ + shout loud\n\ + @ Alice Bot\n\ + im Target Resident private hello\n\ + imgroup {GROUP} group hello\n\ + echomaster\n\ + showeffects on\n\ + logpacket 2 \"{}\"\n\ + !chat\t{ALICE}\t{MASTER}\tMaster Resident\techo this\n\ + !effect\t{ALICE}\tViewerEffect [LookAt] source={MASTER}\n\ + !packet\t{ALICE}\tChatFromSimulator\tFake Region\t120\n\ + !packet\t{ALICE}\tImprovedInstantMessage\tFake Region\t240\n\ + !im\t{ALICE}\t{MASTER}\tMaster Resident\tagent\tfalse\tsay remote works\t{SESSION}\n\ + !im\t{ALICE}\t{GROUP_MEMBER}\tGroup Member\tagent\ttrue\twhisper group works\t{SESSION}\n\ + !im\t{ALICE}\t{STRANGER}\tStranger Resident\tagent\tfalse\tlogin Victim Resident supersecret\t{SESSION}\n\ + setmaster Target Resident\n\ + setmasterkey {MASTER}\n\ + !im\t{ALICE}\t{MASTER}\tMaster Resident\tteleport\tfalse\tlure\t{SESSION}\n\ + sleep 0\n\ + debug info\n\ + md5 secret\n\ + logout\n\ + @\n\ + say after logout\n\ + quit\n", + path_text(&packet_log) + ), + ); + let output = run(&["--fake-script", path_text(&script)]); + assert!(output.status.success(), "{}", stderr(&output)); + assert!(output.stderr.is_empty(), "{}", stderr(&output)); + let text = stdout(&output); + for expected in [ + "Say something. Usage: say [optional-channel] message", + "[Alice Bot] CALL chat channel=7 type=Normal hello world", + "[Bob Bot] CALL chat channel=7 type=Normal hello world", + "[Alice Bot] CALL instant-message 22222222-3333-4444-5555-666666666666 private hello", + "[Alice Bot] CALL group-chat-join 33333333-4444-5555-6666-777777777777", + "[Alice Bot] CALL group-instant-message 33333333-4444-5555-6666-777777777777 group hello", + "[Alice Bot] CALL chat channel=0 type=Normal echo this", + "ViewerEffect [LookAt] source=11111111-2222-3333-4444-555555555555", + "[Alice Bot] CALL chat channel=0 type=Normal remote works", + "[Alice Bot] CALL chat channel=0 type=Whisper group works", + "", + "Master set to Target Resident", + "CALL teleport-lure-respond", + "CALL agent-pause", + "CALL agent-resume", + "Logging is set to Info", + "$1$5ebe2294ecd0e0f08eab7690d2a6ee69", + "[Bob Bot] CALL chat channel=0 type=Normal after logout", + "active-tasks=0 shutdown=true", + ] { + assert!(text.contains(expected), "missing {expected}:\n{text}"); + } + assert!(!text.contains("supersecret"), "credential leaked:\n{text}"); + assert_eq!(text.matches("CALL logout-dispose").count(), 2, "{text}"); + let packet_text = fs::read_to_string(packet_log).expect("read packet log"); + assert_eq!(packet_text.lines().count(), 2, "{packet_text}"); + assert!(packet_text.contains("ChatFromSimulator")); + assert!(packet_text.contains("ImprovedInstantMessage")); +} + +#[test] +fn login_replaces_duplicate_client_and_waitforlogin_is_deterministic() { + let directory = TestDir::new("login"); + let script = directory.write( + "terminal.tsv", + "login Carol Bot credential-one\n\ + waitforlogin\n\ + login Carol Bot credential-two\n\ + say only replacement\n\ + quit\n", + ); + let output = run(&["--fake-script", path_text(&script)]); + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("currently tracking 1 bots"), "{text}"); + assert_eq!(text.matches("Logged in Carol Bot").count(), 2, "{text}"); + assert_eq!( + text.matches("CALL chat channel=0 type=Normal only replacement") + .count(), + 1, + "{text}" + ); + assert!(!text.contains("credential-one"), "password leaked:\n{text}"); + assert!(!text.contains("credential-two"), "password leaked:\n{text}"); +} + +#[test] +fn native_alias_manifest_and_parser_are_bounded_and_deterministic() { + let directory = TestDir::new("load"); + let manifest = directory.write( + "commands.tsv", + "greet\tSend a greeting through channel 9\tsay 9 hello {args}\n", + ); + let script = directory.write( + "terminal.tsv", + &format!( + "!client\t{ALICE}\tAlice\tBot\n\ + load \"{}\"\n\ + help greet\n\ + greet \"quoted world\"\n\ + say 'unterminated\n\ + quit\n", + path_text(&manifest) + ), + ); + let output = run(&["--fake-script", path_text(&script)]); + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("Loaded 1 native command alias"), "{text}"); + assert!(text.contains("Send a greeting through channel 9"), "{text}"); + assert!( + text.contains("CALL chat channel=9 type=Normal hello quoted world"), + "{text}" + ); + assert!( + text.contains("Command parse error: unterminated quoted token"), + "{text}" + ); +} + +#[test] +fn malformed_inputs_and_cancellation_fail_or_shutdown_cleanly() { + let directory = TestDir::new("invalid"); + let invalid = directory.write("terminal.tsv", "!unknown\tvalue\n"); + let output = run(&["--fake-script", path_text(&invalid)]); + assert_exit(&output, EXIT_INPUT); + assert!(stderr(&output).contains("line 1")); + + let cancelled = directory.write( + "cancelled.tsv", + &format!( + "!client\t{ALICE}\tAlice\tBot\n\ + !cancel\n\ + sleep 60\n\ + !shutdown\n" + ), + ); + let output = run(&["--fake-script", path_text(&cancelled)]); + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("Pause cancelled; agent resumed"), "{text}"); + assert!(text.contains("CALL agent-pause"), "{text}"); + assert!(text.contains("CALL agent-resume"), "{text}"); + assert!(text.contains("connected=0"), "{text}"); + assert!(text.contains("active-tasks=0 shutdown=true"), "{text}"); +}