Skip to main content

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