Implement TestClient inventory and appearance commands (#92)
Some checks failed
Native code generation / deterministic (push) Successful in 18m25s
Imaging and meshing gate / native (push) Failing after 4m23s
JPEG 2000 feature / linux (push) Successful in 2m49s
Native Rust workspace compile / compile (push) Failing after 12m37s
Skia feature / linux (push) Has been cancelled

This commit is contained in:
2026-08-11 11:14:50 +00:00
parent 9675440210
commit ea10b7672c
7 changed files with 3379 additions and 15 deletions

View File

@@ -9,6 +9,8 @@ publish = false
[dependencies]
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"] }
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, and communication groups implemented; remaining command groups tracked by #92#94 |
| `test-client` | TestClient | Native shell, registry, system, communication, inventory, appearance, and asset groups implemented; remaining command groups tracked by #93#94 |
| `vivox-test` | VivoxTest | Pending milestone 11 issue #95 |
| `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 |
@@ -165,12 +165,16 @@ broadcast command routing.
The implemented command groups are `@`, `debug`, `echomaster`, `help`, `im`,
`imgroup`, `load`, `login`, `logpacket`, `logout`, `md5`, `quit`, `say`,
`setmaster`, `setmasterkey`, `shout`, `showeffects`, `sleep`, `waitforlogin`, and
`whisper`. Chat and instant-message bodies are bounded to the grid protocol
limit. Group commands require both `--groupcommands` and current group
membership. Master chat can be echoed, 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.
`whisper`. The inventory wave also implements `appearance`, `attachments`,
`avatarinfo`, `clone`, `wear`, `backuptext`, `balance`, `cd`, `createnotecard`,
`deletefolder`, `download`, `dumpoutfit`, `emptylostandfound`, `emptytrash`,
`giveall`, `give`, `i`, `ls`, `objectinventory`, `script`, `taskrunning`,
`uploadimage`, `uploadscript`, `viewnote`, and `xfer`. Chat and instant-message
bodies are bounded to the grid protocol limit. Group commands require both
`--groupcommands` and current group membership. Master chat can be echoed,
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 native `load` command reads a portable command-alias manifest instead of a
CLR assembly. Each non-comment line is tab-separated
@@ -178,6 +182,16 @@ CLR assembly. Each non-comment line is tab-separated
arguments. Manifests are limited to 1 MiB and 128 commands, with an alias
expansion depth of eight.
Inventory and appearance mutations require both the global
`--allow-live-mutations` switch and a per-command `--confirm` argument. Uploads
and L$ transfers additionally require `--allow-spending`. Downloads and
backups reject parent traversal, individual assets are limited to 64 MiB,
backups are limited to 10,000 files and 512 MiB, and image uploads decode
portable TGA, JPEG, PNG, WebP, or JPEG2000 input before producing the grid's
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.
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:
@@ -192,6 +206,16 @@ grid records begin with `!` and use tab-separated fields:
!packet<TAB>client-uuid<TAB>packet-type<TAB>simulator<TAB>bytes
!effect<TAB>client-uuid<TAB>summary
!disconnect<TAB>client-uuid<TAB>reason
!inventory-root<TAB>client-uuid<TAB>root-uuid<TAB>owner-uuid<TAB>name
!inventory-folder<TAB>client-uuid<TAB>folder-uuid<TAB>parent-uuid<TAB>folder-type<TAB>name
!inventory-item<TAB>client-uuid<TAB>item-uuid<TAB>parent-uuid<TAB>asset-uuid<TAB>asset-type<TAB>inventory-type<TAB>permissions-hex<TAB>name<TAB>description
!asset<TAB>client-uuid<TAB>asset-uuid<TAB>asset-type<TAB>hex-bytes
!balance<TAB>client-uuid<TAB>amount
!avatar<TAB>client-uuid<TAB>avatar-uuid<TAB>name<TAB>texture-label=texture-uuid,...
!appearance-cache<TAB>client-uuid<TAB>avatar-uuid
!attachment<TAB>client-uuid<TAB>point<TAB>local-id<TAB>primitive-uuid<TAB>offset
!task<TAB>client-uuid<TAB>object-uuid<TAB>local-id
!task-item<TAB>client-uuid<TAB>object-uuid<TAB>item-uuid<TAB>asset-type<TAB>true|false|none<TAB>name<TAB>description
!cancel
!shutdown
```
@@ -203,10 +227,16 @@ 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_client::tests --locked
cargo test --manifest-path tests/compat/Cargo.toml --test core_runtime_shims --locked
cargo test --manifest-path tests/compat/Cargo.toml --test social_message_semantics --locked
cargo test --manifest-path tests/compat/Cargo.toml --test network_semantics --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
cargo test --manifest-path tests/compat/Cargo.toml --test asset_capability --locked
cargo test --manifest-path tests/compat/Cargo.toml --test asset_document --locked
cargo test --manifest-path tests/compat/Cargo.toml --test inventory_ais --locked
cargo test --manifest-path tests/compat/Cargo.toml --test inventory_manager --locked
cargo test --manifest-path tests/compat/Cargo.toml --test inventory_store --locked
cargo test --manifest-path tests/compat/Cargo.toml --test task_inventory --locked
```
## PacketDump

View File

@@ -1,5 +1,7 @@
//! Native `TestClient` shell, registry, and first-wave command groups.
mod inventory;
use crate::commands::TEST_CLIENT_COMMANDS;
use clap::Parser;
use libremetaverse::packets::PacketType;
@@ -41,6 +43,31 @@ const MAX_LOADED_COMMANDS: usize = 128;
const MAX_ALIAS_DEPTH: usize = 8;
pub const IMPLEMENTED_TEST_CLIENT_COMMANDS: &[&str] = &[
"AppearanceCommand",
"AttachmentsCommand",
"AvatarInfoCommand",
"CloneCommand",
"WearCommand",
"BackupCommand",
"BalanceCommand",
"ChangeDirectoryCommand",
"CreateNotecardCommand",
"DeleteFolderCommand",
"DownloadCommand",
"DumpOutfitCommand",
"EmptyLostAndFound",
"EmptyTrashCommand",
"GiveAllCommand",
"GiveItemCommand",
"InventoryCommand",
"ListContentsCommand",
"ObjectInventoryCommand",
"ScriptCommand",
"TaskRunningCommand",
"UploadImageCommand",
"UploadScriptCommand",
"ViewNotecardCommand",
"XferCommand",
"EchoMasterCommand",
"IMCommand",
"IMGroupCommand",
@@ -79,8 +106,9 @@ 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: @, debug, echomaster, help, im, imgroup, load, login, logpacket, logout, md5, quit, say, setmaster, setmasterkey, shout, showeffects, sleep, waitforlogin, whisper"
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."
)]
#[allow(clippy::struct_excessive_bools)]
struct Cli {
/// First name for one initial account.
#[arg(long)]
@@ -124,6 +152,14 @@ struct Cli {
/// Maximum time allowed for each live login.
#[arg(long, default_value_t = 30, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
login_timeout_seconds: u64,
/// Permit commands that mutate live inventory or appearance state.
#[arg(long)]
allow_live_mutations: bool,
/// Permit commands that spend or transfer L$; each command also requires confirmation.
#[arg(long)]
allow_spending: bool,
}
#[derive(Debug)]
@@ -207,8 +243,9 @@ enum ClientEvent {
}
type BackendFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
type InventoryFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
trait ClientBackend: Send + Sync {
trait ClientBackend: Send + Sync + inventory::Backend {
fn id(&self) -> UUID;
fn name(&self) -> String;
fn connected(&self) -> bool;
@@ -242,6 +279,7 @@ struct LiveBackend {
id: UUID,
name: String,
subscriptions: Mutex<Vec<Subscription>>,
inventory_state: Mutex<inventory::State>,
closed: AtomicBool,
}
@@ -251,6 +289,7 @@ impl LiveBackend {
timeout: Duration,
events: mpsc::Sender<ClientEvent>,
dropped: Arc<AtomicUsize>,
account_policy: inventory::MutationPolicy,
cancellation: CancellationToken,
) -> Result<Arc<Self>, ProgramError> {
let mut client = GridClient::new()
@@ -299,6 +338,7 @@ impl LiveBackend {
id,
name,
subscriptions: Mutex::new(Vec::new()),
inventory_state: Mutex::new(inventory::State::new(account_policy)),
closed: AtomicBool::new(false),
});
backend.install(&events, &dropped);
@@ -309,6 +349,7 @@ impl LiveBackend {
action(lock(&self.client).self_())
}
#[allow(clippy::too_many_lines)]
fn install(self: &Arc<Self>, events: &mpsc::Sender<ClientEvent>, dropped: &Arc<AtomicUsize>) {
let mut subscriptions = Vec::new();
let sender = events.clone();
@@ -390,6 +431,18 @@ impl LiveBackend {
}),
false,
));
let appearance_backend = Arc::downgrade(self);
subscriptions.push(self.network.subscribe_packet(
PacketType::AvatarAppearance,
Arc::new(move |event: PacketReceivedEventArgs| {
if let (Some(backend), Some(bytes)) =
(appearance_backend.upgrade(), event.raw_data())
{
inventory::capture_appearance(&backend, &bytes);
}
}),
false,
));
let sender = events.clone();
let dropped_disconnect = Arc::clone(dropped);
subscriptions.push(self.network.subscribe_disconnected(Arc::new(
@@ -570,6 +623,7 @@ struct FakeBackend {
calls: Mutex<Vec<String>>,
people: Arc<Mutex<HashMap<String, UUID>>>,
group_members: Arc<Mutex<HashSet<UUID>>>,
inventory_state: Mutex<inventory::State>,
}
impl FakeBackend {
@@ -578,6 +632,7 @@ impl FakeBackend {
name: String,
people: Arc<Mutex<HashMap<String, UUID>>>,
group_members: Arc<Mutex<HashSet<UUID>>>,
policy: inventory::MutationPolicy,
) -> Self {
Self {
id,
@@ -586,6 +641,7 @@ impl FakeBackend {
calls: Mutex::new(Vec::new()),
people,
group_members,
inventory_state: Mutex::new(inventory::State::new(policy)),
}
}
@@ -704,7 +760,9 @@ impl ProgramOutput {
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
enum CommandCategory {
Appearance,
Communication,
Inventory,
TestClient,
Other,
}
@@ -717,6 +775,7 @@ enum CommandHandler {
Help,
Im,
ImGroup,
Inventory(inventory::Command),
Load,
Login,
LogPacket,
@@ -876,6 +935,7 @@ fn built_in_commands() -> HashMap<String, CommandDefinition> {
framework_commands()
.into_iter()
.chain(communication_commands())
.chain(inventory::commands())
.map(|(name, description, category, handler)| {
(
name.into(),
@@ -1020,6 +1080,7 @@ struct ClientManager {
default_master_name: String,
default_master_key: UUID,
default_group_commands: bool,
default_mutation_policy: inventory::MutationPolicy,
cancellation: CancellationTokenSource,
output: Arc<ProgramOutput>,
events_tx: mpsc::Sender<ClientEvent>,
@@ -1033,6 +1094,7 @@ struct ClientManager {
}
impl ClientManager {
#[allow(clippy::too_many_arguments)]
fn new(
mode: RunMode,
login_uri: Option<String>,
@@ -1040,6 +1102,7 @@ impl ClientManager {
master_name: String,
master_key: UUID,
group_commands: bool,
mutation_policy: inventory::MutationPolicy,
output: Arc<ProgramOutput>,
) -> Self {
let (events_tx, events_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY);
@@ -1055,6 +1118,7 @@ impl ClientManager {
default_master_name: master_name,
default_master_key: master_key,
default_group_commands: group_commands,
default_mutation_policy: mutation_policy,
cancellation: CancellationTokenSource::new(),
output,
events_tx,
@@ -1114,6 +1178,7 @@ impl ClientManager {
name,
Arc::clone(&self.fake_people),
Arc::clone(&self.fake_group_members),
self.default_mutation_policy,
));
self.add_client(ManagedClient {
backend,
@@ -1140,6 +1205,7 @@ impl ClientManager {
self.login_timeout,
self.events_tx.clone(),
Arc::clone(&self.dropped_events),
self.default_mutation_policy,
self.cancellation.token(),
)
.await
@@ -1153,6 +1219,7 @@ impl ClientManager {
display_name,
Arc::clone(&self.fake_people),
Arc::clone(&self.fake_group_members),
self.default_mutation_policy,
))),
Err(_) => Err(ProgramError::Client(
"could not make fake client UUID".into(),
@@ -1297,6 +1364,11 @@ impl ClientManager {
self.output.line(result);
return;
}
CommandHandler::Inventory(inventory::Command::Script) => {
let result = self.execute_script(args).await;
self.output.line(result);
return;
}
CommandHandler::At | CommandHandler::LoadedAlias(_) => return,
_ => {}
}
@@ -1411,6 +1483,25 @@ impl ClientManager {
format!("Logging is set to {level}")
}
async fn execute_script(&mut self, args: &[String]) -> String {
if args.len() != 1 {
return "Usage: script [filename]".into();
}
let lines = match read_bounded_lines(Path::new(&args[0])) {
Ok(lines) => lines,
Err(error) => return error.to_string(),
};
let count = lines.len();
for line in lines {
if !self.running {
break;
}
Box::pin(self.dispatch_line(line.trim(), UUID::zero())).await;
drain_events(self).await;
}
format!("Finished executing {count} commands")
}
fn load_commands(&mut self, args: &[String]) -> String {
if args.len() != 1 {
return "Usage: load [native-command-manifest]".into();
@@ -1605,7 +1696,7 @@ async fn execute_client_command(
client: &mut ManagedClient,
handler: &CommandHandler,
args: &[String],
_from: UUID,
from: UUID,
cancellation: CancellationToken,
) -> String {
match handler {
@@ -1616,6 +1707,9 @@ async fn execute_client_command(
execute_communication_command(client, handler, args, cancellation).await
}
CommandHandler::LogPacket => configure_packet_log(client, args),
CommandHandler::Inventory(command) => {
inventory::execute(client.backend.as_ref(), *command, args, from, cancellation).await
}
CommandHandler::Md5 => {
if args.len() != 1 {
return "Usage: md5 [password]".into();
@@ -1861,6 +1955,8 @@ 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 master_key = match cli.masterkey.as_deref() {
Some(value) => UUID::new_with_string(value.into())
.map_err(|_| ProgramError::Usage("--masterkey must be a UUID"))?,
@@ -1881,6 +1977,7 @@ async fn run(cli: Cli) -> Result<(), ProgramError> {
cli.master.unwrap_or_default(),
master_key,
cli.groupcommands,
mutation_policy,
Arc::clone(&output),
);
run_fake_script(&mut manager, path).await?;
@@ -1913,6 +2010,7 @@ async fn run(cli: Cli) -> Result<(), ProgramError> {
cli.master.unwrap_or_default(),
master_key,
cli.groupcommands,
mutation_policy,
Arc::clone(&output),
);
for account in accounts {
@@ -2085,7 +2183,7 @@ async fn run_interactive(manager: &mut ClientManager) -> Result<(), ProgramError
async fn drain_events(manager: &mut ClientManager) {
while let Ok(event) = manager.events_rx.try_recv() {
if let Some((line, from)) = manager.process_event(event) {
manager.dispatch_line(&line, from).await;
Box::pin(manager.dispatch_line(&line, from)).await;
}
}
}
@@ -2183,6 +2281,10 @@ async fn run_fake_event_directive(
fields: &[&str],
line: usize,
) -> Result<(), ProgramError> {
if let Some(result) = inventory::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 {
@@ -2254,7 +2356,7 @@ async fn run_fake_event_directive(
_ => {
return invalid(
line,
"unknown fake directive; expected client, person, group-member, chat, im, packet, effect, disconnect, cancel, or shutdown",
"unknown fake directive; see --help and programs/README.md for supported records",
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -97,6 +97,8 @@ fn help_documents_upstream_framework_and_owned_commands() {
"--scriptfile",
"--nogui",
"--fake-script",
"--allow-live-mutations",
"--allow-spending",
"echomaster",
"imgroup",
"logpacket",

View File

@@ -0,0 +1,332 @@
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 MASTER: &str = "11111111-2222-3333-4444-555555555555";
const RECIPIENT: &str = "22222222-3333-4444-5555-666666666666";
const ROOT: &str = "10000000-0000-0000-0000-000000000001";
const OUTFITS: &str = "10000000-0000-0000-0000-000000000002";
const TRASH: &str = "10000000-0000-0000-0000-000000000003";
const LOST: &str = "10000000-0000-0000-0000-000000000004";
const NOTES: &str = "10000000-0000-0000-0000-000000000005";
const SCRIPTS: &str = "10000000-0000-0000-0000-000000000006";
const TEXTURES: &str = "10000000-0000-0000-0000-000000000007";
const OBJECTS: &str = "10000000-0000-0000-0000-000000000008";
const DELETE_ME: &str = "10000000-0000-0000-0000-000000000009";
const GIVE_ITEM: &str = "20000000-0000-0000-0000-000000000001";
const NOTE_ITEM: &str = "20000000-0000-0000-0000-000000000002";
const SCRIPT_ITEM: &str = "20000000-0000-0000-0000-000000000003";
const OUTFIT_ITEM: &str = "20000000-0000-0000-0000-000000000004";
const NOTE_ASSET: &str = "30000000-0000-0000-0000-000000000001";
const SCRIPT_ASSET: &str = "30000000-0000-0000-0000-000000000002";
const OBJECT_ASSET: &str = "30000000-0000-0000-0000-000000000003";
const TEXTURE_ASSET: &str = "30000000-0000-0000-0000-000000000004";
const AVATAR: &str = "40000000-0000-0000-0000-000000000001";
const ATTACHMENT: &str = "40000000-0000-0000-0000-000000000002";
const TASK: &str = "50000000-0000-0000-0000-000000000001";
const TASK_SCRIPT: &str = "50000000-0000-0000-0000-000000000002";
const SESSION: &str = "60000000-0000-0000-0000-000000000001";
const CREATED_NOTE_ITEM: &str = "00000000-0000-0000-0200-00000000e01e";
const UPLOADED_TEXTURE_ASSET: &str = "00000000-0000-0000-0500-0000000050a5";
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-inventory-{name}-{}-{id}",
std::process::id()
));
fs::create_dir(&path).expect("create test directory");
Self(path)
}
fn write(&self, name: &str, contents: impl AsRef<[u8]>) -> PathBuf {
let path = self.0.join(name);
fs::write(&path, contents).expect("write fixture");
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 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 notecard(body: &str) -> Vec<u8> {
format!(
"Linden text version 2\n{{\nLLEmbeddedItems version 1\n{{\ncount 0\n}}\nText length {}\n{}\n}}\n",
body.len(),
body
)
.into_bytes()
}
fn one_pixel_jp2() -> Vec<u8> {
let mut image =
ManagedImage::new(1, 1, ManagedImageImageChannels::COLOR).expect("create managed image");
image.red[0] = 10;
image.green[0] = 20;
image.blue[0] = 30;
J2kCodec::encode(&image, J2kEncodeOptions::default()).expect("encode JPEG2000 fixture")
}
fn one_pixel_tga() -> Vec<u8> {
let mut bytes = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 0x28];
bytes.extend_from_slice(&[30, 20, 10, 255]);
bytes
}
#[test]
#[allow(clippy::too_many_lines)]
fn fake_grid_exercises_every_owned_inventory_appearance_and_asset_command() {
let directory = TestDir::new("commands");
let note_source = directory.write("new note.txt", "created note body");
let script_source = directory.write("new script.lsl", "default { state_entry() {} }");
let image_source = directory.write("image.tga", one_pixel_tga());
let nested_script = directory.write("nested.tc", "balance\nls -l\n");
let download = directory.path("downloaded-note.asset");
let xfer = directory.path("object.xfer");
let backup = directory.path("backup");
let outfit = directory.path("outfit");
let jp2 = one_pixel_jp2();
let script = directory.write(
"terminal.tsv",
format!(
"!client\t{CLIENT}\tAlice\tBot\tMaster Resident\t{MASTER}\tfalse\n\
!inventory-root\t{CLIENT}\t{ROOT}\t{CLIENT}\tMy Inventory\n\
!inventory-folder\t{CLIENT}\t{OUTFITS}\t{ROOT}\toutfit\tOutfits\n\
!inventory-folder\t{CLIENT}\t{TRASH}\t{ROOT}\ttrash\tTrash\n\
!inventory-folder\t{CLIENT}\t{LOST}\t{ROOT}\tlostandfound\tLost And Found\n\
!inventory-folder\t{CLIENT}\t{NOTES}\t{ROOT}\tnotecard\tNotecards\n\
!inventory-folder\t{CLIENT}\t{SCRIPTS}\t{ROOT}\tscript\tScripts\n\
!inventory-folder\t{CLIENT}\t{TEXTURES}\t{ROOT}\ttexture\tTextures\n\
!inventory-folder\t{CLIENT}\t{OBJECTS}\t{ROOT}\tobject\tObjects\n\
!inventory-folder\t{CLIENT}\t{DELETE_ME}\t{ROOT}\tnone\tDelete Me\n\
!inventory-item\t{CLIENT}\t{GIVE_ITEM}\t{ROOT}\t{OBJECT_ASSET}\tobject\tobject\t0008e000\tGiveable\tTransfer me\n\
!inventory-item\t{CLIENT}\t{NOTE_ITEM}\t{NOTES}\t{NOTE_ASSET}\tnotecard\tnotecard\t0008e000\tFixture Note\tA note\n\
!inventory-item\t{CLIENT}\t{SCRIPT_ITEM}\t{SCRIPTS}\t{SCRIPT_ASSET}\tlsltext\tlsl\t0008e000\tFixture Script\tA script\n\
!inventory-item\t{CLIENT}\t{OUTFIT_ITEM}\t{OUTFITS}\t{TEXTURE_ASSET}\tclothing\twearable\t0008e000\tShirt\tOutfit shirt\n\
!asset\t{CLIENT}\t{NOTE_ASSET}\tnotecard\t{}\n\
!asset\t{CLIENT}\t{SCRIPT_ASSET}\tlsltext\t{}\n\
!asset\t{CLIENT}\t{OBJECT_ASSET}\tobject\t{}\n\
!asset\t{CLIENT}\t{TEXTURE_ASSET}\ttexture\t{}\n\
!balance\t{CLIENT}\t75\n\
!avatar\t{CLIENT}\t{AVATAR}\tTarget Resident\tHead={TEXTURE_ASSET}\n\
!appearance-cache\t{CLIENT}\t{AVATAR}\n\
!attachment\t{CLIENT}\tChest\t42\t{ATTACHMENT}\t<1,2,3>\n\
!task\t{CLIENT}\t{TASK}\t99\n\
!task-item\t{CLIENT}\t{TASK}\t{TASK_SCRIPT}\tlsltext\ttrue\tRunning Script\tTask script\n\
appearance rebake --confirm\n\
attachments\n\
avatarinfo Target Resident\n\
clone Target Resident --confirm\n\
wear /Outfits --confirm\n\
backuptext to \"{}\"\n\
backuptext status\n\
backuptext abort\n\
balance\n\
cd Outfits\n\
ls\n\
cd /\n\
ls -l\n\
i\n\
give {RECIPIENT} Giveable --confirm\n\
download {NOTE_ASSET} notecard \"{}\"\n\
xfer {OBJECT_ASSET} \"{}\"\n\
viewnote {NOTE_ITEM}\n\
objectinventory {TASK}\n\
taskrunning {TASK}\n\
taskrunning {TASK} \"Running Script\" false --confirm\n\
taskrunning {TASK}\n\
createnotecard \"{}\" {GIVE_ITEM} --confirm\n\
viewnote {CREATED_NOTE_ITEM}\n\
uploadscript \"{}\" --confirm\n\
uploadimage TestTexture 30000 \"{}\" --confirm\n\
!avatar\t{CLIENT}\t{AVATAR}\tTarget Resident\tUploaded={UPLOADED_TEXTURE_ASSET}\n\
dumpoutfit {AVATAR} \"{}\"\n\
script \"{}\"\n\
deletefolder \"Delete Me\" --confirm\n\
emptylostandfound --confirm\n\
emptytrash --confirm\n\
i\n\
!im\t{CLIENT}\t{MASTER}\tMaster Resident\tagent\tfalse\tgiveall --confirm\t{SESSION}\n\
balance\n\
quit\n",
hex(&notecard("fixture note body")),
hex(b"default { state_entry() {} }"),
hex(b"fake object asset"),
hex(&jp2),
path_text(&backup),
path_text(&download),
path_text(&xfer),
path_text(&note_source),
path_text(&script_source),
path_text(&image_source),
path_text(&outfit),
path_text(&nested_script),
),
);
let output = run(&[
"--allow-live-mutations",
"--allow-spending",
"--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 [
"Appearance sequence started",
"Found 1 attachments",
"Head: 30000000-0000-0000-0000-000000000004",
"Cloned Target Resident",
"Starting to change outfit to /Outfits",
"backuptext completed: found 2, transferred 2, errors 0",
"Last backup: found 2, transferred 2, errors 0",
"No backup is currently running",
"Balance is L$: 75",
"Current folder: Outfits",
"Shirt",
"Returned 12 items.",
"Gave Giveable (Object)",
"Raw Notecard Data: fixture note body",
"[Item] Name: Running Script Desc: Task script Type: LSLText",
"IsRunning: true",
"Setting true => false",
"IsRunning: false",
"Notecard successfully created",
"Raw Notecard Data: created note body",
"compilation requested",
"Texture upload succeeded",
"Downloaded Uploaded",
"Finished executing 2 commands",
"Moved folder Delete Me to Trash",
"Lost And Found Emptied",
"Trash Emptied",
"Returned 14 items.",
"Gave $75 to 11111111-2222-3333-4444-555555555555",
"Balance is L$: 0",
"CALL appearance-set rebake=true",
"CALL appearance-clone 40000000-0000-0000-0000-000000000001",
"CALL appearance-wear 20000000-0000-0000-0000-000000000004",
"CALL inventory-give 20000000-0000-0000-0000-000000000001",
"CALL xfer 30000000-0000-0000-0000-000000000003 Object",
"CALL task-script-running 50000000-0000-0000-0000-000000000001 50000000-0000-0000-0000-000000000002 false",
"CALL inventory-empty LostAndFound",
"CALL inventory-empty Trash",
"active-tasks=0 shutdown=true",
] {
assert!(text.contains(expected), "missing {expected}:\n{text}");
}
assert_eq!(
fs::read(&download).expect("download file"),
notecard("fixture note body")
);
assert_eq!(fs::read(&xfer).expect("xfer file"), b"fake object asset");
assert_eq!(
fs::read(backup.join("Notecards/Fixture Note.txt")).expect("backup notecard"),
notecard("fixture note body")
);
assert_eq!(
fs::read(backup.join("Scripts/Fixture Script.lsl")).expect("backup script"),
b"default { state_entry() {} }"
);
assert_eq!(
J2kCodec::decode_bytes(
&fs::read(outfit.join(format!("{UPLOADED_TEXTURE_ASSET}.jp2")))
.expect("uploaded outfit jp2"),
libremetaverse_imaging::J2kDecodeOptions::default(),
)
.expect("decode uploaded outfit texture")
.width,
1
);
assert!(
outfit
.join(format!("{UPLOADED_TEXTURE_ASSET}.tga"))
.exists()
);
}
#[test]
fn mutations_require_global_opt_in_and_per_command_confirmation() {
let directory = TestDir::new("guards");
let script = directory.write(
"terminal.tsv",
format!(
"!client\t{CLIENT}\tAlice\tBot\n\
!inventory-root\t{CLIENT}\t{ROOT}\t{CLIENT}\tMy Inventory\n\
!inventory-folder\t{CLIENT}\t{TRASH}\t{ROOT}\ttrash\tTrash\n\
emptytrash --confirm\n\
quit\n"
),
);
let output = run(&["--fake-script", path_text(&script)]);
assert!(output.status.success());
assert!(stdout(&output).contains("Live mutation blocked; restart with --allow-live-mutations"));
assert!(!stdout(&output).contains("CALL inventory-empty"));
let no_confirm = directory.write(
"no-confirm.tsv",
format!(
"!client\t{CLIENT}\tAlice\tBot\n\
!inventory-root\t{CLIENT}\t{ROOT}\t{CLIENT}\tMy Inventory\n\
!inventory-folder\t{CLIENT}\t{TRASH}\t{ROOT}\ttrash\tTrash\n\
emptytrash\n\
quit\n"
),
);
let output = run(&[
"--allow-live-mutations",
"--fake-script",
path_text(&no_confirm),
]);
assert!(output.status.success());
assert!(stdout(&output).contains("Operation requires an explicit --confirm argument"));
assert!(!stdout(&output).contains("CALL inventory-empty"));
}