diff --git a/README.md b/README.md index 795e7be..1ba8ad7 100644 --- a/README.md +++ b/README.md @@ -485,8 +485,8 @@ documented in the ### Milestone 11 -The native `osd-inspector`, `simple-bot`, `packet-dump`, and `prim-inspector` -programs are complete. +The native `osd-inspector`, `simple-bot`, `packet-dump`, `prim-inspector`, and +`inventory-explorer` programs are complete. `osd-inspector` validates and converts bounded LLSD and performs deterministic Primitive-to-OSD round trips. `simple-bot` provides native async login, IM and local-chat commands, movement and animation calls, bounded fake-grid @@ -495,6 +495,8 @@ adds filtered, bounded incoming/outgoing capture with native wire validation, sanitized optional raw bytes, and deterministic fake datagrams. `prim-inspector` discovers real simulator primitives, hydrates missing object properties, orders nearest matches, and formats construction, feature, and ownership details with -bounded deterministic fake-grid coverage. Their command +bounded deterministic fake-grid coverage. `inventory-explorer` uses the native +manager/store and AIS parser for bounded browsing, statistics, typed search, +link-aware hierarchy traversal, and deterministic export. Their command surfaces, limits, isolated CLI tests, and the status of every remaining program are documented in the [`native programs guide`](programs/README.md). diff --git a/crates/libremetaverse/src/lib.rs b/crates/libremetaverse/src/lib.rs index efee475..a806a89 100644 --- a/crates/libremetaverse/src/lib.rs +++ b/crates/libremetaverse/src/lib.rs @@ -214,6 +214,11 @@ pub trait InventoryObjectClass: std::any::Any { /// Returns the shared `InventoryBase` portion of the concrete object. fn inventory_base(&self) -> &InventoryBase; + + /// Returns the shared `InventoryItem` portion for item-derived objects. + fn inventory_item(&self) -> Option<&InventoryItem> { + None + } } /// Rust object-safe view of concrete classes derived from C# `InventoryItem`. @@ -233,6 +238,24 @@ macro_rules! inventory_object_class { }; } +macro_rules! inventory_item_class { + ($type:ty, $item:ident $(.$nested:ident)*) => { + impl InventoryObjectClass for $type { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn inventory_base(&self) -> &InventoryBase { + &self.$item$(.$nested)*.base + } + + fn inventory_item(&self) -> Option<&InventoryItem> { + Some(&self.$item$(.$nested)*) + } + } + }; +} + impl InventoryObjectClass for InventoryBase { fn as_any(&self) -> &dyn std::any::Any { self @@ -244,22 +267,34 @@ impl InventoryObjectClass for InventoryBase { } inventory_object_class!(InventoryFolder, base); -inventory_object_class!(InventoryItem, base); -inventory_object_class!(InventoryAnimation, base.base); -inventory_object_class!(InventoryAttachment, base.base); -inventory_object_class!(InventoryCallingCard, base.base); -inventory_object_class!(InventoryCategory, base.base); -inventory_object_class!(InventoryGesture, base.base); -inventory_object_class!(InventoryLSL, base.base); -inventory_object_class!(InventoryLandmark, base.base); -inventory_object_class!(InventoryMaterial, base.base); -inventory_object_class!(InventoryNotecard, base.base); -inventory_object_class!(InventoryObject, base.base); -inventory_object_class!(InventorySettings, base.base); -inventory_object_class!(InventorySnapshot, base.base); -inventory_object_class!(InventorySound, base.base); -inventory_object_class!(InventoryTexture, base.base); -inventory_object_class!(InventoryWearable, base.base); +impl InventoryObjectClass for InventoryItem { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn inventory_base(&self) -> &InventoryBase { + &self.base + } + + fn inventory_item(&self) -> Option<&InventoryItem> { + Some(self) + } +} +inventory_item_class!(InventoryAnimation, base); +inventory_item_class!(InventoryAttachment, base); +inventory_item_class!(InventoryCallingCard, base); +inventory_item_class!(InventoryCategory, base); +inventory_item_class!(InventoryGesture, base); +inventory_item_class!(InventoryLSL, base); +inventory_item_class!(InventoryLandmark, base); +inventory_item_class!(InventoryMaterial, base); +inventory_item_class!(InventoryNotecard, base); +inventory_item_class!(InventoryObject, base); +inventory_item_class!(InventorySettings, base); +inventory_item_class!(InventorySnapshot, base); +inventory_item_class!(InventorySound, base); +inventory_item_class!(InventoryTexture, base); +inventory_item_class!(InventoryWearable, base); impl InventoryItemClass for InventoryItem {} impl InventoryItemClass for InventoryAnimation {} diff --git a/programs/README.md b/programs/README.md index e3d30e4..04d8e17 100644 --- a/programs/README.md +++ b/programs/README.md @@ -11,7 +11,7 @@ here so a source entry is never mistaken for a completed port. | `simple-bot` | SimpleBot | Implemented with live and deterministic fake-grid modes | | `packet-dump` | PacketDump | Implemented with live and deterministic fake-grid capture | | `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery | -| `inventory-explorer` | InventoryExplorer | Pending milestone 11 issue #89 | +| `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures | | `irc-gateway` | IRCGateway | Pending milestone 11 issue #90 | | `test-client` | TestClient | Pending milestone 11 issues #91–#94 | | `vivox-test` | VivoxTest | Pending milestone 11 issue #95 | @@ -189,3 +189,52 @@ Run the isolated CLI golden tests and the related translated primitive cases: cargo test -p libremetaverse-programs --test prim_inspector_cli --locked cargo test --manifest-path tests/compat/Cargo.toml --test world_object_semantics --locked ``` + +## InventoryExplorer + +`inventory-explorer` preserves the source program's live login and operation +options: + +```text +inventory-explorer FIRSTNAME LASTNAME PASSWORD [--stats] + [--search TERM [--type TYPE]] [--export FILE] +``` + +Credentials may instead come from `GRID_FIRST_NAME`, `GRID_LAST_NAME`, and +`GRID_PASSWORD`; `GRID_LOGIN_URL` or `--login-uri` selects the endpoint. Login +and inventory readiness each have a 30-second deadline. The native client waits +for a populated root in the real `InventoryManager::store`, handles Ctrl-C and +disconnects, removes event handlers, logs out, and disposes the client on every +post-construction path. + +With no operation option, the program prints the sorted top-level folders and +items. `--stats` traverses the hierarchy and reports item and root-folder counts +plus the ten most common asset types. `--search` performs a case-insensitive +name search across folders and items, reports parent folders and UUIDs, and +shows link targets. `--type` accepts every native `AssetType` name and narrows +item results. Search output defaults to 50 results and is capped at 1,000. + +`--export FILE` writes a deterministic `deterministic-v1` hierarchy with +folders before items, stable case-insensitive name and UUID ordering, asset +types, link targets, and UUIDs. The destination must be an explicit non-symlink +path of at most 4,096 characters. The in-memory export is validated before the +file is written and defaults to a 16 MiB limit. Traversal defaults to 100,000 +entries and 64 folder levels, detects cycles, and exposes configurable bounded +limits. Standard output is buffered and limited to 4 MiB, and inventory/status +text redacts URLs and credential-like assignments. + +Offline validation uses `--fake-ais FILE`. The file is an LLSD JSON AIS response +with a nonzero `root_id` and ordinary `_embedded.categories`, `.items`, and +`.links` maps. It is capped at 8 MiB and parsed by the public native +`InventoryAISClient`; parsed folders, items, and links are then installed +through the real manager-owned `Inventory` store. This exercises AIS typing, +derived inventory items, link targets, parent indexes, and folder contents +without contacting a grid or embedding credentials. + +Run the isolated CLI/golden suite and its related translated inventory gates: + +```sh +cargo test -p libremetaverse-programs --test inventory_explorer_cli --locked +cargo test --manifest-path tests/compat/Cargo.toml --test inventory_store_semantics --locked +cargo test --manifest-path tests/compat/Cargo.toml --test inventory_ais_semantics --locked +``` diff --git a/programs/src/bin/inventory_explorer.rs b/programs/src/bin/inventory_explorer.rs index e1409f3..25fe74f 100644 --- a/programs/src/bin/inventory_explorer.rs +++ b/programs/src/bin/inventory_explorer.rs @@ -1,3 +1,3 @@ fn main() -> std::process::ExitCode { - libremetaverse_programs::pending_program("InventoryExplorer") + libremetaverse_programs::inventory_explorer::main_entry() } diff --git a/programs/src/inventory_explorer.rs b/programs/src/inventory_explorer.rs new file mode 100644 index 0000000..81eb786 --- /dev/null +++ b/programs/src/inventory_explorer.rs @@ -0,0 +1,1059 @@ +//! 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" + ); + } +} diff --git a/programs/src/lib.rs b/programs/src/lib.rs index f7ee146..1f504c4 100644 --- a/programs/src/lib.rs +++ b/programs/src/lib.rs @@ -1,6 +1,7 @@ //! Rust targets corresponding to the upstream example and tool projects. pub mod commands; +pub mod inventory_explorer; pub mod osd_inspector; pub mod packet_dump; pub mod prim_inspector; diff --git a/programs/tests/inventory_explorer_cli.rs b/programs/tests/inventory_explorer_cli.rs new file mode 100644 index 0000000..28a3784 --- /dev/null +++ b/programs/tests/inventory_explorer_cli.rs @@ -0,0 +1,289 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const EXIT_USAGE: i32 = 2; +const EXIT_INPUT: i32 = 3; +const EXIT_OUTPUT: i32 = 5; +static TEMP_ID: AtomicU64 = AtomicU64::new(0); + +struct TestDir(PathBuf); + +impl TestDir { + fn new(name: &str) -> Self { + let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "metacrate-inventory-explorer-{name}-{}-{id}", + std::process::id() + )); + fs::create_dir(&path).expect("create test directory"); + Self(path) + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_inventory-explorer")) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("run inventory-explorer") +} + +fn text(bytes: &[u8]) -> &str { + std::str::from_utf8(bytes).expect("UTF-8 output") +} + +fn path_text(path: &Path) -> &str { + path.to_str().expect("UTF-8 path") +} + +fn assert_exit(output: &Output, expected: i32) { + assert_eq!( + output.status.code(), + Some(expected), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn fixture(directory: &TestDir) -> PathBuf { + let path = directory.path("inventory.json"); + fs::write( + &path, + r#"{ + "root_id":"10000000-0000-0000-0000-000000000000", + "_embedded":{ + "categories":{ + "root":{"category_id":"10000000-0000-0000-0000-000000000000","parent_id":"00000000-0000-0000-0000-000000000000","name":"My Inventory","type_default":8,"version":4,"descendents":5}, + "objects":{"category_id":"20000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","name":"Objects","type_default":6,"version":2,"descendents":2}, + "scripts":{"category_id":"30000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","name":"Scripts","type_default":10,"version":2,"descendents":1}, + "empty":{"category_id":"40000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","name":"Empty","type_default":-1,"version":1,"descendents":0} + }, + "items":{ + "sword":{"item_id":"50000000-0000-0000-0000-000000000000","parent_id":"20000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","asset_id":"51000000-0000-0000-0000-000000000000","name":"Sword","desc":"A test object","type":"object","inv_type":"object","created_at":1}, + "texture":{"item_id":"60000000-0000-0000-0000-000000000000","parent_id":"20000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","asset_id":"61000000-0000-0000-0000-000000000000","name":"Sword Texture","desc":"Texture","type":"texture","inv_type":"texture","created_at":2}, + "script":{"item_id":"70000000-0000-0000-0000-000000000000","parent_id":"30000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","asset_id":"71000000-0000-0000-0000-000000000000","name":"Sword Script","desc":"Script","type":"lsltext","inv_type":"script","created_at":3}, + "readme":{"item_id":"80000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","asset_id":"81000000-0000-0000-0000-000000000000","name":"Read Me","desc":"Notes","type":"notecard","inv_type":"notecard","created_at":4} + }, + "links":{ + "shortcut":{"item_id":"90000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","linked_id":"50000000-0000-0000-0000-000000000000","name":"Sword Shortcut","desc":"Link","type":"object","inv_type":"texture","created_at":5} + } + } +}"#, + ) + .expect("write AIS fixture"); + path +} + +#[test] +fn help_preserves_upstream_options_and_documents_bounded_fake_mode() { + let output = run(&["--help"]); + assert!(output.status.success()); + let help = text(&output.stdout); + for marker in [ + "[FIRSTNAME]", + "[LASTNAME]", + "[PASSWORD]", + "--stats", + "--search ", + "--type ", + "--export ", + "--fake-ais ", + "--max-entries", + "--max-export-bytes", + ] { + assert!(help.contains(marker), "help omitted {marker}:\n{help}"); + } + let output = run(&[]); + assert_exit(&output, EXIT_USAGE); + assert!(text(&output.stderr).contains("Usage: inventory-explorer")); +} + +#[test] +fn fake_ais_top_level_and_statistics_use_real_hierarchical_store() { + let directory = TestDir::new("browse-stats"); + let fixture = fixture(&directory); + let output = run(&["--fake-ais", path_text(&fixture)]); + assert!(output.status.success(), "{}", text(&output.stderr)); + let output = text(&output.stdout); + for expected in [ + "CALL AIS fetch root=10000000-0000-0000-0000-000000000000", + "Fake AIS inventory loaded: 9 entries", + "=== Inventory Tree (Top Level) ===", + "Folder: Empty (0 entries)", + "Folder: Objects (2 entries)", + "Folder: Scripts (1 entries)", + "Item: Read Me (Notecard)", + "Link: Sword Shortcut (Link -> 50000000-0000-0000-0000-000000000000)", + "Fake grid logout complete; active_tasks=0 open_sockets=0", + ] { + assert!( + output.contains(expected), + "output omitted {expected}:\n{output}" + ); + } + assert!(output.find("Folder: Empty").unwrap() < output.find("Folder: Objects").unwrap()); + + let output = run(&["--fake-ais", path_text(&fixture), "--stats"]); + assert!(output.status.success(), "{}", text(&output.stderr)); + let output = text(&output.stdout); + for expected in [ + "=== Inventory Statistics ===", + "Total Items: 5", + "Root Folders: 3", + "Link: 1 items", + "LSLText: 1 items", + "Notecard: 1 items", + "Object: 1 items", + "Texture: 1 items", + ] { + assert!( + output.contains(expected), + "stats omitted {expected}:\n{output}" + ); + } +} + +#[test] +fn search_filter_limit_parent_and_link_details_are_deterministic() { + let directory = TestDir::new("search"); + let fixture = fixture(&directory); + let output = run(&[ + "--fake-ais", + path_text(&fixture), + "--search", + "sWoRd", + "--type", + "TeXtUrE", + ]); + assert!(output.status.success(), "{}", text(&output.stderr)); + let output = text(&output.stdout); + assert!(output.contains("Found 1 matching entries")); + assert!(output.contains("Sword Texture\nType: Texture\nFolder: Objects")); + assert!(!output.contains("Sword Script\n")); + assert!(!output.contains("Sword Shortcut\n")); + + let output = run(&[ + "--fake-ais", + path_text(&fixture), + "--search", + "Sword", + "--max-results", + "2", + ]); + assert!(output.status.success()); + let output = text(&output.stdout); + assert!(output.contains("Found 4 matching entries")); + assert!(output.contains("... and 2 more results")); + assert!(output.contains("Sword\nType: Object")); + assert!(output.contains("Sword Script\nType: LSLText")); +} + +#[test] +fn export_is_hierarchical_deterministic_and_bounded() { + let directory = TestDir::new("export"); + let fixture = fixture(&directory); + let first = directory.path("first.txt"); + let second = directory.path("second.txt"); + for destination in [&first, &second] { + let output = run(&[ + "--fake-ais", + path_text(&fixture), + "--export", + path_text(destination), + ]); + assert!(output.status.success(), "{}", text(&output.stderr)); + assert!(text(&output.stdout).contains("Exported 8 entries")); + } + let first_bytes = fs::read(&first).expect("read first export"); + let second_bytes = fs::read(&second).expect("read second export"); + assert_eq!(first_bytes, second_bytes); + let export = text(&first_bytes); + assert_eq!( + export, + concat!( + "LibreMetaverse Inventory Export\n", + "Format: deterministic-v1\n", + "Entries: 8\n", + "\n", + "[Folder] Empty\n", + "[Folder] Objects\n", + " Sword (Object) - 50000000-0000-0000-0000-000000000000\n", + " Sword Texture (Texture) - 60000000-0000-0000-0000-000000000000\n", + "[Folder] Scripts\n", + " Sword Script (LSLText) - 70000000-0000-0000-0000-000000000000\n", + "Read Me (Notecard) - 80000000-0000-0000-0000-000000000000\n", + "Sword Shortcut (Link -> 50000000-0000-0000-0000-000000000000) - ", + "90000000-0000-0000-0000-000000000000\n", + ) + ); + + let limited = directory.path("limited.txt"); + let output = run(&[ + "--fake-ais", + path_text(&fixture), + "--export", + path_text(&limited), + "--max-export-bytes", + "10", + ]); + assert_exit(&output, EXIT_OUTPUT); + assert!(!limited.exists()); + assert!(text(&output.stderr).contains("export exceeds the byte limit")); +} + +#[test] +fn invalid_fixtures_options_filesystem_and_credentials_fail_explicitly() { + let directory = TestDir::new("invalid"); + let malformed = directory.path("malformed.json"); + fs::write(&malformed, "{bad-json}").expect("write malformed fixture"); + let output = run(&["--fake-ais", path_text(&malformed)]); + assert_exit(&output, EXIT_INPUT); + assert!(output.stdout.is_empty()); + assert!(text(&output.stderr).contains("could not parse LLSD JSON")); + + let fixture = fixture(&directory); + let output = run(&["--fake-ais", path_text(&fixture), "--type", "Texture"]); + assert_exit(&output, EXIT_USAGE); + assert!(text(&output.stderr).contains("--type requires --search")); + + let output = run(&[ + "--fake-ais", + path_text(&fixture), + "--search", + "Sword", + "--type", + "Money", + ]); + assert_exit(&output, EXIT_USAGE); + assert!(text(&output.stderr).contains("unknown asset type")); + + let password = "do-not-print-this-password"; + let output = run(&["First", "Last", password, "--fake-ais", path_text(&fixture)]); + assert_exit(&output, EXIT_USAGE); + assert!(!text(&output.stdout).contains(password)); + assert!(!text(&output.stderr).contains(password)); + + let output = run(&[ + "--fake-ais", + path_text(&fixture), + "--export", + path_text(&directory.0), + ]); + assert_exit(&output, EXIT_OUTPUT); + assert!(text(&output.stderr).contains("writing inventory export")); +}