1use std::path::{Component, Path, PathBuf};
4
5mod local_write;
6mod tenant;
7pub use tenant::TenantStorage;
8mod public_url;
9
10#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
12pub enum StorageError {
13 #[error("Storage I/O error: {0}")]
15 Io(String),
16
17 #[error("Storage object not found: {0}")]
19 NotFound(String),
20
21 #[error("Invalid path traversal attempt: {0}")]
23 PathTraversal(String),
24
25 #[error("Storage driver error: {0}")]
27 Driver(String),
28
29 #[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#[derive(Debug, Clone)]
46#[non_exhaustive]
47pub enum StorageDriver {
48 Local {
50 base_path: String,
52 },
53 S3 {
55 bucket: String,
57 region: String,
59 },
60 R2 {
62 bucket: String,
64 account_id: String,
66 },
67}
68
69#[derive(Debug, Clone)]
71pub struct Storage {
72 driver: StorageDriver,
73}
74
75impl Storage {
76 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 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 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 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 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 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 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#[derive(Debug, Clone)]
172pub struct LocalDriver {
173 base_dir: PathBuf,
174}
175
176impl LocalDriver {
177 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 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 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 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 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 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}