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

This commit is contained in:
2026-08-11 12:10:19 +00:00
parent ea10b7672c
commit 374d7958f1
9 changed files with 4182 additions and 13 deletions

2
Cargo.lock generated
View File

@@ -1055,6 +1055,8 @@ dependencies = [
"libremetaverse",
"libremetaverse-imaging",
"libremetaverse-imaging-skia",
"libremetaverse-structured-data",
"regex",
"tokio",
]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")?;

File diff suppressed because it is too large Load Diff

View 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}");
}