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
2895 lines
102 KiB
Rust
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,
|
|
}
|
|
}
|