1use std::fmt;
12use std::pin::Pin;
13use std::sync::Arc;
14use std::task::{Context, Poll};
15use std::time::Duration;
16
17use async_trait::async_trait;
18use bytes::Bytes;
19use futures_core::Stream;
20use thiserror::Error;
21
22use crate::stream::{BoxStream, ResponseStream, StreamError};
23
24pub const MAX_BLOB_BYTES: usize = 10 * 1024 * 1024;
36
37pub const MAX_LARGE_BLOB_BYTES: u64 = 5 * 1024 * 1024 * 1024;
43
44pub const MAX_MULTIPART_PARTS: u16 = 10_000;
48
49pub const MIN_MULTIPART_PART_BYTES: u64 = 5 * 1024 * 1024;
55
56pub const MAX_LIST_LIMIT: usize = 1000;
60
61pub const MAX_PRESIGN_TTL: Duration = Duration::from_hours(7 * 24);
65
66pub const DEFAULT_PRESIGN_TTL: Duration = Duration::from_hours(1);
70
71fn check_presign_ttl(ttl: Duration) -> Result<(), BlobError> {
76 if ttl < Duration::from_secs(1) || ttl > MAX_PRESIGN_TTL {
77 return Err(BlobError::Operation(format!(
78 "presign ttl must be between 1 and {} seconds, got {}",
79 MAX_PRESIGN_TTL.as_secs(),
80 ttl.as_secs(),
81 )));
82 }
83 Ok(())
84}
85
86pub fn check_blob_size(bytes: &[u8]) -> Result<(), BlobError> {
96 if bytes.len() > MAX_BLOB_BYTES {
97 return Err(BlobError::TooLarge(format!(
98 "blob of {} bytes exceeds the {MAX_BLOB_BYTES}-byte bound",
99 bytes.len()
100 )));
101 }
102 Ok(())
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct BlobObject {
108 pub bytes: Vec<u8>,
109 pub content_type: String,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct BlobMeta {
117 pub key: String,
119 pub size: u64,
121 pub content_type: String,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct BlobPage {
130 pub objects: Vec<BlobMeta>,
132 pub cursor: Option<String>,
134}
135
136pub struct BlobStream {
141 pub content_type: String,
143 pub size: u64,
145 pub body: BoxStream<'static, Result<Bytes, StreamError>>,
147}
148
149impl BlobStream {
150 #[must_use]
155 pub fn into_response_stream(self) -> ResponseStream {
156 ResponseStream::new(self.body)
157 }
158}
159
160impl fmt::Debug for BlobStream {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 f.debug_struct("BlobStream")
165 .field("content_type", &self.content_type)
166 .field("size", &self.size)
167 .field("body", &"<stream>")
168 .finish()
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Hash)]
176pub struct UploadId(String);
177
178impl UploadId {
179 #[must_use]
181 pub fn new(id: impl Into<String>) -> Self {
182 Self(id.into())
183 }
184
185 #[must_use]
188 pub fn as_str(&self) -> &str {
189 &self.0
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct PartReceipt {
199 pub part_number: u16,
201 pub etag: String,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct PendingUpload {
210 pub key: String,
213 pub upload_id: UploadId,
215}
216
217pub fn check_part_number(n: u16) -> Result<(), BlobError> {
226 if n == 0 || n > MAX_MULTIPART_PARTS {
227 return Err(BlobError::Operation(format!(
228 "multipart part number must be between 1 and {MAX_MULTIPART_PARTS}, got {n}"
229 )));
230 }
231 Ok(())
232}
233
234#[must_use]
244pub fn limit_stream(
245 body: BoxStream<'static, Result<Bytes, StreamError>>,
246 max_bytes: u64,
247) -> BoxStream<'static, Result<Bytes, StreamError>> {
248 Box::pin(LimitStream {
249 inner: Some(body),
250 delivered: 0,
251 max: max_bytes,
252 })
253}
254
255#[must_use]
259pub fn blob_error_from_stream(error: StreamError) -> BlobError {
260 error.into()
261}
262
263impl From<StreamError> for BlobError {
264 fn from(error: StreamError) -> Self {
265 match error {
266 StreamError::TooLarge => {
267 Self::TooLarge("the streamed body exceeded its ceiling".to_owned())
268 }
269 StreamError::Transport(detail) => {
270 Self::Operation(format!("blob stream transport error: {detail}"))
271 }
272 }
273 }
274}
275
276struct LimitStream {
279 inner: Option<BoxStream<'static, Result<Bytes, StreamError>>>,
280 delivered: u64,
281 max: u64,
282}
283
284impl Stream for LimitStream {
285 type Item = Result<Bytes, StreamError>;
286
287 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
288 let this = self.get_mut();
289 let polled = match this.inner.as_mut() {
290 Some(stream) => stream.as_mut().poll_next(cx),
291 None => return Poll::Ready(None),
292 };
293 match polled {
294 Poll::Ready(Some(Ok(chunk))) => {
295 if this.delivered.saturating_add(chunk.len() as u64) > this.max {
296 this.inner = None;
298 Poll::Ready(Some(Err(StreamError::TooLarge)))
299 } else {
300 this.delivered += chunk.len() as u64;
301 Poll::Ready(Some(Ok(chunk)))
302 }
303 }
304 Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),
305 Poll::Ready(None) => {
306 this.inner = None;
307 Poll::Ready(None)
308 }
309 Poll::Pending => Poll::Pending,
310 }
311 }
312}
313
314#[derive(Debug, Clone, Error)]
316pub enum BlobError {
317 #[error("blob operation failed: {0}")]
319 Operation(String),
320 #[error("invalid blob key: {0}")]
322 BadKey(String),
323 #[error("blob rejected by size: {0}")]
325 TooLarge(String),
326 #[error("blob operation not supported: {0}")]
329 Unsupported(String),
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct PresignedPut {
337 pub url: String,
340 pub method: &'static str,
342 pub headers: Vec<(String, String)>,
345}
346
347#[async_trait]
350pub trait Blob: Send + Sync {
351 async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError>;
356 async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError>;
358 async fn delete(&self, key: &str) -> Result<(), BlobError>;
360 async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError>;
366 async fn signed_put_url(
375 &self,
376 key: &str,
377 content_type: &str,
378 content_length: Option<u64>,
379 ttl: Duration,
380 ) -> Result<PresignedPut, BlobError> {
381 let _ = (key, content_type, content_length, ttl);
382 Err(BlobError::Unsupported(
383 "this blob store has no presigned uploads".to_owned(),
384 ))
385 }
386
387 async fn put_stream(
402 &self,
403 key: &str,
404 body: BoxStream<'static, Result<Bytes, StreamError>>,
405 content_type: &str,
406 max_bytes: u64,
407 ) -> Result<u64, BlobError> {
408 let _ = (key, body, content_type, max_bytes);
409 Err(BlobError::Unsupported(
410 "this blob store has no streamed puts".to_owned(),
411 ))
412 }
413
414 async fn get_stream(&self, key: &str) -> Result<Option<BlobStream>, BlobError> {
421 let _ = key;
422 Err(BlobError::Unsupported(
423 "this blob store has no streamed gets".to_owned(),
424 ))
425 }
426
427 async fn head(&self, key: &str) -> Result<Option<BlobMeta>, BlobError> {
432 let _ = key;
433 Err(BlobError::Unsupported(
434 "this blob store has no head".to_owned(),
435 ))
436 }
437
438 async fn list(
446 &self,
447 prefix: &str,
448 cursor: Option<&str>,
449 limit: usize,
450 ) -> Result<BlobPage, BlobError> {
451 let _ = (prefix, cursor, limit);
452 Err(BlobError::Unsupported(
453 "this blob store has no listings".to_owned(),
454 ))
455 }
456
457 async fn create_multipart(&self, key: &str, content_type: &str) -> Result<UploadId, BlobError> {
466 let _ = (key, content_type);
467 Err(BlobError::Unsupported(
468 "this blob store has no multipart uploads".to_owned(),
469 ))
470 }
471
472 async fn upload_part(
482 &self,
483 key: &str,
484 upload_id: &UploadId,
485 part_number: u16,
486 body: BoxStream<'static, Result<Bytes, StreamError>>,
487 max_part_bytes: u64,
488 ) -> Result<PartReceipt, BlobError> {
489 let _ = (key, upload_id, part_number, body, max_part_bytes);
490 Err(BlobError::Unsupported(
491 "this blob store has no multipart uploads".to_owned(),
492 ))
493 }
494
495 async fn complete_multipart(
502 &self,
503 key: &str,
504 upload_id: &UploadId,
505 parts: &[PartReceipt],
506 ) -> Result<(), BlobError> {
507 let _ = (key, upload_id, parts);
508 Err(BlobError::Unsupported(
509 "this blob store has no multipart uploads".to_owned(),
510 ))
511 }
512
513 async fn abort_multipart(&self, key: &str, upload_id: &UploadId) -> Result<(), BlobError> {
518 let _ = (key, upload_id);
519 Err(BlobError::Unsupported(
520 "this blob store has no multipart uploads".to_owned(),
521 ))
522 }
523
524 async fn list_multipart_uploads(&self, prefix: &str) -> Result<Vec<PendingUpload>, BlobError> {
529 let _ = prefix;
530 Err(BlobError::Unsupported(
531 "this blob store has no multipart uploads".to_owned(),
532 ))
533 }
534}
535
536pub struct ScopedBlob {
540 inner: Arc<dyn Blob>,
541 prefix: String,
542 max_object_bytes: u64,
547}
548
549impl ScopedBlob {
550 #[must_use]
552 pub fn new(inner: Arc<dyn Blob>, module: &str) -> Self {
553 Self {
554 inner,
555 prefix: format!("{module}/"),
556 max_object_bytes: MAX_BLOB_BYTES as u64,
557 }
558 }
559
560 #[must_use]
568 pub fn with_max_object_bytes(mut self, max: u64) -> Self {
569 self.max_object_bytes = max.min(MAX_LARGE_BLOB_BYTES);
570 self
571 }
572
573 fn scope(&self, key: &str) -> Result<String, BlobError> {
576 if key.is_empty() {
577 return Err(BlobError::BadKey("a blob key cannot be empty".to_owned()));
578 }
579 if key.starts_with('/') {
580 return Err(BlobError::BadKey(format!("key `{key}` must be relative")));
581 }
582 if key
583 .split('/')
584 .any(|segment| segment == ".." || segment == ".")
585 {
586 return Err(BlobError::BadKey(format!(
587 "key `{key}` must not contain `.` or `..` segments"
588 )));
589 }
590 Ok(format!("{}{key}", self.prefix))
591 }
592
593 fn scope_prefix(&self, prefix: &str) -> Result<String, BlobError> {
597 if prefix.is_empty() {
598 return Ok(self.prefix.clone());
599 }
600 if prefix.starts_with('/') {
601 return Err(BlobError::BadKey(format!(
602 "prefix `{prefix}` must be relative"
603 )));
604 }
605 if prefix
606 .split('/')
607 .any(|segment| segment == ".." || segment == ".")
608 {
609 return Err(BlobError::BadKey(format!(
610 "prefix `{prefix}` must not contain `.` or `..` segments"
611 )));
612 }
613 Ok(format!("{}{prefix}", self.prefix))
614 }
615
616 fn unscope(&self, key: &str) -> Option<String> {
621 key.strip_prefix(&self.prefix).map(str::to_owned)
622 }
623}
624
625#[async_trait]
626impl Blob for ScopedBlob {
627 async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
628 check_blob_size(bytes)?;
629 self.inner.put(&self.scope(key)?, bytes, content_type).await
630 }
631 async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
632 self.inner.get(&self.scope(key)?).await
633 }
634 async fn delete(&self, key: &str) -> Result<(), BlobError> {
635 self.inner.delete(&self.scope(key)?).await
636 }
637 async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError> {
638 let key = self.scope(key)?;
639 check_presign_ttl(ttl)?;
640 self.inner.signed_url(&key, ttl).await
641 }
642 async fn signed_put_url(
643 &self,
644 key: &str,
645 content_type: &str,
646 content_length: Option<u64>,
647 ttl: Duration,
648 ) -> Result<PresignedPut, BlobError> {
649 let key = self.scope(key)?;
650 check_presign_ttl(ttl)?;
651 self.inner
652 .signed_put_url(&key, content_type, content_length, ttl)
653 .await
654 }
655 async fn put_stream(
656 &self,
657 key: &str,
658 body: BoxStream<'static, Result<Bytes, StreamError>>,
659 content_type: &str,
660 max_bytes: u64,
661 ) -> Result<u64, BlobError> {
662 let key = self.scope(key)?;
663 let effective = max_bytes.min(self.max_object_bytes);
664 self.inner
665 .put_stream(&key, body, content_type, effective)
666 .await
667 }
668 async fn get_stream(&self, key: &str) -> Result<Option<BlobStream>, BlobError> {
669 let key = self.scope(key)?;
670 self.inner.get_stream(&key).await
671 }
672 async fn head(&self, key: &str) -> Result<Option<BlobMeta>, BlobError> {
673 let scoped = self.scope(key)?;
674 Ok(self.inner.head(&scoped).await?.map(|mut meta| {
675 key.clone_into(&mut meta.key);
679 meta
680 }))
681 }
682 async fn list(
683 &self,
684 prefix: &str,
685 cursor: Option<&str>,
686 limit: usize,
687 ) -> Result<BlobPage, BlobError> {
688 let scoped = self.scope_prefix(prefix)?;
689 let limit = limit.clamp(1, MAX_LIST_LIMIT);
690 let page = self.inner.list(&scoped, cursor, limit).await?;
691 let objects = page
692 .objects
693 .into_iter()
694 .filter_map(|mut meta| {
695 meta.key = self.unscope(&meta.key)?;
696 Some(meta)
697 })
698 .collect();
699 Ok(BlobPage {
700 objects,
701 cursor: page.cursor,
702 })
703 }
704 async fn create_multipart(&self, key: &str, content_type: &str) -> Result<UploadId, BlobError> {
705 let key = self.scope(key)?;
706 self.inner.create_multipart(&key, content_type).await
707 }
708 async fn upload_part(
709 &self,
710 key: &str,
711 upload_id: &UploadId,
712 part_number: u16,
713 body: BoxStream<'static, Result<Bytes, StreamError>>,
714 max_part_bytes: u64,
715 ) -> Result<PartReceipt, BlobError> {
716 check_part_number(part_number)?;
717 let key = self.scope(key)?;
718 let effective = max_part_bytes.min(self.max_object_bytes);
719 self.inner
720 .upload_part(&key, upload_id, part_number, body, effective)
721 .await
722 }
723 async fn complete_multipart(
724 &self,
725 key: &str,
726 upload_id: &UploadId,
727 parts: &[PartReceipt],
728 ) -> Result<(), BlobError> {
729 if parts.is_empty() {
730 return Err(BlobError::Operation(
731 "a multipart upload needs at least one part".to_owned(),
732 ));
733 }
734 if parts.len() > usize::from(MAX_MULTIPART_PARTS) {
735 return Err(BlobError::Operation(format!(
736 "a multipart upload takes at most {MAX_MULTIPART_PARTS} parts"
737 )));
738 }
739 let key = self.scope(key)?;
740 self.inner
741 .complete_multipart(&key, upload_id, parts)
742 .await?;
743 match self.inner.head(&key).await? {
746 Some(meta) if meta.size > self.max_object_bytes => {
747 self.inner.delete(&key).await?;
748 Err(BlobError::TooLarge(format!(
749 "completed object of {} bytes exceeds the {}-byte bound",
750 meta.size, self.max_object_bytes
751 )))
752 }
753 Some(_) => Ok(()),
754 None => Err(BlobError::Operation(
757 "completed object not found".to_owned(),
758 )),
759 }
760 }
761 async fn abort_multipart(&self, key: &str, upload_id: &UploadId) -> Result<(), BlobError> {
762 let key = self.scope(key)?;
763 self.inner.abort_multipart(&key, upload_id).await
764 }
765 async fn list_multipart_uploads(&self, prefix: &str) -> Result<Vec<PendingUpload>, BlobError> {
766 let scoped = self.scope_prefix(prefix)?;
767 let uploads = self.inner.list_multipart_uploads(&scoped).await?;
768 Ok(uploads
769 .into_iter()
770 .filter_map(|mut upload| {
771 upload.key = self.unscope(&upload.key)?;
772 Some(upload)
773 })
774 .collect())
775 }
776}
777
778#[cfg(test)]
779mod tests {
780 #![allow(clippy::disallowed_types)]
784
785 use super::*;
786 use std::sync::Mutex;
787
788 #[derive(Default)]
790 struct MemBlob {
791 objects: Mutex<std::collections::HashMap<String, BlobObject>>,
792 presigned: Mutex<Vec<String>>,
795 }
796
797 #[async_trait]
798 impl Blob for MemBlob {
799 async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
800 self.objects.lock().unwrap().insert(
801 key.to_owned(),
802 BlobObject {
803 bytes: bytes.to_vec(),
804 content_type: content_type.to_owned(),
805 },
806 );
807 Ok(())
808 }
809 async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
810 Ok(self.objects.lock().unwrap().get(key).cloned())
811 }
812 async fn delete(&self, key: &str) -> Result<(), BlobError> {
813 self.objects.lock().unwrap().remove(key);
814 Ok(())
815 }
816 async fn signed_url(&self, key: &str, _ttl: Duration) -> Result<String, BlobError> {
817 self.presigned.lock().unwrap().push(key.to_owned());
818 Ok(format!("mem://{key}"))
819 }
820 async fn signed_put_url(
821 &self,
822 key: &str,
823 content_type: &str,
824 _content_length: Option<u64>,
825 _ttl: Duration,
826 ) -> Result<PresignedPut, BlobError> {
827 self.presigned.lock().unwrap().push(key.to_owned());
828 Ok(PresignedPut {
829 url: format!("mem://{key}"),
830 method: "PUT",
831 headers: vec![("content-type".to_owned(), content_type.to_owned())],
832 })
833 }
834 async fn put_stream(
835 &self,
836 key: &str,
837 body: BoxStream<'static, Result<Bytes, StreamError>>,
838 content_type: &str,
839 max_bytes: u64,
840 ) -> Result<u64, BlobError> {
841 let mut limited = limit_stream(body, max_bytes);
842 let mut bytes = Vec::new();
843 while let Some(chunk) = std::future::poll_fn(|cx| limited.as_mut().poll_next(cx)).await
844 {
845 bytes.extend_from_slice(&chunk.map_err(blob_error_from_stream)?);
846 }
847 let written = bytes.len() as u64;
848 self.objects.lock().unwrap().insert(
849 key.to_owned(),
850 BlobObject {
851 bytes,
852 content_type: content_type.to_owned(),
853 },
854 );
855 Ok(written)
856 }
857 async fn head(&self, key: &str) -> Result<Option<BlobMeta>, BlobError> {
858 Ok(self
859 .objects
860 .lock()
861 .unwrap()
862 .get(key)
863 .map(|object| BlobMeta {
864 key: key.to_owned(),
865 size: object.bytes.len() as u64,
866 content_type: object.content_type.clone(),
867 }))
868 }
869 async fn list(
870 &self,
871 prefix: &str,
872 cursor: Option<&str>,
873 limit: usize,
874 ) -> Result<BlobPage, BlobError> {
875 let objects = self.objects.lock().unwrap();
876 let mut keys: Vec<String> = objects
877 .keys()
878 .filter(|key| key.starts_with(prefix))
879 .cloned()
880 .collect();
881 keys.sort();
882 let start = cursor.map_or(0, |cursor| {
883 keys.partition_point(|key| key.as_str() <= cursor)
884 });
885 let take = (keys.len() - start).min(limit);
886 let selected = &keys[start..start + take];
887 let objects = selected
888 .iter()
889 .map(|key| {
890 let object = &objects[key];
891 BlobMeta {
892 key: key.clone(),
893 size: object.bytes.len() as u64,
894 content_type: object.content_type.clone(),
895 }
896 })
897 .collect();
898 let cursor = if start + take < keys.len() {
899 selected.last().cloned()
900 } else {
901 None
902 };
903 Ok(BlobPage { objects, cursor })
904 }
905 }
906
907 struct Chunks(std::vec::IntoIter<Result<Bytes, StreamError>>);
910
911 impl Stream for Chunks {
912 type Item = Result<Bytes, StreamError>;
913
914 fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
915 Poll::Ready(self.get_mut().0.next())
916 }
917 }
918
919 fn chunk_stream(chunks: Vec<Vec<u8>>) -> BoxStream<'static, Result<Bytes, StreamError>> {
921 Box::pin(Chunks(
922 chunks
923 .into_iter()
924 .map(|chunk| Ok(Bytes::from(chunk)))
925 .collect::<Vec<_>>()
926 .into_iter(),
927 ))
928 }
929
930 struct BareBlob;
933
934 #[async_trait]
935 impl Blob for BareBlob {
936 async fn put(
937 &self,
938 _key: &str,
939 _bytes: &[u8],
940 _content_type: &str,
941 ) -> Result<(), BlobError> {
942 Ok(())
943 }
944 async fn get(&self, _key: &str) -> Result<Option<BlobObject>, BlobError> {
945 Ok(None)
946 }
947 async fn delete(&self, _key: &str) -> Result<(), BlobError> {
948 Ok(())
949 }
950 async fn signed_url(&self, _key: &str, _ttl: Duration) -> Result<String, BlobError> {
951 Err(BlobError::Unsupported("memory store".to_owned()))
952 }
953 }
954
955 #[pollster::test]
956 async fn a_scoped_blob_prefixes_the_key() {
957 let mem = Arc::new(MemBlob::default());
958 let scoped = ScopedBlob::new(mem.clone(), "waitlist");
959 scoped.put("clip.mp3", b"x", "audio/mpeg").await.unwrap();
960 assert!(mem.get("waitlist/clip.mp3").await.unwrap().is_some());
962 assert!(mem.get("clip.mp3").await.unwrap().is_none());
963 assert!(scoped.get("clip.mp3").await.unwrap().is_some());
965 }
966
967 #[pollster::test]
968 async fn a_scoped_blob_refuses_an_escaping_key() {
969 let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
970 for bad in ["", "/etc/passwd", "../secrets/x", "a/../../b", "."] {
971 assert!(
972 matches!(scoped.get(bad).await.unwrap_err(), BlobError::BadKey(_)),
973 "key `{bad}` should be refused"
974 );
975 }
976 }
977
978 #[pollster::test]
979 async fn delete_is_idempotent() {
980 let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
981 scoped.delete("missing").await.expect("no-op delete is ok");
982 }
983
984 #[pollster::test]
985 async fn an_oversized_put_is_refused_before_the_store_is_reached() {
986 let mem = Arc::new(MemBlob::default());
987 let scoped = ScopedBlob::new(mem.clone(), "cms");
988 let oversized = vec![0_u8; MAX_BLOB_BYTES + 1];
989 let err = scoped
990 .put("big.bin", &oversized, "application/octet-stream")
991 .await
992 .expect_err("one byte past the bound must be refused");
993 assert!(matches!(err, BlobError::TooLarge(_)), "got {err}");
994 assert!(
995 mem.get("cms/big.bin").await.unwrap().is_none(),
996 "the store never saw the rejected key"
997 );
998 }
999
1000 #[pollster::test]
1001 async fn a_put_at_exactly_the_bound_is_accepted() {
1002 let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
1003 let exact = vec![0_u8; MAX_BLOB_BYTES];
1004 scoped
1005 .put("edge.bin", &exact, "application/octet-stream")
1006 .await
1007 .expect("the bound itself is allowed");
1008 }
1009
1010 #[pollster::test]
1011 async fn a_scoped_blob_prefixes_both_presign_keys() {
1012 let mem = Arc::new(MemBlob::default());
1013 let scoped = ScopedBlob::new(mem.clone(), "cms");
1014 let get = scoped
1015 .signed_url("clip.mp3", Duration::from_secs(60))
1016 .await
1017 .unwrap();
1018 assert_eq!(get, "mem://cms/clip.mp3");
1019 let put = scoped
1020 .signed_put_url("clip.mp3", "audio/mpeg", Some(3), Duration::from_secs(60))
1021 .await
1022 .unwrap();
1023 assert_eq!(put.url, "mem://cms/clip.mp3");
1024 assert_eq!(
1025 *mem.presigned.lock().unwrap(),
1026 vec!["cms/clip.mp3".to_owned(), "cms/clip.mp3".to_owned()],
1027 );
1028 }
1029
1030 #[pollster::test]
1031 async fn a_scoped_blob_refuses_an_escaping_presign_key_before_the_store() {
1032 let mem = Arc::new(MemBlob::default());
1033 let scoped = ScopedBlob::new(mem.clone(), "cms");
1034 for bad in ["", "/etc/passwd", "../secrets/x"] {
1035 assert!(
1036 matches!(
1037 scoped
1038 .signed_url(bad, Duration::from_secs(60))
1039 .await
1040 .unwrap_err(),
1041 BlobError::BadKey(_)
1042 ),
1043 "key `{bad}` should be refused"
1044 );
1045 assert!(
1046 matches!(
1047 scoped
1048 .signed_put_url(bad, "image/png", None, Duration::from_secs(60))
1049 .await
1050 .unwrap_err(),
1051 BlobError::BadKey(_)
1052 ),
1053 "key `{bad}` should be refused"
1054 );
1055 }
1056 assert!(
1057 mem.presigned.lock().unwrap().is_empty(),
1058 "the store never saw a key"
1059 );
1060 }
1061
1062 #[pollster::test]
1063 async fn a_scoped_blob_refuses_a_presign_ttl_outside_the_bound() {
1064 let mem = Arc::new(MemBlob::default());
1065 let scoped = ScopedBlob::new(mem.clone(), "cms");
1066 for bad in [
1067 Duration::from_millis(500),
1070 Duration::ZERO,
1071 MAX_PRESIGN_TTL + Duration::from_secs(1),
1072 ] {
1073 assert!(
1074 matches!(
1075 scoped.signed_url("clip.mp3", bad).await.unwrap_err(),
1076 BlobError::Operation(_)
1077 ),
1078 "ttl {bad:?} should be refused"
1079 );
1080 }
1081 scoped
1083 .signed_url("clip.mp3", MAX_PRESIGN_TTL)
1084 .await
1085 .unwrap();
1086 assert_eq!(mem.presigned.lock().unwrap().len(), 1);
1087 }
1088
1089 #[pollster::test]
1090 async fn the_default_signed_put_url_is_unsupported() {
1091 let scoped = ScopedBlob::new(Arc::new(BareBlob), "cms");
1092 assert!(matches!(
1093 scoped
1094 .signed_put_url("clip.mp3", "audio/mpeg", None, Duration::from_secs(60))
1095 .await
1096 .unwrap_err(),
1097 BlobError::Unsupported(_)
1098 ));
1099 }
1100
1101 #[test]
1102 fn the_ceiling_defaults_to_the_small_bound_and_clamps_to_the_large_one() {
1103 let default = ScopedBlob::new(Arc::new(BareBlob), "cms");
1104 assert_eq!(
1105 default.max_object_bytes, MAX_BLOB_BYTES as u64,
1106 "a view that says nothing keeps the buffered bound"
1107 );
1108 let raised = ScopedBlob::new(Arc::new(BareBlob), "cms").with_max_object_bytes(u64::MAX);
1109 assert_eq!(
1110 raised.max_object_bytes, MAX_LARGE_BLOB_BYTES,
1111 "a declaration cannot outrun the harness-wide ceiling"
1112 );
1113 let lowered = ScopedBlob::new(Arc::new(BareBlob), "cms").with_max_object_bytes(4096);
1114 assert_eq!(lowered.max_object_bytes, 4096, "lowering is honoured");
1115 }
1116
1117 #[test]
1118 fn check_part_number_bounds_the_one_based_range() {
1119 assert!(check_part_number(1).is_ok());
1120 assert!(check_part_number(MAX_MULTIPART_PARTS).is_ok());
1121 assert!(matches!(
1122 check_part_number(0).unwrap_err(),
1123 BlobError::Operation(_)
1124 ));
1125 assert!(matches!(
1126 check_part_number(MAX_MULTIPART_PARTS + 1).unwrap_err(),
1127 BlobError::Operation(_)
1128 ));
1129 }
1130
1131 #[pollster::test]
1132 async fn limit_stream_refuses_the_crossing_chunk_and_fuses() {
1133 let mut stream = limit_stream(
1134 chunk_stream(vec![b"12345".to_vec(), b"67890".to_vec(), b"x".to_vec()]),
1135 8,
1136 );
1137 let first = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
1138 assert_eq!(first.unwrap().unwrap(), Bytes::from_static(b"12345"));
1139 let second = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
1141 assert!(matches!(second.unwrap(), Err(StreamError::TooLarge)));
1142 let third = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
1143 assert!(third.is_none(), "fused after the refusal");
1144 }
1145
1146 #[pollster::test]
1147 async fn list_strips_the_prefix_and_hides_other_modules() {
1148 let mem = Arc::new(MemBlob::default());
1149 mem.put("cms/a.txt", b"a", "text/plain").await.unwrap();
1150 mem.put("cms/b.txt", b"b", "text/plain").await.unwrap();
1151 mem.put("cms2/c.txt", b"c", "text/plain").await.unwrap();
1152 let scoped = ScopedBlob::new(mem, "cms");
1153
1154 let page = scoped.list("", None, 10).await.unwrap();
1155 let keys: Vec<&str> = page.objects.iter().map(|meta| meta.key.as_str()).collect();
1156 assert_eq!(keys, ["a.txt", "b.txt"], "stripped, ordered, scoped");
1157 assert!(page.cursor.is_none());
1158
1159 let narrowed = scoped.list("a", None, 10).await.unwrap();
1160 assert_eq!(narrowed.objects.len(), 1, "the prefix narrows the listing");
1161 assert_eq!(narrowed.objects[0].key, "a.txt");
1162
1163 assert!(matches!(
1164 scoped.list("/abs", None, 10).await.unwrap_err(),
1165 BlobError::BadKey(_)
1166 ));
1167 assert!(matches!(
1168 scoped.list("../x", None, 10).await.unwrap_err(),
1169 BlobError::BadKey(_)
1170 ));
1171 }
1172
1173 #[pollster::test]
1174 async fn put_stream_uses_the_smaller_of_the_call_and_the_ceiling() {
1175 let mem = Arc::new(MemBlob::default());
1176 let scoped = ScopedBlob::new(mem.clone(), "cms").with_max_object_bytes(8);
1177 let err = scoped
1178 .put_stream(
1179 "clip.bin",
1180 chunk_stream(vec![vec![0_u8; 10]]),
1181 "application/octet-stream",
1182 1 << 30,
1183 )
1184 .await
1185 .unwrap_err();
1186 assert!(matches!(err, BlobError::TooLarge(_)), "got {err:?}");
1187 assert!(
1188 mem.get("cms/clip.bin").await.unwrap().is_none(),
1189 "the ceiling left no object behind"
1190 );
1191
1192 let written = scoped
1193 .put_stream(
1194 "clip.bin",
1195 chunk_stream(vec![vec![1_u8; 8]]),
1196 "application/octet-stream",
1197 1 << 30,
1198 )
1199 .await
1200 .unwrap();
1201 assert_eq!(written, 8, "the effective ceiling is the view's own");
1202 assert!(mem.get("cms/clip.bin").await.unwrap().is_some());
1203 }
1204}