Files
MetaCrate/crates/libremetaverse/src/caps.rs
Chili Palmer 1ea44e5e42
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m51s
CI / required (push) Failing after 28m9s
fix(ci): close capability races and green audit
2026-08-13 09:43:42 +00:00

831 lines
27 KiB
Rust

//! Seed-capability discovery and capability event routing.
#![allow(clippy::missing_errors_doc)] // Public Result shapes are fixed by the compatibility map.
#![allow(clippy::needless_pass_by_value)] // Mapped APIs preserve owned CLR argument shapes.
use crate::event_queue::{
EventQueueClient, EventQueueClientConnectedCallback, EventQueueClientEventCallback,
};
use crate::packets::Packet;
use crate::{Error, Simulator};
use libremetaverse_structured_data::{OSD, OSDArray, OSDFormat, OSDMap, OSDParser};
use libremetaverse_types::compat::{
CancellationToken, CancellationTokenSource, EventHandler, Subscription, Uri,
};
use std::collections::HashMap;
use std::fmt;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock, Weak};
use std::thread::{self, JoinHandle};
use std::time::Duration;
const INITIAL_SEED_RETRY: Duration = Duration::from_secs(1);
const MAXIMUM_SEED_RETRY: Duration = Duration::from_secs(30);
fn mutex<T>(value: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
value
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
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)
}
struct CapabilitiesEventState {
next_id: AtomicU64,
handlers: Mutex<Vec<(u64, EventHandler<CapabilitiesReceivedEventArgs>)>>,
}
impl Default for CapabilitiesEventState {
fn default() -> Self {
Self {
next_id: AtomicU64::new(1),
handlers: Mutex::new(Vec::new()),
}
}
}
#[derive(Clone)]
pub struct CapabilitiesReceivedEventArgs {
simulator: Simulator,
}
impl CapabilitiesReceivedEventArgs {
pub const fn new(simulator: Simulator) -> Result<Self, Error> {
Ok(Self { simulator })
}
#[must_use]
pub fn simulator(&self) -> Simulator {
self.simulator.clone()
}
}
struct SeedTask {
cancellation: CancellationTokenSource,
handle: JoinHandle<()>,
}
pub(crate) struct CapsInner {
simulator: Weak<crate::network_manager::SimulatorData>,
seed_uri: Uri,
caps: RwLock<HashMap<String, Uri>>,
event_queue: Mutex<Option<EventQueueClient>>,
seed_task: Mutex<Option<SeedTask>>,
events: Arc<CapabilitiesEventState>,
seed_request_completed: AtomicBool,
disconnected: AtomicBool,
}
impl CapsInner {
fn simulator(&self) -> Option<Simulator> {
Simulator::native_from_weak(&self.simulator)
}
pub(crate) fn disconnect(&self, immediate: bool) {
if self.disconnected.swap(true, Ordering::AcqRel) {
return;
}
if let Some(task) = mutex(&self.seed_task).take() {
task.cancellation.cancel();
if task.handle.thread().id() != thread::current().id() {
let _ = task.handle.join();
}
}
if let Some(queue) = mutex(&self.event_queue).take() {
let _ = queue.stop(immediate);
let _ = queue.dispose();
}
}
fn emit_capabilities_received(&self, simulator: Simulator) {
let handlers: Vec<_> = mutex(&self.events.handlers)
.iter()
.map(|(_, handler)| Arc::clone(handler))
.collect();
for handler in handlers {
let args = CapabilitiesReceivedEventArgs {
simulator: simulator.clone(),
};
let _ = catch_unwind(AssertUnwindSafe(|| handler(args)));
}
}
fn route_event(&self, event_name: String, body: OSDMap) {
let Some(simulator) = self.simulator() else {
return;
};
match crate::message_decoder::decode_event(&event_name, &body) {
Ok(Some(message)) => {
simulator.native_dispatch_caps_event(&event_name, message.as_ref());
}
Ok(None) | Err(_) => {
if let Ok(Some(packet)) = Packet::build_packet_with_string_osd_map(event_name, body)
{
let _ = simulator.native_enqueue_caps_packet(packet);
}
}
}
}
}
impl Drop for CapsInner {
fn drop(&mut self) {
self.disconnected.store(true, Ordering::Release);
if let Some(task) = self
.seed_task
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
{
task.cancellation.cancel();
if task.handle.thread().id() != thread::current().id() {
let _ = task.handle.join();
}
}
if let Some(queue) = self
.event_queue
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
{
let _ = queue.dispose();
}
}
}
/// Capability collection associated with a simulator.
pub struct Caps {
pub simulator: Box<Simulator>,
inner: Arc<CapsInner>,
}
impl Clone for Caps {
fn clone(&self) -> Self {
Self {
simulator: Box::new(self.simulator.native_clone_without_caps()),
inner: Arc::clone(&self.inner),
}
}
}
impl Caps {
pub(crate) fn native_create(
simulator: &Simulator,
seed_uri: Uri,
) -> Result<Arc<CapsInner>, Error> {
if !is_http_uri(&seed_uri) {
return Err(Error::Argument);
}
let inner = Arc::new(CapsInner {
simulator: simulator.native_data_weak(),
seed_uri,
caps: RwLock::new(HashMap::new()),
event_queue: Mutex::new(None),
seed_task: Mutex::new(None),
events: Arc::new(CapabilitiesEventState::default()),
seed_request_completed: AtomicBool::new(false),
disconnected: AtomicBool::new(false),
});
start_seed_task(&inner)?;
Ok(inner)
}
pub(crate) fn native_from_inner(inner: Arc<CapsInner>, simulator: Simulator) -> Self {
Self {
simulator: Box::new(simulator.native_clone_without_caps()),
inner,
}
}
pub fn subscribe_capabilities_received(
&self,
handler: Option<EventHandler<CapabilitiesReceivedEventArgs>>,
) -> Subscription {
let Some(handler) = handler else {
return Subscription::detached();
};
let id = self.inner.events.next_id.fetch_add(1, Ordering::Relaxed);
mutex(&self.inner.events.handlers).push((id, handler));
let state = Arc::downgrade(&self.inner.events);
Subscription::new(move || {
if let Some(state) = state.upgrade() {
mutex(&state.handlers).retain(|(candidate, _)| *candidate != id);
}
})
}
#[must_use]
/// Returns the fixed capability names requested from a seed capability.
///
/// # Panics
///
/// Panics only if constructing an in-memory LLSD array fails.
pub fn all_capabilities() -> OSDArray {
OSDArray::new_with_list(
ALL_CAPABILITIES
.iter()
.map(|name| OSD::String((*name).to_owned()))
.collect(),
)
.expect("static capability list is valid")
}
pub fn capabilities(&self) -> Result<Vec<String>, Error> {
let mut names: Vec<_> = read(&self.inner.caps).keys().cloned().collect();
names.sort_unstable();
Ok(names)
}
pub fn capability_name_from_uri(&self, cap: Uri) -> Result<String, Error> {
read(&self.inner.caps)
.iter()
.find_map(|(name, uri)| (uri == &cap).then(|| name.clone()))
.ok_or(Error::InvalidOperation)
}
pub fn capability_uri(&self, capability: String) -> Result<Option<Uri>, Error> {
Ok(read(&self.inner.caps).get(&capability).cloned())
}
pub(crate) fn seed_request_finished(&self) -> bool {
self.inner.seed_request_completed.load(Ordering::Acquire)
}
pub fn disconnect(&self, immediate: bool) -> Result<(), Error> {
self.inner.disconnect(immediate);
Ok(())
}
pub fn get_mesh_cap_uri(&self) -> Result<Option<Uri>, Error> {
let caps = read(&self.inner.caps);
Ok(caps
.get("ViewerAsset")
.or_else(|| caps.get("GetMesh2"))
.or_else(|| caps.get("GetMesh"))
.cloned())
}
pub fn get_texture_cap_uri(&self) -> Result<Option<Uri>, Error> {
let caps = read(&self.inner.caps);
Ok(caps
.get("ViewerAsset")
.or_else(|| caps.get("GetTexture"))
.cloned())
}
#[must_use]
pub fn event_queue(&self) -> Option<EventQueueClient> {
mutex(&self.inner.event_queue).clone()
}
#[must_use]
pub fn is_event_queue_running(&self) -> bool {
mutex(&self.inner.event_queue)
.as_ref()
.is_some_and(EventQueueClient::running)
}
#[must_use]
pub fn seed_caps_uri(&self) -> Uri {
self.inner.seed_uri.clone()
}
}
impl fmt::Debug for Caps {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Caps")
.field("capability_count", &read(&self.inner.caps).len())
.field("event_queue_running", &self.is_event_queue_running())
.field(
"disconnected",
&self.inner.disconnected.load(Ordering::Acquire),
)
.finish_non_exhaustive()
}
}
fn start_seed_task(inner: &Arc<CapsInner>) -> Result<(), Error> {
let cancellation = CancellationTokenSource::new();
let token = cancellation.token();
let weak = Arc::downgrade(inner);
let (ready_sender, ready_receiver) = std::sync::mpsc::sync_channel(1);
let handle = thread::Builder::new()
.name("libremetaverse-seed-caps".to_owned())
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let Ok(runtime) = runtime else {
let _ = ready_sender.send(false);
return;
};
let _ = ready_sender.send(true);
runtime.block_on(run_seed_requests(weak, token));
})
.map_err(|_| Error::InvalidOperation)?;
if ready_receiver.recv_timeout(Duration::from_secs(2)) != Ok(true) {
cancellation.cancel();
let _ = handle.join();
return Err(Error::InvalidOperation);
}
*mutex(&inner.seed_task) = Some(SeedTask {
cancellation,
handle,
});
Ok(())
}
async fn run_seed_requests(weak: Weak<CapsInner>, cancellation: CancellationToken) {
let mut retry = 0_u32;
loop {
let Some(inner) = weak.upgrade() else {
return;
};
if cancellation.is_cancellation_requested() || inner.disconnected.load(Ordering::Acquire) {
return;
}
let Some(simulator) = inner.simulator() else {
return;
};
// Seed discovery is HTTP-only. It is valid for composed/fake clients
// to install a simulator and query capabilities before a UDP circuit
// is started, and tying this request to UDP connectivity creates an
// unnecessary race for every capability-backed manager.
let http = simulator.client.native_http_caps_client();
let response = http
.post_with_uri_osd_format_osd_cancellation_token_i_progress(
inner.seed_uri.clone(),
OSDFormat::Xml,
OSD::Array(Caps::all_capabilities().snapshot()),
cancellation.clone(),
None,
)
.await;
match response {
Ok((response, data)) if response.is_success_status_code() => {
if install_seed_response(&inner, &simulator, data, cancellation.clone())
.await
.is_ok()
{
finish_seed_request(&inner, simulator);
return;
}
}
Ok((response, _)) if response.status_code == 404 => {
finish_seed_request(&inner, simulator);
return;
}
Err(Error::Cancelled) if cancellation.is_cancellation_requested() => return,
Ok(_) | Err(_) => {}
}
retry = retry.saturating_add(1);
let delay = seed_retry_delay(&inner.seed_uri, retry);
tokio::select! {
() = tokio::time::sleep(delay) => {}
() = cancellation.cancelled() => return,
}
}
}
async fn install_seed_response(
inner: &Arc<CapsInner>,
simulator: &Simulator,
data: Vec<u8>,
cancellation: CancellationToken,
) -> Result<(), Error> {
let OSD::Map(map) = OSDParser::deserialize_with_bytes(data)? else {
return Err(Error::Parse {
position: 0,
context: "seed capability response is not a map",
});
};
let mut discovered = HashMap::with_capacity(map.len());
for (name, value) in map {
if let Some(uri) = value.as_uri()? {
if !is_http_uri(&uri) {
continue;
}
simulator
.client
.native_caps_rate_limiter()
.register_cap_uri(name.clone(), uri.clone())?;
discovered.insert(name, uri);
}
}
*write(&inner.caps) = discovered;
if let Some(uri) = read(&inner.caps).get("EventQueueGet").cloned() {
let mut queue = EventQueueClient::new_for_caps(uri, simulator.native_clone_without_caps())?;
let connected_inner = Arc::downgrade(inner);
queue.on_connected = Some(EventQueueClientConnectedCallback::from_handler(move || {
if let Some(inner) = connected_inner.upgrade()
&& let Some(simulator) = inner.simulator()
{
simulator.native_raise_event_queue_running();
}
}));
let event_inner = Arc::downgrade(inner);
queue.on_event = Some(EventQueueClientEventCallback::from_handler(
move |event_name, body| {
if let Some(inner) = event_inner.upgrade() {
inner.route_event(event_name, body);
}
},
));
queue.start()?;
*mutex(&inner.event_queue) = Some(queue);
}
// SimulatorFeatures is a GET capability, not merely an event-queue
// message. Load its initial snapshot before announcing that capability
// discovery is complete, while still allowing grids that omit or reject
// the optional endpoint to finish seed discovery normally.
let simulator_features_uri = read(&inner.caps).get("SimulatorFeatures").cloned();
if let Some(uri) = simulator_features_uri
&& let Ok((response, bytes)) = simulator
.client
.native_http_caps_client()
.get(uri, cancellation.clone(), None)
.await
&& response.is_success_status_code()
&& bytes.len() <= 8 * 1024 * 1024
{
let _ = simulator.features.set_features(None, Some(bytes), None);
}
cancellation.throw_if_cancellation_requested()?;
Ok(())
}
fn finish_seed_request(inner: &CapsInner, simulator: Simulator) {
if !inner.seed_request_completed.swap(true, Ordering::AcqRel) {
inner.emit_capabilities_received(simulator);
}
}
fn seed_retry_delay(uri: &Uri, retry: u32) -> Duration {
let multiplier = 1_u32
.checked_shl(retry.saturating_sub(1).min(30))
.unwrap_or(u32::MAX);
let base = INITIAL_SEED_RETRY
.saturating_mul(multiplier)
.min(MAXIMUM_SEED_RETRY);
let mut hash = 0xcbf2_9ce4_8422_2325_u64 ^ u64::from(retry);
for byte in uri.0.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100_0000_01b3);
}
let max_jitter = base / 8;
if max_jitter.is_zero() {
return base;
}
let nanos = u64::try_from(max_jitter.as_nanos())
.unwrap_or(u64::MAX)
.saturating_add(1);
base.saturating_add(Duration::from_nanos(hash % nanos))
}
fn is_http_uri(uri: &Uri) -> bool {
reqwest::Url::parse(&uri.0)
.ok()
.is_some_and(|uri| matches!(uri.scheme(), "http" | "https") && uri.host().is_some())
}
const ALL_CAPABILITIES: &[&str] = &[
"AbuseCategories",
"AcceptFriendship",
"AcceptGroupInvite",
"AgentPreferences",
"AgentProfile",
"AgentState",
"AttachmentResources",
"AvatarPickerSearch",
"AvatarRenderInfo",
"CharacterProperties",
"ChatSessionRequest",
"CopyInventoryFromNotecard",
"CreateInventoryCategory",
"DeclineFriendship",
"DeclineGroupInvite",
"DispatchRegionInfo",
"DirectDelivery",
"EnvironmentSettings",
"EstateAccess",
"EstateChangeInfo",
"EventQueueGet",
"ExtEnvironment",
"FetchLib2",
"FetchLibDescendents2",
"FetchInventory2",
"FetchInventoryDescendents2",
"IncrementCOFVersion",
"RequestTaskInventory",
"InterestList",
"InventoryThumbnailUpload",
"GetDisplayNames",
"GetExperiences",
"AgentExperiences",
"FindExperienceByName",
"GetExperienceInfo",
"GetAdminExperiences",
"GetCreatorExperiences",
"ExperiencePreferences",
"GroupExperiences",
"UpdateExperience",
"IsExperienceAdmin",
"IsExperienceContributor",
"RegionExperiences",
"ExperienceQuery",
"GetMesh",
"GetMesh2",
"GetMetadata",
"GetObjectCost",
"GetObjectPhysicsData",
"GetTexture",
"GroupAPIv1",
"GroupMemberData",
"GroupProposalBallot",
"HomeLocation",
"LandResources",
"LSLSyntax",
"MapLayer",
"MapLayerGod",
"MeshUploadFlag",
"ModifyMaterialParams",
"ModifyRegion",
"NavMeshGenerationStatus",
"NewFileAgentInventory",
"NewFileAgentInventoryVariablePrice",
"ObjectAnimation",
"ObjectMedia",
"ObjectMediaNavigate",
"ObjectNavMeshProperties",
"ParcelPropertiesUpdate",
"ParcelVoiceInfoRequest",
"ProductInfoRequest",
"ProvisionVoiceAccountRequest",
"ReadOfflineMsgs",
"RegionObjects",
"RegionSchedule",
"RemoteParcelRequest",
"RenderMaterials",
"RequestTextureDownload",
"ResourceCostSelected",
"RetrieveNavMeshSrc",
"SearchStatRequest",
"SearchStatTracking",
"SendPostcard",
"SendUserReport",
"SendUserReportWithScreenshot",
"ServerReleaseNotes",
"SetDisplayName",
"SimConsoleAsync",
"SimulatorFeatures",
"StartGroupProposal",
"TerrainNavMeshProperties",
"TextureStats",
"UntrustedSimulatorMessage",
"UpdateAgentInformation",
"UpdateAgentLanguage",
"UpdateAvatarAppearance",
"UpdateGestureAgentInventory",
"UpdateGestureTaskInventory",
"UpdateNotecardAgentInventory",
"UpdateNotecardTaskInventory",
"UpdateScriptAgent",
"UpdateScriptTask",
"UpdateSettingsAgentInventory",
"UpdateSettingsTaskInventory",
"UploadAgentProfileImage",
"UpdateMaterialAgentInventory",
"UpdateMaterialTaskInventory",
"UploadBakedTexture",
"UserInfo",
"ViewerAsset",
"ViewerBenefits",
"ViewerMetrics",
"ViewerStartAuction",
"ViewerStats",
"VoiceSignalingRequest",
"InventoryAPIv3",
"LibraryAPIv3",
];
#[cfg(test)]
#[allow(clippy::too_many_lines)] // The seed exchange is one contiguous protocol scenario.
mod tests {
use super::*;
use crate::{GridClient, HttpCapsClient};
use libremetaverse_types::compat::{HttpMessageHandler, HttpRequest, HttpResponse};
use std::collections::BTreeMap;
use std::sync::mpsc;
use std::time::Instant;
fn lock<T>(value: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
value
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn response(status_code: u16, body: Vec<u8>) -> HttpResponse {
HttpResponse {
status_code,
headers: BTreeMap::new(),
content_type: Some(HttpCapsClient::LLSD_XML.to_owned()),
body,
}
}
fn seed_body() -> Vec<u8> {
OSDParser::serialize_llsd_xml_bytes(OSD::Map(HashMap::from([
(
"EventQueueGet".to_owned(),
OSD::Uri(Uri(
"https://caps.example.test/queue?token=eq-secret".to_owned()
)),
),
(
"ViewerAsset".to_owned(),
OSD::Uri(Uri("https://asset.example.test/viewer".to_owned())),
),
(
"GetMesh2".to_owned(),
OSD::Uri(Uri("https://asset.example.test/mesh2".to_owned())),
),
(
"GetTexture".to_owned(),
OSD::Uri(Uri("https://asset.example.test/texture".to_owned())),
),
(
"SimulatorFeatures".to_owned(),
OSD::Uri(Uri("https://caps.example.test/features".to_owned())),
),
(
"InvalidScheme".to_owned(),
OSD::Uri(Uri("file:///private/capability".to_owned())),
),
])))
.unwrap()
}
#[test]
fn seed_discovery_preserves_request_shape_registers_caps_and_starts_queue() {
let requests = Arc::new(Mutex::new(Vec::<HttpRequest>::new()));
let recorded = Arc::clone(&requests);
let release_seed = Arc::new(AtomicBool::new(false));
let release = Arc::clone(&release_seed);
let handler = HttpMessageHandler::new(move |request, _cancellation| {
let recorded = Arc::clone(&recorded);
let release = Arc::clone(&release);
async move {
let is_seed = request.uri.0.contains("/seed");
let is_features = request.uri.0.contains("/features");
lock(&recorded).push(request);
if is_seed {
while !release.load(Ordering::Acquire) {
tokio::time::sleep(Duration::from_millis(1)).await;
}
response(200, seed_body())
} else if is_features {
response(
200,
OSDParser::serialize_llsd_xml_bytes(OSD::Map(HashMap::from([(
"MeshRezEnabled".to_owned(),
OSD::Boolean(true),
)])))
.unwrap(),
)
} else {
response(404, Vec::new())
}
}
});
let mut client = GridClient::new().unwrap();
client.set_http_caps_client(HttpCapsClient::new(handler).unwrap());
let simulator =
Simulator::new(client, "127.0.0.1:13001".parse().unwrap(), 2, None, None).unwrap();
simulator.native_set_connected_for_tests(true);
simulator
.native_set_seed_caps(
Some(Uri(
"https://caps.example.test/seed?token=seed-secret".to_owned()
)),
Some(false),
)
.unwrap();
let caps = simulator.clone().caps.expect("caps snapshot");
let (sender, receiver) = mpsc::channel();
let _subscription = caps.subscribe_capabilities_received(Some(Arc::new(move |args| {
sender.send(args.simulator().handle).unwrap();
})));
release_seed.store(true, Ordering::Release);
assert_eq!(receiver.recv_timeout(Duration::from_secs(2)).unwrap(), 2);
assert_eq!(
caps.seed_caps_uri(),
Uri("https://caps.example.test/seed?token=seed-secret".to_owned())
);
assert_eq!(
caps.get_texture_cap_uri().unwrap(),
Some(Uri("https://asset.example.test/viewer".to_owned()))
);
assert_eq!(
caps.get_mesh_cap_uri().unwrap(),
Some(Uri("https://asset.example.test/viewer".to_owned()))
);
assert_eq!(
caps.capability_name_from_uri(Uri("https://asset.example.test/mesh2".to_owned()))
.unwrap(),
"GetMesh2"
);
assert_eq!(
caps.capability_uri("InvalidScheme".to_owned()).unwrap(),
None
);
assert!(caps.event_queue().is_some());
assert_eq!(
simulator.features.get("MeshRezEnabled".to_owned()).unwrap(),
Some(OSD::Boolean(true))
);
let deadline = Instant::now() + Duration::from_secs(2);
while lock(&requests).len() < 2 && Instant::now() < deadline {
thread::sleep(Duration::from_millis(5));
}
let requests = lock(&requests);
let OSD::Array(requested) =
OSDParser::deserialize_llsd_xml_with_bytes(requests[0].body.clone()).unwrap()
else {
panic!("capability request array")
};
let requested: Vec<_> = requested
.iter()
.map(|value| value.as_string().unwrap())
.collect();
assert_eq!(
requested,
ALL_CAPABILITIES
.iter()
.map(|value| (*value).to_owned())
.collect::<Vec<_>>()
);
assert_eq!(
requests[0].content_type.as_deref(),
Some(HttpCapsClient::LLSD_XML)
);
drop(requests);
caps.disconnect(true).unwrap();
}
#[test]
fn all_capabilities_matches_reference_and_debug_redacts_seed_uri() {
let values: Vec<_> = Caps::all_capabilities()
.snapshot()
.into_iter()
.map(|value| value.as_string().unwrap())
.collect();
assert_eq!(
values,
ALL_CAPABILITIES
.iter()
.map(|value| (*value).to_owned())
.collect::<Vec<_>>()
);
assert!(values.contains(&"EventQueueGet".to_owned()));
assert!(values.contains(&"InventoryAPIv3".to_owned()));
let handler = HttpMessageHandler::new(|_request, cancellation| async move {
cancellation.cancelled().await;
response(499, Vec::new())
});
let mut client = GridClient::new().unwrap();
client.set_http_caps_client(HttpCapsClient::new(handler).unwrap());
let simulator =
Simulator::new(client, "127.0.0.1:13002".parse().unwrap(), 3, None, None).unwrap();
simulator.native_set_connected_for_tests(true);
simulator
.native_set_seed_caps(
Some(Uri(
"https://caps.example.test/seed?token=do-not-log".to_owned()
)),
Some(false),
)
.unwrap();
let caps = simulator.clone().caps.unwrap();
let debug = format!("{caps:?}");
assert!(!debug.contains("do-not-log"));
caps.disconnect(true).unwrap();
}
}