Implement OSDInspector offline tool (#85)
Some checks failed
Native code generation / deterministic (push) Successful in 18m30s
Imaging and meshing gate / native (push) Successful in 5m41s
JPEG 2000 feature / linux (push) Successful in 2m53s
Skia feature / linux (push) Has been cancelled
Native Rust workspace compile / compile (push) Has been cancelled

This commit is contained in:
2026-08-11 06:45:22 +00:00
parent d4990f3b07
commit f93a27f469
8 changed files with 1141 additions and 8 deletions

124
Cargo.lock generated
View File

@@ -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"

View File

@@ -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).

View File

@@ -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

62
programs/README.md Normal file
View File

@@ -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 <file> # alias: i
osd-inspector convert <input> <format> <out> # alias: c
osd-inspector validate <file> # alias: v
osd-inspector prim-to-osd
osd-inspector osd-to-prim <file>
```
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 <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
```

View File

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

View File

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

View File

@@ -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<Primitive, libremetaverse::Error> {
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<Vec<u8>, 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<Vec<u8>, 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<OSD, InspectorError> {
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<OSD, libremetaverse::Error> {
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("<? llsd/binary ?>") || trimmed.starts_with("<?llsd/binary?>") {
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<Vec<u8>, 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::<Vec<_>>();
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) {
"<stdin>".into()
} else {
path.display().to_string()
}
}
fn stdout_error(source: io::Error) -> InspectorError {
InspectorError::Io {
action: "writing standard output".into(),
source,
}
}

View File

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