Implement typed RLV protocol parser (#78)
Some checks failed
Native code generation / deterministic (push) Failing after 2m0s
Imaging and meshing gate / native (push) Failing after 4m11s
JPEG 2000 feature / linux (push) Successful in 2m52s
Native Rust workspace compile / compile (push) Failing after 6m15s
Skia feature / linux (push) Has been cancelled
Some checks failed
Native code generation / deterministic (push) Failing after 2m0s
Imaging and meshing gate / native (push) Failing after 4m11s
JPEG 2000 feature / linux (push) Successful in 2m52s
Native Rust workspace compile / compile (push) Failing after 6m15s
Skia feature / linux (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
|
||||
12
README.md
12
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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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" }
|
||||
|
||||
73
crates/libremetaverse-rlv/README.md
Normal file
73
crates/libremetaverse-rlv/README.md
Normal file
@@ -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`.
|
||||
@@ -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<Option<libremetaverse_rlv::RlvAttachmentPoint>>,
|
||||
) -> 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<libremetaverse_types::compat::Object>,
|
||||
) -> Result<Self, crate::Error> {
|
||||
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<libremetaverse_types::compat::Object>) -> 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::compat::Object> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
1543
crates/libremetaverse-rlv/src/protocol.rs
Normal file
1543
crates/libremetaverse-rlv/src/protocol.rs
Normal file
File diff suppressed because it is too large
Load Diff
443
crates/libremetaverse-rlv/tests/protocol_parsing.rs
Normal file
443
crates/libremetaverse-rlv/tests/protocol_parsing.rs
Normal file
@@ -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::<Vec<_>>()
|
||||
.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"
|
||||
);
|
||||
}
|
||||
@@ -1153,7 +1153,64 @@ impl<T> Future for AsyncEnumerableNext<T> {
|
||||
|
||||
pub struct ImmutableDictionary<TKey, TValue>(pub PhantomData<fn(TKey, TValue)>);
|
||||
|
||||
pub struct ImmutableList<T>(pub PhantomData<fn(T)>);
|
||||
/// Read-only, cheaply clonable snapshot corresponding to
|
||||
/// `System.Collections.Immutable.ImmutableList<T>`.
|
||||
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
|
||||
pub struct ImmutableList<T>(Arc<[T]>);
|
||||
|
||||
impl<T> ImmutableList<T> {
|
||||
/// Creates an immutable snapshot from owned values.
|
||||
#[must_use]
|
||||
pub fn from_vec(values: Vec<T>) -> 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<T> From<Vec<T>> for ImmutableList<T> {
|
||||
fn from(values: Vec<T>) -> Self {
|
||||
Self::from_vec(values)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for ImmutableList<T> {
|
||||
type Target = [T];
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.as_slice()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> IntoIterator for &'a ImmutableList<T> {
|
||||
type Item = &'a T;
|
||||
type IntoIter = std::slice::Iter<'a, T>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ICollection;
|
||||
|
||||
|
||||
109
tools/check_milestone_10_issue_78.py
Normal file
109
tools/check_milestone_10_issue_78.py
Normal file
@@ -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()
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user