Files
MetaCrate/crates/libremetaverse/src/caps_http.rs
Chili Palmer d5c318d280
All checks were successful
Native code generation / deterministic (push) Successful in 13m18s
Imaging and meshing gate / native (push) Successful in 4m12s
Native Rust workspace compile / compile (push) Successful in 4m12s
Implement native AIS inventory reconciliation (#63)
2026-08-10 06:41:29 +00:00

1459 lines
46 KiB
Rust

//! 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<Option<T>>.
#![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<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)
}
/// 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<Option<i64>>,
bytes_transferred: i64,
percent: Option<Option<f64>>,
}
impl HttpCapsClientProgressReport {
pub fn new(
total_bytes: Option<Option<i64>>,
bytes_transferred: i64,
percent: Option<Option<f64>>,
) -> Result<Self, Error> {
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<Option<f64>> {
self.percent
}
#[must_use]
pub const fn total_bytes(&self) -> Option<Option<i64>> {
self.total_bytes
}
#[allow(clippy::cast_precision_loss)] // C# computes the same rounded percentage as double.
fn known(total: usize, transferred: usize) -> Option<Self> {
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<Self> {
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<Self, Error> {
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<u64>,
}
struct CategoryBucket {
options: CapsRateLimiterOptions,
next_ticket: AtomicU64,
state: Mutex<BucketState>,
}
impl CategoryBucket {
fn new(options: CapsRateLimiterOptions, now: SystemTime) -> Result<Self, Error> {
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<CapsCategory, Arc<CategoryBucket>>,
uri_categories: RwLock<HashMap<String, CapsCategory>>,
clock: TimeProvider,
shutdown: CancellationTokenSource,
disposed: AtomicBool,
}
/// Per-capability-category token buckets with a bounded oldest-first queue.
#[derive(Clone)]
pub struct CapsRateLimiter(Arc<CapsRateLimiterInner>);
impl CapsRateLimiter {
pub fn new_with_constructor() -> Result<Self, Error> {
Self::new_with_clock_and_overrides(TimeProvider::system(), None)
}
pub fn new_with_i_read_only_dictionary(
overrides: Option<HashMap<CapsCategory, CapsRateLimiterOptions>>,
) -> Result<Self, Error> {
Self::new_with_clock_and_overrides(TimeProvider::system(), overrides)
}
pub fn new_with_clock_and_overrides(
clock: TimeProvider,
overrides: Option<HashMap<CapsCategory, CapsRateLimiterOptions>>,
) -> Result<Self, Error> {
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<CancellationToken>,
) -> Result<RateLimitLease, Error> {
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<CapsCategory, CapsRateLimiterOptions> {
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<Option<CapsRateLimiter>>,
request_slots: Arc<tokio::sync::Semaphore>,
shutdown: CancellationTokenSource,
disposed: AtomicBool,
}
/// Native capability HTTP client with injectable fake and reqwest backends.
#[derive(Clone)]
pub struct HttpCapsClient(Arc<HttpCapsClientInner>);
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, Error> {
Self::with_handler_and_limits(handler, CapsHttpLimits::default())
}
pub fn with_handler_and_limits(
handler: HttpMessageHandler,
limits: CapsHttpLimits,
) -> Result<Self, Error> {
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<CapsRateLimiter>,
limits: CapsHttpLimits,
max_concurrent_requests: usize,
) -> Result<Self, Error> {
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<CapsRateLimiter>,
limits: CapsHttpLimits,
) -> Result<Self, Error> {
Self::with_reqwest_client_and_concurrency(client, rate_limiter, limits, 32)
}
fn with_reqwest_client_and_concurrency(
client: reqwest::Client,
rate_limiter: Option<CapsRateLimiter>,
limits: CapsHttpLimits,
max_connections: usize,
) -> Result<Self, Error> {
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<Self, Error> {
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<CapsRateLimiter>) {
*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<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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<String, String>,
content_type: Option<String>,
payload: Vec<u8>,
cancellation_token: CancellationToken,
) -> Result<(HttpResponse, Vec<u8>), 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<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> 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<u8>,
cancellation_token: CancellationToken,
progress: Option<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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<u8>,
cancellation_token: CancellationToken,
progress: Option<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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<u8>,
cancellation_token: CancellationToken,
progress: Option<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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<u8>,
cancellation_token: CancellationToken,
progress: Option<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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<u8>,
cancellation_token: CancellationToken,
progress: Option<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> 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<u8>,
cancellation_token: CancellationToken,
progress: Option<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> 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<u8>,
cancellation_token: CancellationToken,
progress: Option<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> 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<u8>,
cancellation_token: CancellationToken,
progress: Option<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> 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<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> 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<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> 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<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> 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<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> 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<String>,
payload: Vec<u8>,
cancellation_token: CancellationToken,
progress: Option<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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<String, String>,
content_type: Option<String>,
payload: Vec<u8>,
cancellation_token: CancellationToken,
progress: Option<Box<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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<Arc<dyn IProgress<HttpCapsClientProgressReport>>> =
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<String, String>,
content_type: Option<String>,
payload: Vec<u8>,
cancellation_token: CancellationToken,
progress: Option<Arc<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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<String, String>,
content_type: Option<String>,
payload: Vec<u8>,
cancellation_token: CancellationToken,
progress: Option<Arc<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> Result<(HttpResponse, Vec<u8>), 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::<usize>().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<dyn IProgress<HttpCapsClientProgressReport>>>,
report_progress: bool,
) -> Result<(HttpResponse, Vec<u8>), 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::<usize>().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<reqwest::Url, Error> {
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<u8>), 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<u8>,
progress: Option<Arc<dyn IProgress<HttpCapsClientProgressReport>>>,
) -> impl futures_util::Stream<Item = Result<Vec<u8>, 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<dyn IProgress<HttpCapsClientProgressReport>>>,
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<dyn IProgress<HttpCapsClientProgressReport>>>,
body: &[u8],
declared_length: Option<&str>,
) {
let Some(progress) = progress else {
return;
};
let total = declared_length
.and_then(|value| value.parse::<usize>().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<dyn IProgress<HttpCapsClientProgressReport>>>,
total: Option<usize>,
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<String, String>,
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<u8>,
encoding: Option<&str>,
limit: usize,
) -> Result<Vec<u8>, 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<Vec<u8>, 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)
}