1use std::path::{Component, Path, PathBuf};
4
5mod tenant;
6pub use tenant::TenantStorage;
7
8#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
10pub enum StorageError {
11 #[error("Storage I/O error: {0}")]
13 Io(String),
14
15 #[error("Storage object not found: {0}")]
17 NotFound(String),
18
19 #[error("Invalid path traversal attempt: {0}")]
21 PathTraversal(String),
22
23 #[error("Storage driver error: {0}")]
25 Driver(String),
26
27 #[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#[derive(Debug, Clone)]
44#[non_exhaustive]
45pub enum StorageDriver {
46 Local {
48 base_path: String,
50 },
51 S3 {
53 bucket: String,
55 region: String,
57 },
58 R2 {
60 bucket: String,
62 account_id: String,
64 },
65}
66
67#[derive(Debug, Clone)]
69pub struct Storage {
70 driver: StorageDriver,
71}
72
73impl Storage {
74 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 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 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 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 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 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 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#[derive(Debug, Clone)]
173pub struct LocalDriver {
174 base_dir: PathBuf,
175}
176
177impl LocalDriver {
178 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 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 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 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 pub async fn url(&self, path: &str) -> Result<String, StorageError> {
262 Ok(format!("/storage/{}", normalized_object_key(path)?))
263 }
264
265 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}