use std::fmt;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use async_trait::async_trait;
use bytes::Bytes;
use futures_core::Stream;
use thiserror::Error;
use crate::stream::{BoxStream, ResponseStream, StreamError};
pub const MAX_BLOB_BYTES: usize = 10 * 1024 * 1024;
pub const MAX_LARGE_BLOB_BYTES: u64 = 5 * 1024 * 1024 * 1024;
pub const MAX_MULTIPART_PARTS: u16 = 10_000;
pub const MIN_MULTIPART_PART_BYTES: u64 = 5 * 1024 * 1024;
pub const MAX_LIST_LIMIT: usize = 1000;
pub const MAX_PRESIGN_TTL: Duration = Duration::from_hours(7 * 24);
pub const DEFAULT_PRESIGN_TTL: Duration = Duration::from_hours(1);
fn check_presign_ttl(ttl: Duration) -> Result<(), BlobError> {
if ttl < Duration::from_secs(1) || ttl > MAX_PRESIGN_TTL {
return Err(BlobError::Operation(format!(
"presign ttl must be between 1 and {} seconds, got {}",
MAX_PRESIGN_TTL.as_secs(),
ttl.as_secs(),
)));
}
Ok(())
}
pub fn check_blob_size(bytes: &[u8]) -> Result<(), BlobError> {
if bytes.len() > MAX_BLOB_BYTES {
return Err(BlobError::TooLarge(format!(
"blob of {} bytes exceeds the {MAX_BLOB_BYTES}-byte bound",
bytes.len()
)));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobObject {
pub bytes: Vec<u8>,
pub content_type: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobMeta {
pub key: String,
pub size: u64,
pub content_type: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobPage {
pub objects: Vec<BlobMeta>,
pub cursor: Option<String>,
}
pub struct BlobStream {
pub content_type: String,
pub size: u64,
pub body: BoxStream<'static, Result<Bytes, StreamError>>,
}
impl BlobStream {
#[must_use]
pub fn into_response_stream(self) -> ResponseStream {
ResponseStream::new(self.body)
}
}
impl fmt::Debug for BlobStream {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BlobStream")
.field("content_type", &self.content_type)
.field("size", &self.size)
.field("body", &"<stream>")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UploadId(String);
impl UploadId {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PartReceipt {
pub part_number: u16,
pub etag: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingUpload {
pub key: String,
pub upload_id: UploadId,
}
pub fn check_part_number(n: u16) -> Result<(), BlobError> {
if n == 0 || n > MAX_MULTIPART_PARTS {
return Err(BlobError::Operation(format!(
"multipart part number must be between 1 and {MAX_MULTIPART_PARTS}, got {n}"
)));
}
Ok(())
}
#[must_use]
pub fn limit_stream(
body: BoxStream<'static, Result<Bytes, StreamError>>,
max_bytes: u64,
) -> BoxStream<'static, Result<Bytes, StreamError>> {
Box::pin(LimitStream {
inner: Some(body),
delivered: 0,
max: max_bytes,
})
}
#[must_use]
pub fn blob_error_from_stream(error: StreamError) -> BlobError {
error.into()
}
impl From<StreamError> for BlobError {
fn from(error: StreamError) -> Self {
match error {
StreamError::TooLarge => {
Self::TooLarge("the streamed body exceeded its ceiling".to_owned())
}
StreamError::Transport(detail) => {
Self::Operation(format!("blob stream transport error: {detail}"))
}
}
}
}
struct LimitStream {
inner: Option<BoxStream<'static, Result<Bytes, StreamError>>>,
delivered: u64,
max: u64,
}
impl Stream for LimitStream {
type Item = Result<Bytes, StreamError>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
let polled = match this.inner.as_mut() {
Some(stream) => stream.as_mut().poll_next(cx),
None => return Poll::Ready(None),
};
match polled {
Poll::Ready(Some(Ok(chunk))) => {
if this.delivered.saturating_add(chunk.len() as u64) > this.max {
this.inner = None;
Poll::Ready(Some(Err(StreamError::TooLarge)))
} else {
this.delivered += chunk.len() as u64;
Poll::Ready(Some(Ok(chunk)))
}
}
Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),
Poll::Ready(None) => {
this.inner = None;
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
}
}
#[derive(Debug, Clone, Error)]
pub enum BlobError {
#[error("blob operation failed: {0}")]
Operation(String),
#[error("invalid blob key: {0}")]
BadKey(String),
#[error("blob rejected by size: {0}")]
TooLarge(String),
#[error("blob operation not supported: {0}")]
Unsupported(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PresignedPut {
pub url: String,
pub method: &'static str,
pub headers: Vec<(String, String)>,
}
#[async_trait]
pub trait Blob: Send + Sync {
async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError>;
async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError>;
async fn delete(&self, key: &str) -> Result<(), BlobError>;
async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError>;
async fn signed_put_url(
&self,
key: &str,
content_type: &str,
content_length: Option<u64>,
ttl: Duration,
) -> Result<PresignedPut, BlobError> {
let _ = (key, content_type, content_length, ttl);
Err(BlobError::Unsupported(
"this blob store has no presigned uploads".to_owned(),
))
}
async fn put_stream(
&self,
key: &str,
body: BoxStream<'static, Result<Bytes, StreamError>>,
content_type: &str,
max_bytes: u64,
) -> Result<u64, BlobError> {
let _ = (key, body, content_type, max_bytes);
Err(BlobError::Unsupported(
"this blob store has no streamed puts".to_owned(),
))
}
async fn get_stream(&self, key: &str) -> Result<Option<BlobStream>, BlobError> {
let _ = key;
Err(BlobError::Unsupported(
"this blob store has no streamed gets".to_owned(),
))
}
async fn head(&self, key: &str) -> Result<Option<BlobMeta>, BlobError> {
let _ = key;
Err(BlobError::Unsupported(
"this blob store has no head".to_owned(),
))
}
async fn list(
&self,
prefix: &str,
cursor: Option<&str>,
limit: usize,
) -> Result<BlobPage, BlobError> {
let _ = (prefix, cursor, limit);
Err(BlobError::Unsupported(
"this blob store has no listings".to_owned(),
))
}
async fn create_multipart(&self, key: &str, content_type: &str) -> Result<UploadId, BlobError> {
let _ = (key, content_type);
Err(BlobError::Unsupported(
"this blob store has no multipart uploads".to_owned(),
))
}
async fn upload_part(
&self,
key: &str,
upload_id: &UploadId,
part_number: u16,
body: BoxStream<'static, Result<Bytes, StreamError>>,
max_part_bytes: u64,
) -> Result<PartReceipt, BlobError> {
let _ = (key, upload_id, part_number, body, max_part_bytes);
Err(BlobError::Unsupported(
"this blob store has no multipart uploads".to_owned(),
))
}
async fn complete_multipart(
&self,
key: &str,
upload_id: &UploadId,
parts: &[PartReceipt],
) -> Result<(), BlobError> {
let _ = (key, upload_id, parts);
Err(BlobError::Unsupported(
"this blob store has no multipart uploads".to_owned(),
))
}
async fn abort_multipart(&self, key: &str, upload_id: &UploadId) -> Result<(), BlobError> {
let _ = (key, upload_id);
Err(BlobError::Unsupported(
"this blob store has no multipart uploads".to_owned(),
))
}
async fn list_multipart_uploads(&self, prefix: &str) -> Result<Vec<PendingUpload>, BlobError> {
let _ = prefix;
Err(BlobError::Unsupported(
"this blob store has no multipart uploads".to_owned(),
))
}
}
pub struct ScopedBlob {
inner: Arc<dyn Blob>,
prefix: String,
max_object_bytes: u64,
}
impl ScopedBlob {
#[must_use]
pub fn new(inner: Arc<dyn Blob>, module: &str) -> Self {
Self {
inner,
prefix: format!("{module}/"),
max_object_bytes: MAX_BLOB_BYTES as u64,
}
}
#[must_use]
pub fn with_max_object_bytes(mut self, max: u64) -> Self {
self.max_object_bytes = max.min(MAX_LARGE_BLOB_BYTES);
self
}
fn scope(&self, key: &str) -> Result<String, BlobError> {
if key.is_empty() {
return Err(BlobError::BadKey("a blob key cannot be empty".to_owned()));
}
if key.starts_with('/') {
return Err(BlobError::BadKey(format!("key `{key}` must be relative")));
}
if key
.split('/')
.any(|segment| segment == ".." || segment == ".")
{
return Err(BlobError::BadKey(format!(
"key `{key}` must not contain `.` or `..` segments"
)));
}
Ok(format!("{}{key}", self.prefix))
}
fn scope_prefix(&self, prefix: &str) -> Result<String, BlobError> {
if prefix.is_empty() {
return Ok(self.prefix.clone());
}
if prefix.starts_with('/') {
return Err(BlobError::BadKey(format!(
"prefix `{prefix}` must be relative"
)));
}
if prefix
.split('/')
.any(|segment| segment == ".." || segment == ".")
{
return Err(BlobError::BadKey(format!(
"prefix `{prefix}` must not contain `.` or `..` segments"
)));
}
Ok(format!("{}{prefix}", self.prefix))
}
fn unscope(&self, key: &str) -> Option<String> {
key.strip_prefix(&self.prefix).map(str::to_owned)
}
}
#[async_trait]
impl Blob for ScopedBlob {
async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
check_blob_size(bytes)?;
self.inner.put(&self.scope(key)?, bytes, content_type).await
}
async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
self.inner.get(&self.scope(key)?).await
}
async fn delete(&self, key: &str) -> Result<(), BlobError> {
self.inner.delete(&self.scope(key)?).await
}
async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError> {
let key = self.scope(key)?;
check_presign_ttl(ttl)?;
self.inner.signed_url(&key, ttl).await
}
async fn signed_put_url(
&self,
key: &str,
content_type: &str,
content_length: Option<u64>,
ttl: Duration,
) -> Result<PresignedPut, BlobError> {
let key = self.scope(key)?;
check_presign_ttl(ttl)?;
self.inner
.signed_put_url(&key, content_type, content_length, ttl)
.await
}
async fn put_stream(
&self,
key: &str,
body: BoxStream<'static, Result<Bytes, StreamError>>,
content_type: &str,
max_bytes: u64,
) -> Result<u64, BlobError> {
let key = self.scope(key)?;
let effective = max_bytes.min(self.max_object_bytes);
self.inner
.put_stream(&key, body, content_type, effective)
.await
}
async fn get_stream(&self, key: &str) -> Result<Option<BlobStream>, BlobError> {
let key = self.scope(key)?;
self.inner.get_stream(&key).await
}
async fn head(&self, key: &str) -> Result<Option<BlobMeta>, BlobError> {
let scoped = self.scope(key)?;
Ok(self.inner.head(&scoped).await?.map(|mut meta| {
key.clone_into(&mut meta.key);
meta
}))
}
async fn list(
&self,
prefix: &str,
cursor: Option<&str>,
limit: usize,
) -> Result<BlobPage, BlobError> {
let scoped = self.scope_prefix(prefix)?;
let limit = limit.clamp(1, MAX_LIST_LIMIT);
let page = self.inner.list(&scoped, cursor, limit).await?;
let objects = page
.objects
.into_iter()
.filter_map(|mut meta| {
meta.key = self.unscope(&meta.key)?;
Some(meta)
})
.collect();
Ok(BlobPage {
objects,
cursor: page.cursor,
})
}
async fn create_multipart(&self, key: &str, content_type: &str) -> Result<UploadId, BlobError> {
let key = self.scope(key)?;
self.inner.create_multipart(&key, content_type).await
}
async fn upload_part(
&self,
key: &str,
upload_id: &UploadId,
part_number: u16,
body: BoxStream<'static, Result<Bytes, StreamError>>,
max_part_bytes: u64,
) -> Result<PartReceipt, BlobError> {
check_part_number(part_number)?;
let key = self.scope(key)?;
let effective = max_part_bytes.min(self.max_object_bytes);
self.inner
.upload_part(&key, upload_id, part_number, body, effective)
.await
}
async fn complete_multipart(
&self,
key: &str,
upload_id: &UploadId,
parts: &[PartReceipt],
) -> Result<(), BlobError> {
if parts.is_empty() {
return Err(BlobError::Operation(
"a multipart upload needs at least one part".to_owned(),
));
}
if parts.len() > usize::from(MAX_MULTIPART_PARTS) {
return Err(BlobError::Operation(format!(
"a multipart upload takes at most {MAX_MULTIPART_PARTS} parts"
)));
}
let key = self.scope(key)?;
self.inner
.complete_multipart(&key, upload_id, parts)
.await?;
match self.inner.head(&key).await? {
Some(meta) if meta.size > self.max_object_bytes => {
self.inner.delete(&key).await?;
Err(BlobError::TooLarge(format!(
"completed object of {} bytes exceeds the {}-byte bound",
meta.size, self.max_object_bytes
)))
}
Some(_) => Ok(()),
None => Err(BlobError::Operation(
"completed object not found".to_owned(),
)),
}
}
async fn abort_multipart(&self, key: &str, upload_id: &UploadId) -> Result<(), BlobError> {
let key = self.scope(key)?;
self.inner.abort_multipart(&key, upload_id).await
}
async fn list_multipart_uploads(&self, prefix: &str) -> Result<Vec<PendingUpload>, BlobError> {
let scoped = self.scope_prefix(prefix)?;
let uploads = self.inner.list_multipart_uploads(&scoped).await?;
Ok(uploads
.into_iter()
.filter_map(|mut upload| {
upload.key = self.unscope(&upload.key)?;
Some(upload)
})
.collect())
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::disallowed_types)]
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct MemBlob {
objects: Mutex<std::collections::HashMap<String, BlobObject>>,
presigned: Mutex<Vec<String>>,
}
#[async_trait]
impl Blob for MemBlob {
async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
self.objects.lock().unwrap().insert(
key.to_owned(),
BlobObject {
bytes: bytes.to_vec(),
content_type: content_type.to_owned(),
},
);
Ok(())
}
async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
Ok(self.objects.lock().unwrap().get(key).cloned())
}
async fn delete(&self, key: &str) -> Result<(), BlobError> {
self.objects.lock().unwrap().remove(key);
Ok(())
}
async fn signed_url(&self, key: &str, _ttl: Duration) -> Result<String, BlobError> {
self.presigned.lock().unwrap().push(key.to_owned());
Ok(format!("mem://{key}"))
}
async fn signed_put_url(
&self,
key: &str,
content_type: &str,
_content_length: Option<u64>,
_ttl: Duration,
) -> Result<PresignedPut, BlobError> {
self.presigned.lock().unwrap().push(key.to_owned());
Ok(PresignedPut {
url: format!("mem://{key}"),
method: "PUT",
headers: vec![("content-type".to_owned(), content_type.to_owned())],
})
}
async fn put_stream(
&self,
key: &str,
body: BoxStream<'static, Result<Bytes, StreamError>>,
content_type: &str,
max_bytes: u64,
) -> Result<u64, BlobError> {
let mut limited = limit_stream(body, max_bytes);
let mut bytes = Vec::new();
while let Some(chunk) = std::future::poll_fn(|cx| limited.as_mut().poll_next(cx)).await
{
bytes.extend_from_slice(&chunk.map_err(blob_error_from_stream)?);
}
let written = bytes.len() as u64;
self.objects.lock().unwrap().insert(
key.to_owned(),
BlobObject {
bytes,
content_type: content_type.to_owned(),
},
);
Ok(written)
}
async fn head(&self, key: &str) -> Result<Option<BlobMeta>, BlobError> {
Ok(self
.objects
.lock()
.unwrap()
.get(key)
.map(|object| BlobMeta {
key: key.to_owned(),
size: object.bytes.len() as u64,
content_type: object.content_type.clone(),
}))
}
async fn list(
&self,
prefix: &str,
cursor: Option<&str>,
limit: usize,
) -> Result<BlobPage, BlobError> {
let objects = self.objects.lock().unwrap();
let mut keys: Vec<String> = objects
.keys()
.filter(|key| key.starts_with(prefix))
.cloned()
.collect();
keys.sort();
let start = cursor.map_or(0, |cursor| {
keys.partition_point(|key| key.as_str() <= cursor)
});
let take = (keys.len() - start).min(limit);
let selected = &keys[start..start + take];
let objects = selected
.iter()
.map(|key| {
let object = &objects[key];
BlobMeta {
key: key.clone(),
size: object.bytes.len() as u64,
content_type: object.content_type.clone(),
}
})
.collect();
let cursor = if start + take < keys.len() {
selected.last().cloned()
} else {
None
};
Ok(BlobPage { objects, cursor })
}
}
struct Chunks(std::vec::IntoIter<Result<Bytes, StreamError>>);
impl Stream for Chunks {
type Item = Result<Bytes, StreamError>;
fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Poll::Ready(self.get_mut().0.next())
}
}
fn chunk_stream(chunks: Vec<Vec<u8>>) -> BoxStream<'static, Result<Bytes, StreamError>> {
Box::pin(Chunks(
chunks
.into_iter()
.map(|chunk| Ok(Bytes::from(chunk)))
.collect::<Vec<_>>()
.into_iter(),
))
}
struct BareBlob;
#[async_trait]
impl Blob for BareBlob {
async fn put(
&self,
_key: &str,
_bytes: &[u8],
_content_type: &str,
) -> Result<(), BlobError> {
Ok(())
}
async fn get(&self, _key: &str) -> Result<Option<BlobObject>, BlobError> {
Ok(None)
}
async fn delete(&self, _key: &str) -> Result<(), BlobError> {
Ok(())
}
async fn signed_url(&self, _key: &str, _ttl: Duration) -> Result<String, BlobError> {
Err(BlobError::Unsupported("memory store".to_owned()))
}
}
#[pollster::test]
async fn a_scoped_blob_prefixes_the_key() {
let mem = Arc::new(MemBlob::default());
let scoped = ScopedBlob::new(mem.clone(), "waitlist");
scoped.put("clip.mp3", b"x", "audio/mpeg").await.unwrap();
assert!(mem.get("waitlist/clip.mp3").await.unwrap().is_some());
assert!(mem.get("clip.mp3").await.unwrap().is_none());
assert!(scoped.get("clip.mp3").await.unwrap().is_some());
}
#[pollster::test]
async fn a_scoped_blob_refuses_an_escaping_key() {
let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
for bad in ["", "/etc/passwd", "../secrets/x", "a/../../b", "."] {
assert!(
matches!(scoped.get(bad).await.unwrap_err(), BlobError::BadKey(_)),
"key `{bad}` should be refused"
);
}
}
#[pollster::test]
async fn delete_is_idempotent() {
let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
scoped.delete("missing").await.expect("no-op delete is ok");
}
#[pollster::test]
async fn an_oversized_put_is_refused_before_the_store_is_reached() {
let mem = Arc::new(MemBlob::default());
let scoped = ScopedBlob::new(mem.clone(), "cms");
let oversized = vec![0_u8; MAX_BLOB_BYTES + 1];
let err = scoped
.put("big.bin", &oversized, "application/octet-stream")
.await
.expect_err("one byte past the bound must be refused");
assert!(matches!(err, BlobError::TooLarge(_)), "got {err}");
assert!(
mem.get("cms/big.bin").await.unwrap().is_none(),
"the store never saw the rejected key"
);
}
#[pollster::test]
async fn a_put_at_exactly_the_bound_is_accepted() {
let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
let exact = vec![0_u8; MAX_BLOB_BYTES];
scoped
.put("edge.bin", &exact, "application/octet-stream")
.await
.expect("the bound itself is allowed");
}
#[pollster::test]
async fn a_scoped_blob_prefixes_both_presign_keys() {
let mem = Arc::new(MemBlob::default());
let scoped = ScopedBlob::new(mem.clone(), "cms");
let get = scoped
.signed_url("clip.mp3", Duration::from_secs(60))
.await
.unwrap();
assert_eq!(get, "mem://cms/clip.mp3");
let put = scoped
.signed_put_url("clip.mp3", "audio/mpeg", Some(3), Duration::from_secs(60))
.await
.unwrap();
assert_eq!(put.url, "mem://cms/clip.mp3");
assert_eq!(
*mem.presigned.lock().unwrap(),
vec!["cms/clip.mp3".to_owned(), "cms/clip.mp3".to_owned()],
);
}
#[pollster::test]
async fn a_scoped_blob_refuses_an_escaping_presign_key_before_the_store() {
let mem = Arc::new(MemBlob::default());
let scoped = ScopedBlob::new(mem.clone(), "cms");
for bad in ["", "/etc/passwd", "../secrets/x"] {
assert!(
matches!(
scoped
.signed_url(bad, Duration::from_secs(60))
.await
.unwrap_err(),
BlobError::BadKey(_)
),
"key `{bad}` should be refused"
);
assert!(
matches!(
scoped
.signed_put_url(bad, "image/png", None, Duration::from_secs(60))
.await
.unwrap_err(),
BlobError::BadKey(_)
),
"key `{bad}` should be refused"
);
}
assert!(
mem.presigned.lock().unwrap().is_empty(),
"the store never saw a key"
);
}
#[pollster::test]
async fn a_scoped_blob_refuses_a_presign_ttl_outside_the_bound() {
let mem = Arc::new(MemBlob::default());
let scoped = ScopedBlob::new(mem.clone(), "cms");
for bad in [
Duration::from_millis(500),
Duration::ZERO,
MAX_PRESIGN_TTL + Duration::from_secs(1),
] {
assert!(
matches!(
scoped.signed_url("clip.mp3", bad).await.unwrap_err(),
BlobError::Operation(_)
),
"ttl {bad:?} should be refused"
);
}
scoped
.signed_url("clip.mp3", MAX_PRESIGN_TTL)
.await
.unwrap();
assert_eq!(mem.presigned.lock().unwrap().len(), 1);
}
#[pollster::test]
async fn the_default_signed_put_url_is_unsupported() {
let scoped = ScopedBlob::new(Arc::new(BareBlob), "cms");
assert!(matches!(
scoped
.signed_put_url("clip.mp3", "audio/mpeg", None, Duration::from_secs(60))
.await
.unwrap_err(),
BlobError::Unsupported(_)
));
}
#[test]
fn the_ceiling_defaults_to_the_small_bound_and_clamps_to_the_large_one() {
let default = ScopedBlob::new(Arc::new(BareBlob), "cms");
assert_eq!(
default.max_object_bytes, MAX_BLOB_BYTES as u64,
"a view that says nothing keeps the buffered bound"
);
let raised = ScopedBlob::new(Arc::new(BareBlob), "cms").with_max_object_bytes(u64::MAX);
assert_eq!(
raised.max_object_bytes, MAX_LARGE_BLOB_BYTES,
"a declaration cannot outrun the harness-wide ceiling"
);
let lowered = ScopedBlob::new(Arc::new(BareBlob), "cms").with_max_object_bytes(4096);
assert_eq!(lowered.max_object_bytes, 4096, "lowering is honoured");
}
#[test]
fn check_part_number_bounds_the_one_based_range() {
assert!(check_part_number(1).is_ok());
assert!(check_part_number(MAX_MULTIPART_PARTS).is_ok());
assert!(matches!(
check_part_number(0).unwrap_err(),
BlobError::Operation(_)
));
assert!(matches!(
check_part_number(MAX_MULTIPART_PARTS + 1).unwrap_err(),
BlobError::Operation(_)
));
}
#[pollster::test]
async fn limit_stream_refuses_the_crossing_chunk_and_fuses() {
let mut stream = limit_stream(
chunk_stream(vec![b"12345".to_vec(), b"67890".to_vec(), b"x".to_vec()]),
8,
);
let first = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
assert_eq!(first.unwrap().unwrap(), Bytes::from_static(b"12345"));
let second = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
assert!(matches!(second.unwrap(), Err(StreamError::TooLarge)));
let third = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
assert!(third.is_none(), "fused after the refusal");
}
#[pollster::test]
async fn list_strips_the_prefix_and_hides_other_modules() {
let mem = Arc::new(MemBlob::default());
mem.put("cms/a.txt", b"a", "text/plain").await.unwrap();
mem.put("cms/b.txt", b"b", "text/plain").await.unwrap();
mem.put("cms2/c.txt", b"c", "text/plain").await.unwrap();
let scoped = ScopedBlob::new(mem, "cms");
let page = scoped.list("", None, 10).await.unwrap();
let keys: Vec<&str> = page.objects.iter().map(|meta| meta.key.as_str()).collect();
assert_eq!(keys, ["a.txt", "b.txt"], "stripped, ordered, scoped");
assert!(page.cursor.is_none());
let narrowed = scoped.list("a", None, 10).await.unwrap();
assert_eq!(narrowed.objects.len(), 1, "the prefix narrows the listing");
assert_eq!(narrowed.objects[0].key, "a.txt");
assert!(matches!(
scoped.list("/abs", None, 10).await.unwrap_err(),
BlobError::BadKey(_)
));
assert!(matches!(
scoped.list("../x", None, 10).await.unwrap_err(),
BlobError::BadKey(_)
));
}
#[pollster::test]
async fn put_stream_uses_the_smaller_of_the_call_and_the_ceiling() {
let mem = Arc::new(MemBlob::default());
let scoped = ScopedBlob::new(mem.clone(), "cms").with_max_object_bytes(8);
let err = scoped
.put_stream(
"clip.bin",
chunk_stream(vec![vec![0_u8; 10]]),
"application/octet-stream",
1 << 30,
)
.await
.unwrap_err();
assert!(matches!(err, BlobError::TooLarge(_)), "got {err:?}");
assert!(
mem.get("cms/clip.bin").await.unwrap().is_none(),
"the ceiling left no object behind"
);
let written = scoped
.put_stream(
"clip.bin",
chunk_stream(vec![vec![1_u8; 8]]),
"application/octet-stream",
1 << 30,
)
.await
.unwrap();
assert_eq!(written, 8, "the effective ceiling is the view's own");
assert!(mem.get("cms/clip.bin").await.unwrap().is_some());
}
}