Files
MetaCrate/crates/libremetaverse-rlv/tests/protocol_parsing.rs
Chili Palmer 468a0f0619
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
Implement typed RLV protocol parser (#78)
2026-08-10 23:12:02 +00:00

444 lines
15 KiB
Rust

#![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"
);
}