From 4504abcc64f9b8385996d9de233a312f74ebdd83 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 11 Aug 2026 08:32:22 +0000 Subject: [PATCH] Implement native PrimInspector --- README.md | 8 +- programs/README.md | 43 +- programs/src/bin/prim_inspector.rs | 2 +- programs/src/lib.rs | 1 + programs/src/prim_inspector.rs | 893 +++++++++++++++++++++++++++ programs/tests/prim_inspector_cli.rs | 260 ++++++++ 6 files changed, 1203 insertions(+), 4 deletions(-) create mode 100644 programs/src/prim_inspector.rs create mode 100644 programs/tests/prim_inspector_cli.rs diff --git a/README.md b/README.md index a4070e7..795e7be 100644 --- a/README.md +++ b/README.md @@ -485,12 +485,16 @@ documented in the ### Milestone 11 -The native `osd-inspector`, `simple-bot`, and `packet-dump` programs are complete. +The native `osd-inspector`, `simple-bot`, `packet-dump`, and `prim-inspector` +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 conversations, secret redaction, and cancellation-safe logout. `packet-dump` adds filtered, bounded incoming/outgoing capture with native wire validation, -sanitized optional raw bytes, and deterministic fake datagrams. Their command +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 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/programs/README.md b/programs/README.md index 0f6046f..e3d30e4 100644 --- a/programs/README.md +++ b/programs/README.md @@ -10,7 +10,7 @@ here so a source entry is never mistaken for a completed port. | `osd-inspector` | OSDInspector | Implemented and tested offline | | `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 | Pending milestone 11 issue #88 | +| `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery | | `inventory-explorer` | InventoryExplorer | Pending milestone 11 issue #89 | | `irc-gateway` | IRCGateway | Pending milestone 11 issue #90 | | `test-client` | TestClient | Pending milestone 11 issues #91–#94 | @@ -148,3 +148,44 @@ cargo test -p libremetaverse-programs --test packet_dump_cli --locked cargo test -p libremetaverse --test packet_wire --locked cargo test --manifest-path tests/compat/Cargo.toml --test wire_semantics --locked ``` + +## PrimInspector + +`prim-inspector` preserves the upstream positional login and search arguments: + +```text +prim-inspector FIRSTNAME LASTNAME PASSWORD [SEARCH_TERM] +GRID_FIRST_NAME=... GRID_LAST_NAME=... GRID_PASSWORD=... prim-inspector +``` + +The native client disables multiple-simulator connections, allows 30 seconds +for login, collects object updates for two seconds, and snapshots the current +simulator's real primitive table. It requests missing properties through +`ObjectManager::SelectObject`, sharing a three-second deadline across at most +1,024 outstanding requests. A disconnect, simulator change, or Ctrl-C cancels +the operation and still removes handlers, logs out, and disposes the client. + +Names are filtered case-insensitively after available properties are hydrated. +Matches are ordered by distance from the agent, with local ID and UUID tie +breakers, and the nearest ten are shown by default. `--limit` accepts one +through 100 objects. Output covers transforms, construction data, flags, +parentage, sculpt, light, flexible, ownership, creator, description, sale, and +hover-text details. Status text, descriptions, and names suppress URLs and +credential-like assignments, and `--max-output-bytes` bounds the complete +buffer before anything is written. + +For offline validation, `--fake-grid FILE [--search SEARCH_TERM]` reads an LLSD +JSON map containing `region`, `agent_position`, and an `objects` array. Object +maps use the public `Primitive::from_osd` fields and may add `owner`, `creator`, +`sale_type`, `sale_price`, `hover_text`, and `property_state`. The latter is +`ready`, `delayed`, or `missing`; delayed and missing fixtures record the same +selection/property-timeout decisions as a live session. Input is limited to 8 +MiB, 100,000 objects, LLSD depth/allocation limits, nonzero UUID/local IDs, and +validated property/sale states. No live grid is contacted by the tests. + +Run the isolated CLI golden tests and the related translated primitive cases: + +```sh +cargo test -p libremetaverse-programs --test prim_inspector_cli --locked +cargo test --manifest-path tests/compat/Cargo.toml --test world_object_semantics --locked +``` diff --git a/programs/src/bin/prim_inspector.rs b/programs/src/bin/prim_inspector.rs index 0f466e0..06c107d 100644 --- a/programs/src/bin/prim_inspector.rs +++ b/programs/src/bin/prim_inspector.rs @@ -1,3 +1,3 @@ fn main() -> std::process::ExitCode { - libremetaverse_programs::pending_program("PrimInspector") + libremetaverse_programs::prim_inspector::main_entry() } diff --git a/programs/src/lib.rs b/programs/src/lib.rs index bac59c7..f7ee146 100644 --- a/programs/src/lib.rs +++ b/programs/src/lib.rs @@ -3,6 +3,7 @@ pub mod commands; pub mod osd_inspector; pub mod packet_dump; +pub mod prim_inspector; pub mod simple_bot; pub use libremetaverse::shim::pending_program; diff --git a/programs/src/prim_inspector.rs b/programs/src/prim_inspector.rs new file mode 100644 index 0000000..07d93cf --- /dev/null +++ b/programs/src/prim_inspector.rs @@ -0,0 +1,893 @@ +//! Native primitive discovery and inspection for live and deterministic fake grids. + +use clap::Parser; +use clap::error::ErrorKind; +use libremetaverse::structured_data::{OSD, OSDParser}; +use libremetaverse::types::compat::{CancellationTokenSource, Subscription}; +use libremetaverse::types::{SaleType, UUID, Vector3}; +use libremetaverse::{ + DisconnectedEventArgs, GridClient, LoginProgressEventArgs, NetworkManager, + ObjectPropertiesEventArgs, Primitive, PrimitiveObjectProperties, SimChangedEventArgs, + Simulator, +}; +use std::collections::HashMap; +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_DISCOVERY_DELAY_MS: u64 = 2_000; +const DEFAULT_PROPERTY_TIMEOUT_MS: u64 = 3_000; +const DEFAULT_MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024; +const MAX_FIXTURE_BYTES: u64 = 8 * 1024 * 1024; +const MAX_FIXTURE_ALLOC_BYTES: usize = 8 * 1024 * 1024; +const MAX_OBJECTS: usize = 100_000; +const MAX_PROPERTY_REQUESTS: usize = 1_024; +const EVENT_QUEUE_CAPACITY: usize = 2_048; + +#[derive(Parser)] +#[command( + name = "prim-inspector", + version, + about = "Discover and inspect the nearest primitives on a native LibreMetaverse grid", + 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, + + /// Case-insensitive object-name substring. + #[arg(value_name = "SEARCH_TERM")] + search_term: Option, + + /// Case-insensitive object-name substring for `--fake-grid` mode. + #[arg(long = "search", value_name = "SEARCH_TERM")] + fake_search: Option, + + /// Inspect a bounded LLSD JSON fixture without accessing a live grid. + #[arg(long, value_name = "FILE")] + fake_grid: Option, + + /// Maximum number of nearest matching objects to print. + #[arg(long, default_value_t = 10, value_name = "COUNT")] + limit: usize, + + /// 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, + + /// Time to collect object updates after login. + #[arg(long, default_value_t = DEFAULT_DISCOVERY_DELAY_MS, value_name = "MILLISECONDS", value_parser = clap::value_parser!(u64).range(0..=300_000))] + discovery_delay_ms: u64, + + /// Shared deadline for outstanding object-property requests. + #[arg(long, default_value_t = DEFAULT_PROPERTY_TIMEOUT_MS, value_name = "MILLISECONDS", value_parser = clap::value_parser!(u64).range(1..=300_000))] + property_timeout_ms: u64, + + /// Refuse to emit more than this many bytes. + #[arg(long, default_value_t = DEFAULT_MAX_OUTPUT_BYTES, value_name = "BYTES")] + max_output_bytes: usize, +} + +struct LiveArguments { + first_name: String, + last_name: String, + password: String, + search_term: Option, + login_uri: Option, + login_timeout: Duration, +} + +#[derive(Debug)] +enum ProgramError { + Usage(String), + Input { action: String, source: io::Error }, + InvalidFixture(&'static str), + Client(&'static str), + LoginFailed, + LoginTimedOut, + Cancelled, + SimulatorChanged, + Disconnected, + Output(io::Error), + OutputLimit, +} + +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::Cancelled + | Self::SimulatorChanged + | Self::Disconnected => EXIT_CLIENT, + Self::Output(_) | Self::OutputLimit => 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 } => write!(formatter, "{action}: {source}"), + Self::InvalidFixture(reason) => { + write!(formatter, "invalid fake-grid 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::Cancelled => formatter.write_str("inspection cancelled"), + Self::SimulatorChanged => formatter.write_str("simulator changed during inspection"), + Self::Disconnected => formatter.write_str("grid disconnected during inspection"), + Self::Output(source) => write!(formatter, "writing standard output: {source}"), + Self::OutputLimit => formatter.write_str("inspection reached the output byte limit"), + } + } +} + +#[derive(Default)] +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 = sanitize(value.as_ref()); + let required = value + .len() + .checked_add(1) + .ok_or(ProgramError::OutputLimit)?; + if self + .bytes + .len() + .checked_add(required) + .is_none_or(|size| size > 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 output = io::stdout().lock(); + output + .write_all(&self.bytes) + .map_err(ProgramError::Output)?; + output.flush().map_err(ProgramError::Output) + } +} + +enum LiveEvent { + Status(String), + Properties(Box), + Disconnected, + SimulatorChanged { had_previous: bool }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PropertyState { + Ready, + Delayed, + Missing, +} + +struct FixtureObject { + primitive: Primitive, + pending_properties: Option, + state: PropertyState, +} + +struct Fixture { + region: String, + agent_position: Vector3, + objects: Vec, +} + +#[must_use] +pub fn main_entry() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("prim-inspector: {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())), + }; + if !(1..=100).contains(&cli.limit) { + return Err(ProgramError::Usage( + "--limit must be between 1 and 100".into(), + )); + } + if cli.max_output_bytes == 0 { + return Err(ProgramError::Usage( + "--max-output-bytes must be greater than zero".into(), + )); + } + validate_search(cli.search_term.as_deref())?; + validate_search(cli.fake_search.as_deref())?; + let mut output = BoundedOutput::new(cli.max_output_bytes); + let runtime = + tokio::runtime::Runtime::new().map_err(|_| ProgramError::Client("start runtime"))?; + if let Some(path) = &cli.fake_grid { + if 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-grid".into(), + )); + } + if cli.search_term.is_some() && cli.fake_search.is_some() { + return Err(ProgramError::Usage( + "positional SEARCH_TERM and --search cannot be combined".into(), + )); + } + run_fixture( + path, + cli.fake_search.as_deref().or(cli.search_term.as_deref()), + cli.limit, + cli.property_timeout_ms, + &mut output, + )?; + } else { + let live = resolve_live_arguments(&cli)?; + runtime.block_on(run_live( + live, + cli.limit, + Duration::from_millis(cli.discovery_delay_ms), + Duration::from_millis(cli.property_timeout_ms), + &mut output, + ))?; + } + output.flush() +} + +fn validate_search(search: Option<&str>) -> Result<(), ProgramError> { + if search.is_some_and(|value| value.chars().count() > 256) { + return Err(ProgramError::Usage( + "SEARCH_TERM must contain at most 256 characters".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")?, + search_term: cli.search_term.clone(), + 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), + }) +} + +async fn run_live( + mut arguments: LiveArguments, + limit: usize, + discovery_delay: Duration, + property_timeout: Duration, + output: &mut BoundedOutput, +) -> Result<(), ProgramError> { + let mut client = GridClient::new().map_err(|_| ProgramError::Client("construct GridClient"))?; + client.settings().agent_settings_mut().multiple_sims = false; + let network = client.network(); + let objects = client.objects(); + 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), + "PrimInspector".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, &objects, &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); + } + let simulator = network + .current_sim() + .ok_or(ProgramError::Client("resolve current simulator"))?; + drain_login_events(&mut receiver, output)?; + let position = client.self_().sim_position(); + output.line(format!("Region: {}", simulator.name))?; + output.line(format!("Agent position: {}", vector3(position)))?; + output.line(format!( + "Collecting object updates for {} ms...", + discovery_delay.as_millis() + ))?; + wait_duration(discovery_delay, &mut receiver, output).await?; + let mut primitives = snapshot(&simulator)?; + output.line(format!("Found {} primitives", primitives.len()))?; + hydrate_live_properties( + &objects, + &simulator, + &mut primitives, + property_timeout, + &mut receiver, + output, + ) + .await?; + inspect( + primitives, + position, + arguments.search_term.as_deref(), + limit, + output, + ) + } + .await; + shutdown(&client, &network, subscriptions); + result +} + +fn drain_login_events( + receiver: &mut mpsc::Receiver, + output: &mut BoundedOutput, +) -> Result<(), ProgramError> { + while let Ok(event) = receiver.try_recv() { + match event { + LiveEvent::Status(status) => output.line(status)?, + LiveEvent::Properties(_) + | LiveEvent::SimulatorChanged { + had_previous: false, + } => {} + LiveEvent::SimulatorChanged { had_previous: true } => { + return Err(ProgramError::SimulatorChanged); + } + LiveEvent::Disconnected => return Err(ProgramError::Disconnected), + } + } + Ok(()) +} + +fn install_subscriptions( + network: &NetworkManager, + objects: &libremetaverse::ObjectManager, + sender: &mpsc::Sender, +) -> Vec { + let status = sender.clone(); + let properties = sender.clone(); + let disconnected = sender.clone(); + let changed = sender.clone(); + vec![ + network.subscribe_login_progress(Arc::new(move |event: LoginProgressEventArgs| { + let _ = status.try_send(LiveEvent::Status(format!( + "Login {:?}: {}", + event.status(), + event.message() + ))); + })), + objects.subscribe_object_properties(Arc::new(move |event: ObjectPropertiesEventArgs| { + let _ = properties.try_send(LiveEvent::Properties(Box::new(event.properties()))); + })), + network.subscribe_disconnected(Arc::new(move |_event: DisconnectedEventArgs| { + let _ = disconnected.try_send(LiveEvent::Disconnected); + })), + network.subscribe_sim_changed(Arc::new(move |event: SimChangedEventArgs| { + let _ = changed.try_send(LiveEvent::SimulatorChanged { + had_previous: event.previous_simulator().is_some(), + }); + })), + ] +} + +fn shutdown(client: &GridClient, network: &NetworkManager, subscriptions: Vec) { + drop(subscriptions); + let _ = network.logout_with_method(); + let _ = client.dispose_with_method(); +} + +fn snapshot(simulator: &Simulator) -> Result, ProgramError> { + let values = simulator + .objects_primitives + .read() + .map_err(|_| ProgramError::Client("lock simulator primitives"))?; + if values.len() > MAX_OBJECTS { + return Err(ProgramError::Client( + "primitive snapshot exceeds safety limit", + )); + } + Ok(values.values().cloned().collect()) +} + +async fn wait_duration( + duration: Duration, + receiver: &mut mpsc::Receiver, + output: &mut BoundedOutput, +) -> Result<(), ProgramError> { + let deadline = tokio::time::Instant::now() + duration; + loop { + tokio::select! { + () = tokio::time::sleep_until(deadline) => return Ok(()), + 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), + Some(LiveEvent::SimulatorChanged { had_previous: true }) => return Err(ProgramError::SimulatorChanged), + Some(LiveEvent::SimulatorChanged { had_previous: false } | LiveEvent::Properties(_)) => {}, + None => return Err(ProgramError::Client("event queue closed")), + } + } + } +} + +async fn hydrate_live_properties( + objects: &libremetaverse::ObjectManager, + simulator: &Simulator, + primitives: &mut [Primitive], + timeout: Duration, + receiver: &mut mpsc::Receiver, + output: &mut BoundedOutput, +) -> Result<(), ProgramError> { + let mut pending = HashMap::new(); + for primitive in primitives + .iter() + .filter(|primitive| primitive.properties.is_none()) + .take(MAX_PROPERTY_REQUESTS) + { + objects + .select_object_with_simulator_u_int32(simulator.clone(), primitive.local_id) + .map_err(|_| ProgramError::Client("request object properties"))?; + pending.insert(primitive.id, primitive.local_id); + } + if pending.is_empty() { + return Ok(()); + } + output.line(format!( + "Requested properties for {} primitives", + pending.len() + ))?; + let deadline = tokio::time::Instant::now() + timeout; + while !pending.is_empty() { + tokio::select! { + () = tokio::time::sleep_until(deadline) => break, + signal = tokio::signal::ctrl_c() => { + signal.map_err(|_| ProgramError::Cancelled)?; + return Err(ProgramError::Cancelled); + } + event = receiver.recv() => match event { + Some(LiveEvent::Properties(properties)) => { + if pending.remove(&properties.object_id).is_some() + && let Some(primitive) = primitives.iter_mut().find(|primitive| primitive.id == properties.object_id) + { primitive.properties = Some(*properties); } + } + Some(LiveEvent::Status(status)) => output.line(status)?, + Some(LiveEvent::Disconnected) => return Err(ProgramError::Disconnected), + Some(LiveEvent::SimulatorChanged { had_previous: true }) => return Err(ProgramError::SimulatorChanged), + Some(LiveEvent::SimulatorChanged { had_previous: false }) => {}, + None => return Err(ProgramError::Client("event queue closed")), + } + } + } + if !pending.is_empty() { + output.line(format!( + "Property timeout: {} primitives remain unknown", + pending.len() + ))?; + } + Ok(()) +} + +fn run_fixture( + path: &Path, + search: Option<&str>, + limit: usize, + property_timeout_ms: u64, + output: &mut BoundedOutput, +) -> Result<(), ProgramError> { + let fixture = read_fixture(path)?; + output.line("Fake grid connected")?; + output.line(format!("Region: {}", fixture.region))?; + output.line(format!( + "Agent position: {}", + vector3(fixture.agent_position) + ))?; + output.line(format!("Found {} primitives", fixture.objects.len()))?; + let mut primitives = Vec::with_capacity(fixture.objects.len()); + let mut missing = 0; + for mut object in fixture.objects { + match object.state { + PropertyState::Ready => {} + PropertyState::Delayed => { + output.line(format!( + "CALL select-object local_id={}", + object.primitive.local_id + ))?; + object.primitive.properties = object.pending_properties.take(); + output.line(format!( + "PROPERTY received local_id={}", + object.primitive.local_id + ))?; + } + PropertyState::Missing => { + output.line(format!( + "CALL select-object local_id={}", + object.primitive.local_id + ))?; + missing += 1; + } + } + primitives.push(object.primitive); + } + if missing != 0 { + output.line(format!( + "Property timeout after {property_timeout_ms} ms: {missing} primitives remain unknown" + ))?; + } + inspect(primitives, fixture.agent_position, search, limit, output)?; + 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(32, 1_000_000, MAX_FIXTURE_ALLOC_BYTES) + .map_err(|_| ProgramError::InvalidFixture("LLSD limits exceeded"))?; + let OSD::Map(root) = root else { + return Err(ProgramError::InvalidFixture("root must be a map")); + }; + let region = value_string(&root, "region").unwrap_or_else(|| "Fake Region".into()); + let agent_position = root + .get("agent_position") + .and_then(|value| value.as_vector3().ok()) + .ok_or(ProgramError::InvalidFixture( + "agent_position must be a three-number array", + ))?; + let Some(OSD::Array(values)) = root.get("objects") else { + return Err(ProgramError::InvalidFixture("objects must be an array")); + }; + if values.len() > MAX_OBJECTS { + return Err(ProgramError::InvalidFixture("too many objects")); + } + let mut objects = Vec::with_capacity(values.len()); + for value in values { + let OSD::Map(map) = value else { + return Err(ProgramError::InvalidFixture("each object must be a map")); + }; + let mut primitive = Primitive::from_osd(value.clone()) + .map_err(|_| ProgramError::InvalidFixture("invalid primitive data"))?; + if primitive.id == UUID::zero() || primitive.local_id == 0 { + return Err(ProgramError::InvalidFixture( + "each object needs non-zero id and localid", + )); + } + primitive.text = value_string(map, "hover_text").unwrap_or_default(); + let mut properties = primitive.properties.take().unwrap_or_default(); + properties.object_id = primitive.id; + properties.owner_id = value_uuid(map, "owner").unwrap_or_default(); + properties.creator_id = value_uuid(map, "creator").unwrap_or_default(); + properties.sale_type = parse_sale_type(value_string(map, "sale_type").as_deref())?; + properties.sale_price = map + .get("sale_price") + .and_then(|value| value.as_integer().ok()) + .unwrap_or_default(); + let state = match value_string(map, "property_state") + .as_deref() + .unwrap_or("ready") + { + "ready" => PropertyState::Ready, + "delayed" => PropertyState::Delayed, + "missing" => PropertyState::Missing, + _ => { + return Err(ProgramError::InvalidFixture( + "property_state must be ready, delayed, or missing", + )); + } + }; + let (current, pending) = match state { + PropertyState::Ready => (Some(properties), None), + PropertyState::Delayed => (None, Some(properties)), + PropertyState::Missing => (None, None), + }; + primitive.properties = current; + objects.push(FixtureObject { + primitive, + pending_properties: pending, + state, + }); + } + Ok(Fixture { + region, + agent_position, + objects, + }) +} + +fn value_string(map: &HashMap, key: &str) -> Option { + map.get(key).and_then(|value| value.as_string().ok()) +} + +fn value_uuid(map: &HashMap, key: &str) -> Option { + map.get(key) + .and_then(|value| value.as_uuid().ok()) + .filter(|value| *value != UUID::zero()) +} + +fn parse_sale_type(value: Option<&str>) -> Result { + match value.unwrap_or("not").to_ascii_lowercase().as_str() { + "not" | "none" => Ok(SaleType::Not), + "original" => Ok(SaleType::Original), + "copy" => Ok(SaleType::Copy), + "contents" => Ok(SaleType::Contents), + _ => Err(ProgramError::InvalidFixture("unknown sale_type")), + } +} + +fn inspect( + primitives: Vec, + agent_position: Vector3, + search: Option<&str>, + limit: usize, + output: &mut BoundedOutput, +) -> Result<(), ProgramError> { + let needle = search.map(str::to_lowercase); + let mut matches = primitives + .into_iter() + .filter_map(|primitive| { + let name = primitive + .properties + .as_ref() + .map_or("Unknown", |properties| properties.name.as_str()); + if needle + .as_ref() + .is_some_and(|needle| !name.to_lowercase().contains(needle)) + { + return None; + } + let distance = Vector3::distance(primitive.position, agent_position).ok()?; + Some((distance, primitive)) + }) + .collect::>(); + matches.sort_by(|(left_distance, left), (right_distance, right)| { + left_distance + .total_cmp(right_distance) + .then_with(|| left.local_id.cmp(&right.local_id)) + .then_with(|| left.id.to_string().cmp(&right.id.to_string())) + }); + if matches.is_empty() { + if let Some(search) = search { + output.line(format!("No objects matching '{search}'"))?; + } else { + output.line("No primitives found")?; + } + return Ok(()); + } + output.line(format!( + "Inspecting {} nearest matching primitives", + matches.len().min(limit) + ))?; + for (distance, primitive) in matches.into_iter().take(limit) { + format_primitive(&primitive, distance, output)?; + } + Ok(()) +} + +fn format_primitive( + primitive: &Primitive, + distance: f32, + output: &mut BoundedOutput, +) -> Result<(), ProgramError> { + let name = primitive + .properties + .as_ref() + .map(|properties| properties.name.as_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Unknown"); + output.line("")?; + output.line(format!("Object: {name} (ID: {})", primitive.id))?; + output.line(format!(" Local ID: {}", primitive.local_id))?; + output.line(format!(" Type: {:?}", primitive.type_()))?; + output.line(format!(" Distance: {distance:.2} m"))?; + output.line(format!(" Position: {}", vector3(primitive.position)))?; + output.line(format!( + " Rotation: <{:.3}, {:.3}, {:.3}, {:.3}>", + primitive.rotation.x, primitive.rotation.y, primitive.rotation.z, primitive.rotation.w + ))?; + output.line(format!(" Scale: {}", vector3(primitive.scale)))?; + if primitive.parent_id != 0 { + output.line(format!(" Parent ID: {}", primitive.parent_id))?; + } + output.line(format!(" Flags: {:?}", primitive.flags))?; + output.line(format!(" Material: {:?}", primitive.prim_data.material))?; + output.line(format!(" PCode: {:?}", primitive.prim_data.p_code))?; + output.line(format!( + " Profile: {:?}", + primitive.prim_data.profile_curve_with_property() + ))?; + output.line(format!(" Path: {:?}", primitive.prim_data.path_curve))?; + if let Some(sculpt) = &primitive.sculpt { + output.line(format!(" Sculpt Type: {:?}", sculpt.type_()))?; + output.line(format!(" Sculpt Texture: {}", sculpt.sculpt_texture))?; + } + if let Some(light) = &primitive.light { + output.line(format!( + " Light Color: <{:.3}, {:.3}, {:.3}, {:.3}>", + light.color.r, light.color.g, light.color.b, light.color.a + ))?; + output.line(format!(" Light Intensity: {:.3}", light.intensity))?; + output.line(format!(" Light Radius: {:.3}", light.radius))?; + } + if let Some(flexible) = &primitive.flexible { + output.line(format!(" Flexible Softness: {}", flexible.softness))?; + output.line(format!(" Flexible Gravity: {:.3}", flexible.gravity))?; + } + if let Some(properties) = &primitive.properties { + output.line(format!(" Owner: {}", properties.owner_id))?; + output.line(format!(" Creator: {}", properties.creator_id))?; + output.line(format!(" Description: {}", properties.description))?; + if properties.sale_type != SaleType::Not { + output.line(format!( + " For Sale: {:?} for L${}", + properties.sale_type, properties.sale_price + ))?; + } + } else { + output.line(" Properties: unavailable")?; + } + if !primitive.text.is_empty() { + output.line(format!(" Hover Text: {}", primitive.text))?; + } + Ok(()) +} + +fn sanitize(value: &str) -> String { + let value = value.replace(['\r', '\n', '\t'], " "); + value + .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=") + { + "[redacted]" + } else { + word + } + }) + .collect::>() + .join(" ") +} + +fn vector3(value: Vector3) -> String { + format!("<{:.3}, {:.3}, {:.3}>", value.x, value.y, value.z) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_removes_urls_and_secret_parameters() { + assert_eq!( + sanitize("see https://grid.invalid/?token=secret now"), + "see [redacted] now" + ); + } + + #[test] + fn sale_type_parser_accepts_documented_values() { + assert_eq!(parse_sale_type(Some("COPY")).unwrap(), SaleType::Copy); + assert!(parse_sale_type(Some("auction")).is_err()); + } +} diff --git a/programs/tests/prim_inspector_cli.rs b/programs/tests/prim_inspector_cli.rs new file mode 100644 index 0000000..f6636b5 --- /dev/null +++ b/programs/tests/prim_inspector_cli.rs @@ -0,0 +1,260 @@ +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-prim-inspector-{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_prim-inspector")) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("run prim-inspector") +} + +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("objects.json"); + fs::write( + &path, + r#"{ + "region":"Fixture Region", + "agent_position":[0,0,0], + "objects":[ + { + "id":"11111111-1111-1111-1111-111111111111", + "localid":30, + "name":"Far Lamp", + "description":"A bright fixture", + "position":[6,8,0], + "rotation":[0,0,0,1], + "scale":[1,2,3], + "parentid":7, + "material":3, + "pcode":9, + "physical":true, + "light":{"color":[0.2,0.4,0.6,1],"intensity":0.75,"radius":12,"cutoff":0.5,"falloff":0.25}, + "flex":{"simulate_lod":2,"gravity":-0.5,"air_friction":1,"wind_sensitivity":0.2,"tension":0.8,"user_force":[0,0,0]}, + "owner":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "creator":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "sale_type":"copy", + "sale_price":25, + "hover_text":"Welcome visitor", + "property_state":"ready" + }, + { + "id":"22222222-2222-2222-2222-222222222222", + "localid":20, + "name":"Near Lamp", + "description":"Hydrated later", + "position":[3,4,0], + "rotation":[0,0,0,1], + "scale":[1,1,1], + "sculpt":{"texture":"cccccccc-cccc-cccc-cccc-cccccccccccc","type":5}, + "owner":"dddddddd-dddd-dddd-dddd-dddddddddddd", + "creator":"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", + "property_state":"delayed" + }, + { + "id":"33333333-3333-3333-3333-333333333333", + "localid":10, + "name":"Lost Lamp", + "position":[1,0,0], + "rotation":[0,0,0,1], + "scale":[1,1,1], + "property_state":"missing" + }, + { + "id":"44444444-4444-4444-4444-444444444444", + "localid":40, + "name":"Chair", + "position":[-1,0,0], + "rotation":[0,0,0,1], + "scale":[1,1,1] + } + ] +}"#, + ) + .expect("write fixture"); + path +} + +#[test] +fn help_preserves_upstream_arguments_and_documents_bounded_controls() { + let output = run(&["--help"]); + assert!(output.status.success()); + let help = text(&output.stdout); + for marker in [ + "[FIRSTNAME]", + "[LASTNAME]", + "[PASSWORD]", + "[SEARCH_TERM]", + "--fake-grid", + "--search", + "--limit", + "--discovery-delay-ms", + "--property-timeout-ms", + "--max-output-bytes", + ] { + assert!(help.contains(marker), "help omitted {marker}:\n{help}"); + } + + let output = run(&[]); + assert_exit(&output, EXIT_USAGE); + assert!(text(&output.stderr).contains("Usage: prim-inspector")); +} + +#[test] +fn fake_grid_golden_output_hydrates_orders_filters_and_formats_details() { + let directory = TestDir::new("golden"); + let fixture = fixture(&directory); + let output = run(&[ + "--fake-grid", + path_text(&fixture), + "--search", + "Lamp", + "--limit", + "2", + "--property-timeout-ms", + "17", + ]); + assert!(output.status.success(), "{}", text(&output.stderr)); + assert!(output.stderr.is_empty()); + let output = text(&output.stdout); + + for expected in [ + "Fake grid connected", + "Region: Fixture Region", + "Agent position: <0.000, 0.000, 0.000>", + "Found 4 primitives", + "CALL select-object local_id=20", + "PROPERTY received local_id=20", + "CALL select-object local_id=10", + "Property timeout after 17 ms: 1 primitives remain unknown", + "Inspecting 2 nearest matching primitives", + "Object: Near Lamp (ID: 22222222-2222-2222-2222-222222222222)", + "Distance: 5.00 m", + "Sculpt Type: Mesh", + "Sculpt Texture: cccccccc-cccc-cccc-cccc-cccccccccccc", + "Owner: dddddddd-dddd-dddd-dddd-dddddddddddd", + "Description: Hydrated later", + "Object: Far Lamp (ID: 11111111-1111-1111-1111-111111111111)", + "Distance: 10.00 m", + "Parent ID: 7", + "Material:", + "PCode: Prim", + "Light Intensity: 0.750", + "Light Radius: 12.000", + "Flexible Softness: 2", + "Flexible Gravity: -0.500", + "For Sale: Copy for L$25", + "Hover Text: Welcome visitor", + "Fake grid logout complete; active_tasks=0 open_sockets=0", + ] { + assert!( + output.contains(expected), + "output omitted {expected}:\n{output}" + ); + } + assert!(output.find("Object: Near Lamp").unwrap() < output.find("Object: Far Lamp").unwrap()); + assert!(!output.contains("Object: Chair")); + assert!(!output.contains("Object: Lost Lamp")); +} + +#[test] +fn nearest_limit_has_deterministic_distance_and_local_id_tie_breaking() { + let directory = TestDir::new("ordering"); + let fixture = fixture(&directory); + let output = run(&["--fake-grid", path_text(&fixture), "--limit", "3"]); + assert!(output.status.success(), "{}", text(&output.stderr)); + let output = text(&output.stdout); + let lost = output.find("Object: Unknown").unwrap(); + let chair = output.find("Object: Chair").unwrap(); + let near = output.find("Object: Near Lamp").unwrap(); + assert!(lost < chair && chair < near, "unexpected order:\n{output}"); + assert!(!output.contains("Object: Far Lamp")); +} + +#[test] +fn malformed_limits_and_live_credentials_fail_without_partial_or_secret_output() { + let directory = TestDir::new("invalid"); + let malformed = directory.path("malformed.json"); + fs::write(&malformed, "{not-json}").expect("write malformed fixture"); + let output = run(&["--fake-grid", 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-grid", + path_text(&fixture), + "--max-output-bytes", + "10", + ]); + assert_exit(&output, EXIT_OUTPUT); + assert!(output.stdout.is_empty()); + assert!(text(&output.stderr).contains("output byte limit")); + + let password = "do-not-print-this-password"; + let output = run(&[ + "First", + "Last", + password, + "--fake-grid", + path_text(&fixture), + ]); + assert_exit(&output, EXIT_USAGE); + assert!(!text(&output.stdout).contains(password)); + assert!(!text(&output.stderr).contains(password)); + + let output = run(&["--fake-grid", path_text(&fixture), "--limit", "0"]); + assert_exit(&output, EXIT_USAGE); +}