mockforge_ftp/
storage.rs

1use crate::spec_registry::FtpSpecRegistry;
2use crate::vfs::{VirtualFile, VirtualFileSystem};
3use async_trait::async_trait;
4use libunftp::storage::Result;
5use libunftp::storage::{Error, ErrorKind, Fileinfo, Metadata, StorageBackend};
6use std::fmt::Debug;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::SystemTime;
10
11/// Custom storage backend for libunftp that integrates with MockForge's VFS
12#[derive(Debug, Clone)]
13pub struct MockForgeStorage {
14    vfs: Arc<VirtualFileSystem>,
15    spec_registry: Arc<FtpSpecRegistry>,
16}
17
18impl MockForgeStorage {
19    pub fn new(vfs: Arc<VirtualFileSystem>, spec_registry: Arc<FtpSpecRegistry>) -> Self {
20        Self { vfs, spec_registry }
21    }
22}
23
24#[async_trait]
25impl<U: libunftp::auth::UserDetail + Send + Sync + 'static> StorageBackend<U> for MockForgeStorage {
26    type Metadata = MockForgeMetadata;
27
28    async fn metadata<P: AsRef<Path> + Send + Debug>(
29        &self,
30        _user: &U,
31        path: P,
32    ) -> Result<Self::Metadata> {
33        let path = path.as_ref();
34
35        if let Some(file) = self.vfs.get_file(path) {
36            Ok(MockForgeMetadata {
37                file: Some(file),
38                is_dir: false,
39            })
40        } else {
41            // Check if it's a directory (for now, assume root is a directory)
42            if path == Path::new("/") || path == Path::new("") {
43                Ok(MockForgeMetadata {
44                    file: None,
45                    is_dir: true,
46                })
47            } else {
48                Err(Error::from(ErrorKind::PermanentFileNotAvailable))
49            }
50        }
51    }
52
53    async fn list<P: AsRef<Path> + Send + Debug>(
54        &self,
55        _user: &U,
56        path: P,
57    ) -> Result<Vec<Fileinfo<PathBuf, Self::Metadata>>> {
58        let path = path.as_ref();
59        let files = self.vfs.list_files(path);
60
61        let mut result = Vec::new();
62        for file in files {
63            result.push(Fileinfo {
64                path: file.path.clone(),
65                metadata: MockForgeMetadata {
66                    file: Some(file),
67                    is_dir: false,
68                },
69            });
70        }
71
72        Ok(result)
73    }
74
75    async fn get<P: AsRef<Path> + Send + Debug>(
76        &self,
77        _user: &U,
78        path: P,
79        _start_pos: u64,
80    ) -> Result<Box<dyn tokio::io::AsyncRead + Send + Sync + Unpin>> {
81        let path = path.as_ref();
82
83        if let Some(file) = self.vfs.get_file(path) {
84            let content =
85                file.render_content().map_err(|e| Error::new(ErrorKind::LocalError, e))?;
86            Ok(Box::new(std::io::Cursor::new(content)))
87        } else {
88            Err(Error::from(ErrorKind::PermanentFileNotAvailable))
89        }
90    }
91
92    async fn put<
93        P: AsRef<Path> + Send + Debug,
94        R: tokio::io::AsyncRead + Send + Sync + Unpin + 'static,
95    >(
96        &self,
97        _user: &U,
98        bytes: R,
99        path: P,
100        _start_pos: u64,
101    ) -> Result<u64> {
102        let path = path.as_ref();
103        let path_str = path.to_string_lossy().to_string();
104
105        // Read all data
106        use tokio::io::AsyncReadExt;
107        let mut data = Vec::new();
108        let mut reader = bytes;
109        reader
110            .read_to_end(&mut data)
111            .await
112            .map_err(|e| Error::new(ErrorKind::LocalError, e))?;
113
114        // Check upload rules
115        if let Some(rule) = self.spec_registry.find_upload_rule(&path_str) {
116            // Validate the upload
117            rule.validate_file(&data, &path_str)
118                .map_err(|e| Error::new(ErrorKind::PermissionDenied, e))?;
119
120            if rule.auto_accept {
121                // Store the file
122                let file = VirtualFile::new(
123                    path.to_path_buf(),
124                    crate::vfs::FileContent::Static(data.clone()),
125                    crate::vfs::FileMetadata {
126                        size: data.len() as u64,
127                        ..Default::default()
128                    },
129                );
130
131                self.vfs
132                    .add_file(path.to_path_buf(), file)
133                    .map_err(|e| Error::new(ErrorKind::LocalError, e))?;
134
135                // Record the upload
136                let rule_name = Some(rule.path_pattern.clone());
137                self.spec_registry
138                    .record_upload(path.to_path_buf(), data.len() as u64, rule_name)
139                    .map_err(|e| Error::new(ErrorKind::LocalError, e))?;
140
141                Ok(data.len() as u64)
142            } else {
143                Err(Error::new(ErrorKind::PermissionDenied, "Upload rejected by rule"))
144            }
145        } else {
146            Err(Error::new(ErrorKind::PermissionDenied, "No upload rule matches this path"))
147        }
148    }
149
150    async fn del<P: AsRef<Path> + Send + Debug>(&self, _user: &U, path: P) -> Result<()> {
151        let path = path.as_ref();
152
153        if self.vfs.get_file(path).is_some() {
154            self.vfs.remove_file(path).map_err(|e| Error::new(ErrorKind::LocalError, e))?;
155            Ok(())
156        } else {
157            Err(Error::from(ErrorKind::PermanentFileNotAvailable))
158        }
159    }
160
161    async fn mkd<P: AsRef<Path> + Send + Debug>(&self, _user: &U, _path: P) -> Result<()> {
162        // For now, directories are not supported in VFS
163        Err(Error::new(ErrorKind::PermissionDenied, "Directory creation not supported"))
164    }
165
166    async fn rename<P: AsRef<Path> + Send + Debug>(
167        &self,
168        _user: &U,
169        _from: P,
170        _to: P,
171    ) -> Result<()> {
172        // For now, renaming is not supported
173        Err(Error::new(ErrorKind::PermissionDenied, "Rename not supported"))
174    }
175
176    async fn rmd<P: AsRef<Path> + Send + Debug>(&self, _user: &U, _path: P) -> Result<()> {
177        // For now, directory removal is not supported
178        Err(Error::new(ErrorKind::PermissionDenied, "Directory removal not supported"))
179    }
180
181    async fn cwd<P: AsRef<Path> + Send + Debug>(&self, _user: &U, _path: P) -> Result<()> {
182        // For now, directory changes are not supported
183        Err(Error::new(ErrorKind::PermissionDenied, "Directory changes not supported"))
184    }
185}
186
187/// Metadata implementation for MockForge storage
188#[derive(Debug, Clone)]
189pub struct MockForgeMetadata {
190    file: Option<VirtualFile>,
191    is_dir: bool,
192}
193
194impl Metadata for MockForgeMetadata {
195    fn len(&self) -> u64 {
196        if let Some(file) = &self.file {
197            file.metadata.size
198        } else {
199            0
200        }
201    }
202
203    fn is_dir(&self) -> bool {
204        self.is_dir
205    }
206
207    fn is_file(&self) -> bool {
208        self.file.is_some()
209    }
210
211    fn is_symlink(&self) -> bool {
212        false
213    }
214
215    fn modified(&self) -> Result<SystemTime> {
216        if let Some(file) = &self.file {
217            // Convert DateTime to SystemTime (approximate)
218            Ok(SystemTime::UNIX_EPOCH
219                + std::time::Duration::from_secs(file.modified_at.timestamp() as u64))
220        } else {
221            Ok(SystemTime::now())
222        }
223    }
224
225    fn gid(&self) -> u32 {
226        1000 // Default GID
227    }
228
229    fn uid(&self) -> u32 {
230        1000 // Default UID
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::fixtures::{
238        FileContentConfig, FileValidation, FtpFixture, UploadRule, UploadStorage, VirtualFileConfig,
239    };
240    use crate::vfs::{FileContent, FileMetadata};
241
242    #[test]
243    fn test_mockforge_storage_new() {
244        let vfs = Arc::new(VirtualFileSystem::new(std::path::PathBuf::from("/")));
245        let spec_registry = Arc::new(FtpSpecRegistry::new());
246        let storage = MockForgeStorage::new(vfs.clone(), spec_registry.clone());
247
248        let debug = format!("{:?}", storage);
249        assert!(debug.contains("MockForgeStorage"));
250    }
251
252    #[test]
253    fn test_mockforge_storage_clone() {
254        let vfs = Arc::new(VirtualFileSystem::new(std::path::PathBuf::from("/")));
255        let spec_registry = Arc::new(FtpSpecRegistry::new());
256        let storage = MockForgeStorage::new(vfs.clone(), spec_registry.clone());
257
258        let _cloned = storage.clone();
259        // Just verify it can be cloned
260    }
261
262    #[test]
263    fn test_mockforge_metadata_len_with_file() {
264        let file = VirtualFile::new(
265            std::path::PathBuf::from("/test.txt"),
266            FileContent::Static(b"test content".to_vec()),
267            FileMetadata {
268                size: 1024,
269                ..Default::default()
270            },
271        );
272
273        let metadata = MockForgeMetadata {
274            file: Some(file),
275            is_dir: false,
276        };
277
278        assert_eq!(metadata.len(), 1024);
279    }
280
281    #[test]
282    fn test_mockforge_metadata_len_without_file() {
283        let metadata = MockForgeMetadata {
284            file: None,
285            is_dir: true,
286        };
287
288        assert_eq!(metadata.len(), 0);
289    }
290
291    #[test]
292    fn test_mockforge_metadata_is_dir() {
293        let metadata = MockForgeMetadata {
294            file: None,
295            is_dir: true,
296        };
297
298        assert!(metadata.is_dir());
299        assert!(!metadata.is_file());
300    }
301
302    #[test]
303    fn test_mockforge_metadata_is_file() {
304        let file = VirtualFile::new(
305            std::path::PathBuf::from("/test.txt"),
306            FileContent::Static(vec![]),
307            FileMetadata::default(),
308        );
309
310        let metadata = MockForgeMetadata {
311            file: Some(file),
312            is_dir: false,
313        };
314
315        assert!(metadata.is_file());
316        assert!(!metadata.is_dir());
317    }
318
319    #[test]
320    fn test_mockforge_metadata_is_symlink() {
321        let metadata = MockForgeMetadata {
322            file: None,
323            is_dir: false,
324        };
325
326        assert!(!metadata.is_symlink());
327    }
328
329    #[test]
330    fn test_mockforge_metadata_modified() {
331        let file = VirtualFile::new(
332            std::path::PathBuf::from("/test.txt"),
333            FileContent::Static(vec![]),
334            FileMetadata::default(),
335        );
336
337        let metadata = MockForgeMetadata {
338            file: Some(file),
339            is_dir: false,
340        };
341
342        let modified = metadata.modified();
343        assert!(modified.is_ok());
344    }
345
346    #[test]
347    fn test_mockforge_metadata_modified_no_file() {
348        let metadata = MockForgeMetadata {
349            file: None,
350            is_dir: true,
351        };
352
353        let modified = metadata.modified();
354        assert!(modified.is_ok());
355    }
356
357    #[test]
358    fn test_mockforge_metadata_gid() {
359        let metadata = MockForgeMetadata {
360            file: None,
361            is_dir: false,
362        };
363
364        assert_eq!(metadata.gid(), 1000);
365    }
366
367    #[test]
368    fn test_mockforge_metadata_uid() {
369        let metadata = MockForgeMetadata {
370            file: None,
371            is_dir: false,
372        };
373
374        assert_eq!(metadata.uid(), 1000);
375    }
376
377    #[test]
378    fn test_mockforge_metadata_clone() {
379        let file = VirtualFile::new(
380            std::path::PathBuf::from("/test.txt"),
381            FileContent::Static(vec![]),
382            FileMetadata::default(),
383        );
384
385        let metadata = MockForgeMetadata {
386            file: Some(file),
387            is_dir: false,
388        };
389
390        let _cloned = metadata.clone();
391        // Just verify it can be cloned
392    }
393
394    #[test]
395    fn test_mockforge_metadata_debug() {
396        let metadata = MockForgeMetadata {
397            file: None,
398            is_dir: true,
399        };
400
401        let debug = format!("{:?}", metadata);
402        assert!(debug.contains("MockForgeMetadata"));
403    }
404
405    // Note: The async methods of StorageBackend trait are difficult to test without
406    // a full async runtime and mock user details. The tests above cover the synchronous
407    // parts and the metadata implementation which is testable without network I/O.
408}