Implement native RLV state and permissions (#79)
Some checks failed
Native code generation / deterministic (push) Failing after 2m1s
Imaging and meshing gate / native (push) Successful in 5m22s
JPEG 2000 feature / linux (push) Successful in 2m45s
Native Rust workspace compile / compile (push) Failing after 6m12s
Skia feature / linux (push) Successful in 30m44s

This commit is contained in:
2026-08-10 23:55:11 +00:00
parent 468a0f0619
commit 362fa62059
12 changed files with 3429 additions and 347 deletions

View File

@@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Bounded native RLV protocol parsing and compatibility types for MetaCrate"
description = "Bounded native RLV protocol, state, inventory, lock, camera, and permission layer"
[dependencies]
libremetaverse-types = { path = "../libremetaverse-types" }

View File

@@ -1,11 +1,13 @@
# Native RLV protocol layer
`libremetaverse-rlv` implements the side-effect-free protocol boundary for the
`libremetaverse-rlv` implements the protocol and pure state 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.
side-effect-free parser converts one bounded chat message into typed commands.
Independent, thread-safe managers then store restrictions and evaluate
inventory, folder locks, camera limits, blacklists, and permissions without
performing network I/O. Command execution, viewer callbacks, replies, and
`GridClient` integration remain separate milestone work that consumes this
layer.
## Message contract
@@ -57,6 +59,33 @@ 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.
## State, inventory, and permission contract
`RlvSharedFolder` and `RlvInventoryItem` preserve the reference semantics of
their C# counterparts. `InventoryMap` walks a bounded shared-inventory tree and
publishes immutable dictionary/list membership snapshots. Path lookup handles
hidden/private prefixes, exact names containing forward slashes, and the
reference implementation's longest matching segment rule. Lookup by item,
attached prim, attachment point, and wearable type does not call a client or
inventory service.
`RlvRestrictionManager` deduplicates exact restrictions, retains deterministic
insertion order, removes all state for selected object sources, and rebuilds
immutable locked-folder snapshots when either restrictions or inventory
changes. Recursive and non-recursive attach/detach locks support sender-item,
attachment, wearable, and path targets plus their exception variants. Update
handlers run only after manager locks are released, so callbacks can safely
query the manager again. Poisoned synchronization primitives recover their
owned state instead of making later reads fail.
`RlvPermissionsService` evaluates simple restrictions, secure and explicit
target rules, permissive exception precedence, IM/chat/channel behavior,
teleport limits, edit/touch/hover decisions, shared and unshared wear, and
folder attachment locks. Camera aggregation applies the reference min/max,
clamping, averaging, alias, texture, and lock rules. The case-insensitive
blacklist returns a sorted snapshot. These providers are deterministic,
thread-safe, and contain no network or callback dependencies.
## Reproducible verification
Run the issue-owned checks with one build job:
@@ -64,9 +93,11 @@ 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 test -p libremetaverse-compat-tests --test rlv_inventory_map_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
python3 tools/check_milestone_10_issue_79.py
```
These commands are cross-platform. The Gitea workflow runs the audit and

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,9 @@ extern crate self as libremetaverse_rlv;
mod generated;
mod protocol;
mod state;
pub use generated::*;
pub use libremetaverse_types::Error;
pub use protocol::*;
pub use state::*;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,602 @@
#![allow(clippy::float_cmp)] // Protocol fixture values are exactly representable.
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use std::thread;
use libremetaverse_rlv::{
CameraSettings, InventoryMap, RlvAttachmentPoint, RlvBlacklist, RlvInventoryItem,
RlvPermissionsService, RlvPermissionsServiceHoverTextLocation,
RlvPermissionsServiceObjectLocation, RlvPermissionsServiceTouchLocation, RlvRestriction,
RlvRestrictionManager, RlvRestrictionType, RlvSharedFolder, RlvValue, RlvWearableType,
};
use libremetaverse_types::compat::Guid;
fn guid(byte: u8) -> Guid {
Guid([byte; 16])
}
fn restriction(behavior: RlvRestrictionType, sender: u8, values: Vec<RlvValue>) -> RlvRestriction {
RlvRestriction::from_values(behavior, guid(sender), format!("sender-{sender}"), values)
}
fn poll_ready<F: Future>(future: F) -> F::Output {
let waker = Waker::noop();
let mut context = Context::from_waker(waker);
let mut future = std::pin::pin!(future);
match future.as_mut().poll(&mut context) {
Poll::Ready(output) => output,
Poll::Pending => panic!("pure state future unexpectedly yielded"),
}
}
#[test]
fn manager_deduplicates_updates_and_dispatches_after_unlocking() {
let manager = RlvRestrictionManager::new();
let events = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&events);
let callback_manager = manager.clone();
let _subscription = manager.subscribe_restriction_updated(Some(Arc::new(move |event| {
// A re-entrant read would deadlock if callbacks ran under the state lock.
let count = callback_manager
.find_restrictions(None, None)
.unwrap()
.len();
captured
.lock()
.unwrap()
.push((event.is_new(), event.is_deleted(), count));
})));
let fly = restriction(RlvRestrictionType::Fly, 1, vec![]);
assert!(manager.add_restriction(fly.clone()).unwrap());
assert!(!manager.add_restriction(fly.clone()).unwrap());
assert_eq!(
manager.get_tracked_prim_ids().unwrap().collect::<Vec<_>>(),
[guid(1)]
);
assert!(manager.remove_restriction(&fly));
assert!(!manager.remove_restriction(&fly));
assert_eq!(
*events.lock().unwrap(),
[(true, false, 1), (false, true, 0)]
);
}
#[test]
fn manager_handles_concurrent_independent_sources_deterministically() {
let manager = RlvRestrictionManager::new();
let mut workers = Vec::new();
for sender in 1..=24 {
let worker_manager = manager.clone();
workers.push(thread::spawn(move || {
worker_manager
.add_restriction(restriction(RlvRestrictionType::Jump, sender, vec![]))
.unwrap()
}));
}
for worker in workers {
assert!(worker.join().unwrap());
}
let restrictions = manager
.get_restrictions_by_type(RlvRestrictionType::Jump)
.unwrap();
assert_eq!(restrictions.len(), 24);
let mut tracked = manager.get_tracked_prim_ids().unwrap().collect::<Vec<_>>();
tracked.sort_by_key(|value| value.0);
assert_eq!(tracked, (1..=24).map(guid).collect::<Vec<_>>());
}
#[test]
fn concurrent_lock_rebuilds_cannot_publish_a_stale_revision() {
let root = RlvSharedFolder::new(guid(1), "#RLV".into()).unwrap();
let first = root.add_child(guid(2), "first".into()).unwrap();
let second = root.add_child(guid(3), "second".into()).unwrap();
first
.add_item(
guid(4),
"first attachment".into(),
false,
None,
Some(Some(guid(40))),
None,
None,
)
.unwrap();
second
.add_item(
guid(5),
"second attachment".into(),
false,
None,
Some(Some(guid(50))),
None,
None,
)
.unwrap();
let manager = RlvRestrictionManager::new();
manager.set_inventory_map(Some(InventoryMap::new(root, vec![]).unwrap()));
let barrier = Arc::new(std::sync::Barrier::new(3));
let mut workers = Vec::new();
for sender in [40, 50] {
let worker_manager = manager.clone();
let worker_barrier = Arc::clone(&barrier);
workers.push(thread::spawn(move || {
worker_barrier.wait();
worker_manager
.add_restriction(restriction(RlvRestrictionType::DetachThis, sender, vec![]))
.unwrap();
}));
}
barrier.wait();
for worker in workers {
worker.join().unwrap();
}
let locked = manager.get_locked_folders().unwrap();
assert_eq!(locked.len(), 2);
assert!(!locked[&first.id()].can_detach());
assert!(!locked[&second.id()].can_detach());
}
#[test]
fn source_removal_counts_repeated_inputs_before_deduplication() {
let manager = RlvRestrictionManager::new();
manager
.add_restriction(restriction(RlvRestrictionType::Fly, 1, vec![]))
.unwrap();
let repeated = std::iter::repeat_n(guid(1), 100_001);
assert!(poll_ready(manager.remove_restrictions_for_objects(Box::new(repeated), None)).is_err());
assert_eq!(manager.find_restrictions(None, None).unwrap().len(), 1);
}
#[test]
fn blacklist_is_case_insensitive_sorted_and_thread_safe() {
let blacklist = RlvBlacklist::new();
let mut workers = Vec::new();
for behavior in ["Fly", "JUMP", "sit", "CamZoomMax"] {
let worker_blacklist = blacklist.clone();
workers.push(thread::spawn(move || {
worker_blacklist
.blacklist_behavior(behavior.to_owned())
.unwrap();
}));
}
for worker in workers {
worker.join().unwrap();
}
assert_eq!(
blacklist.get_blacklist().unwrap(),
["camzoommax", "fly", "jump", "sit"]
);
assert!(blacklist.is_blacklisted("fLy".into()).unwrap());
blacklist.un_blacklist_behavior("FLY".into()).unwrap();
assert!(!blacklist.is_blacklisted("fly".into()).unwrap());
}
#[test]
fn camera_restrictions_apply_minimum_maximum_alias_and_last_value_rules() {
let manager = RlvRestrictionManager::new();
for (sender, value) in [(4, 0.4), (7, 0.7)] {
manager
.add_restriction(restriction(
RlvRestrictionType::CamZoomMin,
sender,
vec![RlvValue::Real(value)],
))
.unwrap();
}
for (sender, value) in [(20, 2.0), (12, 1.2)] {
manager
.add_restriction(restriction(
RlvRestrictionType::CamZoomMax,
sender,
vec![RlvValue::Real(value)],
))
.unwrap();
}
manager
.add_restriction(restriction(
RlvRestrictionType::CamDistMin,
31,
vec![RlvValue::Real(3.0)],
))
.unwrap();
manager
.add_restriction(restriction(
RlvRestrictionType::CamDrawColor,
32,
vec![
RlvValue::Real(0.0),
RlvValue::Real(0.5),
RlvValue::Real(1.0),
],
))
.unwrap();
manager
.add_restriction(restriction(
RlvRestrictionType::CamDrawColor,
33,
vec![
RlvValue::Real(1.0),
RlvValue::Real(1.5),
RlvValue::Real(-1.0),
],
))
.unwrap();
manager
.add_restriction(restriction(RlvRestrictionType::CamUnlock, 34, vec![]))
.unwrap();
let camera = RlvPermissionsService::new(manager)
.get_camera_restrictions()
.unwrap();
assert_eq!(camera.zoom_min().flatten(), Some(0.7));
assert_eq!(camera.zoom_max().flatten(), Some(1.2));
assert_eq!(camera.av_dist_min().flatten(), Some(3.0));
assert_eq!(camera.draw_color().flatten().unwrap().0, [0.5, 0.75, 0.5]);
assert!(camera.is_locked());
}
#[test]
fn camera_settings_preserve_constructor_field_order() {
let settings = CameraSettings::new(1.0, 2.0, 3.0, 4.0, 5.0, 6.0).unwrap();
assert_eq!(settings.av_dist_min(), 1.0);
assert_eq!(settings.av_dist_max(), 2.0);
assert_eq!(settings.fov_min(), 3.0);
assert_eq!(settings.fov_max(), 4.0);
assert_eq!(settings.zoom_min(), 5.0);
assert_eq!(settings.current_fov(), 6.0);
}
#[test]
fn permission_exceptions_are_targeted_and_secure_rules_take_precedence() {
let manager = RlvRestrictionManager::new();
let permissions = RlvPermissionsService::new(manager.clone());
let allowed_user = guid(90);
let other_user = guid(91);
manager
.add_restriction(restriction(RlvRestrictionType::SendIm, 1, vec![]))
.unwrap();
assert!(
!permissions
.can_send_im("hello".into(), Some(Some(allowed_user)), None)
.unwrap()
);
manager
.add_restriction(restriction(
RlvRestrictionType::SendIm,
1,
vec![RlvValue::Uuid(allowed_user)],
))
.unwrap();
assert!(
permissions
.can_send_im("hello".into(), Some(Some(allowed_user)), None)
.unwrap()
);
assert!(
!permissions
.can_send_im("hello".into(), Some(Some(other_user)), None)
.unwrap()
);
manager
.add_restriction(restriction(RlvRestrictionType::SendImSec, 2, vec![]))
.unwrap();
assert!(
!permissions
.can_send_im("hello".into(), Some(Some(allowed_user)), None)
.unwrap()
);
}
#[test]
fn simple_interaction_and_location_permissions_compose() {
let manager = RlvRestrictionManager::new();
let permissions = RlvPermissionsService::new(manager.clone());
assert!(permissions.can_fly().unwrap());
assert!(permissions.can_sit().unwrap());
assert!(permissions.can_rez().unwrap());
assert!(
permissions
.can_edit(RlvPermissionsServiceObjectLocation::RezzedInWorld, None)
.unwrap()
);
let interact = restriction(RlvRestrictionType::Interact, 1, vec![]);
manager.add_restriction(interact.clone()).unwrap();
assert!(!permissions.can_sit().unwrap());
assert!(!permissions.can_rez().unwrap());
assert!(
!permissions
.can_edit(RlvPermissionsServiceObjectLocation::Hud, None)
.unwrap()
);
assert!(manager.remove_restriction(&interact));
manager
.add_restriction(restriction(RlvRestrictionType::EditWorld, 2, vec![]))
.unwrap();
assert!(
!permissions
.can_edit(RlvPermissionsServiceObjectLocation::RezzedInWorld, None)
.unwrap()
);
assert!(
permissions
.can_edit(RlvPermissionsServiceObjectLocation::Attached, None)
.unwrap()
);
}
#[test]
fn distance_and_channel_outputs_apply_defaults_minima_and_precedence() {
let manager = RlvRestrictionManager::new();
let permissions = RlvPermissionsService::new(manager.clone());
let mut distance = -1.0;
assert!(!permissions.can_sit_tp(&mut distance).unwrap());
assert_eq!(distance, 1.5);
manager
.add_restriction(restriction(
RlvRestrictionType::SitTp,
1,
vec![RlvValue::Real(4.0)],
))
.unwrap();
manager
.add_restriction(restriction(
RlvRestrictionType::SitTp,
2,
vec![RlvValue::Real(2.0)],
))
.unwrap();
assert!(permissions.can_sit_tp(&mut distance).unwrap());
assert_eq!(distance, 2.0);
manager
.add_restriction(restriction(
RlvRestrictionType::RedirChat,
3,
vec![RlvValue::Integer(7)],
))
.unwrap();
manager
.add_restriction(restriction(
RlvRestrictionType::RedirChat,
4,
vec![RlvValue::Integer(7)],
))
.unwrap();
let mut channels = Vec::new();
assert!(permissions.try_get_redir_chat_channels(&mut channels));
assert_eq!(channels, [7]);
manager
.add_restriction(restriction(RlvRestrictionType::SendChannel, 5, vec![]))
.unwrap();
assert!(!permissions.can_chat(8, "private".into()).unwrap());
manager
.add_restriction(restriction(
RlvRestrictionType::SendChannel,
5,
vec![RlvValue::Integer(8)],
))
.unwrap();
assert!(permissions.can_chat(8, "private".into()).unwrap());
manager
.add_restriction(restriction(
RlvRestrictionType::SendChannelExcept,
6,
vec![RlvValue::Integer(8)],
))
.unwrap();
assert!(!permissions.can_chat(8, "private".into()).unwrap());
}
#[test]
fn touch_and_hover_rules_apply_early_overrides_and_explicit_targets() {
let manager = RlvRestrictionManager::new();
let permissions = RlvPermissionsService::new(manager.clone());
let prim = guid(80);
manager
.add_restriction(restriction(RlvRestrictionType::TouchAll, 1, vec![]))
.unwrap();
assert!(
!permissions
.can_touch(
RlvPermissionsServiceTouchLocation::RezzedInWorld,
prim,
None,
None,
)
.unwrap()
);
manager
.add_restriction(restriction(RlvRestrictionType::TouchMe, 80, vec![]))
.unwrap();
assert!(
permissions
.can_touch(
RlvPermissionsServiceTouchLocation::RezzedInWorld,
prim,
None,
None,
)
.unwrap()
);
manager
.add_restriction(restriction(
RlvRestrictionType::FarTouch,
2,
vec![RlvValue::Real(1.5)],
))
.unwrap();
assert!(
!permissions
.can_touch(
RlvPermissionsServiceTouchLocation::RezzedInWorld,
prim,
None,
Some(Some(2.0)),
)
.unwrap()
);
manager
.add_restriction(restriction(
RlvRestrictionType::ShowHoverText,
3,
vec![RlvValue::Uuid(prim)],
))
.unwrap();
assert!(
!permissions
.can_show_hover_text(
RlvPermissionsServiceHoverTextLocation::World,
Some(Some(prim)),
)
.unwrap()
);
assert!(
permissions
.can_show_hover_text(
RlvPermissionsServiceHoverTextLocation::World,
Some(Some(guid(81))),
)
.unwrap()
);
}
#[test]
fn attach_and_detach_permissions_combine_item_type_source_and_folder_locks() {
let root = RlvSharedFolder::new(guid(1), "#RLV".into()).unwrap();
let folder = root.add_child(guid(2), "clothes".into()).unwrap();
let item = folder
.add_item(
guid(3),
"shirt".into(),
false,
Some(Some(RlvAttachmentPoint::Chest)),
Some(Some(guid(30))),
Some(Some(RlvWearableType::Shirt)),
None,
)
.unwrap();
let manager = RlvRestrictionManager::new();
manager.set_inventory_map(Some(InventoryMap::new(root, vec![]).unwrap()));
let permissions = RlvPermissionsService::new(manager.clone());
assert!(
permissions
.can_attach_with_rlv_inventory_item_boolean(item.clone(), true)
.unwrap()
);
assert!(
permissions
.can_detach_with_rlv_inventory_item(item.clone())
.unwrap()
);
manager
.add_restriction(restriction(RlvRestrictionType::DetachThis, 30, vec![]))
.unwrap();
assert!(
!permissions
.can_detach_with_rlv_inventory_item(item.clone())
.unwrap()
);
manager
.add_restriction(restriction(
RlvRestrictionType::AttachThis,
31,
vec![RlvValue::String("clothes".into())],
))
.unwrap();
assert!(
!permissions
.can_attach_with_rlv_inventory_item_boolean(item.clone(), true)
.unwrap()
);
manager
.add_restriction(restriction(
RlvRestrictionType::RemOutfit,
32,
vec![RlvValue::WearableType(RlvWearableType::Shirt)],
))
.unwrap();
assert!(
!permissions
.can_detach_with_rlv_inventory_item(item)
.unwrap()
);
}
#[test]
fn recursive_folder_locks_and_exceptions_publish_isolated_snapshots() {
let root = RlvSharedFolder::new(guid(1), "#RLV".into()).unwrap();
let locked = root.add_child(guid(2), "locked".into()).unwrap();
let nested = locked.add_child(guid(3), "nested".into()).unwrap();
locked
.add_item(
guid(4),
"worn object".into(),
false,
Some(Some(RlvAttachmentPoint::Chest)),
Some(Some(guid(44))),
None,
None,
)
.unwrap();
let inventory = InventoryMap::new(root, vec![]).unwrap();
let manager = RlvRestrictionManager::new();
manager.set_inventory_map(Some(inventory));
let lock = restriction(RlvRestrictionType::DetachAllThis, 44, vec![]);
manager.add_restriction(lock.clone()).unwrap();
let first_snapshot = manager.get_locked_folders().unwrap();
assert!(!first_snapshot[&locked.id()].can_detach());
assert!(!first_snapshot[&nested.id()].can_detach());
let exception = restriction(
RlvRestrictionType::DetachAllThisExcept,
45,
vec![RlvValue::String("locked".into())],
);
manager.add_restriction(exception).unwrap();
let second_snapshot = manager.get_locked_folders().unwrap();
assert!(second_snapshot[&locked.id()].can_detach());
assert!(second_snapshot[&nested.id()].can_detach());
// Previously returned snapshots remain immutable after later manager updates.
assert!(!first_snapshot[&locked.id()].can_detach());
assert!(manager.remove_restriction(&lock));
}
#[test]
fn external_inventory_dictionary_membership_is_snapshotted_with_reference_values() {
let root = RlvSharedFolder::new(guid(1), "#RLV".into()).unwrap();
let mut external = RlvInventoryItem::new(
guid(2),
"outside".into(),
false,
Some(Some(guid(3))),
None,
None,
None,
None,
)
.unwrap();
let map = InventoryMap::new(root, vec![external.clone()]).unwrap();
external.set_name("changed".into());
// Items use C# reference semantics, while the dictionary's membership is a
// fixed immutable snapshot. Mutating the referenced item is visible.
assert_eq!(map.external_items().len(), 1);
assert_eq!(
map.external_items().get(&guid(2)).unwrap().name(),
"changed"
);
external.set_attached_prim_id(Some(Some(guid(4))));
assert_eq!(
map.external_items()
.get(&guid(2))
.unwrap()
.attached_prim_id(),
Some(Some(guid(4)))
);
}