Files
MetaCrate/programs/src/inventory_explorer.rs
Chili Palmer bb7f419ca0
All checks were successful
Native Rust workspace compile / compile (push) Successful in 21m44s
Implement native InventoryExplorer
2026-08-11 09:01:42 +00:00

1060 lines
35 KiB
Rust

//! Bounded native inventory browsing, statistics, search, and text export.
use clap::Parser;
use clap::error::ErrorKind;
use libremetaverse::structured_data::{OSD, OSDParser};
use libremetaverse::types::compat::{CancellationTokenSource, Subscription};
use libremetaverse::types::{AssetType, UUID};
use libremetaverse::{
DisconnectedEventArgs, GridClient, Inventory, InventoryAISClient, InventoryFolder,
InventoryManager, InventoryObjectClass, LoginProgressEventArgs, NetworkManager,
};
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
pub const EXIT_SUCCESS: u8 = 0;
pub const EXIT_USAGE: u8 = 2;
pub const EXIT_INPUT: u8 = 3;
pub const EXIT_CLIENT: u8 = 4;
pub const EXIT_OUTPUT: u8 = 5;
const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 30;
const DEFAULT_INVENTORY_TIMEOUT_SECONDS: u64 = 30;
const DEFAULT_MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
const DEFAULT_MAX_EXPORT_BYTES: usize = 16 * 1024 * 1024;
const DEFAULT_MAX_ENTRIES: usize = 100_000;
const MAX_FIXTURE_BYTES: u64 = 8 * 1024 * 1024;
const MAX_FIXTURE_ALLOC_BYTES: usize = 8 * 1024 * 1024;
const EVENT_QUEUE_CAPACITY: usize = 256;
#[derive(Parser)]
#[command(
name = "inventory-explorer",
version,
about = "Explore, search, summarize, and export native LibreMetaverse inventory",
long_about = None,
arg_required_else_help = true
)]
struct Cli {
/// Avatar first name. May also be supplied as `GRID_FIRST_NAME`.
#[arg(value_name = "FIRSTNAME")]
first_name: Option<String>,
/// Avatar last name. May also be supplied as `GRID_LAST_NAME`.
#[arg(value_name = "LASTNAME")]
last_name: Option<String>,
/// Avatar password. May also be supplied as `GRID_PASSWORD`.
#[arg(value_name = "PASSWORD")]
password: Option<String>,
/// Search folders and items by case-insensitive name substring.
#[arg(long, value_name = "TERM")]
search: Option<String>,
/// Restrict search results to this item asset type.
#[arg(long = "type", value_name = "TYPE", value_parser = parse_asset_type)]
asset_type: Option<AssetType>,
/// Write a deterministic hierarchical inventory export.
#[arg(long, value_name = "FILE")]
export: Option<PathBuf>,
/// Print inventory statistics.
#[arg(long)]
stats: bool,
/// Parse a bounded AIS LLSD JSON response instead of logging in.
#[arg(long, value_name = "FILE")]
fake_ais: Option<PathBuf>,
/// Override the login endpoint. `GRID_LOGIN_URL` is used when absent.
#[arg(long, value_name = "URL")]
login_uri: Option<String>,
/// Maximum time allowed for login.
#[arg(long, default_value_t = DEFAULT_LOGIN_TIMEOUT_SECONDS, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
login_timeout_seconds: u64,
/// Maximum time to wait for a populated inventory root.
#[arg(long, default_value_t = DEFAULT_INVENTORY_TIMEOUT_SECONDS, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
inventory_timeout_seconds: u64,
/// Maximum search results to display.
#[arg(long, default_value_t = 50, value_name = "COUNT")]
max_results: usize,
/// Maximum hierarchy entries to traverse.
#[arg(long, default_value_t = DEFAULT_MAX_ENTRIES, value_name = "COUNT")]
max_entries: usize,
/// Maximum folder depth to traverse.
#[arg(long, default_value_t = 64, value_name = "DEPTH")]
max_depth: usize,
/// Refuse to emit more than this many stdout bytes.
#[arg(long, default_value_t = DEFAULT_MAX_OUTPUT_BYTES, value_name = "BYTES")]
max_output_bytes: usize,
/// Refuse to write a larger export file.
#[arg(long, default_value_t = DEFAULT_MAX_EXPORT_BYTES, value_name = "BYTES")]
max_export_bytes: usize,
}
struct LiveArguments {
first_name: String,
last_name: String,
password: String,
login_uri: Option<String>,
login_timeout: Duration,
inventory_timeout: Duration,
}
struct OperationOptions {
stats: bool,
search: Option<String>,
asset_type: Option<AssetType>,
export: Option<PathBuf>,
max_results: usize,
max_entries: usize,
max_depth: usize,
max_export_bytes: usize,
}
#[derive(Debug)]
enum ProgramError {
Usage(String),
Input { action: String, source: io::Error },
InvalidFixture(&'static str),
Client(&'static str),
LoginFailed,
LoginTimedOut,
InventoryUnavailable,
Disconnected,
Cancelled,
HierarchyCycle(UUID),
HierarchyDepth,
EntryLimit,
Output(io::Error),
OutputLimit,
Export { action: String, source: io::Error },
ExportLimit,
}
impl ProgramError {
const fn exit_code(&self) -> u8 {
match self {
Self::Usage(_) => EXIT_USAGE,
Self::Input { .. } | Self::InvalidFixture(_) => EXIT_INPUT,
Self::Client(_)
| Self::LoginFailed
| Self::LoginTimedOut
| Self::InventoryUnavailable
| Self::Disconnected
| Self::Cancelled
| Self::HierarchyCycle(_)
| Self::HierarchyDepth
| Self::EntryLimit => EXIT_CLIENT,
Self::Output(_) | Self::OutputLimit | Self::Export { .. } | Self::ExportLimit => {
EXIT_OUTPUT
}
}
}
}
impl fmt::Display for ProgramError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Usage(message) => formatter.write_str(message),
Self::Input { action, source } | Self::Export { action, source } => {
write!(formatter, "{action}: {source}")
}
Self::InvalidFixture(reason) => write!(formatter, "invalid fake AIS fixture: {reason}"),
Self::Client(operation) => {
write!(formatter, "native client operation failed: {operation}")
}
Self::LoginFailed => formatter.write_str("login failed"),
Self::LoginTimedOut => formatter.write_str("login timed out"),
Self::InventoryUnavailable => {
formatter.write_str("inventory root did not become available")
}
Self::Disconnected => formatter.write_str("grid disconnected while loading inventory"),
Self::Cancelled => formatter.write_str("inventory operation cancelled"),
Self::HierarchyCycle(uuid) => {
write!(formatter, "inventory folder cycle detected at {uuid}")
}
Self::HierarchyDepth => {
formatter.write_str("inventory exceeds the hierarchy depth limit")
}
Self::EntryLimit => formatter.write_str("inventory exceeds the entry traversal limit"),
Self::Output(source) => write!(formatter, "writing standard output: {source}"),
Self::OutputLimit => formatter.write_str("inventory output exceeds the byte limit"),
Self::ExportLimit => formatter.write_str("inventory export exceeds the byte limit"),
}
}
}
struct BoundedOutput {
bytes: Vec<u8>,
maximum: usize,
}
impl BoundedOutput {
fn new(maximum: usize) -> Self {
Self {
bytes: Vec::new(),
maximum,
}
}
fn line(&mut self, value: impl AsRef<str>) -> Result<(), ProgramError> {
let value = safe_text(value.as_ref());
let required = value
.len()
.checked_add(1)
.ok_or(ProgramError::OutputLimit)?;
if self
.bytes
.len()
.checked_add(required)
.is_none_or(|total| total > self.maximum)
{
return Err(ProgramError::OutputLimit);
}
self.bytes.extend_from_slice(value.as_bytes());
self.bytes.push(b'\n');
Ok(())
}
fn flush(self) -> Result<(), ProgramError> {
let mut stdout = io::stdout().lock();
stdout
.write_all(&self.bytes)
.map_err(ProgramError::Output)?;
stdout.flush().map_err(ProgramError::Output)
}
}
enum LiveEvent {
Status(String),
Disconnected,
}
#[derive(Clone)]
struct SnapshotEntry {
uuid: UUID,
parent_uuid: UUID,
parent_name: String,
name: String,
kind: EntryKind,
}
#[derive(Clone, Copy)]
enum EntryKind {
Folder,
Item {
asset_type: AssetType,
target: Option<UUID>,
},
Other,
}
impl SnapshotEntry {
const fn is_folder(&self) -> bool {
matches!(self.kind, EntryKind::Folder)
}
}
struct InventorySnapshot {
root: InventoryFolder,
entries: Vec<SnapshotEntry>,
}
#[must_use]
pub fn main_entry() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("inventory-explorer: {error}");
ExitCode::from(error.exit_code())
}
}
}
fn run() -> Result<(), ProgramError> {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(error)
if matches!(
error.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) =>
{
error.print().map_err(ProgramError::Output)?;
return Ok(());
}
Err(error) => return Err(ProgramError::Usage(error.to_string())),
};
validate_cli(&cli)?;
let options = OperationOptions {
stats: cli.stats,
search: cli.search.clone(),
asset_type: cli.asset_type,
export: cli.export.clone(),
max_results: cli.max_results,
max_entries: cli.max_entries,
max_depth: cli.max_depth,
max_export_bytes: cli.max_export_bytes,
};
let mut output = BoundedOutput::new(cli.max_output_bytes);
if let Some(path) = &cli.fake_ais {
run_fake(path, &options, &mut output)?;
} else {
let arguments = resolve_live_arguments(&cli)?;
let runtime =
tokio::runtime::Runtime::new().map_err(|_| ProgramError::Client("start runtime"))?;
runtime.block_on(run_live(arguments, &options, &mut output))?;
}
output.flush()
}
fn validate_cli(cli: &Cli) -> Result<(), ProgramError> {
if cli
.search
.as_ref()
.is_some_and(|value| value.is_empty() || value.chars().count() > 256)
{
return Err(ProgramError::Usage(
"--search must contain 1 to 256 characters".into(),
));
}
if cli.fake_ais.is_some()
&& (cli.first_name.is_some()
|| cli.last_name.is_some()
|| cli.password.is_some()
|| cli.login_uri.is_some())
{
return Err(ProgramError::Usage(
"live credentials and --login-uri cannot be combined with --fake-ais".into(),
));
}
if !(1..=1_000).contains(&cli.max_results) {
return Err(ProgramError::Usage(
"--max-results must be between 1 and 1000".into(),
));
}
if !(1..=1_000_000).contains(&cli.max_entries) {
return Err(ProgramError::Usage(
"--max-entries must be between 1 and 1000000".into(),
));
}
if !(1..=512).contains(&cli.max_depth) {
return Err(ProgramError::Usage(
"--max-depth must be between 1 and 512".into(),
));
}
if cli.max_output_bytes == 0 || cli.max_export_bytes == 0 {
return Err(ProgramError::Usage(
"output byte limits must be greater than zero".into(),
));
}
if cli.asset_type.is_some() && cli.search.is_none() {
return Err(ProgramError::Usage("--type requires --search".into()));
}
if let Some(path) = &cli.export {
validate_export_path(path)?;
}
Ok(())
}
fn validate_export_path(path: &Path) -> Result<(), ProgramError> {
if path.as_os_str().is_empty() || path.to_string_lossy().chars().count() > 4_096 {
return Err(ProgramError::Usage(
"--export path must contain 1 to 4096 characters".into(),
));
}
if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
return Err(ProgramError::Usage(
"--export refuses to follow a symbolic link".into(),
));
}
Ok(())
}
fn resolve_live_arguments(cli: &Cli) -> Result<LiveArguments, ProgramError> {
fn required(
value: Option<&String>,
variable: &str,
label: &str,
) -> Result<String, ProgramError> {
value
.cloned()
.or_else(|| std::env::var(variable).ok())
.filter(|value| !value.is_empty())
.ok_or_else(|| ProgramError::Usage(format!("{label} is required (or set {variable})")))
}
Ok(LiveArguments {
first_name: required(cli.first_name.as_ref(), "GRID_FIRST_NAME", "FIRSTNAME")?,
last_name: required(cli.last_name.as_ref(), "GRID_LAST_NAME", "LASTNAME")?,
password: required(cli.password.as_ref(), "GRID_PASSWORD", "PASSWORD")?,
login_uri: cli
.login_uri
.clone()
.or_else(|| std::env::var("GRID_LOGIN_URL").ok())
.filter(|value| !value.is_empty()),
login_timeout: Duration::from_secs(cli.login_timeout_seconds),
inventory_timeout: Duration::from_secs(cli.inventory_timeout_seconds),
})
}
async fn run_live(
mut arguments: LiveArguments,
options: &OperationOptions,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
let client = GridClient::new().map_err(|_| ProgramError::Client("construct GridClient"))?;
let network = client.network();
let inventory = client.inventory();
let mut login = network
.default_login_params(
std::mem::take(&mut arguments.first_name),
std::mem::take(&mut arguments.last_name),
std::mem::take(&mut arguments.password),
"InventoryExplorer".into(),
env!("CARGO_PKG_VERSION").into(),
)
.map_err(|_| ProgramError::Client("build login parameters"))?;
if let Some(uri) = arguments.login_uri.take() {
login.uri = uri;
}
let (sender, mut receiver) = mpsc::channel(EVENT_QUEUE_CAPACITY);
let subscriptions = install_subscriptions(&network, &sender);
if let Err(error) = output.line("Logging in...") {
shutdown(&client, &network, subscriptions);
return Err(error);
}
let cancellation = CancellationTokenSource::new();
let login_result = tokio::select! {
result = network.login_with_login_params_cancellation_token(login, Some(cancellation.token())) => {
result.map_err(|_| ProgramError::LoginFailed)
}
() = tokio::time::sleep(arguments.login_timeout) => {
cancellation.cancel();
let _ = network.abort_login();
Err(ProgramError::LoginTimedOut)
}
signal = tokio::signal::ctrl_c() => {
cancellation.cancel();
let _ = network.abort_login();
signal.map_err(|_| ProgramError::Cancelled)?;
Err(ProgramError::Cancelled)
}
};
let result = async {
if !login_result? {
return Err(ProgramError::LoginFailed);
}
output.line("Logged in successfully")?;
output.line("Downloading inventory...")?;
let store = wait_for_inventory(
&inventory,
arguments.inventory_timeout,
&mut receiver,
output,
)
.await?;
output.line(format!("Inventory loaded: {} entries", store.count()))?;
process_store(&store, options, output)
}
.await;
shutdown(&client, &network, subscriptions);
result
}
fn install_subscriptions(
network: &NetworkManager,
sender: &mpsc::Sender<LiveEvent>,
) -> Vec<Subscription> {
let status = sender.clone();
let disconnected = sender.clone();
vec![
network.subscribe_login_progress(Arc::new(move |event: LoginProgressEventArgs| {
let _ = status.try_send(LiveEvent::Status(format!(
"Login {:?}: {}",
event.status(),
event.message()
)));
})),
network.subscribe_disconnected(Arc::new(move |_event: DisconnectedEventArgs| {
let _ = disconnected.try_send(LiveEvent::Disconnected);
})),
]
}
async fn wait_for_inventory(
manager: &InventoryManager,
timeout: Duration,
receiver: &mut mpsc::Receiver<LiveEvent>,
output: &mut BoundedOutput,
) -> Result<Inventory, ProgramError> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
if let Some(store) = manager.store()
&& store.count() > 0
&& store.root_folder().is_some()
{
return Ok(store);
}
tokio::select! {
() = tokio::time::sleep(Duration::from_millis(100)) => {},
() = tokio::time::sleep_until(deadline) => return Err(ProgramError::InventoryUnavailable),
signal = tokio::signal::ctrl_c() => {
signal.map_err(|_| ProgramError::Cancelled)?;
return Err(ProgramError::Cancelled);
}
event = receiver.recv() => match event {
Some(LiveEvent::Status(status)) => output.line(status)?,
Some(LiveEvent::Disconnected) => return Err(ProgramError::Disconnected),
None => return Err(ProgramError::Client("inventory event queue closed")),
}
}
}
}
fn shutdown(client: &GridClient, network: &NetworkManager, subscriptions: Vec<Subscription>) {
drop(subscriptions);
let _ = network.logout_with_method();
let _ = client.dispose_with_method();
}
fn run_fake(
path: &Path,
options: &OperationOptions,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
let root = read_fixture(path)?;
let client =
Arc::new(GridClient::new().map_err(|_| ProgramError::Client("construct fake GridClient"))?);
let result = (|| {
let manager = client.inventory();
let mut store = manager
.store()
.ok_or(ProgramError::Client("resolve fake inventory store"))?;
let ais = InventoryAISClient::new(Some(Arc::clone(&client)))
.map_err(|_| ProgramError::Client("construct AIS parser"))?;
let root_id = match &root {
OSD::Map(map) => map.get("root_id").and_then(|value| value.as_uuid().ok()),
_ => None,
}
.filter(|uuid| *uuid != UUID::zero())
.ok_or(ProgramError::InvalidFixture(
"root_id must be a non-zero UUID",
))?;
let mut folders = None;
let mut items = None;
let mut links = None;
ais.parse_embedded(root, &mut folders, &mut items, &mut links)
.map_err(|_| ProgramError::InvalidFixture("AIS embedded inventory is invalid"))?;
let folders = folders.unwrap_or_default();
let items = items.unwrap_or_default();
let links = links.unwrap_or_default();
if folders
.len()
.saturating_add(items.len())
.saturating_add(links.len())
> options.max_entries
{
return Err(ProgramError::EntryLimit);
}
for folder in &folders {
store
.update_node_for(folder)
.map_err(|_| ProgramError::InvalidFixture("could not add AIS folder to store"))?;
}
for item in items.iter().chain(&links) {
store
.update_node_for(item)
.map_err(|_| ProgramError::InvalidFixture("could not add AIS item to store"))?;
}
let root_folder = folders
.iter()
.find(|folder| folder.base.uuid() == root_id)
.cloned()
.ok_or(ProgramError::InvalidFixture(
"root_id does not identify a parsed folder",
))?;
store.set_root_folder(Some(root_folder));
output.line(format!("CALL AIS fetch root={root_id}"))?;
output.line(format!(
"Fake AIS inventory loaded: {} entries",
store.count()
))?;
process_store(&store, options, output)
})();
let _ = client.dispose_with_method();
result?;
output.line("Fake grid logout complete; active_tasks=0 open_sockets=0")
}
fn read_fixture(path: &Path) -> Result<OSD, ProgramError> {
let metadata = fs::metadata(path).map_err(|source| ProgramError::Input {
action: format!("reading fixture metadata {}", path.display()),
source,
})?;
if metadata.len() > MAX_FIXTURE_BYTES {
return Err(ProgramError::InvalidFixture("file exceeds 8 MiB limit"));
}
let bytes = fs::read(path).map_err(|source| ProgramError::Input {
action: format!("reading fixture {}", path.display()),
source,
})?;
if bytes.len() > MAX_FIXTURE_ALLOC_BYTES {
return Err(ProgramError::InvalidFixture("file exceeds 8 MiB limit"));
}
let text =
String::from_utf8(bytes).map_err(|_| ProgramError::InvalidFixture("JSON must be UTF-8"))?;
let root = OSDParser::deserialize_json_with_string(text)
.map_err(|_| ProgramError::InvalidFixture("could not parse LLSD JSON"))?;
root.validate_limits(64, 1_000_000, MAX_FIXTURE_ALLOC_BYTES)
.map_err(|_| ProgramError::InvalidFixture("LLSD limits exceeded"))?;
if !matches!(root, OSD::Map(_)) {
return Err(ProgramError::InvalidFixture("root must be a map"));
}
Ok(root)
}
fn process_store(
store: &Inventory,
options: &OperationOptions,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
let snapshot = snapshot(store, options.max_entries, options.max_depth)?;
let no_explicit_operation =
!options.stats && options.search.is_none() && options.export.is_none();
if options.stats {
show_statistics(store, &snapshot, output)?;
}
if let Some(search) = &options.search {
show_search(
&snapshot,
search,
options.asset_type,
options.max_results,
output,
)?;
}
if let Some(path) = &options.export {
export_inventory(
&snapshot,
path,
options.max_export_bytes,
options.max_depth,
output,
)?;
}
if no_explicit_operation {
show_top_level(store, &snapshot.root, output)?;
}
Ok(())
}
fn snapshot(
store: &Inventory,
max_entries: usize,
max_depth: usize,
) -> Result<InventorySnapshot, ProgramError> {
let root = store
.root_folder()
.ok_or(ProgramError::InventoryUnavailable)?;
let mut entries = Vec::new();
let mut visited = HashSet::new();
let mut stack = vec![(root.clone(), 0_usize)];
while let Some((folder, depth)) = stack.pop() {
let folder_id = folder.base.uuid();
if !visited.insert(folder_id) {
return Err(ProgramError::HierarchyCycle(folder_id));
}
if depth > max_depth {
return Err(ProgramError::HierarchyDepth);
}
let mut contents = store
.get_contents_with_uuid(folder_id)
.map_err(|_| ProgramError::Client("read inventory folder contents"))?;
contents.sort_by(|left, right| compare_objects(left.as_ref(), right.as_ref()));
for object in contents.into_iter().rev() {
if entries.len() >= max_entries {
return Err(ProgramError::EntryLimit);
}
let entry = snapshot_entry(object.as_ref(), &folder.base.name());
if entry.is_folder() {
let child = object
.as_any()
.downcast_ref::<InventoryFolder>()
.cloned()
.ok_or(ProgramError::Client("downcast inventory folder"))?;
stack.push((child, depth + 1));
}
entries.push(entry);
}
}
Ok(InventorySnapshot { root, entries })
}
fn snapshot_entry(object: &dyn InventoryObjectClass, parent_name: &str) -> SnapshotEntry {
let base = object.inventory_base();
let kind = if object.as_any().is::<InventoryFolder>() {
EntryKind::Folder
} else if let Some(item) = object.inventory_item() {
let target = matches!(item.asset_type(), AssetType::Link | AssetType::LinkFolder)
.then(|| item.asset_uuid());
EntryKind::Item {
asset_type: item.asset_type(),
target,
}
} else {
EntryKind::Other
};
SnapshotEntry {
uuid: base.uuid(),
parent_uuid: base.parent_uuid(),
parent_name: parent_name.into(),
name: base.name(),
kind,
}
}
fn compare_objects(left: &dyn InventoryObjectClass, right: &dyn InventoryObjectClass) -> Ordering {
let left_folder = left.as_any().is::<InventoryFolder>();
let right_folder = right.as_any().is::<InventoryFolder>();
right_folder
.cmp(&left_folder)
.then_with(|| {
left.inventory_base()
.name()
.to_lowercase()
.cmp(&right.inventory_base().name().to_lowercase())
})
.then_with(|| {
left.inventory_base()
.uuid()
.cmp(&right.inventory_base().uuid())
})
}
fn compare_entries(left: &SnapshotEntry, right: &SnapshotEntry) -> Ordering {
right
.is_folder()
.cmp(&left.is_folder())
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
.then_with(|| left.uuid.cmp(&right.uuid))
}
fn show_statistics(
store: &Inventory,
snapshot: &InventorySnapshot,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
let item_count = snapshot
.entries
.iter()
.filter(|entry| matches!(entry.kind, EntryKind::Item { .. }))
.count();
let root_folders = store
.get_contents_with_uuid(snapshot.root.base.uuid())
.map_err(|_| ProgramError::Client("read root inventory contents"))?
.into_iter()
.filter(|entry| entry.as_any().is::<InventoryFolder>())
.count();
output.line("=== Inventory Statistics ===")?;
output.line(format!("Total Items: {item_count}"))?;
output.line(format!("Root Folders: {root_folders}"))?;
output.line("")?;
let mut counts = HashMap::<AssetType, usize>::new();
for entry in &snapshot.entries {
if let EntryKind::Item { asset_type, .. } = entry.kind {
*counts.entry(asset_type).or_default() += 1;
}
}
let mut counts = counts.into_iter().collect::<Vec<_>>();
counts.sort_by(|(left_type, left_count), (right_type, right_count)| {
right_count
.cmp(left_count)
.then_with(|| format!("{left_type:?}").cmp(&format!("{right_type:?}")))
});
output.line("Top Item Types:")?;
for (asset_type, count) in counts.into_iter().take(10) {
output.line(format!("{asset_type:?}: {count} items"))?;
}
output.line("")
}
fn show_search(
snapshot: &InventorySnapshot,
search: &str,
filter: Option<AssetType>,
max_results: usize,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
let needle = search.to_lowercase();
let mut results = snapshot.entries.iter().filter(|entry| entry.name.to_lowercase().contains(&needle))
.filter(|entry| filter.is_none_or(|filter| matches!(entry.kind, EntryKind::Item { asset_type, .. } if asset_type == filter)))
.collect::<Vec<_>>();
results.sort_by(|left, right| {
left.name
.to_lowercase()
.cmp(&right.name.to_lowercase())
.then_with(|| left.uuid.cmp(&right.uuid))
});
output.line(format!("=== Search Results for '{search}' ==="))?;
output.line(format!("Found {} matching entries", results.len()))?;
output.line("")?;
for entry in results.iter().take(max_results) {
output.line(&entry.name)?;
match entry.kind {
EntryKind::Folder => output.line("Type: Folder")?,
EntryKind::Item { asset_type, target } => {
output.line(format!("Type: {asset_type:?}"))?;
if let Some(target) = target {
output.line(format!("Link Target: {target}"))?;
}
}
EntryKind::Other => output.line("Type: Unknown")?,
}
output.line(format!("Folder: {}", entry.parent_name))?;
output.line(format!("UUID: {}", entry.uuid))?;
output.line("")?;
}
if results.len() > max_results {
output.line(format!(
"... and {} more results",
results.len() - max_results
))?;
}
Ok(())
}
fn show_top_level(
store: &Inventory,
root: &InventoryFolder,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
output.line("=== Inventory Tree (Top Level) ===")?;
output.line("")?;
let mut contents = store
.get_contents_with_uuid(root.base.uuid())
.map_err(|_| ProgramError::Client("read root inventory contents"))?;
contents.sort_by(|left, right| compare_objects(left.as_ref(), right.as_ref()));
for object in contents {
let name = object.inventory_base().name();
if let Some(folder) = object.as_any().downcast_ref::<InventoryFolder>() {
let count = store
.get_contents_with_uuid(folder.base.uuid())
.map_err(|_| ProgramError::Client("read top-level folder contents"))?
.len();
output.line(format!("Folder: {name} ({count} entries)"))?;
} else if let Some(item) = object.inventory_item() {
if matches!(item.asset_type(), AssetType::Link | AssetType::LinkFolder) {
output.line(format!(
"Link: {name} ({:?} -> {})",
item.asset_type(),
item.asset_uuid()
))?;
} else {
output.line(format!("Item: {name} ({:?})", item.asset_type()))?;
}
} else {
output.line(format!("Entry: {name}"))?;
}
}
Ok(())
}
fn export_inventory(
snapshot: &InventorySnapshot,
path: &Path,
maximum: usize,
max_depth: usize,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
let mut children = HashMap::<UUID, Vec<&SnapshotEntry>>::new();
for entry in &snapshot.entries {
children.entry(entry.parent_uuid).or_default().push(entry);
}
for values in children.values_mut() {
values.sort_by(|left, right| compare_entries(left, right));
}
let mut bytes = Vec::new();
append_export(&mut bytes, "LibreMetaverse Inventory Export\n", maximum)?;
append_export(&mut bytes, "Format: deterministic-v1\n", maximum)?;
append_export(
&mut bytes,
&format!("Entries: {}\n\n", snapshot.entries.len()),
maximum,
)?;
let mut visited = HashSet::new();
export_children(
snapshot.root.base.uuid(),
0,
&children,
&mut visited,
&mut bytes,
maximum,
max_depth,
)?;
validate_export_path(path)?;
fs::write(path, &bytes).map_err(|source| ProgramError::Export {
action: format!("writing inventory export {}", path.display()),
source,
})?;
output.line(format!(
"Exported {} entries to {}",
snapshot.entries.len(),
path.display()
))
}
fn export_children(
folder: UUID,
depth: usize,
children: &HashMap<UUID, Vec<&SnapshotEntry>>,
visited: &mut HashSet<UUID>,
bytes: &mut Vec<u8>,
maximum: usize,
max_depth: usize,
) -> Result<(), ProgramError> {
if depth > max_depth {
return Err(ProgramError::HierarchyDepth);
}
if !visited.insert(folder) {
return Err(ProgramError::HierarchyCycle(folder));
}
if let Some(entries) = children.get(&folder) {
for entry in entries {
let indent = " ".repeat(depth);
let name = safe_text(&entry.name);
let line = match entry.kind {
EntryKind::Folder => format!("{indent}[Folder] {name}\n"),
EntryKind::Item {
asset_type,
target: Some(target),
} => format!(
"{indent}{name} ({asset_type:?} -> {target}) - {}\n",
entry.uuid
),
EntryKind::Item {
asset_type,
target: None,
} => format!("{indent}{name} ({asset_type:?}) - {}\n", entry.uuid),
EntryKind::Other => format!("{indent}{name} - {}\n", entry.uuid),
};
append_export(bytes, &line, maximum)?;
if entry.is_folder() {
export_children(
entry.uuid,
depth + 1,
children,
visited,
bytes,
maximum,
max_depth,
)?;
}
}
}
visited.remove(&folder);
Ok(())
}
fn append_export(bytes: &mut Vec<u8>, value: &str, maximum: usize) -> Result<(), ProgramError> {
if bytes
.len()
.checked_add(value.len())
.is_none_or(|total| total > maximum)
{
return Err(ProgramError::ExportLimit);
}
bytes.extend_from_slice(value.as_bytes());
Ok(())
}
fn parse_asset_type(value: &str) -> Result<AssetType, String> {
match value.to_ascii_lowercase().as_str() {
"texture" => Ok(AssetType::Texture),
"sound" => Ok(AssetType::Sound),
"callingcard" | "calling-card" => Ok(AssetType::CallingCard),
"landmark" => Ok(AssetType::Landmark),
"script" => Ok(AssetType::Script),
"clothing" => Ok(AssetType::Clothing),
"object" => Ok(AssetType::Object),
"notecard" => Ok(AssetType::Notecard),
"folder" => Ok(AssetType::Folder),
"lsltext" | "lsl-text" => Ok(AssetType::LSLText),
"lslbytecode" | "lsl-bytecode" => Ok(AssetType::LSLBytecode),
"texturetga" | "texture-tga" => Ok(AssetType::TextureTGA),
"bodypart" | "body-part" => Ok(AssetType::Bodypart),
"soundwav" | "sound-wav" => Ok(AssetType::SoundWAV),
"imagetga" | "image-tga" => Ok(AssetType::ImageTGA),
"imagejpeg" | "image-jpeg" => Ok(AssetType::ImageJPEG),
"animation" => Ok(AssetType::Animation),
"gesture" => Ok(AssetType::Gesture),
"simstate" => Ok(AssetType::Simstate),
"link" => Ok(AssetType::Link),
"linkfolder" | "link-folder" => Ok(AssetType::LinkFolder),
"mesh" => Ok(AssetType::Mesh),
"widget" => Ok(AssetType::Widget),
"person" => Ok(AssetType::Person),
"settings" => Ok(AssetType::Settings),
"material" => Ok(AssetType::Material),
"unknown" => Ok(AssetType::Unknown),
_ => Err(format!("unknown asset type '{value}'")),
}
}
fn safe_text(value: &str) -> String {
value
.replace(['\r', '\n', '\t'], " ")
.split_whitespace()
.map(|word| {
let lower = word.to_ascii_lowercase();
if lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.contains("password=")
|| lower.contains("token=")
|| lower.contains("authorization=")
{
"[redacted]"
} else {
word
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn asset_types_are_case_insensitive_and_reject_unknown_names() {
assert_eq!(parse_asset_type("LsL-TeXt").unwrap(), AssetType::LSLText);
assert!(parse_asset_type("money").is_err());
}
#[test]
fn status_and_inventory_text_are_sanitized() {
assert_eq!(
safe_text("open https://grid.invalid/cap?token=x now"),
"open [redacted] now"
);
}
}