elif_storage/backends/
local.rs

1//! Local filesystem storage backend
2
3use crate::{StorageBackend, StorageResult, StorageError, FileMetadata, UploadOptions, StorageStats};
4use crate::config::LocalStorageConfig;
5use async_trait::async_trait;
6use bytes::Bytes;
7use futures::{Stream, StreamExt};
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10use tokio::fs;
11use tokio::io::{AsyncReadExt, AsyncWriteExt};
12use chrono::Utc;
13use std::collections::HashMap;
14
15/// Local filesystem storage backend
16#[derive(Debug, Clone)]
17pub struct LocalBackend {
18    config: LocalStorageConfig,
19}
20
21impl LocalBackend {
22    /// Create a new local storage backend
23    pub fn new(config: LocalStorageConfig) -> Self {
24        Self { config }
25    }
26    
27    /// Get the full filesystem path for a storage path
28    fn full_path(&self, path: &str) -> PathBuf {
29        // Sanitize the path to prevent directory traversal
30        let sanitized = self.sanitize_path(path);
31        self.config.root_path.join(sanitized)
32    }
33    
34    /// Sanitize a path to prevent directory traversal attacks
35    fn sanitize_path(&self, path: &str) -> PathBuf {
36        let path = path.trim_start_matches('/');
37        let components: Vec<&str> = path
38            .split('/')
39            .filter(|component| !component.is_empty() && *component != "." && *component != "..")
40            .collect();
41        
42        components.iter().collect()
43    }
44    
45    /// Ensure the parent directory exists
46    async fn ensure_parent_dir(&self, file_path: &Path) -> StorageResult<()> {
47        if !self.config.create_directories {
48            return Ok(());
49        }
50        
51        if let Some(parent) = file_path.parent() {
52            if !parent.exists() {
53                fs::create_dir_all(parent).await.map_err(|e| {
54                    StorageError::Io(std::io::Error::new(
55                        std::io::ErrorKind::PermissionDenied,
56                        format!("Failed to create directory {}: {}", parent.display(), e)
57                    ))
58                })?;
59                
60                #[cfg(unix)]
61                if let Some(permissions) = self.config.directory_permissions {
62                    use std::os::unix::fs::PermissionsExt;
63                    let perms = std::fs::Permissions::from_mode(permissions);
64                    std::fs::set_permissions(parent, perms).map_err(|e| {
65                        StorageError::Io(std::io::Error::new(
66                            std::io::ErrorKind::PermissionDenied,
67                            format!("Failed to set directory permissions: {}", e)
68                        ))
69                    })?;
70                }
71            }
72        }
73        Ok(())
74    }
75    
76    /// Set file permissions after creation
77    #[cfg(unix)]
78    async fn set_file_permissions(&self, file_path: &Path) -> StorageResult<()> {
79        if let Some(permissions) = self.config.file_permissions {
80            use std::os::unix::fs::PermissionsExt;
81            let perms = std::fs::Permissions::from_mode(permissions);
82            fs::set_permissions(file_path, perms).await.map_err(|e| {
83                StorageError::Io(std::io::Error::new(
84                    std::io::ErrorKind::PermissionDenied,
85                    format!("Failed to set file permissions: {}", e)
86                ))
87            })?;
88        }
89        Ok(())
90    }
91    
92    #[cfg(not(unix))]
93    async fn set_file_permissions(&self, _file_path: &Path) -> StorageResult<()> {
94        Ok(())
95    }
96    
97    /// Generate ETag for file (using file size and modification time)
98    async fn generate_etag(&self, file_path: &Path) -> StorageResult<String> {
99        let metadata = fs::metadata(file_path).await?;
100        let size = metadata.len();
101        let modified = metadata.modified()
102            .map_err(|e| StorageError::Io(e))?
103            .duration_since(std::time::UNIX_EPOCH)
104            .map_err(|e| StorageError::Backend(format!("Time error: {}", e)))?
105            .as_secs();
106        
107        Ok(format!("{}-{}", size, modified))
108    }
109}
110
111#[async_trait]
112impl StorageBackend for LocalBackend {
113    async fn put(&self, path: &str, data: &[u8], options: Option<UploadOptions>) -> StorageResult<FileMetadata> {
114        let file_path = self.full_path(path);
115        
116        // Check if file exists and overwrite is not allowed
117        if let Some(opts) = &options {
118            if !opts.overwrite && file_path.exists() {
119                return Err(StorageError::Backend(format!("File already exists: {}", path)));
120            }
121        }
122        
123        // Ensure parent directory exists
124        self.ensure_parent_dir(&file_path).await?;
125        
126        // Write the file
127        fs::write(&file_path, data).await?;
128        
129        // Set file permissions
130        self.set_file_permissions(&file_path).await?;
131        
132        // Generate metadata
133        let content_type = options
134            .as_ref()
135            .and_then(|o| o.content_type.clone())
136            .unwrap_or_else(|| crate::detect_content_type(path, data));
137        
138        let etag = self.generate_etag(&file_path).await?;
139        let now = Utc::now();
140        
141        let metadata = FileMetadata {
142            path: path.to_string(),
143            size: data.len() as u64,
144            content_type,
145            created_at: now,
146            modified_at: now,
147            etag: Some(etag),
148            metadata: options
149                .as_ref()
150                .map(|o| o.metadata.clone())
151                .unwrap_or_default(),
152            #[cfg(feature = "access-control")]
153            permissions: options.as_ref().and_then(|o| o.permissions.clone()),
154        };
155        
156        // Store extended metadata in a sidecar file if we have custom metadata
157        if !metadata.metadata.is_empty() {
158            let metadata_path = format!("{}.metadata", file_path.display());
159            let metadata_json = serde_json::to_string(&metadata.metadata)
160                .map_err(|e| StorageError::Backend(format!("Failed to serialize metadata: {}", e)))?;
161            fs::write(&metadata_path, metadata_json).await?;
162        }
163        
164        Ok(metadata)
165    }
166    
167    async fn put_stream<S>(&self, path: &str, mut stream: S, options: Option<UploadOptions>) -> StorageResult<FileMetadata>
168    where
169        S: Stream<Item = Result<Bytes, std::io::Error>> + Send + Unpin,
170    {
171        let file_path = self.full_path(path);
172        
173        // Check if file exists and overwrite is not allowed
174        if let Some(opts) = &options {
175            if !opts.overwrite && file_path.exists() {
176                return Err(StorageError::Backend(format!("File already exists: {}", path)));
177            }
178        }
179        
180        // Ensure parent directory exists
181        self.ensure_parent_dir(&file_path).await?;
182        
183        // Create file and write stream to it
184        let mut file = fs::File::create(&file_path).await?;
185        let mut total_size = 0u64;
186        
187        while let Some(chunk) = stream.next().await {
188            let chunk = chunk?;
189            file.write_all(&chunk).await?;
190            total_size += chunk.len() as u64;
191        }
192        
193        file.flush().await?;
194        drop(file);
195        
196        // Set file permissions
197        self.set_file_permissions(&file_path).await?;
198        
199        // Generate metadata
200        let content_type = options
201            .as_ref()
202            .and_then(|o| o.content_type.clone())
203            .unwrap_or_else(|| {
204                // For streams, we can't analyze content, so detect from extension
205                crate::detect_content_type(path, &[])
206            });
207        
208        let etag = self.generate_etag(&file_path).await?;
209        let now = Utc::now();
210        
211        let metadata = FileMetadata {
212            path: path.to_string(),
213            size: total_size,
214            content_type,
215            created_at: now,
216            modified_at: now,
217            etag: Some(etag),
218            metadata: options
219                .as_ref()
220                .map(|o| o.metadata.clone())
221                .unwrap_or_default(),
222            #[cfg(feature = "access-control")]
223            permissions: options.as_ref().and_then(|o| o.permissions.clone()),
224        };
225        
226        // Store extended metadata if needed
227        if !metadata.metadata.is_empty() {
228            let metadata_path = format!("{}.metadata", file_path.display());
229            let metadata_json = serde_json::to_string(&metadata.metadata)
230                .map_err(|e| StorageError::Backend(format!("Failed to serialize metadata: {}", e)))?;
231            fs::write(&metadata_path, metadata_json).await?;
232        }
233        
234        Ok(metadata)
235    }
236    
237    async fn get(&self, path: &str) -> StorageResult<Option<Bytes>> {
238        let file_path = self.full_path(path);
239        
240        if !file_path.exists() {
241            return Ok(None);
242        }
243        
244        let data = fs::read(&file_path).await?;
245        Ok(Some(Bytes::from(data)))
246    }
247    
248    async fn get_stream(&self, path: &str) -> StorageResult<Option<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send + Unpin>>> {
249        let file_path = self.full_path(path);
250        
251        if !file_path.exists() {
252            return Ok(None);
253        }
254        
255        let file = fs::File::open(&file_path).await?;
256        let stream = tokio_util::io::ReaderStream::new(file);
257        let byte_stream = stream.map(|result| result.map(Bytes::from));
258        
259        Ok(Some(Box::new(byte_stream)))
260    }
261    
262    async fn exists(&self, path: &str) -> StorageResult<bool> {
263        let file_path = self.full_path(path);
264        Ok(file_path.exists())
265    }
266    
267    async fn metadata(&self, path: &str) -> StorageResult<Option<FileMetadata>> {
268        let file_path = self.full_path(path);
269        
270        if !file_path.exists() {
271            return Ok(None);
272        }
273        
274        let fs_metadata = fs::metadata(&file_path).await?;
275        let size = fs_metadata.len();
276        
277        let created_at = fs_metadata.created()
278            .map_err(|e| StorageError::Io(e))?
279            .into();
280        
281        let modified_at = fs_metadata.modified()
282            .map_err(|e| StorageError::Io(e))?
283            .into();
284        
285        let etag = self.generate_etag(&file_path).await?;
286        
287        // Try to load extended metadata
288        let metadata_path = format!("{}.metadata", file_path.display());
289        let custom_metadata = if Path::new(&metadata_path).exists() {
290            let metadata_json = fs::read_to_string(&metadata_path).await?;
291            serde_json::from_str::<HashMap<String, String>>(&metadata_json)
292                .unwrap_or_default()
293        } else {
294            HashMap::new()
295        };
296        
297        // Detect content type
298        let content_type = if size <= 1024 * 1024 {
299            // For small files, read a bit to detect content type
300            let sample = fs::read(&file_path).await.unwrap_or_default();
301            crate::detect_content_type(path, &sample)
302        } else {
303            // For large files, just use extension
304            crate::detect_content_type(path, &[])
305        };
306        
307        let file_metadata = FileMetadata {
308            path: path.to_string(),
309            size,
310            content_type,
311            created_at,
312            modified_at,
313            etag: Some(etag),
314            metadata: custom_metadata,
315            #[cfg(feature = "access-control")]
316            permissions: None, // Would load from metadata if implemented
317        };
318        
319        Ok(Some(file_metadata))
320    }
321    
322    async fn delete(&self, path: &str) -> StorageResult<bool> {
323        let file_path = self.full_path(path);
324        
325        if !file_path.exists() {
326            return Ok(false);
327        }
328        
329        fs::remove_file(&file_path).await?;
330        
331        // Also remove metadata file if it exists
332        let metadata_path = format!("{}.metadata", file_path.display());
333        if Path::new(&metadata_path).exists() {
334            let _ = fs::remove_file(&metadata_path).await;
335        }
336        
337        Ok(true)
338    }
339    
340    async fn list(&self, prefix: Option<&str>, limit: Option<u32>) -> StorageResult<Vec<FileMetadata>> {
341        let search_path = if let Some(prefix) = prefix {
342            self.full_path(prefix)
343        } else {
344            self.config.root_path.clone()
345        };
346        
347        let mut files = Vec::new();
348        let limit = limit.unwrap_or(1000) as usize;
349        
350        fn collect_files<'a>(
351            dir: &'a Path,
352            root: &'a Path,
353            files: &'a mut Vec<FileMetadata>,
354            limit: usize,
355        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = StorageResult<()>> + Send + 'a>> {
356            Box::pin(async move {
357                if files.len() >= limit {
358                    return Ok(());
359                }
360                
361                let mut entries = fs::read_dir(dir).await?;
362                while let Some(entry) = entries.next_entry().await? {
363                    if files.len() >= limit {
364                        break;
365                    }
366                    
367                    let path = entry.path();
368                    
369                    if path.is_file() {
370                        // Skip metadata files
371                        if path.extension().and_then(|e| e.to_str()) == Some("metadata") {
372                            continue;
373                        }
374                        
375                        let relative_path = path.strip_prefix(root)
376                            .map_err(|e| StorageError::Backend(format!("Path error: {}", e)))?;
377                        
378                        let relative_str = relative_path.to_string_lossy().replace('\\', "/");
379                        
380                        // Get file metadata
381                        if let Ok(Some(metadata)) = LocalBackend::metadata_for_path(&path, &relative_str).await {
382                            files.push(metadata);
383                        }
384                    } else if path.is_dir() {
385                        collect_files(&path, root, files, limit).await?;
386                    }
387                }
388                
389                Ok(())
390            })
391        }
392        
393        collect_files(&search_path, &self.config.root_path, &mut files, limit).await?;
394        
395        Ok(files)
396    }
397    
398    async fn copy(&self, from: &str, to: &str, options: Option<UploadOptions>) -> StorageResult<FileMetadata> {
399        let from_path = self.full_path(from);
400        let to_path = self.full_path(to);
401        
402        if !from_path.exists() {
403            return Err(StorageError::FileNotFound(from.to_string()));
404        }
405        
406        // Check if destination exists and overwrite is not allowed
407        if let Some(opts) = &options {
408            if !opts.overwrite && to_path.exists() {
409                return Err(StorageError::Backend(format!("File already exists: {}", to)));
410            }
411        }
412        
413        // Ensure parent directory exists
414        self.ensure_parent_dir(&to_path).await?;
415        
416        // Copy the file
417        fs::copy(&from_path, &to_path).await?;
418        
419        // Set file permissions
420        self.set_file_permissions(&to_path).await?;
421        
422        // Copy metadata file if it exists
423        let from_metadata_path = format!("{}.metadata", from_path.display());
424        let to_metadata_path = format!("{}.metadata", to_path.display());
425        
426        if Path::new(&from_metadata_path).exists() {
427            let _ = fs::copy(&from_metadata_path, &to_metadata_path).await;
428        }
429        
430        // Generate new metadata for the copied file
431        self.metadata(to).await?
432            .ok_or_else(|| StorageError::Backend("Failed to get metadata for copied file".to_string()))
433    }
434    
435    async fn move_file(&self, from: &str, to: &str, options: Option<UploadOptions>) -> StorageResult<FileMetadata> {
436        let from_path = self.full_path(from);
437        let to_path = self.full_path(to);
438        
439        if !from_path.exists() {
440            return Err(StorageError::FileNotFound(from.to_string()));
441        }
442        
443        // Check if destination exists and overwrite is not allowed
444        if let Some(opts) = &options {
445            if !opts.overwrite && to_path.exists() {
446                return Err(StorageError::Backend(format!("File already exists: {}", to)));
447            }
448        }
449        
450        // Ensure parent directory exists
451        self.ensure_parent_dir(&to_path).await?;
452        
453        // Move the file
454        fs::rename(&from_path, &to_path).await?;
455        
456        // Move metadata file if it exists
457        let from_metadata_path = format!("{}.metadata", from_path.display());
458        let to_metadata_path = format!("{}.metadata", to_path.display());
459        
460        if Path::new(&from_metadata_path).exists() {
461            let _ = fs::rename(&from_metadata_path, &to_metadata_path).await;
462        }
463        
464        // Generate new metadata for the moved file
465        self.metadata(to).await?
466            .ok_or_else(|| StorageError::Backend("Failed to get metadata for moved file".to_string()))
467    }
468    
469    async fn signed_url(&self, path: &str, _expires_in: Duration) -> StorageResult<String> {
470        // Local storage doesn't support signed URLs, return file:// URL
471        let file_path = self.full_path(path);
472        if !file_path.exists() {
473            return Err(StorageError::FileNotFound(path.to_string()));
474        }
475        
476        Ok(format!("file://{}", file_path.display()))
477    }
478    
479    async fn public_url(&self, path: &str) -> StorageResult<String> {
480        // Local storage doesn't have public URLs, return file:// URL
481        let file_path = self.full_path(path);
482        if !file_path.exists() {
483            return Err(StorageError::FileNotFound(path.to_string()));
484        }
485        
486        Ok(format!("file://{}", file_path.display()))
487    }
488    
489    async fn stats(&self) -> StorageResult<StorageStats> {
490        let mut total_files = 0u64;
491        let mut total_size = 0u64;
492        
493        fn collect_stats<'a>(dir: &'a Path, stats: &'a mut (u64, u64)) -> std::pin::Pin<Box<dyn std::future::Future<Output = StorageResult<()>> + Send + 'a>> {
494            Box::pin(async move {
495                let mut entries = fs::read_dir(dir).await?;
496                while let Some(entry) = entries.next_entry().await? {
497                    let path = entry.path();
498                    
499                    if path.is_file() {
500                        // Skip metadata files
501                        if path.extension().and_then(|e| e.to_str()) == Some("metadata") {
502                            continue;
503                        }
504                        
505                        let metadata = fs::metadata(&path).await?;
506                        stats.0 += 1;
507                        stats.1 += metadata.len();
508                    } else if path.is_dir() {
509                        collect_stats(&path, stats).await?;
510                    }
511                }
512                
513                Ok(())
514            })
515        }
516        
517        let mut stats = (0u64, 0u64);
518        collect_stats(&self.config.root_path, &mut stats).await?;
519        
520        // Try to get filesystem info
521        let (available_space, used_space) = if let Ok(metadata) = fs::metadata(&self.config.root_path).await {
522            // This is platform-specific and simplified
523            (None, None)
524        } else {
525            (None, None)
526        };
527        
528        Ok(StorageStats {
529            total_files: stats.0,
530            total_size: stats.1,
531            available_space,
532            used_space,
533        })
534    }
535}
536
537impl LocalBackend {
538    /// Helper method to generate metadata for a file path
539    async fn metadata_for_path(file_path: &Path, relative_path: &str) -> StorageResult<Option<FileMetadata>> {
540        if !file_path.exists() {
541            return Ok(None);
542        }
543        
544        let fs_metadata = fs::metadata(file_path).await?;
545        let size = fs_metadata.len();
546        
547        let created_at = fs_metadata.created()
548            .map_err(|e| StorageError::Io(e))?
549            .into();
550        
551        let modified_at = fs_metadata.modified()
552            .map_err(|e| StorageError::Io(e))?
553            .into();
554        
555        // Generate ETag
556        let modified_timestamp = fs_metadata.modified()
557            .map_err(|e| StorageError::Io(e))?
558            .duration_since(std::time::UNIX_EPOCH)
559            .map_err(|e| StorageError::Backend(format!("Time error: {}", e)))?
560            .as_secs();
561        let etag = format!("{}-{}", size, modified_timestamp);
562        
563        // Try to load extended metadata
564        let metadata_path = format!("{}.metadata", file_path.display());
565        let custom_metadata = if Path::new(&metadata_path).exists() {
566            let metadata_json = fs::read_to_string(&metadata_path).await?;
567            serde_json::from_str::<HashMap<String, String>>(&metadata_json)
568                .unwrap_or_default()
569        } else {
570            HashMap::new()
571        };
572        
573        // Detect content type
574        let content_type = crate::detect_content_type(relative_path, &[]);
575        
576        let file_metadata = FileMetadata {
577            path: relative_path.to_string(),
578            size,
579            content_type,
580            created_at,
581            modified_at,
582            etag: Some(etag),
583            metadata: custom_metadata,
584            #[cfg(feature = "access-control")]
585            permissions: None,
586        };
587        
588        Ok(Some(file_metadata))
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use tempfile::tempdir;
596    
597    async fn create_test_backend() -> (LocalBackend, tempfile::TempDir) {
598        let temp_dir = tempdir().unwrap();
599        let config = LocalStorageConfig::new()
600            .with_root_path(temp_dir.path().to_path_buf());
601        let backend = LocalBackend::new(config);
602        (backend, temp_dir)
603    }
604    
605    #[tokio::test]
606    async fn test_put_and_get() {
607        let (backend, _temp_dir) = create_test_backend().await;
608        
609        let data = b"Hello, World!";
610        let metadata = backend.put("test.txt", data, None).await.unwrap();
611        
612        assert_eq!(metadata.path, "test.txt");
613        assert_eq!(metadata.size, data.len() as u64);
614        assert_eq!(metadata.content_type, "text/plain");
615        
616        let retrieved = backend.get("test.txt").await.unwrap().unwrap();
617        assert_eq!(retrieved.as_ref(), data);
618    }
619    
620    #[tokio::test]
621    async fn test_exists_and_delete() {
622        let (backend, _temp_dir) = create_test_backend().await;
623        
624        let data = b"Test data";
625        backend.put("test.txt", data, None).await.unwrap();
626        
627        assert!(backend.exists("test.txt").await.unwrap());
628        assert!(!backend.exists("nonexistent.txt").await.unwrap());
629        
630        assert!(backend.delete("test.txt").await.unwrap());
631        assert!(!backend.exists("test.txt").await.unwrap());
632        assert!(!backend.delete("nonexistent.txt").await.unwrap());
633    }
634    
635    #[tokio::test]
636    async fn test_metadata() {
637        let (backend, _temp_dir) = create_test_backend().await;
638        
639        let data = b"Test metadata";
640        let options = UploadOptions::new()
641            .content_type("text/custom".to_string())
642            .metadata("key1".to_string(), "value1".to_string())
643            .metadata("key2".to_string(), "value2".to_string());
644        
645        backend.put("test.txt", data, Some(options)).await.unwrap();
646        
647        let metadata = backend.metadata("test.txt").await.unwrap().unwrap();
648        assert_eq!(metadata.path, "test.txt");
649        assert_eq!(metadata.size, data.len() as u64);
650        assert!(metadata.etag.is_some());
651        assert_eq!(metadata.metadata.get("key1"), Some(&"value1".to_string()));
652        assert_eq!(metadata.metadata.get("key2"), Some(&"value2".to_string()));
653    }
654    
655    #[tokio::test]
656    async fn test_copy_and_move() {
657        let (backend, _temp_dir) = create_test_backend().await;
658        
659        let data = b"Test copy/move";
660        backend.put("original.txt", data, None).await.unwrap();
661        
662        // Test copy
663        backend.copy("original.txt", "copy.txt", None).await.unwrap();
664        assert!(backend.exists("original.txt").await.unwrap());
665        assert!(backend.exists("copy.txt").await.unwrap());
666        
667        let copied_data = backend.get("copy.txt").await.unwrap().unwrap();
668        assert_eq!(copied_data.as_ref(), data);
669        
670        // Test move
671        backend.move_file("original.txt", "moved.txt", None).await.unwrap();
672        assert!(!backend.exists("original.txt").await.unwrap());
673        assert!(backend.exists("moved.txt").await.unwrap());
674        
675        let moved_data = backend.get("moved.txt").await.unwrap().unwrap();
676        assert_eq!(moved_data.as_ref(), data);
677    }
678    
679    #[tokio::test]
680    async fn test_list() {
681        let (backend, _temp_dir) = create_test_backend().await;
682        
683        // Create some test files
684        backend.put("dir1/file1.txt", b"content1", None).await.unwrap();
685        backend.put("dir1/file2.txt", b"content2", None).await.unwrap();
686        backend.put("dir2/file3.txt", b"content3", None).await.unwrap();
687        
688        // List all files
689        let files = backend.list(None, None).await.unwrap();
690        assert_eq!(files.len(), 3);
691        
692        // Check file paths
693        let paths: Vec<String> = files.iter().map(|f| f.path.clone()).collect();
694        assert!(paths.contains(&"dir1/file1.txt".to_string()));
695        assert!(paths.contains(&"dir1/file2.txt".to_string()));
696        assert!(paths.contains(&"dir2/file3.txt".to_string()));
697    }
698    
699    #[tokio::test]
700    async fn test_sanitize_path() {
701        let (backend, _temp_dir) = create_test_backend().await;
702        
703        // Test directory traversal prevention
704        assert_eq!(backend.sanitize_path("../../../etc/passwd"), PathBuf::from("etc/passwd"));
705        assert_eq!(backend.sanitize_path("./test/../file.txt"), PathBuf::from("test/file.txt"));
706        assert_eq!(backend.sanitize_path("normal/path/file.txt"), PathBuf::from("normal/path/file.txt"));
707        assert_eq!(backend.sanitize_path("/absolute/path"), PathBuf::from("absolute/path"));
708    }
709}