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}