Skip to main content

laterite_media/
driver.rs

1//! Where blob bytes physically live, and the trait that hides which.
2//!
3//! Deliberately without a `list()`. The reference system's media browser needed
4//! directory listing because its folders were disk directories; here folders are
5//! database rows, so browsing never touches a driver. That removes listing costs,
6//! per-file stat calls, and eventual-consistency surprises in one move.
7
8use std::io;
9use std::path::{Component, Path, PathBuf};
10use std::pin::Pin;
11
12use bytes::Bytes;
13use futures::Stream;
14use laterite_core::strata::async_trait;
15
16/// Blob bytes in flight. Never a `Vec<u8>`: a file is streamed from the moment it
17/// arrives to the moment it lands, so its size never has to fit in memory.
18pub type ByteStream = Pin<Box<dyn Stream<Item = io::Result<Bytes>> + Send>>;
19
20#[derive(Debug, thiserror::Error)]
21pub enum StorageError {
22    #[error("storage io error: {0}")]
23    Io(#[from] io::Error),
24    /// A path that escapes the disk root, or is otherwise not one this driver
25    /// will accept. Never reported with the offending path, which may be
26    /// attacker-supplied.
27    #[error("refused a path outside the disk")]
28    BadPath,
29}
30
31/// What a driver knows about a stored blob without reading it.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct BlobMeta {
34    pub size: u64,
35}
36
37/// A place blobs are stored: the local filesystem, an object store.
38#[async_trait]
39pub trait StorageDriver: Send + Sync + 'static {
40    /// Writes `data` at `path`, replacing anything there.
41    ///
42    /// Blob paths are derived from a content hash, so a replacement writes
43    /// identical bytes and overwriting is always safe.
44    async fn put(&self, path: &str, data: ByteStream) -> Result<(), StorageError>;
45
46    async fn get(&self, path: &str) -> Result<ByteStream, StorageError>;
47
48    /// Removes a blob. Missing is success: deletion is driven by garbage
49    /// collection, which may run twice over the same unreferenced blob.
50    async fn delete(&self, path: &str) -> Result<(), StorageError>;
51
52    /// Size and existence, without reading the blob.
53    async fn metadata(&self, path: &str) -> Result<Option<BlobMeta>, StorageError>;
54
55    /// The blob's directly reachable URL, or `None` on a disk that has none.
56    /// A private disk returns `None` and is served through the application.
57    fn public_url(&self, path: &str) -> Option<String>;
58
59    /// A URL a browser may PUT a file of `content_type` to at `path` for
60    /// `expires_in`, from a disk that can presign; `None` when it cannot, and
61    /// uploads travel through the application.
62    async fn presigned_put(
63        &self,
64        path: &str,
65        content_type: &str,
66        expires_in: std::time::Duration,
67    ) -> Result<Option<String>, StorageError> {
68        let _ = (path, content_type, expires_in);
69        Ok(None)
70    }
71
72    /// Copies the blob at `from` to `to` on this disk. The default streams it
73    /// through `get` and `put`; an object store copies server-side.
74    async fn copy(&self, from: &str, to: &str) -> Result<(), StorageError> {
75        let data = self.get(from).await?;
76        self.put(to, data).await
77    }
78
79    /// A URL that serves `path` for `expires_in`, from a disk that can presign
80    /// (an object store); `None` when it cannot, and the application streams
81    /// the file itself behind its own token.
82    async fn signed_url(
83        &self,
84        path: &str,
85        expires_in: std::time::Duration,
86        disposition: Disposition,
87    ) -> Result<Option<String>, StorageError> {
88        let _ = (path, expires_in, disposition);
89        Ok(None)
90    }
91}
92
93/// How a served file is offered: shown in the page, or saved.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum Disposition {
96    Inline,
97    Attachment,
98}
99
100/// Joins `path` under `root`, refusing anything that escapes it.
101///
102/// Blob paths are framework-derived rather than user-supplied, so this should
103/// never fire. It exists because "should never" is not a security property, and
104/// the cost of being wrong here is reading or writing any file the process can.
105pub(crate) fn resolve(root: &Path, path: &str) -> Result<PathBuf, StorageError> {
106    let candidate = Path::new(path);
107    if candidate.is_absolute() {
108        return Err(StorageError::BadPath);
109    }
110    let mut out = root.to_path_buf();
111    for part in candidate.components() {
112        match part {
113            Component::Normal(segment) => out.push(segment),
114            // `..` is the escape; a root or prefix component means the path was
115            // not relative after all; `.` is meaningless here.
116            _ => return Err(StorageError::BadPath),
117        }
118    }
119    Ok(out)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn a_path_may_not_escape_its_disk() {
128        let root = Path::new("/srv/media");
129        for bad in [
130            "../etc/passwd",
131            "blobs/../../etc/passwd",
132            "/etc/passwd",
133            "./blobs/ab/cd/hash",
134        ] {
135            assert!(
136                matches!(resolve(root, bad), Err(StorageError::BadPath)),
137                "{bad:?} was allowed"
138            );
139        }
140    }
141
142    #[test]
143    fn an_ordinary_blob_path_resolves_under_the_root() {
144        let root = Path::new("/srv/media");
145        assert_eq!(
146            resolve(root, "blobs/ab/cd/abcdef").unwrap(),
147            Path::new("/srv/media/blobs/ab/cd/abcdef")
148        );
149    }
150}