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(self.config.passive_ports.0..=self.config.passive_ports.1)
61            .idle_session_timeout(self.config.timeout_secs);
62
63        info!("FTP server listening on {}", addr);
64        let server = server.build()?;
65        server.listen(&addr).await?;
66
67        Ok(())
68    }
69
70    pub async fn handle_upload(&self, path: &std::path::Path, data: Vec<u8>) -> Result<()> {
71        // Handle file upload through fixtures
72        let path_str = path.to_string_lossy();
73
74        // Find matching upload rule
75        if let Some(rule) = self.spec_registry.find_upload_rule(&path_str) {
76            // Validate the upload
77            rule.validate_file(&data, &path_str).map_err(|e| anyhow::anyhow!(e))?;
78
79            if rule.auto_accept {
80                // Store the file based on rule
81                match &rule.storage {
82                    crate::fixtures::UploadStorage::Memory => {
83                        // Store in VFS
84                        let size = data.len() as u64;
85                        let file = crate::vfs::VirtualFile::new(
86                            path.to_path_buf(),
87                            crate::vfs::FileContent::Static(data),
88                            crate::vfs::FileMetadata {
89                                size,
90                                ..Default::default()
91                            },
92                        );
93                        self.vfs.add_file_async(path.to_path_buf(), file).await?;
94                    }
95                    crate::fixtures::UploadStorage::File { path: storage_path } => {
96                        // Write to file system
97                        tokio::fs::write(storage_path, &data).await?;
98                    }
99                    crate::fixtures::UploadStorage::Discard => {
100                        // Do nothing
101                    }
102                }
103            }
104        }
105
106        Ok(())
107    }
108
109    pub fn spec_registry(&self) -> Arc<FtpSpecRegistry> {
110        self.spec_registry.clone()
111    }
112
113    pub fn vfs(&self) -> Arc<VirtualFileSystem> {
114        self.vfs.clone()
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::fixtures::{FileValidation, UploadRule, UploadStorage};
122
123    #[test]
124    fn test_ftp_server_new() {
125        let config = FtpConfig {
126            host: "127.0.0.1".to_string(),
127            port: 2121,
128            virtual_root: std::path::PathBuf::from("/"),
129            ..Default::default()
130        };
131
132        let server = FtpServer::new(config.clone());
133        assert_eq!(server.config.host, "127.0.0.1");
134        assert_eq!(server.config.port, 2121);
135    }
136
137    #[test]
138    fn test_ftp_server_debug() {
139        let config = FtpConfig {
140            host: "localhost".to_string(),
141            port: 21,
142            virtual_root: std::path::PathBuf::from("/tmp"),
143            ..Default::default()
144        };
145
146        let server = FtpServer::new(config);
147        let debug = format!("{:?}", server);
148        assert!(debug.contains("FtpServer"));
149    }
150
151    #[test]
152    fn test_ftp_server_spec_registry() {
153        let config = FtpConfig {
154            host: "127.0.0.1".to_string(),
155            port: 2121,
156            virtual_root: std::path::PathBuf::from("/"),
157            ..Default::default()
158        };
159
160        let server = FtpServer::new(config);
161        let registry = server.spec_registry();
162        assert!(registry.fixtures.is_empty());
163    }
164
165    #[test]
166    fn test_ftp_server_vfs() {
167        let config = FtpConfig {
168            host: "127.0.0.1".to_string(),
169            port: 2121,
170            virtual_root: std::path::PathBuf::from("/test"),
171            ..Default::default()
172        };
173
174        let server = FtpServer::new(config);
175        let vfs = server.vfs();
176        let files = vfs.list_files(&std::path::PathBuf::from("/"));
177        assert!(files.is_empty());
178    }
179
180    #[tokio::test]
181    async fn test_handle_upload_memory_storage() {
182        let config = FtpConfig {
183            host: "127.0.0.1".to_string(),
184            port: 2121,
185            virtual_root: std::path::PathBuf::from("/"),
186            ..Default::default()
187        };
188
189        let server = FtpServer::new(config);
190
191        // Create a fixture with an upload rule
192        let rule = UploadRule {
193            path_pattern: r"^/uploads/.*".to_string(),
194            auto_accept: true,
195            validation: None,
196            storage: UploadStorage::Memory,
197        };
198
199        let fixture = crate::fixtures::FtpFixture {
200            identifier: "test".to_string(),
201            name: "Test".to_string(),
202            description: None,
203            virtual_files: vec![],
204            upload_rules: vec![rule],
205        };
206
207        // Update the spec registry
208        let new_registry = FtpSpecRegistry::new()
209            .with_vfs(server.vfs.clone())
210            .with_fixtures(vec![fixture])
211            .unwrap();
212
213        let server = FtpServer {
214            config: server.config,
215            vfs: server.vfs.clone(),
216            spec_registry: Arc::new(new_registry),
217        };
218
219        let path = std::path::Path::new("/uploads/test.txt");
220        let data = b"test file content".to_vec();
221
222        let result = server.handle_upload(path, data.clone()).await;
223        assert!(result.is_ok());
224
225        // Verify file was stored in VFS
226        let file = server.vfs.get_file_async(path).await;
227        assert!(file.is_some());
228    }
229
230    #[tokio::test]
231    async fn test_handle_upload_discard_storage() {
232        let config = FtpConfig {
233            host: "127.0.0.1".to_string(),
234            port: 2121,
235            virtual_root: std::path::PathBuf::from("/"),
236            ..Default::default()
237        };
238
239        let server = FtpServer::new(config);
240
241        let rule = UploadRule {
242            path_pattern: r"^/uploads/.*".to_string(),
243            auto_accept: true,
244            validation: None,
245            storage: UploadStorage::Discard,
246        };
247
248        let fixture = crate::fixtures::FtpFixture {
249            identifier: "test".to_string(),
250            name: "Test".to_string(),
251            description: None,
252            virtual_files: vec![],
253            upload_rules: vec![rule],
254        };
255
256        let new_registry = FtpSpecRegistry::new()
257            .with_vfs(server.vfs.clone())
258            .with_fixtures(vec![fixture])
259            .unwrap();
260
261        let server = FtpServer {
262            config: server.config,
263            vfs: server.vfs.clone(),
264            spec_registry: Arc::new(new_registry),
265        };
266
267        let path = std::path::Path::new("/uploads/test.txt");
268        let data = b"test file content".to_vec();
269
270        let result = server.handle_upload(path, data).await;
271        assert!(result.is_ok());
272
273        // With discard storage, file should not be in VFS
274        let file = server.vfs.get_file_async(path).await;
275        assert!(file.is_none());
276    }
277
278    #[tokio::test]
279    async fn test_handle_upload_validation_failure() {
280        let config = FtpConfig {
281            host: "127.0.0.1".to_string(),
282            port: 2121,
283            virtual_root: std::path::PathBuf::from("/"),
284            ..Default::default()
285        };
286
287        let server = FtpServer::new(config);
288
289        let rule = UploadRule {
290            path_pattern: r"^/uploads/.*".to_string(),
291            auto_accept: true,
292            validation: Some(FileValidation {
293                max_size_bytes: Some(10),
294                allowed_extensions: None,
295                mime_types: None,
296            }),
297            storage: UploadStorage::Memory,
298        };
299
300        let fixture = crate::fixtures::FtpFixture {
301            identifier: "test".to_string(),
302            name: "Test".to_string(),
303            description: None,
304            virtual_files: vec![],
305            upload_rules: vec![rule],
306        };
307
308        let new_registry = FtpSpecRegistry::new()
309            .with_vfs(server.vfs.clone())
310            .with_fixtures(vec![fixture])
311            .unwrap();
312
313        let server = FtpServer {
314            config: server.config,
315            vfs: server.vfs.clone(),
316            spec_registry: Arc::new(new_registry),
317        };
318
319        let path = std::path::Path::new("/uploads/test.txt");
320        let data = b"this is a very large file that exceeds the limit".to_vec();
321
322        let result = server.handle_upload(path, data).await;
323        assert!(result.is_err());
324    }
325
326    #[tokio::test]
327    async fn test_handle_upload_no_matching_rule() {
328        let config = FtpConfig {
329            host: "127.0.0.1".to_string(),
330            port: 2121,
331            virtual_root: std::path::PathBuf::from("/"),
332            ..Default::default()
333        };
334
335        let server = FtpServer::new(config);
336
337        let path = std::path::Path::new("/no-rule/test.txt");
338        let data = b"test content".to_vec();
339
340        let result = server.handle_upload(path, data).await;
341        // Should succeed but do nothing since no rule matches
342        assert!(result.is_ok());
343    }
344
345    #[test]
346    fn test_new_with_fixtures_loads_virtual_files() {
347        let config = FtpConfig {
348            host: "127.0.0.1".to_string(),
349            port: 2121,
350            virtual_root: std::path::PathBuf::from("/"),
351            ..Default::default()
352        };
353
354        let fixture = crate::fixtures::FtpFixture {
355            identifier: "programmatic".to_string(),
356            name: "Programmatic Fixture".to_string(),
357            description: Some("Created in code".to_string()),
358            virtual_files: vec![crate::fixtures::VirtualFileConfig {
359                path: std::path::PathBuf::from("/pub/hello.txt"),
360                content: crate::fixtures::FileContentConfig::Static {
361                    content: "hello world".to_string(),
362                },
363                permissions: "644".to_string(),
364                owner: "mockforge".to_string(),
365                group: "mockforge".to_string(),
366            }],
367            upload_rules: vec![],
368        };
369
370        let server = FtpServer::new_with_fixtures(config, vec![fixture]).unwrap();
371
372        // The fixture's identifier is registered and its virtual file was
373        // loaded into the server's VFS.
374        assert_eq!(server.spec_registry().fixtures.len(), 1);
375        assert_eq!(server.spec_registry().fixtures[0].identifier, "programmatic");
376
377        let file = server.vfs().get_file(std::path::Path::new("/pub/hello.txt"));
378        assert!(file.is_some(), "virtual file from fixture should be in VFS");
379    }
380
381    #[tokio::test]
382    async fn test_readme_yaml_fixture_loads_into_server_inside_async_runtime() {
383        let readme = include_str!("../README.md");
384        let yaml = readme.split("```yaml\n").nth(1).unwrap().split("```").next().unwrap();
385        let directory = tempfile::tempdir().unwrap();
386        let path = directory.path().join("ftp-fixture.yaml");
387        std::fs::write(&path, yaml).unwrap();
388        let fixture = crate::FtpFixture::from_yaml_file(&path).unwrap();
389        let server = FtpServer::new_with_fixtures(FtpConfig::default(), vec![fixture]).unwrap();
390        assert_eq!(server.spec_registry().fixtures.len(), 1);
391        let file = server
392            .vfs()
393            .get_file_async(std::path::Path::new("/welcome.txt"))
394            .await
395            .expect("documented fixture should populate the actual server VFS");
396        assert!(
397            matches!(file.content, crate::FileContent::Static(bytes) if bytes == b"Welcome to MockForge FTP!")
398        );
399        assert!(server.vfs().get_file_async(std::path::Path::new("/data.bin")).await.is_some());
400    }
401
402    #[test]
403    fn test_fixture_file_reports_missing_and_invalid_yaml() {
404        let directory = tempfile::tempdir().unwrap();
405        let path = directory.path().join("fixture.yaml");
406        let error = crate::FtpFixture::from_yaml_file(&path).unwrap_err();
407        assert!(error.to_string().contains("Failed to open FTP fixture"));
408        std::fs::write(&path, "virtual_files: [").unwrap();
409        let error = crate::FtpFixture::from_yaml_file(&path).unwrap_err();
410        assert!(error.to_string().contains("Failed to parse FTP fixture"));
411    }
412}