Skip to main content

laterite_media/
ingest.rs

1//! Taking bytes in: hash while streaming, then store.
2//!
3//! Every source converges here. A browser upload, a server-side fetch of a file
4//! a user picked from their own cloud storage, a URL import: each is only a
5//! different way to produce a byte stream, and none of them is a separate
6//! pipeline. That is why [`ingest`] takes a stream rather than a request.
7//!
8//! Two rules hold whatever the source:
9//!
10//! - **Nothing is buffered whole.** The hash is computed incrementally as the
11//!   bytes pass, so a file never has to fit in memory and no second read pass is
12//!   needed to hash what was just written.
13//! - **The size cap is enforced during the stream**, not after. Checking a
14//!   declared length, or checking afterwards, means having already accepted the
15//!   bytes; a sender who lies about a length is the case the cap exists for.
16
17use bytes::Bytes;
18use futures::TryStreamExt;
19
20use crate::driver::{ByteStream, StorageDriver, StorageError};
21
22/// What went wrong taking a file in.
23#[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/// A blob that has been stored: what it is, and where it went.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Ingested {
38    /// BLAKE3 of the bytes, hex. The blob's path is derived from it, so identical
39    /// bytes on one disk are stored once.
40    pub hash: String,
41    pub size: u64,
42    /// Sniffed from the leading bytes, never taken from the sender. A client's
43    /// declared content type is a claim about a file it chose; believing it is
44    /// how an HTML page gets served as an image.
45    pub content_type: String,
46    /// Where the blob sits on its disk.
47    pub path: String,
48}
49
50/// The storage path for a hash: `blobs/ab/cd/<hash>`.
51///
52/// Two levels of hex fan-out, because a single directory holding every blob is a
53/// problem on most filesystems long before a media library is large.
54pub fn blob_path(hash: &str) -> String {
55    format!("blobs/{}/{}/{}", &hash[0..2], &hash[2..4], hash)
56}
57
58/// The type sniffed from a file's leading bytes, or a neutral default.
59///
60/// Never `application/octet-stream` for something recognised, and never the
61/// sender's claim: the bytes are the only honest source.
62fn sniff(head: &[u8]) -> String {
63    // An SVG is known by its root element, before a prolog reads as plain XML.
64    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
72/// Hashes and stores a byte stream, returning what it turned out to be.
73///
74/// `limit` is a hard ceiling enforced mid-stream: the read stops and the partial
75/// blob is discarded the moment it is passed, so an oversized or dishonest sender
76/// costs only what has been read so far.
77pub 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        // The first bytes decide the content type. `infer` needs only a few
95        // hundred; keeping more would be holding the file in memory again.
96        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    // Identical bytes on this disk are already stored, and the path is the hash,
111    // so there is nothing to write and nothing that could differ.
112    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
127/// Enough leading bytes for every signature `infer` knows.
128const SNIFF_BYTES: usize = 512;
129
130/// Lets a caller hand `ingest` a plain buffer without building a stream.
131pub 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    /// A stream that yields many chunks, so the cap is exercised mid-flight
147    /// rather than against one buffer that was already in memory.
148    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        // BLAKE3 of "hello", so the name is reproducible anywhere.
164        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    /// Identical bytes are one blob. An editor re-uploading the same logo is the
175    /// ordinary case, not an edge one.
176    #[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    /// The cap has to stop the read, not judge it afterwards: by then the bytes
185    /// have already been accepted, which is the cost the cap exists to avoid.
186    #[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        // Nothing was written: no blob, and no partial file under the root.
196        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    /// The content type comes from the bytes. A sender's claim is a claim about a
210    /// file it chose, and believing it is how a page gets served as an image.
211    #[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        // Unrecognised bytes get a neutral type rather than a guess.
220        let out = ingest(&disk, stream_of("just text"), 1024).await.unwrap();
221        assert_eq!(out.content_type, "application/octet-stream");
222    }
223
224    /// Sniffing reads only the leading bytes, so a signature split across the
225    /// first chunks is still found.
226    #[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    /// Deleting an already-absent blob succeeds: garbage collection may pass
258    /// over the same unreferenced blob more than once.
259    #[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}