//! Bounded, offline command implementation for the `OSDInspector` tool. use clap::{Parser, Subcommand, ValueEnum}; use libremetaverse::structured_data::{OSD, OSDParser, OSDType}; use libremetaverse::types::{Material, PCode, PathCurve, ProfileCurve, Quaternion, UUID, Vector3}; use libremetaverse::{Primitive, PrimitiveConstructionData, PrimitiveObjectProperties}; use std::fmt; use std::fs::File; use std::io::{self, BufReader, BufWriter, Read, Write}; use std::path::{Path, PathBuf}; use std::process::ExitCode; /// Successful command completion. pub const EXIT_SUCCESS: u8 = 0; /// Command-line usage errors. Clap also uses this value for invalid arguments. pub const EXIT_USAGE: u8 = 2; /// File or standard-stream I/O failed. pub const EXIT_IO: u8 = 3; /// Input was too large or was not valid OSD. pub const EXIT_INVALID_OSD: u8 = 4; /// Valid OSD could not be interpreted as a primitive. pub const EXIT_INVALID_PRIMITIVE: u8 = 5; const DEFAULT_MAX_INPUT_BYTES: u64 = OSD::DEFAULT_MAX_BINARY_BYTES as u64; #[derive(Debug, Parser)] #[command( name = "osd-inspector", version, about = "Inspect, convert, and validate LibreMetaverse structured data offline", long_about = None, arg_required_else_help = true )] pub struct Cli { /// Maximum accepted input size. Parsing also enforces the library depth and node limits. #[arg(long, global = true, default_value_t = DEFAULT_MAX_INPUT_BYTES, value_name = "BYTES")] max_input_bytes: u64, #[command(subcommand)] command: Command, } #[derive(Debug, Subcommand)] enum Command { /// Display an OSD value's type and recursively formatted structure. #[command(alias = "i")] Inspect { /// Input file, or '-' for standard input. input: PathBuf, }, /// Convert OSD to JSON, XML, binary LLSD, or notation LLSD. #[command(alias = "c")] Convert { /// Input file, or '-' for standard input. input: PathBuf, /// Output format (json/j, xml/x, binary/bin/b, notation/llsd/n). format: OutputFormat, /// Output file, or '-' for standard output. output: PathBuf, }, /// Validate an OSD value and report its root shape. #[command(alias = "v")] Validate { /// Input file, or '-' for standard input. input: PathBuf, }, /// Emit a deterministic sample cube as pretty JSON. PrimToOsd, /// Parse an OSD value as a Primitive and summarize it. OsdToPrim { /// Input file, or '-' for standard input. input: PathBuf, }, } #[derive(Clone, Copy, Debug, ValueEnum)] enum OutputFormat { #[value(alias = "j")] Json, #[value(alias = "x")] Xml, #[value(alias = "bin", alias = "b")] Binary, #[value(alias = "llsd", alias = "n")] Notation, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum InputFormat { Json, Xml, Binary, Notation, } #[derive(Debug)] enum InspectorError { Io { action: String, source: io::Error }, InputTooLarge { limit: u64 }, InvalidOsd { input: String }, Serialization { format: OutputFormat }, InvalidPrimitive, } impl InspectorError { const fn exit_code(&self) -> u8 { match self { Self::Io { .. } => EXIT_IO, Self::InputTooLarge { .. } | Self::InvalidOsd { .. } => EXIT_INVALID_OSD, Self::Serialization { .. } | Self::InvalidPrimitive => EXIT_INVALID_PRIMITIVE, } } } impl fmt::Display for InspectorError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io { action, source } => write!(formatter, "{action}: {source}"), Self::InputTooLarge { limit } => { write!(formatter, "input exceeds the configured {limit}-byte limit") } Self::InvalidOsd { input } => write!(formatter, "invalid OSD input: {input}"), Self::Serialization { format } => { write!(formatter, "could not serialize OSD as {format:?}") } Self::InvalidPrimitive => write!(formatter, "OSD value is not a Primitive map"), } } } /// Parses command-line arguments with clap and runs the offline tool. #[must_use] pub fn main_entry() -> ExitCode { let cli = Cli::parse(); let stdin = io::stdin(); let stdout = io::stdout(); let stderr = io::stderr(); let mut input = stdin.lock(); let mut output = stdout.lock(); let mut errors = stderr.lock(); match execute(cli, &mut input, &mut output) { Ok(()) => ExitCode::from(EXIT_SUCCESS), Err(error) => { let _ = writeln!(errors, "osd-inspector: {error}"); ExitCode::from(error.exit_code()) } } } fn execute(cli: Cli, stdin: &mut dyn Read, stdout: &mut dyn Write) -> Result<(), InspectorError> { if cli.max_input_bytes == 0 { return Err(InspectorError::InputTooLarge { limit: 0 }); } match cli.command { Command::Inspect { input } => inspect(&input, cli.max_input_bytes, stdin, stdout), Command::Convert { input, format, output, } => convert(&input, format, &output, cli.max_input_bytes, stdin, stdout), Command::Validate { input } => validate(&input, cli.max_input_bytes, stdin, stdout), Command::PrimToOsd => primitive_to_osd(stdout), Command::OsdToPrim { input } => { osd_to_primitive(&input, cli.max_input_bytes, stdin, stdout) } } } fn inspect( input: &Path, limit: u64, stdin: &mut dyn Read, output: &mut dyn Write, ) -> Result<(), InspectorError> { let bytes = read_input(input, limit, stdin)?; let osd = parse_osd(&bytes, input)?; writeln!(output, "File: {}", display_path(input)).map_err(stdout_error)?; writeln!(output, "Type: {}", type_name(osd.type_())).map_err(stdout_error)?; writeln!(output, "Size: {} bytes", bytes.len()).map_err(stdout_error)?; writeln!(output, "\nStructure:\n{}", "-".repeat(60)).map_err(stdout_error)?; display_osd(&osd, 0, output)?; writeln!(output, "{}", "-".repeat(60)).map_err(stdout_error) } fn convert( input: &Path, format: OutputFormat, destination: &Path, limit: u64, stdin: &mut dyn Read, stdout: &mut dyn Write, ) -> Result<(), InspectorError> { let bytes = read_input(input, limit, stdin)?; let osd = parse_osd(&bytes, input)?; let encoded = serialize(osd, format)?; if is_standard_stream(destination) { stdout.write_all(&encoded).map_err(stdout_error)?; stdout.flush().map_err(stdout_error)?; } else { write_file(destination, &encoded)?; writeln!( stdout, "Converted {} to {}: {} ({} bytes)", display_path(input), format_name(format), destination.display(), encoded.len() ) .map_err(stdout_error)?; } Ok(()) } fn validate( input: &Path, limit: u64, stdin: &mut dyn Read, output: &mut dyn Write, ) -> Result<(), InspectorError> { let bytes = read_input(input, limit, stdin)?; let osd = parse_osd(&bytes, input)?; writeln!(output, "Valid OSD file").map_err(stdout_error)?; writeln!(output, " Type: {}", type_name(osd.type_())).map_err(stdout_error)?; match osd { OSD::Map(values) => writeln!(output, " Keys: {}", values.len()).map_err(stdout_error)?, OSD::Array(values) => { writeln!(output, " Elements: {}", values.len()).map_err(stdout_error)?; } _ => {} } Ok(()) } fn primitive_to_osd(output: &mut dyn Write) -> Result<(), InspectorError> { let osd = sample_primitive() .and_then(|primitive| primitive.get_osd()) .map_err(|_| InspectorError::InvalidPrimitive)?; let json = OSDParser::serialize_json_string(osd, Some(true)).map_err(|_| { InspectorError::Serialization { format: OutputFormat::Json, } })?; writeln!(output, "{json}").map_err(stdout_error) } fn osd_to_primitive( input: &Path, limit: u64, stdin: &mut dyn Read, output: &mut dyn Write, ) -> Result<(), InspectorError> { let bytes = read_input(input, limit, stdin)?; let osd = parse_osd(&bytes, input)?; if !matches!(osd, OSD::Map(_)) { return Err(InspectorError::InvalidPrimitive); } let primitive = Primitive::from_osd(osd).map_err(|_| InspectorError::InvalidPrimitive)?; let name = primitive .properties .as_ref() .map_or("Unknown", |properties| properties.name.as_str()); writeln!(output, "Successfully parsed Primitive:").map_err(stdout_error)?; writeln!(output, " ID: {}", primitive.id).map_err(stdout_error)?; writeln!(output, " Name: {name}").map_err(stdout_error)?; writeln!(output, " Type: {:?}", primitive.type_()).map_err(stdout_error)?; writeln!(output, " Position: {}", primitive.position.to_string()).map_err(stdout_error)?; writeln!(output, " Scale: {}", primitive.scale.to_string()).map_err(stdout_error)?; writeln!(output, " Material: {:?}", primitive.prim_data.material).map_err(stdout_error)?; writeln!(output, " PCode: {:?}", primitive.prim_data.p_code).map_err(stdout_error) } fn sample_primitive() -> Result { let mut primitive = Primitive::new_with_constructor()?; primitive.id = UUID::new_with_string("11111111-2222-3333-4444-555555555555".into())?; primitive.local_id = 12_345; primitive.position = Vector3 { x: 128.0, y: 128.0, z: 25.0, }; primitive.rotation = Quaternion::identity(); primitive.scale = Vector3 { x: 1.0, y: 1.0, z: 1.0, }; let mut construction = PrimitiveConstructionData::new_with_constructor()?; construction.p_code = PCode::Prim; construction.material = Material::Wood; construction.path_curve = PathCurve::Line; construction.path_scale_x = 1.0; construction.path_scale_y = 1.0; construction.set_profile_curve_with_property(ProfileCurve::Square); primitive.prim_data = construction; let mut properties = PrimitiveObjectProperties::new()?; properties.name = "Example Cube".into(); properties.description = "A simple cube created with LibreMetaverse".into(); primitive.properties = Some(properties); Ok(primitive) } fn read_input(path: &Path, limit: u64, stdin: &mut dyn Read) -> Result, InspectorError> { if is_standard_stream(path) { return read_bounded(stdin, limit, "reading standard input"); } let file = File::open(path).map_err(|source| InspectorError::Io { action: format!("opening {}", path.display()), source, })?; if file.metadata().map_or(0, |value| value.len()) > limit { return Err(InspectorError::InputTooLarge { limit }); } read_bounded( &mut BufReader::new(file), limit, &format!("reading {}", path.display()), ) } fn read_bounded(input: &mut dyn Read, limit: u64, action: &str) -> Result, InspectorError> { let capacity = usize::try_from(limit.min(64 * 1024)).unwrap_or(64 * 1024); let mut bytes = Vec::with_capacity(capacity); input .take(limit.saturating_add(1)) .read_to_end(&mut bytes) .map_err(|source| InspectorError::Io { action: action.into(), source, })?; if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit { return Err(InspectorError::InputTooLarge { limit }); } Ok(bytes) } fn write_file(path: &Path, bytes: &[u8]) -> Result<(), InspectorError> { let file = File::create(path).map_err(|source| InspectorError::Io { action: format!("creating {}", path.display()), source, })?; let mut writer = BufWriter::new(file); writer .write_all(bytes) .and_then(|()| writer.flush()) .map_err(|source| InspectorError::Io { action: format!("writing {}", path.display()), source, }) } fn parse_osd(bytes: &[u8], path: &Path) -> Result { if bytes.is_empty() || bytes.iter().all(u8::is_ascii_whitespace) { return Err(InspectorError::InvalidOsd { input: display_path(path), }); } let hinted = format_hint(path, bytes); let mut attempts = vec![hinted]; for format in [ InputFormat::Json, InputFormat::Xml, InputFormat::Binary, InputFormat::Notation, ] { if !attempts.contains(&format) { attempts.push(format); } } for format in attempts { if let Ok(value) = parse_as(bytes, format) { return Ok(value); } } Err(InspectorError::InvalidOsd { input: display_path(path), }) } fn parse_as(bytes: &[u8], format: InputFormat) -> Result { match format { InputFormat::Json => OSDParser::deserialize_json_with_string( String::from_utf8(bytes.to_vec()).map_err(|_| libremetaverse::Error::Argument)?, ), InputFormat::Xml => OSDParser::deserialize_llsd_xml_with_bytes(bytes.to_vec()), InputFormat::Binary => OSDParser::deserialize_llsd_binary_with_bytes(bytes.to_vec()), InputFormat::Notation => OSDParser::deserialize_llsd_notation_with_string( String::from_utf8(bytes.to_vec()).map_err(|_| libremetaverse::Error::Argument)?, ), } } fn format_hint(path: &Path, bytes: &[u8]) -> InputFormat { match path .extension() .and_then(|extension| extension.to_str()) .map(str::to_ascii_lowercase) .as_deref() { Some("json") => InputFormat::Json, Some("xml") => InputFormat::Xml, Some("bin" | "binary") => InputFormat::Binary, Some("notation" | "nt") => InputFormat::Notation, _ => sniff_format(bytes), } } fn sniff_format(bytes: &[u8]) -> InputFormat { let text = String::from_utf8_lossy(&bytes[..bytes.len().min(64)]); let trimmed = text.trim_start_matches('\u{feff}').trim_start(); if trimmed.starts_with("") || trimmed.starts_with("") { InputFormat::Binary } else if trimmed.starts_with('<') { InputFormat::Xml } else if matches!( trimmed.as_bytes().first(), Some(b'{' | b'[' | b'"' | b'-' | b'0'..=b'9') ) || trimmed.starts_with("true") || trimmed.starts_with("false") || trimmed.starts_with("null") { InputFormat::Json } else { InputFormat::Notation } } fn serialize(osd: OSD, format: OutputFormat) -> Result, InspectorError> { let result = match format { OutputFormat::Json => { OSDParser::serialize_json_string(osd, Some(true)).map(String::into_bytes) } OutputFormat::Xml => OSDParser::serialize_llsd_xml_bytes(osd), OutputFormat::Binary => OSDParser::serialize_llsd_binary_with_osd(osd), OutputFormat::Notation => OSDParser::serialize_llsd_notation(osd).map(String::into_bytes), }; result.map_err(|_| InspectorError::Serialization { format }) } fn display_osd(osd: &OSD, indent: usize, output: &mut dyn Write) -> Result<(), InspectorError> { let prefix = " ".repeat(indent); match osd { OSD::Map(values) => { writeln!(output, "{prefix}Map ({} keys)", values.len()).map_err(stdout_error)?; let mut entries = values.iter().collect::>(); entries.sort_unstable_by_key(|(key, _)| *key); for (key, value) in entries { write!(output, "{prefix} {key}: ").map_err(stdout_error)?; if matches!(value, OSD::Map(_) | OSD::Array(_)) { writeln!(output).map_err(stdout_error)?; display_osd(value, indent + 2, output)?; } else { display_osd(value, 0, output)?; } } } OSD::Array(values) => { writeln!(output, "{prefix}Array ({} elements)", values.len()).map_err(stdout_error)?; for (index, value) in values.iter().enumerate() { write!(output, "{prefix} [{index}]: ").map_err(stdout_error)?; if matches!(value, OSD::Map(_) | OSD::Array(_)) { writeln!(output).map_err(stdout_error)?; display_osd(value, indent + 2, output)?; } else { display_osd(value, 0, output)?; } } } OSD::String(value) => writeln!(output, "String: {value:?}").map_err(stdout_error)?, OSD::Integer(value) => writeln!(output, "Integer: {value}").map_err(stdout_error)?, OSD::Real(value) => writeln!(output, "Real: {value}").map_err(stdout_error)?, OSD::Boolean(value) => writeln!(output, "Boolean: {value}").map_err(stdout_error)?, OSD::UUID(value) => writeln!(output, "UUID: {value}").map_err(stdout_error)?, OSD::Date(_) => writeln!(output, "Date: {}", osd.as_string().unwrap_or_default()) .map_err(stdout_error)?, OSD::Uri(value) => writeln!(output, "URI: {}", value.0).map_err(stdout_error)?, OSD::Binary(value) => { writeln!(output, "Binary: {} bytes", value.len()).map_err(stdout_error)?; } OSD::LlsdXml(value) => writeln!(output, "LLSD XML: {value:?}").map_err(stdout_error)?, OSD::Undefined => writeln!(output, "Unknown (Undefined)").map_err(stdout_error)?, _ => writeln!(output, "Unknown ({:?})", osd.type_()).map_err(stdout_error)?, } Ok(()) } const fn type_name(value: OSDType) -> &'static str { match value { OSDType::Unknown => "Unknown", OSDType::Boolean => "Boolean", OSDType::Integer => "Integer", OSDType::Real => "Real", OSDType::String => "String", OSDType::UUID => "UUID", OSDType::Date => "Date", OSDType::URI => "URI", OSDType::Binary => "Binary", OSDType::Map => "Map", OSDType::Array => "Array", OSDType::LlsdXml => "LlsdXml", } } const fn format_name(format: OutputFormat) -> &'static str { match format { OutputFormat::Json => "json", OutputFormat::Xml => "xml", OutputFormat::Binary => "binary", OutputFormat::Notation => "notation", } } fn is_standard_stream(path: &Path) -> bool { path.as_os_str() == "-" } fn display_path(path: &Path) -> String { if is_standard_stream(path) { "".into() } else { path.display().to_string() } } fn stdout_error(source: io::Error) -> InspectorError { InspectorError::Io { action: "writing standard output".into(), source, } }