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