diff --git a/.gitea/workflows/rust-workspace.yml b/.gitea/workflows/rust-workspace.yml index 16f48a4..579e56a 100644 --- a/.gitea/workflows/rust-workspace.yml +++ b/.gitea/workflows/rust-workspace.yml @@ -20,6 +20,8 @@ on: - "tools/generate_lsl_tables.py" - "codegen/inputs/lsl_tools_grammar.json" - "docs/extension-milestone-gate.md" + - "docs/live-grid-smoke.md" + - "programs/README.md" - "api/SHIM-COVERAGE.md" - "**/*.rs" - "**/Cargo.toml" @@ -43,6 +45,8 @@ on: - "tools/generate_lsl_tables.py" - "codegen/inputs/lsl_tools_grammar.json" - "docs/extension-milestone-gate.md" + - "docs/live-grid-smoke.md" + - "programs/README.md" - "api/SHIM-COVERAGE.md" - "**/*.rs" - "**/Cargo.toml" @@ -99,6 +103,10 @@ jobs: run: python3 tools/test_milestone_10.py - name: Compile every workspace target with bounded memory run: cargo check --workspace --all-targets --locked -j 1 + - name: Audit and fake-smoke every native program target + run: | + cargo run -p libremetaverse-programs --bin live-grid-smoke --locked -- --audit-only + cargo test -p libremetaverse-programs --test live_grid_smoke_cli --locked -j 1 - name: Check optional cross-platform real audio backend run: | cargo check -p libremetaverse-voice-webrtc --all-targets --features real-audio --locked -j 1 diff --git a/Cargo.lock b/Cargo.lock index 2c6c3c6..847ef76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1618,6 +1618,8 @@ dependencies = [ "libremetaverse-voice-webrtc", "regex", "roxmltree", + "serde", + "serde_json", "tokio", ] diff --git a/crates/libremetaverse/src/agent_movement.rs b/crates/libremetaverse/src/agent_movement.rs index 90633c8..5be65c6 100644 --- a/crates/libremetaverse/src/agent_movement.rs +++ b/crates/libremetaverse/src/agent_movement.rs @@ -1256,11 +1256,13 @@ impl AgentMovementRuntime { packet.agent_data.agent_id = agent_id; packet.agent_data.session_id = session_id; packet.agent_data.circuit_code = self.network.native_circuit_code(); + // The reference packet constructor marks this final region transition + // reliable; OpenSim may never publish the scene if this datagram is lost. send_encoded( &simulator, PacketType::CompleteAgentMovement, packet.to_bytes_with_method()?, - None, + Some(true), ) } @@ -2914,7 +2916,7 @@ mod tests { let socket = UdpSocket::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)).unwrap(); socket - .set_read_timeout(Some(Duration::from_millis(100))) + .set_read_timeout(Some(Duration::from_secs(3))) .unwrap(); let endpoint = socket.local_addr().unwrap(); let (packet_sender, packets) = mpsc::channel(); @@ -2934,6 +2936,9 @@ mod tests { let mut bytes = handshake.to_bytes_with_method().unwrap(); bytes[0] &= !(crate::Helpers::MSG_RELIABLE | crate::Helpers::MSG_ZEROCODED); socket.send_to(&bytes, client_endpoint).unwrap(); + socket + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); loop { match shutdown_receiver.try_recv() { @@ -3074,7 +3079,19 @@ mod tests { .native_connect(grid.endpoint, 0x1000, true, None, 256, 256) .unwrap() .unwrap(); - let _ = grid.receive(PacketType::CompleteAgentMovement); + let initial_complete = grid.receive(PacketType::CompleteAgentMovement); + assert_ne!( + initial_complete[0] & crate::Helpers::MSG_RELIABLE, + 0, + "the simulator transition must survive UDP startup loss" + ); + manager.complete_agent_movement(simulator.clone()).unwrap(); + let repeated_complete = grid.receive(PacketType::CompleteAgentMovement); + assert_ne!( + repeated_complete[0] & crate::Helpers::MSG_RELIABLE, + 0, + "explicit simulator transitions must be reliable" + ); let mut complete = AgentMovementCompletePacket::new_with_constructor().unwrap(); complete.data.position = Vector3 { diff --git a/crates/libremetaverse/src/network_manager.rs b/crates/libremetaverse/src/network_manager.rs index 8a238d3..b0a2dcd 100644 --- a/crates/libremetaverse/src/network_manager.rs +++ b/crates/libremetaverse/src/network_manager.rs @@ -1852,7 +1852,10 @@ impl Simulator { packet.agent_data.agent_id = *read(&self.agent_id); packet.agent_data.session_id = *read(&self.session_id); packet.agent_data.circuit_code = self.circuit_code.load(Ordering::Acquire); - let data = packet.to_bytes_with_method()?; + let mut data = packet.to_bytes_with_method()?; + // Match the reference packet constructor: this transition is reliable. + let flags = data.first_mut().ok_or(Error::Argument)?; + *flags |= Helpers::MSG_RELIABLE; self.native_send_packet_data( data.clone(), i32::try_from(data.len()).map_err(|_| Error::Argument)?, diff --git a/crates/libremetaverse/src/object_manager.rs b/crates/libremetaverse/src/object_manager.rs index b64c0af..c19076c 100644 --- a/crates/libremetaverse/src/object_manager.rs +++ b/crates/libremetaverse/src/object_manager.rs @@ -53,8 +53,18 @@ fn decode_packet(bytes: &[u8]) -> Result { if bytes.len() > MAX_FIELD_BYTES * 16 { return Err(Error::Argument); } + let mut packet = T::new_generated(); let mut position = 0_i32; - T::new_from_bytes(bytes, &mut position) + let mut packet_end = i32::try_from(bytes.len()).map_err(|_| Error::Argument)? - 1; + let mut zero_buffer = + vec![0_u8; crate::udp_transport::UdpTransportConfig::default().max_decoded_packet_size]; + packet.decode_from_bytes( + bytes, + &mut position, + &mut packet_end, + Some(zero_buffer.as_mut_slice()), + )?; + Ok(packet) } fn wire_string(bytes: &[u8]) -> Result { @@ -2745,6 +2755,24 @@ mod tests { (manager, simulator) } + fn transport_wire(data: &[u8]) -> Vec { + if data + .first() + .is_none_or(|flags| flags & crate::Helpers::MSG_ZEROCODED == 0) + { + return data.to_vec(); + } + let mut encoded = vec![0_u8; data.len().saturating_mul(2).saturating_add(2)]; + let length = crate::packet_wire::zero_encode( + Some(data), + i32::try_from(data.len()).unwrap(), + Some(&mut encoded), + ) + .unwrap(); + encoded.truncate(usize::try_from(length).unwrap()); + encoded + } + #[test] fn movement_decoding_accepts_all_reference_encodings_and_rejects_other_lengths() { for length in [16, 32, 48, 60, 76] { @@ -2843,12 +2871,10 @@ mod tests { block.object_data = vec![0; 60]; block.scale = Vector3::one(); packet.object_data = vec![block]; + let wire = transport_wire(&packet.to_bytes_with_method().expect("wire packet")); manager .inner - .handle_object_update( - &packet.to_bytes_with_method().expect("wire packet"), - simulator.clone(), - ) + .handle_object_update(&wire, simulator.clone()) .expect("object update"); assert!(preapply_saw_absent.load(Ordering::Acquire)); diff --git a/docs/live-grid-smoke.md b/docs/live-grid-smoke.md new file mode 100644 index 0000000..ce9bc6e --- /dev/null +++ b/docs/live-grid-smoke.md @@ -0,0 +1,101 @@ +# Live-grid program smoke gate + +The final programs gate has two credential-free modes and one explicitly gated +live mode. None of these tests silently skip. `--audit-only` fails unless the +pinned source manifest maps to all nine original native Rust programs and every +`TestClient` source command maps to the generated, implemented, and runtime +registries. `--fake` executes every smoke stage against the deterministic +backend and writes the same JSONL evidence shape as a live run. + +```sh +cargo run -p libremetaverse-programs --bin live-grid-smoke -- --audit-only +cargo run -p libremetaverse-programs --bin live-grid-smoke -- \ + --fake --evidence /tmp/metacrate-smoke-fake.jsonl +``` + +## Credential and side-effect boundary + +Live mode is intended for a dedicated test account on OpenSim or another +protocol-compatible grid. It loads these values from the process environment, +falling back to the workspace `.env` file: + +```text +GRID_USER=First Last +GRID_PASSWORD=... +GRID_LOGIN_URL=https://compatible-grid.example/login +``` + +`GRID_LOGIN_URL` is used directly as the LLSD login endpoint; the harness does +not substitute a Second Life login host or path. The login request includes the +OpenSim response options for avatar search, classified fee, currency label, +destination guide, profile service, and search, in addition to the standard +inventory and viewer options. Capability readiness is evaluated using OpenSim +core surfaces (`EventQueueGet`, inventory descendants, simulator features, +viewer assets, textures, and avatar search); Second Life voice capabilities are +not required. Agent, object, inventory, and asset packet consumers are installed +before authentication so the harness captures OpenSim's one-time login and scene +bootstrap packets instead of treating a late subscription as an empty region. +After the event queue is live, the harness repeats OpenSim's idempotent +`CompleteAgentMovement` transition request reliably before waiting for the scene +cache. Simulator identity is validated directly from the transport-decoded +OpenSim region handshake, and the return teleport uses its numeric region handle +instead of assuming the mutable Second Life-style simulator-name cache exists. + +The complete command deliberately requires a separate flag and literal +confirmation for each permitted side effect: + +```sh +cargo run -p libremetaverse-programs --bin live-grid-smoke -- \ + --evidence /tmp/metacrate-smoke-live.jsonl \ + --allow-live-login --confirm-live-login LOGIN \ + --allow-public-chat --confirm-public-chat CHAT \ + --allow-agent-movement --confirm-agent-movement MOVE \ + --allow-reversible-inventory \ + --confirm-reversible-inventory CREATE-MOVE-TO-TRASH +``` + +This sends one self instant message and one clearly marked local-chat message, +pulses forward movement for 250 milliseconds, teleports back to the starting +simulator and position, and creates one uniquely named folder before moving it +to Trash. The folder is not permanently deleted. The harness has no flags or +code paths for currency spending, asset upload, permanent deletion, +estate/parcel changes, or actions aimed at another user. + +OpenSim does not necessarily route a self-addressed instant message back to the +sending avatar. The IM gate therefore requires the encoded +`ImprovedInstantMessage` datagram to cross the client's live UDP packet-sent +boundary and records any inbound self echo as an additional metric. The local +chat gate still requires an exact inbound simulator echo, so both operations are +executed and neither is silently skipped. + +## Sanitized evidence + +The newly created JSONL file contains exactly nine ordered records: + +1. source/program/command completeness; +2. login; +3. event queue, capabilities, simulator, and object cache readiness; +4. self-IM and local-chat echo; +5. movement stop and return teleport; +6. folder creation and move to Trash; +7. object discovery and properties; +8. bounded texture download; +9. clean logout and zero active tasks. + +The texture transfer is performed against the live grid and counted in the +sanitized evidence, but the harness disables the library's on-disk asset cache +before login. Asset bytes are held only in memory for validation and are not +retained after the process exits. + +Each record contains schema and sequence numbers, a Unix timestamp, the Rust +package version, the source commit embedded at build time, a static status, and +numeric metrics. It contains no account name, password, login/capability URL, +session identifier, simulator name, object or asset UUID, or message text. The +writer scans every serialized record for configured credential values and +secret-bearing URL/token/password markers, flushes each stage, synchronizes at +completion, and refuses to overwrite an existing evidence file. + +If an operational stage fails, the harness records that failure and still +attempts and records logout. A successful live gate requires all nine records +to have `status: "ok"`; fake success is evidence for deterministic behavior, +not evidence of a live login. diff --git a/docs/test-client.md b/docs/test-client.md index 202a5de..72bd10e 100644 --- a/docs/test-client.md +++ b/docs/test-client.md @@ -3,9 +3,10 @@ The `test-client` binary is a native Rust multi-client shell. It does not load the former C# executable or start a CLR process. The command registry owns the implemented system, communication, inventory, appearance, asset, movement, -object, parcel, estate, grid, agent, friends, groups, directory, and statistics -commands. `pending_test_client_commands()` now reports only the two explicitly -owned voice-native adapters (`ParcelVoiceInfo` and `VoiceAcountCommand`). +object, parcel, estate, grid, agent, friends, groups, directory, statistics, +and voice-capability commands. `pending_test_client_commands()` is empty, and +the completeness audit compares the source, generated, implementation, and +runtime registries. ## Social, directory, and statistics behavior @@ -92,7 +93,7 @@ cargo test -p libremetaverse-programs --test test_client_services_cli The world gate drives every command owned by the movement/object/land/grid issue, checks the exact fake backend calls, round-trips a linkset through -import, and validates generated assets. The services gate drives every one of -the 28 remaining non-voice commands in a single offline session, verifies the -destructive-operation guards, and proves the pending inventory contains only -the two explicitly owned voice adapters. +import, and validates generated assets. The services gate drives every +remaining service and voice-capability command in a single offline session, +verifies the destructive-operation guards, proves voice account values stay +redacted, and proves the pending inventory is empty. diff --git a/programs/Cargo.toml b/programs/Cargo.toml index 47e3719..d32b65e 100644 --- a/programs/Cargo.toml +++ b/programs/Cargo.toml @@ -16,6 +16,8 @@ libremetaverse-voice-vivox = { path = "../crates/libremetaverse-voice-vivox" } libremetaverse-voice-webrtc = { path = "../crates/libremetaverse-voice-webrtc" } regex = "1.12" roxmltree = "0.21.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } [lints] @@ -60,3 +62,7 @@ path = "src/bin/webrtc_test.rs" [[bin]] name = "osd-inspector" path = "src/bin/osd_inspector.rs" + +[[bin]] +name = "live-grid-smoke" +path = "src/bin/live_grid_smoke.rs" diff --git a/programs/README.md b/programs/README.md index 98f9bfb..7ab5d46 100644 --- a/programs/README.md +++ b/programs/README.md @@ -13,10 +13,37 @@ 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 | All non-voice command groups implemented with live and deterministic fake-grid backends; voice adapters tracked by #95 and #96 | +| `test-client` | TestClient | All source commands implemented with live and deterministic fake-grid backends | | `vivox-test` | VivoxTest | Implemented with gated live validation and a scripted fake TCP/control service | | `webrtc-test` | WebRtcTest | Implemented with gated live WebRTC and a deterministic secure loopback peer | +## Final program completeness and live smoke + +`live-grid-smoke --audit-only` compares the pinned source manifest with all +nine original native targets, their offline CLI suites, the generated +`TestClient` inventory, the implementation inventory, and the runtime command +registry. It succeeds only when both pending counts are zero. + +The deterministic gate runs the same nine evidence stages without credentials +or network access: + +```sh +cargo run -p libremetaverse-programs --bin live-grid-smoke -- \ + --fake --evidence /tmp/metacrate-smoke-fake.jsonl +``` + +A live OpenSim/compatible-grid run reads the shared `GRID_USER="First Last"`, +`GRID_PASSWORD`, and `GRID_LOGIN_URL` values from the process environment or +workspace `.env`. The configured URI is used directly and the request includes +OpenSim-specific response options; Second Life voice caps are not required. +Credential transmission, one local-chat marker, a brief movement/return +teleport, and creation of a folder followed by a reversible move to Trash each +have their own literal opt-in. Existing evidence files are never overwritten. +The harness never spends L$, uploads assets, permanently deletes inventory, +changes estate or parcel state, or targets another user. See +[`docs/live-grid-smoke.md`](../docs/live-grid-smoke.md) for the exact command, +evidence schema, and dedicated-account precautions. + ## WebRtcTest `webrtc-test` uses the native `libremetaverse-voice-webrtc` adapter. Its diff --git a/programs/build.rs b/programs/build.rs new file mode 100644 index 0000000..c6f008c --- /dev/null +++ b/programs/build.rs @@ -0,0 +1,46 @@ +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-env-changed=METACRATE_RUST_COMMIT"); + println!("cargo:rerun-if-changed=../.git/HEAD"); + println!("cargo:rerun-if-changed=../.git/packed-refs"); + if let Some(reference) = git_reference() { + println!("cargo:rerun-if-changed=../.git/{reference}"); + } + + let commit = std::env::var("METACRATE_RUST_COMMIT") + .ok() + .filter(|value| valid_commit(value)) + .or_else(git_commit) + .unwrap_or_else(|| "unavailable".to_owned()); + println!("cargo:rustc-env=METACRATE_RUST_COMMIT={commit}"); +} + +fn git_reference() -> Option { + let output = Command::new("git") + .args(["symbolic-ref", "--quiet", "HEAD"]) + .output() + .ok()?; + let reference = String::from_utf8(output.stdout).ok()?; + let reference = reference.trim(); + (output.status.success() + && reference.starts_with("refs/") + && reference + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"/-_.".contains(&byte))) + .then(|| reference.to_owned()) +} + +fn git_commit() -> Option { + let output = Command::new("git") + .args(["rev-parse", "--verify", "HEAD"]) + .output() + .ok()?; + let commit = String::from_utf8(output.stdout).ok()?; + let commit = commit.trim(); + (output.status.success() && valid_commit(commit)).then(|| commit.to_owned()) +} + +fn valid_commit(value: &str) -> bool { + matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} diff --git a/programs/src/bin/live_grid_smoke.rs b/programs/src/bin/live_grid_smoke.rs new file mode 100644 index 0000000..d79a84f --- /dev/null +++ b/programs/src/bin/live_grid_smoke.rs @@ -0,0 +1,3 @@ +fn main() -> std::process::ExitCode { + libremetaverse_programs::live_grid_smoke::main_entry() +} diff --git a/programs/src/completeness.rs b/programs/src/completeness.rs new file mode 100644 index 0000000..4322220 --- /dev/null +++ b/programs/src/completeness.rs @@ -0,0 +1,203 @@ +//! Compile-time-backed audit of every pinned upstream program and `TestClient` command. + +use crate::commands::TEST_CLIENT_COMMANDS; +use crate::test_client::{IMPLEMENTED_TEST_CLIENT_COMMANDS, registered_test_client_command_count}; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; + +const CARGO_MANIFEST: &str = include_str!("../Cargo.toml"); +const SOURCE_MANIFEST: &str = include_str!("../upstream-programs.json"); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProgramTarget { + pub project: &'static str, + pub binary: &'static str, + binary_source: &'static str, + offline_test_source: &'static str, +} + +pub const ORIGINAL_PROGRAMS: &[ProgramTarget] = &[ + ProgramTarget { + project: "OSDInspector", + binary: "osd-inspector", + binary_source: include_str!("bin/osd_inspector.rs"), + offline_test_source: include_str!("../tests/osd_inspector_cli.rs"), + }, + ProgramTarget { + project: "SimpleBot", + binary: "simple-bot", + binary_source: include_str!("bin/simple_bot.rs"), + offline_test_source: include_str!("../tests/simple_bot_cli.rs"), + }, + ProgramTarget { + project: "PacketDump", + binary: "packet-dump", + binary_source: include_str!("bin/packet_dump.rs"), + offline_test_source: include_str!("../tests/packet_dump_cli.rs"), + }, + ProgramTarget { + project: "PrimInspector", + binary: "prim-inspector", + binary_source: include_str!("bin/prim_inspector.rs"), + offline_test_source: include_str!("../tests/prim_inspector_cli.rs"), + }, + ProgramTarget { + project: "InventoryExplorer", + binary: "inventory-explorer", + binary_source: include_str!("bin/inventory_explorer.rs"), + offline_test_source: include_str!("../tests/inventory_explorer_cli.rs"), + }, + ProgramTarget { + project: "IRCGateway", + binary: "irc-gateway", + binary_source: include_str!("bin/irc_gateway.rs"), + offline_test_source: include_str!("../tests/irc_gateway_cli.rs"), + }, + ProgramTarget { + project: "TestClient", + binary: "test-client", + binary_source: include_str!("bin/test_client.rs"), + offline_test_source: include_str!("../tests/test_client_services_cli.rs"), + }, + ProgramTarget { + project: "VivoxTest", + binary: "vivox-test", + binary_source: include_str!("bin/vivox_test.rs"), + offline_test_source: include_str!("../tests/vivox_test_cli.rs"), + }, + ProgramTarget { + project: "WebRtcTest", + binary: "webrtc-test", + binary_source: include_str!("bin/webrtc_test.rs"), + offline_test_source: include_str!("../tests/webrtc_test_cli.rs"), + }, +]; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompletenessReport { + pub programs: usize, + pub source_files: usize, + pub test_client_commands: usize, + pub pending_programs: usize, + pub pending_commands: usize, +} + +#[derive(Deserialize)] +struct SourceManifest { + upstream_commit: String, + projects: Vec, +} + +#[derive(Deserialize)] +struct SourceProject { + project: String, + files: Vec, +} + +#[derive(Deserialize)] +struct SourceFile { + path: String, + sha256: String, +} + +/// Audits the pinned source manifest, all original Rust program targets, their +/// offline test entry points, and every generated `TestClient` command. +/// +/// # Errors +/// +/// Returns an error when the pinned source manifest is malformed or differs +/// from the native target, test, implementation, or runtime registries. +pub fn audit_program_completeness() -> Result { + let manifest: SourceManifest = serde_json::from_str(SOURCE_MANIFEST) + .map_err(|_| "program source manifest is not valid JSON".to_owned())?; + if manifest.upstream_commit.len() != 40 + || !manifest + .upstream_commit + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err("program source manifest has an invalid upstream commit".into()); + } + let mut projects = BTreeMap::new(); + let mut source_paths = BTreeSet::new(); + let mut source_files = 0_usize; + for project in &manifest.projects { + if project.files.is_empty() || projects.insert(project.project.as_str(), project).is_some() + { + return Err("program source manifest has an empty or duplicate project".into()); + } + for file in &project.files { + if !source_paths.insert(file.path.as_str()) + || file.sha256.len() != 64 + || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err("program source manifest has an invalid file record".into()); + } + source_files += 1; + } + } + let expected_projects: BTreeSet<_> = ORIGINAL_PROGRAMS + .iter() + .map(|target| target.project) + .collect(); + if projects.keys().copied().collect::>() != expected_projects { + return Err("Rust program targets do not exactly match the source manifest".into()); + } + for target in ORIGINAL_PROGRAMS { + let cargo_marker = format!("name = \"{}\"", target.binary); + if !CARGO_MANIFEST.contains(&cargo_marker) + || target.binary_source.contains("pending_program") + || !target.binary_source.contains("main_entry") + || !target.offline_test_source.contains("#[test]") + { + return Err(format!( + "program {} lacks a native target or offline test", + target.project + )); + } + } + + let source_commands: BTreeSet<_> = projects["TestClient"] + .files + .iter() + .filter_map(|file| { + file.path + .contains("/Commands/") + .then(|| file.path.rsplit('/').next()) + .flatten() + .and_then(|name| name.strip_suffix(".cs")) + }) + .collect(); + let generated_commands: BTreeSet<_> = TEST_CLIENT_COMMANDS.iter().copied().collect(); + let implemented_commands: BTreeSet<_> = + IMPLEMENTED_TEST_CLIENT_COMMANDS.iter().copied().collect(); + if source_commands != generated_commands + || implemented_commands != generated_commands + || registered_test_client_command_count() != generated_commands.len() + { + return Err("TestClient source, implementation, and runtime registries differ".into()); + } + + Ok(CompletenessReport { + programs: ORIGINAL_PROGRAMS.len(), + source_files, + test_client_commands: generated_commands.len(), + pending_programs: 0, + pending_commands: 0, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_source_program_and_command_has_a_native_tested_target() { + let report = audit_program_completeness().unwrap(); + assert_eq!(report.programs, 9); + assert!(report.source_files > report.programs); + assert_eq!(report.test_client_commands, TEST_CLIENT_COMMANDS.len()); + assert_eq!(report.pending_programs, 0); + assert_eq!(report.pending_commands, 0); + } +} diff --git a/programs/src/lib.rs b/programs/src/lib.rs index 332fe7b..d1324d4 100644 --- a/programs/src/lib.rs +++ b/programs/src/lib.rs @@ -1,8 +1,10 @@ //! Rust targets corresponding to the upstream example and tool projects. pub mod commands; +pub mod completeness; pub mod inventory_explorer; pub mod irc_gateway; +pub mod live_grid_smoke; pub mod osd_inspector; pub mod packet_dump; pub mod prim_inspector; diff --git a/programs/src/live_grid_smoke.rs b/programs/src/live_grid_smoke.rs new file mode 100644 index 0000000..9c8d13d --- /dev/null +++ b/programs/src/live_grid_smoke.rs @@ -0,0 +1,1340 @@ +//! Explicitly gated, credential-safe live-grid smoke workflow. + +#![allow(clippy::too_many_lines)] // The stage order is deliberately linear and auditable. + +use crate::completeness::audit_program_completeness; +use clap::Parser; +use libremetaverse::packets::{Packet, PacketType, RegionHandshakePacket}; +use libremetaverse::types::compat::{CancellationToken, Subscription}; +use libremetaverse::types::{AssetType, FolderType, UUID}; +use libremetaverse::{ + ChatEventArgs, ChatType, ClientLifecycleState, GridClient, InstantMessageEventArgs, + NetworkManager, PacketReceivedEventArgs, Primitive, PrimitiveTextureEntry, Simulator, +}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::future::Future; +use std::io::{self, BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::process::ExitCode; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::sync::mpsc; + +pub const EXIT_SUCCESS: u8 = 0; +pub const EXIT_USAGE: u8 = 2; +pub const EXIT_INPUT: u8 = 3; +pub const EXIT_GRID: u8 = 4; +const LIVE_CONFIRMATION: &str = "LOGIN"; +const CHAT_CONFIRMATION: &str = "CHAT"; +const MOVEMENT_CONFIRMATION: &str = "MOVE"; +const INVENTORY_CONFIRMATION: &str = "CREATE-MOVE-TO-TRASH"; +const MAX_TEXTURE_BYTES: usize = 128 * 1024 * 1024; +const OPENSIM_LOGIN_OPTIONS: [&str; 6] = [ + "avatar_picker_url", + "classified_fee", + "currency", + "destination_guide_url", + "profile-server-url", + "search", +]; +const OPENSIM_CORE_CAPABILITIES: [&str; 7] = [ + "EventQueueGet", + "FetchInventory2", + "FetchInventoryDescendents2", + "GetTexture", + "SimulatorFeatures", + "ViewerAsset", + "AvatarPickerSearch", +]; + +#[derive(Parser)] +#[command( + name = "live-grid-smoke", + version, + about = "Run the native credential-safe milestone program smoke workflow", + arg_required_else_help = true, + after_help = "Live credentials are read only from GRID_USER, GRID_PASSWORD, and GRID_LOGIN_URL in the process environment or workspace .env. The configured URI is used directly as an OpenSim/compatible-grid LLSD login endpoint, with OpenSim response options enabled. A complete live run requires explicit LOGIN, CHAT, MOVE, and CREATE-MOVE-TO-TRASH confirmations. The harness never spends L$, uploads assets, permanently deletes inventory, changes estate/parcel state, or targets another user. --fake exercises the identical evidence stages without credentials or network access." +)] +#[allow(clippy::struct_excessive_bools)] +struct Cli { + /// Run every smoke/evidence stage against the deterministic in-process backend. + #[arg(long)] + fake: bool, + /// Audit all original programs and `TestClient` commands without creating evidence. + #[arg(long)] + audit_only: bool, + /// New JSONL evidence file; existing files are never overwritten. + #[arg(long, value_name = "FILE")] + evidence: Option, + /// Explicitly permit transmission of the configured grid credentials. + #[arg(long)] + allow_live_login: bool, + /// Required literal confirmation for a live login. + #[arg(long, value_name = "LOGIN")] + confirm_live_login: Option, + /// Permit one clearly marked local-chat message from the dedicated account. + #[arg(long, requires = "allow_live_login")] + allow_public_chat: bool, + /// Required literal confirmation for local chat. + #[arg(long, value_name = "CHAT")] + confirm_public_chat: Option, + /// Permit a brief movement pulse and teleport back to the starting position. + #[arg(long, requires = "allow_live_login")] + allow_agent_movement: bool, + /// Required literal confirmation for movement/teleport. + #[arg(long, value_name = "MOVE")] + confirm_agent_movement: Option, + /// Permit creation of one smoke folder followed by a reversible move to Trash. + #[arg(long, requires = "allow_live_login")] + allow_reversible_inventory: bool, + /// Required literal confirmation for the reversible folder operation. + #[arg(long, value_name = "CREATE-MOVE-TO-TRASH")] + confirm_reversible_inventory: Option, + /// Per-stage timeout. + #[arg(long, default_value_t = 45, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))] + timeout_seconds: u64, +} + +#[derive(Clone, Eq, PartialEq)] +struct SmokeSecret(String); + +impl fmt::Debug for SmokeSecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SmokeSecret()") + } +} + +#[derive(Clone)] +pub struct SmokeConfig { + first_name: String, + last_name: String, + password: SmokeSecret, + login_url: SmokeSecret, +} + +impl fmt::Debug for SmokeConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SmokeConfig") + .field("identity", &"") + .field("password", &self.password) + .field("login_url", &self.login_url) + .finish_non_exhaustive() + } +} + +impl SmokeConfig { + /// Loads the one shared live-smoke identity from the process environment or + /// workspace `.env`. Values are never printed or written to evidence. + /// + /// # Errors + /// + /// Returns an error when a required credential is missing or `GRID_USER` + /// does not contain exactly a first and last name. + pub fn load() -> Result { + let full_name = credential("GRID_USER").ok_or(SmokeError::Config( + "GRID_USER is required in the environment or workspace .env", + ))?; + let mut names = full_name.split_whitespace(); + let (Some(first_name), Some(last_name), None) = (names.next(), names.next(), names.next()) + else { + return Err(SmokeError::Config( + "GRID_USER must contain exactly FIRSTNAME LASTNAME", + )); + }; + Ok(Self { + first_name: first_name.to_owned(), + last_name: last_name.to_owned(), + password: SmokeSecret(credential("GRID_PASSWORD").ok_or(SmokeError::Config( + "GRID_PASSWORD is required in the environment or workspace .env", + ))?), + login_url: SmokeSecret(credential("GRID_LOGIN_URL").ok_or(SmokeError::Config( + "GRID_LOGIN_URL is required in the environment or workspace .env", + ))?), + }) + } + + fn forbidden_values(&self) -> Vec { + vec![ + self.first_name.clone(), + self.last_name.clone(), + format!("{} {}", self.first_name, self.last_name), + self.password.0.clone(), + self.login_url.0.clone(), + ] + } +} + +#[derive(Debug)] +pub enum SmokeError { + Usage(&'static str), + Config(&'static str), + Input(io::Error), + Evidence(&'static str), + Stage(&'static str), +} + +impl SmokeError { + const fn exit_code(&self) -> u8 { + match self { + Self::Usage(_) | Self::Config(_) => EXIT_USAGE, + Self::Input(_) | Self::Evidence(_) => EXIT_INPUT, + Self::Stage(_) => EXIT_GRID, + } + } +} + +impl fmt::Display for SmokeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Usage(message) + | Self::Config(message) + | Self::Evidence(message) + | Self::Stage(message) => formatter.write_str(message), + Self::Input(error) => error.fmt(formatter), + } + } +} + +impl From for SmokeError { + fn from(error: io::Error) -> Self { + Self::Input(error) + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +enum Stage { + Completeness, + Login, + CapabilitiesSimulator, + ImChat, + MovementTeleport, + InventoryFolder, + ObjectProperties, + AssetTexture, + Logout, +} + +#[derive(Serialize)] +struct EvidenceRecord { + schema: u8, + sequence: u8, + recorded_unix_seconds: u64, + program_version: &'static str, + rust_commit: &'static str, + stage: Stage, + status: &'static str, + metrics: BTreeMap<&'static str, u64>, +} + +struct EvidenceWriter { + writer: BufWriter, + sequence: u8, + forbidden: Vec, +} + +impl EvidenceWriter { + fn create(path: &Path, forbidden: Vec) -> Result { + let file = OpenOptions::new().write(true).create_new(true).open(path)?; + Ok(Self { + writer: BufWriter::new(file), + sequence: 0, + forbidden: forbidden + .into_iter() + .filter(|value| value.len() >= 4) + .collect(), + }) + } + + fn success( + &mut self, + stage: Stage, + metrics: BTreeMap<&'static str, u64>, + ) -> Result<(), SmokeError> { + self.record(stage, "ok", metrics) + } + + fn failure(&mut self, stage: Stage) -> Result<(), SmokeError> { + self.record(stage, "failed", BTreeMap::new()) + } + + fn record( + &mut self, + stage: Stage, + status: &'static str, + metrics: BTreeMap<&'static str, u64>, + ) -> Result<(), SmokeError> { + self.sequence = self + .sequence + .checked_add(1) + .ok_or(SmokeError::Evidence("too many evidence records"))?; + let line = serde_json::to_string(&EvidenceRecord { + schema: 1, + sequence: self.sequence, + recorded_unix_seconds: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| SmokeError::Evidence("system clock predates the Unix epoch"))? + .as_secs(), + program_version: env!("CARGO_PKG_VERSION"), + rust_commit: env!("METACRATE_RUST_COMMIT"), + stage, + status, + metrics, + }) + .map_err(|_| SmokeError::Evidence("could not serialize smoke evidence"))?; + if self.forbidden.iter().any(|value| line.contains(value)) + || line.contains("://") + || line.to_ascii_lowercase().contains("token") + || line.to_ascii_lowercase().contains("password") + { + return Err(SmokeError::Evidence( + "evidence sanitizer rejected a secret-bearing record", + )); + } + writeln!(self.writer, "{line}")?; + self.writer.flush()?; + println!("smoke stage {stage:?}: {status}"); + Ok(()) + } + + fn finish(mut self) -> Result<(), SmokeError> { + self.writer.flush()?; + self.writer.get_ref().sync_all()?; + Ok(()) + } +} + +type SmokeFuture<'a, T> = Pin + 'a>>; + +trait SmokeBackend { + fn login(&mut self) -> SmokeFuture<'_, Result, SmokeError>>; + fn capabilities_simulator( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>>; + fn im_chat(&mut self) -> SmokeFuture<'_, Result, SmokeError>>; + fn movement_teleport( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>>; + fn inventory_folder( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>>; + fn object_properties( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>>; + fn asset_texture(&mut self) + -> SmokeFuture<'_, Result, SmokeError>>; + fn logout(&mut self) -> SmokeFuture<'_, Result, SmokeError>>; +} + +struct FakeSmokeBackend { + logged_in: bool, + texture_ready: bool, +} + +impl FakeSmokeBackend { + const fn new() -> Self { + Self { + logged_in: false, + texture_ready: false, + } + } + + fn require_login(&self) -> Result<(), SmokeError> { + self.logged_in + .then_some(()) + .ok_or(SmokeError::Stage("fake smoke backend is not logged in")) + } +} + +impl SmokeBackend for FakeSmokeBackend { + fn login(&mut self) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + self.logged_in = true; + Ok(metrics(&[ + ("authenticated", 1), + ("client_version_recorded", 1), + ( + "rust_commit_recorded", + u64::from(env!("METACRATE_RUST_COMMIT") != "unavailable"), + ), + ])) + }) + } + + fn capabilities_simulator( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + self.require_login()?; + Ok(metrics(&[ + ("event_queue_running", 1), + ("known_capabilities", 5), + ("cached_primitives", 2), + ])) + }) + } + + fn im_chat(&mut self) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + self.require_login()?; + Ok(metrics(&[ + ("self_im_transmitted", 1), + ("self_im_echo", 1), + ("local_chat_echo", 1), + ])) + }) + } + + fn movement_teleport( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + self.require_login()?; + Ok(metrics(&[ + ("movement_stopped", 1), + ("teleport_returned", 1), + ])) + }) + } + + fn inventory_folder( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + self.require_login()?; + Ok(metrics(&[("folder_created", 1), ("moved_to_trash", 1)])) + }) + } + + fn object_properties( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + self.require_login()?; + self.texture_ready = true; + Ok(metrics(&[ + ("object_observed", 1), + ("properties_received", 1), + ])) + }) + } + + fn asset_texture( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + self.require_login()?; + if !self.texture_ready { + return Err(SmokeError::Stage("fake texture was not discovered")); + } + Ok(metrics(&[ + ("texture_downloaded", 1), + ("texture_bytes", 4096), + ])) + }) + } + + fn logout(&mut self) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + self.logged_in = false; + Ok(metrics(&[("clean_logout", 1), ("active_tasks", 0)])) + }) + } +} + +struct LiveSmokeBackend { + config: SmokeConfig, + timeout: Duration, + client: Option, + network: Option, + texture: Option, + scene_traffic: Arc, + scene_subscription: Option, +} + +#[derive(Default)] +struct SceneTraffic { + handshake_identity_present: AtomicBool, + movement_complete: AtomicU64, + full_object_updates: AtomicU64, + compressed_object_updates: AtomicU64, + terse_object_updates: AtomicU64, +} + +impl SceneTraffic { + fn observe(&self, event: &PacketReceivedEventArgs) { + let packet_type = event.packet().type_; + match packet_type { + PacketType::RegionHandshake => { + if event.raw_data().is_some_and(handshake_identity_present) { + self.handshake_identity_present + .store(true, Ordering::Release); + } + } + PacketType::AgentMovementComplete => { + self.movement_complete.fetch_add(1, Ordering::Relaxed); + } + PacketType::ObjectUpdate => { + self.full_object_updates.fetch_add(1, Ordering::Relaxed); + } + PacketType::ObjectUpdateCompressed => { + self.compressed_object_updates + .fetch_add(1, Ordering::Relaxed); + } + PacketType::ImprovedTerseObjectUpdate => { + self.terse_object_updates.fetch_add(1, Ordering::Relaxed); + } + _ => {} + } + } +} + +fn handshake_identity_present(bytes: Vec) -> bool { + let Ok(length) = i32::try_from(bytes.len()) else { + return false; + }; + let Some(mut packet_end) = length.checked_sub(1) else { + return false; + }; + let Ok(mut packet) = RegionHandshakePacket::new_with_constructor() else { + return false; + }; + let mut position = 0_i32; + let mut zero_buffer = vec![0_u8; 65_536]; + packet + .from_bytes_with_bytes_int32_int32_bytes( + bytes, + &mut position, + &mut packet_end, + Some(zero_buffer.as_mut_slice()), + ) + .is_ok() + && packet + .region_info + .sim_name + .iter() + .any(|byte| *byte != 0 && !byte.is_ascii_whitespace()) +} + +fn wire_packet_type(bytes: Vec) -> Option { + let mut packet_end = i32::try_from(bytes.len()).ok()?.checked_sub(1)?; + Packet::build_packet_with_bytes_int32_bytes(bytes, &mut packet_end, vec![0_u8; 65_536]) + .ok() + .map(|packet| packet.type_) +} + +impl LiveSmokeBackend { + fn new(config: SmokeConfig, timeout: Duration) -> Self { + Self { + config, + timeout, + client: None, + network: None, + texture: None, + scene_traffic: Arc::new(SceneTraffic { + handshake_identity_present: AtomicBool::new(false), + movement_complete: AtomicU64::new(0), + full_object_updates: AtomicU64::new(0), + compressed_object_updates: AtomicU64::new(0), + terse_object_updates: AtomicU64::new(0), + }), + scene_subscription: None, + } + } + + fn client(&self) -> Result<&GridClient, SmokeError> { + self.client + .as_ref() + .ok_or(SmokeError::Stage("live smoke client is not logged in")) + } + + fn client_mut(&mut self) -> Result<&mut GridClient, SmokeError> { + self.client + .as_mut() + .ok_or(SmokeError::Stage("live smoke client is not logged in")) + } + + fn network(&self) -> Result<&NetworkManager, SmokeError> { + self.network + .as_ref() + .ok_or(SmokeError::Stage("live smoke network is unavailable")) + } + + async fn current_simulator(&self) -> Result { + let network = self.network()?.clone(); + tokio::time::timeout(self.timeout, async move { + 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(|_| SmokeError::Stage("event queue did not become ready")) + } +} + +impl SmokeBackend for LiveSmokeBackend { + fn login(&mut self) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + if env!("METACRATE_RUST_COMMIT") == "unavailable" { + return Err(SmokeError::Stage( + "live smoke requires recorded Rust source provenance", + )); + } + let mut client = GridClient::new() + .map_err(|_| SmokeError::Stage("could not construct live grid client"))?; + // The smoke proves a live transfer from the grid but must not retain grid + // content after the run. Disable the library's on-disk asset cache before + // constructing AssetManager so the texture bytes remain memory-only. + client.settings().asset_cache_mut().enabled = false; + // These services install their packet consumers lazily. OpenSim sends the + // movement-complete, initial scene, and inventory bootstrap packets during + // login, so constructing them afterwards permanently loses that evidence. + // Keep the client-owned services ready before credentials cross the wire. + let _ = client.self_(); + let _ = client.objects(); + let _ = client.inventory(); + let _ = client.assets(); + let network = client.network(); + let traffic = Arc::clone(&self.scene_traffic); + self.scene_subscription = Some(network.subscribe_packet( + PacketType::Default, + Arc::new(move |event| traffic.observe(&event)), + false, + )); + let mut login = network + .default_login_params( + self.config.first_name.clone(), + self.config.last_name.clone(), + self.config.password.0.clone(), + "MetaCrate Live Smoke".into(), + env!("CARGO_PKG_VERSION").into(), + ) + .map_err(|_| SmokeError::Stage("could not construct grid login parameters"))?; + login.uri = self.config.login_url.0.clone(); + for option in OPENSIM_LOGIN_OPTIONS { + if !login.options.iter().any(|existing| existing == option) { + login.options.push(option.to_owned()); + } + } + let authenticated = tokio::time::timeout( + self.timeout, + network.login_with_login_params_cancellation_token(login, None), + ) + .await + .map_err(|_| SmokeError::Stage("live grid login timed out"))? + .map_err(|_| SmokeError::Stage("live grid login failed"))?; + if !authenticated { + return Err(SmokeError::Stage("live grid rejected the smoke account")); + } + self.client = Some(client); + self.network = Some(network); + let _ = self.current_simulator().await?; + Ok(metrics(&[ + ("authenticated", 1), + ("disk_asset_cache_enabled", 0), + ("opensim_login_options", as_u64(OPENSIM_LOGIN_OPTIONS.len())), + ("packet_consumers_ready", 4), + ])) + }) + } + + fn capabilities_simulator( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + let simulator = self.current_simulator().await?; + // OpenSim may finish the seed-capability/event-queue bootstrap after the + // initial UDP transition packet. Repeating the idempotent final handshake + // once the event queue is live makes the simulator publish the agent and + // its initial scene to headless clients as it does to interactive viewers. + self.client_mut()? + .self_() + .complete_agent_movement(simulator.clone()) + .map_err(|_| SmokeError::Stage("OpenSim scene bootstrap request failed"))?; + let known_capabilities = OPENSIM_CORE_CAPABILITIES + .into_iter() + .filter(|name| { + simulator + .native_capability_uri(name) + .ok() + .flatten() + .is_some() + }) + .count(); + let cached_primitives = tokio::time::timeout(self.timeout, async { + loop { + let count = simulator + .objects_primitives + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len(); + if count > 0 { + return count; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await; + let Ok(cached_primitives) = cached_primitives else { + let movement_complete = + self.scene_traffic.movement_complete.load(Ordering::Relaxed); + let full_updates = self + .scene_traffic + .full_object_updates + .load(Ordering::Relaxed); + let compressed_updates = self + .scene_traffic + .compressed_object_updates + .load(Ordering::Relaxed); + let terse_updates = self + .scene_traffic + .terse_object_updates + .load(Ordering::Relaxed); + let cached_avatars = simulator + .objects_avatars + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len(); + eprintln!( + "sanitized OpenSim scene counts: movement-complete={movement_complete} full={full_updates} compressed={compressed_updates} terse={terse_updates} avatars={cached_avatars}" + ); + return Err(if movement_complete == 0 { + SmokeError::Stage("OpenSim did not acknowledge the agent movement transition") + } else if full_updates == 0 && compressed_updates == 0 && cached_avatars == 0 { + SmokeError::Stage("OpenSim published no scene object updates") + } else if full_updates > 0 || compressed_updates > 0 { + SmokeError::Stage( + "OpenSim scene object updates did not populate the primitive cache", + ) + } else { + SmokeError::Stage("OpenSim scene contained avatars but no primitives") + }); + }; + let identity_present = self + .scene_traffic + .handshake_identity_present + .load(Ordering::Acquire); + if !identity_present || simulator.seed_capability().is_none() || known_capabilities == 0 + { + return Err(SmokeError::Stage( + "simulator identity or capability bootstrap was incomplete", + )); + } + Ok(metrics(&[ + ("event_queue_running", 1), + ("simulator_identity_present", u64::from(identity_present)), + ( + "seed_capability_present", + u64::from(simulator.seed_capability().is_some()), + ), + ("known_capabilities", as_u64(known_capabilities)), + ("cached_primitives", as_u64(cached_primitives)), + ("scene_bootstrap_requested", 1), + ( + "movement_complete_packets", + self.scene_traffic.movement_complete.load(Ordering::Relaxed), + ), + ( + "full_object_update_packets", + self.scene_traffic + .full_object_updates + .load(Ordering::Relaxed), + ), + ( + "compressed_object_update_packets", + self.scene_traffic + .compressed_object_updates + .load(Ordering::Relaxed), + ), + ( + "terse_object_update_packets", + self.scene_traffic + .terse_object_updates + .load(Ordering::Relaxed), + ), + ])) + }) + } + + fn im_chat(&mut self) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + enum SocialEvent { + Im(UUID, String), + Chat(UUID, String), + } + let timeout = self.timeout; + let client = self.client_mut()?; + let own_id = client.self_().agent_id(); + let marker_id = UUID::random() + .map_err(|_| SmokeError::Stage("could not create social smoke marker"))?; + let marker = format!("MetaCrate smoke {marker_id}"); + let observed_im = Arc::new(AtomicBool::new(false)); + let observed_chat = Arc::new(AtomicBool::new(false)); + let transmitted_im = Arc::new(AtomicBool::new(false)); + let (sender, mut receiver) = mpsc::channel(16); + let im_sender = sender.clone(); + let im_observation = Arc::clone(&observed_im); + let im_marker = marker.clone(); + let im: Subscription = + client + .self_() + .subscribe_im(Arc::new(move |event: InstantMessageEventArgs| { + let message = event.im(); + if message.from_agent_id == own_id && message.message == im_marker { + im_observation.store(true, Ordering::Release); + } + let _ = im_sender + .try_send(SocialEvent::Im(message.from_agent_id, message.message)); + })); + let chat_sender = sender; + let chat_observation = Arc::clone(&observed_chat); + let chat_marker = marker.clone(); + let chat: Subscription = client.self_().subscribe_chat_from_simulator(Arc::new( + move |event: ChatEventArgs| { + if event.source_id() == own_id && event.message() == chat_marker { + chat_observation.store(true, Ordering::Release); + } + let _ = + chat_sender.try_send(SocialEvent::Chat(event.source_id(), event.message())); + }, + )); + let im_transmission = Arc::clone(&transmitted_im); + let sent = client + .network() + .subscribe_packet_sent(Arc::new(move |event| { + let mut data = event.data(); + data.truncate(usize::try_from(event.sent_bytes()).unwrap_or_default()); + if wire_packet_type(data) == Some(PacketType::ImprovedInstantMessage) { + im_transmission.store(true, Ordering::Release); + } + })); + client + .self_() + .instant_message_with_uuid_string(own_id, marker.clone()) + .map_err(|_| SmokeError::Stage("self instant-message send failed"))?; + client + .self_() + .chat(marker.clone(), 0, ChatType::Normal, Some(false)) + .map_err(|_| SmokeError::Stage("local-chat smoke send failed"))?; + let echoes = tokio::time::timeout(timeout, async { + let mut saw_im = false; + let mut saw_chat = false; + while !(saw_chat && (saw_im || transmitted_im.load(Ordering::Acquire))) { + match receiver.recv().await { + Some(SocialEvent::Im(source, message)) => { + saw_im |= source == own_id && message == marker; + } + Some(SocialEvent::Chat(source, message)) => { + saw_chat |= source == own_id && message == marker; + } + None => break, + } + } + (saw_im, saw_chat) + }) + .await; + drop((im, chat, sent)); + let (self_im_echo, local_chat_echo) = echoes.unwrap_or_else(|_| { + ( + observed_im.load(Ordering::Acquire), + observed_chat.load(Ordering::Acquire), + ) + }); + let self_im_transmitted = transmitted_im.load(Ordering::Acquire); + if !self_im_transmitted || !local_chat_echo { + eprintln!( + "sanitized OpenSim social evidence: self-im-transmitted={} self-im-echo={} local-chat-echo={}", + u8::from(self_im_transmitted), + u8::from(self_im_echo), + u8::from(local_chat_echo) + ); + return Err(SmokeError::Stage("IM/chat smoke evidence was incomplete")); + } + Ok(metrics(&[ + ("self_im_transmitted", 1), + ("self_im_echo", u64::from(self_im_echo)), + ("local_chat_echo", 1), + ])) + }) + } + + fn movement_teleport( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + let simulator = self.current_simulator().await?; + let timeout = self.timeout; + let agent = self.client_mut()?.self_(); + let original = agent.sim_position(); + agent.movement.set_at_pos(true); + agent + .movement + .send_update_with_boolean(Some(false)) + .map_err(|_| SmokeError::Stage("movement smoke update failed"))?; + tokio::time::sleep(Duration::from_millis(250)).await; + agent.movement.set_at_pos(false); + agent + .movement + .send_update_with_boolean(Some(true)) + .map_err(|_| SmokeError::Stage("movement smoke stop failed"))?; + let teleport = agent.teleport_with_u_int64_vector3_cancellation_token( + simulator.handle, + original, + Some(CancellationToken::default()), + ); + let returned = tokio::time::timeout(timeout, teleport) + .await + .map_err(|_| SmokeError::Stage("return teleport timed out"))? + .map_err(|_| SmokeError::Stage("return teleport failed"))?; + if !returned { + return Err(SmokeError::Stage("return teleport was rejected")); + } + Ok(metrics(&[ + ("movement_stopped", 1), + ("teleport_returned", 1), + ])) + }) + } + + fn inventory_folder( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + let manager = self.client()?.inventory(); + let (root, trash) = tokio::time::timeout(self.timeout, async { + loop { + if let Some(store) = manager.store() + && let Some(root) = store.root_folder() + && let Ok(trash) = + manager.find_folder_for_type_with_folder_type(FolderType::Trash) + { + return (root.base.uuid(), trash); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .map_err(|_| SmokeError::Stage("inventory root/trash did not become ready"))?; + let marker = UUID::random() + .map_err(|_| SmokeError::Stage("could not create inventory smoke marker"))?; + let folder = manager + .create_folder_with_uuid_string(root, format!("MetaCrate Smoke {marker}")) + .map_err(|_| SmokeError::Stage("smoke folder creation failed"))?; + tokio::time::timeout( + self.timeout, + manager.move_folder_with_uuid_uuid_cancellation_token_4497f975( + folder, + trash, + Some(CancellationToken::default()), + ), + ) + .await + .map_err(|_| SmokeError::Stage("smoke folder move-to-trash timed out"))? + .map_err(|_| SmokeError::Stage("smoke folder move-to-trash failed"))?; + Ok(metrics(&[("folder_created", 1), ("moved_to_trash", 1)])) + }) + } + + fn object_properties( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + let simulator = self.current_simulator().await?; + let primitive = simulator + .objects_primitives + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .find(|primitive| first_texture(primitive).is_some()) + .cloned() + .ok_or(SmokeError::Stage( + "no textured primitive was available for object smoke", + ))?; + self.texture = first_texture(&primitive); + let property_received = if primitive.properties.is_some() { + true + } else { + let objects = self.client()?.objects(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + let callback = Arc::clone(&sender); + let object_id = primitive.id; + let subscription = objects.subscribe_object_properties(Arc::new(move |event| { + if event.properties().object_id == object_id + && let Some(sender) = lock(&callback).take() + { + let _ = sender.send(()); + } + })); + objects + .select_object_with_simulator_u_int32(simulator.clone(), primitive.local_id) + .map_err(|_| SmokeError::Stage("object property request failed"))?; + let property_arrived = tokio::time::timeout(self.timeout, receiver).await.is_ok(); + let _ = objects.deselect_object(simulator, primitive.local_id); + drop(subscription); + property_arrived + }; + if !property_received { + return Err(SmokeError::Stage("object properties were not received")); + } + Ok(metrics(&[ + ("object_observed", 1), + ("properties_received", 1), + ])) + }) + } + + fn asset_texture( + &mut self, + ) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + let texture = self + .texture + .ok_or(SmokeError::Stage("object smoke did not discover a texture"))?; + let assets = self.client()?.assets(); + let request = assets.request_asset_with_uuid_asset_type_boolean_cancellation_token( + texture, + AssetType::Texture, + true, + Some(CancellationToken::default()), + ); + let asset = tokio::time::timeout(self.timeout, request) + .await + .map_err(|_| SmokeError::Stage("texture download timed out"))? + .map_err(|_| SmokeError::Stage("texture download failed"))? + .ok_or(SmokeError::Stage("texture was not returned"))?; + if asset.asset_data.is_empty() || asset.asset_data.len() > MAX_TEXTURE_BYTES { + return Err(SmokeError::Stage( + "texture response was empty or exceeded 128 MiB", + )); + } + Ok(metrics(&[ + ("texture_downloaded", 1), + ("texture_bytes", as_u64(asset.asset_data.len())), + ])) + }) + } + + fn logout(&mut self) -> SmokeFuture<'_, Result, SmokeError>> { + Box::pin(async move { + let (network_ok, disconnected, pending_logout_tasks) = + self.network.take().map_or((true, true, 0), |network| { + let result = network.logout_with_method(); + ( + result.is_ok(), + !network.connected(), + network.pending_logout_tasks(), + ) + }); + let (client_ok, client_disposed) = if let Some(mut client) = self.client.take() { + let agent_ok = client.self_().dispose().is_ok(); + let shutdown_ok = client.dispose_with_method().is_ok(); + ( + agent_ok && shutdown_ok, + client.lifecycle_state() == ClientLifecycleState::Disposed, + ) + } else { + (true, true) + }; + if !network_ok + || !disconnected + || pending_logout_tasks != 0 + || !client_ok + || !client_disposed + { + return Err(SmokeError::Stage("live grid cleanup failed")); + } + Ok(metrics(&[ + ("clean_logout", 1), + ("network_disconnected", 1), + ("client_disposed", 1), + ("active_tasks", 0), + ])) + }) + } +} + +/// Runs the live-grid smoke 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!("live-grid-smoke: could not initialize async runtime"); + return ExitCode::from(EXIT_GRID); + }; + let result = runtime.block_on(run(cli)); + runtime.shutdown_timeout(Duration::from_secs(2)); + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("live-grid-smoke: {error}"); + ExitCode::from(error.exit_code()) + } + } +} + +async fn run(cli: Cli) -> Result<(), SmokeError> { + if cli.audit_only { + if cli.fake + || cli.evidence.is_some() + || cli.allow_live_login + || cli.allow_public_chat + || cli.allow_agent_movement + || cli.allow_reversible_inventory + { + return Err(SmokeError::Usage( + "--audit-only cannot be combined with smoke or live options", + )); + } + let report = audit_program_completeness().map_err(|_| { + SmokeError::Stage("program/source completeness audit did not reach 100%") + })?; + println!( + "Program completeness: programs={} TestClient-commands={} pending-programs=0 pending-commands=0", + report.programs, report.test_client_commands + ); + return Ok(()); + } + let evidence = cli.evidence.as_deref().ok_or(SmokeError::Usage( + "--evidence FILE is required for fake and live smoke runs", + ))?; + if cli.fake { + if cli.allow_live_login + || cli.confirm_live_login.is_some() + || cli.allow_public_chat + || cli.confirm_public_chat.is_some() + || cli.allow_agent_movement + || cli.confirm_agent_movement.is_some() + || cli.allow_reversible_inventory + || cli.confirm_reversible_inventory.is_some() + { + return Err(SmokeError::Usage( + "live permissions and confirmations cannot be combined with --fake", + )); + } + let mut writer = EvidenceWriter::create(evidence, Vec::new())?; + let mut backend = FakeSmokeBackend::new(); + run_smoke(&mut backend, &mut writer).await?; + writer.finish()?; + return Ok(()); + } + if !cli.allow_live_login || cli.confirm_live_login.as_deref() != Some(LIVE_CONFIRMATION) { + return Err(SmokeError::Usage( + "live smoke requires --allow-live-login --confirm-live-login LOGIN", + )); + } + if !cli.allow_public_chat || cli.confirm_public_chat.as_deref() != Some(CHAT_CONFIRMATION) { + return Err(SmokeError::Usage( + "complete live smoke requires --allow-public-chat --confirm-public-chat CHAT", + )); + } + if !cli.allow_agent_movement + || cli.confirm_agent_movement.as_deref() != Some(MOVEMENT_CONFIRMATION) + { + return Err(SmokeError::Usage( + "complete live smoke requires --allow-agent-movement --confirm-agent-movement MOVE", + )); + } + if !cli.allow_reversible_inventory + || cli.confirm_reversible_inventory.as_deref() != Some(INVENTORY_CONFIRMATION) + { + return Err(SmokeError::Usage( + "complete live smoke requires --allow-reversible-inventory --confirm-reversible-inventory CREATE-MOVE-TO-TRASH", + )); + } + let config = SmokeConfig::load()?; + let mut writer = EvidenceWriter::create(evidence, config.forbidden_values())?; + let mut backend = LiveSmokeBackend::new(config, Duration::from_secs(cli.timeout_seconds)); + run_smoke(&mut backend, &mut writer).await?; + writer.finish() +} + +async fn run_smoke( + backend: &mut B, + writer: &mut EvidenceWriter, +) -> Result<(), SmokeError> { + let report = audit_program_completeness() + .map_err(|_| SmokeError::Stage("program/source completeness audit failed"))?; + writer.success( + Stage::Completeness, + metrics(&[ + ("programs", as_u64(report.programs)), + ("source_files", as_u64(report.source_files)), + ("test_client_commands", as_u64(report.test_client_commands)), + ("pending_programs", 0), + ("pending_commands", 0), + ]), + )?; + let operation = run_operational_stages(backend, writer).await; + let logout = backend.logout().await; + let logout_record = match &logout { + Ok(values) => writer.success(Stage::Logout, values.clone()), + Err(_) => writer.failure(Stage::Logout), + }; + operation?; + logout?; + logout_record +} + +async fn run_operational_stages( + backend: &mut B, + writer: &mut EvidenceWriter, +) -> Result<(), SmokeError> { + record_stage(writer, Stage::Login, backend.login().await)?; + record_stage( + writer, + Stage::CapabilitiesSimulator, + backend.capabilities_simulator().await, + )?; + record_stage(writer, Stage::ImChat, backend.im_chat().await)?; + record_stage( + writer, + Stage::MovementTeleport, + backend.movement_teleport().await, + )?; + record_stage( + writer, + Stage::InventoryFolder, + backend.inventory_folder().await, + )?; + record_stage( + writer, + Stage::ObjectProperties, + backend.object_properties().await, + )?; + record_stage(writer, Stage::AssetTexture, backend.asset_texture().await)?; + Ok(()) +} + +fn record_stage( + writer: &mut EvidenceWriter, + stage: Stage, + result: Result, SmokeError>, +) -> Result<(), SmokeError> { + match result { + Ok(values) => writer.success(stage, values), + Err(error) => { + writer.failure(stage)?; + Err(error) + } + } +} + +fn credential(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + let dotenv = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env"); + let contents = std::fs::read_to_string(dotenv).ok()?; + contents.lines().find_map(|line| { + let line = line.trim().strip_prefix("export ").unwrap_or(line.trim()); + let (key, value) = line.split_once('=')?; + (key.trim() == name) + .then(|| value.trim().trim_matches(['\'', '"']).to_owned()) + .filter(|value| !value.is_empty()) + }) + }) +} + +fn metrics(values: &[(&'static str, u64)]) -> BTreeMap<&'static str, u64> { + values.iter().copied().collect() +} + +fn as_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +fn first_texture(primitive: &Primitive) -> Option { + let mut textures = BTreeSet::new(); + if let Some(entry) = &primitive.textures { + if let Some(face) = &entry.default_texture { + textures.insert(face.texture_id()); + } + textures.extend( + entry + .face_textures + .iter() + .flatten() + .map(libremetaverse::PrimitiveTextureEntryFace::texture_id), + ); + } + if let Some(sculpt) = &primitive.sculpt { + textures.insert(sculpt.sculpt_texture); + } + textures.remove(&UUID::zero()); + textures.remove(&PrimitiveTextureEntry::white_texture()); + textures.into_iter().next() +} + +fn lock(value: &Mutex) -> MutexGuard<'_, T> { + value + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secrets_are_always_redacted_from_debug() { + let config = SmokeConfig { + first_name: "First".into(), + last_name: "Last".into(), + password: SmokeSecret("super-secret".into()), + login_url: SmokeSecret("https://grid.invalid/login".into()), + }; + let debug = format!("{config:?}"); + assert!(!debug.contains("super-secret")); + assert!(!debug.contains("grid.invalid")); + assert!(debug.contains("")); + } + + #[test] + fn texture_selection_ignores_zero_and_builtin_white() { + let primitive = Primitive::new_with_constructor().unwrap(); + assert!(first_texture(&primitive).is_none()); + } + + #[test] + fn simulator_identity_is_read_from_transport_zerocoded_opensim_handshake() { + let mut packet = RegionHandshakePacket::new_with_constructor().unwrap(); + packet.region_info.sim_name = b"OpenSim Smoke Region\0".to_vec(); + let raw = packet.to_bytes_with_method().unwrap(); + let wire = if raw[0] & libremetaverse::Helpers::MSG_ZEROCODED == 0 { + raw + } else { + let mut encoded = vec![0_u8; raw.len().saturating_mul(2).saturating_add(2)]; + let length = libremetaverse::Helpers::zero_encode( + Some(&raw), + i32::try_from(raw.len()).unwrap(), + Some(&mut encoded), + ) + .unwrap(); + encoded.truncate(usize::try_from(length).unwrap()); + encoded + }; + assert_eq!( + wire_packet_type(wire.clone()), + Some(PacketType::RegionHandshake) + ); + assert!(handshake_identity_present(wire)); + } +} diff --git a/programs/src/test_client.rs b/programs/src/test_client.rs index 884bb2f..61984bd 100644 --- a/programs/src/test_client.rs +++ b/programs/src/test_client.rs @@ -2,6 +2,7 @@ mod inventory; mod services; +mod voice; mod world; use crate::commands::TEST_CLIENT_COMMANDS; @@ -164,6 +165,8 @@ pub const IMPLEMENTED_TEST_CLIENT_COMMANDS: &[&str] = &[ "RegionInfoCommand", "StatsCommand", "UptimeCommand", + "ParcelVoiceInfo", + "VoiceAcountCommand", ]; #[must_use] @@ -176,6 +179,11 @@ pub fn pending_test_client_commands() -> Vec<&'static str> { .collect() } +#[must_use] +pub fn registered_test_client_command_count() -> usize { + built_in_commands().len() +} + #[derive(Parser)] #[command( name = "test-client", @@ -325,7 +333,9 @@ enum ClientEvent { type BackendFuture<'a, T> = Pin + Send + 'a>>; type InventoryFuture<'a, T> = Pin + 'a>>; -trait ClientBackend: Send + Sync + inventory::Backend + services::Backend + world::Backend { +trait ClientBackend: + Send + Sync + inventory::Backend + services::Backend + voice::Backend + world::Backend +{ fn id(&self) -> UUID; fn name(&self) -> String; fn connected(&self) -> bool; @@ -876,6 +886,7 @@ enum CommandHandler { ImGroup, Inventory(inventory::Command), Services(services::Command), + Voice(voice::Command), World(world::Command), Load, Login, @@ -1038,6 +1049,7 @@ fn built_in_commands() -> HashMap { .chain(communication_commands()) .chain(inventory::commands()) .chain(services::commands()) + .chain(voice::commands()) .chain(world::commands()) .map(|(name, description, category, handler)| { ( @@ -1822,6 +1834,9 @@ async fn execute_client_command( CommandHandler::Services(command) => { services::execute(client.backend.as_ref(), *command, args, cancellation).await } + CommandHandler::Voice(command) => { + voice::execute(client.backend.as_ref(), *command, args, cancellation).await + } CommandHandler::World(command) => { world::execute(client.backend.as_ref(), *command, args, cancellation).await } @@ -2716,7 +2731,7 @@ mod tests { assert!(TEST_CLIENT_COMMANDS.contains(command)); assert!(!pending.contains(command)); } - assert_eq!(pending, ["ParcelVoiceInfo", "VoiceAcountCommand"]); + assert!(pending.is_empty()); } #[test] diff --git a/programs/src/test_client/voice.rs b/programs/src/test_client/voice.rs new file mode 100644 index 0000000..bbc005b --- /dev/null +++ b/programs/src/test_client/voice.rs @@ -0,0 +1,210 @@ +//! Credential-safe native `TestClient` voice capability commands. + +use super::{FakeBackend, InventoryFuture as BackendFuture, LiveBackend, lock}; +use libremetaverse::types::compat::{CancellationToken, Uri}; +use libremetaverse_structured_data::{OSD, OSDFormat, OSDParser}; +use std::collections::HashMap; +use std::time::Duration; + +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy)] +pub(super) enum Command { + Parcel, + Account, +} + +pub(super) fn commands() -> [super::CommandEntry; 2] { + use super::{CommandCategory as Cat, CommandHandler}; + [ + ( + "voiceparcel", + "Obtain redacted parcel voice information. Usage: voiceparcel", + Cat::Other, + CommandHandler::Voice(Command::Parcel), + ), + ( + "voiceaccount", + "Provision voice account information with credentials redacted. Usage: voiceaccount", + Cat::Other, + CommandHandler::Voice(Command::Account), + ), + ] +} + +pub(super) trait Backend { + fn voice_query( + &self, + command: Command, + cancellation: CancellationToken, + ) -> BackendFuture<'_, Result>; +} + +pub(super) enum VoiceResult { + Parcel { + region_name_present: bool, + parcel_local_id: i32, + channel_present: bool, + }, + Account { + username_present: bool, + password_present: bool, + }, +} + +pub(super) async fn execute( + backend: &B, + command: Command, + args: &[String], + cancellation: CancellationToken, +) -> String { + if !args.is_empty() { + return match command { + Command::Parcel => "Usage: voiceparcel", + Command::Account => "Usage: voiceaccount", + } + .into(); + } + match backend.voice_query(command, cancellation).await { + Ok(VoiceResult::Parcel { + region_name_present, + parcel_local_id, + channel_present, + }) => format!( + "Parcel voice info: region-name-present={region_name_present}, parcel-local-id={parcel_local_id}, channel=<{}>", + if channel_present { + "redacted" + } else { + "missing" + } + ), + Ok(VoiceResult::Account { + username_present, + password_present, + }) => format!( + "Voice account provisioned: credentials=<{}>", + if username_present && password_present { + "redacted" + } else { + "incomplete" + } + ), + Err(error) => error, + } +} + +impl Backend for FakeBackend { + fn voice_query( + &self, + command: Command, + cancellation: CancellationToken, + ) -> BackendFuture<'_, Result> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("Voice capability request cancelled".into()); + } + Ok(match command { + Command::Parcel => { + self.record("CALL voice-parcel-info".into()); + VoiceResult::Parcel { + region_name_present: true, + parcel_local_id: 42, + channel_present: true, + } + } + Command::Account => { + self.record("CALL voice-provision-account credentials=".into()); + VoiceResult::Account { + username_present: true, + password_present: true, + } + } + }) + }) + } +} + +impl Backend for LiveBackend { + fn voice_query( + &self, + command: Command, + cancellation: CancellationToken, + ) -> BackendFuture<'_, Result> { + Box::pin(async move { + let capability = match command { + Command::Parcel => "ParcelVoiceInfoRequest", + Command::Account => "ProvisionVoiceAccountRequest", + }; + let simulator = self + .network + .current_sim() + .ok_or_else(|| "No current simulator is available".to_owned())?; + let uri = simulator + .native_capability_uri(capability) + .map_err(|_| "Voice capability lookup failed".to_owned())? + .ok_or_else(|| "Required voice capability is unavailable".to_owned())?; + let http = lock(&self.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()), + cancellation.clone(), + None, + ); + let result = tokio::select! { + () = cancellation.cancelled() => return Err("Voice capability request cancelled".into()), + () = tokio::time::sleep(REQUEST_TIMEOUT) => return Err("Voice capability request timed out".into()), + result = request => result, + } + .map_err(|_| "Voice capability request failed".to_owned())?; + let (response, bytes) = result; + if !response.is_success_status_code() || bytes.len() > MAX_RESPONSE_BYTES { + return Err("Voice capability returned an error".into()); + } + let OSD::Map(map) = OSDParser::deserialize_with_bytes(bytes) + .map_err(|_| "Voice capability returned malformed LLSD".to_owned())? + else { + return Err("Voice capability response was not an LLSD map".into()); + }; + Ok(match command { + Command::Parcel => { + let region_name_present = present_string(&map, "region_name"); + let parcel_local_id = map + .get("parcel_local_id") + .and_then(|value| value.as_integer().ok()) + .ok_or_else(|| "Parcel voice response omitted local ID".to_owned())?; + let channel_present = match map.get("voice_credentials") { + Some(OSD::Map(credentials)) => present_string(credentials, "channel_uri"), + _ => false, + }; + if !region_name_present || !channel_present { + return Err("Parcel voice response omitted required fields".into()); + } + VoiceResult::Parcel { + region_name_present, + parcel_local_id, + channel_present, + } + } + Command::Account => { + let username_present = present_string(&map, "username"); + let password_present = present_string(&map, "password"); + if !username_present || !password_present { + return Err("Voice account response omitted credentials".into()); + } + VoiceResult::Account { + username_present, + password_present, + } + } + }) + }) + } +} + +fn present_string(map: &HashMap, key: &str) -> bool { + map.get(key) + .and_then(|value| value.as_string().ok()) + .is_some_and(|value| !value.is_empty()) +} diff --git a/programs/tests/live_grid_smoke_cli.rs b/programs/tests/live_grid_smoke_cli.rs new file mode 100644 index 0000000..331f0d6 --- /dev/null +++ b/programs/tests/live_grid_smoke_cli.rs @@ -0,0 +1,178 @@ +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const EXIT_USAGE: i32 = 2; +const EXIT_INPUT: i32 = 3; +static TEMP_ID: AtomicU64 = AtomicU64::new(0); + +struct TestDir(PathBuf); + +impl TestDir { + fn new() -> Self { + let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "metacrate-live-grid-smoke-{}-{id}", + std::process::id() + )); + fs::create_dir(&path).expect("create smoke test directory"); + Self(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 text(path: &Path) -> &str { + path.to_str().expect("UTF-8 test path") +} + +fn run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_live-grid-smoke")) + .args(args) + .env_remove("GRID_USER") + .env_remove("GRID_PASSWORD") + .env_remove("GRID_LOGIN_URL") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("run live-grid-smoke") +} + +#[test] +fn help_documents_credentials_side_effect_gates_and_unsupported_dangerous_actions() { + let output = run(&["--help"]); + assert!(output.status.success()); + let help = String::from_utf8(output.stdout).unwrap(); + for marker in [ + "GRID_USER", + "workspace .env", + "OpenSim/compatible-grid LLSD login endpoint", + "--fake", + "--audit-only", + "--evidence", + "--allow-live-login", + "--allow-public-chat", + "--allow-agent-movement", + "--allow-reversible-inventory", + "never spends L$", + "permanently deletes", + "targets another user", + ] { + assert!(help.contains(marker), "help omitted {marker}:\n{help}"); + } + assert_eq!(run(&[]).status.code(), Some(EXIT_USAGE)); +} + +#[test] +fn fake_smoke_records_every_sanitized_stage_without_skips_or_credentials() { + let directory = TestDir::new(); + let evidence = directory.path("evidence.jsonl"); + let output = run(&["--fake", "--evidence", text(&evidence)]); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + output.status.success(), + "stdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(stderr.is_empty(), "{stderr}"); + let contents = fs::read_to_string(evidence).unwrap(); + assert!(!contents.contains("://")); + assert!(!contents.to_ascii_lowercase().contains("password")); + assert!(!contents.to_ascii_lowercase().contains("token")); + let records: Vec = contents + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(records.len(), 9); + let stages: Vec<_> = records + .iter() + .map(|record| record["stage"].as_str().unwrap()) + .collect(); + assert_eq!( + stages, + [ + "completeness", + "login", + "capabilities-simulator", + "im-chat", + "movement-teleport", + "inventory-folder", + "object-properties", + "asset-texture", + "logout", + ] + ); + assert!(records.iter().all(|record| record["status"] == "ok")); + assert!(records.iter().all(|record| record["schema"] == 1)); + assert!( + records + .iter() + .all(|record| record["recorded_unix_seconds"].as_u64().is_some()) + ); + assert!( + records + .iter() + .all(|record| record["program_version"].as_str().is_some()) + ); + assert!( + records + .iter() + .all(|record| record["rust_commit"].as_str().is_some()) + ); + assert_eq!(records[0]["metrics"]["programs"], 9); + assert_eq!(records[0]["metrics"]["pending_programs"], 0); + assert_eq!(records[0]["metrics"]["pending_commands"], 0); + assert_eq!(records[8]["metrics"]["active_tasks"], 0); +} + +#[test] +fn audit_proves_all_programs_and_commands_without_credentials_or_evidence() { + let output = run(&["--audit-only"]); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(stdout.contains("programs=9"), "{stdout}"); + assert!(stdout.contains("pending-programs=0"), "{stdout}"); + assert!(stdout.contains("pending-commands=0"), "{stdout}"); +} + +#[test] +fn live_mode_requires_every_literal_confirmation_before_loading_credentials() { + let directory = TestDir::new(); + let evidence = directory.path("evidence.jsonl"); + let output = run(&[ + "--evidence", + text(&evidence), + "--allow-live-login", + "--confirm-live-login", + "LOGIN", + ]); + assert_eq!(output.status.code(), Some(EXIT_USAGE)); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("--allow-public-chat"), "{stderr}"); + assert!(!evidence.exists()); +} + +#[test] +fn evidence_is_create_new_and_never_overwrites_a_previous_audit() { + let directory = TestDir::new(); + let evidence = directory.path("evidence.jsonl"); + fs::write(&evidence, "preserve-me\n").unwrap(); + let output = run(&["--fake", "--evidence", text(&evidence)]); + assert_eq!(output.status.code(), Some(EXIT_INPUT)); + assert_eq!(fs::read_to_string(evidence).unwrap(), "preserve-me\n"); +} diff --git a/programs/tests/test_client_services_cli.rs b/programs/tests/test_client_services_cli.rs index 2127167..f102ced 100644 --- a/programs/tests/test_client_services_cli.rs +++ b/programs/tests/test_client_services_cli.rs @@ -54,7 +54,7 @@ fn run(args: &[&str]) -> Output { #[test] #[allow(clippy::too_many_lines)] -fn full_offline_session_exercises_every_remaining_non_voice_command() { +fn full_offline_session_exercises_every_remaining_service_and_voice_command() { let directory = TestDir::new(); let script = directory.write(&format!( "!client\t{CLIENT}\tAlice\tBot\n\ @@ -106,6 +106,8 @@ fn full_offline_session_exercises_every_remaining_non_voice_command() { invitegroup {AVATAR} {GROUP} {ROLE} --confirm\n\ joingroup Builders Guild --confirm\n\ leavegroup Builders Guild --confirm\n\ + voiceparcel\n\ + voiceaccount\n\ quit\n" )); let output = run(&[ @@ -154,6 +156,8 @@ fn full_offline_session_exercises_every_remaining_non_voice_command() { "invited", "Joined the group Builders Guild", "has left the group Builders Guild", + "Parcel voice info: region-name-present=true, parcel-local-id=42, channel=", + "Voice account provisioned: credentials=", "CALL profile-clone", "CALL generic-message", "CALL animation-start", @@ -162,6 +166,8 @@ fn full_offline_session_exercises_every_remaining_non_voice_command() { "CALL group-invite", "CALL group-join", "CALL group-leave", + "CALL voice-parcel-info", + "CALL voice-provision-account credentials=", ] { assert!(stdout.contains(expected), "missing {expected:?}:\n{stdout}"); } @@ -207,9 +213,6 @@ fn social_mutations_require_startup_and_per_command_confirmation() { } #[test] -fn command_inventory_leaves_only_explicit_voice_adapters() { - assert_eq!( - libremetaverse_programs::test_client::pending_test_client_commands(), - ["ParcelVoiceInfo", "VoiceAcountCommand"] - ); +fn command_inventory_has_no_pending_source_commands() { + assert!(libremetaverse_programs::test_client::pending_test_client_commands().is_empty()); }