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(path) {
36            Ok(MockForgeMetadata {
37                file: Some(file),
38                is_dir: false,
39            })
40        } else if self.vfs.directory_exists(path) {
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(path);
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(path) {
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(path.to_path_buf(), file)
130                    .map_err(|e| Error::new(ErrorKind::LocalError, e))?;
131
132                // Record the upload
133                let rule_name = Some(rule.path_pattern.clone());
134                self.spec_registry
135                    .record_upload(path.to_path_buf(), data.len() as u64, rule_name)
136                    .map_err(|e| Error::new(ErrorKind::LocalError, e))?;
137
138                Ok(data.len() as u64)
139            } else {
140                Err(Error::new(ErrorKind::PermissionDenied, "Upload rejected by rule"))
141            }
142        } else {
143            Err(Error::new(ErrorKind::PermissionDenied, "No upload rule matches this path"))
144        }
145    }
146
147    async fn del<P: AsRef<Path> + Send + Debug>(&self, _user: &U, path: P) -> Result<()> {
148        let path = path.as_ref();
149
150        if self.vfs.get_file(path).is_some() {
151            self.vfs.remove_file(path).map_err(|e| Error::new(ErrorKind::LocalError, e))?;
152            Ok(())
153        } else {
154            Err(Error::from(ErrorKind::PermanentFileNotAvailable))
155        }
156    }
157
158    async fn mkd<P: AsRef<Path> + Send + Debug>(&self, _user: &U, path: P) -> Result<()> {
159        let path = path.as_ref();
160        if self.vfs.directory_exists(path) {
161            return Err(Error::new(
162                ErrorKind::PermanentFileNotAvailable,
163                "Directory already exists",
164            ));
165        }
166        self.vfs
167            .create_directory(path.to_path_buf())
168            .map_err(|e| Error::new(ErrorKind::LocalError, e))
169    }
170
171    async fn rename<P: AsRef<Path> + Send + Debug>(&self, _user: &U, from: P, to: P) -> Result<()> {
172        let from = from.as_ref();
173        let to = to.as_ref();
174
175        if let Some(file) = self.vfs.get_file(from) {
176            self.vfs.remove_file(from).map_err(|e| Error::new(ErrorKind::LocalError, e))?;
177            self.vfs
178                .add_file(
179                    to.to_path_buf(),
180                    VirtualFile::new(to.to_path_buf(), file.content, file.metadata),
181                )
182                .map_err(|e| Error::new(ErrorKind::LocalError, e))
183        } else {
184            Err(Error::from(ErrorKind::PermanentFileNotAvailable))
185        }
186    }
187
188    async fn rmd<P: AsRef<Path> + Send + Debug>(&self, _user: &U, path: P) -> Result<()> {
189        let path = path.as_ref();
190        if !self.vfs.directory_exists(path) {
191            return Err(Error::from(ErrorKind::PermanentFileNotAvailable));
192        }
193        self.vfs
194            .remove_directory(path)
195            .map_err(|e| Error::new(ErrorKind::PermissionDenied, e))
196    }
197
198    async fn cwd<P: AsRef<Path> + Send + Debug>(&self, _user: &U, path: P) -> Result<()> {
199        let path = path.as_ref();
200        if self.vfs.directory_exists(path) {
201            Ok(())
202        } else {
203            Err(Error::from(ErrorKind::PermanentFileNotAvailable))
204        }
205    }
206}
207
208/// Metadata implementation for MockForge storage
209#[derive(Debug, Clone)]
210pub struct MockForgeMetadata {
211    file: Option<VirtualFile>,
212    is_dir: bool,
213}
214
215impl Metadata for MockForgeMetadata {
216    fn len(&self) -> u64 {
217        if let Some(file) = &self.file {
218            file.metadata.size
219        } else {
220            0
221        }
222    }
223
224    fn is_dir(&self) -> bool {
225        self.is_dir
226    }
227
228    fn is_file(&self) -> bool {
229        self.file.is_some()
230    }
231
232    fn is_symlink(&self) -> bool {
233        false
234    }
235
236    fn modified(&self) -> Result<SystemTime> {
237        if let Some(file) = &self.file {
238            // Convert DateTime to SystemTime (approximate)
239            Ok(SystemTime::UNIX_EPOCH
240                + std::time::Duration::from_secs(file.modified_at.timestamp() as u64))
241        } else {
242            Ok(SystemTime::now())
243        }
244    }
245
246    fn gid(&self) -> u32 {
247        1000 // Default GID
248    }
249
250    fn uid(&self) -> u32 {
251        1000 // Default UID
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::fixtures::{
259        FileContentConfig, FileValidation, FtpFixture, UploadRule, UploadStorage, VirtualFileConfig,
260    };
261    use crate::vfs::{FileContent, FileMetadata};
262
263    #[test]
264    fn test_mockforge_storage_new() {
265        let vfs = Arc::new(VirtualFileSystem::new(std::path::PathBuf::from("/")));
266        let spec_registry = Arc::new(FtpSpecRegistry::new());
267        let storage = MockForgeStorage::new(vfs.clone(), spec_registry.clone());
268
269        let debug = format!("{:?}", storage);
270        assert!(debug.contains("MockForgeStorage"));
271    }
272
273    #[test]
274    fn test_mockforge_storage_clone() {
275        let vfs = Arc::new(VirtualFileSystem::new(std::path::PathBuf::from("/")));
276        let spec_registry = Arc::new(FtpSpecRegistry::new());
277        let storage = MockForgeStorage::new(vfs.clone(), spec_registry.clone());
278
279        let _cloned = storage.clone();
280        // Just verify it can be cloned
281    }
282
283    #[test]
284    fn test_mockforge_metadata_len_with_file() {
285        let file = VirtualFile::new(
286            std::path::PathBuf::from("/test.txt"),
287            FileContent::Static(b"test content".to_vec()),
288            FileMetadata {
289                size: 1024,
290                ..Default::default()
291            },
292        );
293
294        let metadata = MockForgeMetadata {
295            file: Some(file),
296            is_dir: false,
297        };
298
299        assert_eq!(metadata.len(), 1024);
300    }
301
302    #[test]
303    fn test_mockforge_metadata_len_without_file() {
304        let metadata = MockForgeMetadata {
305            file: None,
306            is_dir: true,
307        };
308
309        assert_eq!(metadata.len(), 0);
310    }
311
312    #[test]
313    fn test_mockforge_metadata_is_dir() {
314        let metadata = MockForgeMetadata {
315            file: None,
316            is_dir: true,
317        };
318
319        assert!(metadata.is_dir());
320        assert!(!metadata.is_file());
321    }
322
323    #[test]
324    fn test_mockforge_metadata_is_file() {
325        let file = VirtualFile::new(
326            std::path::PathBuf::from("/test.txt"),
327            FileContent::Static(vec![]),
328            FileMetadata::default(),
329        );
330
331        let metadata = MockForgeMetadata {
332            file: Some(file),
333            is_dir: false,
334        };
335
336        assert!(metadata.is_file());
337        assert!(!metadata.is_dir());
338    }
339
340    #[test]
341    fn test_mockforge_metadata_is_symlink() {
342        let metadata = MockForgeMetadata {
343            file: None,
344            is_dir: false,
345        };
346
347        assert!(!metadata.is_symlink());
348    }
349
350    #[test]
351    fn test_mockforge_metadata_modified() {
352        let file = VirtualFile::new(
353            std::path::PathBuf::from("/test.txt"),
354            FileContent::Static(vec![]),
355            FileMetadata::default(),
356        );
357
358        let metadata = MockForgeMetadata {
359            file: Some(file),
360            is_dir: false,
361        };
362
363        let modified = metadata.modified();
364        assert!(modified.is_ok());
365    }
366
367    #[test]
368    fn test_mockforge_metadata_modified_no_file() {
369        let metadata = MockForgeMetadata {
370            file: None,
371            is_dir: true,
372        };
373
374        let modified = metadata.modified();
375        assert!(modified.is_ok());
376    }
377
378    #[test]
379    fn test_mockforge_metadata_gid() {
380        let metadata = MockForgeMetadata {
381            file: None,
382            is_dir: false,
383        };
384
385        assert_eq!(metadata.gid(), 1000);
386    }
387
388    #[test]
389    fn test_mockforge_metadata_uid() {
390        let metadata = MockForgeMetadata {
391            file: None,
392            is_dir: false,
393        };
394
395        assert_eq!(metadata.uid(), 1000);
396    }
397
398    #[test]
399    fn test_mockforge_metadata_clone() {
400        let file = VirtualFile::new(
401            std::path::PathBuf::from("/test.txt"),
402            FileContent::Static(vec![]),
403            FileMetadata::default(),
404        );
405
406        let metadata = MockForgeMetadata {
407            file: Some(file),
408            is_dir: false,
409        };
410
411        let _cloned = metadata.clone();
412        // Just verify it can be cloned
413    }
414
415    #[test]
416    fn test_mockforge_metadata_debug() {
417        let metadata = MockForgeMetadata {
418            file: None,
419            is_dir: true,
420        };
421
422        let debug = format!("{:?}", metadata);
423        assert!(debug.contains("MockForgeMetadata"));
424    }
425
426    // Note: The async methods of StorageBackend trait are difficult to test without
427    // a full async runtime and mock user details. The tests above cover the synchronous
428    // parts and the metadata implementation which is testable without network I/O.
429}