//! Native Inventory API v3 capability client and cache reconciliation. #![allow(clippy::missing_errors_doc)] #![allow(clippy::needless_pass_by_value)] #![allow(clippy::too_many_arguments)] use crate::client_core::ClientWeakHandle; use crate::{Error, GridClient, InventoryFolder, InventoryItem}; use libremetaverse_structured_data::{OSD, OSDMap, OSDParser}; use libremetaverse_types::compat::{CancellationToken, HttpResponse, Uri}; use libremetaverse_types::{AssetType, UUID}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; const MAX_FOLDER_DEPTH_REQUEST: i32 = 50; const MAX_AIS_OBJECTS: usize = 100_000; const LLSD_XML: &str = "application/llsd+xml"; type InventoryResult = ( bool, Vec, Vec, Vec, ); type ParsedInventory = (Vec, Vec, Vec); type CollectionSections<'a> = (Option<&'a HashMap>, Option<&'a OSD>); #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct AISResponseMeta { broken_links_removed: Vec, items_removed: Vec, categories_removed: Vec, category_version_updates: HashMap, } impl AISResponseMeta { pub fn new( broken_links: Vec, items: Vec, categories: Vec, versions: HashMap, ) -> Result { Ok(Self { broken_links_removed: broken_links, items_removed: items, categories_removed: categories, category_version_updates: versions, }) } #[must_use] pub fn empty() -> Self { Self::default() } #[must_use] pub fn broken_links_removed(&self) -> Vec { self.broken_links_removed.clone() } #[must_use] pub fn items_removed(&self) -> Vec { self.items_removed.clone() } #[must_use] pub fn categories_removed(&self) -> Vec { self.categories_removed.clone() } #[must_use] pub fn category_version_updates(&self) -> HashMap { self.category_version_updates.clone() } #[must_use] pub fn has_any_data(&self) -> bool { !self.broken_links_removed.is_empty() || !self.items_removed.is_empty() || !self.categories_removed.is_empty() || !self.category_version_updates.is_empty() } } #[derive(Clone)] pub struct InventoryAISClient { client: ClientWeakHandle, } impl InventoryAISClient { pub(crate) fn native_new(client: Option>) -> Result { let client = client.ok_or(Error::ArgumentNull)?; Ok(Self { client: client.native_weak_handle(), }) } fn client(&self) -> Result { self.client.upgrade().ok_or(Error::InvalidOperation) } fn capability(&self, name: &str) -> Result, Error> { let client = self.client()?; let Some(simulator) = client.native_network()?.current_sim() else { return Ok(None); }; let Some(caps) = simulator.native_caps() else { return Ok(None); }; caps.capability_uri(name.to_owned()) } pub(crate) fn native_is_available(&self) -> bool { self.capability(Self::INVENTORY_CAP_NAME) .ok() .flatten() .is_some() } fn endpoint(capability: &Uri, path: &str) -> Uri { Uri(format!("{}/{}", capability.0.trim_end_matches('/'), path)) } fn valid_category(category: &str) -> bool { !category.is_empty() && category .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) } fn token(token: Option) -> CancellationToken { token.unwrap_or_default() } fn status_ok(response: &HttpResponse) -> bool { response.is_success_status_code() || response.status_code == 304 } async fn request( &self, cap_name: &str, method: &str, path: String, headers: BTreeMap, payload: Option, cancellation_token: Option, ) -> Result)>, Error> { let token = Self::token(cancellation_token); token.throw_if_cancellation_requested()?; let Some(capability) = self.capability(cap_name)? else { return Ok(None); }; let bytes = match payload { Some(payload) => OSDParser::serialize_llsd_xml_bytes(payload)?, None => Vec::new(), }; let content_type = (!bytes.is_empty()).then(|| LLSD_XML.to_owned()); self.client()? .native_http_caps_client() .send_custom( method, Self::endpoint(&capability, &path), headers, content_type, bytes, token, ) .await .map(Some) } fn decode_map(bytes: Vec) -> Result>, Error> { if bytes.is_empty() { return Ok(None); } match OSDParser::deserialize_with_bytes(bytes)? { OSD::Map(map) => Ok(Some(map)), _ => Err(Error::Argument), } } fn parse_all_map(&self, map: &HashMap) -> Result { let response = OSD::Map(map.clone()); let folders = self.parse_folders_osd(&response)?; let items = self.parse_items_osd(&response)?; let links = self.parse_links_osd(&response)?; if folders.len() + items.len() + links.len() > MAX_AIS_OBJECTS { return Err(Error::InvalidOperation); } Ok((folders, items, links)) } fn reconcile( &self, map: &HashMap, folders: &[InventoryFolder], items: &[InventoryItem], links: &[InventoryItem], ) -> Result<(), Error> { let meta = Self::parse_meta_map(map)?; if folders.is_empty() && items.is_empty() && links.is_empty() && !meta.has_any_data() { return Ok(()); } self.client()?.native_inventory()?.native_reconcile_ais( folders.to_vec(), items.iter().chain(links).cloned().collect(), &meta, ) } async fn mutation( &self, cap_name: &str, method: &str, path: String, headers: BTreeMap, payload: Option, gone_is_success: bool, cancellation_token: Option, ) -> Result< ( bool, Vec, Vec, Vec, ), Error, > { let Some((response, bytes)) = self .request(cap_name, method, path, headers, payload, cancellation_token) .await? else { return Ok((false, Vec::new(), Vec::new(), Vec::new())); }; let success = Self::status_ok(&response) || (gone_is_success && response.status_code == 410); if !success { return Ok((false, Vec::new(), Vec::new(), Vec::new())); } let Some(map) = Self::decode_map(bytes)? else { return Ok((true, Vec::new(), Vec::new(), Vec::new())); }; let (folders, items, links) = self.parse_all_map(&map)?; self.reconcile(&map, &folders, &items, &links)?; Ok((true, folders, items, links)) } async fn mutation_bool( &self, cap_name: &str, method: &str, path: String, headers: BTreeMap, payload: Option, gone_is_success: bool, cancellation_token: Option, ) -> Result { match self .mutation( cap_name, method, path, headers, payload, gone_is_success, cancellation_token, ) .await { Ok((success, _, _, _)) => Ok(success), Err(Error::Cancelled) => Err(Error::Cancelled), Err(_error) => { #[cfg(test)] eprintln!("AIS mutation failed: {_error:?}"); Ok(false) } } } async fn get_parsed( &self, cap_name: &str, path: String, cancellation_token: Option, ) -> Result { let result = async { let Some((response, bytes)) = self .request( cap_name, "GET", path, BTreeMap::new(), None, cancellation_token, ) .await? else { return Ok((false, Vec::new(), Vec::new(), Vec::new())); }; if !Self::status_ok(&response) { return Ok((false, Vec::new(), Vec::new(), Vec::new())); } let Some(map) = Self::decode_map(bytes)? else { return Ok((true, Vec::new(), Vec::new(), Vec::new())); }; let parsed = self.parse_all_map(&map)?; self.reconcile(&map, &parsed.0, &parsed.1, &parsed.2)?; Ok((true, parsed.0, parsed.1, parsed.2)) } .await; match result { Err(Error::Cancelled) => Err(Error::Cancelled), Err(_) => Ok((false, Vec::new(), Vec::new(), Vec::new())), value => value, } } fn tid() -> Result { UUID::random() } fn destination(uuid: UUID) -> BTreeMap { BTreeMap::from([("Destination".to_owned(), uuid.to_string())]) } pub(crate) async fn native_create_inventory( &self, parent_uuid: UUID, new_inventory: OSD, create_link: bool, cancellation_token: Option, ) -> Result<(bool, Option), Error> { let path = format!("category/{parent_uuid}?tid={}", Self::tid()?); match self .mutation( Self::INVENTORY_CAP_NAME, "POST", path, BTreeMap::new(), Some(new_inventory), false, cancellation_token, ) .await { Ok((success, _, items, links)) => Ok(( success, if create_link { links } else { items }.into_iter().next(), )), Err(Error::Cancelled) => Err(Error::Cancelled), Err(_) => Ok((false, None)), } } #[allow(dead_code)] // Internal mapped helper used by InventoryManager batch links. pub(crate) async fn native_create_inventory_links( &self, parent_uuid: UUID, new_inventory: OSD, cancellation_token: Option, ) -> Result, Error> { let path = format!("category/{parent_uuid}?tid={}", Self::tid()?); let (_, _, _, links) = self .mutation( Self::INVENTORY_CAP_NAME, "POST", path, BTreeMap::new(), Some(new_inventory), false, cancellation_token, ) .await?; Ok(links) } pub(crate) async fn native_slam_folder( &self, folder: UUID, payload: OSD, token: Option, ) -> Result { self.mutation_bool( Self::INVENTORY_CAP_NAME, "PUT", format!("category/{folder}/links?tid={}", Self::tid()?), BTreeMap::new(), Some(payload), false, token, ) .await } pub(crate) async fn native_remove_category( &self, id: UUID, token: Option, ) -> Result { self.mutation_bool( Self::INVENTORY_CAP_NAME, "DELETE", format!("category/{id}"), BTreeMap::new(), None, true, token, ) .await } pub(crate) async fn native_remove_item( &self, id: UUID, token: Option, ) -> Result { self.mutation_bool( Self::INVENTORY_CAP_NAME, "DELETE", format!("item/{id}"), BTreeMap::new(), None, true, token, ) .await } pub(crate) async fn native_copy_library_category( &self, source: UUID, destination: UUID, subfolders: bool, token: Option, ) -> Result { let suffix = if subfolders { String::new() } else { ",depth=0".to_owned() }; self.mutation_bool( Self::LIBRARY_CAP_NAME, "COPY", format!("category/{source}?tid={}{}", Self::tid()?, suffix), Self::destination(destination), None, false, token, ) .await } pub(crate) async fn native_purge_descendents( &self, id: UUID, token: Option, ) -> Result { self.mutation_bool( Self::INVENTORY_CAP_NAME, "DELETE", format!("category/{id}/children"), BTreeMap::new(), None, false, token, ) .await } pub(crate) async fn native_update_category( &self, id: UUID, payload: OSD, token: Option, ) -> Result { self.mutation_bool( Self::INVENTORY_CAP_NAME, "PATCH", format!("category/{id}"), BTreeMap::new(), Some(payload), false, token, ) .await } pub(crate) async fn native_update_item( &self, id: UUID, payload: OSD, token: Option, ) -> Result { self.mutation_bool( Self::INVENTORY_CAP_NAME, "PATCH", format!("item/{id}"), BTreeMap::new(), Some(payload), false, token, ) .await } pub(crate) async fn native_copy_category( &self, source: UUID, destination: UUID, simulate: Option, token: Option, ) -> Result { let simulate = if simulate.unwrap_or(false) { "&simulate=1" } else { "" }; self.mutation_bool( Self::INVENTORY_CAP_NAME, "COPY", format!("category/{source}?tid={}{}", Self::tid()?, simulate), Self::destination(destination), None, false, token, ) .await } pub(crate) async fn native_move_category( &self, source: UUID, destination: UUID, token: Option, ) -> Result { self.native_update_category( source, OSD::Map(HashMap::from([( "parent_id".into(), OSD::UUID(destination), )])), token, ) .await } pub(crate) async fn native_put_category_children( &self, category: String, payload: OSD, token: Option, ) -> Result { if !Self::valid_category(&category) { return Ok(false); } self.mutation_bool( Self::INVENTORY_CAP_NAME, "PUT", format!("category/{category}/children?tid={}", Self::tid()?), BTreeMap::new(), Some(payload), false, token, ) .await } pub(crate) async fn native_copy_category_children( &self, category: String, destination: UUID, token: Option, ) -> Result { if !Self::valid_category(&category) { return Ok(false); } self.mutation_bool( Self::INVENTORY_CAP_NAME, "COPY", format!("category/{category}/children?tid={}", Self::tid()?), Self::destination(destination), None, false, token, ) .await } #[allow(clippy::unused_async)] // Fixed Task API must return an immediately ready future. pub(crate) async fn native_move_category_children( &self, _category: String, _destination: UUID, _token: Option, ) -> Result { Ok(false) } pub(crate) async fn native_delete_category_children( &self, category: String, token: Option, ) -> Result { if !Self::valid_category(&category) { return Ok(false); } self.mutation_bool( Self::INVENTORY_CAP_NAME, "DELETE", format!("category/{category}/children"), BTreeMap::new(), None, false, token, ) .await } pub(crate) async fn native_put_category_links( &self, category: String, payload: OSD, token: Option, ) -> Result { if !Self::valid_category(&category) { return Ok(false); } self.mutation_bool( Self::INVENTORY_CAP_NAME, "PUT", format!("category/{category}/links?tid={}", Self::tid()?), BTreeMap::new(), Some(payload), false, token, ) .await } pub(crate) async fn native_copy_category_links( &self, category: String, destination: UUID, token: Option, ) -> Result { if !Self::valid_category(&category) { return Ok(false); } self.mutation_bool( Self::INVENTORY_CAP_NAME, "COPY", format!("category/{category}/links?tid={}", Self::tid()?), Self::destination(destination), None, false, token, ) .await } #[allow(clippy::unused_async)] // Fixed Task API must return an immediately ready future. pub(crate) async fn native_move_category_links( &self, _category: String, _destination: UUID, _token: Option, ) -> Result { Ok(false) } pub(crate) async fn native_delete_category_links( &self, category: String, token: Option, ) -> Result { if !Self::valid_category(&category) { return Ok(false); } self.mutation_bool( Self::INVENTORY_CAP_NAME, "DELETE", format!("category/{category}/links"), BTreeMap::new(), None, false, token, ) .await } pub(crate) async fn native_copy_item( &self, item: UUID, destination: UUID, token: Option, ) -> Result { self.mutation_bool( Self::INVENTORY_CAP_NAME, "COPY", format!("item/{item}?tid={}", Self::tid()?), Self::destination(destination), None, false, token, ) .await } pub(crate) async fn native_move_item( &self, item: UUID, destination: UUID, token: Option, ) -> Result { self.native_update_item( item, OSD::Map(HashMap::from([( "parent_id".into(), OSD::UUID(destination), )])), token, ) .await } pub(crate) async fn native_get_category( &self, category: String, token: Option, ) -> Result { if !Self::valid_category(&category) { return Ok((false, vec![], vec![], vec![])); } self.get_parsed( Self::INVENTORY_CAP_NAME, format!("category/{category}"), token, ) .await } pub(crate) async fn native_get_category_children( &self, category: String, depth: i32, recursive: bool, token: Option, ) -> Result { if !Self::valid_category(&category) { return Ok((false, vec![], vec![], vec![])); } let depth = if recursive { MAX_FOLDER_DEPTH_REQUEST } else { depth.min(MAX_FOLDER_DEPTH_REQUEST) }; self.get_parsed( Self::INVENTORY_CAP_NAME, format!("category/{category}/children?depth={depth}"), token, ) .await } pub(crate) async fn native_fetch_category_categories( &self, id: UUID, inventory: bool, recursive: bool, depth: Option, token: Option, ) -> Result { let depth = if recursive { MAX_FOLDER_DEPTH_REQUEST } else { depth.unwrap_or(0).min(MAX_FOLDER_DEPTH_REQUEST) }; let cap = if inventory { Self::INVENTORY_CAP_NAME } else { Self::LIBRARY_CAP_NAME }; self.get_parsed( cap, format!("category/{id}/categories?depth={depth}"), token, ) .await } pub(crate) async fn native_fetch_category_subset( &self, id: UUID, children: Box>, inventory: bool, recursive: bool, depth: Option, token: Option, ) -> Result { let children: Vec<_> = children.take(MAX_AIS_OBJECTS).collect(); if children.is_empty() { return Ok((false, vec![], vec![], vec![])); } let depth = if recursive { MAX_FOLDER_DEPTH_REQUEST } else { depth.unwrap_or(0).min(MAX_FOLDER_DEPTH_REQUEST) }; let list = children .iter() .map(ToString::to_string) .collect::>() .join(","); let cap = if inventory { Self::INVENTORY_CAP_NAME } else { Self::LIBRARY_CAP_NAME }; self.get_parsed( cap, format!("category/{id}/children?depth={depth}&children={list}"), token, ) .await } pub(crate) async fn native_get_category_links( &self, category: String, token: Option, ) -> Result { if !Self::valid_category(&category) { return Ok((false, vec![], vec![], vec![])); } self.get_parsed( Self::INVENTORY_CAP_NAME, format!("category/{category}/links"), token, ) .await } pub(crate) async fn native_fetch_item( &self, id: UUID, token: Option, ) -> Result { let result = self .get_parsed( Self::INVENTORY_CAP_NAME, format!("item/{id}"), token.clone(), ) .await?; if result.0 && result.1.is_empty() && result.2.is_empty() && result.3.is_empty() { // `get_parsed` handles embedded shapes; retrying is intentionally avoided. } Ok(result) } pub(crate) async fn native_fetch_cof( &self, token: Option, ) -> Result { self.native_get_category_links("current".into(), token) .await .map(|v| v.0) } pub(crate) async fn native_fetch_orphans( &self, token: Option, ) -> Result { self.get_parsed(Self::INVENTORY_CAP_NAME, "orphans".into(), token) .await .map(|v| v.0) } pub(crate) async fn native_empty_trash( &self, token: Option, ) -> Result { self.native_delete_category_children("trash".into(), token) .await } pub(crate) fn native_parse_items_from_embedded( &self, response: Option, ) -> Result, Error> { response.map_or(Ok(Vec::new()), |response| self.parse_items_osd(&response)) } pub(crate) fn native_parse_folders_from_embedded( &self, response: OSDMap, ) -> Result, Error> { self.parse_folders_osd(&OSD::Map(response.snapshot())) } pub(crate) fn native_parse_links_from_embedded( &self, response: Option, ) -> Result, Error> { response.map_or(Ok(Vec::new()), |response| self.parse_links_osd(&response)) } pub(crate) fn native_parse_embedded( &self, response: OSD, folders: &mut Option>, items: &mut Option>, links: &mut Option>, ) -> Result<(), Error> { let parsed = match response { OSD::Map(map) => self.parse_all_map(&map)?, _ => return Err(Error::Argument), }; *folders = Some(parsed.0); *items = Some(parsed.1); *links = Some(parsed.2); Ok(()) } fn collection_map<'a>( response: &'a OSD, plural: &str, singular: &str, ) -> Option> { let OSD::Map(root) = response else { return None; }; let container = match root.get("_embedded") { Some(OSD::Map(map)) => map, _ => root, }; let collection = match container.get(plural) { Some(OSD::Map(map)) => Some(map), _ => None, }; Some((collection, container.get(singular))) } #[allow(clippy::unused_self)] // Parser belongs to the fixed instance API. fn parse_items_osd(&self, response: &OSD) -> Result, Error> { let Some((collection, singular)) = Self::collection_map(response, "items", "item") else { return Ok(Vec::new()); }; let mut result = Vec::new(); if let Some(collection) = collection { for value in collection.values() { if let OSD::Map(map) = value { result.push(InventoryItem::from_osd(OSD::Map(map.clone()))?); } } } if let Some(OSD::Map(map)) = singular && map.contains_key("item_id") { result.push(InventoryItem::from_osd(OSD::Map(map.clone()))?); } // GET /item/{id} returns the item itself at the top level. if let OSD::Map(map) = response && map.contains_key("item_id") { result.push(InventoryItem::from_osd(response.clone())?); } Self::dedup_items(result) } #[allow(clippy::unused_self)] // Parser belongs to the fixed instance API. fn parse_folders_osd(&self, response: &OSD) -> Result, Error> { let Some((collection, singular)) = Self::collection_map(response, "categories", "category") else { return Ok(Vec::new()); }; let mut result = Vec::new(); if let Some(collection) = collection { for value in collection.values() { if let OSD::Map(map) = value { result.push(InventoryFolder::from_osd(OSD::Map(map.clone()))?); } } } if let Some(OSD::Map(map)) = singular && map.contains_key("category_id") { result.push(InventoryFolder::from_osd(OSD::Map(map.clone()))?); } Self::dedup_folders(result) } #[allow(clippy::unused_self)] // Parser belongs to the fixed instance API. fn parse_links_osd(&self, response: &OSD) -> Result, Error> { let Some((collection, _)) = Self::collection_map(response, "links", "link") else { return Ok(Vec::new()); }; let mut result = Vec::new(); if let Some(collection) = collection { for value in collection.values() { let OSD::Map(map) = value else { return Err(Error::Argument); }; let mut item = InventoryItem::from_osd(OSD::Map(map.clone()))?; item.set_asset_type(AssetType::Link); if let Some(agent) = map.get("agent_id") { let id = agent.as_uuid()?; item.base.set_owner_id(id); item.set_creator_id(id); item.set_last_owner_id(id); } result.push(item); } } Self::dedup_items(result) } fn dedup_items(items: Vec) -> Result, Error> { let mut ids = HashSet::new(); if items .iter() .any(|item| item.base.uuid() == UUID::zero() || !ids.insert(item.base.uuid())) { return Err(Error::Argument); } Ok(items) } fn dedup_folders(folders: Vec) -> Result, Error> { let mut ids = HashSet::new(); if folders .iter() .any(|folder| folder.base.uuid() == UUID::zero() || !ids.insert(folder.base.uuid())) { return Err(Error::Argument); } Ok(folders) } pub(crate) fn native_parse_ais_response_meta( response: Option, ) -> Result { response.map_or_else( || Ok(AISResponseMeta::empty()), |map| Self::parse_meta_map(&map.snapshot()), ) } fn parse_meta_map(map: &HashMap) -> Result { let broken = Self::uuid_list(map.get("_broken_links_removed"))?; let mut removed = Self::uuid_list(map.get("_removed_items"))?; removed.extend(Self::uuid_list(map.get("_category_items_removed"))?); let categories = Self::uuid_list(map.get("_categories_removed"))?; let mut versions = HashMap::new(); if let Some(OSD::Map(values)) = map.get("_updated_category_versions") { for (key, value) in values { if let Ok(uuid) = UUID::parse(key.clone()) && uuid != UUID::zero() { versions.insert(uuid, value.as_integer()?); } } } AISResponseMeta::new(broken, removed, categories, versions) } fn uuid_list(value: Option<&OSD>) -> Result, Error> { let mut values = Vec::new(); match value { None => {} Some(OSD::Array(array)) => { for value in array { let uuid = value.as_uuid()?; if uuid != UUID::zero() { values.push(uuid); } } } Some(OSD::Map(map)) => { for key in map.keys() { if let Ok(uuid) = UUID::parse(key.clone()) && uuid != UUID::zero() { values.push(uuid); } } } Some(_) => return Err(Error::Argument), } Ok(values) } }