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

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,
}
}