Implement TestClient world commands (#93)
Some checks failed
Native code generation / deterministic (push) Failing after 31s
Imaging and meshing gate / native (push) Successful in 5m30s
JPEG 2000 feature / linux (push) Successful in 2m47s
Native Rust workspace compile / compile (push) Successful in 22m56s
Skia feature / linux (push) Successful in 31m28s
Some checks failed
Native code generation / deterministic (push) Failing after 31s
Imaging and meshing gate / native (push) Successful in 5m30s
JPEG 2000 feature / linux (push) Successful in 2m47s
Native Rust workspace compile / compile (push) Successful in 22m56s
Skia feature / linux (push) Successful in 31m28s
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1055,6 +1055,8 @@ dependencies = [
|
||||
"libremetaverse",
|
||||
"libremetaverse-imaging",
|
||||
"libremetaverse-imaging-skia",
|
||||
"libremetaverse-structured-data",
|
||||
"regex",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
||||
11
README.md
11
README.md
@@ -500,3 +500,14 @@ manager/store and AIS parser for bounded browsing, statistics, typed search,
|
||||
link-aware hierarchy traversal, and deterministic export. Their command
|
||||
surfaces, limits, isolated CLI tests, and the status of every remaining program
|
||||
are documented in the [`native programs guide`](programs/README.md).
|
||||
|
||||
The native `TestClient` now exposes its system, communication, inventory,
|
||||
appearance, asset, movement, object, parcel, estate, and grid command groups
|
||||
through one bounded multi-client shell. World commands use the native manager
|
||||
APIs for movement and teleportation, object inspection and linkset transfer,
|
||||
parcel and map queries, estate terrain transfer, texture caching, and primitive
|
||||
creation. Offline fake-world directives exercise the same parsing, filtering,
|
||||
output, cancellation, and safety paths without connecting to a privileged live
|
||||
grid. Command ownership, resource limits, explicit live-operation gates, and
|
||||
the focused verification command are documented in the
|
||||
[`TestClient` guide](docs/test-client.md).
|
||||
|
||||
64
docs/test-client.md
Normal file
64
docs/test-client.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Native TestClient command shell
|
||||
|
||||
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, and grid commands, and reports the remaining upstream
|
||||
targets through `pending_test_client_commands()`.
|
||||
|
||||
## World command behavior
|
||||
|
||||
Movement commands use `AgentManager` movement updates, autopilot, teleport,
|
||||
sit, stand, flight, and home APIs. Timed movement is limited to 60 seconds and
|
||||
checks cancellation while sending updates. `follow` retains the selected
|
||||
avatar and refreshes autopilot whenever a coarse-location update moves it;
|
||||
`follow off` cancels autopilot. Region crossing is bounded to 60 seconds.
|
||||
|
||||
Object queries snapshot the current simulator caches before filtering.
|
||||
Searches and parcel/map results are capped at 65,535 entries. Linkset export
|
||||
requires ownership, sorts serialized LLSD deterministically, limits a linkset
|
||||
to 10,000 primitives, and downloads each unique texture as both JPEG 2000 and
|
||||
TGA. Import accepts only a bounded LLSD array, waits for each native rez event,
|
||||
applies primitive properties, links children, and restores root rotation and
|
||||
permissions. Individual asset and terrain files are limited to 64 MiB. Regular
|
||||
expressions are limited in source and compiled size.
|
||||
|
||||
`textures on` downloads the textures already visible in the current simulator
|
||||
and subscribes newly observed primitives for deduplicated native asset-cache
|
||||
requests. `--gettextures` enables that subscription at login. Parcel, grid,
|
||||
wind, owner, selection, covenant, and terrain commands use their corresponding
|
||||
native managers and propagate cancellation through request/reply waits.
|
||||
|
||||
## Live-operation gates
|
||||
|
||||
Read-only queries need no privilege flag. Every movement, teleport, derez,
|
||||
permission, import, tree, and terrain mutation requires both
|
||||
`--allow-live-mutations` at process startup and `--confirm` on the command.
|
||||
Import and terrain upload additionally require `--allow-spending`; estate
|
||||
terrain download/upload additionally require `--allow-estate-actions`.
|
||||
Consequently the default invocation cannot move an avatar, alter or create an
|
||||
object, upload an asset, or invoke an estate-owner action.
|
||||
|
||||
Output paths reject parent-directory traversal. Timeouts, cancellation, file
|
||||
limits, result limits, and linkset limits apply equally to live and fake-grid
|
||||
runs. Passwords and login secrets continue to pass through the shell's common
|
||||
redaction path.
|
||||
|
||||
## Offline verification
|
||||
|
||||
Fake scripts can seed the same command layer with `!world-region`,
|
||||
`!world-avatar`, `!world-prim`, `!world-parcel`, `!world-parcel-owner`,
|
||||
`!world-parcel-object`, `!world-grid-region`, `!world-layer`,
|
||||
`!world-agent-location`, `!world-estate`, `!world-asset`, and `!world-syntax`
|
||||
tab-separated directives. These fixtures never open a network connection.
|
||||
|
||||
Run the issue-focused compatibility gate with:
|
||||
|
||||
```text
|
||||
cargo test -p libremetaverse-programs --test test_client_world_cli
|
||||
```
|
||||
|
||||
The gate drives every command owned by the movement/object/land/grid issue,
|
||||
checks the exact fake backend calls, verifies destructive-operation guards,
|
||||
round-trips an exported linkset through import, compares two exports byte for
|
||||
byte, and validates generated terrain, texture, and TGA files.
|
||||
@@ -11,6 +11,8 @@ clap = { version = "4.5", features = ["derive"] }
|
||||
libremetaverse = { path = "../crates/libremetaverse", default-features = false }
|
||||
libremetaverse-imaging = { path = "../crates/libremetaverse-imaging", features = ["jpeg2000"] }
|
||||
libremetaverse-imaging-skia = { path = "../crates/libremetaverse-imaging-skia", features = ["skia"] }
|
||||
libremetaverse-structured-data = { path = "../crates/libremetaverse-structured-data" }
|
||||
regex = "1.12"
|
||||
tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -13,7 +13,7 @@ here so a source entry is never mistaken for a completed port.
|
||||
| `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery |
|
||||
| `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures |
|
||||
| `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts |
|
||||
| `test-client` | TestClient | Native shell, registry, system, communication, inventory, appearance, and asset groups implemented; remaining command groups tracked by #93–#94 |
|
||||
| `test-client` | TestClient | Native shell, registry, system, communication, inventory, appearance, asset, movement, object, parcel, estate, and grid groups implemented; remaining command group tracked by #94 |
|
||||
| `vivox-test` | VivoxTest | Pending milestone 11 issue #95 |
|
||||
| `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 |
|
||||
|
||||
@@ -157,8 +157,8 @@ cargo test --manifest-path tests/compat/Cargo.toml --test social_message_semanti
|
||||
one account with `--first`, `--last`, and `--pass`, or a bounded account file
|
||||
with `--file`. Account-file records use `First Last Password` followed by an
|
||||
optional `Region/x/y/z` start location. `--loginuri`, `--startpos`, `--master`,
|
||||
`--masterkey`, `--groupcommands`, `--scriptfile`, and `--nogui` preserve the
|
||||
upstream shell controls. Each login has a configurable timeout, the registry is
|
||||
`--masterkey`, `--groupcommands`, `--gettextures`, `--scriptfile`, and `--nogui`
|
||||
preserve the upstream shell controls. Each login has a configurable timeout, the registry is
|
||||
limited to 64 clients, and `@ First Last` selects one client while `@` restores
|
||||
broadcast command routing.
|
||||
|
||||
@@ -176,6 +176,14 @@ master teleport lures are accepted, and remote `login` and `md5` command text
|
||||
is redacted from transcripts. Packet logs contain only timestamped packet type,
|
||||
simulator name, and byte count; they are limited to 10,000 records and 16 MiB.
|
||||
|
||||
The world wave adds all movement and teleport commands, primitive search,
|
||||
inspection, permissions, derez, deterministic linkset export/import, texture
|
||||
download, particle export, tree creation, parcel and estate queries, terrain
|
||||
transfer, and grid map/layer/location/wind commands. `textures on` and
|
||||
`--gettextures` perform deduplicated native asset-cache requests for observed
|
||||
primitive textures. Detailed command behavior and limits are in
|
||||
[`docs/test-client.md`](../docs/test-client.md).
|
||||
|
||||
The native `load` command reads a portable command-alias manifest instead of a
|
||||
CLR assembly. Each non-comment line is tab-separated
|
||||
`name<TAB>description<TAB>template`; `{args}` or `$*` inserts the quoted command
|
||||
@@ -192,6 +200,12 @@ JPEG2000 texture format. Live commands use the public inventory, appearance,
|
||||
asset, agent, directory, and task-inventory managers; Xfer downloads wait for
|
||||
their correlated UDP completion event.
|
||||
|
||||
World mutations use the same global switch and confirmation requirement.
|
||||
Imports and terrain uploads also require `--allow-spending`; terrain owner
|
||||
messages also require `--allow-estate-actions`. Movement durations, reply
|
||||
waits, files, query results, regexes, and exported/imported linksets are
|
||||
bounded, and all waits propagate cancellation.
|
||||
|
||||
Use `--fake-script FILE` for deterministic, credential-free terminal and grid
|
||||
validation. Normal lines are dispatched exactly like interactive input. Fake
|
||||
grid records begin with `!` and use tab-separated fields:
|
||||
@@ -228,6 +242,7 @@ framework and compatibility checks with:
|
||||
```sh
|
||||
cargo test -p libremetaverse-programs --test test_client_cli --locked
|
||||
cargo test -p libremetaverse-programs --test test_client_inventory_cli --locked
|
||||
cargo test -p libremetaverse-programs --test test_client_world_cli --locked
|
||||
cargo test -p libremetaverse-programs test_client::tests --locked
|
||||
cargo test --manifest-path tests/compat/Cargo.toml --test appearance_semantics --locked
|
||||
cargo test --manifest-path tests/compat/Cargo.toml --test appearance_visual --locked
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Native `TestClient` shell, registry, and first-wave command groups.
|
||||
|
||||
mod inventory;
|
||||
mod world;
|
||||
|
||||
use crate::commands::TEST_CLIENT_COMMANDS;
|
||||
use clap::Parser;
|
||||
@@ -68,6 +69,52 @@ pub const IMPLEMENTED_TEST_CLIENT_COMMANDS: &[&str] = &[
|
||||
"UploadScriptCommand",
|
||||
"ViewNotecardCommand",
|
||||
"XferCommand",
|
||||
"DownloadTerrainCommand",
|
||||
"GetEstateCovenantCommand",
|
||||
"UploadRawTerrainCommand",
|
||||
"AgentLocationsCommand",
|
||||
"FindSimCommand",
|
||||
"GridLayerCommand",
|
||||
"GridMapCommand",
|
||||
"ParcelDetailsCommand",
|
||||
"ParcelInfoCommand",
|
||||
"ParcelPrimOwnersCommand",
|
||||
"ParcelSelectObjectsCommand",
|
||||
"SyntaxIdCommand",
|
||||
"WindCommand",
|
||||
"BackCommand",
|
||||
"CrossRegionCommand",
|
||||
"CrouchCommand",
|
||||
"FlyCommand",
|
||||
"FlyToCommand",
|
||||
"FollowCommand",
|
||||
"ForwardCommand",
|
||||
"GoHome",
|
||||
"GotoCommand",
|
||||
"GotoLandmark",
|
||||
"JumpCommand",
|
||||
"LeftCommand",
|
||||
"LocationCommand",
|
||||
"MoveToCommand",
|
||||
"RightCommand",
|
||||
"SetHome",
|
||||
"SitCommand",
|
||||
"SitOnCommand",
|
||||
"StandCommand",
|
||||
"TurnToCommand",
|
||||
"ChangePermsCommand",
|
||||
"DeRezObjectCommand",
|
||||
"DownloadTextureCommand",
|
||||
"ExportCommand",
|
||||
"ExportParticlesCommand",
|
||||
"FindObjectsCommand",
|
||||
"FindTextureCommand",
|
||||
"ImportCommand",
|
||||
"PrimCountCommand",
|
||||
"PrimInfoCommand",
|
||||
"PrimRegexCommand",
|
||||
"TexturesCommand",
|
||||
"TreeCommand",
|
||||
"EchoMasterCommand",
|
||||
"IMCommand",
|
||||
"IMGroupCommand",
|
||||
@@ -106,7 +153,7 @@ pub fn pending_test_client_commands() -> Vec<&'static str> {
|
||||
version,
|
||||
about = "Run the native LibreMetaverse multi-client command shell",
|
||||
long_about = None,
|
||||
after_help = "Owned commands include @, debug, echomaster, help, im, imgroup, load, login, logpacket, logout, md5, quit, say, setmaster, setmasterkey, shout, showeffects, sleep, waitforlogin, whisper, plus the inventory/assets and appearance groups. Run `help` or `help COMMAND` inside the shell for the complete command registry."
|
||||
after_help = "Owned commands include @, debug, echomaster, help, im, imgroup, load, login, logpacket, logout, md5, quit, say, setmaster, setmasterkey, shout, showeffects, sleep, waitforlogin, whisper, plus the inventory, appearance, asset, movement, object, parcel, estate, and grid groups. Run `help` or `help COMMAND` inside the shell for the complete command registry."
|
||||
)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
struct Cli {
|
||||
@@ -137,7 +184,7 @@ struct Cli {
|
||||
/// Permit commands from known members of joined group-chat sessions.
|
||||
#[arg(long)]
|
||||
groupcommands: bool,
|
||||
/// Preserve the upstream flag for later texture-aware command groups.
|
||||
/// Automatically fetch newly observed object textures into the asset cache.
|
||||
#[arg(long)]
|
||||
gettextures: bool,
|
||||
/// Run bounded terminal commands from a file before the interactive loop.
|
||||
@@ -160,6 +207,10 @@ struct Cli {
|
||||
/// Permit commands that spend or transfer L$; each command also requires confirmation.
|
||||
#[arg(long)]
|
||||
allow_spending: bool,
|
||||
|
||||
/// Permit estate-owner commands; mutations still require their other gates.
|
||||
#[arg(long)]
|
||||
allow_estate_actions: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -245,7 +296,7 @@ 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 {
|
||||
trait ClientBackend: Send + Sync + inventory::Backend + world::Backend {
|
||||
fn id(&self) -> UUID;
|
||||
fn name(&self) -> String;
|
||||
fn connected(&self) -> bool;
|
||||
@@ -280,6 +331,7 @@ struct LiveBackend {
|
||||
name: String,
|
||||
subscriptions: Mutex<Vec<Subscription>>,
|
||||
inventory_state: Mutex<inventory::State>,
|
||||
world_state: Mutex<world::State>,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
@@ -290,6 +342,7 @@ impl LiveBackend {
|
||||
events: mpsc::Sender<ClientEvent>,
|
||||
dropped: Arc<AtomicUsize>,
|
||||
account_policy: inventory::MutationPolicy,
|
||||
get_textures: bool,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<Arc<Self>, ProgramError> {
|
||||
let mut client = GridClient::new()
|
||||
@@ -339,6 +392,7 @@ impl LiveBackend {
|
||||
name,
|
||||
subscriptions: Mutex::new(Vec::new()),
|
||||
inventory_state: Mutex::new(inventory::State::new(account_policy)),
|
||||
world_state: Mutex::new(world::State::new(account_policy, get_textures)),
|
||||
closed: AtomicBool::new(false),
|
||||
});
|
||||
backend.install(&events, &dropped);
|
||||
@@ -457,6 +511,7 @@ impl LiveBackend {
|
||||
);
|
||||
},
|
||||
)));
|
||||
world::install_live(self, &mut subscriptions);
|
||||
*lock(&self.subscriptions) = subscriptions;
|
||||
}
|
||||
}
|
||||
@@ -624,6 +679,7 @@ struct FakeBackend {
|
||||
people: Arc<Mutex<HashMap<String, UUID>>>,
|
||||
group_members: Arc<Mutex<HashSet<UUID>>>,
|
||||
inventory_state: Mutex<inventory::State>,
|
||||
world_state: Mutex<world::State>,
|
||||
}
|
||||
|
||||
impl FakeBackend {
|
||||
@@ -633,6 +689,7 @@ impl FakeBackend {
|
||||
people: Arc<Mutex<HashMap<String, UUID>>>,
|
||||
group_members: Arc<Mutex<HashSet<UUID>>>,
|
||||
policy: inventory::MutationPolicy,
|
||||
get_textures: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -642,6 +699,7 @@ impl FakeBackend {
|
||||
people,
|
||||
group_members,
|
||||
inventory_state: Mutex::new(inventory::State::new(policy)),
|
||||
world_state: Mutex::new(world::State::new(policy, get_textures)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,6 +821,10 @@ enum CommandCategory {
|
||||
Appearance,
|
||||
Communication,
|
||||
Inventory,
|
||||
Movement,
|
||||
Objects,
|
||||
Parcel,
|
||||
Simulator,
|
||||
TestClient,
|
||||
Other,
|
||||
}
|
||||
@@ -776,6 +838,7 @@ enum CommandHandler {
|
||||
Im,
|
||||
ImGroup,
|
||||
Inventory(inventory::Command),
|
||||
World(world::Command),
|
||||
Load,
|
||||
Login,
|
||||
LogPacket,
|
||||
@@ -936,6 +999,7 @@ fn built_in_commands() -> HashMap<String, CommandDefinition> {
|
||||
.into_iter()
|
||||
.chain(communication_commands())
|
||||
.chain(inventory::commands())
|
||||
.chain(world::commands())
|
||||
.map(|(name, description, category, handler)| {
|
||||
(
|
||||
name.into(),
|
||||
@@ -1081,6 +1145,7 @@ struct ClientManager {
|
||||
default_master_key: UUID,
|
||||
default_group_commands: bool,
|
||||
default_mutation_policy: inventory::MutationPolicy,
|
||||
default_get_textures: bool,
|
||||
cancellation: CancellationTokenSource,
|
||||
output: Arc<ProgramOutput>,
|
||||
events_tx: mpsc::Sender<ClientEvent>,
|
||||
@@ -1103,6 +1168,7 @@ impl ClientManager {
|
||||
master_key: UUID,
|
||||
group_commands: bool,
|
||||
mutation_policy: inventory::MutationPolicy,
|
||||
get_textures: bool,
|
||||
output: Arc<ProgramOutput>,
|
||||
) -> Self {
|
||||
let (events_tx, events_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY);
|
||||
@@ -1119,6 +1185,7 @@ impl ClientManager {
|
||||
default_master_key: master_key,
|
||||
default_group_commands: group_commands,
|
||||
default_mutation_policy: mutation_policy,
|
||||
default_get_textures: get_textures,
|
||||
cancellation: CancellationTokenSource::new(),
|
||||
output,
|
||||
events_tx,
|
||||
@@ -1179,6 +1246,7 @@ impl ClientManager {
|
||||
Arc::clone(&self.fake_people),
|
||||
Arc::clone(&self.fake_group_members),
|
||||
self.default_mutation_policy,
|
||||
self.default_get_textures,
|
||||
));
|
||||
self.add_client(ManagedClient {
|
||||
backend,
|
||||
@@ -1206,6 +1274,7 @@ impl ClientManager {
|
||||
self.events_tx.clone(),
|
||||
Arc::clone(&self.dropped_events),
|
||||
self.default_mutation_policy,
|
||||
self.default_get_textures,
|
||||
self.cancellation.token(),
|
||||
)
|
||||
.await
|
||||
@@ -1220,6 +1289,7 @@ impl ClientManager {
|
||||
Arc::clone(&self.fake_people),
|
||||
Arc::clone(&self.fake_group_members),
|
||||
self.default_mutation_policy,
|
||||
self.default_get_textures,
|
||||
))),
|
||||
Err(_) => Err(ProgramError::Client(
|
||||
"could not make fake client UUID".into(),
|
||||
@@ -1710,6 +1780,9 @@ async fn execute_client_command(
|
||||
CommandHandler::Inventory(command) => {
|
||||
inventory::execute(client.backend.as_ref(), *command, args, from, cancellation).await
|
||||
}
|
||||
CommandHandler::World(command) => {
|
||||
world::execute(client.backend.as_ref(), *command, args, cancellation).await
|
||||
}
|
||||
CommandHandler::Md5 => {
|
||||
if args.len() != 1 {
|
||||
return "Usage: md5 [password]".into();
|
||||
@@ -1955,8 +2028,11 @@ pub fn main_entry() -> ExitCode {
|
||||
}
|
||||
|
||||
async fn run(cli: Cli) -> Result<(), ProgramError> {
|
||||
let mutation_policy =
|
||||
inventory::MutationPolicy::new(cli.allow_live_mutations, cli.allow_spending);
|
||||
let mutation_policy = inventory::MutationPolicy::new(
|
||||
cli.allow_live_mutations,
|
||||
cli.allow_spending,
|
||||
cli.allow_estate_actions,
|
||||
);
|
||||
let master_key = match cli.masterkey.as_deref() {
|
||||
Some(value) => UUID::new_with_string(value.into())
|
||||
.map_err(|_| ProgramError::Usage("--masterkey must be a UUID"))?,
|
||||
@@ -1978,6 +2054,7 @@ async fn run(cli: Cli) -> Result<(), ProgramError> {
|
||||
master_key,
|
||||
cli.groupcommands,
|
||||
mutation_policy,
|
||||
cli.gettextures,
|
||||
Arc::clone(&output),
|
||||
);
|
||||
run_fake_script(&mut manager, path).await?;
|
||||
@@ -2011,6 +2088,7 @@ async fn run(cli: Cli) -> Result<(), ProgramError> {
|
||||
master_key,
|
||||
cli.groupcommands,
|
||||
mutation_policy,
|
||||
cli.gettextures,
|
||||
Arc::clone(&output),
|
||||
);
|
||||
for account in accounts {
|
||||
@@ -2285,6 +2363,10 @@ async fn run_fake_event_directive(
|
||||
result.map_err(|reason| ProgramError::InvalidInput { line, reason })?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(result) = world::apply_fake_directive(manager, fields) {
|
||||
result.map_err(|reason| ProgramError::InvalidInput { line, reason })?;
|
||||
return Ok(());
|
||||
}
|
||||
match fields {
|
||||
["chat", client, source, name, message] => {
|
||||
let event = ClientEvent::Chat {
|
||||
|
||||
@@ -222,18 +222,23 @@ pub(super) struct MutationPolicy(u8);
|
||||
impl MutationPolicy {
|
||||
const MUTATIONS: u8 = 1;
|
||||
const SPENDING: u8 = 1 << 1;
|
||||
const ESTATE: u8 = 1 << 2;
|
||||
|
||||
pub(super) const fn new(mutations: bool, spending: bool) -> Self {
|
||||
Self((mutations as u8) | ((spending as u8) << 1))
|
||||
pub(super) const fn new(mutations: bool, spending: bool, estate: bool) -> Self {
|
||||
Self((mutations as u8) | ((spending as u8) << 1) | ((estate as u8) << 2))
|
||||
}
|
||||
|
||||
const fn mutations(self) -> bool {
|
||||
pub(super) const fn mutations(self) -> bool {
|
||||
self.0 & Self::MUTATIONS != 0
|
||||
}
|
||||
|
||||
const fn spending(self) -> bool {
|
||||
pub(super) const fn spending(self) -> bool {
|
||||
self.0 & Self::SPENDING != 0
|
||||
}
|
||||
|
||||
pub(super) const fn estate(self) -> bool {
|
||||
self.0 & Self::ESTATE != 0
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct State {
|
||||
@@ -1662,7 +1667,7 @@ fn decode_tga(bytes: &[u8]) -> Result<ManagedImage, String> {
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
fn encode_tga(image: &ManagedImage) -> Result<Vec<u8>, String> {
|
||||
pub(super) fn encode_tga(image: &ManagedImage) -> Result<Vec<u8>, String> {
|
||||
image
|
||||
.validate()
|
||||
.map_err(|_| "Invalid decoded image layout")?;
|
||||
|
||||
3661
programs/src/test_client/world.rs
Normal file
3661
programs/src/test_client/world.rs
Normal file
File diff suppressed because it is too large
Load Diff
327
programs/tests/test_client_world_cli.rs
Normal file
327
programs/tests/test_client_world_cli.rs
Normal file
@@ -0,0 +1,327 @@
|
||||
use libremetaverse_imaging::{J2kCodec, J2kEncodeOptions, ManagedImage, ManagedImageImageChannels};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
const CLIENT: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
const TARGET: &str = "11111111-2222-3333-4444-555555555555";
|
||||
const ROOT: &str = "90000000-0000-0000-0000-000000000001";
|
||||
const CHILD: &str = "90000000-0000-0000-0000-000000000002";
|
||||
const REMOVE: &str = "90000000-0000-0000-0000-000000000003";
|
||||
const TEXTURE: &str = "80000000-0000-0000-0000-000000000001";
|
||||
const COVENANT: &str = "70000000-0000-0000-0000-000000000001";
|
||||
const LAYER: &str = "60000000-0000-0000-0000-000000000001";
|
||||
const LANDMARK: &str = "50000000-0000-0000-0000-000000000001";
|
||||
const HANDLE: &str = "4294967298000";
|
||||
|
||||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl TestDir {
|
||||
fn new(name: &str) -> Self {
|
||||
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"metacrate-test-client-world-{name}-{}-{id}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&path).expect("create test directory");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
self.0.join(name)
|
||||
}
|
||||
|
||||
fn write(&self, name: &str, contents: impl AsRef<[u8]>) -> PathBuf {
|
||||
let path = self.path(name);
|
||||
fs::write(&path, contents).expect("write fixture");
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn path_text(path: &Path) -> &str {
|
||||
path.to_str().expect("UTF-8 test path")
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_test-client"))
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.expect("run test-client")
|
||||
}
|
||||
|
||||
fn stdout(output: &Output) -> &str {
|
||||
std::str::from_utf8(&output.stdout).expect("UTF-8 stdout")
|
||||
}
|
||||
|
||||
fn stderr(output: &Output) -> &str {
|
||||
std::str::from_utf8(&output.stderr).expect("UTF-8 stderr")
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
const DIGITS: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut output = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
output.push(char::from(DIGITS[usize::from(byte >> 4)]));
|
||||
output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn one_pixel_jp2() -> Vec<u8> {
|
||||
let mut image =
|
||||
ManagedImage::new(1, 1, ManagedImageImageChannels::COLOR).expect("create image");
|
||||
image.red[0] = 10;
|
||||
image.green[0] = 20;
|
||||
image.blue[0] = 30;
|
||||
J2kCodec::encode(&image, J2kEncodeOptions::default()).expect("encode JPEG2000")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn fake_grid_exercises_every_owned_world_command() {
|
||||
let directory = TestDir::new("commands");
|
||||
let terrain_source = directory.write("terrain.raw", [9, 8, 7, 6]);
|
||||
let terrain_download = directory.path("downloaded.raw");
|
||||
let texture_download = directory.path("download.jp2");
|
||||
let export_one = directory.path("export-one.xml");
|
||||
let export_two = directory.path("export-two.xml");
|
||||
let texture = one_pixel_jp2();
|
||||
let script = directory.write(
|
||||
"terminal.tsv",
|
||||
format!(
|
||||
"!client\t{CLIENT}\tAlice\tBot\n\
|
||||
!person\tTarget Resident\t{TARGET}\n\
|
||||
!world-region\t{CLIENT}\t{HANDLE}\tTest Region\t128\t128\t25\t1.5\t-2.5\n\
|
||||
!world-avatar\t{CLIENT}\t{TARGET}\tTarget Resident\t132\t132\t25\n\
|
||||
!world-prim\t{CLIENT}\t{ROOT}\t100\t0\t{CLIENT}\tCube Root\tExport root\t130\t130\t25\t{TEXTURE}\ttrue\n\
|
||||
!world-prim\t{CLIENT}\t{CHILD}\t101\t100\t{CLIENT}\tCube Child\tExport child\t1\t0\t0\t{TEXTURE}\tfalse\n\
|
||||
!world-prim\t{CLIENT}\t{REMOVE}\t102\t0\t{CLIENT}\tRemove Me\tDerez target\t131\t130\t25\t{TEXTURE}\tfalse\n\
|
||||
!world-parcel\t{CLIENT}\t1\t{CLIENT}\tHome Parcel\tFixture parcel\t2.5\t4096\t3\t117\n\
|
||||
!world-parcel-owner\t{CLIENT}\t1\t{CLIENT}\t3\n\
|
||||
!world-parcel-object\t{CLIENT}\t1\t{CLIENT}\t100,101,102\n\
|
||||
!world-grid-region\t{CLIENT}\tTest Region\t{HANDLE}\t1000\t2000\t3\n\
|
||||
!world-layer\t{CLIENT}\t{LAYER}\t0\t0\t1024\t1024\n\
|
||||
!world-agent-location\t{CLIENT}\t{HANDLE}\t3\t128\t128\n\
|
||||
!world-estate\t{CLIENT}\tFixture Estate\t{CLIENT}\t{COVENANT}\t12345\tBe kind.\t0102\n\
|
||||
!world-asset\t{CLIENT}\t{TEXTURE}\t{}\n\
|
||||
!world-syntax\t{CLIENT}\tdefault,state_entry,llSay\n\
|
||||
downloadterrain 1000 \"{}\" --confirm\n\
|
||||
getestatecovenant 1\n\
|
||||
uploadterrain \"{}\" --confirm\n\
|
||||
agentlocations\n\
|
||||
findsim Test Region\n\
|
||||
gridlayer\n\
|
||||
gridmap\n\
|
||||
parceldetails 1\n\
|
||||
parcelinfo\n\
|
||||
primowners 1\n\
|
||||
selectobjects 1 {CLIENT}\n\
|
||||
syntaxid\n\
|
||||
wind\n\
|
||||
back 0 --confirm\n\
|
||||
crossregion north fly --confirm\n\
|
||||
crouch start --confirm\n\
|
||||
fly start --confirm\n\
|
||||
flyto 130 130 25 1 --confirm\n\
|
||||
follow Target Resident --confirm\n\
|
||||
follow off --confirm\n\
|
||||
forward 0 --confirm\n\
|
||||
gohome --confirm\n\
|
||||
goto Test Region/128/128/25 --confirm\n\
|
||||
goto_landmark {LANDMARK} --confirm\n\
|
||||
jump --confirm\n\
|
||||
left 0 --confirm\n\
|
||||
location\n\
|
||||
moveto 10 11 12 --confirm\n\
|
||||
right 0 --confirm\n\
|
||||
sethome --confirm\n\
|
||||
sit --confirm\n\
|
||||
siton {ROOT} --confirm\n\
|
||||
stand --confirm\n\
|
||||
turnto 140 141 25 --confirm\n\
|
||||
changeperms {ROOT} copy mod xfer --confirm\n\
|
||||
derez {REMOVE} --confirm\n\
|
||||
downloadtexture {TEXTURE} 0 \"{}\"\n\
|
||||
export {ROOT} \"{}\"\n\
|
||||
export {ROOT} \"{}\"\n\
|
||||
exportparticles {ROOT}\n\
|
||||
findobjects 4096 Cube\n\
|
||||
findtexture 0 {TEXTURE}\n\
|
||||
import \"{}\" usegroup --confirm\n\
|
||||
primcount\n\
|
||||
priminfo {ROOT}\n\
|
||||
primregex Cube\n\
|
||||
textures on\n\
|
||||
textures off\n\
|
||||
tree Oak --confirm\n\
|
||||
quit\n",
|
||||
hex(&texture),
|
||||
path_text(&terrain_download),
|
||||
path_text(&terrain_source),
|
||||
path_text(&texture_download),
|
||||
path_text(&export_one),
|
||||
path_text(&export_two),
|
||||
path_text(&export_one),
|
||||
),
|
||||
);
|
||||
|
||||
let output = run(&[
|
||||
"--allow-live-mutations",
|
||||
"--allow-spending",
|
||||
"--allow-estate-actions",
|
||||
"--fake-script",
|
||||
path_text(&script),
|
||||
]);
|
||||
assert!(output.status.success(), "{}", stderr(&output));
|
||||
assert!(output.stderr.is_empty(), "{}", stderr(&output));
|
||||
let text = stdout(&output);
|
||||
for expected in [
|
||||
"Terrain file Test Region (2 bytes) downloaded successfully",
|
||||
"Estate name: Fixture Estate",
|
||||
"Terrain raw file uploaded and applied",
|
||||
"3 avatar(s) at 128,128",
|
||||
"Test Region: handle=4294967298000",
|
||||
"Received 1 layer chunks",
|
||||
"Received 1 grid regions",
|
||||
"Name = Home Parcel",
|
||||
"Downloaded 1 Parcels in Test Region",
|
||||
"Owner: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee Count: 3",
|
||||
"Found a total of 3 Objects",
|
||||
"llSay",
|
||||
"Local wind speed is Vector2",
|
||||
"Following Target Resident",
|
||||
"CurrentSim: 'Test Region'",
|
||||
"Set permissions to PermissionMask",
|
||||
"Removing Remove Me",
|
||||
"Saved",
|
||||
"Exported 2 prims",
|
||||
"llParticleSystem",
|
||||
"Done searching; found 2 objects",
|
||||
"Done searching; found 2 faces",
|
||||
"Import complete.",
|
||||
"Tracking a total of",
|
||||
"ID: 90000000-0000-0000-0000-000000000001",
|
||||
"Texture downloading is on",
|
||||
"Texture downloading is off",
|
||||
"CALL estate-upload-terrain terrain.raw",
|
||||
"CALL world-query download-terrain",
|
||||
"CALL world-query estate-covenant",
|
||||
"CALL world-query agent-locations",
|
||||
"CALL world-query find-region",
|
||||
"CALL world-query grid-layer",
|
||||
"CALL world-query grid-map",
|
||||
"CALL world-query parcel-owners",
|
||||
"CALL world-query parcel-objects",
|
||||
"CALL movement-back duration-ms=0",
|
||||
"CALL movement-cross Vector3",
|
||||
"CALL movement-crouch true",
|
||||
"CALL movement-fly true",
|
||||
"CALL movement-flyto Vector3",
|
||||
"CALL movement-follow 11111111-2222-3333-4444-555555555555",
|
||||
"CALL movement-follow off",
|
||||
"CALL movement-forward duration-ms=0",
|
||||
"CALL teleport-home",
|
||||
"CALL teleport-region Test Region",
|
||||
"CALL teleport-landmark 50000000-0000-0000-0000-000000000001",
|
||||
"CALL movement-jump",
|
||||
"CALL movement-left duration-ms=0",
|
||||
"CALL movement-autopilot [1010.0, 2011.0, 12.0]",
|
||||
"CALL movement-right duration-ms=0",
|
||||
"CALL movement-set-home",
|
||||
"CALL movement-sit 90000000-0000-0000-0000-000000000001",
|
||||
"CALL movement-stand",
|
||||
"CALL movement-turn Vector3",
|
||||
"CALL object-permissions 90000000-0000-0000-0000-000000000001",
|
||||
"CALL object-derez 90000000-0000-0000-0000-000000000003",
|
||||
"CALL texture-download 80000000-0000-0000-0000-000000000001",
|
||||
"CALL object-import count=2 use-group=true",
|
||||
"CALL texture-stream enabled=true",
|
||||
"CALL texture-stream enabled=false",
|
||||
"CALL object-tree Oak",
|
||||
] {
|
||||
assert!(text.contains(expected), "missing {expected:?} in:\n{text}");
|
||||
}
|
||||
assert_eq!(
|
||||
text.matches("CALL world-query parcels").count(),
|
||||
2,
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(text.matches("CALL movement-sit ").count(), 2, "{text}");
|
||||
assert_eq!(
|
||||
text.matches("CALL texture-download 80000000-0000-0000-0000-000000000001")
|
||||
.count(),
|
||||
3,
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(fs::read(&terrain_download).expect("terrain output"), [1, 2]);
|
||||
assert_eq!(
|
||||
fs::read(&texture_download).expect("texture output"),
|
||||
texture
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&export_one).expect("first export"),
|
||||
fs::read(&export_two).expect("second export"),
|
||||
"exports must be deterministic"
|
||||
);
|
||||
assert!(directory.path(&format!("{TEXTURE}.jp2")).is_file());
|
||||
assert!(directory.path(&format!("{TEXTURE}.tga")).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privileged_world_mutations_require_every_gate_and_confirmation() {
|
||||
let directory = TestDir::new("guards");
|
||||
let terrain = directory.write("terrain.raw", [1, 2, 3]);
|
||||
let script = directory.write(
|
||||
"guards.tsv",
|
||||
format!(
|
||||
"!client\t{CLIENT}\tAlice\tBot\n\
|
||||
!world-region\t{CLIENT}\t{HANDLE}\tTest Region\t128\t128\t25\t0\t0\n\
|
||||
!world-estate\t{CLIENT}\tFixture Estate\t{CLIENT}\t{COVENANT}\t1\tCovenant\t0102\n\
|
||||
forward 1 --confirm\n\
|
||||
uploadterrain \"{}\" --confirm\n\
|
||||
quit\n",
|
||||
path_text(&terrain),
|
||||
),
|
||||
);
|
||||
let output = run(&["--fake-script", path_text(&script)]);
|
||||
assert!(output.status.success(), "{}", stderr(&output));
|
||||
let text = stdout(&output);
|
||||
assert!(text.contains("Live world mutation blocked"), "{text}");
|
||||
assert!(!text.contains("CALL movement-forward"), "{text}");
|
||||
assert!(!text.contains("CALL estate-upload-terrain"), "{text}");
|
||||
|
||||
let script = directory.write(
|
||||
"confirm.tsv",
|
||||
format!(
|
||||
"!client\t{CLIENT}\tAlice\tBot\n\
|
||||
!world-region\t{CLIENT}\t{HANDLE}\tTest Region\t128\t128\t25\t0\t0\n\
|
||||
!world-estate\t{CLIENT}\tFixture Estate\t{CLIENT}\t{COVENANT}\t1\tCovenant\t0102\n\
|
||||
uploadterrain \"{}\"\n\
|
||||
quit\n",
|
||||
path_text(&terrain),
|
||||
),
|
||||
);
|
||||
let output = run(&[
|
||||
"--allow-live-mutations",
|
||||
"--allow-spending",
|
||||
"--allow-estate-actions",
|
||||
"--fake-script",
|
||||
path_text(&script),
|
||||
]);
|
||||
assert!(output.status.success(), "{}", stderr(&output));
|
||||
let text = stdout(&output);
|
||||
assert!(text.contains("explicit --confirm"), "{text}");
|
||||
assert!(!text.contains("CALL estate-upload-terrain"), "{text}");
|
||||
}
|
||||
Reference in New Issue
Block a user