use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use thiserror::Error;
pub const MAX_BLOB_BYTES: usize = 10 * 1024 * 1024;
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, 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(),
))
}
}
pub struct ScopedBlob {
inner: Arc<dyn Blob>,
prefix: String,
}
impl ScopedBlob {
#[must_use]
pub fn new(inner: Arc<dyn Blob>, module: &str) -> Self {
Self {
inner,
prefix: format!("{module}/"),
}
}
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))
}
}
#[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
}
}
#[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())],
})
}
}
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(_)
));
}
}