diff --git a/Cargo.lock b/Cargo.lock index 4b44e87..b102888 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1053,6 +1053,8 @@ version = "0.0.1" dependencies = [ "clap", "libremetaverse", + "libremetaverse-imaging", + "libremetaverse-imaging-skia", "tokio", ] diff --git a/programs/Cargo.toml b/programs/Cargo.toml index 14f9648..2901afd 100644 --- a/programs/Cargo.toml +++ b/programs/Cargo.toml @@ -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] diff --git a/programs/README.md b/programs/README.md index c59ae46..2f1442e 100644 --- a/programs/README.md +++ b/programs/README.md @@ -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: !packetclient-uuidpacket-typesimulatorbytes !effectclient-uuidsummary !disconnectclient-uuidreason +!inventory-rootclient-uuidroot-uuidowner-uuidname +!inventory-folderclient-uuidfolder-uuidparent-uuidfolder-typename +!inventory-itemclient-uuiditem-uuidparent-uuidasset-uuidasset-typeinventory-typepermissions-hexnamedescription +!assetclient-uuidasset-uuidasset-typehex-bytes +!balanceclient-uuidamount +!avatarclient-uuidavatar-uuidnametexture-label=texture-uuid,... +!appearance-cacheclient-uuidavatar-uuid +!attachmentclient-uuidpointlocal-idprimitive-uuidoffset +!taskclient-uuidobject-uuidlocal-id +!task-itemclient-uuidobject-uuiditem-uuidasset-typetrue|false|nonenamedescription !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 diff --git a/programs/src/test_client.rs b/programs/src/test_client.rs index a029cd6..ce5b4e9 100644 --- a/programs/src/test_client.rs +++ b/programs/src/test_client.rs @@ -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 + Send + 'a>>; +type InventoryFuture<'a, T> = Pin + '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>, + inventory_state: Mutex, closed: AtomicBool, } @@ -251,6 +289,7 @@ impl LiveBackend { timeout: Duration, events: mpsc::Sender, dropped: Arc, + account_policy: inventory::MutationPolicy, cancellation: CancellationToken, ) -> Result, 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, events: &mpsc::Sender, dropped: &Arc) { 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>, people: Arc>>, group_members: Arc>>, + inventory_state: Mutex, } impl FakeBackend { @@ -578,6 +632,7 @@ impl FakeBackend { name: String, people: Arc>>, group_members: Arc>>, + 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 { 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, events_tx: mpsc::Sender, @@ -1033,6 +1094,7 @@ struct ClientManager { } impl ClientManager { + #[allow(clippy::too_many_arguments)] fn new( mode: RunMode, login_uri: Option, @@ -1040,6 +1102,7 @@ impl ClientManager { master_name: String, master_key: UUID, group_commands: bool, + mutation_policy: inventory::MutationPolicy, output: Arc, ) -> 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", ); } } diff --git a/programs/src/test_client/inventory.rs b/programs/src/test_client/inventory.rs new file mode 100644 index 0000000..abf3731 --- /dev/null +++ b/programs/src/test_client/inventory.rs @@ -0,0 +1,2894 @@ +//! Inventory, appearance, and asset command groups for the native `TestClient`. + +// The command registry and backend adapters intentionally keep the upstream command surface +// together, and the explicit lifetimes keep the object-safe backend futures unambiguous. +#![allow( + clippy::elidable_lifetime_names, + clippy::format_push_string, + clippy::needless_pass_by_value, + clippy::too_many_lines +)] + +use super::{ClientManager, FakeBackend, InventoryFuture as BackendFuture, LiveBackend, lock}; +use libremetaverse::types::compat::CancellationToken; +use libremetaverse::types::{AssetType, FolderType, InventoryType, UUID}; +use libremetaverse::{PermissionMask, Permissions}; +use libremetaverse_imaging::{ + J2kCodec, J2kCompression, J2kDecodeOptions, J2kEncodeOptions, ManagedImage, + ManagedImageImageChannels, +}; +use libremetaverse_imaging_skia::SkiaTextureCodec; +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::{Cursor, Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +const MAX_ASSET_BYTES: u64 = 64 * 1024 * 1024; +const MAX_BACKUP_FILES: usize = 10_000; +const MAX_BACKUP_BYTES: u64 = 512 * 1024 * 1024; +const MAX_TREE_ENTRIES: usize = 100_000; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum Command { + Appearance, + Attachments, + AvatarInfo, + Clone, + Wear, + BackupText, + Balance, + Cd, + CreateNotecard, + DeleteFolder, + Download, + DumpOutfit, + EmptyLostAndFound, + EmptyTrash, + GiveAll, + Give, + Inventory, + Ls, + ObjectInventory, + Script, + TaskRunning, + UploadImage, + UploadScript, + ViewNote, + Xfer, +} + +pub(super) fn commands() -> Vec { + use super::{CommandCategory, CommandHandler}; + use Command as C; + vec![ + ( + "appearance", + "Set current appearance from the saved outfit. Usage: appearance [rebake] --confirm", + CommandCategory::Appearance, + CommandHandler::Inventory(C::Appearance), + ), + ( + "attachments", + "Print currently known agent attachments", + CommandCategory::Appearance, + CommandHandler::Inventory(C::Attachments), + ), + ( + "avatarinfo", + "Print nearby avatar textures. Usage: avatarinfo [firstname] [lastname]", + CommandCategory::Appearance, + CommandHandler::Inventory(C::AvatarInfo), + ), + ( + "clone", + "Clone a cached nearby avatar appearance. Usage: clone [name] --confirm", + CommandCategory::Appearance, + CommandHandler::Inventory(C::Clone), + ), + ( + "wear", + "Wear an inventory outfit. Usage: wear [path] [--confirm]", + CommandCategory::Appearance, + CommandHandler::Inventory(C::Wear), + ), + ( + "backuptext", + "Back up scripts and notecards. Usage: backuptext to [directory] | status | abort", + CommandCategory::Inventory, + CommandHandler::Inventory(C::BackupText), + ), + ( + "balance", + "Request and show the current L$ balance", + CommandCategory::Inventory, + CommandHandler::Inventory(C::Balance), + ), + ( + "cd", + "Change inventory folder. Usage: cd [path]", + CommandCategory::Inventory, + CommandHandler::Inventory(C::Cd), + ), + ( + "createnotecard", + "Create a notecard from a local file. Usage: createnotecard [file] [embedded-item] --confirm", + CommandCategory::Inventory, + CommandHandler::Inventory(C::CreateNotecard), + ), + ( + "deletefolder", + "Move a folder to Trash. Usage: deletefolder [path] --confirm", + CommandCategory::Inventory, + CommandHandler::Inventory(C::DeleteFolder), + ), + ( + "download", + "Download an asset. Usage: download [asset-uuid] [asset-type] [output]", + CommandCategory::Inventory, + CommandHandler::Inventory(C::Download), + ), + ( + "dumpoutfit", + "Download avatar outfit textures. Usage: dumpoutfit [avatar-uuid] [directory]", + CommandCategory::Inventory, + CommandHandler::Inventory(C::DumpOutfit), + ), + ( + "emptylostandfound", + "Empty Lost And Found. Usage: emptylostandfound --confirm", + CommandCategory::Inventory, + CommandHandler::Inventory(C::EmptyLostAndFound), + ), + ( + "emptytrash", + "Empty Trash. Usage: emptytrash --confirm", + CommandCategory::Inventory, + CommandHandler::Inventory(C::EmptyTrash), + ), + ( + "giveall", + "Transfer the current balance to the requesting avatar. Usage: giveall --confirm", + CommandCategory::Inventory, + CommandHandler::Inventory(C::GiveAll), + ), + ( + "give", + "Give an inventory item. Usage: give [avatar-uuid] [item] --confirm", + CommandCategory::Inventory, + CommandHandler::Inventory(C::Give), + ), + ( + "i", + "Print the complete inventory tree", + CommandCategory::Inventory, + CommandHandler::Inventory(C::Inventory), + ), + ( + "ls", + "List current inventory folder. Usage: ls [-l]", + CommandCategory::Inventory, + CommandHandler::Inventory(C::Ls), + ), + ( + "objectinventory", + "List task inventory. Usage: objectinventory [object-uuid]", + CommandCategory::Inventory, + CommandHandler::Inventory(C::ObjectInventory), + ), + ( + "script", + "Execute bounded TestClient commands from a file. Usage: script [file]", + CommandCategory::TestClient, + CommandHandler::Inventory(C::Script), + ), + ( + "taskrunning", + "Query or set task scripts. Usage: taskrunning [object-uuid] [[name] true|false] [--confirm]", + CommandCategory::Inventory, + CommandHandler::Inventory(C::TaskRunning), + ), + ( + "uploadimage", + "Upload an image. Usage: uploadimage [name] [timeout-ms] [file] --confirm", + CommandCategory::Inventory, + CommandHandler::Inventory(C::UploadImage), + ), + ( + "uploadscript", + "Upload a local LSL script. Usage: uploadscript [file] --confirm", + CommandCategory::Inventory, + CommandHandler::Inventory(C::UploadScript), + ), + ( + "viewnote", + "Download and display a notecard. Usage: viewnote [item-uuid]", + CommandCategory::Inventory, + CommandHandler::Inventory(C::ViewNote), + ), + ( + "xfer", + "Download an object asset with Xfer. Usage: xfer [asset-uuid] [output]", + CommandCategory::Inventory, + CommandHandler::Inventory(C::Xfer), + ), + ] +} + +#[derive(Clone, Copy, Default)] +pub(super) struct MutationPolicy(u8); + +impl MutationPolicy { + const MUTATIONS: u8 = 1; + const SPENDING: u8 = 1 << 1; + + pub(super) const fn new(mutations: bool, spending: bool) -> Self { + Self((mutations as u8) | ((spending as u8) << 1)) + } + + const fn mutations(self) -> bool { + self.0 & Self::MUTATIONS != 0 + } + + const fn spending(self) -> bool { + self.0 & Self::SPENDING != 0 + } +} + +pub(super) struct State { + policy: MutationPolicy, + current_directory: Option, + fake: FakeGrid, + live_appearances: HashMap, + clone_serial: u32, + backup: BackupProgress, +} + +impl State { + pub(super) fn new(policy: MutationPolicy) -> Self { + Self { + policy, + current_directory: None, + fake: FakeGrid::default(), + live_appearances: HashMap::new(), + clone_serial: 0, + backup: BackupProgress::default(), + } + } +} + +#[derive(Clone, Default)] +struct BackupProgress { + running: bool, + abort_requested: bool, + found: usize, + transferred: usize, + errors: usize, + bytes: u64, +} + +#[derive(Clone, Default)] +struct CachedAppearance { + texture_entry: Vec, + visual_params: Vec, +} + +pub(super) fn capture_appearance(backend: &LiveBackend, bytes: &[u8]) { + use libremetaverse::packets::AvatarAppearancePacket; + let Ok(mut packet) = AvatarAppearancePacket::new_with_constructor() else { + return; + }; + let Ok(mut packet_end) = i32::try_from(bytes.len()) else { + return; + }; + packet_end -= 1; + let mut offset = 0; + let mut zero_buffer = vec![0_u8; 64 * 1024]; + if packet + .from_bytes_with_bytes_int32_int32_bytes( + bytes.to_vec(), + &mut offset, + &mut packet_end, + Some(&mut zero_buffer), + ) + .is_err() + { + return; + } + let appearance = CachedAppearance { + texture_entry: packet.object_data.texture_entry, + visual_params: packet + .visual_param + .into_iter() + .map(|block| block.param_value) + .collect(), + }; + lock(&backend.inventory_state) + .live_appearances + .insert(packet.sender.id, appearance); +} + +#[derive(Clone)] +pub(super) struct Entry { + id: UUID, + parent: UUID, + name: String, + description: String, + creator: UUID, + owner: UUID, + last_owner: UUID, + group: UUID, + kind: EntryKind, +} + +#[derive(Clone, Copy)] +enum EntryKind { + Folder(FolderType), + Item { + asset_id: UUID, + asset_type: AssetType, + inventory_type: InventoryType, + permissions: Permissions, + }, +} + +impl Entry { + const fn is_folder(&self) -> bool { + matches!(self.kind, EntryKind::Folder(_)) + } + + const fn item(&self) -> Option<(UUID, AssetType, InventoryType, Permissions)> { + match self.kind { + EntryKind::Item { + asset_id, + asset_type, + inventory_type, + permissions, + } => Some((asset_id, asset_type, inventory_type, permissions)), + EntryKind::Folder(_) => None, + } + } +} + +#[derive(Clone)] +pub(super) struct Snapshot { + root: UUID, + entries: Vec, +} + +#[derive(Clone)] +pub(super) struct AttachmentView { + point: String, + local_id: u32, + id: UUID, + offset: String, +} + +#[derive(Clone)] +pub(super) struct AvatarView { + id: UUID, + name: String, + textures: Vec<(String, UUID)>, +} + +#[derive(Clone)] +struct TaskItemView { + id: UUID, + name: String, + description: String, + asset_type: AssetType, + running: Option, +} + +#[derive(Clone)] +pub(super) struct TaskInventoryView { + local_id: u32, + items: Vec, +} + +#[derive(Clone)] +pub(super) enum Mutation { + MoveFolderToTrash { + folder: UUID, + }, + EmptySystemFolder(FolderType), + GiveItem { + item: Entry, + recipient: UUID, + }, + GiveMoney { + recipient: UUID, + amount: i32, + }, + CreateAsset { + name: String, + description: String, + data: Vec, + asset_type: AssetType, + inventory_type: InventoryType, + }, + Wear { + items: Vec, + }, + RequestAppearance { + rebake: bool, + }, + CloneAppearance { + avatar: UUID, + }, + SetScriptRunning { + object: UUID, + script: UUID, + running: bool, + }, +} + +#[derive(Clone, Copy, Default)] +pub(super) struct MutationResult { + item_id: UUID, + asset_id: UUID, +} + +pub(super) trait Backend { + fn inventory_state(&self) -> &Mutex; + fn snapshot<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result>; + fn fetch_asset<'a>( + &'a self, + id: UUID, + asset_type: AssetType, + xfer: bool, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>>; + fn balance<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result>; + fn mutate<'a>( + &'a self, + mutation: Mutation, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result>; + fn attachments<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>>; + fn avatars<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>>; + fn task_inventory<'a>( + &'a self, + object: UUID, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result>; + fn resolve_inventory_avatar<'a>( + &'a self, + _name: &'a str, + _cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>> { + Box::pin(async { Ok(None) }) + } + fn apply_fake_fixture(&self, _fields: &[&str]) -> Result<(), &'static str> { + Err("inventory fixture requires fake-grid mode") + } +} + +#[derive(Default)] +struct FakeGrid { + root: UUID, + balance: i32, + entries: HashMap, + assets: HashMap<(UUID, i8), Vec>, + avatars: HashMap, + attachments: Vec, + tasks: HashMap, + cached_appearances: HashSet, + next_id: u64, +} + +pub(super) async fn execute( + backend: &B, + command: Command, + args: &[String], + from: UUID, + cancellation: CancellationToken, +) -> String { + match execute_inner(backend, command, args, from, cancellation).await { + Ok(output) => output, + Err(error) => error, + } +} + +async fn execute_inner( + backend: &B, + command: Command, + args: &[String], + from: UUID, + cancellation: CancellationToken, +) -> Result { + match command { + Command::Appearance => appearance(backend, args, cancellation).await, + Command::Attachments => attachments(backend, args, cancellation).await, + Command::AvatarInfo => avatar_info(backend, args, cancellation).await, + Command::Clone => clone_appearance(backend, args, cancellation).await, + Command::Wear => wear(backend, args, cancellation).await, + Command::BackupText => backup_text(backend, args, cancellation).await, + Command::Balance => balance(backend, args, cancellation).await, + Command::Cd => change_directory(backend, args, cancellation).await, + Command::CreateNotecard => create_notecard(backend, args, cancellation).await, + Command::DeleteFolder => delete_folder(backend, args, cancellation).await, + Command::Download => download(backend, args, false, cancellation).await, + Command::DumpOutfit => dump_outfit(backend, args, cancellation).await, + Command::EmptyLostAndFound => { + empty_system_folder(backend, args, FolderType::LostAndFound, cancellation).await + } + Command::EmptyTrash => { + empty_system_folder(backend, args, FolderType::Trash, cancellation).await + } + Command::GiveAll => give_all(backend, args, from, cancellation).await, + Command::Give => give_item(backend, args, cancellation).await, + Command::Inventory => inventory_tree(backend, args, cancellation).await, + Command::Ls => list_contents(backend, args, cancellation).await, + Command::ObjectInventory => object_inventory(backend, args, cancellation).await, + Command::Script => Ok("Command is handled by the client manager".into()), + Command::TaskRunning => task_running(backend, args, cancellation).await, + Command::UploadImage => upload_image(backend, args, cancellation).await, + Command::UploadScript => upload_script(backend, args, cancellation).await, + Command::ViewNote => view_notecard(backend, args, cancellation).await, + Command::Xfer => download(backend, args, true, cancellation).await, + } +} + +fn require_confirmation( + backend: &B, + args: &[String], + spending: bool, +) -> Result, String> { + let policy = lock(backend.inventory_state()).policy; + if !policy.mutations() { + return Err("Live mutation blocked; restart with --allow-live-mutations".into()); + } + if spending && !policy.spending() { + return Err("L$ transfer or upload blocked; restart with --allow-spending".into()); + } + if !args.iter().any(|arg| arg == "--confirm") { + return Err("Operation requires an explicit --confirm argument".into()); + } + Ok(args + .iter() + .filter(|arg| arg.as_str() != "--confirm") + .cloned() + .collect()) +} + +async fn appearance( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = require_confirmation(backend, args, false)?; + if args.len() > 1 || args.first().is_some_and(|arg| arg != "rebake") { + return Err("Usage: appearance [rebake] --confirm".into()); + } + backend + .mutate( + Mutation::RequestAppearance { + rebake: args.first().is_some_and(|arg| arg == "rebake"), + }, + cancellation, + ) + .await?; + Ok("Appearance sequence started".into()) +} + +async fn attachments( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if !args.is_empty() { + return Err("Usage: attachments".into()); + } + let attachments = backend.attachments(cancellation).await?; + let mut output = String::new(); + for attachment in &attachments { + output.push_str(&format!( + "[Attachment @ {}] LocalID: {} UUID: {} Offset: {}\n", + attachment.point, attachment.local_id, attachment.id, attachment.offset + )); + } + output.push_str(&format!("Found {} attachments", attachments.len())); + Ok(output) +} + +async fn avatar_info( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.len() != 2 { + return Err("Usage: avatarinfo [firstname] [lastname]".into()); + } + let target = format!("{} {}", args[0], args[1]); + let avatars = backend.avatars(cancellation.clone()).await?; + let resolved = avatars + .iter() + .find(|avatar| avatar.name == target) + .map(|avatar| avatar.id); + let resolved = match resolved { + Some(id) => Some(id), + None => { + backend + .resolve_inventory_avatar(&target, cancellation) + .await? + } + }; + let Some(mut avatar) = + resolved.and_then(|id| avatars.into_iter().find(|avatar| avatar.id == id)) + else { + return Ok(format!("No nearby avatar named {target}")); + }; + avatar.name = target; + let mut output = format!("{} ({})\n", avatar.name, avatar.id); + for (texture_type, id) in avatar.textures { + output.push_str(&format!("{texture_type}: {id}\n")); + } + Ok(output.trim_end().into()) +} + +async fn clone_appearance( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = require_confirmation(backend, args, false)?; + if args.is_empty() { + return Err("Usage: clone [name] --confirm".into()); + } + let target = args.join(" "); + let avatars = backend.avatars(cancellation.clone()).await?; + let resolved = avatars + .iter() + .find(|avatar| avatar.name.eq_ignore_ascii_case(&target)) + .map(|avatar| avatar.id) + .or_else(|| parse_uuid(&target)); + let resolved = match resolved { + Some(id) => Some(id), + None => { + backend + .resolve_inventory_avatar(&target, cancellation.clone()) + .await? + } + }; + let Some(id) = resolved else { + return Ok(format!("Could not find {target}")); + }; + let avatar = avatars + .into_iter() + .find(|avatar| avatar.id == id) + .unwrap_or(AvatarView { + id, + name: target.clone(), + textures: Vec::new(), + }); + backend + .mutate( + Mutation::CloneAppearance { avatar: avatar.id }, + cancellation, + ) + .await?; + Ok(format!("Cloned {} ({})", avatar.name, avatar.id)) +} + +async fn wear( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = require_confirmation(backend, args, false)?; + if args.is_empty() { + return Err("Usage: wear [outfit name] --confirm".into()); + } + let target = args.join(" "); + let snapshot = backend.snapshot(cancellation.clone()).await?; + let folder = find_path(&snapshot, snapshot.root, &target) + .ok_or_else(|| format!("Outfit path {target} not found"))?; + if !folder.is_folder() { + return Err(format!("Outfit path {target} is not a folder")); + } + let items: Vec<_> = children(&snapshot, folder.id) + .into_iter() + .filter(|entry| entry.item().is_some()) + .cloned() + .collect(); + backend + .mutate(Mutation::Wear { items }, cancellation) + .await?; + Ok(format!("Starting to change outfit to {target}")) +} + +async fn balance( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if !args.is_empty() { + return Err("Usage: balance".into()); + } + Ok(format!( + "Balance is L$: {}", + backend.balance(cancellation).await? + )) +} + +async fn change_directory( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.len() > 1 { + return Err("Usage: cd [path-to-folder]".into()); + } + let snapshot = backend.snapshot(cancellation).await?; + let current = lock(backend.inventory_state()) + .current_directory + .unwrap_or(snapshot.root); + let target = args.first().map_or("", String::as_str); + let folder = if target.is_empty() { + snapshot.entries.iter().find(|entry| entry.id == current) + } else { + find_path(&snapshot, current, target) + } + .ok_or_else(|| format!("{target} not found"))?; + if !folder.is_folder() { + return Err(format!("{} is not a folder.", folder.name)); + } + lock(backend.inventory_state()).current_directory = Some(folder.id); + Ok(format!("Current folder: {}", folder.name)) +} + +async fn list_contents( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.len() > 1 || args.first().is_some_and(|arg| arg != "-l") { + return Err("Usage: ls [-l]".into()); + } + let long = !args.is_empty(); + let snapshot = backend.snapshot(cancellation).await?; + let current = lock(backend.inventory_state()) + .current_directory + .unwrap_or(snapshot.root); + let mut output = String::new(); + for entry in children(&snapshot, current) { + if !long { + output.push_str(&entry.name); + output.push('\n'); + continue; + } + match entry.kind { + EntryKind::Folder(_) => { + output.push_str(&format!("d--------- {} {}\n", entry.id, entry.name)); + } + EntryKind::Item { + asset_id, + permissions, + .. + } => { + output.push_str(&format!( + "-{} {} {}\n AssetID: {}\n", + permission_string(permissions), + entry.id, + entry.name, + asset_id + )); + } + } + } + Ok(output.trim_end().into()) +} + +async fn inventory_tree( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if !args.is_empty() { + return Err("Usage: i".into()); + } + let snapshot = backend.snapshot(cancellation).await?; + let mut output = String::new(); + let mut visited = HashSet::new(); + let mut count = 0; + render_tree( + &snapshot, + snapshot.root, + 0, + &mut visited, + &mut count, + &mut output, + )?; + output.push_str(&format!("Returned {count} items.")); + Ok(output) +} + +fn render_tree( + snapshot: &Snapshot, + folder: UUID, + depth: usize, + visited: &mut HashSet, + count: &mut usize, + output: &mut String, +) -> Result<(), String> { + if !visited.insert(folder) { + return Err(format!("Inventory cycle detected at {folder}")); + } + for entry in children(snapshot, folder) { + *count += 1; + if *count > MAX_TREE_ENTRIES { + return Err("Inventory exceeds 100,000 entries".into()); + } + output.push_str(&format!( + "{}{} ({})\n", + " ".repeat(depth), + entry.name, + entry.id + )); + if entry.is_folder() { + render_tree(snapshot, entry.id, depth + 1, visited, count, output)?; + } + } + visited.remove(&folder); + Ok(()) +} + +fn children(snapshot: &Snapshot, parent: UUID) -> Vec<&Entry> { + let mut entries: Vec<_> = snapshot + .entries + .iter() + .filter(|entry| entry.parent == parent) + .collect(); + entries.sort_by(|left, right| { + right + .is_folder() + .cmp(&left.is_folder()) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + .then_with(|| left.id.cmp(&right.id)) + }); + entries +} + +fn find_path<'a>(snapshot: &'a Snapshot, current: UUID, path: &str) -> Option<&'a Entry> { + let mut folder = if path.starts_with('/') { + snapshot.root + } else { + current + }; + let mut found = snapshot.entries.iter().find(|entry| entry.id == folder)?; + for part in path.split('/') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + if found.id != snapshot.root { + folder = found.parent; + found = snapshot.entries.iter().find(|entry| entry.id == folder)?; + } + continue; + } + found = children(snapshot, folder) + .into_iter() + .find(|entry| entry.name == part || entry.id.to_string() == part)?; + folder = found.id; + } + Some(found) +} + +fn permission_string(permissions: Permissions) -> String { + let mask = permissions.owner_mask.0; + let mut output = String::with_capacity(3); + output.push(if mask & PermissionMask::COPY.0 != 0 { + 'C' + } else { + '-' + }); + output.push(if mask & PermissionMask::MODIFY.0 != 0 { + 'M' + } else { + '-' + }); + output.push(if mask & PermissionMask::TRANSFER.0 != 0 { + 'T' + } else { + '-' + }); + output +} + +async fn delete_folder( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = require_confirmation(backend, args, false)?; + if args.is_empty() { + return Err("Usage: deletefolder [path] --confirm".into()); + } + let target = args.join(" "); + let snapshot = backend.snapshot(cancellation.clone()).await?; + let folder = find_path(&snapshot, snapshot.root, &target) + .filter(|entry| entry.is_folder()) + .ok_or_else(|| format!("Folder {target} not found"))?; + if folder.id == snapshot.root { + return Err("The inventory root cannot be moved to Trash".into()); + } + if matches!(folder.kind, EntryKind::Folder(FolderType::Trash)) { + return Err("The Trash folder cannot be moved into itself".into()); + } + let name = folder.name.clone(); + backend + .mutate( + Mutation::MoveFolderToTrash { folder: folder.id }, + cancellation, + ) + .await?; + Ok(format!("Moved folder {name} to Trash")) +} + +async fn empty_system_folder( + backend: &B, + args: &[String], + folder: FolderType, + cancellation: CancellationToken, +) -> Result { + let args = require_confirmation(backend, args, false)?; + if !args.is_empty() { + return Err(match folder { + FolderType::Trash => "Usage: emptytrash --confirm", + _ => "Usage: emptylostandfound --confirm", + } + .into()); + } + backend + .mutate(Mutation::EmptySystemFolder(folder), cancellation) + .await?; + Ok(match folder { + FolderType::Trash => "Trash Emptied", + _ => "Lost And Found Emptied", + } + .into()) +} + +async fn give_item( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = require_confirmation(backend, args, false)?; + if args.len() < 2 { + return Err("Usage: give itemname --confirm".into()); + } + let recipient = parse_uuid(&args[0]).ok_or("First argument expected agent UUID.")?; + let target = args[1..].join(" "); + let snapshot = backend.snapshot(cancellation.clone()).await?; + let current = lock(backend.inventory_state()) + .current_directory + .unwrap_or(snapshot.root); + let matches: Vec<_> = children(&snapshot, current) + .into_iter() + .filter(|entry| entry.name == target || entry.id.to_string() == target) + .collect(); + if matches.is_empty() { + return Ok(format!("No inventory item named {target} found.")); + } + let mut output = String::new(); + for entry in matches { + let Some((_, asset_type, _, permissions)) = entry.item() else { + output.push_str(&format!("Unable to give folder {}\n", entry.name)); + continue; + }; + if permissions.owner_mask.0 & PermissionMask::TRANSFER.0 == 0 { + output.push_str(&format!( + "Unable to give non-transferable item {}\n", + entry.name + )); + continue; + } + backend + .mutate( + Mutation::GiveItem { + item: entry.clone(), + recipient, + }, + cancellation.clone(), + ) + .await?; + output.push_str(&format!("Gave {} ({asset_type:?})\n", entry.name)); + } + Ok(output.trim_end().into()) +} + +async fn give_all( + backend: &B, + args: &[String], + from: UUID, + cancellation: CancellationToken, +) -> Result { + let args = require_confirmation(backend, args, true)?; + if !args.is_empty() { + return Err("Usage: giveall --confirm (through an authorized IM)".into()); + } + if from == UUID::zero() { + return Ok("Unable to send money to console. This command only works when IMed.".into()); + } + let amount = backend.balance(cancellation.clone()).await?; + if amount <= 0 { + return Ok("No L$ available to transfer".into()); + } + backend + .mutate( + Mutation::GiveMoney { + recipient: from, + amount, + }, + cancellation, + ) + .await?; + Ok(format!("Gave ${amount} to {from}")) +} + +async fn download( + backend: &B, + args: &[String], + xfer: bool, + cancellation: CancellationToken, +) -> Result { + let (id, asset_type, path) = if xfer { + if args.is_empty() || args.len() > 2 { + return Err("Usage: xfer [uuid] [output]".into()); + } + let id = parse_uuid(&args[0]).ok_or("Usage: xfer [uuid] [output]")?; + let path = args + .get(1) + .map_or_else(|| PathBuf::from(format!("{id}.asset")), PathBuf::from); + (id, AssetType::Object, path) + } else { + if args.len() < 2 || args.len() > 3 { + return Err("Usage: download [uuid] [assetType] [output]".into()); + } + let id = parse_uuid(&args[0]).ok_or("Usage: download [uuid] [assetType] [output]")?; + let asset_type = + parse_asset_type(&args[1]).ok_or("Usage: download [uuid] [assetType] [output]")?; + let path = args.get(2).map_or_else( + || PathBuf::from(format!("{id}.{}", asset_extension(asset_type))), + PathBuf::from, + ); + (id, asset_type, path) + }; + let bytes = backend + .fetch_asset(id, asset_type, xfer, cancellation) + .await?; + write_bounded(&path, &bytes)?; + Ok(if xfer { + format!("Saved asset {}", path.display()) + } else { + format!("Saved {}", path.display()) + }) +} + +async fn object_inventory( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.len() != 1 { + return Err("Usage: objectinventory [objectID]".into()); + } + let object = parse_uuid(&args[0]).ok_or("Usage: objectinventory [objectID]")?; + let view = backend.task_inventory(object, cancellation).await?; + let mut output = String::new(); + for item in view.items { + output.push_str(&format!( + "[Item] Name: {} Desc: {} Type: {:?}\n", + item.name, item.description, item.asset_type + )); + } + if output.is_empty() { + output = format!("Task inventory for {} is empty", view.local_id); + } + Ok(output.trim_end().into()) +} + +async fn task_running( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let changing = args + .iter() + .any(|arg| matches!(arg.as_str(), "true" | "false")); + let args = if changing { + require_confirmation(backend, args, false)? + } else { + args.to_vec() + }; + if args.is_empty() || args.len() > 3 { + return Err("Usage: taskrunning objectID [[scriptName] true|false] [--confirm]".into()); + } + let object = parse_uuid(&args[0]) + .ok_or("Usage: taskrunning objectID [[scriptName] true|false] [--confirm]")?; + let (matching, set_to) = match args.as_slice() { + [_] => (None, None), + [_, value] if value == "true" || value == "false" => (Some(""), Some(value == "true")), + [_, name, value] if value == "true" || value == "false" => { + (Some(name.as_str()), Some(value == "true")) + } + _ => { + return Err("Usage: taskrunning objectID [[scriptName] true|false] [--confirm]".into()); + } + }; + let mut view = backend.task_inventory(object, cancellation.clone()).await?; + let mut output = String::new(); + for item in &mut view.items { + output.push_str(&format!( + "[Item] Name: {} Desc: {} Type: {:?}", + item.name, item.description, item.asset_type + )); + if matches!(item.asset_type, AssetType::LSLText | AssetType::LSLBytecode) { + let old = item.running.unwrap_or(false); + output.push_str(&format!(" IsRunning: {old}")); + if let (Some(pattern), Some(running)) = (matching, set_to) + && item.name.contains(pattern) + && old != running + { + backend + .mutate( + Mutation::SetScriptRunning { + object, + script: item.id, + running, + }, + cancellation.clone(), + ) + .await?; + output.push_str(&format!(" Setting {old} => {running}")); + } + } + output.push('\n'); + } + Ok(output.trim_end().into()) +} + +async fn backup_text( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args == ["status"] { + let progress = lock(backend.inventory_state()).backup.clone(); + return Ok(format!( + "{} backup: found {}, transferred {}, errors {}, bytes {}", + if progress.running { "Current" } else { "Last" }, + progress.found, + progress.transferred, + progress.errors, + progress.bytes + )); + } + if args == ["abort"] { + let mut state = lock(backend.inventory_state()); + if state.backup.running { + state.backup.abort_requested = true; + return Ok("Backup abort requested".into()); + } + return Ok("No backup is currently running".into()); + } + if args.len() != 2 || args[0] != "to" { + return Err("Usage: backuptext to [directory] | abort | status".into()); + } + let destination = PathBuf::from(&args[1]); + validate_output_path(&destination)?; + fs::create_dir_all(&destination) + .map_err(|error| format!("creating {}: {error}", destination.display()))?; + let snapshot = backend.snapshot(cancellation.clone()).await?; + let mut candidates = Vec::new(); + collect_text_assets( + &snapshot, + snapshot.root, + PathBuf::new(), + &mut HashSet::new(), + &mut candidates, + )?; + if candidates.len() > MAX_BACKUP_FILES { + return Err("Backup exceeds 10,000 text assets".into()); + } + lock(backend.inventory_state()).backup = BackupProgress { + running: true, + found: candidates.len(), + ..BackupProgress::default() + }; + let mut transferred = 0_usize; + let mut errors = 0_usize; + let mut total = 0_u64; + let result: Result = async { + for (entry, relative) in candidates { + if cancellation.is_cancellation_requested() + || lock(backend.inventory_state()).backup.abort_requested + { + return Err("Backup cancelled".into()); + } + let Some((asset_id, asset_type, _, _)) = entry.item() else { + continue; + }; + match backend + .fetch_asset(asset_id, asset_type, false, cancellation.clone()) + .await + { + Ok(bytes) => { + total = total + .checked_add(bytes.len() as u64) + .ok_or("Backup byte count overflow")?; + if total > MAX_BACKUP_BYTES { + return Err("Backup exceeds 512 MiB".into()); + } + let path = destination.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("creating {}: {error}", parent.display()))?; + } + if write_bounded(&path, &bytes).is_ok() { + transferred += 1; + } else { + errors += 1; + } + } + Err(_) => errors += 1, + } + let mut state = lock(backend.inventory_state()); + state.backup.transferred = transferred; + state.backup.errors = errors; + state.backup.bytes = total; + } + Ok(format!( + "backuptext completed: found {}, transferred {transferred}, errors {errors}, bytes {total}", + transferred + errors + )) + } + .await; + lock(backend.inventory_state()).backup.running = false; + result +} + +fn collect_text_assets<'a>( + snapshot: &'a Snapshot, + folder: UUID, + relative: PathBuf, + visited: &mut HashSet, + output: &mut Vec<(&'a Entry, PathBuf)>, +) -> Result<(), String> { + if !visited.insert(folder) { + return Err(format!("Inventory cycle detected at {folder}")); + } + for entry in children(snapshot, folder) { + match entry.kind { + EntryKind::Folder(_) => collect_text_assets( + snapshot, + entry.id, + relative.join(safe_component(&entry.name)), + visited, + output, + )?, + EntryKind::Item { asset_type, .. } + if matches!(asset_type, AssetType::LSLText | AssetType::Notecard) => + { + let extension = if asset_type == AssetType::LSLText { + "lsl" + } else { + "txt" + }; + output.push(( + entry, + relative.join(format!("{}.{}", safe_component(&entry.name), extension)), + )); + } + EntryKind::Item { .. } => {} + } + } + visited.remove(&folder); + Ok(()) +} + +async fn create_notecard( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = require_confirmation(backend, args, true)?; + if args.is_empty() || args.len() > 2 { + return Err("Usage: createnotecard filename.txt [itemid] --confirm".into()); + } + let path = Path::new(&args[0]); + let body = String::from_utf8(read_bounded(path)?) + .map_err(|_| format!("{} is not UTF-8 text", path.display()))?; + let snapshot = backend.snapshot(cancellation.clone()).await?; + let embedded = if let Some(id) = args.get(1) { + let id = parse_uuid(id).ok_or("Embedded item UUID is invalid")?; + Some( + snapshot + .entries + .iter() + .find(|entry| entry.id == id && entry.item().is_some()) + .ok_or_else(|| format!("Failed to fetch inventory item {id}"))?, + ) + } else { + None + }; + let data = encode_notecard(&body, embedded); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("notecard") + .to_owned(); + let result = backend + .mutate( + Mutation::CreateAsset { + name: name.clone(), + description: format!("{name} created by native TestClient"), + data, + asset_type: AssetType::Notecard, + inventory_type: InventoryType::NOTECARD, + }, + cancellation, + ) + .await?; + Ok(format!( + "Notecard successfully created, ItemID {} AssetID {}", + result.item_id, result.asset_id + )) +} + +fn encode_notecard(body: &str, embedded: Option<&Entry>) -> Vec { + let mut output = String::from("Linden text version 2\n{\nLLEmbeddedItems version 1\n{\n"); + if let Some(entry) = embedded { + let (asset_id, asset_type, inventory_type, permissions) = + entry.item().expect("embedded entry is an item"); + output.push_str("count 1\n{\next char index 0\n\tinv_item\t0\n\t{\n"); + output.push_str(&format!( + "\t\titem_id\t{}\n\t\tparent_id\t{}\n\tpermissions 0\n\t{{\n\t\tbase_mask\t{:08x}\n\t\towner_mask\t{:08x}\n\t\tgroup_mask\t{:08x}\n\t\teveryone_mask\t{:08x}\n\t\tnext_owner_mask\t{:08x}\n\t\tcreator_id\t{}\n\t\towner_id\t{}\n\t\tlast_owner_id\t{}\n\t\tgroup_id\t{}\n\t}}\n\t\tasset_id\t{asset_id}\n\t\ttype\t{}\n\t\tinv_type\t{}\n\t\tflags\t00000000\n\tsale_info\t0\n\t{{\n\t\tsale_type\tnot\n\t\tsale_price\t0\n\t}}\n\t\tname\t{}|\n\t\tdesc\t{}|\n\t\tcreation_date\t0\n\t}}\n}}\n", + entry.id, + entry.parent, + permissions.base_mask.0, + permissions.owner_mask.0, + permissions.group_mask.0, + permissions.everyone_mask.0, + permissions.next_owner_mask.0, + entry.creator, + entry.owner, + entry.last_owner, + entry.group, + asset_type_name(asset_type), + inventory_type_name(inventory_type), + entry.name.replace('|', "_"), + entry.description.replace('|', "_") + )); + } else { + output.push_str("count 0\n"); + } + output.push_str("}\n"); + output.push_str(&format!("Text length {}\n{}}}\n", body.len(), body)); + output.into_bytes() +} + +async fn upload_script( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = require_confirmation(backend, args, true)?; + if args.is_empty() { + return Err("Usage: uploadscript filename.lsl --confirm".into()); + } + let path = PathBuf::from(args.join(" ")); + let data = read_bounded(&path)?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("script.lsl") + .to_owned(); + let result = backend + .mutate( + Mutation::CreateAsset { + name: name.clone(), + description: format!("{name} created by native TestClient"), + data, + asset_type: AssetType::LSLText, + inventory_type: InventoryType::LSL, + }, + cancellation, + ) + .await?; + Ok(format!( + "Filename: {} successfully uploaded, ItemID {} AssetID {} compilation requested", + path.display(), + result.item_id, + result.asset_id + )) +} + +async fn upload_image( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = require_confirmation(backend, args, true)?; + if args.len() != 3 { + return Err("Usage: uploadimage [inventoryname] [timeout] [filename] --confirm".into()); + } + let timeout_ms = args[1] + .parse::() + .ok() + .filter(|timeout| (1..=300_000).contains(timeout)) + .ok_or("Upload timeout must be between 1 and 300000 milliseconds")?; + let path = Path::new(&args[2]); + let data = image_to_j2k(path)?; + let future = backend.mutate( + Mutation::CreateAsset { + name: args[0].clone(), + description: "Uploaded with native TestClient".into(), + data, + asset_type: AssetType::Texture, + inventory_type: InventoryType::TEXTURE, + }, + cancellation, + ); + let result = tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), future) + .await + .map_err(|_| "Texture upload timed out".to_owned())??; + Ok(format!("Texture upload succeeded: {}", result.asset_id)) +} + +fn image_to_j2k(path: &Path) -> Result, String> { + let bytes = read_bounded(path)?; + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if matches!(extension.as_str(), "jp2" | "j2c" | "j2k") { + J2kCodec::decode_bytes(&bytes, J2kDecodeOptions::default()) + .map_err(|_| "Invalid JPEG 2000 image".to_owned())?; + return Ok(bytes); + } + let mut image = if matches!(extension.as_str(), "tga" | "targa") { + decode_tga(&bytes)? + } else { + SkiaTextureCodec::new() + .map_err(|_| "Could not initialize the image decoder".to_owned())? + .decode(Box::new(Cursor::new(bytes))) + .map_err(|_| "Failed to decode image".to_owned())? + }; + let mut width = image.width; + let mut height = image.height; + if !is_power_of_two(width) || !is_power_of_two(height) { + image + .resize_bilinear(256, 256) + .map_err(|_| "Failed to resize image to 256x256".to_owned())?; + width = 256; + height = 256; + } + if width > 1024 || height > 1024 { + image + .resize_bilinear(width.min(1024), height.min(1024)) + .map_err(|_| "Failed to limit image dimensions".to_owned())?; + } + J2kCodec::encode( + &image, + J2kEncodeOptions::default().with_compression(J2kCompression::Lossy { + compression_ratio: 10.0, + }), + ) + .map_err(|_| "Failed to compress image to JPEG2000".to_owned()) +} + +const fn is_power_of_two(value: i32) -> bool { + value > 0 && value.cast_unsigned().is_power_of_two() +} + +async fn view_notecard( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.len() != 1 { + return Err("Usage: viewnote [notecard item uuid]".into()); + } + let item_id = parse_uuid(&args[0]).ok_or("First argument expected item UUID.")?; + let snapshot = backend.snapshot(cancellation.clone()).await?; + let item = snapshot + .entries + .iter() + .find(|entry| entry.id == item_id) + .ok_or("Cannot find item in inventory store, use 'i' to populate store")?; + let (asset_id, asset_type, _, _) = item.item().ok_or("Inventory UUID is a folder")?; + if asset_type != AssetType::Notecard { + return Err("Inventory item is not a notecard".into()); + } + let bytes = backend + .fetch_asset(asset_id, asset_type, false, cancellation) + .await?; + let body = decode_notecard_body(&bytes)?; + Ok(format!("Raw Notecard Data:\n {body}")) +} + +fn decode_notecard_body(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| "Notecard is not UTF-8")?; + let marker = "Text length "; + let start = text.find(marker).ok_or("Notecard text length is missing")? + marker.len(); + let line_end = text[start..] + .find('\n') + .map(|offset| start + offset) + .ok_or("Notecard text length is malformed")?; + let length = text[start..line_end] + .trim() + .parse::() + .map_err(|_| "Notecard text length is invalid")?; + let body_start = line_end + 1; + let body_end = body_start + .checked_add(length) + .ok_or("Notecard text length overflow")?; + text.get(body_start..body_end) + .map(str::to_owned) + .ok_or_else(|| "Notecard body is truncated".into()) +} + +async fn dump_outfit( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.is_empty() || args.len() > 2 { + return Err("Usage: dumpoutfit [avatar-uuid] [directory]".into()); + } + let target = parse_uuid(&args[0]).ok_or("Usage: dumpoutfit [avatar-uuid] [directory]")?; + let directory = args + .get(1) + .map_or_else(|| PathBuf::from("."), PathBuf::from); + validate_output_path(&directory)?; + fs::create_dir_all(&directory) + .map_err(|error| format!("creating {}: {error}", directory.display()))?; + let avatar = backend + .avatars(cancellation.clone()) + .await? + .into_iter() + .find(|avatar| avatar.id == target) + .ok_or_else(|| format!("Couldn't find avatar {target}"))?; + let mut downloaded = Vec::new(); + let mut seen = HashSet::new(); + for (texture_type, texture_id) in avatar.textures { + if texture_id == UUID::zero() || !seen.insert(texture_id) { + continue; + } + let bytes = backend + .fetch_asset(texture_id, AssetType::Texture, false, cancellation.clone()) + .await?; + let jp2 = directory.join(format!("{texture_id}.jp2")); + write_bounded(&jp2, &bytes)?; + let image = J2kCodec::decode_bytes(&bytes, J2kDecodeOptions::default()) + .map_err(|_| format!("Failed to decode outfit texture {texture_id}"))?; + let tga = directory.join(format!("{texture_id}.tga")); + write_bounded(&tga, &encode_tga(&image)?)?; + downloaded.push(texture_type); + } + Ok(format!("Downloaded {}", downloaded.join(" ")) + .trim_end() + .into()) +} + +fn decode_tga(bytes: &[u8]) -> Result { + if bytes.len() < 18 { + return Err("TGA header is truncated".into()); + } + let id_length = usize::from(bytes[0]); + if bytes[1] != 0 || !matches!(bytes[2], 2 | 3) { + return Err("Only uncompressed true-color and grayscale TGA images are supported".into()); + } + let width = i32::from(u16::from_le_bytes([bytes[12], bytes[13]])); + let height = i32::from(u16::from_le_bytes([bytes[14], bytes[15]])); + let depth = bytes[16]; + let grayscale = bytes[2] == 3; + if (grayscale && depth != 8) || (!grayscale && !matches!(depth, 24 | 32)) { + return Err("Unsupported TGA pixel depth".into()); + } + let channels = if grayscale { + ManagedImageImageChannels::GRAY + } else if depth == 32 { + ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA + } else { + ManagedImageImageChannels::COLOR + }; + let mut image = ManagedImage::new(width, height, channels) + .map_err(|_| "Invalid TGA dimensions".to_owned())?; + let pixel_count = usize::try_from(width) + .ok() + .and_then(|width| { + usize::try_from(height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .ok_or("Invalid TGA dimensions")?; + let stride = usize::from(depth / 8); + let start = 18_usize + .checked_add(id_length) + .ok_or("TGA offset overflow")?; + let end = start + .checked_add(pixel_count.checked_mul(stride).ok_or("TGA size overflow")?) + .ok_or("TGA size overflow")?; + let pixels = bytes.get(start..end).ok_or("TGA pixel data is truncated")?; + let top_origin = bytes[17] & 0x20 != 0; + let right_origin = bytes[17] & 0x10 != 0; + let width_usize = usize::try_from(width).map_err(|_| "Invalid TGA width")?; + let height_usize = usize::try_from(height).map_err(|_| "Invalid TGA height")?; + for source_y in 0..height_usize { + let target_y = if top_origin { + source_y + } else { + height_usize - source_y - 1 + }; + for source_x in 0..width_usize { + let target_x = if right_origin { + width_usize - source_x - 1 + } else { + source_x + }; + let source = (source_y * width_usize + source_x) * stride; + let target = target_y * width_usize + target_x; + if grayscale { + image.red[target] = pixels[source]; + } else { + image.blue[target] = pixels[source]; + image.green[target] = pixels[source + 1]; + image.red[target] = pixels[source + 2]; + if depth == 32 { + image.alpha[target] = pixels[source + 3]; + } + } + } + } + Ok(image) +} + +fn encode_tga(image: &ManagedImage) -> Result, String> { + image + .validate() + .map_err(|_| "Invalid decoded image layout")?; + let width = u16::try_from(image.width).map_err(|_| "Image is too wide for TGA")?; + let height = u16::try_from(image.height).map_err(|_| "Image is too tall for TGA")?; + let pixel_count = usize::from(width) * usize::from(height); + let mut output = Vec::with_capacity(18 + pixel_count * 4); + output.extend_from_slice(&[0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + output.extend_from_slice(&width.to_le_bytes()); + output.extend_from_slice(&height.to_le_bytes()); + output.extend_from_slice(&[32, 0x28]); + let gray = image.channels.contains(ManagedImageImageChannels::GRAY); + for index in 0..pixel_count { + let red = image.red[index]; + let green = if gray { red } else { image.green[index] }; + let blue = if gray { red } else { image.blue[index] }; + let alpha = image.alpha.get(index).copied().unwrap_or(u8::MAX); + output.extend_from_slice(&[blue, green, red, alpha]); + } + Ok(output) +} + +fn parse_uuid(value: &str) -> Option { + UUID::parse(value.to_owned()).ok() +} + +fn parse_asset_type(value: &str) -> Option { + let normalized = value.to_ascii_lowercase().replace(['_', '-'], ""); + Some(match normalized.as_str() { + "texture" | "0" => AssetType::Texture, + "sound" | "1" => AssetType::Sound, + "callingcard" | "2" => AssetType::CallingCard, + "landmark" | "3" => AssetType::Landmark, + "script" | "4" => AssetType::Script, + "clothing" | "5" => AssetType::Clothing, + "object" | "6" => AssetType::Object, + "notecard" | "7" => AssetType::Notecard, + "folder" | "8" => AssetType::Folder, + "lsltext" | "lsl" | "10" => AssetType::LSLText, + "lslbytecode" | "11" => AssetType::LSLBytecode, + "texturetga" | "12" => AssetType::TextureTGA, + "bodypart" | "13" => AssetType::Bodypart, + "soundwav" | "17" => AssetType::SoundWAV, + "imagetga" | "18" => AssetType::ImageTGA, + "imagejpeg" | "jpeg" | "19" => AssetType::ImageJPEG, + "animation" | "20" => AssetType::Animation, + "gesture" | "21" => AssetType::Gesture, + "simstate" | "22" => AssetType::Simstate, + "link" | "24" => AssetType::Link, + "linkfolder" | "25" => AssetType::LinkFolder, + "widget" | "40" => AssetType::Widget, + "person" | "45" => AssetType::Person, + "mesh" | "49" => AssetType::Mesh, + "settings" | "56" => AssetType::Settings, + "material" | "57" => AssetType::Material, + _ => return None, + }) +} + +fn parse_inventory_type(value: &str) -> Option { + let normalized = value.to_ascii_lowercase().replace(['_', '-'], ""); + Some(match normalized.as_str() { + "texture" | "0" => InventoryType::TEXTURE, + "sound" | "1" => InventoryType::SOUND, + "callingcard" | "2" => InventoryType::CALLING_CARD, + "landmark" | "3" => InventoryType::LANDMARK, + "object" | "6" => InventoryType::OBJECT, + "notecard" | "7" => InventoryType::NOTECARD, + "category" | "folder" | "8" => InventoryType::CATEGORY, + "rootcategory" | "9" => InventoryType::ROOT_CATEGORY, + "lsl" | "script" | "10" => InventoryType::LSL, + "snapshot" | "15" => InventoryType::SNAPSHOT, + "attachment" | "17" => InventoryType::ATTACHMENT, + "wearable" | "18" => InventoryType::WEARABLE, + "animation" | "19" => InventoryType::ANIMATION, + "gesture" | "20" => InventoryType::GESTURE, + "mesh" | "22" => InventoryType::MESH, + "settings" | "25" => InventoryType::SETTINGS, + "material" | "26" => InventoryType::MATERIAL, + _ => return None, + }) +} + +fn parse_folder_type(value: &str) -> Option { + Some( + match value.to_ascii_lowercase().replace(['_', '-'], "").as_str() { + "none" | "-1" => FolderType::None, + "root" | "8" => FolderType::Root, + "texture" | "0" => FolderType::Texture, + "object" | "6" => FolderType::Object, + "notecard" | "7" => FolderType::Notecard, + "lsltext" | "script" | "10" => FolderType::LSLText, + "trash" | "14" => FolderType::Trash, + "lostandfound" | "16" => FolderType::LostAndFound, + "outfit" | "47" => FolderType::Outfit, + "myoutfits" | "48" => FolderType::MyOutfits, + _ => return None, + }, + ) +} + +const fn asset_extension(asset_type: AssetType) -> &'static str { + match asset_type { + AssetType::Texture => "jp2", + AssetType::Sound | AssetType::SoundWAV => "ogg", + AssetType::LSLText => "lsl", + AssetType::Notecard => "txt", + AssetType::ImageJPEG => "jpg", + AssetType::ImageTGA | AssetType::TextureTGA => "tga", + _ => "asset", + } +} + +const fn asset_type_name(value: AssetType) -> &'static str { + match value { + AssetType::Texture => "texture", + AssetType::Sound => "sound", + AssetType::CallingCard => "callcard", + AssetType::Landmark => "landmark", + AssetType::Script | AssetType::LSLText | AssetType::LSLBytecode => "lsltext", + AssetType::Clothing => "clothing", + AssetType::Object => "object", + AssetType::Notecard => "notecard", + AssetType::Bodypart => "bodypart", + AssetType::Animation => "animation", + AssetType::Gesture => "gesture", + _ => "unknown", + } +} + +const fn inventory_type_name(value: InventoryType) -> &'static str { + if value.0 == InventoryType::TEXTURE.0 { + "texture" + } else if value.0 == InventoryType::SOUND.0 { + "sound" + } else if value.0 == InventoryType::CALLING_CARD.0 { + "callcard" + } else if value.0 == InventoryType::LANDMARK.0 { + "landmark" + } else if value.0 == InventoryType::OBJECT.0 { + "object" + } else if value.0 == InventoryType::NOTECARD.0 { + "notecard" + } else if value.0 == InventoryType::LSL.0 { + "lsl" + } else if value.0 == InventoryType::WEARABLE.0 { + "wearable" + } else if value.0 == InventoryType::ANIMATION.0 { + "animation" + } else if value.0 == InventoryType::GESTURE.0 { + "gesture" + } else { + "unknown" + } +} + +fn safe_component(value: &str) -> String { + let value: String = value + .chars() + .map(|character| match character { + '/' | '\\' | '\0' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + character if character.is_control() => '_', + character => character, + }) + .collect(); + let value = value.trim_matches([' ', '.']); + if value.is_empty() || matches!(value, "." | "..") { + "unnamed".into() + } else { + value.into() + } +} + +fn read_bounded(path: &Path) -> Result, String> { + let file = File::open(path).map_err(|error| format!("opening {}: {error}", path.display()))?; + let length = file + .metadata() + .map_err(|error| format!("reading {} metadata: {error}", path.display()))? + .len(); + if length > MAX_ASSET_BYTES { + return Err(format!("{} exceeds 64 MiB", path.display())); + } + let mut bytes = Vec::with_capacity(usize::try_from(length).unwrap_or(0)); + file.take(MAX_ASSET_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("reading {}: {error}", path.display()))?; + if bytes.len() as u64 > MAX_ASSET_BYTES { + return Err(format!("{} exceeds 64 MiB", path.display())); + } + Ok(bytes) +} + +fn write_bounded(path: &Path, bytes: &[u8]) -> Result<(), String> { + if bytes.len() as u64 > MAX_ASSET_BYTES { + return Err("Asset exceeds 64 MiB".into()); + } + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .map_err(|error| format!("creating {}: {error}", parent.display()))?; + } + validate_output_path(path)?; + let mut file = + File::create(path).map_err(|error| format!("creating {}: {error}", path.display()))?; + file.write_all(bytes) + .map_err(|error| format!("writing {}: {error}", path.display())) +} + +fn validate_output_path(path: &Path) -> Result<(), String> { + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + Err("Output paths may not contain parent traversal".into()) + } else { + Ok(()) + } +} + +impl Backend for FakeBackend { + fn inventory_state(&self) -> &Mutex { + &self.inventory_state + } + + fn snapshot<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("Inventory request cancelled".into()); + } + let state = lock(&self.inventory_state); + let mut entries: Vec<_> = state.fake.entries.values().cloned().collect(); + entries.sort_by_key(|entry| entry.id); + Ok(Snapshot { + root: state.fake.root, + entries, + }) + }) + } + + fn fetch_asset<'a>( + &'a self, + id: UUID, + asset_type: AssetType, + xfer: bool, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("Asset request cancelled".into()); + } + self.record(format!( + "CALL {} {id} {asset_type:?}", + if xfer { "xfer" } else { "asset-download" } + )); + lock(&self.inventory_state) + .fake + .assets + .get(&(id, asset_type as i8)) + .cloned() + .ok_or_else(|| format!("Asset {id} ({asset_type:?}) is not in the fake fixture")) + }) + } + + fn balance<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("Balance request cancelled".into()); + } + self.record("CALL balance".into()); + Ok(lock(&self.inventory_state).fake.balance) + }) + } + + fn mutate<'a>( + &'a self, + mutation: Mutation, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("Mutation cancelled".into()); + } + let mut state = lock(&self.inventory_state); + let grid = &mut state.fake; + match mutation { + Mutation::MoveFolderToTrash { folder } => { + let trash = grid + .entries + .values() + .find(|entry| matches!(entry.kind, EntryKind::Folder(FolderType::Trash))) + .map(|entry| entry.id) + .ok_or("Trash fixture is missing")?; + grid.entries + .get_mut(&folder) + .ok_or("Folder fixture is missing")? + .parent = trash; + drop(state); + self.record(format!("CALL inventory-move-folder {folder} {trash}")); + } + Mutation::EmptySystemFolder(folder_type) => { + let folder = grid.entries.values().find(|entry| matches!(entry.kind, EntryKind::Folder(value) if value == folder_type)).map(|entry| entry.id).ok_or("System folder fixture is missing")?; + let mut remove = HashSet::new(); + let mut stack = vec![folder]; + while let Some(parent) = stack.pop() { + let children: Vec<_> = grid + .entries + .values() + .filter(|entry| entry.parent == parent) + .map(|entry| entry.id) + .collect(); + for child in children { + remove.insert(child); + stack.push(child); + } + } + grid.entries.retain(|id, _| !remove.contains(id)); + drop(state); + self.record(format!("CALL inventory-empty {folder_type:?}")); + } + Mutation::GiveItem { item, recipient } => { + drop(state); + self.record(format!("CALL inventory-give {} {recipient}", item.id)); + } + Mutation::GiveMoney { recipient, amount } => { + if amount <= 0 || grid.balance < amount { + return Err("Insufficient fake-grid balance".into()); + } + grid.balance -= amount; + drop(state); + self.record(format!("CALL give-money {recipient} {amount}")); + return Ok(MutationResult::default()); + } + Mutation::CreateAsset { + name, + description, + data, + asset_type, + inventory_type, + } => { + grid.next_id = grid.next_id.saturating_add(1); + let asset_id = UUID::new_with_u_int64(0xa550_0000_0000_0000 | grid.next_id) + .map_err(|_| "creating fake asset UUID")?; + grid.next_id = grid.next_id.saturating_add(1); + let item_id = UUID::new_with_u_int64(0x1ee0_0000_0000_0000 | grid.next_id) + .map_err(|_| "creating fake item UUID")?; + let parent = grid.entries.values().find(|entry| matches!(entry.kind, EntryKind::Folder(kind) if folder_for_asset(asset_type) == kind)).map_or(grid.root, |entry| entry.id); + grid.assets.insert((asset_id, asset_type as i8), data); + grid.entries.insert( + item_id, + Entry { + id: item_id, + parent, + name: name.clone(), + description, + creator: self.id, + owner: self.id, + last_owner: self.id, + group: UUID::zero(), + kind: EntryKind::Item { + asset_id, + asset_type, + inventory_type, + permissions: Permissions::full_permissions(), + }, + }, + ); + drop(state); + self.record(format!("CALL inventory-upload {asset_type:?} item={item_id} asset={asset_id} name={name}")); + return Ok(MutationResult { item_id, asset_id }); + } + Mutation::Wear { items } => { + let ids: Vec<_> = items.iter().map(|item| item.id.to_string()).collect(); + drop(state); + self.record(format!("CALL appearance-wear {}", ids.join(","))); + } + Mutation::RequestAppearance { rebake } => { + drop(state); + self.record(format!("CALL appearance-set rebake={rebake}")); + } + Mutation::CloneAppearance { avatar } => { + if !grid.cached_appearances.contains(&avatar) { + return Err(format!("Don't have an appearance cached for {avatar}")); + } + drop(state); + self.record(format!("CALL appearance-clone {avatar}")); + } + Mutation::SetScriptRunning { + object, + script, + running, + } => { + let task = grid + .tasks + .get_mut(&object) + .ok_or("Task fixture is missing")?; + let item = task + .items + .iter_mut() + .find(|item| item.id == script) + .ok_or("Task script fixture is missing")?; + item.running = Some(running); + drop(state); + self.record(format!( + "CALL task-script-running {object} {script} {running}" + )); + } + } + Ok(MutationResult::default()) + }) + } + + fn attachments<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + Err("Attachment request cancelled".into()) + } else { + Ok(lock(&self.inventory_state).fake.attachments.clone()) + } + }) + } + fn avatars<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + Err("Avatar request cancelled".into()) + } else { + Ok(lock(&self.inventory_state) + .fake + .avatars + .values() + .cloned() + .collect()) + } + }) + } + fn task_inventory<'a>( + &'a self, + object: UUID, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("Task inventory request cancelled".into()); + } + lock(&self.inventory_state) + .fake + .tasks + .get(&object) + .cloned() + .ok_or_else(|| format!("Couldn't find object {object}")) + }) + } + fn resolve_inventory_avatar<'a>( + &'a self, + name: &'a str, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("Avatar lookup cancelled".into()); + } + Ok(lock(&self.inventory_state) + .fake + .avatars + .values() + .find(|avatar| avatar.name.eq_ignore_ascii_case(name)) + .map(|avatar| avatar.id)) + }) + } + fn apply_fake_fixture(&self, fields: &[&str]) -> Result<(), &'static str> { + apply_fixture(self, fields) + } +} + +const fn folder_for_asset(asset_type: AssetType) -> FolderType { + match asset_type { + AssetType::Texture => FolderType::Texture, + AssetType::Notecard => FolderType::Notecard, + AssetType::LSLText | AssetType::LSLBytecode => FolderType::LSLText, + AssetType::Object => FolderType::Object, + _ => FolderType::None, + } +} + +impl Backend for LiveBackend { + fn inventory_state(&self) -> &Mutex { + &self.inventory_state + } + + fn snapshot<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("Inventory request cancelled".into()); + } + let manager = lock(&self.client).inventory(); + let inventory = manager.store().ok_or("Inventory store is unavailable")?; + let root = inventory + .root_folder() + .ok_or("Inventory root is unavailable")?; + let owner = inventory.owner(); + let mut entries = vec![entry_from_folder(&root)]; + let mut pending = vec![root.base.uuid()]; + let mut visited = HashSet::new(); + while let Some(folder) = pending.pop() { + if !visited.insert(folder) { + return Err(format!("Inventory cycle detected at {folder}")); + } + let contents = manager + .folder_contents( + folder, + owner, + true, + true, + libremetaverse::InventorySortOrder::BY_NAME, + Some(cancellation.clone()), + Some(false), + ) + .await + .map_err(|_| format!("Fetching inventory folder {folder}"))?; + for object in contents { + if entries.len() >= MAX_TREE_ENTRIES { + return Err("Inventory exceeds 100,000 entries".into()); + } + if let Some(item) = object.inventory_item() { + entries.push(entry_from_item(item)); + } else if let Some(child) = object + .as_any() + .downcast_ref::() + { + entries.push(entry_from_folder(child)); + pending.push(child.base.uuid()); + } + } + } + Ok(Snapshot { + root: root.base.uuid(), + entries, + }) + }) + } + + fn fetch_asset<'a>( + &'a self, + id: UUID, + asset_type: AssetType, + xfer: bool, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>> { + Box::pin(async move { + if !xfer { + let manager = lock(&self.client).assets(); + let asset = manager + .request_asset_with_uuid_asset_type_boolean_cancellation_token( + id, + asset_type, + false, + Some(cancellation), + ) + .await + .map_err(|_| format!("Asset request failed for {id}"))? + .ok_or_else(|| format!("Asset {id} was not returned"))?; + return Ok(asset.asset_data); + } + let manager = lock(&self.client).assets(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + let callback = Arc::clone(&sender); + let subscription = manager.subscribe_xfer_received(Arc::new(move |event| { + let xfer = event.xfer(); + if xfer.v_file_id == id + && let Some(sender) = lock(&callback).take() + { + let _ = sender.send(xfer.base.asset_data); + } + })); + manager + .request_asset_xfer(String::new(), false, true, id, asset_type, false) + .map_err(|_| format!("Could not start Xfer for {id}"))?; + let result = tokio::select! { + () = cancellation.cancelled() => Err("Xfer cancelled".into()), + () = tokio::time::sleep(Duration::from_secs(30)) => Err("Xfer timed out".into()), + result = receiver => result.map_err(|_| "Xfer completion channel closed".into()), + }; + drop(subscription); + result + }) + } + + fn balance<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + let callback = Arc::clone(&sender); + let subscription = self.with_agent(|agent| { + agent.subscribe_money_balance(Arc::new(move |event| { + if let Some(sender) = lock(&callback).take() { + let _ = sender.send(event.balance()); + } + })) + }); + self.with_agent(|agent| agent.request_balance()) + .map_err(|_| "Could not request balance")?; + let result = tokio::select! { + () = cancellation.cancelled() => Err("Balance request cancelled".into()), + () = tokio::time::sleep(Duration::from_secs(20)) => Err("Balance request timed out".into()), + result = receiver => result.map_err(|_| "Balance completion channel closed".into()), + }; + drop(subscription); + result + }) + } + + fn mutate<'a>( + &'a self, + mutation: Mutation, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + match mutation { + Mutation::MoveFolderToTrash { folder } => { + let manager = lock(&self.client).inventory(); + let trash = manager + .find_folder_for_type_with_folder_type(FolderType::Trash) + .map_err(|_| "Trash folder is unavailable")?; + manager + .move_folder_with_uuid_uuid_cancellation_token_4497f975( + folder, + trash, + Some(cancellation), + ) + .await + .map_err(|_| "Moving folder to Trash failed")?; + } + Mutation::EmptySystemFolder(folder) => { + let manager = lock(&self.client).inventory(); + match folder { + FolderType::Trash => manager.empty_trash(Some(cancellation)).await, + FolderType::LostAndFound => { + manager.empty_lost_and_found(Some(cancellation)).await + } + _ => return Err("Unsupported system folder".into()), + } + .map_err(|_| "Emptying system folder failed")?; + } + Mutation::GiveItem { item, recipient } => { + let (_, asset_type, _, _) = + item.item().ok_or("Folder cannot be given as an item")?; + lock(&self.client) + .inventory() + .give_item(item.id, item.name, asset_type, recipient, true) + .map_err(|_| "Giving inventory item failed")?; + } + Mutation::GiveMoney { recipient, amount } => { + self.with_agent(|agent| { + agent.give_money( + recipient, + amount, + "TestClient giveall".into(), + libremetaverse::MoneyTransactionType::Gift, + libremetaverse::TransactionFlags::NONE, + ) + }) + .map_err(|_| "L$ transfer failed")?; + } + Mutation::CreateAsset { + name, + description, + data, + asset_type, + inventory_type, + } => { + let transaction = + UUID::random().map_err(|_| "Could not create upload transaction")?; + let (assets, inventory) = { + let client = lock(&self.client); + (client.assets(), client.inventory()) + }; + let asset_id = assets + .request_upload_with_asset_type_bytes_boolean_uuid_cancellation_token( + asset_type, + data, + false, + transaction, + Some(cancellation.clone()), + ) + .await + .map_err(|_| "Asset upload failed")?; + let folder = inventory + .find_folder_for_type_with_asset_type(asset_type) + .map_err(|_| "Upload inventory folder is unavailable")?; + let item = inventory.create_item_with_uuid_string_string_asset_type_uuid_inventory_type_permission_mask_cancellation_token(folder, name, description, asset_type, transaction, inventory_type, PermissionMask::ALL, Some(cancellation)).await.map_err(|_| "Creating uploaded inventory item failed")?.ok_or("Upload completed without an inventory item")?; + return Ok(MutationResult { + item_id: item.base.uuid(), + asset_id, + }); + } + Mutation::Wear { items } => { + let (appearance, store) = { + let client = lock(&self.client); + ( + client.appearance(), + client + .inventory() + .store() + .ok_or("Inventory store is unavailable")?, + ) + }; + let items: Vec = items.into_iter().map(|entry| store.get_value_or_default_with_uuid_a7c63fbe::(entry.id).map_err(|_| "Reading outfit item").and_then(|item| item.ok_or("Outfit item is missing from the store"))).collect::>()?; + appearance + .replace_outfit_with_list(items) + .await + .map_err(|_| "Replacing outfit failed")?; + } + Mutation::RequestAppearance { rebake } => { + let appearance = lock(&self.client).appearance(); + appearance + .request_set_appearance(Some(rebake)) + .await + .map_err(|_| "Appearance request failed")?; + } + Mutation::CloneAppearance { avatar } => { + self.send_cloned_appearance(avatar)?; + } + Mutation::SetScriptRunning { + object, + script, + running, + } => { + lock(&self.client) + .inventory() + .request_set_script_running(object, script, running) + .map_err(|_| "Setting task script state failed")?; + } + } + Ok(MutationResult::default()) + }) + } + + fn attachments<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("Attachment request cancelled".into()); + } + let simulator = self.network.current_sim().ok_or("No current simulator")?; + let avatar_local = simulator + .global_to_local_id + .read() + .map_err(|_| "Locking simulator object index")? + .get(&self.id) + .copied(); + let primitives = simulator + .objects_primitives + .read() + .map_err(|_| "Locking simulator primitives")?; + Ok(primitives + .values() + .filter(|primitive| { + primitive.is_attachment + && (primitive.owner_id == self.id + || avatar_local == Some(primitive.parent_id)) + }) + .map(|primitive| AttachmentView { + point: format!("{:?}", primitive.prim_data.attachment_point()), + local_id: primitive.local_id, + id: primitive.id, + offset: primitive.position.to_string(), + }) + .collect()) + }) + } + + fn avatars<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("Avatar request cancelled".into()); + } + let appearances = lock(&self.inventory_state).live_appearances.clone(); + let mut avatars = Vec::new(); + for (id, appearance) in appearances { + let mut textures = Vec::new(); + if let Ok(entry) = libremetaverse::PrimitiveTextureEntry::new_with_bytes_int32_int32( + appearance.texture_entry.clone(), + 0, + i32::try_from(appearance.texture_entry.len()).unwrap_or(0), + ) { + for index in 0..45_u32 { + if let Ok(Some(face)) = entry.get_face(index) { + textures.push((format!("Texture{index}"), face.texture_id())); + } + } + } + avatars.push(AvatarView { + id, + name: id.to_string(), + textures, + }); + } + Ok(avatars) + }) + } + + fn task_inventory<'a>( + &'a self, + object: UUID, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + let simulator = self.network.current_sim().ok_or("No current simulator")?; + let local_id = simulator + .global_to_local_id + .read() + .map_err(|_| "Locking simulator object index")? + .get(&object) + .copied() + .ok_or_else(|| format!("Couldn't find object {object}"))?; + let manager = lock(&self.client).inventory(); + let objects = manager + .get_task_inventory( + object, + local_id, + Some(simulator), + Some(cancellation.clone()), + ) + .await + .map_err(|_| "Task inventory request failed")?; + let mut items: Vec<_> = objects + .into_iter() + .filter_map(|object| object.inventory_item().cloned()) + .map(|item| TaskItemView { + id: item.base.uuid(), + name: item.base.name(), + description: item.description(), + asset_type: item.asset_type(), + running: None, + }) + .collect(); + let expected: HashSet<_> = items + .iter() + .filter(|item| { + matches!(item.asset_type, AssetType::LSLText | AssetType::LSLBytecode) + }) + .map(|item| item.id) + .collect(); + if !expected.is_empty() { + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + let expected_callback = expected.clone(); + let subscription = manager.subscribe_script_running_reply(Arc::new(move |event| { + if event.object_id() == object && expected_callback.contains(&event.script_id()) + { + let _ = sender.send((event.script_id(), event.is_running())); + } + })); + for script in &expected { + manager + .request_get_script_running(object, *script) + .map_err(|_| "Requesting task script state failed")?; + } + let deadline = tokio::time::Instant::now() + Duration::from_secs(20); + let mut states = HashMap::new(); + while states.len() < expected.len() { + tokio::select! { + () = cancellation.cancelled() => return Err("Task script query cancelled".into()), + () = tokio::time::sleep_until(deadline) => return Err("Task script query timed out".into()), + reply = receiver.recv() => { + let Some((script, running)) = reply else { return Err("Task script reply channel closed".into()); }; + states.insert(script, running); + } + } + } + drop(subscription); + for item in &mut items { + item.running = states.get(&item.id).copied(); + } + } + Ok(TaskInventoryView { local_id, items }) + }) + } + + fn resolve_inventory_avatar<'a>( + &'a self, + name: &'a str, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>> { + Box::pin(async move { + super::ClientBackend::resolve_avatar(self, name, cancellation) + .await + .map_err(str::to_owned) + }) + } +} + +fn entry_from_folder(folder: &libremetaverse::InventoryFolder) -> Entry { + Entry { + id: folder.base.uuid(), + parent: folder.base.parent_uuid(), + name: folder.base.name(), + description: String::new(), + creator: UUID::zero(), + owner: folder.base.owner_id(), + last_owner: UUID::zero(), + group: UUID::zero(), + kind: EntryKind::Folder(folder.preferred_type()), + } +} + +fn entry_from_item(item: &libremetaverse::InventoryItem) -> Entry { + Entry { + id: item.base.uuid(), + parent: item.base.parent_uuid(), + name: item.base.name(), + description: item.description(), + creator: item.creator_id(), + owner: item.base.owner_id(), + last_owner: item.last_owner_id(), + group: item.group_id(), + kind: EntryKind::Item { + asset_id: item.asset_uuid(), + asset_type: item.asset_type(), + inventory_type: item.inventory_type(), + permissions: item.permissions(), + }, + } +} + +impl LiveBackend { + fn send_cloned_appearance(&self, avatar: UUID) -> Result<(), String> { + use libremetaverse::packets::{ + AgentSetAppearancePacket, AgentSetAppearancePacketVisualParamBlock, PacketType, + }; + use libremetaverse::types::Vector3; + let appearance = lock(&self.inventory_state) + .live_appearances + .get(&avatar) + .cloned() + .ok_or_else(|| format!("Don't have an appearance cached for {avatar}"))?; + let mut packet = AgentSetAppearancePacket::new_with_constructor() + .map_err(|_| "Creating appearance packet")?; + packet.agent_data.agent_id = self.id; + packet.agent_data.session_id = self.with_agent(|agent| agent.session_id()); + let serial = { + let mut state = lock(&self.inventory_state); + state.clone_serial = state.clone_serial.saturating_add(1); + state.clone_serial + }; + packet.agent_data.serial_num = serial; + packet.agent_data.size = + Vector3::new_with_single(2.0).map_err(|_| "Creating appearance size")?; + packet.object_data.texture_entry = appearance.texture_entry; + for value in appearance.visual_params { + let mut block = AgentSetAppearancePacketVisualParamBlock::new_with_constructor() + .map_err(|_| "Creating visual parameter")?; + block.param_value = value; + packet.visual_param.push(block); + } + lock(&self.client) + .appearance() + .add_attachments(Vec::new(), true, Some(false)) + .map_err(|_| "Detaching current attachments")?; + let data = packet + .to_bytes_with_method() + .map_err(|_| "Encoding appearance packet")?; + self.network + .current_sim() + .ok_or("No current simulator")? + .send_packet_data( + data.clone(), + i32::try_from(data.len()).map_err(|_| "Appearance packet is too large")?, + PacketType::AgentSetAppearance, + true, + ) + .map_err(|_| "Sending appearance packet".to_owned()) + } +} + +const FIXTURE_DIRECTIVES: &[&str] = &[ + "inventory-root", + "inventory-folder", + "inventory-item", + "asset", + "balance", + "avatar", + "appearance-cache", + "attachment", + "task", + "task-item", +]; + +pub(super) fn apply_fake_directive( + manager: &mut ClientManager, + fields: &[&str], +) -> Option> { + if !fields + .first() + .is_some_and(|name| FIXTURE_DIRECTIVES.contains(name)) + { + return None; + } + let Some(client_id) = fields.get(1).and_then(|value| parse_uuid(value)) else { + return Some(Err("inventory fixture client UUID is invalid")); + }; + let Some(client) = manager.clients.get(&client_id) else { + return Some(Err("inventory fixture client is not registered")); + }; + Some(client.backend.apply_fake_fixture(fields)) +} + +fn apply_fixture(backend: &FakeBackend, fields: &[&str]) -> Result<(), &'static str> { + let mut state = lock(&backend.inventory_state); + let grid = &mut state.fake; + match fields { + ["inventory-root", _, root, owner, name] => { + let root = fixture_uuid(root)?; + grid.root = root; + let _owner = fixture_uuid(owner)?; + grid.entries.insert( + root, + Entry { + id: root, + parent: UUID::zero(), + name: (*name).into(), + description: String::new(), + creator: UUID::zero(), + owner: backend.id, + last_owner: UUID::zero(), + group: UUID::zero(), + kind: EntryKind::Folder(FolderType::Root), + }, + ); + } + ["inventory-folder", _, id, parent, folder_type, name] => { + let id = fixture_uuid(id)?; + grid.entries.insert( + id, + Entry { + id, + parent: fixture_uuid(parent)?, + name: (*name).into(), + description: String::new(), + creator: UUID::zero(), + owner: backend.id, + last_owner: UUID::zero(), + group: UUID::zero(), + kind: EntryKind::Folder( + parse_folder_type(folder_type) + .ok_or("inventory fixture folder type is invalid")?, + ), + }, + ); + } + [ + "inventory-item", + _, + id, + parent, + asset, + asset_type, + inventory_type, + permissions, + name, + description, + ] => { + let id = fixture_uuid(id)?; + let permissions = u32::from_str_radix(permissions.trim_start_matches("0x"), 16) + .map_err(|_| "inventory fixture permissions are invalid")?; + grid.entries.insert( + id, + Entry { + id, + parent: fixture_uuid(parent)?, + name: (*name).into(), + description: (*description).into(), + creator: backend.id, + owner: backend.id, + last_owner: backend.id, + group: UUID::zero(), + kind: EntryKind::Item { + asset_id: fixture_uuid(asset)?, + asset_type: parse_asset_type(asset_type) + .ok_or("inventory fixture asset type is invalid")?, + inventory_type: parse_inventory_type(inventory_type) + .ok_or("inventory fixture inventory type is invalid")?, + permissions: Permissions::new(permissions, 0, 0, permissions, permissions) + .map_err(|_| "inventory fixture permissions are invalid")?, + }, + }, + ); + } + ["asset", _, id, asset_type, hex] => { + grid.assets.insert( + ( + fixture_uuid(id)?, + parse_asset_type(asset_type).ok_or("asset fixture type is invalid")? as i8, + ), + decode_hex(hex)?, + ); + } + ["balance", _, amount] => { + grid.balance = amount + .parse() + .map_err(|_| "balance fixture amount is invalid")?; + } + ["avatar", _, id, name, textures] => { + let id = fixture_uuid(id)?; + let mut parsed = Vec::new(); + if *textures != "-" { + for pair in textures.split(',') { + let (label, texture) = pair + .split_once('=') + .ok_or("avatar texture fixture is invalid")?; + parsed.push((label.into(), fixture_uuid(texture)?)); + } + } + grid.avatars.insert( + id, + AvatarView { + id, + name: (*name).into(), + textures: parsed, + }, + ); + } + ["appearance-cache", _, avatar] => { + grid.cached_appearances.insert(fixture_uuid(avatar)?); + } + ["attachment", _, point, local_id, id, offset] => { + grid.attachments.push(AttachmentView { + point: (*point).into(), + local_id: local_id + .parse() + .map_err(|_| "attachment local ID is invalid")?, + id: fixture_uuid(id)?, + offset: (*offset).into(), + }); + } + ["task", _, object, local_id] => { + let object = fixture_uuid(object)?; + grid.tasks.insert( + object, + TaskInventoryView { + local_id: local_id.parse().map_err(|_| "task local ID is invalid")?, + items: Vec::new(), + }, + ); + } + [ + "task-item", + _, + object, + item, + asset_type, + running, + name, + description, + ] => { + let running = match *running { + "true" => Some(true), + "false" => Some(false), + "none" => None, + _ => return Err("task running fixture is invalid"), + }; + grid.tasks + .get_mut(&fixture_uuid(object)?) + .ok_or("task fixture must precede task-item")? + .items + .push(TaskItemView { + id: fixture_uuid(item)?, + name: (*name).into(), + description: (*description).into(), + asset_type: parse_asset_type(asset_type).ok_or("task asset type is invalid")?, + running, + }); + } + _ => return Err("invalid inventory fake directive"), + } + Ok(()) +} + +fn fixture_uuid(value: &str) -> Result { + parse_uuid(value).ok_or("inventory fixture UUID is invalid") +} + +fn decode_hex(value: &str) -> Result, &'static str> { + if !value.len().is_multiple_of(2) || value.len() as u64 / 2 > MAX_ASSET_BYTES { + return Err("asset fixture hex is invalid or too large"); + } + value + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let high = hex_nibble(pair[0]).ok_or("asset fixture hex is invalid")?; + let low = hex_nibble(pair[1]).ok_or("asset fixture hex is invalid")?; + Ok((high << 4) | low) + }) + .collect() +} + +const fn hex_nibble(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} diff --git a/programs/tests/test_client_cli.rs b/programs/tests/test_client_cli.rs index 158a7d2..8be9ebe 100644 --- a/programs/tests/test_client_cli.rs +++ b/programs/tests/test_client_cli.rs @@ -97,6 +97,8 @@ fn help_documents_upstream_framework_and_owned_commands() { "--scriptfile", "--nogui", "--fake-script", + "--allow-live-mutations", + "--allow-spending", "echomaster", "imgroup", "logpacket", diff --git a/programs/tests/test_client_inventory_cli.rs b/programs/tests/test_client_inventory_cli.rs new file mode 100644 index 0000000..c9448c3 --- /dev/null +++ b/programs/tests/test_client_inventory_cli.rs @@ -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 { + 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 { + 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 { + 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(¬ecard("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(¬e_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")); +}