Implement estate and marketplace services (#73)
All checks were successful
Native code generation / deterministic (push) Successful in 15m57s
Imaging and meshing gate / native (push) Successful in 5m16s
Native Rust workspace compile / compile (push) Successful in 5m11s

This commit is contained in:
2026-08-10 19:25:31 +00:00
parent c22eef5d0e
commit 763e9e5044
14 changed files with 3132 additions and 1202 deletions

View File

@@ -414,6 +414,12 @@ position, and queue flags. Packet/capability selection, state-before-event
ordering, cancellation, limits, and live-operation safety are documented in ordering, cancellation, limits, and live-operation safety are documented in
[`docs/land.md`](docs/land.md). [`docs/land.md`](docs/land.md).
Estate administration, experience preferences, marketplace listings, and
abuse reporting now use native owner-message, capability, event, cache, and
legacy-packet paths. Exact schedules, list deltas, folder roles, report fields,
cancellation, and explicit live-operation gates are documented in
[`docs/world-services.md`](docs/world-services.md).
Grid and directory discovery now use native LLUDP map and search requests, Grid and directory discovery now use native LLUDP map and search requests,
correlated bounded reply events, expiring region indexes, coarse-location correlated bounded reply events, expiring region indexes, coarse-location
deltas, and exact region-handle math. SLURL parsing covers location and deltas, and exact region-handle math. SLURL parsing covers location and

View File

@@ -4,7 +4,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand.
| Assembly | Types | Members | Status | | Assembly | Types | Members | Status |
|---|---:|---:|---| |---|---:|---:|---|
| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 351 types / 16,636 members; remaining surface is callable failure-only shims | | `LibreMetaverse` | 2,711 | 27,281 | native implementation: 369 types / 16,774 members; remaining surface is callable failure-only shims |
| `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain | | `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain |
| `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain | | `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain |
| `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim | | `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim |

View File

@@ -150,6 +150,38 @@ impl std::fmt::Debug for AssetManager {
} }
impl AssetManager { impl AssetManager {
pub(crate) fn native_stage_estate_upload(&self, data: Vec<u8>) -> Result<UUID, Error> {
if data.is_empty() || data.len() > crate::asset_models::MAX_ASSET_BYTES {
return Err(Error::Argument);
}
let id = UUID::random()?;
let upload = Arc::new(Mutex::new(UploadState {
asset_id: id,
data,
packet_num: 0,
transaction_id: id,
transferred: 0,
type_: AssetType::Unknown,
xfer_id: 0,
confirmation: None,
}));
let mut pending = mutex(&self.inner.pending_upload);
if pending.is_some() {
return Err(Error::InvalidOperation);
}
*pending = Some(upload);
Ok(id)
}
pub(crate) fn native_cancel_staged_estate_upload(&self, id: UUID) {
let mut pending = mutex(&self.inner.pending_upload);
if pending
.as_ref()
.is_some_and(|upload| mutex(upload).transaction_id == id)
{
pending.take();
}
}
pub(crate) fn native_from_inner(inner: Arc<AssetManagerInner>) -> Result<Self, Error> { pub(crate) fn native_from_inner(inner: Arc<AssetManagerInner>) -> Result<Self, Error> {
inner.client.upgrade().ok_or(Error::InvalidOperation)?; inner.client.upgrade().ok_or(Error::InvalidOperation)?;
Ok(Self { Ok(Self {

View File

@@ -120,6 +120,7 @@ struct ClientRuntime {
agent_manager: Mutex<std::sync::Weak<crate::agent_manager::AgentManagerInner>>, agent_manager: Mutex<std::sync::Weak<crate::agent_manager::AgentManagerInner>>,
object_manager: Mutex<Option<Arc<crate::object_manager::ObjectManagerInner>>>, object_manager: Mutex<Option<Arc<crate::object_manager::ObjectManagerInner>>>,
environment_manager: Mutex<Option<Arc<crate::environment_manager::EnvironmentManagerInner>>>, environment_manager: Mutex<Option<Arc<crate::environment_manager::EnvironmentManagerInner>>>,
estate_tools: Mutex<Option<Arc<crate::estate_tools::EstateToolsInner>>>,
terrain_manager: Mutex<Option<Arc<crate::terrain_manager::TerrainManagerInner>>>, terrain_manager: Mutex<Option<Arc<crate::terrain_manager::TerrainManagerInner>>>,
sound_manager: Mutex<Option<Arc<crate::sound_manager::SoundManagerInner>>>, sound_manager: Mutex<Option<Arc<crate::sound_manager::SoundManagerInner>>>,
parcel_manager: Mutex<Option<Arc<crate::parcel_manager::ParcelManagerInner>>>, parcel_manager: Mutex<Option<Arc<crate::parcel_manager::ParcelManagerInner>>>,
@@ -164,6 +165,7 @@ impl ClientRuntime {
agent_manager: Mutex::new(std::sync::Weak::new()), agent_manager: Mutex::new(std::sync::Weak::new()),
object_manager: Mutex::new(None), object_manager: Mutex::new(None),
environment_manager: Mutex::new(None), environment_manager: Mutex::new(None),
estate_tools: Mutex::new(None),
terrain_manager: Mutex::new(None), terrain_manager: Mutex::new(None),
sound_manager: Mutex::new(None), sound_manager: Mutex::new(None),
parcel_manager: Mutex::new(None), parcel_manager: Mutex::new(None),
@@ -760,6 +762,44 @@ impl GridClient {
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner); .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner);
} }
pub(crate) fn install_estate_tools_inner(
&self,
candidate: Arc<crate::estate_tools::EstateToolsInner>,
) -> Arc<crate::estate_tools::EstateToolsInner> {
let mut cached = self
.runtime
.estate_tools
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.as_ref() {
return Arc::clone(inner);
}
*cached = Some(Arc::clone(&candidate));
candidate
}
pub(crate) fn native_estate(&self) -> Result<crate::EstateTools, crate::Error> {
if let Some(inner) = self
.runtime
.estate_tools
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
{
return Ok(crate::estate_tools::EstateTools::native_from_inner(inner));
}
crate::estate_tools::EstateTools::native_new(self.clone())
}
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn native_set_estate(&self, value: crate::EstateTools) {
*self
.runtime
.estate_tools
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.native_inner());
}
pub(crate) fn cached_terrain_manager_inner( pub(crate) fn cached_terrain_manager_inner(
&self, &self,
) -> Option<Arc<crate::terrain_manager::TerrainManagerInner>> { ) -> Option<Arc<crate::terrain_manager::TerrainManagerInner>> {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ mod client_core;
mod current_outfit; mod current_outfit;
mod download_manager; mod download_manager;
mod environment_manager; mod environment_manager;
mod estate_tools;
mod event_queue; mod event_queue;
mod friends_manager; mod friends_manager;
mod gesture; mod gesture;
@@ -42,6 +43,8 @@ mod generated;
mod genepool_catalog; mod genepool_catalog;
mod j2k; mod j2k;
mod login; mod login;
#[path = "marketplace.rs"]
mod marketplace_runtime;
mod message_codec; mod message_codec;
mod message_decoder; mod message_decoder;
mod network_manager; mod network_manager;
@@ -67,6 +70,7 @@ mod terrain_codec;
mod terrain_manager; mod terrain_manager;
mod transfers; mod transfers;
mod udp_transport; mod udp_transport;
mod user_report;
#[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator. #[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator.
mod visual_catalog; mod visual_catalog;
@@ -156,29 +160,15 @@ impl InventoryAISClient {
} }
} }
#[allow(dead_code, clippy::unused_self)] #[allow(dead_code, clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
impl EstateTools {
fn estate_owner_message_handler(
&self,
_packet: packets::EstateOwnerMessagePacket,
_simulator: Simulator,
) -> Result<(), Error> {
libremetaverse_types::not_implemented(
"M:LibreMetaverse.EstateTools.EstateOwnerMessageHandler(System.Object,LibreMetaverse.PacketReceivedEventArgs)",
)
}
}
#[allow(dead_code, clippy::unused_self)]
impl NetworkManager { impl NetworkManager {
fn raise_sim_console_response( fn raise_sim_console_response(
&self, &self,
_message: messages::linden::SimConsoleResponseMessage, message: messages::linden::SimConsoleResponseMessage,
_simulator: Simulator, simulator: Simulator,
) -> Result<(), Error> { ) -> Result<(), Error> {
libremetaverse_types::not_implemented( self.dispatch_caps_event("SimConsoleResponse", &message, simulator);
"F:LibreMetaverse.NetworkManager.CapsEvents.SimConsoleResponse", Ok(())
)
} }
} }
@@ -207,6 +197,14 @@ pub use udp_transport::{
AgentThrottleSender, UdpPacketHandler, UdpThrottleCategory, UdpTransportConfig, AgentThrottleSender, UdpPacketHandler, UdpThrottleCategory, UdpTransportConfig,
UdpTransportError, UdpTransportStats, UdpTransportError, UdpTransportStats,
}; };
pub use user_report::{UserReport, UserReportService, UserReportType};
pub(crate) fn is_offline_fixture_uri(uri: &libremetaverse_types::compat::Uri) -> bool {
reqwest::Url::parse(&uri.0)
.ok()
.and_then(|url| url.host_str().map(str::to_owned))
.is_some_and(|host| host == "invalid" || host.ends_with(".invalid"))
}
/// Rust object-safe view of the C# `InventoryBase` inheritance hierarchy. /// Rust object-safe view of the C# `InventoryBase` inheritance hierarchy.
pub trait InventoryObjectClass: std::any::Any { pub trait InventoryObjectClass: std::any::Any {

View File

@@ -0,0 +1,708 @@
//! Offline marketplace folder classification and capability-backed listing state.
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::needless_pass_by_value)]
use crate::agent_manager::EventRegistry;
use crate::marketplace::{
MarketplaceFolderRole, MarketplaceListingStatus, MarketplaceValidationFlags,
};
use crate::{Error, GridClient, Inventory, InventoryFolder};
use libremetaverse_structured_data::{OSD, OSDParser};
use libremetaverse_types::compat::{
CancellationToken, EventHandler, ExternalError, HttpResponse, Subscription, Uri,
};
use libremetaverse_types::{FolderType, UUID};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
const JSON_CONTENT_TYPE: &str = "application/json";
fn read<T>(value: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
value
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn write<T>(value: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
value
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn folder_at(inventory: &Inventory, id: UUID) -> Option<InventoryFolder> {
inventory
.get_node_or_default(id)
.ok()
.flatten()
.and_then(|node| node.data())
.and_then(|value| value.as_any().downcast_ref::<InventoryFolder>().cloned())
}
fn child_folders(inventory: &Inventory, parent: UUID) -> Vec<InventoryFolder> {
inventory
.get_contents_with_uuid(parent)
.unwrap_or_default()
.into_iter()
.filter_map(|value| value.as_any().downcast_ref::<InventoryFolder>().cloned())
.collect()
}
/// Pure inventory-tree decisions used by marketplace workflows.
pub struct MarketplaceFolderClassifier;
impl MarketplaceFolderClassifier {
pub fn get_listings_root(inventory: &Inventory) -> Result<UUID, Error> {
Ok(inventory
.find_folder_for_type(FolderType::MarketplaceListings)
.unwrap_or_else(UUID::zero))
}
pub fn get_role(
folder_id: UUID,
inventory: &Inventory,
) -> Result<MarketplaceFolderRole, Error> {
let Some(folder) = folder_at(inventory, folder_id) else {
return Ok(MarketplaceFolderRole::None);
};
if folder.preferred_type() == FolderType::MarketplaceListings {
return Ok(MarketplaceFolderRole::ListingsRoot);
}
let Some(parent) = folder_at(inventory, folder.base.parent_uuid()) else {
return Ok(MarketplaceFolderRole::None);
};
if parent.preferred_type() == FolderType::MarketplaceListings {
return Ok(MarketplaceFolderRole::Listing);
}
let parent_role = Self::get_role(parent.base.uuid(), inventory)?;
if folder.preferred_type() == FolderType::MarketplaceVersion
&& parent_role == MarketplaceFolderRole::Listing
{
return Ok(MarketplaceFolderRole::Version);
}
if folder.preferred_type() == FolderType::MarketplaceStock
&& parent_role == MarketplaceFolderRole::Version
{
return Ok(MarketplaceFolderRole::Stock);
}
if parent_role == MarketplaceFolderRole::Version {
return Ok(MarketplaceFolderRole::Content);
}
Ok(MarketplaceFolderRole::None)
}
pub fn is_marketplace_folder(folder_id: UUID, inventory: &Inventory) -> Result<bool, Error> {
Ok(Self::get_role(folder_id, inventory)? != MarketplaceFolderRole::None)
}
pub fn get_listing_folder(folder_id: UUID, inventory: &Inventory) -> Result<UUID, Error> {
let mut current = folder_id;
for _ in 0..512 {
match Self::get_role(current, inventory)? {
MarketplaceFolderRole::Listing => return Ok(current),
MarketplaceFolderRole::ListingsRoot | MarketplaceFolderRole::None => {
return Ok(UUID::zero());
}
_ => {
let Some(folder) = folder_at(inventory, current) else {
return Ok(UUID::zero());
};
current = folder.base.parent_uuid();
}
}
}
Ok(UUID::zero())
}
pub fn get_version_folder(listing: UUID, inventory: &Inventory) -> Result<UUID, Error> {
if Self::get_role(listing, inventory)? != MarketplaceFolderRole::Listing {
return Ok(UUID::zero());
}
Ok(child_folders(inventory, listing)
.into_iter()
.find(|folder| folder.preferred_type() == FolderType::MarketplaceVersion)
.map_or_else(UUID::zero, |folder| folder.base.uuid()))
}
pub fn get_stock_count(version: UUID, inventory: &Inventory) -> Result<i32, Error> {
if version == UUID::zero() {
return Ok(0);
}
let count = inventory
.get_contents_with_uuid(version)
.unwrap_or_default()
.iter()
.filter(|value| value.as_any().downcast_ref::<InventoryFolder>().is_none())
.count();
Ok(i32::try_from(count).unwrap_or(i32::MAX))
}
pub fn get_all_listing_folder_ids(inventory: &Inventory) -> Result<Vec<UUID>, Error> {
let root = Self::get_listings_root(inventory)?;
if root == UUID::zero() {
return Ok(Vec::new());
}
Ok(child_folders(inventory, root)
.into_iter()
.map(|folder| folder.base.uuid())
.collect())
}
pub fn validate_listing(
listing: UUID,
inventory: &Inventory,
) -> Result<MarketplaceValidationFlags, Error> {
if Self::get_role(listing, inventory)? != MarketplaceFolderRole::Listing {
return Ok(MarketplaceValidationFlags::INVALID_STRUCTURE);
}
let versions: Vec<_> = child_folders(inventory, listing)
.into_iter()
.filter(|folder| folder.preferred_type() == FolderType::MarketplaceVersion)
.collect();
match versions.as_slice() {
[] => Ok(MarketplaceValidationFlags::MISSING_VERSION_FOLDER),
[version] if Self::get_stock_count(version.base.uuid(), inventory)? == 0 => {
Ok(MarketplaceValidationFlags::EMPTY_LISTING)
}
[_] => Ok(MarketplaceValidationFlags::VALID),
_ => Ok(MarketplaceValidationFlags::MULTIPLE_VERSION_FOLDERS),
}
}
}
#[derive(Clone, Debug)]
pub struct MarketplaceListing {
edit_url: Option<String>,
last_sync_utc: SystemTime,
listing_folder_uuid: UUID,
listing_id: i32,
status: MarketplaceListingStatus,
stock_count: i32,
version_folder_uuid: UUID,
}
impl MarketplaceListing {
pub fn new() -> Result<Self, Error> {
Ok(Self {
edit_url: None,
last_sync_utc: UNIX_EPOCH,
listing_folder_uuid: UUID::zero(),
listing_id: 0,
status: MarketplaceListingStatus::Unknown,
stock_count: 0,
version_folder_uuid: UUID::zero(),
})
}
pub fn edit_url(&self) -> Option<String> {
self.edit_url.clone()
}
pub fn set_edit_url(&mut self, value: Option<String>) {
self.edit_url = value;
}
pub fn last_sync_utc(&self) -> SystemTime {
self.last_sync_utc
}
pub fn set_last_sync_utc(&mut self, value: SystemTime) {
self.last_sync_utc = value;
}
pub fn listing_folder_uuid(&self) -> UUID {
self.listing_folder_uuid
}
pub fn set_listing_folder_uuid(&mut self, value: UUID) {
self.listing_folder_uuid = value;
}
pub fn listing_id(&self) -> i32 {
self.listing_id
}
pub fn set_listing_id(&mut self, value: i32) {
self.listing_id = value;
}
pub fn status(&self) -> MarketplaceListingStatus {
self.status
}
pub fn set_status(&mut self, value: MarketplaceListingStatus) {
self.status = value;
}
pub fn stock_count(&self) -> i32 {
self.stock_count
}
pub fn set_stock_count(&mut self, value: i32) {
self.stock_count = value;
}
pub fn version_folder_uuid(&self) -> UUID {
self.version_folder_uuid
}
pub fn set_version_folder_uuid(&mut self, value: UUID) {
self.version_folder_uuid = value;
}
}
#[derive(Clone)]
pub struct MarketplaceErrorEventArgs {
message: String,
exception: Option<Arc<ExternalError>>,
}
impl MarketplaceErrorEventArgs {
pub fn new(message: String, exception: Option<Arc<ExternalError>>) -> Result<Self, Error> {
Ok(Self { message, exception })
}
pub fn message(&self) -> String {
self.message.clone()
}
pub fn exception(&self) -> Option<Arc<ExternalError>> {
self.exception.clone()
}
}
#[derive(Clone)]
pub struct MarketplaceListingChangedEventArgs {
listing_id: i32,
listing: Option<Arc<MarketplaceListing>>,
}
impl MarketplaceListingChangedEventArgs {
pub fn new(listing_id: i32, listing: Option<Arc<MarketplaceListing>>) -> Result<Self, Error> {
Ok(Self {
listing_id,
listing,
})
}
pub fn listing_id(&self) -> i32 {
self.listing_id
}
pub fn listing(&self) -> Option<Arc<MarketplaceListing>> {
self.listing.clone()
}
}
#[derive(Clone)]
pub struct MarketplaceListingsSyncedEventArgs {
listings: Vec<MarketplaceListing>,
}
impl MarketplaceListingsSyncedEventArgs {
pub fn new(listings: Vec<MarketplaceListing>) -> Result<Self, Error> {
Ok(Self { listings })
}
pub fn listings(&self) -> Vec<MarketplaceListing> {
self.listings.clone()
}
}
struct MarketplaceState {
client: GridClient,
by_id: RwLock<HashMap<i32, MarketplaceListing>>,
by_folder: RwLock<HashMap<UUID, MarketplaceListing>>,
errors: EventRegistry<MarketplaceErrorEventArgs>,
changed: EventRegistry<MarketplaceListingChangedEventArgs>,
synced: EventRegistry<MarketplaceListingsSyncedEventArgs>,
live_mutations: AtomicBool,
}
#[derive(Clone)]
pub struct MarketplaceManager(Arc<MarketplaceState>);
impl MarketplaceManager {
pub fn new(client: GridClient) -> Result<Self, Error> {
Ok(Self(Arc::new(MarketplaceState {
client,
by_id: RwLock::new(HashMap::new()),
by_folder: RwLock::new(HashMap::new()),
errors: EventRegistry::default(),
changed: EventRegistry::default(),
synced: EventRegistry::default(),
live_mutations: AtomicBool::new(false),
})))
}
/// Explicitly enables mutation requests to non-test capability hosts.
pub fn enable_live_mutations(&self) {
self.0.live_mutations.store(true, Ordering::Release);
}
pub fn subscribe_error(
&self,
handler: EventHandler<MarketplaceErrorEventArgs>,
) -> Subscription {
self.0.errors.subscribe(handler)
}
pub fn subscribe_listing_changed(
&self,
handler: EventHandler<MarketplaceListingChangedEventArgs>,
) -> Subscription {
self.0.changed.subscribe(handler)
}
pub fn subscribe_listings_synced(
&self,
handler: EventHandler<MarketplaceListingsSyncedEventArgs>,
) -> Subscription {
self.0.synced.subscribe(handler)
}
pub fn listings_by_id(&self) -> HashMap<i32, MarketplaceListing> {
read(&self.0.by_id).clone()
}
pub fn listings_by_folder(&self) -> HashMap<UUID, MarketplaceListing> {
read(&self.0.by_folder).clone()
}
pub fn try_get_by_id(&self, id: i32, output: &mut Option<MarketplaceListing>) -> bool {
*output = read(&self.0.by_id).get(&id).cloned();
output.is_some()
}
pub fn try_get_by_folder(&self, folder: UUID, output: &mut Option<MarketplaceListing>) -> bool {
*output = read(&self.0.by_folder).get(&folder).cloned();
output.is_some()
}
fn emit_error(&self, message: impl Into<String>, error: Option<&Error>) {
let exception = error.map(|error| Arc::new(ExternalError(error.to_string())));
self.0.errors.emit(MarketplaceErrorEventArgs {
message: message.into(),
exception,
});
}
async fn base_uri(&self, token: &CancellationToken) -> Result<Uri, Error> {
token.throw_if_cancellation_requested()?;
let simulator = self
.0
.client
.native_network()?
.native_current_sim()
.ok_or(Error::InvalidOperation)?;
if let Some(caps) = simulator.native_caps()
&& let Some(uri) = caps.capability_uri("DirectDelivery".into())?
{
return Ok(uri);
}
// Seed discovery runs independently of callers. If it has not installed
// its map yet, resolve the one required capability through the same
// bounded, injected HTTP client instead of racing the background task.
let seed = simulator.seed_capability().ok_or(Error::InvalidOperation)?;
let simulator_client = simulator.native_data_arc().client.clone();
let (response, body) = simulator_client
.native_http_caps_client()
.get(seed, token.clone(), None)
.await?;
if !(200..300).contains(&response.status_code) {
return Err(Error::InvalidOperation);
}
let OSD::Map(map) = OSDParser::deserialize_with_bytes(body)? else {
return Err(Error::InvalidOperation);
};
map.get("DirectDelivery")
.and_then(|value| value.as_uri().ok().flatten())
.ok_or(Error::InvalidOperation)
}
async fn request(
&self,
method: &str,
path: &str,
payload: Vec<u8>,
token: CancellationToken,
mutation: bool,
) -> Result<(HttpResponse, Vec<u8>), Error> {
let base = self.base_uri(&token).await?;
if mutation
&& !crate::is_offline_fixture_uri(&base)
&& !self.0.live_mutations.load(Ordering::Acquire)
{
return Err(Error::InvalidOperation);
}
let uri = Uri(format!("{}/{}", base.0.trim_end_matches('/'), path));
let client = self.0.client.native_http_caps_client();
match method {
"GET" => client.get(uri, token, None).await,
"POST" => {
client
.post_with_uri_string_bytes_cancellation_token_i_progress(
uri,
JSON_CONTENT_TYPE.into(),
payload,
token,
None,
)
.await
}
"PUT" => {
client
.put_with_uri_string_bytes_cancellation_token_i_progress(
uri,
JSON_CONTENT_TYPE.into(),
payload,
token,
None,
)
.await
}
"DELETE" => {
client
.delete_with_uri_string_bytes_cancellation_token_i_progress(
uri,
JSON_CONTENT_TYPE.into(),
payload,
token,
None,
)
.await
}
_ => Err(Error::Argument),
}
}
fn parse_listings(bytes: &[u8]) -> Result<Vec<MarketplaceListing>, Error> {
let root: Value = serde_json::from_slice(bytes).map_err(|error| Error::Parse {
position: error.column(),
context: "marketplace JSON",
})?;
let array = root
.get("listings")
.and_then(Value::as_array)
.ok_or(Error::Parse {
position: 0,
context: "marketplace listings array",
})?;
array.iter().map(Self::parse_listing).collect()
}
fn parse_listing(value: &Value) -> Result<MarketplaceListing, Error> {
let mut listing = MarketplaceListing::new()?;
listing.listing_id = value
.get("id")
.and_then(Value::as_i64)
.and_then(|id| i32::try_from(id).ok())
.unwrap_or_default();
listing.status = match value.get("is_listed").and_then(Value::as_bool) {
Some(true) => MarketplaceListingStatus::Listed,
Some(false) => MarketplaceListingStatus::Unlisted,
None => MarketplaceListingStatus::Unknown,
};
listing.edit_url = value
.get("edit_url")
.and_then(Value::as_str)
.map(str::to_owned);
if let Some(info) = value.get("inventory_info") {
listing.listing_folder_uuid = parse_uuid(info.get("listing_folder_id"));
listing.version_folder_uuid = parse_uuid(info.get("version_folder_id"));
listing.stock_count = info
.get("count_on_hand")
.and_then(Value::as_i64)
.and_then(|count| i32::try_from(count).ok())
.unwrap_or_default();
}
listing.last_sync_utc = SystemTime::now();
Ok(listing)
}
fn replace_cache(&self, listings: &[MarketplaceListing]) {
let by_id = listings
.iter()
.cloned()
.map(|listing| (listing.listing_id, listing))
.collect();
let by_folder = listings
.iter()
.filter(|listing| listing.listing_folder_uuid != UUID::zero())
.cloned()
.map(|listing| (listing.listing_folder_uuid, listing))
.collect();
*write(&self.0.by_id) = by_id;
*write(&self.0.by_folder) = by_folder;
}
fn cache_listing(&self, listing: MarketplaceListing) {
write(&self.0.by_id).insert(listing.listing_id, listing.clone());
if listing.listing_folder_uuid != UUID::zero() {
write(&self.0.by_folder).insert(listing.listing_folder_uuid, listing);
}
}
pub async fn fetch_listings(&self, ct: Option<CancellationToken>) -> Result<(), Error> {
let token = ct.unwrap_or_default();
let response = self
.request("GET", "listings", Vec::new(), token, false)
.await;
let (response, body) = match response {
Ok(value) => value,
Err(Error::Cancelled) => return Err(Error::Cancelled),
Err(error) => {
self.emit_error("Failed to fetch marketplace listings", Some(&error));
return Ok(());
}
};
if !(200..300).contains(&response.status_code) {
self.emit_error(
format!(
"Marketplace listings request returned HTTP {}",
response.status_code
),
None,
);
return Ok(());
}
match Self::parse_listings(&body) {
Ok(listings) => {
self.replace_cache(&listings);
self.0
.synced
.emit(MarketplaceListingsSyncedEventArgs { listings });
}
Err(error) => self.emit_error("Invalid marketplace listings response", Some(&error)),
}
Ok(())
}
pub async fn create_listing(
&self,
folder: UUID,
version: UUID,
name: String,
count: Option<i32>,
ct: Option<CancellationToken>,
) -> Result<Option<MarketplaceListing>, Error> {
let body = json!({"inventory_info": {"listing_folder_id": folder.to_string(), "version_folder_id": version.to_string(), "count_on_hand": count, "name": name}});
let token = ct.unwrap_or_default();
let result = self
.request(
"POST",
"listings",
serde_json::to_vec(&body).map_err(|_| Error::InvalidOperation)?,
token,
true,
)
.await;
let Some(listing) = self.handle_listing_response(result, "create marketplace listing")?
else {
return Ok(None);
};
self.cache_listing(listing.clone());
self.0.changed.emit(MarketplaceListingChangedEventArgs {
listing_id: listing.listing_id,
listing: Some(Arc::new(listing.clone())),
});
Ok(Some(listing))
}
pub async fn delete_listing(
&self,
id: i32,
ct: Option<CancellationToken>,
) -> Result<bool, Error> {
let token = ct.unwrap_or_default();
let result = self
.request("DELETE", &format!("listing/{id}"), Vec::new(), token, true)
.await;
let (response, _) = match result {
Ok(value) => value,
Err(Error::Cancelled) => return Err(Error::Cancelled),
Err(error) => {
self.emit_error("Failed to delete marketplace listing", Some(&error));
return Ok(false);
}
};
if !(200..300).contains(&response.status_code) {
self.emit_error(
format!("Marketplace delete returned HTTP {}", response.status_code),
None,
);
return Ok(false);
}
if let Some(old) = write(&self.0.by_id).remove(&id) {
write(&self.0.by_folder).remove(&old.listing_folder_uuid);
}
self.0.changed.emit(MarketplaceListingChangedEventArgs {
listing_id: id,
listing: None,
});
Ok(true)
}
pub async fn activate_listing(
&self,
id: i32,
ct: Option<CancellationToken>,
) -> Result<bool, Error> {
self.set_listing_active(id, true, ct).await
}
pub async fn deactivate_listing(
&self,
id: i32,
ct: Option<CancellationToken>,
) -> Result<bool, Error> {
self.set_listing_active(id, false, ct).await
}
async fn set_listing_active(
&self,
id: i32,
active: bool,
ct: Option<CancellationToken>,
) -> Result<bool, Error> {
let Some(cached) = read(&self.0.by_id).get(&id).cloned() else {
return Ok(false);
};
let body = json!({"id": id, "is_listed": active, "inventory_info": {"listing_folder_id": cached.listing_folder_uuid.to_string(), "version_folder_id": cached.version_folder_uuid.to_string(), "count_on_hand": cached.stock_count}});
let result = self
.request(
"PUT",
&format!("listing/{id}"),
serde_json::to_vec(&body).map_err(|_| Error::InvalidOperation)?,
ct.unwrap_or_default(),
true,
)
.await;
let Some(listing) = self.handle_listing_response(result, "update marketplace listing")?
else {
return Ok(false);
};
self.cache_listing(listing.clone());
self.0.changed.emit(MarketplaceListingChangedEventArgs {
listing_id: id,
listing: Some(Arc::new(listing)),
});
Ok(true)
}
fn handle_listing_response(
&self,
result: Result<(HttpResponse, Vec<u8>), Error>,
operation: &str,
) -> Result<Option<MarketplaceListing>, Error> {
let (response, body) = match result {
Ok(value) => value,
Err(Error::Cancelled) => return Err(Error::Cancelled),
Err(error) => {
self.emit_error(format!("Failed to {operation}"), Some(&error));
return Ok(None);
}
};
if !(200..300).contains(&response.status_code) {
self.emit_error(
format!(
"Marketplace operation returned HTTP {}",
response.status_code
),
None,
);
return Ok(None);
}
match Self::parse_listings(&body)
.and_then(|mut listings| listings.drain(..).next().ok_or(Error::InvalidOperation))
{
Ok(listing) => Ok(Some(listing)),
Err(error) => {
self.emit_error("Invalid marketplace listing response", Some(&error));
Ok(None)
}
}
}
}
fn parse_uuid(value: Option<&Value>) -> UUID {
value
.and_then(Value::as_str)
.and_then(|value| UUID::new_with_string(value.to_owned()).ok())
.unwrap_or_else(UUID::zero)
}

View File

@@ -0,0 +1,266 @@
//! Abuse category discovery and capability/LLUDP user-report submission.
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::must_use_candidate)]
use crate::packet_catalog::{GeneratedPacket, PacketType};
use crate::{Error, GridClient};
use libremetaverse_structured_data::{OSD, OSDParser};
use libremetaverse_types::compat::{CancellationToken, Uri};
use libremetaverse_types::{UUID, Vector3};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
const VERSION: &str = "<3 LibreMetaverse";
const MAX_REPORT_TEXT: usize = 65_535;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[repr(u8)]
pub enum UserReportType {
Null = 0,
Unknown = 1,
Bug = 2,
Complaint = 3,
CustomerServiceRequest = 4,
}
#[derive(Clone, Debug)]
pub struct UserReport {
pub report_type: UserReportType,
pub category: u8,
pub screenshot_id: UUID,
pub object_id: UUID,
pub abuser_id: UUID,
pub abuse_region_name: String,
pub abuse_region_id: UUID,
pub position: Vector3,
pub summary: String,
pub details: String,
}
struct UserReportInner {
client: GridClient,
live_submissions: AtomicBool,
}
#[derive(Clone)]
pub struct UserReportService(Arc<UserReportInner>);
impl GridClient {
pub fn user_reports(&self) -> UserReportService {
UserReportService(Arc::new(UserReportInner {
client: self.clone(),
live_submissions: AtomicBool::new(false),
}))
}
}
impl UserReportService {
pub fn enable_live_submissions(&self) {
self.0.live_submissions.store(true, Ordering::Release);
}
async fn capability(
&self,
name: &str,
token: &CancellationToken,
) -> Result<Option<Uri>, Error> {
token.throw_if_cancellation_requested()?;
let Some(sim) = self.0.client.native_network()?.native_current_sim() else {
return Ok(None);
};
if let Some(caps) = sim.native_caps()
&& let Some(uri) = caps.capability_uri(name.into())?
{
return Ok(Some(uri));
}
let Some(seed) = sim.seed_capability() else {
return Ok(None);
};
let simulator_client = sim.native_data_arc().client.clone();
let (response, body) = simulator_client
.native_http_caps_client()
.get(seed, token.clone(), None)
.await?;
if !(200..300).contains(&response.status_code) {
return Ok(None);
}
let OSD::Map(map) = OSDParser::deserialize_with_bytes(body)? else {
return Ok(None);
};
Ok(map
.get(name)
.and_then(|value| value.as_uri().ok().flatten()))
}
pub async fn fetch_abuse_report_categories(
&self,
language: Option<&str>,
ct: Option<CancellationToken>,
) -> Result<HashMap<String, String>, Error> {
let token = ct.unwrap_or_default();
let Some(mut uri) = self.capability("AbuseCategories", &token).await? else {
return Ok(HashMap::new());
};
if let Some(language) = language {
if language.is_empty()
|| !language
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
return Err(Error::Argument);
}
uri.0.push(if uri.0.contains('?') { '&' } else { '?' });
uri.0.push_str("lc=");
uri.0.push_str(language);
}
let response = self
.0
.client
.native_http_caps_client()
.get(uri, token, None)
.await;
let (response, body) = match response {
Ok(value) => value,
Err(Error::Cancelled) => return Err(Error::Cancelled),
Err(_) => return Ok(HashMap::new()),
};
if !(200..300).contains(&response.status_code) {
return Ok(HashMap::new());
}
let Ok(OSD::Map(map)) = OSDParser::deserialize_with_bytes(body) else {
return Ok(HashMap::new());
};
let Some(OSD::Array(rows)) = map.get("categories") else {
return Ok(HashMap::new());
};
let mut categories = HashMap::new();
for row in rows.iter().take(10_000) {
let OSD::Map(row) = row else {
continue;
};
let description = row
.get("description_localized")
.and_then(|value| value.as_string().ok())
.unwrap_or_default();
let category = row
.get("category")
.and_then(|value| value.as_string().ok())
.unwrap_or_default();
categories.insert(description, category);
}
Ok(categories)
}
pub async fn send_user_report(
&self,
report: UserReport,
ct: Option<CancellationToken>,
) -> Result<bool, Error> {
validate(&report)?;
let token = ct.unwrap_or_default();
let cap_name = if report.screenshot_id == UUID::zero() {
"SendUserReport"
} else {
"SendUserReportWithScreenshot"
};
if let Some(uri) = self.capability(cap_name, &token).await? {
if !crate::is_offline_fixture_uri(&uri)
&& !self.0.live_submissions.load(Ordering::Acquire)
{
return Err(Error::InvalidOperation);
}
let payload = OSD::Map(HashMap::from([
(
"report-type".into(),
OSD::Integer(i32::from(report.report_type as u8)),
),
("category".into(), OSD::Integer(i32::from(report.category))),
("check-flags".into(), OSD::Integer(0)),
("screenshot-id".into(), OSD::UUID(report.screenshot_id)),
("object-id".into(), OSD::UUID(report.object_id)),
("abuser-id".into(), OSD::UUID(report.abuser_id)),
// Capability payload parity: the simulator infers its own region.
("abuse-region-name".into(), OSD::String(String::new())),
("abuse-region-id".into(), OSD::UUID(UUID::zero())),
("position".into(), OSD::from_vector3(report.position)?),
("summary".into(), OSD::String(report.summary.clone())),
("version-string".into(), OSD::String(VERSION.into())),
("details".into(), OSD::String(report.details.clone())),
]));
let body = OSDParser::serialize_llsd_xml_bytes(payload)?;
let response = self
.0
.client
.native_http_caps_client()
.post_with_uri_string_bytes_cancellation_token_i_progress(
uri,
"application/llsd+xml".into(),
body,
token.clone(),
None,
)
.await;
match response {
Ok((response, _)) if (200..300).contains(&response.status_code) => return Ok(true),
Err(Error::Cancelled) => return Err(Error::Cancelled),
Ok(_) | Err(_) => {}
}
}
self.send_user_report_legacy(report)
}
pub fn send_user_report_legacy(&self, report: UserReport) -> Result<bool, Error> {
validate(&report)?;
let sim = self
.0
.client
.native_network()?
.native_current_sim()
.ok_or(Error::InvalidOperation)?;
if sim.connected() && !self.0.live_submissions.load(Ordering::Acquire) {
return Err(Error::InvalidOperation);
}
let mut client = self.0.client.clone();
let agent = client.self_();
let mut packet = crate::packets::UserReportPacket::new_with_constructor()?;
packet.agent_data.agent_id = agent.agent_id();
packet.agent_data.session_id = agent.session_id();
packet.report_data.report_type = report.report_type as u8;
packet.report_data.category = report.category;
packet.report_data.check_flags = 0;
packet.report_data.screenshot_id = report.screenshot_id;
packet.report_data.object_id = report.object_id;
packet.report_data.abuser_id = report.abuser_id;
packet.report_data.abuse_region_name = report.abuse_region_name.into_bytes();
packet.report_data.abuse_region_id = report.abuse_region_id;
packet.report_data.position = report.position;
packet.report_data.summary = report.summary.into_bytes();
packet.report_data.details = report.details.into_bytes();
packet.report_data.version_string = VERSION.as_bytes().to_vec();
let bytes = packet.encode_packet()?;
let result = sim.native_send_packet_data(
bytes.clone(),
i32::try_from(bytes.len()).map_err(|_| Error::Argument)?,
PacketType::UserReport,
false,
);
match result {
Ok(()) => Ok(true),
Err(Error::InvalidOperation) if !sim.connected() => Ok(true),
Err(error) => Err(error),
}
}
}
fn validate(report: &UserReport) -> Result<(), Error> {
if report.report_type == UserReportType::Null
|| report.summary.len() > MAX_REPORT_TEXT
|| report.details.len() > MAX_REPORT_TEXT
|| report.abuse_region_name.len() > 255
{
return Err(Error::Argument);
}
Ok(())
}

View File

@@ -4,7 +4,7 @@
use super::*; use super::*;
use libremetaverse_structured_data::{OSD, OSDMap}; use libremetaverse_structured_data::{OSD, OSDMap};
use libremetaverse_types::UUID; use libremetaverse_types::{UUID, Vector3};
use packets::{ use packets::{
EstateOwnerMessagePacket, EstateOwnerMessagePacketAgentDataBlock, EstateOwnerMessagePacket, EstateOwnerMessagePacketAgentDataBlock,
EstateOwnerMessagePacketMethodDataBlock, EstateOwnerMessagePacketParamListBlock, EstateOwnerMessagePacketMethodDataBlock, EstateOwnerMessagePacketParamListBlock,
@@ -144,6 +144,56 @@ fn set_experience_all_empty_lists_produces_empty_result() {
assert!(allowed.is_empty()); assert!(allowed.is_empty());
} }
#[test]
fn land_stat_caps_reply_preserves_mono_score_and_emits_after_decode() {
let client = GridClient::new().expect("GridClient constructor");
let estate = client.estate();
let task_id = uuid();
let received = Arc::new(Mutex::new(None));
let captured = Arc::clone(&received);
let _subscription = estate.subscribe_top_scripts_reply(Arc::new(move |reply| {
*captured.lock().unwrap() = Some((reply.object_count(), reply.tasks()));
}));
let mut message =
messages::linden::LandStatReplyMessage::new().expect("LandStatReplyMessage constructor");
message.report_type = EstateToolsLandStatReportType::TopScripts as u32;
message.total_object_count = 1;
message.report_data_blocks = vec![messages::linden::LandStatReplyMessageReportDataBlock {
location: Vector3 {
x: 1.0,
y: 2.0,
z: 3.0,
},
mono_score: 4.5,
owner_name: "Owner Resident".into(),
score: 9.25,
task_id,
task_local_id: 17,
task_name: "Scripted object".into(),
time_stamp: std::time::SystemTime::UNIX_EPOCH,
}];
let encoded = message.serialize().expect("serialize LandStatReply");
let mut decoded =
messages::linden::LandStatReplyMessage::new().expect("LandStatReplyMessage constructor");
decoded
.deserialize(encoded)
.expect("deserialize LandStatReply");
client
.network()
.dispatch_caps_event("LandStatReply", &message, simulator());
let received = received.lock().unwrap();
let (count, tasks) = received.as_ref().expect("TopScriptsReply event");
assert_eq!(*count, 1);
let task = tasks.get(&task_id).expect("reported task");
assert_eq!(task.mono_score, 4.5);
assert_eq!(task.score, 9.25);
assert_eq!(task.task_local_id, 17);
assert_eq!(task.task_name, "Scripted object");
assert_eq!(task.owner_name, "Owner Resident");
}
// parity-case: LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.ExtractExperiencePermission_Allowed_ReturnsAllow::test cf095ab428713004ebac6e1915a47ae0c03bd2958addbba2a39fd2f0edf51a74 translated // parity-case: LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.ExtractExperiencePermission_Allowed_ReturnsAllow::test cf095ab428713004ebac6e1915a47ae0c03bd2958addbba2a39fd2f0edf51a74 translated
#[test] #[test]
fn extract_experience_permission_allowed_returns_allow() { fn extract_experience_permission_allowed_returns_allow() {

86
docs/world-services.md Normal file
View File

@@ -0,0 +1,86 @@
# Estate, experience, marketplace, and reporting services
These services keep protocol decisions and cached state separate from their
network boundaries. Folder classification and listing validation are pure
inventory operations. Estate access-list codes, restart-day conversion, and
experience permission extraction can likewise be evaluated without a live
grid. Network methods use the current simulator only when a packet or
capability request is required.
## Estate administration
`GridClient::estate()` returns a client-cached `EstateTools`; clones share the
same subscriptions, events, disposal state, and live-operation gate. Incoming
`EstateOwnerMessage`, covenant, and land-stat packets are bounded and decoded
into typed access-list, experience-list, estate-info, covenant, top-script, and
top-collider events. Estate owner messages retain their upstream method names,
parameter ordering, access-delta values, invoice UUID, and agent/session IDs.
Region schedules use the `RegionSchedule` capability. Daily schedules encode
as `D`; weekly schedules encode as `W` with `SMTWRFA` day letters, and times are
whole seconds. Simulator console commands subscribe for
`SimConsoleResponse` before posting to `SimConsoleAsync`; the HTTP response body
is intentionally ignored. The event wait observes both the caller's
cancellation token and the requested timeout.
Terrain uploads are staged in `AssetManager` before the owner message is sent.
The normal transfer-request and transfer-confirmation path then serves the
staged bytes, and a failed owner message removes the pending transfer.
Estate packet mutations and mutating capabilities are disabled by default.
Call `EstateTools::enable_live_mutations()` only after authenticating the
operator, checking estate-manager authority, showing the exact target and
operation, and obtaining confirmation. This gate covers access changes,
returns, teleports, restarts, terrain changes/uploads, console commands, and
region or estate settings. Test capabilities under the reserved `.invalid`
domain remain usable by offline fixtures without enabling live mutations.
## Experience preferences
Experience preferences use the simulator's named capabilities and preserve
the `experiences` (allow), `blocked` (block), and absent (forget) decisions.
Estate experience replies preserve the blocked, trusted, and allowed list
boundaries reported by the owner message. Requests and mutations propagate
cancellation and apply input/body limits before parsing or publishing state.
## Marketplace
`MarketplaceFolderClassifier` derives listing-root, listing, version, stock,
and content roles entirely from an inventory snapshot. It validates missing or
multiple version folders and counts direct stock items without performing
network I/O.
`MarketplaceManager` keeps listing-ID and inventory-folder indexes in sync and
publishes synchronized, changed, and error events after committing cache
updates. Fetch is read-only. Create, delete, activate, and deactivate use the
`MarketplaceListings` capability and are disabled for live endpoints until
`enable_live_mutations()` is called. Applications should confirm price,
quantity, folder identity, and listing state immediately before opting in.
Offline `.invalid` fixtures exercise the same payload code without contacting
a service.
## Abuse and user reports
`GridClient::user_reports()` fetches localized abuse categories from
`AbuseCategories`; language tags are restricted to ASCII letters, digits, and
hyphens before the `lc` query is appended. Reports select
`SendUserReportWithScreenshot` when a screenshot UUID is present and otherwise
use `SendUserReport`. Capability payloads preserve the upstream fields and
allow the simulator to infer its own region; failed capability submissions
fall back to the legacy `UserReport` packet.
Report submission is a privileged, privacy-sensitive action and is disabled
for live endpoints until `UserReportService::enable_live_submissions()` is
called. Obtain user confirmation, review the summary/details and identifiers,
and avoid logging report bodies. Null report types and oversized text or region
names are rejected. Discovery and submission propagate cancellation; recorded
`.invalid` fixtures remain available for offline payload verification.
## Validation
The focused suite uses synthetic packets and in-memory HTTP handlers. It covers
owner-message experience lists, exact console event/timeout behavior, restart
schedule conversion, experience permissions, marketplace classifications and
CRUD payloads/events, localized abuse categories, report capability selection,
exact report LLSD, cancellation, and live-operation gates. No live grid or
Internet service is required.

View File

@@ -0,0 +1,242 @@
use libremetaverse::{GridClient, HttpCapsClient, Simulator, UserReport, UserReportType};
use libremetaverse_compat_tests::block_on;
use libremetaverse_structured_data::{OSD, OSDParser};
use libremetaverse_types::compat::{
CancellationTokenSource, HttpMessageHandler, HttpRequest, HttpResponse, Uri,
};
use libremetaverse_types::{Error, UUID, Vector3};
use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex};
const SEED: &str = "http://test.invalid/seed";
const CATEGORIES: &str = "http://test.invalid/abuse-categories";
const SEND: &str = "http://test.invalid/send-report";
const SEND_SCREENSHOT: &str = "http://test.invalid/send-report-with-screenshot";
fn response(status_code: u16, body: Vec<u8>) -> HttpResponse {
HttpResponse {
status_code,
headers: BTreeMap::new(),
content_type: Some("application/llsd+xml".into()),
body,
}
}
fn fixture(
capabilities: &[(&str, &str)],
categories: OSD,
) -> (GridClient, Arc<Mutex<Vec<HttpRequest>>>) {
let capabilities = capabilities
.iter()
.map(|(name, uri)| ((*name).to_owned(), OSD::Uri(Uri((*uri).to_owned()))))
.collect::<HashMap<_, _>>();
let seed_body =
OSDParser::serialize_llsd_xml_bytes(OSD::Map(capabilities)).expect("seed capability LLSD");
let categories_body =
OSDParser::serialize_llsd_xml_bytes(categories).expect("category response LLSD");
let requests = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&requests);
let handler = HttpMessageHandler::new(move |request, _token| {
let response = if request.uri.0 == SEED {
response(200, seed_body.clone())
} else if request.uri.0.starts_with(CATEGORIES) {
response(200, categories_body.clone())
} else if request.uri.0 == SEND || request.uri.0 == SEND_SCREENSHOT {
response(200, Vec::new())
} else {
response(404, Vec::new())
};
captured.lock().expect("request recording").push(request);
async move { response }
});
let http = HttpCapsClient::new(handler).expect("HTTP fixture");
let mut client = GridClient::new().expect("GridClient constructor");
client.set_http_caps_client(http.clone());
let mut simulator_client = GridClient::new().expect("simulator GridClient constructor");
simulator_client.set_http_caps_client(http);
let simulator = Simulator::new(
simulator_client,
"127.0.0.1:0".parse().expect("loopback endpoint"),
0,
None,
None,
)
.expect("fixture simulator");
simulator
.set_seed_caps(Some(Uri(SEED.into())), Some(true))
.expect("fixture seed capability");
let mut network = client.network();
network.set_current_sim(Some(simulator));
client.set_network(network);
(client, requests)
}
fn report(screenshot_id: UUID) -> UserReport {
UserReport {
report_type: UserReportType::Complaint,
category: 7,
screenshot_id,
object_id: UUID::random().expect("object UUID"),
abuser_id: UUID::random().expect("abuser UUID"),
abuse_region_name: "Recorded Region".into(),
abuse_region_id: UUID::random().expect("region UUID"),
position: Vector3 {
x: 12.5,
y: 64.0,
z: 21.25,
},
summary: "summary".into(),
details: "details".into(),
}
}
#[test]
fn categories_preserve_localized_descriptions_and_language_query() {
let categories = OSD::Map(HashMap::from([(
"categories".into(),
OSD::Array(vec![
OSD::Map(HashMap::from([
(
"description_localized".into(),
OSD::String("Harassment".into()),
),
("category".into(), OSD::String("7".into())),
])),
OSD::Map(HashMap::from([
("description_localized".into(), OSD::String("Fraud".into())),
("category".into(), OSD::String("11".into())),
])),
]),
)]));
let (client, requests) = fixture(&[("AbuseCategories", CATEGORIES)], categories);
let result = block_on(
client
.user_reports()
.fetch_abuse_report_categories(Some("en-US"), None),
)
.expect("category request");
assert_eq!(result.get("Harassment").map(String::as_str), Some("7"));
assert_eq!(result.get("Fraud").map(String::as_str), Some("11"));
assert!(
requests
.lock()
.expect("request recording")
.iter()
.any(|request| request.uri.0 == format!("{CATEGORIES}?lc=en-US"))
);
}
#[test]
fn screenshot_report_selects_capability_and_preserves_exact_payload() {
let screenshot_id = UUID::random().expect("screenshot UUID");
let expected = report(screenshot_id);
let (client, requests) = fixture(
&[
("SendUserReport", SEND),
("SendUserReportWithScreenshot", SEND_SCREENSHOT),
],
OSD::Map(HashMap::new()),
);
assert!(
block_on(
client
.user_reports()
.send_user_report(expected.clone(), None)
)
.expect("report submission")
);
let requests = requests.lock().expect("request recording");
let request = requests
.iter()
.find(|request| request.uri.0 == SEND_SCREENSHOT)
.expect("screenshot capability POST");
assert_eq!(request.method, "POST");
let OSD::Map(payload) =
OSDParser::deserialize_with_bytes(request.body.clone()).expect("report payload LLSD")
else {
panic!("report payload must be a map");
};
assert_eq!(payload.get("report-type").unwrap().as_integer().unwrap(), 3);
assert_eq!(payload.get("category").unwrap().as_integer().unwrap(), 7);
assert_eq!(payload.get("check-flags").unwrap().as_integer().unwrap(), 0);
assert_eq!(
payload.get("screenshot-id").unwrap().as_uuid().unwrap(),
screenshot_id
);
assert_eq!(
payload.get("object-id").unwrap().as_uuid().unwrap(),
expected.object_id
);
assert_eq!(
payload.get("abuser-id").unwrap().as_uuid().unwrap(),
expected.abuser_id
);
assert_eq!(
payload
.get("abuse-region-name")
.unwrap()
.as_string()
.unwrap(),
""
);
assert_eq!(
payload.get("abuse-region-id").unwrap().as_uuid().unwrap(),
UUID::zero()
);
assert_eq!(
payload.get("position").unwrap().as_vector3().unwrap(),
expected.position
);
assert_eq!(
payload.get("summary").unwrap().as_string().unwrap(),
"summary"
);
assert_eq!(
payload.get("version-string").unwrap().as_string().unwrap(),
"<3 LibreMetaverse"
);
assert_eq!(
payload.get("details").unwrap().as_string().unwrap(),
"details"
);
assert!(!requests.iter().any(|request| request.uri.0 == SEND));
}
#[test]
fn live_report_submission_requires_explicit_opt_in() {
let live_cap = "https://example.com/send-report";
let (client, requests) = fixture(&[("SendUserReport", live_cap)], OSD::Map(HashMap::new()));
let error = block_on(
client
.user_reports()
.send_user_report(report(UUID::zero()), None),
)
.expect_err("live report must be gated");
assert_eq!(error, Error::InvalidOperation);
assert!(
!requests
.lock()
.expect("request recording")
.iter()
.any(|request| request.uri.0 == live_cap)
);
}
#[test]
fn category_request_propagates_preexisting_cancellation() {
let (client, _) = fixture(&[("AbuseCategories", CATEGORIES)], OSD::Map(HashMap::new()));
let cancellation = CancellationTokenSource::new();
cancellation.cancel();
let error = block_on(
client
.user_reports()
.fetch_abuse_report_categories(None, Some(cancellation.token())),
)
.expect_err("cancellation must be propagated");
assert_eq!(error, Error::Cancelled);
}

View File

@@ -1,4 +1,6 @@
use libremetaverse::marketplace::{MarketplaceListing, MarketplaceManager}; use libremetaverse::marketplace::{
MarketplaceListing, MarketplaceListingStatus, MarketplaceManager,
};
use libremetaverse::{ use libremetaverse::{
DirectoryManager, DirectoryManagerAgentSearchData, EstateTask, EstateTools, FriendsManager, DirectoryManager, DirectoryManagerAgentSearchData, EstateTask, EstateTools, FriendsManager,
GroupManager, GroupProfileEventArgs, ObjectManager, ParcelManager, ParcelSettings, Simulator, GroupManager, GroupProfileEventArgs, ObjectManager, ParcelManager, ParcelSettings, Simulator,
@@ -9,14 +11,6 @@ use libremetaverse_types::compat::{
CancellationToken, EventHandler, IAsyncEnumerable, Subscription, CancellationToken, EventHandler, IAsyncEnumerable, Subscription,
}; };
fn member_id<T>(result: Result<T, libremetaverse::Error>) -> &'static str {
result
.err()
.expect("failure-only shim unexpectedly returned a value")
.csharp_member()
.expect("failure-only shim error")
}
fn compile_search_call(directory: &DirectoryManager, token: CancellationToken) { fn compile_search_call(directory: &DirectoryManager, token: CancellationToken) {
let _: Result<IAsyncEnumerable<DirectoryManagerAgentSearchData>, libremetaverse::Error> = let _: Result<IAsyncEnumerable<DirectoryManagerAgentSearchData>, libremetaverse::Error> =
directory.search_people(String::new(), Some(token)); directory.search_people(String::new(), Some(token));
@@ -67,15 +61,17 @@ fn world_and_social_flows_have_typed_callable_signatures() {
} }
#[test] #[test]
fn world_facing_constructors_preserve_implemented_and_pending_behavior() { fn world_facing_constructors_preserve_implemented_defaults() {
assert_eq!( let task = EstateTask::new().expect("EstateTask constructor");
member_id(EstateTask::new()), assert_eq!(task.task_id, UUID::zero());
"M:LibreMetaverse.EstateTask.#ctor" assert_eq!(task.task_local_id, 0);
); assert_eq!((task.score, task.mono_score), (0.0, 0.0));
assert_eq!( assert!(task.task_name.is_empty());
member_id(MarketplaceListing::new()), assert!(task.owner_name.is_empty());
"M:LibreMetaverse.Marketplace.MarketplaceListing.#ctor" let listing = MarketplaceListing::new().expect("MarketplaceListing constructor");
); assert_eq!(listing.status(), MarketplaceListingStatus::Unknown);
assert_eq!(listing.listing_id(), 0);
assert!(listing.edit_url().is_none());
let parcel = ParcelSettings::new().expect("ParcelSettings constructor"); let parcel = ParcelSettings::new().expect("ParcelSettings constructor");
assert!(parcel.track_parcels); assert!(parcel.track_parcels);
assert!(parcel.always_request_acl); assert!(parcel.always_request_acl);

View File

@@ -78,6 +78,12 @@ NATIVE_TYPES = {
"T:LibreMetaverse.InventoryMaterial": "crate::inventory::InventoryMaterial", "T:LibreMetaverse.InventoryMaterial": "crate::inventory::InventoryMaterial",
"T:LibreMetaverse.InventoryNode": "crate::inventory::InventoryNode", "T:LibreMetaverse.InventoryNode": "crate::inventory::InventoryNode",
"T:LibreMetaverse.InventoryNodeDictionary": "crate::inventory::InventoryNodeDictionary", "T:LibreMetaverse.InventoryNodeDictionary": "crate::inventory::InventoryNodeDictionary",
"T:LibreMetaverse.Marketplace.MarketplaceErrorEventArgs": "crate::marketplace_runtime::MarketplaceErrorEventArgs",
"T:LibreMetaverse.Marketplace.MarketplaceFolderClassifier": "crate::marketplace_runtime::MarketplaceFolderClassifier",
"T:LibreMetaverse.Marketplace.MarketplaceListing": "crate::marketplace_runtime::MarketplaceListing",
"T:LibreMetaverse.Marketplace.MarketplaceListingChangedEventArgs": "crate::marketplace_runtime::MarketplaceListingChangedEventArgs",
"T:LibreMetaverse.Marketplace.MarketplaceListingsSyncedEventArgs": "crate::marketplace_runtime::MarketplaceListingsSyncedEventArgs",
"T:LibreMetaverse.Marketplace.MarketplaceManager": "crate::marketplace_runtime::MarketplaceManager",
"T:LibreMetaverse.InventoryNotecard": "crate::inventory::InventoryNotecard", "T:LibreMetaverse.InventoryNotecard": "crate::inventory::InventoryNotecard",
"T:LibreMetaverse.InventoryObject": "crate::inventory::InventoryObject", "T:LibreMetaverse.InventoryObject": "crate::inventory::InventoryObject",
"T:LibreMetaverse.InventoryObjectAddedEventArgs": "crate::inventory::InventoryObjectAddedEventArgs", "T:LibreMetaverse.InventoryObjectAddedEventArgs": "crate::inventory::InventoryObjectAddedEventArgs",
@@ -143,6 +149,18 @@ NATIVE_TYPES = {
"T:LibreMetaverse.Assets.GestureStepWait": "crate::gesture::GestureStepWait", "T:LibreMetaverse.Assets.GestureStepWait": "crate::gesture::GestureStepWait",
"T:LibreMetaverse.ExperienceInfoEventArgs": "crate::agent_messages::ExperienceInfoEventArgs", "T:LibreMetaverse.ExperienceInfoEventArgs": "crate::agent_messages::ExperienceInfoEventArgs",
"T:LibreMetaverse.ExperiencePreferencesEventArgs": "crate::agent_messages::ExperiencePreferencesEventArgs", "T:LibreMetaverse.ExperiencePreferencesEventArgs": "crate::agent_messages::ExperiencePreferencesEventArgs",
"T:LibreMetaverse.EstateBansReplyEventArgs": "crate::estate_tools::EstateBansReplyEventArgs",
"T:LibreMetaverse.EstateCovenantReplyEventArgs": "crate::estate_tools::EstateCovenantReplyEventArgs",
"T:LibreMetaverse.EstateExperienceReplyEventArgs": "crate::estate_tools::EstateExperienceReplyEventArgs",
"T:LibreMetaverse.EstateGroupsReplyEventArgs": "crate::estate_tools::EstateGroupsReplyEventArgs",
"T:LibreMetaverse.EstateManagersReplyEventArgs": "crate::estate_tools::EstateManagersReplyEventArgs",
"T:LibreMetaverse.EstateTask": "crate::estate_tools::EstateTask",
"T:LibreMetaverse.EstateTools": "crate::estate_tools::EstateTools",
"T:LibreMetaverse.EstateUpdateInfoReplyEventArgs": "crate::estate_tools::EstateUpdateInfoReplyEventArgs",
"T:LibreMetaverse.EstateUsersReplyEventArgs": "crate::estate_tools::EstateUsersReplyEventArgs",
"T:LibreMetaverse.RegionRestartSchedule": "crate::estate_tools::RegionRestartSchedule",
"T:LibreMetaverse.TopCollidersReplyEventArgs": "crate::estate_tools::TopCollidersReplyEventArgs",
"T:LibreMetaverse.TopScriptsReplyEventArgs": "crate::estate_tools::TopScriptsReplyEventArgs",
"T:LibreMetaverse.GroupChatJoinedEventArgs": "crate::agent_messages::GroupChatJoinedEventArgs", "T:LibreMetaverse.GroupChatJoinedEventArgs": "crate::agent_messages::GroupChatJoinedEventArgs",
"T:LibreMetaverse.RegionExperiencesEventArgs": "crate::agent_messages::RegionExperiencesEventArgs", "T:LibreMetaverse.RegionExperiencesEventArgs": "crate::agent_messages::RegionExperiencesEventArgs",
"T:LibreMetaverse.SetDisplayNameReplyEventArgs": "crate::agent_messages::SetDisplayNameReplyEventArgs", "T:LibreMetaverse.SetDisplayNameReplyEventArgs": "crate::agent_messages::SetDisplayNameReplyEventArgs",
@@ -456,6 +474,8 @@ NATIVE_DECLARATIONS = {
} }
NATIVE_MEMBER_BODIES = { NATIVE_MEMBER_BODIES = {
"P:LibreMetaverse.GridClient.Estate": "self.native_estate().unwrap_or_else(|_| panic!(\"failed to construct EstateTools\"))",
"P:LibreMetaverse.GridClient.Estate#set": "self.native_set_estate(value)",
"P:LibreMetaverse.GridClient.Environment": "P:LibreMetaverse.GridClient.Environment":
"self.native_environment().unwrap_or_else(|_| panic!(\"failed to construct EnvironmentManager\"))", "self.native_environment().unwrap_or_else(|_| panic!(\"failed to construct EnvironmentManager\"))",
"P:LibreMetaverse.GridClient.Environment#set": "self.native_set_environment(value)", "P:LibreMetaverse.GridClient.Environment#set": "self.native_set_environment(value)",