Implement credential-safe programs smoke gate (#97)
Some checks failed
Native code generation / deterministic (push) Failing after 2m12s
Imaging and meshing gate / native (push) Failing after 5m40s
JPEG 2000 feature / linux (push) Successful in 2m50s
Native Rust workspace compile / compile (push) Failing after 56s
Skia feature / linux (push) Successful in 31m24s

This commit is contained in:
2026-08-11 17:04:40 +00:00
parent 66f10baa1b
commit a9e2711447
18 changed files with 2216 additions and 25 deletions

View File

@@ -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

2
Cargo.lock generated
View File

@@ -1618,6 +1618,8 @@ dependencies = [
"libremetaverse-voice-webrtc",
"regex",
"roxmltree",
"serde",
"serde_json",
"tokio",
]

View File

@@ -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 {

View File

@@ -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)?,

View File

@@ -53,8 +53,18 @@ fn decode_packet<T: GeneratedPacket>(bytes: &[u8]) -> Result<T, Error> {
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<String, Error> {
@@ -2745,6 +2755,24 @@ mod tests {
(manager, simulator)
}
fn transport_wire(data: &[u8]) -> Vec<u8> {
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));

101
docs/live-grid-smoke.md Normal file
View File

@@ -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.

View File

@@ -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.

View File

@@ -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"

View File

@@ -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

46
programs/build.rs Normal file
View File

@@ -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<String> {
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<String> {
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())
}

View File

@@ -0,0 +1,3 @@
fn main() -> std::process::ExitCode {
libremetaverse_programs::live_grid_smoke::main_entry()
}

View File

@@ -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<SourceProject>,
}
#[derive(Deserialize)]
struct SourceProject {
project: String,
files: Vec<SourceFile>,
}
#[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<CompletenessReport, String> {
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::<BTreeSet<_>>() != 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);
}
}

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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<Box<dyn Future<Output = T> + Send + 'a>>;
type InventoryFuture<'a, T> = Pin<Box<dyn Future<Output = T> + '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<String, CommandDefinition> {
.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]

View File

@@ -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<VoiceResult, String>>;
}
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<B: Backend + ?Sized>(
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<VoiceResult, String>> {
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=<redacted>".into());
VoiceResult::Account {
username_present: true,
password_present: true,
}
}
})
})
}
}
impl Backend for LiveBackend {
fn voice_query(
&self,
command: Command,
cancellation: CancellationToken,
) -> BackendFuture<'_, Result<VoiceResult, String>> {
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<String, OSD>, key: &str) -> bool {
map.get(key)
.and_then(|value| value.as_string().ok())
.is_some_and(|value| !value.is_empty())
}

View File

@@ -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<Value> = 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");
}

View File

@@ -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=<redacted>",
"Voice account provisioned: credentials=<redacted>",
"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=<redacted>",
] {
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());
}