use bytes::Bytes;
use futures::TryStreamExt;
use crate::driver::{ByteStream, StorageDriver, StorageError};
#[derive(Debug, thiserror::Error)]
pub enum IngestError {
#[error("the file is larger than the {limit} byte limit")]
TooLarge { limit: u64 },
#[error("the file is empty")]
Empty,
#[error("storage error: {0}")]
Storage(#[from] StorageError),
#[error("read error: {0}")]
Read(#[from] std::io::Error),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ingested {
pub hash: String,
pub size: u64,
pub content_type: String,
pub path: String,
}
pub fn blob_path(hash: &str) -> String {
format!("blobs/{}/{}/{}", &hash[0..2], &hash[2..4], hash)
}
fn sniff(head: &[u8]) -> String {
if crate::sanitize::is_svg(head) {
return "image/svg+xml".to_string();
}
infer::get(head)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string())
}
pub async fn ingest(
disk: &dyn StorageDriver,
data: ByteStream,
limit: u64,
) -> Result<Ingested, IngestError> {
let mut hasher = blake3::Hasher::new();
let mut size: u64 = 0;
let mut head: Vec<u8> = Vec::new();
let mut chunks: Vec<Bytes> = Vec::new();
let mut data = data;
while let Some(chunk) = data.try_next().await? {
size += chunk.len() as u64;
if size > limit {
return Err(IngestError::TooLarge { limit });
}
hasher.update(&chunk);
if head.len() < SNIFF_BYTES {
let want = SNIFF_BYTES - head.len();
head.extend_from_slice(&chunk[..want.min(chunk.len())]);
}
chunks.push(chunk);
}
if size == 0 {
return Err(IngestError::Empty);
}
let hash = hasher.finalize().to_hex().to_string();
let path = blob_path(&hash);
if disk.metadata(&path).await?.is_none() {
let stored: ByteStream = Box::pin(futures::stream::iter(
chunks.into_iter().map(Ok::<Bytes, std::io::Error>),
));
disk.put(&path, stored).await?;
}
Ok(Ingested {
hash,
size,
content_type: sniff(&head),
path,
})
}
const SNIFF_BYTES: usize = 512;
pub fn stream_of(bytes: impl Into<Bytes>) -> ByteStream {
let bytes = bytes.into();
Box::pin(futures::stream::iter([Ok::<Bytes, std::io::Error>(bytes)]))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BlobMeta, LocalDisk};
fn disk() -> (LocalDisk, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
(LocalDisk::new(dir.path()), dir)
}
fn chunked(total: usize, chunk: usize) -> ByteStream {
let chunks: Vec<_> = (0..total.div_ceil(chunk))
.map(|i| {
let len = chunk.min(total - i * chunk);
Ok::<Bytes, std::io::Error>(Bytes::from(vec![b'x'; len]))
})
.collect();
Box::pin(futures::stream::iter(chunks))
}
#[tokio::test]
async fn a_file_is_named_by_its_own_contents() {
let (disk, _dir) = disk();
let out = ingest(&disk, stream_of("hello"), 1024).await.unwrap();
assert_eq!(out.size, 5);
assert_eq!(out.hash.len(), 64);
assert_eq!(out.path, blob_path(&out.hash));
assert!(out.path.starts_with(&format!("blobs/{}/", &out.hash[0..2])));
assert_eq!(
disk.metadata(&out.path).await.unwrap(),
Some(BlobMeta { size: 5 })
);
}
#[tokio::test]
async fn identical_bytes_are_stored_once() {
let (disk, _dir) = disk();
let first = ingest(&disk, stream_of("same"), 1024).await.unwrap();
let second = ingest(&disk, stream_of("same"), 1024).await.unwrap();
assert_eq!(first, second);
}
#[tokio::test]
async fn an_oversized_file_is_refused_and_leaves_nothing_behind() {
let (disk, dir) = disk();
let err = ingest(&disk, chunked(5000, 64), 1024).await.unwrap_err();
assert!(
matches!(err, IngestError::TooLarge { limit: 1024 }),
"{err:?}"
);
let blobs = dir.path().join("blobs");
assert!(!blobs.exists(), "a refused upload left files behind");
}
#[tokio::test]
async fn an_empty_file_is_not_a_file() {
let (disk, _dir) = disk();
assert!(matches!(
ingest(&disk, stream_of(""), 1024).await.unwrap_err(),
IngestError::Empty
));
}
#[tokio::test]
async fn the_content_type_is_sniffed_from_the_bytes() {
let (disk, _dir) = disk();
let png = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
let out = ingest(&disk, stream_of(png.to_vec()), 1024).await.unwrap();
assert_eq!(out.content_type, "image/png");
let out = ingest(&disk, stream_of("just text"), 1024).await.unwrap();
assert_eq!(out.content_type, "application/octet-stream");
}
#[tokio::test]
async fn a_signature_split_across_chunks_is_still_read() {
let (disk, _dir) = disk();
let chunks = vec![
Ok::<Bytes, std::io::Error>(Bytes::from_static(&[0x89, b'P'])),
Ok(Bytes::from_static(&[b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A])),
Ok(Bytes::from(vec![0u8; 64])),
];
let out = ingest(&disk, Box::pin(futures::stream::iter(chunks)), 1024)
.await
.unwrap();
assert_eq!(out.content_type, "image/png");
}
#[tokio::test]
async fn a_blob_reads_back_byte_for_byte() {
use futures::TryStreamExt;
let (disk, _dir) = disk();
let out = ingest(&disk, stream_of("round trip"), 1024).await.unwrap();
let chunks: Vec<Bytes> = disk
.get(&out.path)
.await
.unwrap()
.try_collect()
.await
.unwrap();
let read: Vec<u8> = chunks.concat();
assert_eq!(read, b"round trip");
}
#[tokio::test]
async fn deleting_a_missing_blob_is_not_an_error() {
let (disk, _dir) = disk();
assert!(disk.delete(&blob_path(&"a".repeat(64))).await.is_ok());
}
#[tokio::test]
async fn a_private_disk_has_no_public_url() {
let (disk, _dir) = disk();
assert_eq!(disk.public_url("blobs/ab/cd/x"), None);
let dir = tempfile::tempdir().unwrap();
let public = LocalDisk::new(dir.path()).public_at("/storage/media/");
assert_eq!(
public.public_url("blobs/ab/cd/x").as_deref(),
Some("/storage/media/blobs/ab/cd/x")
);
}
}