Skip to main content

boatramp_server/
limits.rs

1//! Operational request limits: bound blob uploads in size, time,
2//! and concurrency so a single client can't exhaust disk, sockets, or memory —
3//! **without breaking streaming** (nothing is buffered to enforce a cap; the
4//! upload stream is wrapped and aborted the moment a bound is crossed).
5//!
6//! These are server-tier (operational) knobs, not per-site config: they protect
7//! the host, so they live on the listener, configured by `boatramp serve`.
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use boatramp_core::{ByteStream, StorageError};
13use futures::StreamExt;
14use tokio::sync::{OwnedSemaphorePermit, Semaphore};
15
16/// Server-tier upload limits. All `None` = unlimited (the default; preserves the
17/// unbounded streaming behavior for operators who front boatramp with their own
18/// limits).
19#[derive(Debug, Clone, Default)]
20pub struct ServerLimits {
21    /// Reject a blob upload larger than this many bytes (413 on a declared
22    /// `Content-Length`; the stream is also aborted if it exceeds the cap while
23    /// streaming, in case the length was absent or lied).
24    pub max_upload_bytes: Option<u64>,
25    /// Abort an upload whose body stalls (no bytes) for longer than this —
26    /// slowloris protection that doesn't penalize slow-but-steady transfers.
27    pub upload_idle_timeout: Option<Duration>,
28    /// Cap on simultaneous in-flight blob uploads; further uploads get 503 until
29    /// a slot frees. `None` = unbounded.
30    pub max_concurrent_uploads: Option<usize>,
31}
32
33impl ServerLimits {
34    /// Whether any limit is set (so callers can skip wrapping work entirely).
35    pub fn is_unlimited(&self) -> bool {
36        self.max_upload_bytes.is_none()
37            && self.upload_idle_timeout.is_none()
38            && self.max_concurrent_uploads.is_none()
39    }
40}
41
42/// Runtime guard built from [`ServerLimits`], shared across requests as an axum
43/// extension. Holds the concurrency semaphore (if any) and the per-upload caps.
44#[derive(Clone)]
45pub struct UploadGuard {
46    max_upload_bytes: Option<u64>,
47    upload_idle_timeout: Option<Duration>,
48    uploads: Option<Arc<Semaphore>>,
49}
50
51impl UploadGuard {
52    /// Build a guard from limits (an unbounded guard if `limits.is_unlimited()`).
53    pub fn new(limits: ServerLimits) -> Self {
54        Self {
55            max_upload_bytes: limits.max_upload_bytes,
56            upload_idle_timeout: limits.upload_idle_timeout,
57            uploads: limits
58                .max_concurrent_uploads
59                .map(|n| Arc::new(Semaphore::new(n.max(1)))),
60        }
61    }
62
63    /// Try to claim an upload slot. `Some` (held for the upload's duration) when
64    /// admitted — including the unlimited case; `None` when the concurrency cap
65    /// is currently saturated.
66    pub fn try_acquire(&self) -> Option<UploadPermit> {
67        match &self.uploads {
68            None => Some(UploadPermit(None)),
69            Some(sem) => sem
70                .clone()
71                .try_acquire_owned()
72                .ok()
73                .map(|permit| UploadPermit(Some(permit))),
74        }
75    }
76
77    /// Whether `content_length` already exceeds the size cap (cheap early 413).
78    pub fn content_length_rejected(&self, content_length: Option<u64>) -> bool {
79        matches!((self.max_upload_bytes, content_length), (Some(max), Some(len)) if len > max)
80    }
81
82    /// Wrap an upload body so it is aborted if it exceeds the size cap or stalls
83    /// past the idle timeout. A no-op (returns the stream unchanged) when neither
84    /// applies, so the unlimited path keeps zero overhead.
85    pub fn limit_body(&self, stream: ByteStream) -> ByteStream {
86        limited_stream(stream, self.max_upload_bytes, self.upload_idle_timeout)
87    }
88}
89
90/// An admitted upload slot; the underlying semaphore permit (if any) is released
91/// when this is dropped, i.e. when the upload finishes.
92pub struct UploadPermit(#[allow(dead_code)] Option<OwnedSemaphorePermit>);
93
94/// Wrap `inner` to enforce a running byte cap and/or an idle (between-chunk)
95/// timeout, erroring (and stopping) the instant either is crossed.
96fn limited_stream(inner: ByteStream, max: Option<u64>, idle: Option<Duration>) -> ByteStream {
97    if max.is_none() && idle.is_none() {
98        return inner;
99    }
100    futures::stream::unfold(
101        (inner, 0u64, false),
102        move |(mut inner, sent, done)| async move {
103            if done {
104                return None;
105            }
106            let next = match idle {
107                Some(timeout) => match tokio::time::timeout(timeout, inner.next()).await {
108                    Ok(item) => item,
109                    Err(_) => {
110                        return Some((
111                            Err(StorageError::backend("upload idle timeout")),
112                            (inner, sent, true),
113                        ))
114                    }
115                },
116                None => inner.next().await,
117            };
118            match next {
119                None => None,
120                Some(Err(err)) => Some((Err(err), (inner, sent, true))),
121                Some(Ok(chunk)) => {
122                    let sent = sent + chunk.len() as u64;
123                    if let Some(max) = max {
124                        if sent > max {
125                            return Some((
126                                Err(StorageError::backend(format!(
127                                    "upload exceeds the {max}-byte limit"
128                                ))),
129                                (inner, sent, true),
130                            ));
131                        }
132                    }
133                    Some((Ok(chunk), (inner, sent, false)))
134                }
135            }
136        },
137    )
138    .boxed()
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use bytes::Bytes;
145
146    fn stream_of(chunks: Vec<&'static [u8]>) -> ByteStream {
147        futures::stream::iter(chunks.into_iter().map(|c| Ok(Bytes::from_static(c)))).boxed()
148    }
149
150    async fn drain(mut s: ByteStream) -> Result<u64, StorageError> {
151        let mut total = 0u64;
152        while let Some(item) = s.next().await {
153            total += item?.len() as u64;
154        }
155        Ok(total)
156    }
157
158    #[tokio::test]
159    async fn under_cap_passes_through() {
160        let s = limited_stream(stream_of(vec![b"abc", b"de"]), Some(10), None);
161        assert_eq!(drain(s).await.unwrap(), 5);
162    }
163
164    #[tokio::test]
165    async fn over_cap_aborts() {
166        let s = limited_stream(stream_of(vec![b"abc", b"defgh", b"more"]), Some(6), None);
167        let err = drain(s).await.unwrap_err();
168        assert!(err.to_string().contains("6-byte limit"), "{err}");
169    }
170
171    #[test]
172    fn content_length_early_reject() {
173        let guard = UploadGuard::new(ServerLimits {
174            max_upload_bytes: Some(100),
175            ..Default::default()
176        });
177        assert!(guard.content_length_rejected(Some(101)));
178        assert!(!guard.content_length_rejected(Some(100)));
179        assert!(!guard.content_length_rejected(None));
180    }
181
182    #[test]
183    fn concurrency_cap_admits_then_saturates() {
184        let guard = UploadGuard::new(ServerLimits {
185            max_concurrent_uploads: Some(1),
186            ..Default::default()
187        });
188        let permit = guard.try_acquire().expect("first admitted");
189        assert!(guard.try_acquire().is_none(), "second rejected while held");
190        drop(permit);
191        assert!(guard.try_acquire().is_some(), "slot freed after drop");
192    }
193
194    #[test]
195    fn unlimited_always_admits() {
196        let guard = UploadGuard::new(ServerLimits::default());
197        assert!(guard.try_acquire().is_some());
198        assert!(guard.try_acquire().is_some());
199    }
200}