Skip to main content

reinhardt_utils/storage/
local.rs

1//! Local filesystem storage backend
2
3use super::backend::Storage;
4use super::errors::{StorageError, StorageResult};
5use super::file::{FileMetadata, StoredFile};
6use async_trait::async_trait;
7use sha2::{Digest, Sha256};
8use std::path::{Path, PathBuf};
9use tokio::fs;
10
11/// Local filesystem storage
12pub struct LocalStorage {
13	base_path: PathBuf,
14	base_url: String,
15}
16
17impl LocalStorage {
18	/// Create a new local filesystem storage backend
19	///
20	/// # Examples
21	///
22	/// ```
23	/// use reinhardt_utils::storage::{LocalStorage, Storage};
24	///
25	/// let storage = LocalStorage::new("/tmp/storage", "http://localhost/media");
26	/// assert_eq!(storage.url("test.txt"), "http://localhost/media/test.txt");
27	/// ```
28	pub fn new(base_path: impl Into<PathBuf>, base_url: impl Into<String>) -> Self {
29		Self {
30			base_path: base_path.into(),
31			base_url: base_url.into(),
32		}
33	}
34	/// Ensure the base directory exists, creating it if necessary
35	///
36	/// # Examples
37	///
38	/// ```
39	/// use reinhardt_utils::storage::LocalStorage;
40	/// use tempfile::TempDir;
41	///
42	/// # tokio_test::block_on(async {
43	/// let temp_dir = TempDir::new().unwrap();
44	/// let storage_path = temp_dir.path().join("new_storage");
45	/// let storage = LocalStorage::new(&storage_path, "http://localhost/media");
46	///
47	/// storage.ensure_base_dir().await.unwrap();
48	/// assert!(storage_path.exists());
49	/// # });
50	/// ```
51	pub async fn ensure_base_dir(&self) -> StorageResult<()> {
52		fs::create_dir_all(&self.base_path).await?;
53		Ok(())
54	}
55
56	fn full_path(&self, path: &str) -> PathBuf {
57		self.base_path.join(path)
58	}
59
60	/// Validate path to prevent directory traversal attacks
61	fn validate_path(path: &str) -> StorageResult<()> {
62		// Check for absolute paths
63		if path.starts_with('/') || path.starts_with('\\') {
64			return Err(StorageError::InvalidPath(format!(
65				"Detected path traversal attempt in '{}'",
66				path
67			)));
68		}
69
70		// Check for parent directory references
71		let path_obj = Path::new(path);
72		for component in path_obj.components() {
73			if component == std::path::Component::ParentDir {
74				return Err(StorageError::InvalidPath(format!(
75					"Detected path traversal attempt in '{}'",
76					path
77				)));
78			}
79		}
80
81		// Check if path is just current dir or empty
82		if path == "." || path == ".." || path.is_empty() {
83			return Err(StorageError::InvalidPath(format!(
84				"Could not derive file name from '{}'",
85				path
86			)));
87		}
88
89		Ok(())
90	}
91
92	fn compute_checksum(content: &[u8]) -> String {
93		let mut hasher = Sha256::new();
94		hasher.update(content);
95		hex::encode(hasher.finalize())
96	}
97}
98
99#[async_trait]
100impl Storage for LocalStorage {
101	async fn save(&self, path: &str, content: &[u8]) -> StorageResult<FileMetadata> {
102		// Validate path to prevent directory traversal
103		Self::validate_path(path)?;
104
105		let full_path = self.full_path(path);
106
107		// Create parent directories if needed
108		if let Some(parent) = full_path.parent() {
109			fs::create_dir_all(parent).await?;
110		}
111
112		// Write file
113		fs::write(&full_path, content).await?;
114
115		// Get file metadata
116		let file_meta = fs::metadata(&full_path).await?;
117		let size = file_meta.len();
118		let checksum = Self::compute_checksum(content);
119
120		Ok(FileMetadata::new(path.to_string(), size).with_checksum(checksum))
121	}
122
123	async fn read(&self, path: &str) -> StorageResult<StoredFile> {
124		// Validate path to prevent directory traversal
125		Self::validate_path(path)?;
126
127		let full_path = self.full_path(path);
128
129		if !full_path.exists() {
130			return Err(StorageError::NotFound(path.to_string()));
131		}
132
133		let content = fs::read(&full_path).await?;
134		let file_meta = fs::metadata(&full_path).await?;
135		let size = file_meta.len();
136
137		let metadata = FileMetadata::new(path.to_string(), size);
138		Ok(StoredFile::new(metadata, content))
139	}
140
141	async fn delete(&self, path: &str) -> StorageResult<()> {
142		// Validate path to prevent directory traversal
143		Self::validate_path(path)?;
144
145		let full_path = self.full_path(path);
146
147		if !full_path.exists() {
148			return Err(StorageError::NotFound(path.to_string()));
149		}
150
151		fs::remove_file(&full_path).await?;
152		Ok(())
153	}
154
155	async fn exists(&self, path: &str) -> StorageResult<bool> {
156		// Validate path to prevent directory traversal
157		Self::validate_path(path)?;
158
159		let full_path = self.full_path(path);
160		Ok(full_path.exists())
161	}
162
163	async fn metadata(&self, path: &str) -> StorageResult<FileMetadata> {
164		// Validate path to prevent directory traversal
165		Self::validate_path(path)?;
166
167		let full_path = self.full_path(path);
168
169		if !full_path.exists() {
170			return Err(StorageError::NotFound(path.to_string()));
171		}
172
173		let file_meta = fs::metadata(&full_path).await?;
174		let size = file_meta.len();
175
176		Ok(FileMetadata::new(path.to_string(), size))
177	}
178
179	async fn list(&self, path: &str) -> StorageResult<Vec<FileMetadata>> {
180		// Validate path to prevent directory traversal
181		// Empty path is allowed to list the base directory
182		if !path.is_empty() {
183			Self::validate_path(path)?;
184		}
185
186		let full_path = self.full_path(path);
187		let mut entries = fs::read_dir(&full_path).await?;
188		let mut results = Vec::new();
189
190		while let Some(entry) = entries.next_entry().await? {
191			let metadata = entry.metadata().await?;
192			if metadata.is_file() {
193				let file_name = entry.file_name().to_string_lossy().to_string();
194				let relative_path = Path::new(path).join(&file_name);
195				results.push(FileMetadata::new(
196					relative_path.to_string_lossy().to_string(),
197					metadata.len(),
198				));
199			}
200		}
201
202		Ok(results)
203	}
204
205	fn url(&self, path: &str) -> String {
206		format!(
207			"{}/{}",
208			self.base_url.trim_end_matches('/'),
209			path.trim_start_matches('/')
210		)
211	}
212
213	fn path(&self, name: &str) -> String {
214		name.to_string()
215	}
216
217	async fn get_accessed_time(&self, path: &str) -> StorageResult<chrono::DateTime<chrono::Utc>> {
218		// Validate path to prevent directory traversal
219		Self::validate_path(path)?;
220
221		let full_path = self.full_path(path);
222
223		if !full_path.exists() {
224			return Err(StorageError::NotFound(path.to_string()));
225		}
226
227		let file_meta = fs::metadata(&full_path).await?;
228		let accessed = file_meta.accessed()?;
229		let datetime: chrono::DateTime<chrono::Utc> = accessed.into();
230		Ok(datetime)
231	}
232
233	async fn get_created_time(&self, path: &str) -> StorageResult<chrono::DateTime<chrono::Utc>> {
234		// Validate path to prevent directory traversal
235		Self::validate_path(path)?;
236
237		let full_path = self.full_path(path);
238
239		if !full_path.exists() {
240			return Err(StorageError::NotFound(path.to_string()));
241		}
242
243		let file_meta = fs::metadata(&full_path).await?;
244		let created = file_meta.created()?;
245		let datetime: chrono::DateTime<chrono::Utc> = created.into();
246		Ok(datetime)
247	}
248
249	async fn get_modified_time(&self, path: &str) -> StorageResult<chrono::DateTime<chrono::Utc>> {
250		// Validate path to prevent directory traversal
251		Self::validate_path(path)?;
252
253		let full_path = self.full_path(path);
254
255		if !full_path.exists() {
256			return Err(StorageError::NotFound(path.to_string()));
257		}
258
259		let file_meta = fs::metadata(&full_path).await?;
260		let modified = file_meta.modified()?;
261		let datetime: chrono::DateTime<chrono::Utc> = modified.into();
262		Ok(datetime)
263	}
264}
265
266#[cfg(test)]
267mod tests {
268	use super::*;
269	use tempfile::TempDir;
270
271	async fn create_test_storage() -> (LocalStorage, TempDir) {
272		let temp_dir = TempDir::new().unwrap();
273		let storage = LocalStorage::new(temp_dir.path(), "http://localhost/media");
274		storage.ensure_base_dir().await.unwrap();
275		(storage, temp_dir)
276	}
277
278	#[tokio::test]
279	async fn test_local_storage_path() {
280		let storage = LocalStorage::new("/tmp/storage", "http://localhost/media");
281		assert_eq!(storage.url("test.txt"), "http://localhost/media/test.txt");
282	}
283
284	#[tokio::test]
285	async fn test_file_access_options() {
286		let (storage, _temp_dir) = create_test_storage().await;
287
288		// Test that file doesn't exist initially
289		assert!(!storage.exists("storage_test").await.unwrap());
290
291		// Save a file
292		let content = b"storage contents";
293		storage.save("storage_test", content).await.unwrap();
294
295		// Check file exists
296		assert!(storage.exists("storage_test").await.unwrap());
297
298		// Read file content
299		let file = storage.read("storage_test").await.unwrap();
300		assert_eq!(file.content, content);
301
302		// Delete file
303		storage.delete("storage_test").await.unwrap();
304		assert!(!storage.exists("storage_test").await.unwrap());
305	}
306
307	#[tokio::test]
308	async fn test_file_save_with_path() {
309		let (storage, _temp_dir) = create_test_storage().await;
310
311		// Saving a pathname should create intermediate directories
312		assert!(!storage.exists("path/to").await.unwrap());
313
314		storage
315			.save("path/to/test.file", b"file saved with path")
316			.await
317			.unwrap();
318
319		assert!(storage.exists("path/to/test.file").await.unwrap());
320
321		let file = storage.read("path/to/test.file").await.unwrap();
322		assert_eq!(file.content, b"file saved with path");
323
324		storage.delete("path/to/test.file").await.unwrap();
325	}
326
327	#[tokio::test]
328	async fn test_file_size() {
329		let (storage, _temp_dir) = create_test_storage().await;
330
331		storage.save("file.txt", b"test").await.unwrap();
332		let metadata = storage.metadata("file.txt").await.unwrap();
333		assert_eq!(metadata.size, 4);
334
335		storage.delete("file.txt").await.unwrap();
336	}
337
338	#[tokio::test]
339	async fn test_exists() {
340		let (storage, _temp_dir) = create_test_storage().await;
341
342		storage.save("dir/subdir/file.txt", b"test").await.unwrap();
343		assert!(storage.exists("dir/subdir/file.txt").await.unwrap());
344
345		storage.delete("dir/subdir/file.txt").await.unwrap();
346	}
347
348	#[tokio::test]
349	async fn test_delete() {
350		let (storage, _temp_dir) = create_test_storage().await;
351
352		storage.save("dir/subdir/file.txt", b"test").await.unwrap();
353		storage
354			.save("dir/subdir/other_file.txt", b"test")
355			.await
356			.unwrap();
357
358		assert!(storage.exists("dir/subdir/file.txt").await.unwrap());
359		assert!(storage.exists("dir/subdir/other_file.txt").await.unwrap());
360
361		storage.delete("dir/subdir/other_file.txt").await.unwrap();
362		assert!(!storage.exists("dir/subdir/other_file.txt").await.unwrap());
363
364		storage.delete("dir/subdir/file.txt").await.unwrap();
365		assert!(!storage.exists("dir/subdir/file.txt").await.unwrap());
366	}
367
368	#[tokio::test]
369	async fn test_delete_missing_file() {
370		let (storage, _temp_dir) = create_test_storage().await;
371
372		// Deleting a missing file should return an error
373		let result = storage.delete("missing_file.txt").await;
374		assert!(result.is_err());
375		assert!(matches!(result.unwrap_err(), StorageError::NotFound(_)));
376	}
377
378	#[tokio::test]
379	async fn test_file_url() {
380		let storage = LocalStorage::new("/tmp/storage", "http://localhost/media");
381
382		assert_eq!(storage.url("test.file"), "http://localhost/media/test.file");
383
384		// Test URL with base_url without trailing slash
385		let storage2 = LocalStorage::new("/tmp/storage", "http://localhost/media/");
386		assert_eq!(
387			storage2.url("test.file"),
388			"http://localhost/media/test.file"
389		);
390	}
391
392	#[tokio::test]
393	async fn test_base_url() {
394		// Test with no trailing slash in base_url
395		let storage = LocalStorage::new("/tmp/storage", "http://localhost/no_ending_slash");
396		assert_eq!(
397			storage.url("test.file"),
398			"http://localhost/no_ending_slash/test.file"
399		);
400	}
401
402	#[tokio::test]
403	async fn test_listdir() {
404		let (storage, _temp_dir) = create_test_storage().await;
405
406		storage
407			.save("storage_test_1", b"custom content")
408			.await
409			.unwrap();
410		storage
411			.save("storage_test_2", b"custom content")
412			.await
413			.unwrap();
414		storage.save("dir/file_c.txt", b"test").await.unwrap();
415
416		let files = storage.list("").await.unwrap();
417		let file_names: Vec<String> = files
418			.iter()
419			.map(|f| {
420				std::path::Path::new(&f.path)
421					.file_name()
422					.unwrap()
423					.to_string_lossy()
424					.to_string()
425			})
426			.collect();
427
428		assert!(file_names.contains(&"storage_test_1".to_string()));
429		assert!(file_names.contains(&"storage_test_2".to_string()));
430
431		// Cleanup
432		storage.delete("storage_test_1").await.unwrap();
433		storage.delete("storage_test_2").await.unwrap();
434		storage.delete("dir/file_c.txt").await.unwrap();
435	}
436
437	#[tokio::test]
438	async fn test_open_missing_file() {
439		let (storage, _temp_dir) = create_test_storage().await;
440
441		let result = storage.read("missing.txt").await;
442		assert!(result.is_err());
443		assert!(matches!(result.unwrap_err(), StorageError::NotFound(_)));
444	}
445
446	#[tokio::test]
447	async fn test_large_file_saving() {
448		let (storage, _temp_dir) = create_test_storage().await;
449
450		// Create a large file (3 * 64KB)
451		let large_content = vec![b'A'; 64 * 1024 * 3];
452		storage
453			.save("large_file.txt", &large_content)
454			.await
455			.unwrap();
456
457		let metadata = storage.metadata("large_file.txt").await.unwrap();
458		assert_eq!(metadata.size, large_content.len() as u64);
459
460		storage.delete("large_file.txt").await.unwrap();
461	}
462
463	#[tokio::test]
464	async fn test_file_checksum() {
465		let (storage, _temp_dir) = create_test_storage().await;
466
467		let metadata = storage.save("file.txt", b"test").await.unwrap();
468		assert!(metadata.checksum.is_some());
469
470		// Same content should produce same checksum
471		let metadata2 = storage.save("file2.txt", b"test").await.unwrap();
472		assert_eq!(metadata.checksum, metadata2.checksum);
473
474		storage.delete("file.txt").await.unwrap();
475		storage.delete("file2.txt").await.unwrap();
476	}
477
478	#[tokio::test]
479	async fn test_file_get_accessed_time() {
480		let (storage, _temp_dir) = create_test_storage().await;
481
482		storage.save("test.file", b"custom contents").await.unwrap();
483
484		let atime = storage.get_accessed_time("test.file").await.unwrap();
485		let now = chrono::Utc::now();
486
487		// Access time should be close to current time
488		let diff = (now - atime).num_seconds().abs();
489		assert!(
490			diff < 5,
491			"Access time difference too large: {} seconds",
492			diff
493		);
494
495		storage.delete("test.file").await.unwrap();
496	}
497
498	#[tokio::test]
499	async fn test_file_get_created_time() {
500		let (storage, _temp_dir) = create_test_storage().await;
501
502		storage.save("test.file", b"custom contents").await.unwrap();
503
504		let ctime = storage.get_created_time("test.file").await.unwrap();
505		let now = chrono::Utc::now();
506
507		// Creation time should be close to current time
508		let diff = (now - ctime).num_seconds().abs();
509		assert!(
510			diff < 5,
511			"Creation time difference too large: {} seconds",
512			diff
513		);
514
515		storage.delete("test.file").await.unwrap();
516	}
517
518	#[tokio::test]
519	async fn test_file_get_modified_time() {
520		let (storage, _temp_dir) = create_test_storage().await;
521
522		storage.save("test.file", b"custom contents").await.unwrap();
523
524		let mtime = storage.get_modified_time("test.file").await.unwrap();
525		let now = chrono::Utc::now();
526
527		// Modified time should be close to current time
528		let diff = (now - mtime).num_seconds().abs();
529		assert!(
530			diff < 5,
531			"Modified time difference too large: {} seconds",
532			diff
533		);
534
535		storage.delete("test.file").await.unwrap();
536	}
537
538	#[tokio::test]
539	async fn test_file_modified_time_changes() {
540		use tokio::time::{Duration, sleep};
541
542		let (storage, _temp_dir) = create_test_storage().await;
543
544		storage.save("file.txt", b"test").await.unwrap();
545		let modified_time = storage.get_modified_time("file.txt").await.unwrap();
546
547		// Wait a bit
548		sleep(Duration::from_millis(100)).await;
549
550		// Modify the file
551		storage.save("file.txt", b"new content").await.unwrap();
552
553		let new_modified_time = storage.get_modified_time("file.txt").await.unwrap();
554		assert!(
555			new_modified_time > modified_time,
556			"Modified time should increase after file change"
557		);
558
559		storage.delete("file.txt").await.unwrap();
560	}
561
562	#[tokio::test]
563	async fn test_file_storage_prevents_directory_traversal() {
564		let (storage, _temp_dir) = create_test_storage().await;
565
566		// Test parent directory traversal
567		let result = storage.save("../test.txt", b"test").await;
568		assert!(result.is_err());
569		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
570
571		// Test absolute path
572		let result = storage.save("/etc/passwd", b"test").await;
573		assert!(result.is_err());
574		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
575	}
576
577	#[tokio::test]
578	async fn test_storage_dangerous_paths() {
579		let (storage, _temp_dir) = create_test_storage().await;
580
581		let dangerous_paths = vec!["..", ".", "", "../path", "tmp/../path", "/tmp/path"];
582
583		for path in dangerous_paths {
584			let result = storage.save(path, b"test").await;
585			assert!(
586				result.is_err(),
587				"Path '{}' should be rejected but was accepted",
588				path
589			);
590			assert!(
591				matches!(result.unwrap_err(), StorageError::InvalidPath(_)),
592				"Path '{}' should return InvalidPath error",
593				path
594			);
595		}
596	}
597
598	#[tokio::test]
599	async fn test_path_with_dots_in_filename() {
600		let (storage, _temp_dir) = create_test_storage().await;
601
602		// Valid path with dots in directory and filename should work
603		storage.save("my.dir/test.file.txt", b"test").await.unwrap();
604		assert!(storage.exists("my.dir/test.file.txt").await.unwrap());
605
606		storage.delete("my.dir/test.file.txt").await.unwrap();
607	}
608
609	#[tokio::test]
610	async fn test_url_encoding() {
611		let storage = LocalStorage::new("/tmp/storage", "http://localhost/media");
612
613		// Basic file
614		assert_eq!(storage.url("test.file"), "http://localhost/media/test.file");
615
616		// File with special characters (note: basic implementation doesn't encode)
617		// This test documents current behavior
618		let url = storage.url("test file.txt");
619		assert!(url.contains("test file.txt"));
620	}
621
622	#[tokio::test]
623	async fn test_read_prevents_directory_traversal() {
624		let (storage, _temp_dir) = create_test_storage().await;
625
626		// Test parent directory traversal
627		let result = storage.read("../test.txt").await;
628		assert!(result.is_err());
629		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
630
631		// Test absolute path
632		let result = storage.read("/etc/passwd").await;
633		assert!(result.is_err());
634		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
635
636		// Test embedded parent directory
637		let result = storage.read("tmp/../test.txt").await;
638		assert!(result.is_err());
639		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
640	}
641
642	#[tokio::test]
643	async fn test_delete_prevents_directory_traversal() {
644		let (storage, _temp_dir) = create_test_storage().await;
645
646		// Test parent directory traversal
647		let result = storage.delete("../test.txt").await;
648		assert!(result.is_err());
649		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
650
651		// Test absolute path
652		let result = storage.delete("/etc/passwd").await;
653		assert!(result.is_err());
654		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
655	}
656
657	#[tokio::test]
658	async fn test_exists_prevents_directory_traversal() {
659		let (storage, _temp_dir) = create_test_storage().await;
660
661		// Test parent directory traversal
662		let result = storage.exists("../test.txt").await;
663		assert!(result.is_err());
664		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
665
666		// Test absolute path
667		let result = storage.exists("/etc/passwd").await;
668		assert!(result.is_err());
669		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
670	}
671
672	#[tokio::test]
673	async fn test_metadata_prevents_directory_traversal() {
674		let (storage, _temp_dir) = create_test_storage().await;
675
676		// Test parent directory traversal
677		let result = storage.metadata("../test.txt").await;
678		assert!(result.is_err());
679		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
680
681		// Test absolute path
682		let result = storage.metadata("/etc/passwd").await;
683		assert!(result.is_err());
684		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
685	}
686
687	#[tokio::test]
688	async fn test_list_prevents_directory_traversal() {
689		let (storage, _temp_dir) = create_test_storage().await;
690
691		// Test parent directory traversal
692		let result = storage.list("../test").await;
693		assert!(result.is_err());
694		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
695
696		// Test absolute path
697		let result = storage.list("/etc").await;
698		assert!(result.is_err());
699		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
700	}
701
702	#[tokio::test]
703	async fn test_get_time_operations_prevent_directory_traversal() {
704		let (storage, _temp_dir) = create_test_storage().await;
705
706		// Test get_accessed_time
707		let result = storage.get_accessed_time("../test.txt").await;
708		assert!(result.is_err());
709		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
710
711		// Test get_created_time
712		let result = storage.get_created_time("../test.txt").await;
713		assert!(result.is_err());
714		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
715
716		// Test get_modified_time
717		let result = storage.get_modified_time("../test.txt").await;
718		assert!(result.is_err());
719		assert!(matches!(result.unwrap_err(), StorageError::InvalidPath(_)));
720	}
721}