//! 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, } }