Skip to main content

rullst_core/
storage.rs

1//! Unified Object Storage & Media Pipeline for Rullst applications.
2
3use std::path::{Component, Path, PathBuf};
4
5mod local_write;
6mod tenant;
7pub use tenant::TenantStorage;
8mod public_url;
9
10/// Strongly-typed error domain for Rullst Storage operations.
11#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
12pub enum StorageError {
13    /// File or directory I/O failure.
14    #[error("Storage I/O error: {0}")]
15    Io(String),
16
17    /// The requested object does not exist.
18    #[error("Storage object not found: {0}")]
19    NotFound(String),
20
21    /// Path traversal attack attempt intercepted.
22    #[error("Invalid path traversal attempt: {0}")]
23    PathTraversal(String),
24
25    /// Storage driver error.
26    #[error("Storage driver error: {0}")]
27    Driver(String),
28
29    /// The selected backend or operation is not implemented by this build.
30    #[error("Unsupported storage operation: {0}")]
31    Unsupported(String),
32}
33
34impl From<std::io::Error> for StorageError {
35    fn from(err: std::io::Error) -> Self {
36        if err.kind() == std::io::ErrorKind::NotFound {
37            StorageError::NotFound(err.to_string())
38        } else {
39            StorageError::Io(err.to_string())
40        }
41    }
42}
43
44/// Storage Driver Target Adapter
45#[derive(Debug, Clone)]
46#[non_exhaustive]
47pub enum StorageDriver {
48    /// Local file system storage
49    Local {
50        /// Base directory path on local disk
51        base_path: String,
52    },
53    /// Amazon Web Services (AWS) S3 Storage
54    S3 {
55        /// S3 Bucket name
56        bucket: String,
57        /// AWS Region
58        region: String,
59    },
60    /// Cloudflare R2 Object Storage
61    R2 {
62        /// R2 Bucket name
63        bucket: String,
64        /// Cloudflare Account ID
65        account_id: String,
66    },
67}
68
69/// Unified Storage Engine Client
70#[derive(Debug, Clone)]
71pub struct Storage {
72    driver: StorageDriver,
73}
74
75impl Storage {
76    /// Initialize local disk storage provider
77    pub fn local<S: Into<String>>(base_path: S) -> Self {
78        Self {
79            driver: StorageDriver::Local {
80                base_path: base_path.into(),
81            },
82        }
83    }
84
85    /// Initialize AWS S3 storage provider
86    pub fn s3(bucket: impl Into<String>, region: impl Into<String>) -> Self {
87        Self {
88            driver: StorageDriver::S3 {
89                bucket: bucket.into(),
90                region: region.into(),
91            },
92        }
93    }
94
95    /// Initialize Cloudflare R2 storage provider
96    pub fn r2(bucket: impl Into<String>, account_id: impl Into<String>) -> Self {
97        Self {
98            driver: StorageDriver::R2 {
99                bucket: bucket.into(),
100                account_id: account_id.into(),
101            },
102        }
103    }
104
105    /// Put binary payload to target path
106    pub async fn put(&self, relative_path: &str, bytes: &[u8]) -> Result<(), StorageError> {
107        match &self.driver {
108            StorageDriver::Local { base_path } => {
109                LocalDriver::new(base_path).put(relative_path, bytes).await
110            }
111            StorageDriver::S3 { .. } => Err(StorageError::Unsupported(
112                "AWS S3 uploads require an installed S3 backend".to_string(),
113            )),
114            StorageDriver::R2 { .. } => Err(StorageError::Unsupported(
115                "Cloudflare R2 uploads require an installed R2 backend".to_string(),
116            )),
117        }
118    }
119
120    /// Retrieve binary payload from target path
121    pub async fn get(&self, relative_path: &str) -> Result<Vec<u8>, StorageError> {
122        match &self.driver {
123            StorageDriver::Local { base_path } => {
124                LocalDriver::new(base_path).get(relative_path).await
125            }
126            StorageDriver::S3 { .. } => Err(StorageError::Unsupported(
127                "AWS S3 downloads require an installed S3 backend".to_string(),
128            )),
129            StorageDriver::R2 { .. } => Err(StorageError::Unsupported(
130                "Cloudflare R2 downloads require an installed R2 backend".to_string(),
131            )),
132        }
133    }
134
135    /// Public URL resolution helper for uploaded asset.
136    ///
137    /// Each key segment is percent-encoded (every byte except the unreserved
138    /// `A-Z a-z 0-9 - . _ ~`), so `#`, `?`, `%`, spaces and non-ASCII names
139    /// stay part of the object key. Local storage returns the root-relative
140    /// `/storage/<key>` path, exactly like [`LocalDriver::url`]; the
141    /// application serves its base directory at `/storage`. The filesystem
142    /// base path never appears in the URL.
143    pub fn url(&self, relative_path: &str) -> Result<String, StorageError> {
144        let path = public_url::encode_key_path(&normalized_object_key(relative_path)?);
145        match &self.driver {
146            StorageDriver::Local { .. } => Ok(format!("/storage/{path}")),
147            StorageDriver::S3 { bucket, region } => {
148                Ok(format!("https://{bucket}.s3.{region}.amazonaws.com/{path}"))
149            }
150            StorageDriver::R2 { bucket, account_id } => Ok(format!(
151                "https://{account_id}.r2.cloudflarestorage.com/{bucket}/{path}"
152            )),
153        }
154    }
155
156    /// Resize media buffer pipeline helper
157    pub fn resize_webp(
158        &self,
159        bytes: &[u8],
160        _width: u32,
161        _height: u32,
162    ) -> Result<Vec<u8>, StorageError> {
163        let _ = bytes;
164        Err(StorageError::Unsupported(
165            "WebP resizing is not enabled in this build".to_string(),
166        ))
167    }
168}
169
170/// Local disk storage driver supporting path traversal protection
171#[derive(Debug, Clone)]
172pub struct LocalDriver {
173    base_dir: PathBuf,
174}
175
176impl LocalDriver {
177    /// Create new LocalDriver
178    pub fn new<P: Into<PathBuf>>(base_dir: P) -> Self {
179        Self {
180            base_dir: base_dir.into(),
181        }
182    }
183
184    async fn canonical_base(&self) -> Result<PathBuf, StorageError> {
185        tokio::fs::create_dir_all(&self.base_dir).await?;
186        tokio::fs::canonicalize(&self.base_dir)
187            .await
188            .map_err(StorageError::from)
189    }
190
191    async fn resolve_existing_path(&self, relative_path: &str) -> Result<PathBuf, StorageError> {
192        let validated_path = validate_relative_path(relative_path)?;
193        let lexical_path = self.base_dir.join(&validated_path);
194        let canonical_base = self.canonical_base().await?;
195        reject_symlink_components(&canonical_base, &validated_path).await?;
196        let canonical_path = tokio::fs::canonicalize(&lexical_path)
197            .await
198            .map_err(StorageError::from)?;
199
200        if !canonical_path.starts_with(&canonical_base) {
201            return Err(StorageError::PathTraversal(relative_path.to_string()));
202        }
203
204        Ok(canonical_path)
205    }
206
207    async fn resolve_write_path(&self, relative_path: &str) -> Result<PathBuf, StorageError> {
208        let validated_path = validate_relative_path(relative_path)?;
209        let lexical_path = self.base_dir.join(&validated_path);
210        let canonical_base = self.canonical_base().await?;
211        reject_symlink_components(&canonical_base, &validated_path).await?;
212        let parent = lexical_path
213            .parent()
214            .ok_or_else(|| StorageError::PathTraversal("storage path has no parent".to_string()))?;
215
216        tokio::fs::create_dir_all(parent).await?;
217        let canonical_parent = tokio::fs::canonicalize(parent).await?;
218        if !canonical_parent.starts_with(&canonical_base) {
219            return Err(StorageError::PathTraversal(relative_path.to_string()));
220        }
221
222        if let Ok(metadata) = tokio::fs::symlink_metadata(&lexical_path).await
223            && metadata.file_type().is_symlink()
224        {
225            return Err(StorageError::PathTraversal(relative_path.to_string()));
226        }
227
228        let file_name = lexical_path.file_name().ok_or_else(|| {
229            StorageError::PathTraversal("storage path must identify a file".to_string())
230        })?;
231        Ok(canonical_parent.join(file_name))
232    }
233
234    /// Put binary payload to target path.
235    ///
236    /// The bytes are written to a unique temporary file in the destination
237    /// directory, flushed to disk and renamed over the destination. Readers and
238    /// concurrent writers observe one complete version; a failed write leaves
239    /// the previous object untouched.
240    pub async fn put(&self, path: &str, bytes: &[u8]) -> Result<(), StorageError> {
241        let full_path = self.resolve_write_path(path).await?;
242        local_write::replace_file(full_path, bytes.to_vec()).await
243    }
244
245    /// Check if target path exists
246    pub async fn exists(&self, path: &str) -> Result<bool, StorageError> {
247        match self.resolve_existing_path(path).await {
248            Ok(_) => Ok(true),
249            Err(StorageError::NotFound(_)) => Ok(false),
250            Err(error) => Err(error),
251        }
252    }
253
254    /// Retrieve binary payload from target path
255    pub async fn get(&self, path: &str) -> Result<Vec<u8>, StorageError> {
256        let full_path = self.resolve_existing_path(path).await?;
257        tokio::fs::read(full_path)
258            .await
259            .map_err(|e| StorageError::Io(e.to_string()))
260    }
261
262    /// Resolve the root-relative public URL path `/storage/<key>`.
263    ///
264    /// Each key segment is percent-encoded; serve the base directory at
265    /// `/storage` for the URL to resolve.
266    pub async fn url(&self, path: &str) -> Result<String, StorageError> {
267        let path = public_url::encode_key_path(&normalized_object_key(path)?);
268        Ok(format!("/storage/{path}"))
269    }
270
271    /// Delete file from target path
272    pub async fn delete(&self, path: &str) -> Result<(), StorageError> {
273        let full_path = self.resolve_existing_path(path).await?;
274        tokio::fs::remove_file(full_path)
275            .await
276            .map_err(|e| StorageError::Io(e.to_string()))
277    }
278}
279
280fn validate_relative_path(relative_path: &str) -> Result<PathBuf, StorageError> {
281    if relative_path.is_empty() || relative_path.contains('\\') {
282        return Err(StorageError::PathTraversal(relative_path.to_string()));
283    }
284
285    let path = Path::new(relative_path);
286    let mut validated = PathBuf::new();
287    for component in path.components() {
288        match component {
289            Component::Normal(segment) => validated.push(segment),
290            Component::CurDir => {}
291            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
292                return Err(StorageError::PathTraversal(relative_path.to_string()));
293            }
294        }
295    }
296
297    if validated.as_os_str().is_empty() {
298        return Err(StorageError::PathTraversal(relative_path.to_string()));
299    }
300
301    Ok(validated)
302}
303
304fn normalized_object_key(relative_path: &str) -> Result<String, StorageError> {
305    let validated = validate_relative_path(relative_path)?;
306    let mut key = String::new();
307    for segment in validated.iter() {
308        let segment = segment.to_str().ok_or_else(|| {
309            StorageError::PathTraversal("storage path is not valid UTF-8".to_string())
310        })?;
311        if !key.is_empty() {
312            key.push('/');
313        }
314        key.push_str(segment);
315    }
316    Ok(key)
317}
318
319async fn reject_symlink_components(
320    canonical_base: &Path,
321    relative_path: &Path,
322) -> Result<(), StorageError> {
323    let mut candidate = canonical_base.to_path_buf();
324    for component in relative_path.components() {
325        let Component::Normal(segment) = component else {
326            return Err(StorageError::PathTraversal(
327                relative_path.to_string_lossy().into_owned(),
328            ));
329        };
330        candidate.push(segment);
331        match tokio::fs::symlink_metadata(&candidate).await {
332            Ok(metadata) if metadata.file_type().is_symlink() => {
333                return Err(StorageError::PathTraversal(
334                    relative_path.to_string_lossy().into_owned(),
335                ));
336            }
337            Ok(_) => {}
338            Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
339            Err(error) => return Err(StorageError::from(error)),
340        }
341    }
342    Ok(())
343}
344
345#[cfg(test)]
346#[allow(clippy::unwrap_used, clippy::expect_used)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn relative_path_validation_rejects_escape_attempts() {
352        for path in [
353            "",
354            "../secret",
355            "a/../../secret",
356            "/etc/passwd",
357            r"..\\secret",
358        ] {
359            assert!(matches!(
360                validate_relative_path(path),
361                Err(StorageError::PathTraversal(_))
362            ));
363        }
364    }
365
366    #[test]
367    fn object_urls_use_portable_forward_slash_keys() {
368        let local = Storage::local("storage");
369        let s3 = Storage::s3("bucket", "sa-east-1");
370        let r2 = Storage::r2("bucket", "account");
371
372        assert_eq!(
373            local.url("courses/1/lesson.txt").unwrap(),
374            "/storage/courses/1/lesson.txt"
375        );
376        assert_eq!(
377            s3.url("courses/1/lesson.txt").unwrap(),
378            "https://bucket.s3.sa-east-1.amazonaws.com/courses/1/lesson.txt"
379        );
380        assert_eq!(
381            r2.url("courses/1/lesson.txt").unwrap(),
382            "https://account.r2.cloudflarestorage.com/bucket/courses/1/lesson.txt"
383        );
384    }
385
386    #[tokio::test]
387    async fn cloud_backends_never_report_false_success() {
388        let s3 = Storage::s3("bucket", "sa-east-1");
389        let r2 = Storage::r2("bucket", "account");
390
391        assert!(matches!(
392            s3.put("file.txt", b"payload").await,
393            Err(StorageError::Unsupported(_))
394        ));
395        assert!(matches!(
396            r2.get("file.txt").await,
397            Err(StorageError::Unsupported(_))
398        ));
399    }
400
401    #[test]
402    fn resize_never_returns_unmodified_bytes_as_success() {
403        let storage = Storage::local("storage");
404        assert!(matches!(
405            storage.resize_webp(b"not-an-image", 100, 100),
406            Err(StorageError::Unsupported(_))
407        ));
408    }
409
410    #[cfg(unix)]
411    #[tokio::test]
412    async fn local_storage_rejects_symlink_escape() {
413        use std::os::unix::fs::symlink;
414
415        let suffix = uuid::Uuid::new_v4();
416        let root = std::env::temp_dir().join(format!("rullst-storage-root-{suffix}"));
417        let outside = std::env::temp_dir().join(format!("rullst-storage-outside-{suffix}"));
418        std::fs::create_dir_all(&root).unwrap();
419        std::fs::create_dir_all(&outside).unwrap();
420        std::fs::write(outside.join("secret.txt"), b"secret").unwrap();
421        symlink(&outside, root.join("escape")).unwrap();
422
423        let storage = Storage::local(root.to_string_lossy());
424        assert!(matches!(
425            storage.get("escape/secret.txt").await,
426            Err(StorageError::PathTraversal(_))
427        ));
428        assert!(matches!(
429            storage.put("escape/created.txt", b"payload").await,
430            Err(StorageError::PathTraversal(_))
431        ));
432
433        let _ = std::fs::remove_dir_all(root);
434        let _ = std::fs::remove_dir_all(outside);
435    }
436}