From f93a27f46913656cc133a57660013d0ef5d40c58 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 11 Aug 2026 06:45:22 +0000 Subject: [PATCH] Implement OSDInspector offline tool (#85) --- Cargo.lock | 124 ++++++- README.md | 9 + programs/Cargo.toml | 6 +- programs/README.md | 62 ++++ programs/src/bin/osd_inspector.rs | 2 +- programs/src/lib.rs | 1 + programs/src/osd_inspector.rs | 529 ++++++++++++++++++++++++++++ programs/tests/osd_inspector_cli.rs | 416 ++++++++++++++++++++++ 8 files changed, 1141 insertions(+), 8 deletions(-) create mode 100644 programs/README.md create mode 100644 programs/src/osd_inspector.rs create mode 100644 programs/tests/osd_inspector_cli.rs diff --git a/Cargo.lock b/Cargo.lock index db25a80..280e305 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "aotuv_lancer_vorbis_sys" version = "0.1.6" @@ -209,6 +259,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.58" @@ -218,6 +308,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -747,6 +843,12 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -949,10 +1051,8 @@ dependencies = [ name = "libremetaverse-programs" version = "0.0.1" dependencies = [ + "clap", "libremetaverse", - "libremetaverse-imaging-skia", - "libremetaverse-voice-vivox", - "libremetaverse-voice-webrtc", ] [[package]] @@ -1333,6 +1433,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1927,6 +2033,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c0e04424e733e69714ca1bbb9204c1a57f09f5493439520f9f68c132ad25eec" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2251,6 +2363,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" diff --git a/README.md b/README.md index 0547ba7..00e4cc0 100644 --- a/README.md +++ b/README.md @@ -482,3 +482,12 @@ and no callback runs under an internal lock. The grammar, state precedence, normalization rules, service orchestration, limits, and ownership boundary are documented in the [`RLV protocol guide`](crates/libremetaverse-rlv/README.md). + +### Milestone 11 + +The native `osd-inspector` program is the first completed program target. It +inspects, validates, and converts bounded JSON, XML, binary, and notation LLSD, +and performs deterministic Primitive-to-OSD round trips entirely through the +public Rust APIs. Its command aliases, standard-stream behavior, exit codes, +resource 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/Cargo.toml b/programs/Cargo.toml index 8c0f5f2..463cd61 100644 --- a/programs/Cargo.toml +++ b/programs/Cargo.toml @@ -7,10 +7,8 @@ license.workspace = true publish = false [dependencies] -libremetaverse = { path = "../crates/libremetaverse" } -libremetaverse-imaging-skia = { path = "../crates/libremetaverse-imaging-skia" } -libremetaverse-voice-vivox = { path = "../crates/libremetaverse-voice-vivox" } -libremetaverse-voice-webrtc = { path = "../crates/libremetaverse-voice-webrtc" } +clap = { version = "4.5", features = ["derive"] } +libremetaverse = { path = "../crates/libremetaverse", default-features = false } [lints] workspace = true diff --git a/programs/README.md b/programs/README.md new file mode 100644 index 0000000..e1feefe --- /dev/null +++ b/programs/README.md @@ -0,0 +1,62 @@ +# Native programs + +The `libremetaverse-programs` package owns one native Rust binary for every +program in the pinned LibreMetaverse source snapshot. The source inventory and +hashes remain in `upstream-programs.json`; implementation status is tracked +here so a source entry is never mistaken for a completed port. + +| Binary | Upstream project | Status | +| --- | --- | --- | +| `osd-inspector` | OSDInspector | Implemented and tested offline | +| `simple-bot` | SimpleBot | Pending milestone 11 issue #86 | +| `packet-dump` | PacketDump | Pending milestone 11 issue #87 | +| `prim-inspector` | PrimInspector | Pending milestone 11 issue #88 | +| `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 | +| `vivox-test` | VivoxTest | Pending milestone 11 issue #95 | +| `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 | + +## OSDInspector + +`osd-inspector` is a bounded, offline command-line client of the public native +StructuredData and Primitive APIs. It does not initialize a grid client, read +credentials, load a native codec, or invoke a .NET process. + +```text +osd-inspector inspect # alias: i +osd-inspector convert # alias: c +osd-inspector validate # alias: v +osd-inspector prim-to-osd +osd-inspector osd-to-prim +``` + +The supported output formats are `json` (`j`), `xml` (`x`), `binary` +(`bin` or `b`), and `notation` (`llsd` or `n`). Use `-` as an input or output +path for standard input or standard output. Format detection uses the filename +extension as a hint and then checks every native parser, so binary and notation +LLSD work through files and pipes as well as JSON and XML. + +Input is read through a bounded buffer and is limited to the StructuredData +binary allocation limit by default. `--max-input-bytes ` can lower that +ceiling for constrained callers. StructuredData also enforces its depth, node, +and aggregate allocation limits while parsing. + +Normal results are written to stdout and diagnostics to stderr. Exit status is +stable for scripts: + +| Status | Meaning | +| --- | --- | +| 0 | Success | +| 2 | Command-line usage error | +| 3 | File or standard-stream I/O error | +| 4 | Invalid or oversized OSD input | +| 5 | Primitive conversion or output serialization error | + +Run the issue-focused CLI suite and the related translated StructuredData cases +with: + +```sh +cargo test -p libremetaverse-programs --test osd_inspector_cli --locked +cargo test --manifest-path tests/compat/Cargo.toml --test structured_data --locked +``` diff --git a/programs/src/bin/osd_inspector.rs b/programs/src/bin/osd_inspector.rs index dc8f9b9..3cde374 100644 --- a/programs/src/bin/osd_inspector.rs +++ b/programs/src/bin/osd_inspector.rs @@ -1,3 +1,3 @@ fn main() -> std::process::ExitCode { - libremetaverse_programs::pending_program("OSDInspector") + libremetaverse_programs::osd_inspector::main_entry() } diff --git a/programs/src/lib.rs b/programs/src/lib.rs index 45244da..2303805 100644 --- a/programs/src/lib.rs +++ b/programs/src/lib.rs @@ -1,5 +1,6 @@ //! Rust targets corresponding to the upstream example and tool projects. pub mod commands; +pub mod osd_inspector; pub use libremetaverse::shim::pending_program; diff --git a/programs/src/osd_inspector.rs b/programs/src/osd_inspector.rs new file mode 100644 index 0000000..d4f4892 --- /dev/null +++ b/programs/src/osd_inspector.rs @@ -0,0 +1,529 @@ +//! 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, + } +} diff --git a/programs/tests/osd_inspector_cli.rs b/programs/tests/osd_inspector_cli.rs new file mode 100644 index 0000000..6925972 --- /dev/null +++ b/programs/tests/osd_inspector_cli.rs @@ -0,0 +1,416 @@ +use libremetaverse::Primitive; +use libremetaverse::structured_data::{OSD, OSDParser}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const JSON_FIXTURE: &[u8] = + include_bytes!("../../tests/fixtures/structured_data/json_reference.json"); +const EXIT_USAGE: i32 = 2; +const EXIT_IO: i32 = 3; +const EXIT_INVALID_OSD: i32 = 4; +const EXIT_INVALID_PRIMITIVE: i32 = 5; + +static TEMP_ID: AtomicU64 = AtomicU64::new(0); + +struct TestDir(PathBuf); + +impl TestDir { + fn new(test_name: &str) -> Self { + let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "metacrate-osd-inspector-{test_name}-{}-{id}", + std::process::id() + )); + fs::create_dir(&path).expect("create isolated 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], stdin: Option<&[u8]>) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_osd-inspector")); + command + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if stdin.is_some() { + command.stdin(Stdio::piped()); + } else { + command.stdin(Stdio::null()); + } + let mut child = command.spawn().expect("spawn osd-inspector"); + if let Some(bytes) = stdin { + child + .stdin + .take() + .expect("piped stdin") + .write_all(bytes) + .expect("write command stdin"); + } + child.wait_with_output().expect("collect command output") +} + +fn utf8(bytes: &[u8]) -> &str { + std::str::from_utf8(bytes).expect("command output is UTF-8") +} + +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 write_fixture(directory: &TestDir) -> PathBuf { + let path = directory.path("reference.json"); + fs::write(&path, JSON_FIXTURE).expect("write JSON fixture"); + path +} + +fn path_text(path: &Path) -> &str { + path.to_str().expect("test path is UTF-8") +} + +#[test] +fn help_usage_and_upstream_aliases_are_stable() { + let no_args = run(&[], None); + assert_exit(&no_args, EXIT_USAGE); + assert!(utf8(&no_args.stderr).contains("Usage: osd-inspector")); + + let help = run(&["--help"], None); + assert!(help.status.success()); + let help = utf8(&help.stdout); + for command in [ + "inspect", + "convert", + "validate", + "prim-to-osd", + "osd-to-prim", + ] { + assert!(help.contains(command), "help omitted {command}"); + } + + let directory = TestDir::new("aliases"); + let input = write_fixture(&directory); + let input = path_text(&input); + for (command, expected) in [ + ("i", "Structure:"), + ("inspect", "Map (4 keys)"), + ("v", "Valid OSD file"), + ("validate", "Keys: 4"), + ] { + let output = run(&[command, input], None); + assert!( + output.status.success(), + "{command}: {}", + utf8(&output.stderr) + ); + assert!(utf8(&output.stdout).contains(expected)); + } +} + +#[test] +fn inspect_is_deterministic_and_validate_accepts_standard_input() { + let directory = TestDir::new("inspect"); + let input = write_fixture(&directory); + let output = run(&["inspect", path_text(&input)], None); + assert!(output.status.success(), "{}", utf8(&output.stderr)); + let output = utf8(&output.stdout); + assert!(output.contains("Type: Map")); + assert!(output.contains(&format!("Size: {} bytes", JSON_FIXTURE.len()))); + let array = output.find(" array:").expect("array key"); + let false_value = output.find(" false:").expect("false key"); + let true_value = output.find(" true:").expect("true key"); + let zero = output.find(" zero:").expect("zero key"); + assert!(array < false_value && false_value < true_value && true_value < zero); + + let output = run(&["validate", "-"], Some(JSON_FIXTURE)); + assert!(output.status.success(), "{}", utf8(&output.stderr)); + assert_eq!( + utf8(&output.stdout), + "Valid OSD file\n Type: Map\n Keys: 4\n" + ); +} + +#[test] +fn convert_emits_library_golden_bytes_for_every_format() { + let directory = TestDir::new("convert"); + let input = write_fixture(&directory); + let expected = OSDParser::deserialize_json_with_string( + std::str::from_utf8(JSON_FIXTURE) + .expect("compatibility fixture is UTF-8") + .to_owned(), + ) + .expect("parse checked-in compatibility fixture"); + let cases: [(&str, &str, Vec); 4] = [ + ( + "json", + "converted.json", + OSDParser::serialize_json_string(expected.clone(), Some(true)) + .expect("serialize golden JSON") + .into_bytes(), + ), + ( + "xml", + "converted.xml", + OSDParser::serialize_llsd_xml_bytes(expected.clone()).expect("serialize golden XML"), + ), + ( + "binary", + "converted.bin", + OSDParser::serialize_llsd_binary_with_osd(expected.clone()) + .expect("serialize golden binary"), + ), + ( + "notation", + "converted.notation", + OSDParser::serialize_llsd_notation(expected) + .expect("serialize golden notation") + .into_bytes(), + ), + ]; + + for (index, (format, filename, golden)) in cases.into_iter().enumerate() { + let destination = directory.path(filename); + let command = if index == 0 { "convert" } else { "c" }; + let output = run( + &[command, path_text(&input), format, path_text(&destination)], + None, + ); + assert!( + output.status.success(), + "{format}: {}", + utf8(&output.stderr) + ); + assert_eq!(fs::read(&destination).expect("read converted file"), golden); + assert!(utf8(&output.stdout).contains(&format!("to {format}:"))); + + let output = run(&["validate", path_text(&destination)], None); + assert!( + output.status.success(), + "could not read back {format}: {}", + utf8(&output.stderr) + ); + assert!(utf8(&output.stdout).contains("Type: Map")); + } +} + +#[test] +fn every_documented_output_format_alias_is_accepted() { + let directory = TestDir::new("format-aliases"); + let input = write_fixture(&directory); + let expected = OSDParser::deserialize_json_with_string( + std::str::from_utf8(JSON_FIXTURE) + .expect("compatibility fixture is UTF-8") + .to_owned(), + ) + .expect("parse checked-in compatibility fixture"); + let aliases = [ + ( + "j", + OSDParser::serialize_json_string(expected.clone(), Some(true)) + .expect("serialize JSON") + .into_bytes(), + ), + ( + "x", + OSDParser::serialize_llsd_xml_bytes(expected.clone()).expect("serialize XML"), + ), + ( + "bin", + OSDParser::serialize_llsd_binary_with_osd(expected.clone()).expect("serialize binary"), + ), + ( + "b", + OSDParser::serialize_llsd_binary_with_osd(expected.clone()).expect("serialize binary"), + ), + ( + "llsd", + OSDParser::serialize_llsd_notation(expected.clone()) + .expect("serialize notation") + .into_bytes(), + ), + ( + "n", + OSDParser::serialize_llsd_notation(expected) + .expect("serialize notation") + .into_bytes(), + ), + ]; + + for (alias, golden) in aliases { + let output = run(&["c", path_text(&input), alias, "-"], None); + assert!( + output.status.success(), + "format alias {alias}: {}", + utf8(&output.stderr) + ); + assert_eq!(output.stdout, golden, "format alias {alias}"); + } +} + +#[test] +fn conversion_supports_binary_standard_output_and_format_detection() { + let expected = OSDParser::deserialize_json_with_string( + std::str::from_utf8(JSON_FIXTURE) + .expect("compatibility fixture is UTF-8") + .to_owned(), + ) + .expect("parse checked-in compatibility fixture"); + let output = run(&["convert", "-", "binary", "-"], Some(JSON_FIXTURE)); + assert!(output.status.success(), "{}", utf8(&output.stderr)); + assert!(output.stderr.is_empty()); + assert_eq!( + OSDParser::deserialize_llsd_binary_with_bytes(output.stdout) + .expect("parse binary standard output"), + expected + ); + + let binary = OSDParser::serialize_llsd_binary_with_osd(expected.clone()) + .expect("serialize binary fixture"); + let output = run(&["validate", "-"], Some(&binary)); + assert!(output.status.success(), "{}", utf8(&output.stderr)); + assert!(utf8(&output.stdout).contains("Type: Map")); + + let notation = + OSDParser::serialize_llsd_notation(expected).expect("serialize notation fixture"); + let output = run(&["validate", "-"], Some(notation.as_bytes())); + assert!(output.status.success(), "{}", utf8(&output.stderr)); + assert!(utf8(&output.stdout).contains("Type: Map")); +} + +#[test] +#[allow(clippy::float_cmp)] // Exact integral coordinates are part of the golden sample. +fn sample_primitive_round_trips_through_the_public_native_api() { + let output = run(&["prim-to-osd"], None); + assert!(output.status.success(), "{}", utf8(&output.stderr)); + let osd = OSDParser::deserialize_json_with_string( + String::from_utf8(output.stdout.clone()).expect("sample command emits UTF-8"), + ) + .expect("sample command emits JSON"); + let primitive = Primitive::from_osd(osd).expect("sample is native Primitive OSD"); + assert_eq!(primitive.local_id, 12_345); + assert_eq!(primitive.position.x, 128.0); + assert_eq!(primitive.position.y, 128.0); + assert_eq!(primitive.position.z, 25.0); + assert_eq!( + primitive + .properties + .as_ref() + .map(|value| value.name.as_str()), + Some("Example Cube") + ); + + let directory = TestDir::new("primitive"); + let path = directory.path("cube.json"); + fs::write(&path, output.stdout).expect("write primitive JSON"); + let output = run(&["osd-to-prim", path_text(&path)], None); + assert!(output.status.success(), "{}", utf8(&output.stderr)); + let summary = utf8(&output.stdout); + for expected in [ + "Successfully parsed Primitive:", + "Name: Example Cube", + "Position: <128, 128, 25>", + "Scale: <1, 1, 1>", + "Material: Wood", + "PCode: Prim", + ] { + assert!( + summary.contains(expected), + "summary omitted {expected}:\n{summary}" + ); + } +} + +#[test] +fn failures_use_stderr_and_documented_exit_codes() { + let directory = TestDir::new("failures"); + let malformed = directory.path("malformed.json"); + fs::write(&malformed, b"{\"unterminated\": [1, 2}").expect("write malformed fixture"); + let output = run(&["validate", path_text(&malformed)], None); + assert_exit(&output, EXIT_INVALID_OSD); + assert!(output.stdout.is_empty()); + assert!(utf8(&output.stderr).contains("invalid OSD input")); + + let primitive = directory.path("not-a-primitive.json"); + fs::write(&primitive, b"[1,2,3]").expect("write non-primitive fixture"); + let output = run(&["osd-to-prim", path_text(&primitive)], None); + assert_exit(&output, EXIT_INVALID_PRIMITIVE); + assert!(utf8(&output.stderr).contains("not a Primitive map")); + + let missing = directory.path("missing.json"); + let output = run(&["inspect", path_text(&missing)], None); + assert_exit(&output, EXIT_IO); + assert!(utf8(&output.stderr).contains("opening")); + + let input = write_fixture(&directory); + let output = run( + &["--max-input-bytes", "4", "validate", path_text(&input)], + None, + ); + assert_exit(&output, EXIT_INVALID_OSD); + assert!(utf8(&output.stderr).contains("4-byte limit")); + + let output = run( + &[ + "convert", + path_text(&input), + "json", + path_text(&directory.0), + ], + None, + ); + assert_exit(&output, EXIT_IO); + assert!(utf8(&output.stderr).contains("creating")); +} + +#[test] +fn scalar_json_is_not_confused_with_llsd_notation() { + for (input, expected_type) in [ + (b"true".as_slice(), "Boolean"), + (b"42", "Integer"), + (br#""text""#, "String"), + (b"null", "Unknown"), + ] { + let output = run(&["validate", "-"], Some(input)); + assert!(output.status.success(), "{}", utf8(&output.stderr)); + assert!(utf8(&output.stdout).contains(&format!("Type: {expected_type}"))); + } + + for invalid in [b"".as_slice(), b" \t\r\n"] { + let output = run(&["validate", "-"], Some(invalid)); + assert_exit(&output, EXIT_INVALID_OSD); + } +} + +#[test] +fn inspected_fixture_retains_expected_value_types() { + let expected = OSDParser::deserialize_json_with_string( + std::str::from_utf8(JSON_FIXTURE) + .expect("compatibility fixture is UTF-8") + .to_owned(), + ) + .expect("parse checked-in compatibility fixture"); + let OSD::Map(values) = expected else { + panic!("compatibility fixture must remain a map"); + }; + assert!(matches!(values.get("array"), Some(OSD::Array(_)))); + assert!(matches!(values.get("false"), Some(OSD::Boolean(false)))); + assert!(matches!(values.get("true"), Some(OSD::Boolean(true)))); + assert!(matches!(values.get("zero"), Some(OSD::Integer(0)))); +}