Implement native PrimInspector
All checks were successful
Native Rust workspace compile / compile (push) Successful in 21m40s

This commit is contained in:
2026-08-11 08:32:22 +00:00
parent f0420f6c8b
commit 4504abcc64
6 changed files with 1203 additions and 4 deletions

View File

@@ -485,12 +485,16 @@ documented in the
### Milestone 11 ### 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 `osd-inspector` validates and converts bounded LLSD and performs deterministic
Primitive-to-OSD round trips. `simple-bot` provides native async login, IM and Primitive-to-OSD round trips. `simple-bot` provides native async login, IM and
local-chat commands, movement and animation calls, bounded fake-grid local-chat commands, movement and animation calls, bounded fake-grid
conversations, secret redaction, and cancellation-safe logout. `packet-dump` conversations, secret redaction, and cancellation-safe logout. `packet-dump`
adds filtered, bounded incoming/outgoing capture with native wire validation, 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 surfaces, limits, isolated CLI tests, and the status of every remaining program
are documented in the [`native programs guide`](programs/README.md). are documented in the [`native programs guide`](programs/README.md).

View File

@@ -10,7 +10,7 @@ here so a source entry is never mistaken for a completed port.
| `osd-inspector` | OSDInspector | Implemented and tested offline | | `osd-inspector` | OSDInspector | Implemented and tested offline |
| `simple-bot` | SimpleBot | Implemented with live and deterministic fake-grid modes | | `simple-bot` | SimpleBot | Implemented with live and deterministic fake-grid modes |
| `packet-dump` | PacketDump | Implemented with live and deterministic fake-grid capture | | `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 | | `inventory-explorer` | InventoryExplorer | Pending milestone 11 issue #89 |
| `irc-gateway` | IRCGateway | Pending milestone 11 issue #90 | | `irc-gateway` | IRCGateway | Pending milestone 11 issue #90 |
| `test-client` | TestClient | Pending milestone 11 issues #91#94 | | `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 -p libremetaverse --test packet_wire --locked
cargo test --manifest-path tests/compat/Cargo.toml --test wire_semantics --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
```

View File

@@ -1,3 +1,3 @@
fn main() -> std::process::ExitCode { fn main() -> std::process::ExitCode {
libremetaverse_programs::pending_program("PrimInspector") libremetaverse_programs::prim_inspector::main_entry()
} }

View File

@@ -3,6 +3,7 @@
pub mod commands; pub mod commands;
pub mod osd_inspector; pub mod osd_inspector;
pub mod packet_dump; pub mod packet_dump;
pub mod prim_inspector;
pub mod simple_bot; pub mod simple_bot;
pub use libremetaverse::shim::pending_program; pub use libremetaverse::shim::pending_program;

View File

@@ -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<String>,
/// Avatar last name. May also be supplied as `GRID_LAST_NAME`.
#[arg(value_name = "LASTNAME")]
last_name: Option<String>,
/// Avatar password. May also be supplied as `GRID_PASSWORD`.
#[arg(value_name = "PASSWORD")]
password: Option<String>,
/// Case-insensitive object-name substring.
#[arg(value_name = "SEARCH_TERM")]
search_term: Option<String>,
/// Case-insensitive object-name substring for `--fake-grid` mode.
#[arg(long = "search", value_name = "SEARCH_TERM")]
fake_search: Option<String>,
/// Inspect a bounded LLSD JSON fixture without accessing a live grid.
#[arg(long, value_name = "FILE")]
fake_grid: Option<PathBuf>,
/// 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<String>,
/// Maximum time allowed for login.
#[arg(long, default_value_t = DEFAULT_LOGIN_TIMEOUT_SECONDS, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
login_timeout_seconds: u64,
/// 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<String>,
login_uri: Option<String>,
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<u8>,
maximum: usize,
}
impl BoundedOutput {
fn new(maximum: usize) -> Self {
Self {
bytes: Vec::new(),
maximum,
}
}
fn line(&mut self, value: impl AsRef<str>) -> Result<(), ProgramError> {
let value = 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<PrimitiveObjectProperties>),
Disconnected,
SimulatorChanged { had_previous: bool },
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PropertyState {
Ready,
Delayed,
Missing,
}
struct FixtureObject {
primitive: Primitive,
pending_properties: Option<PrimitiveObjectProperties>,
state: PropertyState,
}
struct Fixture {
region: String,
agent_position: Vector3,
objects: Vec<FixtureObject>,
}
#[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<LiveArguments, ProgramError> {
fn required(
value: Option<&String>,
variable: &str,
label: &str,
) -> Result<String, ProgramError> {
value
.cloned()
.or_else(|| std::env::var(variable).ok())
.filter(|value| !value.is_empty())
.ok_or_else(|| ProgramError::Usage(format!("{label} is required (or set {variable})")))
}
Ok(LiveArguments {
first_name: required(cli.first_name.as_ref(), "GRID_FIRST_NAME", "FIRSTNAME")?,
last_name: required(cli.last_name.as_ref(), "GRID_LAST_NAME", "LASTNAME")?,
password: required(cli.password.as_ref(), "GRID_PASSWORD", "PASSWORD")?,
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<LiveEvent>,
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<LiveEvent>,
) -> Vec<Subscription> {
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<Subscription>) {
drop(subscriptions);
let _ = network.logout_with_method();
let _ = client.dispose_with_method();
}
fn snapshot(simulator: &Simulator) -> Result<Vec<Primitive>, 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<LiveEvent>,
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<LiveEvent>,
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<Fixture, ProgramError> {
let metadata = fs::metadata(path).map_err(|source| ProgramError::Input {
action: format!("reading fixture metadata {}", path.display()),
source,
})?;
if metadata.len() > MAX_FIXTURE_BYTES {
return Err(ProgramError::InvalidFixture("file exceeds 8 MiB limit"));
}
let bytes = fs::read(path).map_err(|source| ProgramError::Input {
action: format!("reading fixture {}", path.display()),
source,
})?;
if bytes.len() > MAX_FIXTURE_ALLOC_BYTES {
return Err(ProgramError::InvalidFixture("file exceeds 8 MiB limit"));
}
let text =
String::from_utf8(bytes).map_err(|_| ProgramError::InvalidFixture("JSON must be UTF-8"))?;
let root = OSDParser::deserialize_json_with_string(text)
.map_err(|_| ProgramError::InvalidFixture("could not parse LLSD JSON"))?;
root.validate_limits(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<String, OSD>, key: &str) -> Option<String> {
map.get(key).and_then(|value| value.as_string().ok())
}
fn value_uuid(map: &HashMap<String, OSD>, key: &str) -> Option<UUID> {
map.get(key)
.and_then(|value| value.as_uuid().ok())
.filter(|value| *value != UUID::zero())
}
fn parse_sale_type(value: Option<&str>) -> Result<SaleType, ProgramError> {
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<Primitive>,
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::<Vec<_>>();
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::<Vec<_>>()
.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());
}
}

View File

@@ -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);
}