use std::sync::Arc;
use std::time::Duration;
use boatramp_core::{ByteStream, StorageError};
use futures::StreamExt;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
#[derive(Debug, Clone, Default)]
pub struct ServerLimits {
pub max_upload_bytes: Option<u64>,
pub upload_idle_timeout: Option<Duration>,
pub max_concurrent_uploads: Option<usize>,
}
impl ServerLimits {
pub fn is_unlimited(&self) -> bool {
self.max_upload_bytes.is_none()
&& self.upload_idle_timeout.is_none()
&& self.max_concurrent_uploads.is_none()
}
}
#[derive(Clone)]
pub struct UploadGuard {
max_upload_bytes: Option<u64>,
upload_idle_timeout: Option<Duration>,
uploads: Option<Arc<Semaphore>>,
}
impl UploadGuard {
pub fn new(limits: ServerLimits) -> Self {
Self {
max_upload_bytes: limits.max_upload_bytes,
upload_idle_timeout: limits.upload_idle_timeout,
uploads: limits
.max_concurrent_uploads
.map(|n| Arc::new(Semaphore::new(n.max(1)))),
}
}
pub fn try_acquire(&self) -> Option<UploadPermit> {
match &self.uploads {
None => Some(UploadPermit(None)),
Some(sem) => sem
.clone()
.try_acquire_owned()
.ok()
.map(|permit| UploadPermit(Some(permit))),
}
}
pub fn content_length_rejected(&self, content_length: Option<u64>) -> bool {
matches!((self.max_upload_bytes, content_length), (Some(max), Some(len)) if len > max)
}
pub fn limit_body(&self, stream: ByteStream) -> ByteStream {
limited_stream(stream, self.max_upload_bytes, self.upload_idle_timeout)
}
}
pub struct UploadPermit(#[allow(dead_code)] Option<OwnedSemaphorePermit>);
fn limited_stream(inner: ByteStream, max: Option<u64>, idle: Option<Duration>) -> ByteStream {
if max.is_none() && idle.is_none() {
return inner;
}
futures::stream::unfold(
(inner, 0u64, false),
move |(mut inner, sent, done)| async move {
if done {
return None;
}
let next = match idle {
Some(timeout) => match tokio::time::timeout(timeout, inner.next()).await {
Ok(item) => item,
Err(_) => {
return Some((
Err(StorageError::backend("upload idle timeout")),
(inner, sent, true),
))
}
},
None => inner.next().await,
};
match next {
None => None,
Some(Err(err)) => Some((Err(err), (inner, sent, true))),
Some(Ok(chunk)) => {
let sent = sent + chunk.len() as u64;
if let Some(max) = max {
if sent > max {
return Some((
Err(StorageError::backend(format!(
"upload exceeds the {max}-byte limit"
))),
(inner, sent, true),
));
}
}
Some((Ok(chunk), (inner, sent, false)))
}
}
},
)
.boxed()
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
fn stream_of(chunks: Vec<&'static [u8]>) -> ByteStream {
futures::stream::iter(chunks.into_iter().map(|c| Ok(Bytes::from_static(c)))).boxed()
}
async fn drain(mut s: ByteStream) -> Result<u64, StorageError> {
let mut total = 0u64;
while let Some(item) = s.next().await {
total += item?.len() as u64;
}
Ok(total)
}
#[tokio::test]
async fn under_cap_passes_through() {
let s = limited_stream(stream_of(vec![b"abc", b"de"]), Some(10), None);
assert_eq!(drain(s).await.unwrap(), 5);
}
#[tokio::test]
async fn over_cap_aborts() {
let s = limited_stream(stream_of(vec![b"abc", b"defgh", b"more"]), Some(6), None);
let err = drain(s).await.unwrap_err();
assert!(err.to_string().contains("6-byte limit"), "{err}");
}
#[test]
fn content_length_early_reject() {
let guard = UploadGuard::new(ServerLimits {
max_upload_bytes: Some(100),
..Default::default()
});
assert!(guard.content_length_rejected(Some(101)));
assert!(!guard.content_length_rejected(Some(100)));
assert!(!guard.content_length_rejected(None));
}
#[test]
fn concurrency_cap_admits_then_saturates() {
let guard = UploadGuard::new(ServerLimits {
max_concurrent_uploads: Some(1),
..Default::default()
});
let permit = guard.try_acquire().expect("first admitted");
assert!(guard.try_acquire().is_none(), "second rejected while held");
drop(permit);
assert!(guard.try_acquire().is_some(), "slot freed after drop");
}
#[test]
fn unlimited_always_admits() {
let guard = UploadGuard::new(ServerLimits::default());
assert!(guard.try_acquire().is_some());
assert!(guard.try_acquire().is_some());
}
}