Files
MetaCrate/programs/src/test_client/inventory.rs
Chili Palmer ea10b7672c
Some checks failed
Native code generation / deterministic (push) Successful in 18m25s
Imaging and meshing gate / native (push) Failing after 4m23s
JPEG 2000 feature / linux (push) Successful in 2m49s
Native Rust workspace compile / compile (push) Failing after 12m37s
Skia feature / linux (push) Has been cancelled
Implement TestClient inventory and appearance commands (#92)
2026-08-11 11:14:50 +00:00

2895 lines
102 KiB
Rust

//! 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<super::CommandEntry> {
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<UUID>,
fake: FakeGrid,
live_appearances: HashMap<UUID, CachedAppearance>,
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<u8>,
visual_params: Vec<u8>,
}
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<Entry>,
}
#[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<bool>,
}
#[derive(Clone)]
pub(super) struct TaskInventoryView {
local_id: u32,
items: Vec<TaskItemView>,
}
#[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<u8>,
asset_type: AssetType,
inventory_type: InventoryType,
},
Wear {
items: Vec<Entry>,
},
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<State>;
fn snapshot<'a>(
&'a self,
cancellation: CancellationToken,
) -> BackendFuture<'a, Result<Snapshot, String>>;
fn fetch_asset<'a>(
&'a self,
id: UUID,
asset_type: AssetType,
xfer: bool,
cancellation: CancellationToken,
) -> BackendFuture<'a, Result<Vec<u8>, String>>;
fn balance<'a>(
&'a self,
cancellation: CancellationToken,
) -> BackendFuture<'a, Result<i32, String>>;
fn mutate<'a>(
&'a self,
mutation: Mutation,
cancellation: CancellationToken,
) -> BackendFuture<'a, Result<MutationResult, String>>;
fn attachments<'a>(
&'a self,
cancellation: CancellationToken,
) -> BackendFuture<'a, Result<Vec<AttachmentView>, String>>;
fn avatars<'a>(
&'a self,
cancellation: CancellationToken,
) -> BackendFuture<'a, Result<Vec<AvatarView>, String>>;
fn task_inventory<'a>(
&'a self,
object: UUID,
cancellation: CancellationToken,
) -> BackendFuture<'a, Result<TaskInventoryView, String>>;
fn resolve_inventory_avatar<'a>(
&'a self,
_name: &'a str,
_cancellation: CancellationToken,
) -> BackendFuture<'a, Result<Option<UUID>, 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<UUID, Entry>,
assets: HashMap<(UUID, i8), Vec<u8>>,
avatars: HashMap<UUID, AvatarView>,
attachments: Vec<AttachmentView>,
tasks: HashMap<UUID, TaskInventoryView>,
cached_appearances: HashSet<UUID>,
next_id: u64,
}
pub(super) async fn execute<B: Backend + ?Sized>(
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<B: Backend + ?Sized>(
backend: &B,
command: Command,
args: &[String],
from: UUID,
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
spending: bool,
) -> Result<Vec<String>, 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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
if !args.is_empty() {
return Err("Usage: balance".into());
}
Ok(format!(
"Balance is L$: {}",
backend.balance(cancellation).await?
))
}
async fn change_directory<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<UUID>,
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
folder: FolderType,
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
let args = require_confirmation(backend, args, false)?;
if args.len() < 2 {
return Err("Usage: give <agent uuid> 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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
from: UUID,
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
xfer: bool,
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<String, String> = 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<UUID>,
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<u8> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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::<u64>()
.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<Vec<u8>, 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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<String, String> {
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::<usize>()
.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<B: Backend + ?Sized>(
backend: &B,
args: &[String],
cancellation: CancellationToken,
) -> Result<String, String> {
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<ManagedImage, String> {
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<Vec<u8>, 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> {
UUID::parse(value.to_owned()).ok()
}
fn parse_asset_type(value: &str) -> Option<AssetType> {
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<InventoryType> {
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<FolderType> {
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<Vec<u8>, 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<State> {
&self.inventory_state
}
fn snapshot<'a>(
&'a self,
cancellation: CancellationToken,
) -> BackendFuture<'a, Result<Snapshot, String>> {
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<Vec<u8>, 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<i32, String>> {
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<MutationResult, String>> {
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<Vec<AttachmentView>, 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<Vec<AvatarView>, 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<TaskInventoryView, String>> {
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<Option<UUID>, 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<State> {
&self.inventory_state
}
fn snapshot<'a>(
&'a self,
cancellation: CancellationToken,
) -> BackendFuture<'a, Result<Snapshot, String>> {
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::<libremetaverse::InventoryFolder>()
{
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<Vec<u8>, 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<i32, String>> {
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<MutationResult, String>> {
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<libremetaverse::InventoryItem> = items.into_iter().map(|entry| store.get_value_or_default_with_uuid_a7c63fbe::<libremetaverse::InventoryItem>(entry.id).map_err(|_| "Reading outfit item").and_then(|item| item.ok_or("Outfit item is missing from the store"))).collect::<Result<_, _>>()?;
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<Vec<AttachmentView>, 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<Vec<AvatarView>, 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<TaskInventoryView, String>> {
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<Option<UUID>, 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<Result<(), &'static str>> {
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<UUID, &'static str> {
parse_uuid(value).ok_or("inventory fixture UUID is invalid")
}
fn decode_hex(value: &str) -> Result<Vec<u8>, &'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<u8> {
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,
}
}