1459 lines
46 KiB
Rust
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)
|
|
}
|