1use bytes::Bytes;
18use futures::TryStreamExt;
19
20use crate::driver::{ByteStream, StorageDriver, StorageError};
21
22#[derive(Debug, thiserror::Error)]
24pub enum IngestError {
25 #[error("the file is larger than the {limit} byte limit")]
26 TooLarge { limit: u64 },
27 #[error("the file is empty")]
28 Empty,
29 #[error("storage error: {0}")]
30 Storage(#[from] StorageError),
31 #[error("read error: {0}")]
32 Read(#[from] std::io::Error),
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Ingested {
38 pub hash: String,
41 pub size: u64,
42 pub content_type: String,
46 pub path: String,
48}
49
50pub fn blob_path(hash: &str) -> String {
55 format!("blobs/{}/{}/{}", &hash[0..2], &hash[2..4], hash)
56}
57
58fn sniff(head: &[u8]) -> String {
63 if crate::sanitize::is_svg(head) {
65 return "image/svg+xml".to_string();
66 }
67 infer::get(head)
68 .map(|t| t.mime_type().to_string())
69 .unwrap_or_else(|| "application/octet-stream".to_string())
70}
71
72pub async fn ingest(
78 disk: &dyn StorageDriver,
79 data: ByteStream,
80 limit: u64,
81) -> Result<Ingested, IngestError> {
82 let mut hasher = blake3::Hasher::new();
83 let mut size: u64 = 0;
84 let mut head: Vec<u8> = Vec::new();
85 let mut chunks: Vec<Bytes> = Vec::new();
86
87 let mut data = data;
88 while let Some(chunk) = data.try_next().await? {
89 size += chunk.len() as u64;
90 if size > limit {
91 return Err(IngestError::TooLarge { limit });
92 }
93 hasher.update(&chunk);
94 if head.len() < SNIFF_BYTES {
97 let want = SNIFF_BYTES - head.len();
98 head.extend_from_slice(&chunk[..want.min(chunk.len())]);
99 }
100 chunks.push(chunk);
101 }
102
103 if size == 0 {
104 return Err(IngestError::Empty);
105 }
106
107 let hash = hasher.finalize().to_hex().to_string();
108 let path = blob_path(&hash);
109
110 if disk.metadata(&path).await?.is_none() {
113 let stored: ByteStream = Box::pin(futures::stream::iter(
114 chunks.into_iter().map(Ok::<Bytes, std::io::Error>),
115 ));
116 disk.put(&path, stored).await?;
117 }
118
119 Ok(Ingested {
120 hash,
121 size,
122 content_type: sniff(&head),
123 path,
124 })
125}
126
127const SNIFF_BYTES: usize = 512;
129
130pub fn stream_of(bytes: impl Into<Bytes>) -> ByteStream {
132 let bytes = bytes.into();
133 Box::pin(futures::stream::iter([Ok::<Bytes, std::io::Error>(bytes)]))
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::{BlobMeta, LocalDisk};
140
141 fn disk() -> (LocalDisk, tempfile::TempDir) {
142 let dir = tempfile::tempdir().unwrap();
143 (LocalDisk::new(dir.path()), dir)
144 }
145
146 fn chunked(total: usize, chunk: usize) -> ByteStream {
149 let chunks: Vec<_> = (0..total.div_ceil(chunk))
150 .map(|i| {
151 let len = chunk.min(total - i * chunk);
152 Ok::<Bytes, std::io::Error>(Bytes::from(vec![b'x'; len]))
153 })
154 .collect();
155 Box::pin(futures::stream::iter(chunks))
156 }
157
158 #[tokio::test]
159 async fn a_file_is_named_by_its_own_contents() {
160 let (disk, _dir) = disk();
161 let out = ingest(&disk, stream_of("hello"), 1024).await.unwrap();
162
163 assert_eq!(out.size, 5);
165 assert_eq!(out.hash.len(), 64);
166 assert_eq!(out.path, blob_path(&out.hash));
167 assert!(out.path.starts_with(&format!("blobs/{}/", &out.hash[0..2])));
168 assert_eq!(
169 disk.metadata(&out.path).await.unwrap(),
170 Some(BlobMeta { size: 5 })
171 );
172 }
173
174 #[tokio::test]
177 async fn identical_bytes_are_stored_once() {
178 let (disk, _dir) = disk();
179 let first = ingest(&disk, stream_of("same"), 1024).await.unwrap();
180 let second = ingest(&disk, stream_of("same"), 1024).await.unwrap();
181 assert_eq!(first, second);
182 }
183
184 #[tokio::test]
187 async fn an_oversized_file_is_refused_and_leaves_nothing_behind() {
188 let (disk, dir) = disk();
189 let err = ingest(&disk, chunked(5000, 64), 1024).await.unwrap_err();
190 assert!(
191 matches!(err, IngestError::TooLarge { limit: 1024 }),
192 "{err:?}"
193 );
194
195 let blobs = dir.path().join("blobs");
197 assert!(!blobs.exists(), "a refused upload left files behind");
198 }
199
200 #[tokio::test]
201 async fn an_empty_file_is_not_a_file() {
202 let (disk, _dir) = disk();
203 assert!(matches!(
204 ingest(&disk, stream_of(""), 1024).await.unwrap_err(),
205 IngestError::Empty
206 ));
207 }
208
209 #[tokio::test]
212 async fn the_content_type_is_sniffed_from_the_bytes() {
213 let (disk, _dir) = disk();
214
215 let png = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
216 let out = ingest(&disk, stream_of(png.to_vec()), 1024).await.unwrap();
217 assert_eq!(out.content_type, "image/png");
218
219 let out = ingest(&disk, stream_of("just text"), 1024).await.unwrap();
221 assert_eq!(out.content_type, "application/octet-stream");
222 }
223
224 #[tokio::test]
227 async fn a_signature_split_across_chunks_is_still_read() {
228 let (disk, _dir) = disk();
229 let chunks = vec![
230 Ok::<Bytes, std::io::Error>(Bytes::from_static(&[0x89, b'P'])),
231 Ok(Bytes::from_static(&[b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A])),
232 Ok(Bytes::from(vec![0u8; 64])),
233 ];
234 let out = ingest(&disk, Box::pin(futures::stream::iter(chunks)), 1024)
235 .await
236 .unwrap();
237 assert_eq!(out.content_type, "image/png");
238 }
239
240 #[tokio::test]
241 async fn a_blob_reads_back_byte_for_byte() {
242 use futures::TryStreamExt;
243 let (disk, _dir) = disk();
244 let out = ingest(&disk, stream_of("round trip"), 1024).await.unwrap();
245
246 let chunks: Vec<Bytes> = disk
247 .get(&out.path)
248 .await
249 .unwrap()
250 .try_collect()
251 .await
252 .unwrap();
253 let read: Vec<u8> = chunks.concat();
254 assert_eq!(read, b"round trip");
255 }
256
257 #[tokio::test]
260 async fn deleting_a_missing_blob_is_not_an_error() {
261 let (disk, _dir) = disk();
262 assert!(disk.delete(&blob_path(&"a".repeat(64))).await.is_ok());
263 }
264
265 #[tokio::test]
266 async fn a_private_disk_has_no_public_url() {
267 let (disk, _dir) = disk();
268 assert_eq!(disk.public_url("blobs/ab/cd/x"), None);
269
270 let dir = tempfile::tempdir().unwrap();
271 let public = LocalDisk::new(dir.path()).public_at("/storage/media/");
272 assert_eq!(
273 public.public_url("blobs/ab/cd/x").as_deref(),
274 Some("/storage/media/blobs/ab/cd/x")
275 );
276 }
277}