diff --git a/.gitea/workflows/rust-workspace.yml b/.gitea/workflows/rust-workspace.yml index d4b72c8..a3dd63d 100644 --- a/.gitea/workflows/rust-workspace.yml +++ b/.gitea/workflows/rust-workspace.yml @@ -9,6 +9,7 @@ on: - "tools/test_milestone_09.py" - "tools/check_milestone_10_issue_76.py" - "tools/check_milestone_10_issue_77.py" + - "tools/check_milestone_10_issue_78.py" - "**/*.rs" - "**/Cargo.toml" - "Cargo.lock" @@ -20,6 +21,7 @@ on: - "tools/test_milestone_09.py" - "tools/check_milestone_10_issue_76.py" - "tools/check_milestone_10_issue_77.py" + - "tools/check_milestone_10_issue_78.py" - "**/*.rs" - "**/Cargo.toml" - "Cargo.lock" @@ -57,6 +59,7 @@ jobs: python3 tools/check_milestone_09.py python3 tools/check_milestone_10_issue_76.py python3 tools/check_milestone_10_issue_77.py + python3 tools/check_milestone_10_issue_78.py - name: Test the complete native world milestone run: python3 tools/test_milestone_09.py - name: Compile every workspace target with bounded memory diff --git a/README.md b/README.md index 6865ec2..51bfddb 100644 --- a/README.md +++ b/README.md @@ -462,3 +462,15 @@ rigged-mesh skin matrices and normalized weights. A large executable fixture reports decode allocations and timing without replacing correctness gates. The format, ownership, error, and tangent-output contracts are documented in the [`MeshFoundry` guide](crates/libremetaverse-rendering-mesh-foundry/README.md). + +The native RLV protocol layer parses bounded chat messages into typed clear, +action, restriction, and query directives without changing client state. It +preserves source casing, folder paths, separators, numeric channels, UUIDs, and +attachment/wearable aliases while exposing canonical behavior names and +source-located typed errors. All 119 restriction names from the pinned +LibreMetaverse snapshot are covered, and deterministic malformed-input +mutations exercise the parser without an external fuzzing runtime. State, +camera, inventory, callback, and transport effects remain explicit consumers +of this pure layer. The grammar, normalization rules, limits, and ownership +boundary are documented in the +[`RLV protocol guide`](crates/libremetaverse-rlv/README.md). diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md index 2348827..9713bbe 100644 --- a/api/SHIM-COVERAGE.md +++ b/api/SHIM-COVERAGE.md @@ -9,7 +9,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand. | `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain | | `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim | | `LibreMetaverse.PrimMesher` | 17 | 207 | native implementation: 15 types / 200 members; remaining surface is callable failure-only shims | -| `LibreMetaverse.RLV` | 28 | 499 | callable failure-only shim | +| `LibreMetaverse.RLV` | 28 | 499 | native implementation: 2 types / 11 members; remaining surface is callable failure-only shims | | `LibreMetaverse.Rendering.MeshFoundry` | 1 | 14 | native implementation: 1 type / 14 members; no generated shims remain | | `LibreMetaverse.Rendering.Simple` | 1 | 6 | native implementation: 1 type / 6 members; no generated shims remain | | `LibreMetaverse.StructuredData` | 16 | 295 | native implementation: 16 types / 295 members; no generated shims remain | diff --git a/crates/libremetaverse-rlv/Cargo.toml b/crates/libremetaverse-rlv/Cargo.toml index 398e247..ed2ee15 100644 --- a/crates/libremetaverse-rlv/Cargo.toml +++ b/crates/libremetaverse-rlv/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "RLV protocol shims for the MetaCrate LibreMetaverse rewrite" +description = "Bounded native RLV protocol parsing and compatibility types for MetaCrate" [dependencies] libremetaverse-types = { path = "../libremetaverse-types" } diff --git a/crates/libremetaverse-rlv/README.md b/crates/libremetaverse-rlv/README.md new file mode 100644 index 0000000..8d9b141 --- /dev/null +++ b/crates/libremetaverse-rlv/README.md @@ -0,0 +1,73 @@ +# Native RLV protocol layer + +`libremetaverse-rlv` implements the side-effect-free protocol boundary for the +Restrained Love Viewer support in the pinned LibreMetaverse snapshot. The +parser converts one bounded chat message into typed commands. It does not +mutate restrictions, move the camera, touch inventory, send replies, or invoke +callbacks; those stateful responsibilities are separate milestone work that +consumes this layer. + +## Message contract + +An input begins with `@` and contains at most 128 comma-separated commands in +at most 64 KiB. A command is either the case-insensitive bare `clear` command, +or has the form `behavior[:option]=parameter`. The first colon and first equals +sign are structural. Empty commands, behavior names, or parameters are +rejected. + +The parser preserves the original behavior and parameter spelling, option text, +sender identity and name, and the exact byte span of each command. It also +provides lowercase behavior and parameter fields for protocol dispatch. Option +text is never globally trimmed or lowercased: folder paths, query separators, +setting values, role names, and other opaque strings retain their bytes. Only +an individual typed field applies the conversion required by the reference, +such as case-insensitive attachment aliases or .NET-style whitespace trimming +for a number. + +Parameters select one of three families: + +- `force` produces a typed `RlvAction` with validated UUID, numeric, folder, + attachment, wearable, setting, group, and teleport operands. +- `n`/`add` and `y`/`rem` produce typed add/remove restrictions. The complete + table of 119 behavior spellings is exposed as `RLV_RESTRICTION_NAMES`. +- a nonzero signed decimal channel produces a typed `RlvQuery`, including + camera, inventory, outfit, path, status, version, group, and environment + variants. + +Aliases remain explicit. `FarTouch` canonicalizes to `TouchFar` in a mapped +`RlvRestriction`, while `OriginalBehavior` retains `FarTouch`. The 56 pinned +attachment spellings and 16 wearable spellings are case-insensitive but are +not whitespace-normalized. `root` maps to the avatar-center attachment point, +matching the reference. Secure restriction exception rules and value-sensitive +equality/hash behavior are implemented by the native mapped +`RlvRestriction`; `RlvCommon` implements the last-recognized attachment tag +rule used for inventory item names. + +## Errors and resource limits + +`RlvParseError` reports a stable `RlvParseErrorKind`, zero-based command index, +and half-open byte span. Categories distinguish missing prefix or separators, +empty fields, unknown actions/restrictions/queries, invalid UUIDs, numbers and +typed options, zero query channels, and resource-limit failures. Parsing is +linear in the message size after bounded command counting. It performs no I/O, +does not wait, and does not retain references to caller input. + +The focused malformed corpus includes deterministic deletion, replacement, and +insertion mutations of actions, restrictions, queries, UUIDs, aliases, and +multi-command messages. Every mutation must return a value or a positioned +error without panicking. The limits apply before large parser allocations. + +## Reproducible verification + +Run the issue-owned checks with one build job: + +```sh +CARGO_BUILD_JOBS=1 cargo test -p libremetaverse-rlv --locked +CARGO_BUILD_JOBS=1 cargo test -p libremetaverse-compat-tests --test rlv_common_semantics --locked +CARGO_BUILD_JOBS=1 cargo clippy -p libremetaverse-rlv --all-targets --locked -- -D warnings +RUSTDOCFLAGS='-D warnings' CARGO_BUILD_JOBS=1 cargo doc -p libremetaverse-rlv --no-deps --locked +python3 tools/check_milestone_10_issue_78.py +``` + +These commands are cross-platform. The Gitea workflow runs the audit and +workspace compile on `ubuntu-latest`. diff --git a/crates/libremetaverse-rlv/src/generated.rs b/crates/libremetaverse-rlv/src/generated.rs index f7575e2..d1bfa71 100644 --- a/crates/libremetaverse-rlv/src/generated.rs +++ b/crates/libremetaverse-rlv/src/generated.rs @@ -1107,16 +1107,14 @@ pub struct RlvCommandProcessor; impl RlvCommandProcessor {} /// C# type: `T:LibreMetaverse.RLV.RlvCommon`. -pub struct RlvCommon; +pub use crate::protocol::RlvCommon; impl RlvCommon { /// C# member: `M:LibreMetaverse.RLV.RlvCommon.TryGetAttachmentPointFromItemName(System.String,System.Nullable{LibreMetaverse.RLV.RlvAttachmentPoint}@)`. pub fn try_get_attachment_point_from_item_name( item_name: String, attachment_point: &mut Option>, ) -> bool { - libremetaverse_types::unimplemented_api!( - "M:LibreMetaverse.RLV.RlvCommon.TryGetAttachmentPointFromItemName(System.String,System.Nullable{LibreMetaverse.RLV.RlvAttachmentPoint}@)" - ) + Self::native_try_get_attachment_point_from_item_name(item_name, attachment_point) } } @@ -1834,7 +1832,7 @@ pub enum RlvPermissionsServiceTouchLocation { } /// C# type: `T:LibreMetaverse.RLV.RlvRestriction`. -pub struct RlvRestriction; +pub use crate::protocol::RlvRestriction; impl RlvRestriction { /// C# member: `M:LibreMetaverse.RLV.RlvRestriction.#ctor(LibreMetaverse.RLV.RlvRestrictionType,System.Guid,System.String,System.Collections.Generic.ICollection{System.Object})`. pub fn new( @@ -1843,51 +1841,45 @@ impl RlvRestriction { sender_name: String, args: Vec, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.RLV.RlvRestriction.#ctor(LibreMetaverse.RLV.RlvRestrictionType,System.Guid,System.String,System.Collections.Generic.ICollection{System.Object})", - ) + Self::native_new(behavior, sender, sender_name, args) } /// C# member: `M:LibreMetaverse.RLV.RlvRestriction.Equals(System.Object)`. pub fn equals(&self, obj: Option) -> bool { - libremetaverse_types::unimplemented_api!( - "M:LibreMetaverse.RLV.RlvRestriction.Equals(System.Object)" - ) + self.native_equals(obj) } /// C# member: `M:LibreMetaverse.RLV.RlvRestriction.GetHashCode`. pub fn get_hash_code(&self) -> i32 { - libremetaverse_types::unimplemented_api!("M:LibreMetaverse.RLV.RlvRestriction.GetHashCode") + self.native_get_hash_code() } /// C# member: `M:LibreMetaverse.RLV.RlvRestriction.ToString`. pub fn to_string(&self) -> String { - libremetaverse_types::unimplemented_api!("M:LibreMetaverse.RLV.RlvRestriction.ToString") + self.native_to_string() } /// C# member: `P:LibreMetaverse.RLV.RlvRestriction.Args`. pub fn args( &self, ) -> libremetaverse_types::compat::ImmutableList { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.RLV.RlvRestriction.Args") + self.native_args() } /// C# member: `P:LibreMetaverse.RLV.RlvRestriction.Behavior`. pub fn behavior(&self) -> libremetaverse_rlv::RlvRestrictionType { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.RLV.RlvRestriction.Behavior") + self.native_behavior() } /// C# member: `P:LibreMetaverse.RLV.RlvRestriction.IsException`. pub fn is_exception(&self) -> bool { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.RLV.RlvRestriction.IsException") + self.native_is_exception() } /// C# member: `P:LibreMetaverse.RLV.RlvRestriction.OriginalBehavior`. pub fn original_behavior(&self) -> libremetaverse_rlv::RlvRestrictionType { - libremetaverse_types::unimplemented_api!( - "P:LibreMetaverse.RLV.RlvRestriction.OriginalBehavior" - ) + self.native_original_behavior() } /// C# member: `P:LibreMetaverse.RLV.RlvRestriction.Sender`. pub fn sender(&self) -> libremetaverse_types::compat::Guid { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.RLV.RlvRestriction.Sender") + self.native_sender() } /// C# member: `P:LibreMetaverse.RLV.RlvRestriction.SenderName`. pub fn sender_name(&self) -> String { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.RLV.RlvRestriction.SenderName") + self.native_sender_name() } } diff --git a/crates/libremetaverse-rlv/src/lib.rs b/crates/libremetaverse-rlv/src/lib.rs index 1951123..dea1d19 100644 --- a/crates/libremetaverse-rlv/src/lib.rs +++ b/crates/libremetaverse-rlv/src/lib.rs @@ -3,6 +3,8 @@ extern crate self as libremetaverse_rlv; mod generated; +mod protocol; pub use generated::*; pub use libremetaverse_types::Error; +pub use protocol::*; diff --git a/crates/libremetaverse-rlv/src/protocol.rs b/crates/libremetaverse-rlv/src/protocol.rs new file mode 100644 index 0000000..7077e62 --- /dev/null +++ b/crates/libremetaverse-rlv/src/protocol.rs @@ -0,0 +1,1543 @@ +//! Pure RLV message parsing and typed protocol values. + +#![allow(clippy::missing_errors_doc)] // Error variants carry stable source locations. +#![allow(clippy::missing_panics_doc)] // Parser code contains no intentional panics. +#![allow(clippy::must_use_candidate)] // Mapped methods preserve their source signatures. +#![allow(clippy::needless_pass_by_value)] // Owned values mirror mapped C# signatures. +#![allow(clippy::option_option)] // Nullable out parameters require three states. +#![allow(clippy::unnecessary_wraps)] // Mapped constructors are fallible at the API boundary. +#![allow(clippy::cast_possible_truncation)] // Mapped numeric object coercion intentionally narrows to Single. + +use std::fmt; +use std::hash::{Hash, Hasher}; + +use libremetaverse_types::compat::{Guid, ImmutableList, Object}; +use libremetaverse_types::{Error, UUID}; + +use crate::{RlvAttachmentPoint, RlvRestrictionType, RlvWearableType}; + +/// Maximum accepted chat/instant-message payload size. +pub const MAX_RLV_MESSAGE_BYTES: usize = 64 * 1024; +/// Maximum comma-separated commands accepted in one payload. +pub const MAX_RLV_COMMANDS: usize = 128; + +/// Byte location in the original UTF-8 message. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct RlvSourceSpan { + pub start: usize, + pub end: usize, +} + +/// Stable malformed-input categories exposed by the native parser. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum RlvParseErrorKind { + MessageTooLong, + MissingPrefix, + TooManyCommands, + EmptyCommand, + MissingEquals, + EmptyBehavior, + EmptyParameter, + UnknownAction, + UnknownRestriction, + UnknownQuery, + UnsupportedParameter, + InvalidOption, + InvalidNumber, + InvalidUuid, + InvalidChannel, + ZeroQueryChannel, +} + +/// A typed parse failure with command index and exact byte span. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RlvParseError { + pub kind: RlvParseErrorKind, + pub command_index: usize, + pub span: RlvSourceSpan, +} + +impl fmt::Display for RlvParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "RLV {:?} at command {} bytes {}..{}", + self.kind, self.command_index, self.span.start, self.span.end + ) + } +} + +impl std::error::Error for RlvParseError {} + +/// Parsed RLV scalar with protocol type retained. +#[derive(Clone, Debug)] +pub enum RlvValue { + Integer(i32), + Real(f32), + Uuid(Guid), + String(String), + AttachmentPoint(RlvAttachmentPoint), + WearableType(RlvWearableType), + Object(Object), +} + +impl PartialEq for RlvValue { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Integer(left), Self::Integer(right)) => left == right, + (Self::Real(left), Self::Real(right)) => left.to_bits() == right.to_bits(), + (Self::Uuid(left), Self::Uuid(right)) => left == right, + (Self::String(left), Self::String(right)) => left == right, + (Self::AttachmentPoint(left), Self::AttachmentPoint(right)) => left == right, + (Self::WearableType(left), Self::WearableType(right)) => left == right, + (Self::Object(left), Self::Object(right)) => left == right, + _ => false, + } + } +} + +impl Eq for RlvValue {} + +impl Hash for RlvValue { + fn hash(&self, state: &mut H) { + std::mem::discriminant(self).hash(state); + match self { + Self::Integer(value) => value.hash(state), + Self::Real(value) => value.to_bits().hash(state), + Self::Uuid(value) => value.hash(state), + Self::String(value) => value.hash(state), + Self::AttachmentPoint(value) => value.hash(state), + Self::WearableType(value) => value.hash(state), + Self::Object(value) => value.hash(state), + } + } +} + +impl fmt::Display for RlvValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Integer(value) => value.fmt(formatter), + Self::Real(value) => value.fmt(formatter), + Self::Uuid(value) => formatter.write_str(&guid_to_string(*value)), + Self::String(value) => formatter.write_str(value), + Self::AttachmentPoint(value) => write!(formatter, "{value:?}"), + Self::WearableType(value) => write!(formatter, "{value:?}"), + Self::Object(value) => write!(formatter, "{value:?}"), + } + } +} + +/// Restriction add/remove operation selected by the RLV parameter. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum RlvRestrictionOperation { + Add, + Remove, +} + +/// A UUID, wearable, attachment point, folder path, or implicit sender target. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RlvTarget { + Sender, + Uuid(Guid), + AttachmentPoint(RlvAttachmentPoint), + WearableType(RlvWearableType), + FolderPath(String), +} + +/// A group selected either by UUID or by its case-preserved name. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RlvGroupTarget { + Uuid(Guid), + Name(String), +} + +/// Fully parsed side-effect-free action request. +#[derive(Clone, Debug, PartialEq)] +pub enum RlvAction { + SetRotation(f32), + AdjustHeight { + distance: f32, + factor: f32, + delta: f32, + }, + SetCameraFov(f32), + Teleport { + x: f32, + y: f32, + z: f32, + region: Option, + look_at: Option, + }, + Sit(Guid), + Unsit, + SitGround, + RemoveOutfit(RlvTarget), + DetachMe, + RemoveAttachment(RlvTarget), + DetachAll { + folder_path: String, + }, + DetachThis { + target: RlvTarget, + recursive: bool, + }, + SetGroup { + target: RlvGroupTarget, + role: Option, + }, + SetDebug { + name: String, + value: String, + }, + SetEnvironment { + name: String, + value: String, + }, + Attach { + folder_path: String, + replace: bool, + recursive: bool, + }, + AttachThis { + target: RlvTarget, + replace: bool, + recursive: bool, + }, +} + +/// Camera value requested by a numeric-channel query. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum RlvCameraQuery { + AvatarDistanceMin, + AvatarDistanceMax, + FovMin, + FovMax, + ZoomMin, + CurrentFov, +} + +/// Fully parsed side-effect-free query request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RlvQuery { + Version { + modern: bool, + }, + VersionNumber { + include_blacklist: bool, + }, + Blacklist { + filter: String, + }, + Status { + all_senders: bool, + filter: String, + separator: String, + }, + Camera(RlvCameraQuery), + SitId, + Outfit(Option), + Attachment(Option), + Inventory { + path: String, + worn: bool, + }, + FindFolders { + first_only: bool, + terms: Vec, + separator: String, + }, + Path { + legacy: bool, + target: RlvTarget, + }, + Group, + Debug(String), + Environment(String), +} + +/// Classified meaning of one syntactically valid RLV command. +#[derive(Clone, Debug, PartialEq)] +pub enum RlvDirective { + Clear, + Action(RlvAction), + Restriction { + behavior: RlvRestrictionType, + operation: RlvRestrictionOperation, + values: Vec, + }, + Query { + channel: i32, + query: RlvQuery, + }, +} + +/// One parsed command, retaining original and normalized token forms. +#[derive(Clone, Debug, PartialEq)] +pub struct RlvCommand { + pub raw_behavior: String, + pub behavior: String, + pub option: String, + pub raw_parameter: String, + pub parameter: String, + pub sender: Guid, + pub sender_name: String, + pub source: RlvSourceSpan, + pub directive: RlvDirective, +} + +/// Parsed comma-separated RLV message. +#[derive(Clone, Debug, PartialEq)] +pub struct RlvMessage { + pub commands: Vec, +} + +/// Pure parser for RLV chat messages. +pub struct RlvParser; + +impl RlvParser { + pub fn parse_message( + message: &str, + sender: Guid, + sender_name: impl Into, + ) -> Result { + if message.len() > MAX_RLV_MESSAGE_BYTES { + return Err(parse_error( + RlvParseErrorKind::MessageTooLong, + 0, + 0, + message.len(), + )); + } + if !message.starts_with('@') { + return Err(parse_error( + RlvParseErrorKind::MissingPrefix, + 0, + 0, + message.len(), + )); + } + let body = &message[1..]; + let count = body.bytes().filter(|byte| *byte == b',').count() + 1; + if count > MAX_RLV_COMMANDS { + return Err(parse_error( + RlvParseErrorKind::TooManyCommands, + 0, + 1, + message.len(), + )); + } + let sender_name = sender_name.into(); + let mut commands = Vec::with_capacity(count); + let mut relative_start = 0usize; + for (command_index, raw) in body.split(',').enumerate() { + let start = 1 + relative_start; + let end = start + raw.len(); + let span = RlvSourceSpan { start, end }; + commands.push(parse_command( + raw, + sender, + &sender_name, + command_index, + span, + )?); + relative_start += raw.len() + 1; + } + Ok(RlvMessage { commands }) + } +} + +fn parse_command( + raw: &str, + sender: Guid, + sender_name: &str, + command_index: usize, + span: RlvSourceSpan, +) -> Result { + if raw.is_empty() { + return Err(error_for( + RlvParseErrorKind::EmptyCommand, + command_index, + span, + )); + } + if raw.eq_ignore_ascii_case("clear") { + return Ok(RlvCommand { + raw_behavior: raw.to_owned(), + behavior: "clear".to_owned(), + option: String::new(), + raw_parameter: String::new(), + parameter: String::new(), + sender, + sender_name: sender_name.to_owned(), + source: span, + directive: RlvDirective::Clear, + }); + } + let equals = raw + .find('=') + .ok_or_else(|| error_for(RlvParseErrorKind::MissingEquals, command_index, span))?; + let left = &raw[..equals]; + let raw_parameter = &raw[equals + 1..]; + if raw_parameter.is_empty() { + return Err(error_for( + RlvParseErrorKind::EmptyParameter, + command_index, + span, + )); + } + let (raw_behavior, option) = left + .split_once(':') + .map_or((left, ""), |(behavior, option)| (behavior, option)); + if raw_behavior.is_empty() || raw_behavior.contains(':') || option.contains('=') { + return Err(error_for( + RlvParseErrorKind::EmptyBehavior, + command_index, + span, + )); + } + let behavior = raw_behavior.to_lowercase(); + let parameter = raw_parameter.to_lowercase(); + let directive = classify(&behavior, option, ¶meter) + .map_err(|kind| error_for(kind, command_index, span))?; + Ok(RlvCommand { + raw_behavior: raw_behavior.to_owned(), + behavior, + option: option.to_owned(), + raw_parameter: raw_parameter.to_owned(), + parameter, + sender, + sender_name: sender_name.to_owned(), + source: span, + directive, + }) +} + +fn classify( + behavior: &str, + option: &str, + parameter: &str, +) -> Result { + match parameter { + "force" => parse_action(behavior, option).map(RlvDirective::Action), + "n" | "add" | "y" | "rem" => { + let restriction = + restriction_from_name(behavior).ok_or(RlvParseErrorKind::UnknownRestriction)?; + let values = parse_restriction_values(restriction, option)?; + let operation = if matches!(parameter, "n" | "add") { + RlvRestrictionOperation::Add + } else { + RlvRestrictionOperation::Remove + }; + Ok(RlvDirective::Restriction { + behavior: restriction, + operation, + values, + }) + } + _ => { + let channel = parse_i32(parameter).ok_or(RlvParseErrorKind::UnsupportedParameter)?; + if channel == 0 { + return Err(RlvParseErrorKind::ZeroQueryChannel); + } + let query = parse_query(behavior, option)?; + Ok(RlvDirective::Query { channel, query }) + } + } +} + +fn parse_action(behavior: &str, option: &str) -> Result { + match behavior { + "setrot" => Ok(RlvAction::SetRotation(parse_f32(option)?)), + "adjustheight" => parse_adjust_height(option), + "setcam_fov" => Ok(RlvAction::SetCameraFov(parse_f32(option)?)), + "tpto" => parse_teleport(option), + "sit" => Ok(RlvAction::Sit(parse_guid(option)?)), + "unsit" => Ok(RlvAction::Unsit), + "sitground" => Ok(RlvAction::SitGround), + "remoutfit" => Ok(RlvAction::RemoveOutfit(parse_general_target(option))), + "detachme" => Ok(RlvAction::DetachMe), + "remattach" | "detach" => Ok(RlvAction::RemoveAttachment(parse_general_target(option))), + "detachall" => Ok(RlvAction::DetachAll { + folder_path: option.to_owned(), + }), + "detachthis" => Ok(RlvAction::DetachThis { + target: parse_this_target(option)?, + recursive: false, + }), + "detachallthis" => Ok(RlvAction::DetachThis { + target: parse_this_target(option)?, + recursive: true, + }), + "setgroup" => parse_group(option), + _ if behavior.starts_with("setdebug_") => parse_setting(behavior, option, true), + _ if behavior.starts_with("setenv_") => parse_setting(behavior, option, false), + "attach" | "addoutfit" | "attachoverorreplace" => Ok(RlvAction::Attach { + folder_path: option.to_owned(), + replace: true, + recursive: false, + }), + "attachall" | "addoutfitall" | "attachalloverorreplace" => Ok(RlvAction::Attach { + folder_path: option.to_owned(), + replace: true, + recursive: true, + }), + "attachover" | "addoutfitover" => Ok(RlvAction::Attach { + folder_path: option.to_owned(), + replace: false, + recursive: false, + }), + "attachallover" | "addoutfitallover" => Ok(RlvAction::Attach { + folder_path: option.to_owned(), + replace: false, + recursive: true, + }), + "attachthis" | "addoutfitthis" | "attachthisoverorreplace" => Ok(RlvAction::AttachThis { + target: parse_this_target(option)?, + replace: true, + recursive: false, + }), + "attachallthis" | "addoutfitallthis" | "attachallthisoverorreplace" => { + Ok(RlvAction::AttachThis { + target: parse_this_target(option)?, + replace: true, + recursive: true, + }) + } + "attachthisover" | "addoutfitthisover" => Ok(RlvAction::AttachThis { + target: parse_this_target(option)?, + replace: false, + recursive: false, + }), + "attachallthisover" | "addoutfitallthisover" => Ok(RlvAction::AttachThis { + target: parse_this_target(option)?, + replace: false, + recursive: true, + }), + _ => Err(RlvParseErrorKind::UnknownAction), + } +} + +fn parse_adjust_height(option: &str) -> Result { + let parts = nonempty_parts(option); + let Some(distance) = parts.first().and_then(|value| parse_f32_value(value)) else { + return Err(RlvParseErrorKind::InvalidNumber); + }; + let factor = parts + .get(1) + .and_then(|value| parse_f32_value(value)) + .unwrap_or(1.0); + let delta = parts + .get(2) + .and_then(|value| parse_f32_value(value)) + .unwrap_or(0.0); + Ok(RlvAction::AdjustHeight { + distance, + factor, + delta, + }) +} + +fn parse_teleport(option: &str) -> Result { + let parts = nonempty_parts(option); + let location = parts.first().ok_or(RlvParseErrorKind::InvalidOption)?; + let location: Vec<_> = location.split('/').collect(); + if !(3..=4).contains(&location.len()) { + return Err(RlvParseErrorKind::InvalidOption); + } + let base = usize::from(location.len() == 4); + let region = (base == 1).then(|| location[0].to_owned()); + let x = parse_f32(location[base])?; + let y = parse_f32(location[base + 1])?; + let z = parse_f32(location[base + 2])?; + let look_at = parts.get(1).map(|value| parse_f32(value)).transpose()?; + Ok(RlvAction::Teleport { + x, + y, + z, + region, + look_at, + }) +} + +fn parse_group(option: &str) -> Result { + let parts = nonempty_parts(option); + let name = parts.first().ok_or(RlvParseErrorKind::InvalidOption)?; + let target = parse_guid(name).map_or_else( + |_| RlvGroupTarget::Name((*name).to_owned()), + RlvGroupTarget::Uuid, + ); + Ok(RlvAction::SetGroup { + target, + role: parts.get(1).map(|value| (*value).to_owned()), + }) +} + +fn parse_setting( + behavior: &str, + option: &str, + debug: bool, +) -> Result { + let name = behavior + .split_once('_') + .map(|(_, name)| name) + .unwrap_or_default(); + if name.is_empty() { + return Err(RlvParseErrorKind::InvalidOption); + } + if debug { + Ok(RlvAction::SetDebug { + name: name.to_owned(), + value: option.to_owned(), + }) + } else { + Ok(RlvAction::SetEnvironment { + name: name.to_owned(), + value: option.to_owned(), + }) + } +} + +fn parse_general_target(option: &str) -> RlvTarget { + if option.is_empty() { + RlvTarget::Sender + } else if let Ok(value) = parse_guid(option) { + RlvTarget::Uuid(value) + } else if let Some(value) = wearable_from_name(option) { + RlvTarget::WearableType(value) + } else if let Some(value) = attachment_from_name(option) { + RlvTarget::AttachmentPoint(value) + } else { + RlvTarget::FolderPath(option.to_owned()) + } +} + +fn parse_this_target(option: &str) -> Result { + let target = parse_general_target(option); + if matches!(target, RlvTarget::FolderPath(_)) { + Err(RlvParseErrorKind::InvalidOption) + } else { + Ok(target) + } +} + +fn parse_query(behavior: &str, option: &str) -> Result { + let query = match behavior { + "version" => RlvQuery::Version { modern: false }, + "versionnew" => RlvQuery::Version { modern: true }, + "versionnum" => RlvQuery::VersionNumber { + include_blacklist: false, + }, + "versionnumbl" => RlvQuery::VersionNumber { + include_blacklist: true, + }, + "getblacklist" => RlvQuery::Blacklist { + filter: option.to_owned(), + }, + "getstatus" | "getstatusall" => { + let mut parts = option.split(';'); + let filter = parts.next().unwrap_or_default().to_lowercase(); + let separator = parts.next().unwrap_or("/").to_owned(); + RlvQuery::Status { + all_senders: behavior == "getstatusall", + filter, + separator, + } + } + "getcam_avdistmin" => RlvQuery::Camera(RlvCameraQuery::AvatarDistanceMin), + "getcam_avdistmax" => RlvQuery::Camera(RlvCameraQuery::AvatarDistanceMax), + "getcam_fovmin" => RlvQuery::Camera(RlvCameraQuery::FovMin), + "getcam_fovmax" => RlvQuery::Camera(RlvCameraQuery::FovMax), + "getcam_zoommin" => RlvQuery::Camera(RlvCameraQuery::ZoomMin), + "getcam_fov" => RlvQuery::Camera(RlvCameraQuery::CurrentFov), + "getsitid" => RlvQuery::SitId, + "getoutfit" => RlvQuery::Outfit(wearable_from_name(option)), + "getattach" => RlvQuery::Attachment(attachment_from_name(option)), + "getinv" => RlvQuery::Inventory { + path: option.to_owned(), + worn: false, + }, + "getinvworn" => RlvQuery::Inventory { + path: option.to_owned(), + worn: true, + }, + "findfolder" | "findfolders" => { + let mut parts = option.split(';'); + let terms = parts + .next() + .unwrap_or_default() + .split("&&") + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .collect(); + let separator = parts.next().unwrap_or(",").to_owned(); + RlvQuery::FindFolders { + first_only: behavior == "findfolder", + terms, + separator, + } + } + "getpath" | "getpathnew" => RlvQuery::Path { + legacy: behavior == "getpath", + target: parse_query_path_target(option)?, + }, + "getgroup" => RlvQuery::Group, + _ if behavior.starts_with("getdebug_") => { + RlvQuery::Debug(behavior["getdebug_".len()..].to_owned()) + } + _ if behavior.starts_with("getenv_") => { + RlvQuery::Environment(behavior["getenv_".len()..].to_owned()) + } + _ => return Err(RlvParseErrorKind::UnknownQuery), + }; + Ok(query) +} + +fn parse_query_path_target(option: &str) -> Result { + let parts = nonempty_parts(option); + match parts.as_slice() { + [] => Ok(RlvTarget::Sender), + [value] => parse_this_target(value), + _ => Err(RlvParseErrorKind::InvalidOption), + } +} + +#[allow(clippy::too_many_lines)] // One exhaustive match documents every protocol option grammar. +fn parse_restriction_values( + behavior: RlvRestrictionType, + option: &str, +) -> Result, RlvParseErrorKind> { + use RlvRestrictionType as T; + let args = nonempty_parts(option); + let first_float = || { + args.first() + .ok_or(RlvParseErrorKind::InvalidOption) + .and_then(|value| parse_f32(value)) + .map(|value| vec![RlvValue::Real(value)]) + }; + let one_int = || { + one(&args) + .and_then(|value| parse_i32(value).ok_or(RlvParseErrorKind::InvalidNumber)) + .map(|value| vec![RlvValue::Integer(value)]) + }; + let optional_guid = || { + optional_one(&args) + .and_then(|value| value.map(parse_guid).transpose()) + .map(|value| value.into_iter().map(RlvValue::Uuid).collect()) + }; + match behavior { + T::Notify => { + let channel = args + .first() + .and_then(|value| parse_i32(value)) + .ok_or(RlvParseErrorKind::InvalidChannel)?; + let mut values = vec![RlvValue::Integer(channel)]; + if args.len() == 2 { + values.push(RlvValue::String(args[1].to_owned())); + } + Ok(values) + } + T::CamDrawMin | T::CamDrawMax => { + let values = first_float()?; + if matches!(values.first(), Some(RlvValue::Real(value)) if *value < 0.40) { + Err(RlvParseErrorKind::InvalidOption) + } else { + Ok(values) + } + } + T::CamZoomMax + | T::CamZoomMin + | T::SetCamFovMin + | T::SetCamFovMax + | T::CamDistMax + | T::SetCamAvDistMax + | T::CamDistMin + | T::SetCamAvDistMin + | T::CamDrawAlphaMin + | T::CamDrawAlphaMax + | T::CamAvDist => first_float(), + T::SitTp | T::FarTouch | T::TouchFar | T::TpLocal => optional_one(&args) + .and_then(|value| value.map(parse_f32).transpose()) + .map(|value| value.into_iter().map(RlvValue::Real).collect()), + T::CamDrawColor => { + if args.len() != 3 { + return Err(RlvParseErrorKind::InvalidOption); + } + args.iter() + .map(|value| parse_f32(value).map(RlvValue::Real)) + .collect() + } + T::RedirChat | T::RedirEmote | T::SendChannelExcept => one_int(), + T::SendChannel | T::SendChannelSec => optional_one(&args) + .and_then(|value| { + value + .map(|value| parse_i32(value).ok_or(RlvParseErrorKind::InvalidNumber)) + .transpose() + }) + .map(|value| value.into_iter().map(RlvValue::Integer).collect()), + T::SendImTo | T::RecvImFrom => one(&args).map(|value| { + vec![ + parse_guid(value) + .map_or_else(|_| RlvValue::String(value.to_owned()), RlvValue::Uuid), + ] + }), + T::SendIm | T::RecvIm => optional_one(&args).map(|value| { + value + .map(|value| { + parse_guid(value) + .map_or_else(|_| RlvValue::String(value.to_owned()), RlvValue::Uuid) + }) + .into_iter() + .collect() + }), + T::Detach | T::AddAttach | T::RemAttach => optional_one(&args) + .and_then(|value| { + value + .map(|value| { + attachment_from_name(value).ok_or(RlvParseErrorKind::InvalidOption) + }) + .transpose() + }) + .map(|value| value.into_iter().map(RlvValue::AttachmentPoint).collect()), + T::AddOutfit | T::RemOutfit => optional_one(&args) + .and_then(|value| { + value + .map(|value| wearable_from_name(value).ok_or(RlvParseErrorKind::InvalidOption)) + .transpose() + }) + .map(|value| value.into_iter().map(RlvValue::WearableType).collect()), + T::DetachThis | T::DetachAllThis | T::AttachThis | T::AttachAllThis => optional_one(&args) + .map(|value| { + value + .map(|value| { + wearable_from_name(value) + .map(RlvValue::WearableType) + .or_else(|| attachment_from_name(value).map(RlvValue::AttachmentPoint)) + .unwrap_or_else(|| RlvValue::String(value.to_owned())) + }) + .into_iter() + .collect() + }), + T::DetachThisExcept + | T::DetachAllThisExcept + | T::AttachThisExcept + | T::AttachAllThisExcept => { + one(&args).map(|value| vec![RlvValue::String(value.to_owned())]) + } + T::CamTextures + | T::SetCamTextures + | T::RecvChat + | T::RecvEmote + | T::StartIm + | T::TpLure + | T::AcceptTp + | T::AcceptTpRequest + | T::TpRequest + | T::Edit + | T::Share + | T::TouchWorld + | T::TouchAttachOther + | T::TouchHud + | T::ShowNames + | T::ShowNamesSec + | T::ShowNameTags => optional_guid(), + T::RecvChatFrom + | T::RecvEmoteFrom + | T::StartImTo + | T::EditObj + | T::TouchThis + | T::ShowHoverText => one(&args) + .and_then(parse_guid) + .map(|value| vec![RlvValue::Uuid(value)]), + _ if restriction_takes_no_values(behavior) => { + if args.is_empty() { + Ok(Vec::new()) + } else { + Err(RlvParseErrorKind::InvalidOption) + } + } + _ => Err(RlvParseErrorKind::InvalidOption), + } +} + +fn restriction_takes_no_values(value: RlvRestrictionType) -> bool { + use RlvRestrictionType as T; + matches!( + value, + T::Permissive + | T::SendChat + | T::ChatShout + | T::ChatNormal + | T::ChatWhisper + | T::Emote + | T::RecvChatSec + | T::RecvEmoteSec + | T::SendGesture + | T::SendImSec + | T::RecvImSec + | T::TpLureSec + | T::TpRequestSec + | T::ShareSec + | T::Fly + | T::Jump + | T::TempRun + | T::AlwaysRun + | T::CamUnlock + | T::SetCamUnlock + | T::TpLm + | T::TpLoc + | T::StandTp + | T::ShowInv + | T::ViewNote + | T::ViewScript + | T::ViewTexture + | T::Unsit + | T::Sit + | T::DefaultWear + | T::SetGroup + | T::SetDebug + | T::SetEnv + | T::AllowIdle + | T::ShowWorldMap + | T::ShowMiniMap + | T::ShowLoc + | T::ShowNearby + | T::EditWorld + | T::EditAttach + | T::Rez + | T::DenyPermission + | T::AcceptPermission + | T::UnsharedWear + | T::UnsharedUnwear + | T::SharedWear + | T::SharedUnwear + | T::TouchAll + | T::TouchMe + | T::TouchAttach + | T::TouchAttachSelf + | T::Interact + | T::ShowHoverTextAll + | T::ShowHoverTextHud + | T::ShowHoverTextWorld + ) +} + +/// Native mapped RLV helpers. +pub struct RlvCommon; + +impl RlvCommon { + pub(crate) fn native_try_get_attachment_point_from_item_name( + item_name: String, + output: &mut Option>, + ) -> bool { + *output = None; + let mut remaining = item_name.as_str(); + let mut found = None; + while let Some(open) = remaining.find('(') { + let after_open = &remaining[open + 1..]; + let Some(close) = after_open.find(')') else { + break; + }; + if let Some(point) = attachment_from_name(&after_open[..close]) { + found = Some(point); + } + remaining = &after_open[close + 1..]; + } + if let Some(point) = found { + *output = Some(Some(point)); + true + } else { + false + } + } +} + +/// Native mapped immutable restriction value. +#[derive(Clone, Debug)] +pub struct RlvRestriction { + behavior: RlvRestrictionType, + original_behavior: RlvRestrictionType, + sender: Guid, + sender_name: String, + args: ImmutableList, + values: Vec, +} + +impl RlvRestriction { + pub(crate) fn native_new( + behavior: RlvRestrictionType, + sender: Guid, + sender_name: String, + args: Vec, + ) -> Result { + let values = args + .iter() + .cloned() + .map(|value| value_from_object(behavior, value)) + .collect(); + Ok(Self::from_parts( + behavior, + sender, + sender_name, + args.into(), + values, + )) + } + + pub fn from_values( + original_behavior: RlvRestrictionType, + sender: Guid, + sender_name: String, + values: Vec, + ) -> Self { + let args = values + .iter() + .cloned() + .map(value_to_object) + .collect::>() + .into(); + Self::from_parts(original_behavior, sender, sender_name, args, values) + } + + fn from_parts( + original_behavior: RlvRestrictionType, + sender: Guid, + sender_name: String, + args: ImmutableList, + values: Vec, + ) -> Self { + let mut behavior = real_restriction(original_behavior); + if behavior == RlvRestrictionType::SendChannelSec && args.len() == 1 { + behavior = RlvRestrictionType::SendChannel; + } else if behavior == RlvRestrictionType::ShowNamesSec && args.len() == 1 { + behavior = RlvRestrictionType::ShowNames; + } + Self { + behavior, + original_behavior, + sender, + sender_name, + args, + values, + } + } + + pub fn values(&self) -> &[RlvValue] { + &self.values + } + + pub(crate) fn native_equals(&self, obj: Option) -> bool { + obj.as_ref().and_then(Object::downcast_ref::) == Some(self) + } + + pub(crate) fn native_get_hash_code(&self) -> i32 { + let mut hash = StableHasher::default(); + self.hash(&mut hash); + let bytes = hash.finish().to_le_bytes(); + i32::from_le_bytes([ + bytes[0] ^ bytes[4], + bytes[1] ^ bytes[5], + bytes[2] ^ bytes[6], + bytes[3] ^ bytes[7], + ]) + } + + pub(crate) fn native_to_string(&self) -> String { + let args = self + .args + .iter() + .map(object_to_string) + .collect::>() + .join(", "); + format!( + "RlvRestriction: Behavior={:?} SenderName=\"{}\" Args=[{}]", + self.behavior, self.sender_name, args + ) + } + + pub(crate) fn native_args(&self) -> ImmutableList { + self.args.clone() + } + pub(crate) const fn native_behavior(&self) -> RlvRestrictionType { + self.behavior + } + pub(crate) fn native_is_exception(&self) -> bool { + is_exception(self.behavior, self.args.len()) + } + pub(crate) const fn native_original_behavior(&self) -> RlvRestrictionType { + self.original_behavior + } + pub(crate) const fn native_sender(&self) -> Guid { + self.sender + } + pub(crate) fn native_sender_name(&self) -> String { + self.sender_name.clone() + } +} + +impl PartialEq for RlvRestriction { + fn eq(&self, other: &Self) -> bool { + self.behavior == other.behavior && self.sender == other.sender && self.args == other.args + } +} +impl Eq for RlvRestriction {} +impl Hash for RlvRestriction { + fn hash(&self, state: &mut H) { + self.behavior.hash(state); + self.sender.hash(state); + self.args.hash(state); + } +} + +fn is_exception(behavior: RlvRestrictionType, value_count: usize) -> bool { + use RlvRestrictionType as T; + matches!( + behavior, + T::DetachThisExcept | T::DetachAllThisExcept | T::AttachThisExcept | T::AttachAllThisExcept + ) || (value_count > 0 + && matches!( + behavior, + T::RecvEmote + | T::RecvChat + | T::SendIm + | T::StartIm + | T::RecvIm + | T::SendChannel + | T::TpRequest + | T::TpLure + | T::Edit + | T::Share + | T::TouchWorld + | T::ShowNamesSec + | T::ShowNames + | T::ShowNameTags + | T::AcceptTp + | T::AcceptTpRequest + )) +} + +const fn real_restriction(value: RlvRestrictionType) -> RlvRestrictionType { + match value { + RlvRestrictionType::CamDistMax => RlvRestrictionType::SetCamAvDistMax, + RlvRestrictionType::CamDistMin => RlvRestrictionType::SetCamAvDistMin, + RlvRestrictionType::CamUnlock => RlvRestrictionType::SetCamUnlock, + RlvRestrictionType::CamTextures => RlvRestrictionType::SetCamTextures, + RlvRestrictionType::FarTouch => RlvRestrictionType::TouchFar, + other => other, + } +} + +macro_rules! restriction_table { + ($macro:ident) => { + $macro! { + "notify" => Notify, "permissive" => Permissive, "fly" => Fly, "jump" => Jump, + "temprun" => TempRun, "alwaysrun" => AlwaysRun, "camzoommax" => CamZoomMax, + "camzoommin" => CamZoomMin, "camdrawmin" => CamDrawMin, "camdrawmax" => CamDrawMax, + "setcam_fovmin" => SetCamFovMin, "setcam_fovmax" => SetCamFovMax, + "camdistmax" => CamDistMax, "camdistmin" => CamDistMin, + "camdrawalphamin" => CamDrawAlphaMin, "camdrawalphamax" => CamDrawAlphaMax, + "setcam_avdistmax" => SetCamAvDistMax, "setcam_avdistmin" => SetCamAvDistMin, + "camdrawcolor" => CamDrawColor, "camunlock" => CamUnlock, + "setcam_unlock" => SetCamUnlock, "camavdist" => CamAvDist, + "camtextures" => CamTextures, "setcam_textures" => SetCamTextures, + "sendchat" => SendChat, "chatshout" => ChatShout, "chatnormal" => ChatNormal, + "chatwhisper" => ChatWhisper, "redirchat" => RedirChat, "recvchat" => RecvChat, + "recvchat_sec" => RecvChatSec, "recvchatfrom" => RecvChatFrom, + "sendgesture" => SendGesture, "emote" => Emote, "rediremote" => RedirEmote, + "recvemote" => RecvEmote, "recvemotefrom" => RecvEmoteFrom, + "recvemote_sec" => RecvEmoteSec, "sendchannel" => SendChannel, + "sendchannel_sec" => SendChannelSec, "sendchannel_except" => SendChannelExcept, + "sendim" => SendIm, "sendim_sec" => SendImSec, "sendimto" => SendImTo, + "startim" => StartIm, "startimto" => StartImTo, "recvim" => RecvIm, + "recvim_sec" => RecvImSec, "recvimfrom" => RecvImFrom, "tplocal" => TpLocal, + "tplm" => TpLm, "tploc" => TpLoc, "tplure" => TpLure, "tplure_sec" => TpLureSec, + "sittp" => SitTp, "standtp" => StandTp, "accepttp" => AcceptTp, + "accepttprequest" => AcceptTpRequest, "tprequest" => TpRequest, + "tprequest_sec" => TpRequestSec, "showinv" => ShowInv, "viewnote" => ViewNote, + "viewscript" => ViewScript, "viewtexture" => ViewTexture, "edit" => Edit, + "rez" => Rez, "editobj" => EditObj, "editworld" => EditWorld, + "editattach" => EditAttach, "share" => Share, "share_sec" => ShareSec, + "unsit" => Unsit, "sit" => Sit, "detach" => Detach, "addattach" => AddAttach, + "remattach" => RemAttach, "defaultwear" => DefaultWear, "addoutfit" => AddOutfit, + "remoutfit" => RemOutfit, "acceptpermission" => AcceptPermission, + "denypermission" => DenyPermission, "unsharedwear" => UnsharedWear, + "unsharedunwear" => UnsharedUnwear, "sharedwear" => SharedWear, + "sharedunwear" => SharedUnwear, "detachthis" => DetachThis, + "detachallthis" => DetachAllThis, "attachthis" => AttachThis, + "attachallthis" => AttachAllThis, "detachthis_except" => DetachThisExcept, + "detachallthis_except" => DetachAllThisExcept, "attachthis_except" => AttachThisExcept, + "attachallthis_except" => AttachAllThisExcept, "fartouch" => FarTouch, + "touchfar" => TouchFar, "touchall" => TouchAll, "touchworld" => TouchWorld, + "touchthis" => TouchThis, "touchme" => TouchMe, "touchattach" => TouchAttach, + "touchattachself" => TouchAttachSelf, "touchattachother" => TouchAttachOther, + "touchhud" => TouchHud, "interact" => Interact, "showworldmap" => ShowWorldMap, + "showminimap" => ShowMiniMap, "showloc" => ShowLoc, "shownames" => ShowNames, + "shownames_sec" => ShowNamesSec, "shownametags" => ShowNameTags, + "shownearby" => ShowNearby, "showhovertextall" => ShowHoverTextAll, + "showhovertext" => ShowHoverText, "showhovertexthud" => ShowHoverTextHud, + "showhovertextworld" => ShowHoverTextWorld, "setgroup" => SetGroup, + "setdebug" => SetDebug, "setenv" => SetEnv, "allowidle" => AllowIdle + } + }; +} + +macro_rules! make_restriction_lookup { + ($($name:literal => $variant:ident),+ $(,)?) => { + /// Complete pinned behavior-name table in protocol order. + pub const RLV_RESTRICTION_NAMES: &[(&str, RlvRestrictionType)] = &[ + $(($name, RlvRestrictionType::$variant),)+ + ]; + + /// Maps the exact RLV behavior spelling to its typed restriction. + pub fn restriction_from_name(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + $($name => Some(RlvRestrictionType::$variant),)+ + _ => None, + } + } + + /// Returns the canonical protocol spelling for a restriction. + pub const fn restriction_name(value: RlvRestrictionType) -> &'static str { + match value { $(RlvRestrictionType::$variant => $name,)+ } + } + }; +} +restriction_table!(make_restriction_lookup); + +/// Maps a case-insensitive attachment alias without trimming or punctuation changes. +pub fn attachment_from_name(name: &str) -> Option { + use RlvAttachmentPoint as P; + Some(match name.to_ascii_lowercase().as_str() { + "none" => P::Default, + "chest" => P::Chest, + "skull" => P::Skull, + "left shoulder" => P::LeftShoulder, + "right shoulder" => P::RightShoulder, + "left hand" => P::LeftHand, + "right hand" => P::RightHand, + "left foot" => P::LeftFoot, + "right foot" => P::RightFoot, + "spine" => P::Spine, + "pelvis" => P::Pelvis, + "mouth" => P::Mouth, + "chin" => P::Chin, + "left ear" => P::LeftEar, + "right ear" => P::RightEar, + "left eyeball" => P::LeftEyeball, + "right eyeball" => P::RightEyeball, + "nose" => P::Nose, + "r upper arm" => P::RightUpperArm, + "r forearm" => P::RightForearm, + "l upper arm" => P::LeftUpperArm, + "l forearm" => P::LeftForearm, + "right hip" => P::RightHip, + "r upper leg" => P::RightUpperLeg, + "r lower leg" => P::RightLowerLeg, + "left hip" => P::LeftHip, + "l upper leg" => P::LeftUpperLeg, + "l lower leg" => P::LeftLowerLeg, + "stomach" => P::Stomach, + "left pec" => P::LeftPec, + "right pec" => P::RightPec, + "center 2" => P::HUDCenter2, + "top right" => P::HUDTopRight, + "top" => P::HUDTop, + "top left" => P::HUDTopLeft, + "center" => P::HUDCenter, + "bottom left" => P::HUDBottomLeft, + "bottom" => P::HUDBottom, + "bottom right" => P::HUDBottomRight, + "neck" => P::Neck, + "root" | "avatar center" => P::AvatarCenter, + "left ring finger" => P::LeftHandRing, + "right ring finger" => P::RightHandRing, + "tail base" => P::TailBase, + "tail tip" => P::TailTip, + "left wing" => P::LeftWing, + "right wing" => P::RightWing, + "jaw" => P::Jaw, + "alt left ear" => P::AltLeftEar, + "alt right ear" => P::AltRightEar, + "alt left eye" => P::AltLeftEye, + "alt right eye" => P::AltRightEye, + "tongue" => P::Tongue, + "groin" => P::Groin, + "left hind foot" => P::LeftHindFoot, + "right hind foot" => P::RightHindFoot, + _ => return None, + }) +} + +/// Maps a case-insensitive wearable alias without trimming. +pub fn wearable_from_name(name: &str) -> Option { + use RlvWearableType as W; + Some(match name.to_ascii_lowercase().as_str() { + "gloves" => W::Gloves, + "jacket" => W::Jacket, + "pants" => W::Pants, + "shirt" => W::Shirt, + "shoes" => W::Shoes, + "skirt" => W::Skirt, + "socks" => W::Socks, + "underpants" => W::Underpants, + "undershirt" => W::Undershirt, + "skin" => W::Skin, + "eyes" => W::Eyes, + "hair" => W::Hair, + "shape" => W::Shape, + "alpha" => W::Alpha, + "tattoo" => W::Tattoo, + "physics" => W::Physics, + "universal" => W::Universal, + _ => return None, + }) +} + +fn one<'a>(args: &[&'a str]) -> Result<&'a str, RlvParseErrorKind> { + if let [value] = args { + Ok(value) + } else { + Err(RlvParseErrorKind::InvalidOption) + } +} + +fn optional_one<'a>(args: &[&'a str]) -> Result, RlvParseErrorKind> { + match args { + [] => Ok(None), + [value] => Ok(Some(value)), + _ => Err(RlvParseErrorKind::InvalidOption), + } +} + +fn nonempty_parts(value: &str) -> Vec<&str> { + value.split(';').filter(|part| !part.is_empty()).collect() +} + +fn parse_f32(value: &str) -> Result { + parse_f32_value(value).ok_or(RlvParseErrorKind::InvalidNumber) +} + +fn parse_f32_value(value: &str) -> Option { + value.trim().parse::().ok() +} + +fn parse_i32(value: &str) -> Option { + value.trim().parse::().ok() +} + +fn parse_guid(value: &str) -> Result { + UUID::parse(value.to_owned()) + .map(|value| value.guid()) + .map_err(|_| RlvParseErrorKind::InvalidUuid) +} + +fn guid_to_string(value: Guid) -> String { + UUID::new_with_guid(value).map_or_else( + |_| "00000000-0000-0000-0000-000000000000".to_owned(), + |value| value.to_string(), + ) +} + +fn value_to_object(value: RlvValue) -> Object { + match value { + RlvValue::Integer(value) => Object::Integer(value), + RlvValue::Real(value) => Object::Real(f64::from(value)), + RlvValue::Uuid(value) => UUID::new_with_guid(value).map_or(Object::Undefined, Object::UUID), + RlvValue::String(value) => Object::String(value), + RlvValue::AttachmentPoint(value) => Object::Integer(value as i32), + RlvValue::WearableType(value) => Object::Integer(value as i32), + RlvValue::Object(value) => value, + } +} + +fn object_to_string(value: &Object) -> String { + match value { + Object::Undefined => String::new(), + Object::Boolean(value) => value.to_string(), + Object::Integer(value) => value.to_string(), + Object::UInteger(value) => value.to_string(), + Object::Long(value) => value.to_string(), + Object::ULong(value) => value.to_string(), + Object::Real(value) => value.to_string(), + Object::String(value) => value.clone(), + Object::UUID(value) => value.to_string(), + Object::Opaque(value) => format!("{value:?}"), + other => format!("{other:?}"), + } +} + +fn value_from_object(behavior: RlvRestrictionType, value: Object) -> RlvValue { + if let Some(value) = value.downcast_ref::() { + return RlvValue::AttachmentPoint(*value); + } + if let Some(value) = value.downcast_ref::() { + return RlvValue::WearableType(*value); + } + match value { + Object::Integer(value) + if matches!( + behavior, + RlvRestrictionType::Detach + | RlvRestrictionType::AddAttach + | RlvRestrictionType::RemAttach + ) => + { + attachment_from_discriminant(value) + .map_or(RlvValue::Integer(value), RlvValue::AttachmentPoint) + } + Object::Integer(value) + if matches!( + behavior, + RlvRestrictionType::AddOutfit | RlvRestrictionType::RemOutfit + ) => + { + wearable_from_discriminant(value) + .map_or(RlvValue::Integer(value), RlvValue::WearableType) + } + Object::Integer(value) => RlvValue::Integer(value), + Object::Real(value) => RlvValue::Real(value as f32), + Object::UUID(value) => RlvValue::Uuid(value.guid()), + Object::String(value) => RlvValue::String(value), + other => RlvValue::Object(other), + } +} + +fn attachment_from_discriminant(value: i32) -> Option { + (0..=55).find_map(|candidate| { + let point = attachment_from_name(restriction_attachment_name(candidate)?)?; + (point as i32 == value).then_some(point) + }) +} + +fn restriction_attachment_name(value: i32) -> Option<&'static str> { + const NAMES: [&str; 56] = [ + "none", + "chest", + "skull", + "left shoulder", + "right shoulder", + "left hand", + "right hand", + "left foot", + "right foot", + "spine", + "pelvis", + "mouth", + "chin", + "left ear", + "right ear", + "left eyeball", + "right eyeball", + "nose", + "r upper arm", + "r forearm", + "l upper arm", + "l forearm", + "right hip", + "r upper leg", + "r lower leg", + "left hip", + "l upper leg", + "l lower leg", + "stomach", + "left pec", + "right pec", + "center 2", + "top right", + "top", + "top left", + "center", + "bottom left", + "bottom", + "bottom right", + "neck", + "avatar center", + "left ring finger", + "right ring finger", + "tail base", + "tail tip", + "left wing", + "right wing", + "jaw", + "alt left ear", + "alt right ear", + "alt left eye", + "alt right eye", + "tongue", + "groin", + "left hind foot", + "right hind foot", + ]; + usize::try_from(value) + .ok() + .and_then(|index| NAMES.get(index).copied()) +} + +fn wearable_from_discriminant(value: i32) -> Option { + [ + RlvWearableType::Shape, + RlvWearableType::Skin, + RlvWearableType::Hair, + RlvWearableType::Eyes, + RlvWearableType::Shirt, + RlvWearableType::Pants, + RlvWearableType::Shoes, + RlvWearableType::Socks, + RlvWearableType::Jacket, + RlvWearableType::Gloves, + RlvWearableType::Undershirt, + RlvWearableType::Underpants, + RlvWearableType::Skirt, + RlvWearableType::Alpha, + RlvWearableType::Tattoo, + RlvWearableType::Physics, + RlvWearableType::Universal, + RlvWearableType::Invalid, + ] + .into_iter() + .find(|item| *item as i32 == value) +} + +const fn parse_error( + kind: RlvParseErrorKind, + command_index: usize, + start: usize, + end: usize, +) -> RlvParseError { + RlvParseError { + kind, + command_index, + span: RlvSourceSpan { start, end }, + } +} + +const fn error_for( + kind: RlvParseErrorKind, + command_index: usize, + span: RlvSourceSpan, +) -> RlvParseError { + RlvParseError { + kind, + command_index, + span, + } +} + +#[derive(Default)] +struct StableHasher(u64); +impl Hasher for StableHasher { + fn finish(&self) -> u64 { + self.0 + } + fn write(&mut self, bytes: &[u8]) { + let mut hash = if self.0 == 0 { + 0xcbf2_9ce4_8422_2325 + } else { + self.0 + }; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x1000_0000_01b3); + } + self.0 = hash; + } +} diff --git a/crates/libremetaverse-rlv/tests/protocol_parsing.rs b/crates/libremetaverse-rlv/tests/protocol_parsing.rs new file mode 100644 index 0000000..1db0fe5 --- /dev/null +++ b/crates/libremetaverse-rlv/tests/protocol_parsing.rs @@ -0,0 +1,443 @@ +#![allow(clippy::float_cmp)] // Fixture values are exactly representable protocol literals. + +use libremetaverse_rlv::{ + RLV_RESTRICTION_NAMES, RlvAction, RlvAttachmentPoint, RlvCameraQuery, RlvCommon, RlvDirective, + RlvGroupTarget, RlvParseErrorKind, RlvParser, RlvQuery, RlvRestriction, + RlvRestrictionOperation, RlvRestrictionType, RlvTarget, RlvValue, RlvWearableType, + attachment_from_name, restriction_from_name, restriction_name, wearable_from_name, +}; +use libremetaverse_types::UUID; +use libremetaverse_types::compat::{Guid, Object}; + +fn sender() -> Guid { + UUID::parse("ffffffff-ffff-4fff-8fff-ffffffffffff".to_owned()) + .expect("fixture UUID") + .guid() +} + +fn parse(message: &str) -> libremetaverse_rlv::RlvMessage { + RlvParser::parse_message(message, sender(), "Sender Name").expect("valid RLV message") +} + +#[test] +fn message_preserves_original_casing_options_and_byte_locations() { + let parsed = parse("@FlY=ADD,getdebug_RenderMode:MiXeD=+123"); + assert_eq!(parsed.commands.len(), 2); + let first = &parsed.commands[0]; + assert_eq!(first.raw_behavior, "FlY"); + assert_eq!(first.behavior, "fly"); + assert_eq!(first.raw_parameter, "ADD"); + assert_eq!(first.parameter, "add"); + assert_eq!(first.source.start, 1); + assert_eq!(first.source.end, 8); + assert!(matches!( + first.directive, + RlvDirective::Restriction { + behavior: RlvRestrictionType::Fly, + operation: RlvRestrictionOperation::Add, + ref values, + } if values.is_empty() + )); + let second = &parsed.commands[1]; + assert_eq!(second.raw_behavior, "getdebug_RenderMode"); + assert_eq!(second.behavior, "getdebug_rendermode"); + assert_eq!(second.option, "MiXeD"); + assert!(matches!( + second.directive, + RlvDirective::Query { + channel: 123, + query: RlvQuery::Debug(ref name), + } if name == "rendermode" + )); +} + +#[test] +fn bare_clear_and_all_parameter_classes_are_typed() { + assert!(matches!( + parse("@ClEaR").commands[0].directive, + RlvDirective::Clear + )); + assert!(matches!( + parse("@unsit=FoRcE").commands[0].directive, + RlvDirective::Action(RlvAction::Unsit) + )); + assert!(matches!( + parse("@jump=Y").commands[0].directive, + RlvDirective::Restriction { + operation: RlvRestrictionOperation::Remove, + .. + } + )); + assert!(matches!( + parse("@version= 42 ").commands[0].directive, + RlvDirective::Query { + channel: 42, + query: RlvQuery::Version { modern: false } + } + )); +} + +#[test] +fn action_aliases_and_targets_remain_distinct() { + let attach = &parse("@addoutfitallover:Clothing/Hats=force").commands[0].directive; + assert!(matches!( + attach, + RlvDirective::Action(RlvAction::Attach { + folder_path, + replace: false, + recursive: true, + }) if folder_path == "Clothing/Hats" + )); + let this = &parse("@attachthisoverorreplace:R UPPER ARM=force").commands[0].directive; + assert!(matches!( + this, + RlvDirective::Action(RlvAction::AttachThis { + target: RlvTarget::AttachmentPoint(RlvAttachmentPoint::RightUpperArm), + replace: true, + recursive: false, + }) + )); + let remove = &parse("@remoutfit:Tattoo=force").commands[0].directive; + assert!(matches!( + remove, + RlvDirective::Action(RlvAction::RemoveOutfit(RlvTarget::WearableType( + RlvWearableType::Tattoo + ))) + )); +} + +#[test] +fn numeric_uuid_group_and_teleport_options_are_validated() { + let adjusted = &parse("@adjustheight:1.25;2;bad;ignored=force").commands[0].directive; + assert!(matches!( + adjusted, + RlvDirective::Action(RlvAction::AdjustHeight { distance, factor, delta }) + if *distance == 1.25 && *factor == 2.0 && *delta == 0.0 + )); + let teleported = &parse("@tpto:Region Name/1/2/3;4=force").commands[0].directive; + assert!(matches!( + teleported, + RlvDirective::Action(RlvAction::Teleport { + x, + y, + z, + region: Some(region), + look_at: Some(look_at), + }) if *x == 1.0 && *y == 2.0 && *z == 3.0 && region == "Region Name" && *look_at == 4.0 + )); + let grouped = &parse("@setgroup:My Group;Role Name=force").commands[0].directive; + assert!(matches!( + grouped, + RlvDirective::Action(RlvAction::SetGroup { + target: RlvGroupTarget::Name(name), + role: Some(role), + }) if name == "My Group" && role == "Role Name" + )); +} + +#[test] +fn query_variants_preserve_filters_separators_paths_and_aliases() { + let status = &parse("@getstatus:TP; ! =7").commands[0].directive; + assert!(matches!( + status, + RlvDirective::Query { + query: RlvQuery::Status { all_senders: false, filter, separator }, + channel: 7, + } if filter == "tp" && separator == " ! " + )); + let folders = &parse("@findfolders:Hat&&Red;|=8").commands[0].directive; + assert!(matches!( + folders, + RlvDirective::Query { + query: RlvQuery::FindFolders { first_only: false, terms, separator }, + .. + } if terms == &["Hat", "Red"] && separator == "|" + )); + let path = &parse("@getpathnew:root=9").commands[0].directive; + assert!(matches!( + path, + RlvDirective::Query { + query: RlvQuery::Path { + legacy: false, + target: RlvTarget::AttachmentPoint(RlvAttachmentPoint::AvatarCenter), + }, + .. + } + )); + assert!(matches!( + parse("@getcam_fov=10").commands[0].directive, + RlvDirective::Query { + query: RlvQuery::Camera(RlvCameraQuery::CurrentFov), + .. + } + )); +} + +#[test] +fn restriction_options_are_strongly_typed() { + let notify = &parse("@notify:1234;sendim=add").commands[0].directive; + assert!(matches!( + notify, + RlvDirective::Restriction { values, .. } + if values == &[RlvValue::Integer(1234), RlvValue::String("sendim".to_owned())] + )); + let attachment = &parse("@detach:R Upper Arm=n").commands[0].directive; + assert!(matches!( + attachment, + RlvDirective::Restriction { values, .. } + if values == &[RlvValue::AttachmentPoint(RlvAttachmentPoint::RightUpperArm)] + )); + let wearable = &parse("@remoutfit:UNIVERSAL=n").commands[0].directive; + assert!(matches!( + wearable, + RlvDirective::Restriction { values, .. } + if values == &[RlvValue::WearableType(RlvWearableType::Universal)] + )); + let uuid = "00000000-0000-4000-8000-000000000000"; + assert!(matches!( + &parse(&format!("@accepttp:{uuid}=add")).commands[0].directive, + RlvDirective::Restriction { values, .. } if matches!(values.as_slice(), [RlvValue::Uuid(_)]) + )); + + // The pinned parser reads the first camera scalar and deliberately ignores + // later semicolon fields for this family. + assert!(matches!( + &parse("@camzoommax:2.5;ignored;3=n").commands[0].directive, + RlvDirective::Restriction { values, .. } + if values == &[RlvValue::Real(2.5)] + )); +} + +#[test] +fn every_pinned_restriction_name_round_trips_without_normalization() { + assert_eq!(RLV_RESTRICTION_NAMES.len(), 119); + for (name, restriction) in RLV_RESTRICTION_NAMES { + assert_eq!(restriction_from_name(name), Some(*restriction)); + assert_eq!(restriction_name(*restriction), *name); + } + assert_eq!( + restriction_from_name("TouchFar"), + Some(RlvRestrictionType::TouchFar) + ); + assert_eq!(restriction_from_name("touch-far"), None); +} + +#[test] +fn every_pinned_restriction_has_at_least_one_typed_valid_option_form() { + let uuid = "00000000-0000-4000-8000-000000000000"; + let candidates = [ + "", + "1", + "1.0", + "1;1;1", + uuid, + "chest", + "shirt", + "Folder Name", + ]; + for (name, expected) in RLV_RESTRICTION_NAMES { + let accepted = candidates.iter().any(|option| { + let message = format!("@{name}:{option}=n"); + RlvParser::parse_message(&message, sender(), "sender") + .ok() + .is_some_and(|message| { + matches!( + message.commands[0].directive, + RlvDirective::Restriction { behavior, .. } if behavior == *expected + ) + }) + }); + assert!(accepted, "no valid typed option form for {name}"); + } +} + +#[test] +fn attachment_and_wearable_alias_tables_are_case_insensitive_but_not_trimmed() { + assert_eq!( + attachment_from_name("ROOT"), + Some(RlvAttachmentPoint::AvatarCenter) + ); + assert_eq!( + attachment_from_name("l upper leg"), + Some(RlvAttachmentPoint::LeftUpperLeg) + ); + assert_eq!(attachment_from_name(" chest "), None); + assert_eq!( + wearable_from_name("UnderShirt"), + Some(RlvWearableType::Undershirt) + ); + assert_eq!( + wearable_from_name("universal"), + Some(RlvWearableType::Universal) + ); + assert_eq!(wearable_from_name(" shirt "), None); +} + +#[test] +fn mapped_attachment_tag_helper_uses_last_recognized_tag() { + let mut output = None; + assert!(RlvCommon::try_get_attachment_point_from_item_name( + "Item (mouth) (unknown) (SPINE)".to_owned(), + &mut output, + )); + assert_eq!(output, Some(Some(RlvAttachmentPoint::Spine))); + assert!(!RlvCommon::try_get_attachment_point_from_item_name( + "Item (unknown)".to_owned(), + &mut output, + )); + assert_eq!(output, None); +} + +#[test] +fn malformed_corpus_has_stable_categories_locations_and_never_panics() { + let cases = [ + ("", RlvParseErrorKind::MissingPrefix), + ("fly=n", RlvParseErrorKind::MissingPrefix), + ("@", RlvParseErrorKind::EmptyCommand), + ("@fly", RlvParseErrorKind::MissingEquals), + ("@=n", RlvParseErrorKind::EmptyBehavior), + ("@fly=", RlvParseErrorKind::EmptyParameter), + ("@unknown=force", RlvParseErrorKind::UnknownAction), + ("@unknown=n", RlvParseErrorKind::UnknownRestriction), + ("@unknown=123", RlvParseErrorKind::UnknownQuery), + ("@fly=maybe", RlvParseErrorKind::UnsupportedParameter), + ("@version=0", RlvParseErrorKind::ZeroQueryChannel), + ("@sit:not-a-uuid=force", RlvParseErrorKind::InvalidUuid), + ("@camdrawmin:0.39=n", RlvParseErrorKind::InvalidOption), + ("@detach: chest =n", RlvParseErrorKind::InvalidOption), + ("@fly=n,", RlvParseErrorKind::EmptyCommand), + ]; + for (message, expected) in cases { + let result = + std::panic::catch_unwind(|| RlvParser::parse_message(message, sender(), "sender")); + let error = result.expect("parser never panics").expect_err(message); + assert_eq!(error.kind, expected, "{message}"); + assert!(error.span.start <= error.span.end); + assert!(error.span.end <= message.len()); + } +} + +#[test] +fn deterministic_single_byte_mutations_are_bounded_and_never_panic() { + const SEEDS: &[&str] = &[ + "@fly=n", + "@version=42", + "@notify:1234;sendim=add", + "@attachthisoverorreplace:r upper arm=force", + "@sit:00000000-0000-4000-8000-000000000000=force", + "@getstatus:tp;|=7,@getcam_fov=8", + ]; + const REPLACEMENTS: &[u8] = b"@,:=;0An?-_ "; + + let mut corpus = Vec::new(); + for seed in SEEDS { + for index in 0..seed.len() { + let mut deleted = seed.as_bytes().to_vec(); + deleted.remove(index); + corpus.push(String::from_utf8(deleted).expect("ASCII fixture")); + + for replacement in REPLACEMENTS { + let mut replaced = seed.as_bytes().to_vec(); + replaced[index] = *replacement; + corpus.push(String::from_utf8(replaced).expect("ASCII fixture")); + } + } + for index in 0..=seed.len() { + for insertion in REPLACEMENTS { + let mut inserted = seed.as_bytes().to_vec(); + inserted.insert(index, *insertion); + corpus.push(String::from_utf8(inserted).expect("ASCII fixture")); + } + } + } + + assert!(corpus.len() >= 4_000, "mutation corpus unexpectedly small"); + for message in corpus { + let result = std::panic::catch_unwind(|| { + RlvParser::parse_message(&message, sender(), "mutated sender") + }); + let parsed = result.expect("bounded parser must not panic for any mutation"); + if let Err(error) = parsed { + assert!(error.span.start <= error.span.end, "{message:?}"); + assert!(error.span.end <= message.len(), "{message:?}"); + } + } +} + +#[test] +fn command_count_and_message_size_are_bounded() { + let oversized_count = format!( + "@{}", + std::iter::repeat_n("fly=n", 129) + .collect::>() + .join(",") + ); + assert_eq!( + RlvParser::parse_message(&oversized_count, sender(), "sender") + .expect_err("too many commands") + .kind, + RlvParseErrorKind::TooManyCommands + ); + let oversized_message = format!("@getinv:{}=1", "x".repeat(64 * 1024)); + assert_eq!( + RlvParser::parse_message(&oversized_message, sender(), "sender") + .expect_err("oversized message") + .kind, + RlvParseErrorKind::MessageTooLong + ); +} + +#[test] +fn mapped_restriction_preserves_alias_exception_equality_and_snapshot_args() { + let first = RlvRestriction::new( + RlvRestrictionType::FarTouch, + sender(), + "Object A".to_owned(), + vec![Object::Real(2.5)], + ) + .expect("restriction"); + let second = RlvRestriction::new( + RlvRestrictionType::FarTouch, + sender(), + "Renamed Object".to_owned(), + vec![Object::Real(2.5)], + ) + .expect("restriction"); + assert_eq!(first.behavior(), RlvRestrictionType::TouchFar); + assert_eq!(first.original_behavior(), RlvRestrictionType::FarTouch); + assert!(!first.is_exception()); + assert_eq!(first, second); + assert_eq!(first.get_hash_code(), second.get_hash_code()); + assert_eq!(first.args().as_slice(), &[Object::Real(2.5)]); + assert!(first.to_string().contains("Behavior=TouchFar")); + assert!(first.equals(Some(Object::opaque(second)))); + + let exception = RlvRestriction::from_values( + RlvRestrictionType::RecvChat, + sender(), + "Object".to_owned(), + vec![RlvValue::Uuid(sender())], + ); + assert!(exception.is_exception()); + + let exact_real = 2.500_000_000_000_001_f64; + let exact = RlvRestriction::new( + RlvRestrictionType::CamZoomMax, + sender(), + "Exact value".to_owned(), + vec![Object::Real(exact_real)], + ) + .expect("restriction"); + assert_eq!(exact.args().as_slice(), &[Object::Real(exact_real)]); + let rounded = RlvRestriction::new( + RlvRestrictionType::CamZoomMax, + sender(), + "Rounded value".to_owned(), + vec![Object::Real(2.5)], + ) + .expect("restriction"); + assert_ne!( + exact, rounded, + "mapped equality uses the exact Args snapshot" + ); +} diff --git a/crates/libremetaverse-types/src/compat.rs b/crates/libremetaverse-types/src/compat.rs index 074246f..6aba83b 100644 --- a/crates/libremetaverse-types/src/compat.rs +++ b/crates/libremetaverse-types/src/compat.rs @@ -1153,7 +1153,64 @@ impl Future for AsyncEnumerableNext { pub struct ImmutableDictionary(pub PhantomData); -pub struct ImmutableList(pub PhantomData); +/// Read-only, cheaply clonable snapshot corresponding to +/// `System.Collections.Immutable.ImmutableList`. +#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)] +pub struct ImmutableList(Arc<[T]>); + +impl ImmutableList { + /// Creates an immutable snapshot from owned values. + #[must_use] + pub fn from_vec(values: Vec) -> Self { + Self(values.into()) + } + + /// Returns the snapshot as a slice. + #[must_use] + pub fn as_slice(&self) -> &[T] { + &self.0 + } + + /// Returns the number of values in the snapshot. + #[must_use] + pub fn len(&self) -> usize { + self.0.len() + } + + /// Returns whether the snapshot contains no values. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Iterates over the snapshot. + pub fn iter(&self) -> std::slice::Iter<'_, T> { + self.0.iter() + } +} + +impl From> for ImmutableList { + fn from(values: Vec) -> Self { + Self::from_vec(values) + } +} + +impl std::ops::Deref for ImmutableList { + type Target = [T]; + + fn deref(&self) -> &Self::Target { + self.as_slice() + } +} + +impl<'a, T> IntoIterator for &'a ImmutableList { + type Item = &'a T; + type IntoIter = std::slice::Iter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} pub struct ICollection; diff --git a/tools/check_milestone_10_issue_78.py b/tools/check_milestone_10_issue_78.py new file mode 100644 index 0000000..3fa7e31 --- /dev/null +++ b/tools/check_milestone_10_issue_78.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Audit issue 78's pure native RLV protocol ownership and evidence boundary.""" + +from __future__ import annotations + +from pathlib import Path +import re + +import generate_api_shims + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "crates" / "libremetaverse-rlv" / "src" / "protocol.rs" +GENERATED = ROOT / "crates" / "libremetaverse-rlv" / "src" / "generated.rs" +TESTS = ROOT / "crates" / "libremetaverse-rlv" / "tests" / "protocol_parsing.rs" +COMPAT = ROOT / "tests" / "compat" / "tests" / "rlv_common_semantics.rs" +DOC = ROOT / "crates" / "libremetaverse-rlv" / "README.md" +WORKFLOW = ROOT / ".gitea" / "workflows" / "rust-workspace.yml" +STUB_RE = re.compile(r"\b(?:not_implemented|unimplemented_api)\b|\b(?:todo|unimplemented)!\s*\(") + +TYPES = { + "T:LibreMetaverse.RLV.RlvCommon": "crate::protocol::RlvCommon", + "T:LibreMetaverse.RLV.RlvRestriction": "crate::protocol::RlvRestriction", +} +MEMBERS = { + "M:LibreMetaverse.RLV.RlvCommon.TryGetAttachmentPointFromItemName(System.String,System.Nullable{LibreMetaverse.RLV.RlvAttachmentPoint}@)", + "M:LibreMetaverse.RLV.RlvRestriction.#ctor(LibreMetaverse.RLV.RlvRestrictionType,System.Guid,System.String,System.Collections.Generic.ICollection{System.Object})", + "M:LibreMetaverse.RLV.RlvRestriction.Equals(System.Object)", + "M:LibreMetaverse.RLV.RlvRestriction.GetHashCode", + "M:LibreMetaverse.RLV.RlvRestriction.ToString", + "P:LibreMetaverse.RLV.RlvRestriction.Args", + "P:LibreMetaverse.RLV.RlvRestriction.Behavior", + "P:LibreMetaverse.RLV.RlvRestriction.IsException", + "P:LibreMetaverse.RLV.RlvRestriction.OriginalBehavior", + "P:LibreMetaverse.RLV.RlvRestriction.Sender", + "P:LibreMetaverse.RLV.RlvRestriction.SenderName", +} + + +def require_markers(path: Path, markers: tuple[str, ...]) -> None: + text = path.read_text() + missing = [marker for marker in markers if marker not in text] + if missing: + raise SystemExit(f"{path.name}: audit evidence missing: " + ", ".join(missing)) + + +def generated_type_block(text: str, rust_name: str) -> str: + marker = f"pub use crate::protocol::{rust_name};" + start = text.find(marker) + if start < 0: + raise SystemExit(f"generated declaration for {rust_name} is missing") + next_type = text.find("\n/// C# type:", start + len(marker)) + return text[start:] if next_type < 0 else text[start:next_type] + + +def main() -> None: + for api_type, declaration in TYPES.items(): + if generate_api_shims.NATIVE_DECLARATIONS.get(api_type) != declaration: + raise SystemExit(f"issue 78 native declaration is missing for {api_type}") + missing = sorted(MEMBERS - set(generate_api_shims.NATIVE_MEMBER_BODIES)) + if missing: + raise SystemExit("issue 78 native members missing: " + ", ".join(missing)) + source = SOURCE.read_text() + if STUB_RE.search(source): + raise SystemExit("issue 78 owned Rust stubs remain in protocol.rs") + table = source[source.index("macro_rules! restriction_table") : source.index("macro_rules! make_restriction_lookup")] + if len(re.findall(r'"[^"]+"\s*=>\s*[A-Za-z0-9_]+', table)) != 119: + raise SystemExit("issue 78 restriction table does not contain exactly 119 names") + generated = GENERATED.read_text() + for rust_name in ("RlvCommon", "RlvRestriction"): + if STUB_RE.search(generated_type_block(generated, rust_name)): + raise SystemExit(f"issue 78 owned generated stubs remain for {rust_name}") + + require_markers(SOURCE, ( + "MAX_RLV_MESSAGE_BYTES", "MAX_RLV_COMMANDS", "pub struct RlvSourceSpan", + "pub enum RlvParseErrorKind", "pub enum RlvDirective", "pub enum RlvAction", + "pub enum RlvQuery", "pub enum RlvValue", "pub fn parse_message", + "parse_restriction_values", "RLV_RESTRICTION_NAMES", + '"root" | "avatar center" => P::AvatarCenter', "native_try_get_attachment_point", + "pub struct RlvRestriction", "real_restriction", "is_exception", + )) + require_markers(TESTS, ( + "message_preserves_original_casing_options_and_byte_locations", + "query_variants_preserve_filters_separators_paths_and_aliases", + "every_pinned_restriction_name_round_trips_without_normalization", + "every_pinned_restriction_has_at_least_one_typed_valid_option_form", + "deterministic_single_byte_mutations_are_bounded_and_never_panic", + "command_count_and_message_size_are_bounded", + "mapped_restriction_preserves_alias_exception_equality_and_snapshot_args", + )) + require_markers(COMPAT, ( + "attachment_point_uses_last_known_tag", + "attachment_point_avatar_center", + "attachment_point_root_alias", + "attachment_point_rejects_unknown_tags", + )) + require_markers(DOC, ( + "side-effect-free", "119 behavior", "56 pinned", "half-open byte span", + "64 KiB", "128 comma-separated", "does not wait", "ubuntu-latest", + )) + require_markers(WORKFLOW, ("python3 tools/check_milestone_10_issue_78.py",)) + print( + "issue 78 audit: bounded pure RLV parsing, typed commands/queries/restrictions, " + "exact aliases, positioned errors, mapped values, mutation evidence, and docs are present" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/generate_api_shims.py b/tools/generate_api_shims.py index 4f9fb07..ed57ddb 100644 --- a/tools/generate_api_shims.py +++ b/tools/generate_api_shims.py @@ -466,6 +466,8 @@ NATIVE_TYPES = { # below. This is used for static namespace types such as OSDParser: the type is # hand-written, while its fixed public methods remain generator-audited. NATIVE_DECLARATIONS = { + "T:LibreMetaverse.RLV.RlvCommon": "crate::protocol::RlvCommon", + "T:LibreMetaverse.RLV.RlvRestriction": "crate::protocol::RlvRestriction", "T:LibreMetaverse.AgentManager": "crate::agent_manager::AgentManager", "T:LibreMetaverse.Animation": "crate::avatar_manager::Animation", "T:LibreMetaverse.Animesh.AnimationTrack": "crate::animesh_runtime::AnimationTrack", @@ -508,6 +510,28 @@ NATIVE_DECLARATIONS = { } NATIVE_MEMBER_BODIES = { + "M:LibreMetaverse.RLV.RlvCommon.TryGetAttachmentPointFromItemName(System.String,System.Nullable{LibreMetaverse.RLV.RlvAttachmentPoint}@)": + "Self::native_try_get_attachment_point_from_item_name(item_name, attachment_point)", + "M:LibreMetaverse.RLV.RlvRestriction.#ctor(LibreMetaverse.RLV.RlvRestrictionType,System.Guid,System.String,System.Collections.Generic.ICollection{System.Object})": + "Self::native_new(behavior, sender, sender_name, args)", + "M:LibreMetaverse.RLV.RlvRestriction.Equals(System.Object)": + "self.native_equals(obj)", + "M:LibreMetaverse.RLV.RlvRestriction.GetHashCode": + "self.native_get_hash_code()", + "M:LibreMetaverse.RLV.RlvRestriction.ToString": + "self.native_to_string()", + "P:LibreMetaverse.RLV.RlvRestriction.Args": + "self.native_args()", + "P:LibreMetaverse.RLV.RlvRestriction.Behavior": + "self.native_behavior()", + "P:LibreMetaverse.RLV.RlvRestriction.IsException": + "self.native_is_exception()", + "P:LibreMetaverse.RLV.RlvRestriction.OriginalBehavior": + "self.native_original_behavior()", + "P:LibreMetaverse.RLV.RlvRestriction.Sender": + "self.native_sender()", + "P:LibreMetaverse.RLV.RlvRestriction.SenderName": + "self.native_sender_name()", "M:LibreMetaverse.Rendering.MeshFoundry.#ctor": "Self::native_new()", "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)": "self.native_generate_faceted_mesh(prim, lod)",