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

This commit is contained in:
2026-08-10 23:12:02 +00:00
parent 88408c9680
commit 468a0f0619
12 changed files with 2282 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View 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`.

View File

@@ -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()
}
}

View File

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

File diff suppressed because it is too large Load Diff

View 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"
);
}

View File

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

View 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()

View File

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