//! 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()); } }