Skip to main content

mockforge_ftp/
server.rs

1use crate::spec_registry::FtpSpecRegistry;
2use crate::storage::MockForgeStorage;
3use crate::vfs::VirtualFileSystem;
4use anyhow::Result;
5use libunftp::ServerBuilder;
6use mockforge_core::config::FtpConfig;
7use std::sync::Arc;
8use tracing::info;
9
10/// FTP Server implementation for MockForge
11#[derive(Debug)]
12pub struct FtpServer {
13    config: FtpConfig,
14    vfs: Arc<VirtualFileSystem>,
15    spec_registry: Arc<FtpSpecRegistry>,
16}
17
18impl FtpServer {
19    pub fn new(config: FtpConfig) -> Self {
20        let vfs = Arc::new(VirtualFileSystem::new(config.virtual_root.clone()));
21        let spec_registry = Arc::new(FtpSpecRegistry::new().with_vfs(vfs.clone()));
22
23        Self {
24            config,
25            vfs,
26            spec_registry,
27        }
28    }
29
30    /// Create a server pre-loaded with FTP fixtures.
31    ///
32    /// `FtpSpecRegistry` fixtures are attached at construction time via its
33    /// builder, so this is the public path for programmatic fixtures
34    /// (`FtpServer::new` starts empty; the registry is immutable afterwards).
35    pub fn new_with_fixtures(
36        config: FtpConfig,
37        fixtures: Vec<crate::fixtures::FtpFixture>,
38    ) -> Result<Self> {
39        let vfs = Arc::new(VirtualFileSystem::new(config.virtual_root.clone()));
40        let spec_registry =
41            Arc::new(FtpSpecRegistry::new().with_vfs(vfs.clone()).with_fixtures(fixtures)?);
42
43        Ok(Self {
44            config,
45            vfs,
46            spec_registry,
47        })
48    }
49
50    pub async fn start(&self) -> Result<()> {
51        let addr = format!("{}:{}", self.config.host, self.config.port);
52        info!("Starting FTP server on {}", addr);
53
54        // Create the storage backend
55        let storage = MockForgeStorage::new(self.vfs.clone(), self.spec_registry.clone());
56
57        // Create the FTP server with our custom storage
58        let server = ServerBuilder::new(Box::new(move || storage.clone()))
59            .greeting("MockForge FTP Server")
60            .passive_ports(49152..=65534); // Use dynamic port range for passive mode
61
62        info!("FTP server listening on {}", addr);
63        let server = server.build()?;
64        server.listen(&addr).await?;
65
66        Ok(())
67    }
68
69    pub async fn handle_upload(&self, path: &std::path::Path, data: Vec<u8>) -> Result<()> {
70        // Handle file upload through fixtures
71        let path_str = path.to_string_lossy();
72
73        // Find matching upload rule
74        if let Some(rule) = self.spec_registry.find_upload_rule(&path_str) {
75            // Validate the upload
76            rule.validate_file(&data, &path_str).map_err(|e| anyhow::anyhow!(e))?;
77
78            if rule.auto_accept {
79                // Store the file based on rule
80                match &rule.storage {
81                    crate::fixtures::UploadStorage::Memory => {
82                        // Store in VFS
83                        let size = data.len() as u64;
84                        let file = crate::vfs::VirtualFile::new(
85                            path.to_path_buf(),
86                            crate::vfs::FileContent::Static(data),
87                            crate::vfs::FileMetadata {
88                                size,
89                                ..Default::default()
90                            },
91                        );
92                        self.vfs.add_file_async(path.to_path_buf(), file).await?;
93                    }
94                    crate::fixtures::UploadStorage::File { path: storage_path } => {
95                        // Write to file system
96                        tokio::fs::write(storage_path, &data).await?;
97                    }
98                    crate::fixtures::UploadStorage::Discard => {
99                        // Do nothing
100                    }
101                }
102            }
103        }
104
105        Ok(())
106    }
107
108    pub fn spec_registry(&self) -> Arc<FtpSpecRegistry> {
109        self.spec_registry.clone()
110    }
111
112    pub fn vfs(&self) -> Arc<VirtualFileSystem> {
113        self.vfs.clone()
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::fixtures::{FileValidation, UploadRule, UploadStorage};
121
122    #[test]
123    fn test_ftp_server_new() {
124        let config = FtpConfig {
125            host: "127.0.0.1".to_string(),
126            port: 2121,
127            virtual_root: std::path::PathBuf::from("/"),
128            ..Default::default()
129        };
130
131        let server = FtpServer::new(config.clone());
132        assert_eq!(server.config.host, "127.0.0.1");
133        assert_eq!(server.config.port, 2121);
134    }
135
136    #[test]
137    fn test_ftp_server_debug() {
138        let config = FtpConfig {
139            host: "localhost".to_string(),
140            port: 21,
141            virtual_root: std::path::PathBuf::from("/tmp"),
142            ..Default::default()
143        };
144
145        let server = FtpServer::new(config);
146        let debug = format!("{:?}", server);
147        assert!(debug.contains("FtpServer"));
148    }
149
150    #[test]
151    fn test_ftp_server_spec_registry() {
152        let config = FtpConfig {
153            host: "127.0.0.1".to_string(),
154            port: 2121,
155            virtual_root: std::path::PathBuf::from("/"),
156            ..Default::default()
157        };
158
159        let server = FtpServer::new(config);
160        let registry = server.spec_registry();
161        assert!(registry.fixtures.is_empty());
162    }
163
164    #[test]
165    fn test_ftp_server_vfs() {
166        let config = FtpConfig {
167            host: "127.0.0.1".to_string(),
168            port: 2121,
169            virtual_root: std::path::PathBuf::from("/test"),
170            ..Default::default()
171        };
172
173        let server = FtpServer::new(config);
174        let vfs = server.vfs();
175        let files = vfs.list_files(&std::path::PathBuf::from("/"));
176        assert!(files.is_empty());
177    }
178
179    #[tokio::test]
180    async fn test_handle_upload_memory_storage() {
181        let config = FtpConfig {
182            host: "127.0.0.1".to_string(),
183            port: 2121,
184            virtual_root: std::path::PathBuf::from("/"),
185            ..Default::default()
186        };
187
188        let server = FtpServer::new(config);
189
190        // Create a fixture with an upload rule
191        let rule = UploadRule {
192            path_pattern: r"^/uploads/.*".to_string(),
193            auto_accept: true,
194            validation: None,
195            storage: UploadStorage::Memory,
196        };
197
198        let fixture = crate::fixtures::FtpFixture {
199            identifier: "test".to_string(),
200            name: "Test".to_string(),
201            description: None,
202            virtual_files: vec![],
203            upload_rules: vec![rule],
204        };
205
206        // Update the spec registry
207        let new_registry = FtpSpecRegistry::new()
208            .with_vfs(server.vfs.clone())
209            .with_fixtures(vec![fixture])
210            .unwrap();
211
212        let server = FtpServer {
213            config: server.config,
214            vfs: server.vfs.clone(),
215            spec_registry: Arc::new(new_registry),
216        };
217
218        let path = std::path::Path::new("/uploads/test.txt");
219        let data = b"test file content".to_vec();
220
221        let result = server.handle_upload(path, data.clone()).await;
222        assert!(result.is_ok());
223
224        // Verify file was stored in VFS
225        let file = server.vfs.get_file_async(path).await;
226        assert!(file.is_some());
227    }
228
229    #[tokio::test]
230    async fn test_handle_upload_discard_storage() {
231        let config = FtpConfig {
232            host: "127.0.0.1".to_string(),
233            port: 2121,
234            virtual_root: std::path::PathBuf::from("/"),
235            ..Default::default()
236        };
237
238        let server = FtpServer::new(config);
239
240        let rule = UploadRule {
241            path_pattern: r"^/uploads/.*".to_string(),
242            auto_accept: true,
243            validation: None,
244            storage: UploadStorage::Discard,
245        };
246
247        let fixture = crate::fixtures::FtpFixture {
248            identifier: "test".to_string(),
249            name: "Test".to_string(),
250            description: None,
251            virtual_files: vec![],
252            upload_rules: vec![rule],
253        };
254
255        let new_registry = FtpSpecRegistry::new()
256            .with_vfs(server.vfs.clone())
257            .with_fixtures(vec![fixture])
258            .unwrap();
259
260        let server = FtpServer {
261            config: server.config,
262            vfs: server.vfs.clone(),
263            spec_registry: Arc::new(new_registry),
264        };
265
266        let path = std::path::Path::new("/uploads/test.txt");
267        let data = b"test file content".to_vec();
268
269        let result = server.handle_upload(path, data).await;
270        assert!(result.is_ok());
271
272        // With discard storage, file should not be in VFS
273        let file = server.vfs.get_file_async(path).await;
274        assert!(file.is_none());
275    }
276
277    #[tokio::test]
278    async fn test_handle_upload_validation_failure() {
279        let config = FtpConfig {
280            host: "127.0.0.1".to_string(),
281            port: 2121,
282            virtual_root: std::path::PathBuf::from("/"),
283            ..Default::default()
284        };
285
286        let server = FtpServer::new(config);
287
288        let rule = UploadRule {
289            path_pattern: r"^/uploads/.*".to_string(),
290            auto_accept: true,
291            validation: Some(FileValidation {
292                max_size_bytes: Some(10),
293                allowed_extensions: None,
294                mime_types: None,
295            }),
296            storage: UploadStorage::Memory,
297        };
298
299        let fixture = crate::fixtures::FtpFixture {
300            identifier: "test".to_string(),
301            name: "Test".to_string(),
302            description: None,
303            virtual_files: vec![],
304            upload_rules: vec![rule],
305        };
306
307        let new_registry = FtpSpecRegistry::new()
308            .with_vfs(server.vfs.clone())
309            .with_fixtures(vec![fixture])
310            .unwrap();
311
312        let server = FtpServer {
313            config: server.config,
314            vfs: server.vfs.clone(),
315            spec_registry: Arc::new(new_registry),
316        };
317
318        let path = std::path::Path::new("/uploads/test.txt");
319        let data = b"this is a very large file that exceeds the limit".to_vec();
320
321        let result = server.handle_upload(path, data).await;
322        assert!(result.is_err());
323    }
324
325    #[tokio::test]
326    async fn test_handle_upload_no_matching_rule() {
327        let config = FtpConfig {
328            host: "127.0.0.1".to_string(),
329            port: 2121,
330            virtual_root: std::path::PathBuf::from("/"),
331            ..Default::default()
332        };
333
334        let server = FtpServer::new(config);
335
336        let path = std::path::Path::new("/no-rule/test.txt");
337        let data = b"test content".to_vec();
338
339        let result = server.handle_upload(path, data).await;
340        // Should succeed but do nothing since no rule matches
341        assert!(result.is_ok());
342    }
343
344    #[test]
345    fn test_new_with_fixtures_loads_virtual_files() {
346        let config = FtpConfig {
347            host: "127.0.0.1".to_string(),
348            port: 2121,
349            virtual_root: std::path::PathBuf::from("/"),
350            ..Default::default()
351        };
352
353        let fixture = crate::fixtures::FtpFixture {
354            identifier: "programmatic".to_string(),
355            name: "Programmatic Fixture".to_string(),
356            description: Some("Created in code".to_string()),
357            virtual_files: vec![crate::fixtures::VirtualFileConfig {
358                path: std::path::PathBuf::from("/pub/hello.txt"),
359                content: crate::fixtures::FileContentConfig::Static {
360                    content: "hello world".to_string(),
361                },
362                permissions: "644".to_string(),
363                owner: "mockforge".to_string(),
364                group: "mockforge".to_string(),
365            }],
366            upload_rules: vec![],
367        };
368
369        let server = FtpServer::new_with_fixtures(config, vec![fixture]).unwrap();
370
371        // The fixture's identifier is registered and its virtual file was
372        // loaded into the server's VFS.
373        assert_eq!(server.spec_registry().fixtures.len(), 1);
374        assert_eq!(server.spec_registry().fixtures[0].identifier, "programmatic");
375
376        let file = server.vfs().get_file(std::path::Path::new("/pub/hello.txt"));
377        assert!(file.is_some(), "virtual file from fixture should be in VFS");
378    }
379}