//! 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, /// Avatar last name. May also be supplied as `GRID_LAST_NAME`. #[arg(value_name = "LASTNAME")] last_name: Option, /// Avatar password. May also be supplied as `GRID_PASSWORD`. #[arg(value_name = "PASSWORD")] password: Option, /// Search folders and items by case-insensitive name substring. #[arg(long, value_name = "TERM")] search: Option, /// Restrict search results to this item asset type. #[arg(long = "type", value_name = "TYPE", value_parser = parse_asset_type)] asset_type: Option, /// Write a deterministic hierarchical inventory export. #[arg(long, value_name = "FILE")] export: Option, /// 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, /// Override the login endpoint. `GRID_LOGIN_URL` is used when absent. #[arg(long, value_name = "URL")] login_uri: Option, /// 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, login_timeout: Duration, inventory_timeout: Duration, } struct OperationOptions { stats: bool, search: Option, asset_type: Option, export: Option, 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, maximum: usize, } impl BoundedOutput { fn new(maximum: usize) -> Self { Self { bytes: Vec::new(), maximum, } } fn line(&mut self, value: impl AsRef) -> 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, }, Other, } impl SnapshotEntry { const fn is_folder(&self) -> bool { matches!(self.kind, EntryKind::Folder) } } struct InventorySnapshot { root: InventoryFolder, entries: Vec, } #[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 { fn required( value: Option<&String>, variable: &str, label: &str, ) -> Result { 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, ) -> Vec { 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, output: &mut BoundedOutput, ) -> Result { 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) { 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 { 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 { 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::() .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::() { 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::(); let right_folder = right.as_any().is::(); 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::()) .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::::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::>(); 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, 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::>(); 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::() { 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::>::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>, visited: &mut HashSet, bytes: &mut Vec, 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, 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 { 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::>() .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" ); } }