//! Bounded native HTTP capabilities client and per-category rate limiter. #![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. #![allow(clippy::option_option)] // The fixed generated nullable-value mapping is Option>. #![allow(clippy::too_many_arguments)] // HTTP request boundaries keep explicit policy inputs visible. use crate::{CapsCategory, Error}; use flate2::read::{DeflateDecoder, GzDecoder, ZlibDecoder}; use futures_util::StreamExt; use libremetaverse_structured_data::{OSD, OSDFormat, OSDParser}; use libremetaverse_types::compat::{ CancellationToken, CancellationTokenSource, HttpMessageHandler, HttpRequest, HttpResponse, IProgress, MediaTypeHeaderValue, RateLimitLease, TimeProvider, Uri, }; use std::collections::{HashMap, VecDeque}; use std::fmt; use std::io::Read; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, SystemTime}; const STREAM_CHUNK_SIZE: usize = 81_920; fn mutex(value: &Mutex) -> std::sync::MutexGuard<'_, T> { value .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } fn read(value: &RwLock) -> std::sync::RwLockReadGuard<'_, T> { value .read() .unwrap_or_else(std::sync::PoisonError::into_inner) } fn write(value: &RwLock) -> std::sync::RwLockWriteGuard<'_, T> { value .write() .unwrap_or_else(std::sync::PoisonError::into_inner) } /// Explicit memory and redirect policy for capability HTTP traffic. #[derive(Clone, Debug, Eq, PartialEq)] pub struct CapsHttpLimits { pub max_request_bytes: usize, pub max_response_bytes: usize, pub max_decompressed_bytes: usize, pub max_redirects: usize, } impl Default for CapsHttpLimits { fn default() -> Self { Self { max_request_bytes: 32 * 1024 * 1024, max_response_bytes: 64 * 1024 * 1024, max_decompressed_bytes: 128 * 1024 * 1024, max_redirects: 10, } } } impl CapsHttpLimits { fn validate(&self) -> Result<(), Error> { if self.max_request_bytes == 0 || self.max_response_bytes == 0 || self.max_decompressed_bytes == 0 { return Err(Error::Argument); } Ok(()) } } /// Typed upload/download progress matching the C# nested value type. #[derive(Clone, Copy, Debug, PartialEq)] pub struct HttpCapsClientProgressReport { total_bytes: Option>, bytes_transferred: i64, percent: Option>, } impl HttpCapsClientProgressReport { pub fn new( total_bytes: Option>, bytes_transferred: i64, percent: Option>, ) -> Result { if bytes_transferred < 0 || total_bytes.flatten().is_some_and(|total| total < 0) { return Err(Error::Argument); } Ok(Self { total_bytes, bytes_transferred, percent, }) } #[must_use] pub const fn bytes_transferred(&self) -> i64 { self.bytes_transferred } #[must_use] pub const fn percent(&self) -> Option> { self.percent } #[must_use] pub const fn total_bytes(&self) -> Option> { self.total_bytes } #[allow(clippy::cast_precision_loss)] // C# computes the same rounded percentage as double. fn known(total: usize, transferred: usize) -> Option { let total = i64::try_from(total).ok()?; let transferred = i64::try_from(transferred).ok()?; let percent = if total == 0 { None } else { Some(((transferred as f64 / total as f64) * 10_000.0).round() / 100.0) }; Some(Self { total_bytes: Some(Some(total)), bytes_transferred: transferred, percent: Some(percent), }) } fn unknown(transferred: usize) -> Option { Some(Self { total_bytes: Some(None), bytes_transferred: i64::try_from(transferred).ok()?, percent: Some(None), }) } } /// Mapped token-bucket configuration. #[derive(Clone, Debug, Eq, PartialEq)] pub struct CapsRateLimiterOptions { token_limit: i32, tokens_per_period: i32, replenishment_period: Duration, queue_limit: i32, } impl CapsRateLimiterOptions { pub fn new() -> Result { Ok(Self { token_limit: 0, tokens_per_period: 0, replenishment_period: Duration::ZERO, queue_limit: 0, }) } #[must_use] pub const fn queue_limit(&self) -> i32 { self.queue_limit } pub fn set_queue_limit(&mut self, value: i32) { self.queue_limit = value; } #[must_use] pub const fn replenishment_period(&self) -> Duration { self.replenishment_period } pub fn set_replenishment_period(&mut self, value: Duration) { self.replenishment_period = value; } #[must_use] pub const fn token_limit(&self) -> i32 { self.token_limit } pub fn set_token_limit(&mut self, value: i32) { self.token_limit = value; } #[must_use] pub const fn tokens_per_period(&self) -> i32 { self.tokens_per_period } pub fn set_tokens_per_period(&mut self, value: i32) { self.tokens_per_period = value; } fn configured( token_limit: i32, tokens_per_period: i32, replenishment_period: Duration, queue_limit: i32, ) -> Self { Self { token_limit, tokens_per_period, replenishment_period, queue_limit, } } fn validate(&self) -> Result<(), Error> { if self.token_limit <= 0 || self.tokens_per_period <= 0 || self.replenishment_period.is_zero() || self.queue_limit < 0 { return Err(Error::Argument); } Ok(()) } } #[derive(Debug)] struct BucketState { tokens: u32, last_replenishment: SystemTime, queue: VecDeque, } struct CategoryBucket { options: CapsRateLimiterOptions, next_ticket: AtomicU64, state: Mutex, } impl CategoryBucket { fn new(options: CapsRateLimiterOptions, now: SystemTime) -> Result { options.validate()?; Ok(Self { state: Mutex::new(BucketState { tokens: u32::try_from(options.token_limit).map_err(|_| Error::Argument)?, last_replenishment: now, queue: VecDeque::new(), }), options, next_ticket: AtomicU64::new(1), }) } fn replenish(&self, state: &mut BucketState, now: SystemTime) { let Ok(elapsed) = now.duration_since(state.last_replenishment) else { state.last_replenishment = now; return; }; let period_nanos = self.options.replenishment_period.as_nanos(); if period_nanos == 0 { return; } let periods = elapsed.as_nanos() / period_nanos; if periods == 0 { return; } let added = periods.saturating_mul(u128::from( u32::try_from(self.options.tokens_per_period).unwrap_or(u32::MAX), )); let token_limit = u32::try_from(self.options.token_limit).unwrap_or(u32::MAX); state.tokens = token_limit.min( state .tokens .saturating_add(u32::try_from(added).unwrap_or(u32::MAX)), ); let periods_u32 = u32::try_from(periods).unwrap_or(u32::MAX); state.last_replenishment += self.options.replenishment_period * periods_u32; } fn remove_ticket(&self, ticket: u64) { mutex(&self.state).queue.retain(|queued| *queued != ticket); } } struct CapsRateLimiterInner { buckets: HashMap>, uri_categories: RwLock>, clock: TimeProvider, shutdown: CancellationTokenSource, disposed: AtomicBool, } /// Per-capability-category token buckets with a bounded oldest-first queue. #[derive(Clone)] pub struct CapsRateLimiter(Arc); impl CapsRateLimiter { pub fn new_with_constructor() -> Result { Self::new_with_clock_and_overrides(TimeProvider::system(), None) } pub fn new_with_i_read_only_dictionary( overrides: Option>, ) -> Result { Self::new_with_clock_and_overrides(TimeProvider::system(), overrides) } pub fn new_with_clock_and_overrides( clock: TimeProvider, overrides: Option>, ) -> Result { let mut options = default_rate_options(); if let Some(overrides) = overrides { options.extend(overrides); } let fallback = options .get(&CapsCategory::Default) .cloned() .ok_or(Error::InvalidOperation)?; let now = clock.get_utc_now(); let mut buckets = HashMap::new(); for category in all_categories() { let configured = options .get(&category) .cloned() .unwrap_or_else(|| fallback.clone()); buckets.insert(category, Arc::new(CategoryBucket::new(configured, now)?)); } Ok(Self(Arc::new(CapsRateLimiterInner { buckets, uri_categories: RwLock::new(HashMap::new()), clock, shutdown: CancellationTokenSource::new(), disposed: AtomicBool::new(false), }))) } pub async fn acquire( &self, uri: Uri, cancellation_token: Option, ) -> Result { let cancellation_token = cancellation_token.unwrap_or_default(); cancellation_token.throw_if_cancellation_requested()?; if self.0.disposed.load(Ordering::Acquire) { return Err(Error::InvalidOperation); } let key = parse_http_uri(&uri)?.to_string(); let category = read(&self.0.uri_categories) .get(&key) .copied() .unwrap_or(CapsCategory::Default); let bucket = self .0 .buckets .get(&category) .cloned() .ok_or(Error::InvalidOperation)?; let mut ticket = None; loop { if cancellation_token.is_cancellation_requested() { if let Some(ticket) = ticket { bucket.remove_ticket(ticket); } return Err(Error::Cancelled); } if self.0.disposed.load(Ordering::Acquire) { if let Some(ticket) = ticket { bucket.remove_ticket(ticket); } return Err(Error::InvalidOperation); } let now = self.0.clock.get_utc_now(); let wait = { let mut state = mutex(&bucket.state); bucket.replenish(&mut state, now); let is_front = ticket.map_or_else( || state.queue.is_empty(), |ticket| state.queue.front() == Some(&ticket), ); if is_front && state.tokens > 0 { state.tokens -= 1; if ticket.is_some() { state.queue.pop_front(); } return Ok(RateLimitLease::acquired()); } if ticket.is_none() { let queue_limit = usize::try_from(bucket.options.queue_limit).map_err(|_| Error::Argument)?; if state.queue.len() >= queue_limit { return Ok(RateLimitLease::rejected()); } let next = bucket.next_ticket.fetch_add(1, Ordering::Relaxed); state.queue.push_back(next); ticket = Some(next); } let elapsed = now .duration_since(state.last_replenishment) .unwrap_or_default(); bucket .options .replenishment_period .saturating_sub(elapsed) .max(Duration::from_millis(1)) }; if let Err(error) = wait_executor_neutral(wait, cancellation_token.clone(), self.0.shutdown.token()) .await { if let Some(ticket) = ticket { bucket.remove_ticket(ticket); } return Err(error); } } } pub fn register_cap_uri(&self, cap_name: String, uri: Uri) -> Result<(), Error> { if self.0.disposed.load(Ordering::Acquire) { return Err(Error::InvalidOperation); } let category = category_for_cap_name(&cap_name); write(&self.0.uri_categories).insert(parse_http_uri(&uri)?.to_string(), category); Ok(()) } pub fn dispose(&self) -> Result<(), Error> { if self.0.disposed.swap(true, Ordering::AcqRel) { return Ok(()); } self.0.shutdown.cancel(); write(&self.0.uri_categories).clear(); for bucket in self.0.buckets.values() { mutex(&bucket.state).queue.clear(); } Ok(()) } } impl fmt::Debug for CapsRateLimiter { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CapsRateLimiter") .field("categories", &self.0.buckets.len()) .field("registered_uri_count", &read(&self.0.uri_categories).len()) .field("disposed", &self.0.disposed.load(Ordering::Acquire)) .finish() } } async fn wait_executor_neutral( duration: Duration, cancellation_token: CancellationToken, shutdown: CancellationToken, ) -> Result<(), Error> { tokio::select! { () = tokio::time::sleep(duration) => Ok(()), () = cancellation_token.cancelled() => Err(Error::Cancelled), () = shutdown.cancelled() => Err(Error::InvalidOperation), } } fn all_categories() -> [CapsCategory; 8] { [ CapsCategory::Default, CapsCategory::RenderMaterials, CapsCategory::AssetFetch, CapsCategory::AssetUpload, CapsCategory::Inventory, CapsCategory::EventQueue, CapsCategory::DisplayName, CapsCategory::Voice, ] } fn default_rate_options() -> HashMap { let second = Duration::from_secs(1); HashMap::from([ ( CapsCategory::Default, CapsRateLimiterOptions::configured(20, 10, second, 30), ), ( CapsCategory::RenderMaterials, CapsRateLimiterOptions::configured(4, 2, second, 20), ), ( CapsCategory::AssetFetch, CapsRateLimiterOptions::configured(24, 12, second, 60), ), ( CapsCategory::AssetUpload, CapsRateLimiterOptions::configured(4, 2, second, 10), ), ( CapsCategory::Inventory, CapsRateLimiterOptions::configured(6, 3, second, 20), ), ( CapsCategory::EventQueue, CapsRateLimiterOptions::configured(3, 2, second, 3), ), ( CapsCategory::DisplayName, CapsRateLimiterOptions::configured(5, 2, second, 15), ), ( CapsCategory::Voice, CapsRateLimiterOptions::configured(10, 5, second, 20), ), ]) } fn category_for_cap_name(name: &str) -> CapsCategory { match name.to_ascii_lowercase().as_str() { "rendermaterials" | "modifymaterialparams" | "modifyregion" => { CapsCategory::RenderMaterials } "gettexture" | "viewerasset" | "getmesh" | "getmesh2" | "getmetadata" | "requesttexturedownload" => CapsCategory::AssetFetch, "newfileagentinventory" | "newfileagentinventoryvariableprice" | "uploadbakedtexture" | "updateavatarappearance" | "inventorythumbnailupload" | "updatematerialagentinventory" | "updatematerialtaskinventory" => CapsCategory::AssetUpload, "fetchinventory2" | "fetchinventorydescendents2" | "fetchlib2" | "fetchlibdescendents2" | "inventoryapiv3" | "libraryapiv3" | "requesttaskinventory" => CapsCategory::Inventory, "eventqueueget" => CapsCategory::EventQueue, "getdisplaynames" | "setdisplayname" | "avatarpickersearch" | "agentprofile" => { CapsCategory::DisplayName } "provisionvoiceaccountrequest" | "voicesignalingrequest" | "parcelvoiceinforequest" => { CapsCategory::Voice } _ => CapsCategory::Default, } } enum HttpBackend { Handler(HttpMessageHandler), Reqwest(reqwest::Client), } struct HttpCapsClientInner { backend: HttpBackend, limits: CapsHttpLimits, rate_limiter: RwLock>, request_slots: Arc, shutdown: CancellationTokenSource, disposed: AtomicBool, } /// Native capability HTTP client with injectable fake and reqwest backends. #[derive(Clone)] pub struct HttpCapsClient(Arc); impl HttpCapsClient { pub const LLSD_XML: &'static str = "application/llsd+xml"; pub const LLSD_BINARY: &'static str = "application/llsd+binary"; pub const LLSD_JSON: &'static str = "application/llsd+json"; #[must_use] pub fn hdr_llsd_xml() -> MediaTypeHeaderValue { MediaTypeHeaderValue(Self::LLSD_XML.to_owned()) } #[must_use] pub fn hdr_llsd_binary() -> MediaTypeHeaderValue { MediaTypeHeaderValue(Self::LLSD_BINARY.to_owned()) } #[must_use] pub fn hdr_llsd_json() -> MediaTypeHeaderValue { MediaTypeHeaderValue(Self::LLSD_JSON.to_owned()) } pub fn new(handler: HttpMessageHandler) -> Result { Self::with_handler_and_limits(handler, CapsHttpLimits::default()) } pub fn with_handler_and_limits( handler: HttpMessageHandler, limits: CapsHttpLimits, ) -> Result { Self::with_handler_policy(handler, None, limits, 32) } /// Creates a fully injected fake/backend client with explicit limiter and /// concurrency policy. This is the deterministic test and embedding seam. pub fn with_handler_policy( handler: HttpMessageHandler, rate_limiter: Option, limits: CapsHttpLimits, max_concurrent_requests: usize, ) -> Result { limits.validate()?; if max_concurrent_requests == 0 { return Err(Error::Argument); } Ok(Self(Arc::new(HttpCapsClientInner { backend: HttpBackend::Handler(handler), limits, rate_limiter: RwLock::new(rate_limiter), request_slots: Arc::new(tokio::sync::Semaphore::new(max_concurrent_requests)), shutdown: CancellationTokenSource::new(), disposed: AtomicBool::new(false), }))) } pub fn with_reqwest_client( client: reqwest::Client, rate_limiter: Option, limits: CapsHttpLimits, ) -> Result { Self::with_reqwest_client_and_concurrency(client, rate_limiter, limits, 32) } fn with_reqwest_client_and_concurrency( client: reqwest::Client, rate_limiter: Option, limits: CapsHttpLimits, max_connections: usize, ) -> Result { limits.validate()?; if max_connections == 0 { return Err(Error::Argument); } Ok(Self(Arc::new(HttpCapsClientInner { backend: HttpBackend::Reqwest(client), limits, rate_limiter: RwLock::new(rate_limiter), request_slots: Arc::new(tokio::sync::Semaphore::new(max_connections)), shutdown: CancellationTokenSource::new(), disposed: AtomicBool::new(false), }))) } pub(crate) fn production( user_agent: &str, timeout: Duration, max_connections: usize, rate_limiter: CapsRateLimiter, ) -> Result { if max_connections == 0 { return Err(Error::Argument); } let limits = CapsHttpLimits::default(); let max_redirects = limits.max_redirects; let redirect = reqwest::redirect::Policy::custom(move |attempt| { if attempt.previous().len() > max_redirects { return attempt.stop(); } match attempt.url().scheme() { "http" | "https" => attempt.follow(), _ => attempt.stop(), } }); let client = reqwest::Client::builder() .redirect(redirect) .timeout(timeout) .connect_timeout(timeout) .pool_max_idle_per_host(max_connections) .user_agent(user_agent) .build() .map_err(|_| Error::HttpRequest)?; Self::with_reqwest_client_and_concurrency( client, Some(rate_limiter), limits, max_connections, ) } pub(crate) fn set_rate_limiter(&self, rate_limiter: Option) { *write(&self.0.rate_limiter) = rate_limiter; } pub(crate) fn shutdown(&self) { if !self.0.disposed.swap(true, Ordering::AcqRel) { self.0.shutdown.cancel(); self.0.request_slots.close(); } } pub async fn get( &self, uri: Uri, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { self.send("GET", uri, None, Vec::new(), cancellation_token, progress) .await } /// Sends a capability request whose verb or headers are not covered by the /// fixed public compatibility overloads (`AISv3` uses `COPY` and /// `Destination`). The request still passes through all normal limits, /// cancellation, rate limiting, and injected-handler recording. pub(crate) async fn send_custom( &self, method: &str, uri: Uri, headers: std::collections::BTreeMap, content_type: Option, payload: Vec, cancellation_token: CancellationToken, ) -> Result<(HttpResponse, Vec), Error> { self.send_with_headers( method, uri, headers, content_type, payload, cancellation_token, None, ) .await } pub async fn get_request( &self, uri: Uri, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(), Error> { self.get(uri, cancellation_token, progress) .await .map(|_| ()) } pub async fn post_with_uri_string_bytes_cancellation_token_i_progress( &self, uri: Uri, content_type: String, payload: Vec, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { self.send( "POST", uri, Some(content_type), payload, cancellation_token, progress, ) .await } pub async fn put_with_uri_string_bytes_cancellation_token_i_progress( &self, uri: Uri, content_type: String, payload: Vec, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { self.send( "PUT", uri, Some(content_type), payload, cancellation_token, progress, ) .await } pub async fn patch_with_uri_string_bytes_cancellation_token_i_progress( &self, uri: Uri, content_type: String, payload: Vec, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { self.send( "PATCH", uri, Some(content_type), payload, cancellation_token, progress, ) .await } pub async fn delete_with_uri_string_bytes_cancellation_token_i_progress( &self, uri: Uri, content_type: String, payload: Vec, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { self.send( "DELETE", uri, Some(content_type), payload, cancellation_token, progress, ) .await } pub async fn post_with_uri_osd_format_osd_cancellation_token_i_progress( &self, uri: Uri, format: OSDFormat, payload: OSD, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { let (content_type, payload) = serialize_osd(format, payload)?; self.send( "POST", uri, Some(content_type), payload, cancellation_token, progress, ) .await } pub async fn put_with_uri_osd_format_osd_cancellation_token_i_progress( &self, uri: Uri, format: OSDFormat, payload: OSD, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { let (content_type, payload) = serialize_osd(format, payload)?; self.send( "PUT", uri, Some(content_type), payload, cancellation_token, progress, ) .await } pub async fn patch_with_uri_osd_format_osd_cancellation_token_i_progress( &self, uri: Uri, format: OSDFormat, payload: OSD, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { let (content_type, payload) = serialize_osd(format, payload)?; self.send( "PATCH", uri, Some(content_type), payload, cancellation_token, progress, ) .await } pub async fn delete_with_uri_osd_format_osd_cancellation_token_i_progress( &self, uri: Uri, format: OSDFormat, payload: OSD, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { let (content_type, payload) = serialize_osd(format, payload)?; self.send( "DELETE", uri, Some(content_type), payload, cancellation_token, progress, ) .await } pub async fn post_request_with_uri_string_bytes_cancellation_token_i_progress( &self, uri: Uri, content_type: String, payload: Vec, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(), Error> { self.post_with_uri_string_bytes_cancellation_token_i_progress( uri, content_type, payload, cancellation_token, progress, ) .await .map(|_| ()) } pub async fn put_request_with_uri_string_bytes_cancellation_token_i_progress( &self, uri: Uri, content_type: String, payload: Vec, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(), Error> { self.put_with_uri_string_bytes_cancellation_token_i_progress( uri, content_type, payload, cancellation_token, progress, ) .await .map(|_| ()) } pub async fn patch_request_with_uri_string_bytes_cancellation_token_i_progress( &self, uri: Uri, content_type: String, payload: Vec, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(), Error> { self.patch_with_uri_string_bytes_cancellation_token_i_progress( uri, content_type, payload, cancellation_token, progress, ) .await .map(|_| ()) } pub async fn delete_request_with_uri_string_bytes_cancellation_token_i_progress( &self, uri: Uri, content_type: String, payload: Vec, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(), Error> { self.delete_with_uri_string_bytes_cancellation_token_i_progress( uri, content_type, payload, cancellation_token, progress, ) .await .map(|_| ()) } pub async fn post_request_with_uri_osd_format_osd_cancellation_token_i_progress( &self, uri: Uri, format: OSDFormat, payload: OSD, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(), Error> { self.post_with_uri_osd_format_osd_cancellation_token_i_progress( uri, format, payload, cancellation_token, progress, ) .await .map(|_| ()) } pub async fn put_request_with_uri_osd_format_osd_cancellation_token_i_progress( &self, uri: Uri, format: OSDFormat, payload: OSD, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(), Error> { self.put_with_uri_osd_format_osd_cancellation_token_i_progress( uri, format, payload, cancellation_token, progress, ) .await .map(|_| ()) } pub async fn patch_request_with_uri_osd_format_osd_cancellation_token_i_progress( &self, uri: Uri, format: OSDFormat, payload: OSD, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(), Error> { self.patch_with_uri_osd_format_osd_cancellation_token_i_progress( uri, format, payload, cancellation_token, progress, ) .await .map(|_| ()) } pub async fn delete_request_with_uri_osd_format_osd_cancellation_token_i_progress( &self, uri: Uri, format: OSDFormat, payload: OSD, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(), Error> { self.delete_with_uri_osd_format_osd_cancellation_token_i_progress( uri, format, payload, cancellation_token, progress, ) .await .map(|_| ()) } async fn send( &self, method: &str, uri: Uri, content_type: Option, payload: Vec, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { self.send_with_headers( method, uri, std::collections::BTreeMap::new(), content_type, payload, cancellation_token, progress, ) .await } async fn send_with_headers( &self, method: &str, uri: Uri, headers: std::collections::BTreeMap, content_type: Option, payload: Vec, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { if self.0.disposed.load(Ordering::Acquire) { return Err(Error::InvalidOperation); } cancellation_token.throw_if_cancellation_requested()?; let operation_cancellation = CancellationTokenSource::new_linked(&[cancellation_token, self.0.shutdown.token()]); let cancellation_token = operation_cancellation.token(); if payload.len() > self.0.limits.max_request_bytes { return Err(Error::HttpRequest); } let parsed = parse_http_uri(&uri)?; let limiter = { read(&self.0.rate_limiter).clone() }; if let Some(limiter) = limiter { let _ = limiter .acquire(uri.clone(), Some(cancellation_token.clone())) .await?; } let request_slot = Arc::clone(&self.0.request_slots); let _permit = tokio::select! { permit = request_slot.acquire_owned() => permit.map_err(|_| Error::InvalidOperation)?, () = cancellation_token.cancelled() => return Err(Error::Cancelled), }; let progress: Option>> = progress.map(Arc::from); let result = match &self.0.backend { HttpBackend::Handler(handler) => { self.send_handler( handler, method, uri, headers, content_type, payload, cancellation_token, progress, ) .await } HttpBackend::Reqwest(client) => { self.send_reqwest( client, method, parsed, headers, content_type, payload, cancellation_token, progress, ) .await } }; drop(operation_cancellation); result } async fn send_handler( &self, handler: &HttpMessageHandler, method: &str, uri: Uri, headers: std::collections::BTreeMap, content_type: Option, payload: Vec, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { report_buffer_progress(progress.as_ref(), payload.len()); let request = HttpRequest { method: method.to_owned(), uri, headers, content_type, body: payload, }; let response = tokio::select! { response = handler.send(request, cancellation_token.clone()) => response, () = cancellation_token.cancelled() => return Err(Error::Cancelled), }; cancellation_token.throw_if_cancellation_requested()?; self.finish_response(response, progress.as_ref(), true) } async fn send_reqwest( &self, client: &reqwest::Client, method: &str, uri: reqwest::Url, headers: std::collections::BTreeMap, content_type: Option, payload: Vec, cancellation_token: CancellationToken, progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { let method = reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| Error::Argument)?; let mut request = client.request(method, uri); for (name, value) in headers { request = request.header(name, value); } if let Some(content_type) = content_type { request = request.header(reqwest::header::CONTENT_TYPE, content_type); } request = request.header(reqwest::header::ACCEPT_ENCODING, "gzip, deflate"); if !payload.is_empty() { request = request.header(reqwest::header::CONTENT_LENGTH, payload.len()); let stream = upload_stream(payload, progress.clone()); request = request.body(reqwest::Body::wrap_stream(stream)); } let response = tokio::select! { response = request.send() => response.map_err(|_| Error::HttpRequest)?, () = cancellation_token.cancelled() => return Err(Error::Cancelled), }; cancellation_token.throw_if_cancellation_requested()?; let status_code = response.status().as_u16(); let headers = response .headers() .iter() .map(|(name, value)| { ( name.as_str().to_owned(), String::from_utf8_lossy(value.as_bytes()).into_owned(), ) }) .collect(); let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .map(|value| String::from_utf8_lossy(value.as_bytes()).into_owned()); let declared_length = response .headers() .get(reqwest::header::CONTENT_LENGTH) .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse::().ok()); if declared_length.is_some_and(|length| length > self.0.limits.max_response_bytes) { return Err(Error::HttpRequest); } let mut stream = response.bytes_stream(); let mut body = Vec::new(); while let Some(chunk) = tokio::select! { chunk = stream.next() => chunk, () = cancellation_token.cancelled() => return Err(Error::Cancelled), } { let chunk = chunk.map_err(|_| Error::HttpRequest)?; let new_length = body .len() .checked_add(chunk.len()) .ok_or(Error::HttpRequest)?; if new_length > self.0.limits.max_response_bytes { return Err(Error::HttpRequest); } body.extend_from_slice(&chunk); report_stream_progress(progress.as_ref(), declared_length, body.len()); } if body.is_empty() { report_stream_progress(progress.as_ref(), declared_length, 0); } cancellation_token.throw_if_cancellation_requested()?; self.finish_response( HttpResponse { status_code, headers, content_type, body, }, progress.as_ref(), false, ) } fn finish_response( &self, mut response: HttpResponse, progress: Option<&Arc>>, report_progress: bool, ) -> Result<(HttpResponse, Vec), Error> { if response.body.len() > self.0.limits.max_response_bytes { return Err(Error::HttpRequest); } if let Some(length) = header_value(&response.headers, "content-length") .and_then(|value| value.parse::().ok()) && length > self.0.limits.max_response_bytes { return Err(Error::HttpRequest); } let encoding = header_value(&response.headers, "content-encoding"); let encoded = std::mem::take(&mut response.body); let body = decode_content(encoded, encoding, self.0.limits.max_decompressed_bytes)?; if report_progress { report_download_progress( progress, &body, header_value(&response.headers, "content-length"), ); } response.body.clone_from(&body); Ok((response, body)) } } impl fmt::Debug for HttpCapsClient { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let backend = match &self.0.backend { HttpBackend::Handler(_) => "injected-handler", HttpBackend::Reqwest(_) => "reqwest", }; formatter .debug_struct("HttpCapsClient") .field("backend", &backend) .field("limits", &self.0.limits) .field("disposed", &self.0.disposed.load(Ordering::Acquire)) .finish() } } fn parse_http_uri(uri: &Uri) -> Result { let parsed = reqwest::Url::parse(&uri.0).map_err(|_| Error::Argument)?; if !matches!(parsed.scheme(), "http" | "https") || parsed.host().is_none() { return Err(Error::Argument); } Ok(parsed) } fn serialize_osd(format: OSDFormat, payload: OSD) -> Result<(String, Vec), Error> { match format { OSDFormat::Xml => Ok(( HttpCapsClient::LLSD_XML.to_owned(), OSDParser::serialize_llsd_xml_bytes(payload)?, )), OSDFormat::Binary => Ok(( HttpCapsClient::LLSD_BINARY.to_owned(), OSDParser::serialize_llsd_binary_with_osd(payload)?, )), OSDFormat::Json => Ok(( HttpCapsClient::LLSD_JSON.to_owned(), OSDParser::serialize_json_string(payload, None)?.into_bytes(), )), } } fn upload_stream( payload: Vec, progress: Option>>, ) -> impl futures_util::Stream, std::io::Error>> + Send + 'static { let payload = Arc::new(payload); futures_util::stream::unfold( (payload, 0_usize, progress), |(payload, offset, progress)| async move { if offset >= payload.len() { return None; } let end = offset.saturating_add(STREAM_CHUNK_SIZE).min(payload.len()); if let (Some(progress), Some(report)) = ( progress.as_ref(), HttpCapsClientProgressReport::known(payload.len(), end), ) { progress.report(report); } Some((Ok(payload[offset..end].to_vec()), (payload, end, progress))) }, ) } fn report_buffer_progress( progress: Option<&Arc>>, length: usize, ) { let Some(progress) = progress else { return; }; if length == 0 { if let Some(report) = HttpCapsClientProgressReport::known(0, 0) { progress.report(report); } return; } for transferred in (STREAM_CHUNK_SIZE..length) .step_by(STREAM_CHUNK_SIZE) .chain(std::iter::once(length)) { if let Some(report) = HttpCapsClientProgressReport::known(length, transferred) { progress.report(report); } } } fn report_download_progress( progress: Option<&Arc>>, body: &[u8], declared_length: Option<&str>, ) { let Some(progress) = progress else { return; }; let total = declared_length .and_then(|value| value.parse::().ok()) .filter(|total| *total == body.len()); if body.is_empty() { let report = total.and_then(|total| HttpCapsClientProgressReport::known(total, 0)); if let Some(report) = report.or_else(|| HttpCapsClientProgressReport::unknown(0)) { progress.report(report); } return; } for transferred in (STREAM_CHUNK_SIZE..body.len()) .step_by(STREAM_CHUNK_SIZE) .chain(std::iter::once(body.len())) { let report = total .and_then(|total| HttpCapsClientProgressReport::known(total, transferred)) .or_else(|| HttpCapsClientProgressReport::unknown(transferred)); if let Some(report) = report { progress.report(report); } } } fn report_stream_progress( progress: Option<&Arc>>, total: Option, transferred: usize, ) { let Some(progress) = progress else { return; }; let report = total .and_then(|total| HttpCapsClientProgressReport::known(total, transferred)) .or_else(|| HttpCapsClientProgressReport::unknown(transferred)); if let Some(report) = report { progress.report(report); } } fn header_value<'a>( headers: &'a std::collections::BTreeMap, name: &str, ) -> Option<&'a str> { headers .iter() .find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value.as_str())) } fn decode_content( mut body: Vec, encoding: Option<&str>, limit: usize, ) -> Result, Error> { let Some(encoding) = encoding else { return Ok(body); }; let encodings: Vec<_> = encoding .split(',') .map(str::trim) .filter(|encoding| !encoding.is_empty() && !encoding.eq_ignore_ascii_case("identity")) .collect(); for encoding in encodings.into_iter().rev() { body = if encoding.eq_ignore_ascii_case("gzip") || encoding.eq_ignore_ascii_case("x-gzip") { read_bounded(GzDecoder::new(body.as_slice()), limit)? } else if encoding.eq_ignore_ascii_case("deflate") { match read_bounded(ZlibDecoder::new(body.as_slice()), limit) { Ok(decoded) => decoded, Err(_) => read_bounded(DeflateDecoder::new(body.as_slice()), limit)?, } } else { return Err(Error::HttpRequest); }; } Ok(body) } fn read_bounded(mut reader: impl Read, limit: usize) -> Result, Error> { let take_limit = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1); let mut output = Vec::new(); reader .by_ref() .take(take_limit) .read_to_end(&mut output) .map_err(|_| Error::HttpRequest)?; if output.len() > limit { return Err(Error::HttpRequest); } Ok(output) }