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
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:
@@ -20,6 +20,8 @@ on:
|
|||||||
- "tools/generate_lsl_tables.py"
|
- "tools/generate_lsl_tables.py"
|
||||||
- "codegen/inputs/lsl_tools_grammar.json"
|
- "codegen/inputs/lsl_tools_grammar.json"
|
||||||
- "docs/extension-milestone-gate.md"
|
- "docs/extension-milestone-gate.md"
|
||||||
|
- "docs/live-grid-smoke.md"
|
||||||
|
- "programs/README.md"
|
||||||
- "api/SHIM-COVERAGE.md"
|
- "api/SHIM-COVERAGE.md"
|
||||||
- "**/*.rs"
|
- "**/*.rs"
|
||||||
- "**/Cargo.toml"
|
- "**/Cargo.toml"
|
||||||
@@ -43,6 +45,8 @@ on:
|
|||||||
- "tools/generate_lsl_tables.py"
|
- "tools/generate_lsl_tables.py"
|
||||||
- "codegen/inputs/lsl_tools_grammar.json"
|
- "codegen/inputs/lsl_tools_grammar.json"
|
||||||
- "docs/extension-milestone-gate.md"
|
- "docs/extension-milestone-gate.md"
|
||||||
|
- "docs/live-grid-smoke.md"
|
||||||
|
- "programs/README.md"
|
||||||
- "api/SHIM-COVERAGE.md"
|
- "api/SHIM-COVERAGE.md"
|
||||||
- "**/*.rs"
|
- "**/*.rs"
|
||||||
- "**/Cargo.toml"
|
- "**/Cargo.toml"
|
||||||
@@ -99,6 +103,10 @@ jobs:
|
|||||||
run: python3 tools/test_milestone_10.py
|
run: python3 tools/test_milestone_10.py
|
||||||
- name: Compile every workspace target with bounded memory
|
- name: Compile every workspace target with bounded memory
|
||||||
run: cargo check --workspace --all-targets --locked -j 1
|
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
|
- name: Check optional cross-platform real audio backend
|
||||||
run: |
|
run: |
|
||||||
cargo check -p libremetaverse-voice-webrtc --all-targets --features real-audio --locked -j 1
|
cargo check -p libremetaverse-voice-webrtc --all-targets --features real-audio --locked -j 1
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1618,6 +1618,8 @@ dependencies = [
|
|||||||
"libremetaverse-voice-webrtc",
|
"libremetaverse-voice-webrtc",
|
||||||
"regex",
|
"regex",
|
||||||
"roxmltree",
|
"roxmltree",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1256,11 +1256,13 @@ impl AgentMovementRuntime {
|
|||||||
packet.agent_data.agent_id = agent_id;
|
packet.agent_data.agent_id = agent_id;
|
||||||
packet.agent_data.session_id = session_id;
|
packet.agent_data.session_id = session_id;
|
||||||
packet.agent_data.circuit_code = self.network.native_circuit_code();
|
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(
|
send_encoded(
|
||||||
&simulator,
|
&simulator,
|
||||||
PacketType::CompleteAgentMovement,
|
PacketType::CompleteAgentMovement,
|
||||||
packet.to_bytes_with_method()?,
|
packet.to_bytes_with_method()?,
|
||||||
None,
|
Some(true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2914,7 +2916,7 @@ mod tests {
|
|||||||
let socket =
|
let socket =
|
||||||
UdpSocket::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)).unwrap();
|
UdpSocket::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)).unwrap();
|
||||||
socket
|
socket
|
||||||
.set_read_timeout(Some(Duration::from_millis(100)))
|
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let endpoint = socket.local_addr().unwrap();
|
let endpoint = socket.local_addr().unwrap();
|
||||||
let (packet_sender, packets) = mpsc::channel();
|
let (packet_sender, packets) = mpsc::channel();
|
||||||
@@ -2934,6 +2936,9 @@ mod tests {
|
|||||||
let mut bytes = handshake.to_bytes_with_method().unwrap();
|
let mut bytes = handshake.to_bytes_with_method().unwrap();
|
||||||
bytes[0] &= !(crate::Helpers::MSG_RELIABLE | crate::Helpers::MSG_ZEROCODED);
|
bytes[0] &= !(crate::Helpers::MSG_RELIABLE | crate::Helpers::MSG_ZEROCODED);
|
||||||
socket.send_to(&bytes, client_endpoint).unwrap();
|
socket.send_to(&bytes, client_endpoint).unwrap();
|
||||||
|
socket
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(100)))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match shutdown_receiver.try_recv() {
|
match shutdown_receiver.try_recv() {
|
||||||
@@ -3074,7 +3079,19 @@ mod tests {
|
|||||||
.native_connect(grid.endpoint, 0x1000, true, None, 256, 256)
|
.native_connect(grid.endpoint, 0x1000, true, None, 256, 256)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.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();
|
let mut complete = AgentMovementCompletePacket::new_with_constructor().unwrap();
|
||||||
complete.data.position = Vector3 {
|
complete.data.position = Vector3 {
|
||||||
|
|||||||
@@ -1852,7 +1852,10 @@ impl Simulator {
|
|||||||
packet.agent_data.agent_id = *read(&self.agent_id);
|
packet.agent_data.agent_id = *read(&self.agent_id);
|
||||||
packet.agent_data.session_id = *read(&self.session_id);
|
packet.agent_data.session_id = *read(&self.session_id);
|
||||||
packet.agent_data.circuit_code = self.circuit_code.load(Ordering::Acquire);
|
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(
|
self.native_send_packet_data(
|
||||||
data.clone(),
|
data.clone(),
|
||||||
i32::try_from(data.len()).map_err(|_| Error::Argument)?,
|
i32::try_from(data.len()).map_err(|_| Error::Argument)?,
|
||||||
|
|||||||
@@ -53,8 +53,18 @@ fn decode_packet<T: GeneratedPacket>(bytes: &[u8]) -> Result<T, Error> {
|
|||||||
if bytes.len() > MAX_FIELD_BYTES * 16 {
|
if bytes.len() > MAX_FIELD_BYTES * 16 {
|
||||||
return Err(Error::Argument);
|
return Err(Error::Argument);
|
||||||
}
|
}
|
||||||
|
let mut packet = T::new_generated();
|
||||||
let mut position = 0_i32;
|
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> {
|
fn wire_string(bytes: &[u8]) -> Result<String, Error> {
|
||||||
@@ -2745,6 +2755,24 @@ mod tests {
|
|||||||
(manager, simulator)
|
(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]
|
#[test]
|
||||||
fn movement_decoding_accepts_all_reference_encodings_and_rejects_other_lengths() {
|
fn movement_decoding_accepts_all_reference_encodings_and_rejects_other_lengths() {
|
||||||
for length in [16, 32, 48, 60, 76] {
|
for length in [16, 32, 48, 60, 76] {
|
||||||
@@ -2843,12 +2871,10 @@ mod tests {
|
|||||||
block.object_data = vec![0; 60];
|
block.object_data = vec![0; 60];
|
||||||
block.scale = Vector3::one();
|
block.scale = Vector3::one();
|
||||||
packet.object_data = vec![block];
|
packet.object_data = vec![block];
|
||||||
|
let wire = transport_wire(&packet.to_bytes_with_method().expect("wire packet"));
|
||||||
manager
|
manager
|
||||||
.inner
|
.inner
|
||||||
.handle_object_update(
|
.handle_object_update(&wire, simulator.clone())
|
||||||
&packet.to_bytes_with_method().expect("wire packet"),
|
|
||||||
simulator.clone(),
|
|
||||||
)
|
|
||||||
.expect("object update");
|
.expect("object update");
|
||||||
|
|
||||||
assert!(preapply_saw_absent.load(Ordering::Acquire));
|
assert!(preapply_saw_absent.load(Ordering::Acquire));
|
||||||
|
|||||||
101
docs/live-grid-smoke.md
Normal file
101
docs/live-grid-smoke.md
Normal 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.
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
The `test-client` binary is a native Rust multi-client shell. It does not load
|
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
|
the former C# executable or start a CLR process. The command registry owns the
|
||||||
implemented system, communication, inventory, appearance, asset, movement,
|
implemented system, communication, inventory, appearance, asset, movement,
|
||||||
object, parcel, estate, grid, agent, friends, groups, directory, and statistics
|
object, parcel, estate, grid, agent, friends, groups, directory, statistics,
|
||||||
commands. `pending_test_client_commands()` now reports only the two explicitly
|
and voice-capability commands. `pending_test_client_commands()` is empty, and
|
||||||
owned voice-native adapters (`ParcelVoiceInfo` and `VoiceAcountCommand`).
|
the completeness audit compares the source, generated, implementation, and
|
||||||
|
runtime registries.
|
||||||
|
|
||||||
## Social, directory, and statistics behavior
|
## 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
|
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
|
issue, checks the exact fake backend calls, round-trips a linkset through
|
||||||
import, and validates generated assets. The services gate drives every one of
|
import, and validates generated assets. The services gate drives every
|
||||||
the 28 remaining non-voice commands in a single offline session, verifies the
|
remaining service and voice-capability command in a single offline session,
|
||||||
destructive-operation guards, and proves the pending inventory contains only
|
verifies the destructive-operation guards, proves voice account values stay
|
||||||
the two explicitly owned voice adapters.
|
redacted, and proves the pending inventory is empty.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ libremetaverse-voice-vivox = { path = "../crates/libremetaverse-voice-vivox" }
|
|||||||
libremetaverse-voice-webrtc = { path = "../crates/libremetaverse-voice-webrtc" }
|
libremetaverse-voice-webrtc = { path = "../crates/libremetaverse-voice-webrtc" }
|
||||||
regex = "1.12"
|
regex = "1.12"
|
||||||
roxmltree = "0.21.1"
|
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"] }
|
tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
@@ -60,3 +62,7 @@ path = "src/bin/webrtc_test.rs"
|
|||||||
[[bin]]
|
[[bin]]
|
||||||
name = "osd-inspector"
|
name = "osd-inspector"
|
||||||
path = "src/bin/osd_inspector.rs"
|
path = "src/bin/osd_inspector.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "live-grid-smoke"
|
||||||
|
path = "src/bin/live_grid_smoke.rs"
|
||||||
|
|||||||
@@ -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 |
|
| `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery |
|
||||||
| `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures |
|
| `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures |
|
||||||
| `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts |
|
| `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 |
|
| `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 |
|
| `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
|
## WebRtcTest
|
||||||
|
|
||||||
`webrtc-test` uses the native `libremetaverse-voice-webrtc` adapter. Its
|
`webrtc-test` uses the native `libremetaverse-voice-webrtc` adapter. Its
|
||||||
|
|||||||
46
programs/build.rs
Normal file
46
programs/build.rs
Normal 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())
|
||||||
|
}
|
||||||
3
programs/src/bin/live_grid_smoke.rs
Normal file
3
programs/src/bin/live_grid_smoke.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() -> std::process::ExitCode {
|
||||||
|
libremetaverse_programs::live_grid_smoke::main_entry()
|
||||||
|
}
|
||||||
203
programs/src/completeness.rs
Normal file
203
programs/src/completeness.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
//! Rust targets corresponding to the upstream example and tool projects.
|
//! Rust targets corresponding to the upstream example and tool projects.
|
||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
pub mod completeness;
|
||||||
pub mod inventory_explorer;
|
pub mod inventory_explorer;
|
||||||
pub mod irc_gateway;
|
pub mod irc_gateway;
|
||||||
|
pub mod live_grid_smoke;
|
||||||
pub mod osd_inspector;
|
pub mod osd_inspector;
|
||||||
pub mod packet_dump;
|
pub mod packet_dump;
|
||||||
pub mod prim_inspector;
|
pub mod prim_inspector;
|
||||||
|
|||||||
1340
programs/src/live_grid_smoke.rs
Normal file
1340
programs/src/live_grid_smoke.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
mod inventory;
|
mod inventory;
|
||||||
mod services;
|
mod services;
|
||||||
|
mod voice;
|
||||||
mod world;
|
mod world;
|
||||||
|
|
||||||
use crate::commands::TEST_CLIENT_COMMANDS;
|
use crate::commands::TEST_CLIENT_COMMANDS;
|
||||||
@@ -164,6 +165,8 @@ pub const IMPLEMENTED_TEST_CLIENT_COMMANDS: &[&str] = &[
|
|||||||
"RegionInfoCommand",
|
"RegionInfoCommand",
|
||||||
"StatsCommand",
|
"StatsCommand",
|
||||||
"UptimeCommand",
|
"UptimeCommand",
|
||||||
|
"ParcelVoiceInfo",
|
||||||
|
"VoiceAcountCommand",
|
||||||
];
|
];
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -176,6 +179,11 @@ pub fn pending_test_client_commands() -> Vec<&'static str> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn registered_test_client_command_count() -> usize {
|
||||||
|
built_in_commands().len()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "test-client",
|
name = "test-client",
|
||||||
@@ -325,7 +333,9 @@ enum ClientEvent {
|
|||||||
type BackendFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
type BackendFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
||||||
type InventoryFuture<'a, T> = Pin<Box<dyn Future<Output = T> + '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 id(&self) -> UUID;
|
||||||
fn name(&self) -> String;
|
fn name(&self) -> String;
|
||||||
fn connected(&self) -> bool;
|
fn connected(&self) -> bool;
|
||||||
@@ -876,6 +886,7 @@ enum CommandHandler {
|
|||||||
ImGroup,
|
ImGroup,
|
||||||
Inventory(inventory::Command),
|
Inventory(inventory::Command),
|
||||||
Services(services::Command),
|
Services(services::Command),
|
||||||
|
Voice(voice::Command),
|
||||||
World(world::Command),
|
World(world::Command),
|
||||||
Load,
|
Load,
|
||||||
Login,
|
Login,
|
||||||
@@ -1038,6 +1049,7 @@ fn built_in_commands() -> HashMap<String, CommandDefinition> {
|
|||||||
.chain(communication_commands())
|
.chain(communication_commands())
|
||||||
.chain(inventory::commands())
|
.chain(inventory::commands())
|
||||||
.chain(services::commands())
|
.chain(services::commands())
|
||||||
|
.chain(voice::commands())
|
||||||
.chain(world::commands())
|
.chain(world::commands())
|
||||||
.map(|(name, description, category, handler)| {
|
.map(|(name, description, category, handler)| {
|
||||||
(
|
(
|
||||||
@@ -1822,6 +1834,9 @@ async fn execute_client_command(
|
|||||||
CommandHandler::Services(command) => {
|
CommandHandler::Services(command) => {
|
||||||
services::execute(client.backend.as_ref(), *command, args, cancellation).await
|
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) => {
|
CommandHandler::World(command) => {
|
||||||
world::execute(client.backend.as_ref(), *command, args, cancellation).await
|
world::execute(client.backend.as_ref(), *command, args, cancellation).await
|
||||||
}
|
}
|
||||||
@@ -2716,7 +2731,7 @@ mod tests {
|
|||||||
assert!(TEST_CLIENT_COMMANDS.contains(command));
|
assert!(TEST_CLIENT_COMMANDS.contains(command));
|
||||||
assert!(!pending.contains(command));
|
assert!(!pending.contains(command));
|
||||||
}
|
}
|
||||||
assert_eq!(pending, ["ParcelVoiceInfo", "VoiceAcountCommand"]);
|
assert!(pending.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
210
programs/src/test_client/voice.rs
Normal file
210
programs/src/test_client/voice.rs
Normal 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())
|
||||||
|
}
|
||||||
178
programs/tests/live_grid_smoke_cli.rs
Normal file
178
programs/tests/live_grid_smoke_cli.rs
Normal 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");
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ fn run(args: &[&str]) -> Output {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[allow(clippy::too_many_lines)]
|
#[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 directory = TestDir::new();
|
||||||
let script = directory.write(&format!(
|
let script = directory.write(&format!(
|
||||||
"!client\t{CLIENT}\tAlice\tBot\n\
|
"!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\
|
invitegroup {AVATAR} {GROUP} {ROLE} --confirm\n\
|
||||||
joingroup Builders Guild --confirm\n\
|
joingroup Builders Guild --confirm\n\
|
||||||
leavegroup Builders Guild --confirm\n\
|
leavegroup Builders Guild --confirm\n\
|
||||||
|
voiceparcel\n\
|
||||||
|
voiceaccount\n\
|
||||||
quit\n"
|
quit\n"
|
||||||
));
|
));
|
||||||
let output = run(&[
|
let output = run(&[
|
||||||
@@ -154,6 +156,8 @@ fn full_offline_session_exercises_every_remaining_non_voice_command() {
|
|||||||
"invited",
|
"invited",
|
||||||
"Joined the group Builders Guild",
|
"Joined the group Builders Guild",
|
||||||
"has left 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 profile-clone",
|
||||||
"CALL generic-message",
|
"CALL generic-message",
|
||||||
"CALL animation-start",
|
"CALL animation-start",
|
||||||
@@ -162,6 +166,8 @@ fn full_offline_session_exercises_every_remaining_non_voice_command() {
|
|||||||
"CALL group-invite",
|
"CALL group-invite",
|
||||||
"CALL group-join",
|
"CALL group-join",
|
||||||
"CALL group-leave",
|
"CALL group-leave",
|
||||||
|
"CALL voice-parcel-info",
|
||||||
|
"CALL voice-provision-account credentials=<redacted>",
|
||||||
] {
|
] {
|
||||||
assert!(stdout.contains(expected), "missing {expected:?}:\n{stdout}");
|
assert!(stdout.contains(expected), "missing {expected:?}:\n{stdout}");
|
||||||
}
|
}
|
||||||
@@ -207,9 +213,6 @@ fn social_mutations_require_startup_and_per_command_confirmation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn command_inventory_leaves_only_explicit_voice_adapters() {
|
fn command_inventory_has_no_pending_source_commands() {
|
||||||
assert_eq!(
|
assert!(libremetaverse_programs::test_client::pending_test_client_commands().is_empty());
|
||||||
libremetaverse_programs::test_client::pending_test_client_commands(),
|
|
||||||
["ParcelVoiceInfo", "VoiceAcountCommand"]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user