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#[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 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 let storage = MockForgeStorage::new(self.vfs.clone(), self.spec_registry.clone());
56
57 let server = ServerBuilder::new(Box::new(move || storage.clone()))
59 .greeting("MockForge FTP Server")
60 .passive_ports(49152..=65534); 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 let path_str = path.to_string_lossy();
72
73 if let Some(rule) = self.spec_registry.find_upload_rule(&path_str) {
75 rule.validate_file(&data, &path_str).map_err(|e| anyhow::anyhow!(e))?;
77
78 if rule.auto_accept {
79 match &rule.storage {
81 crate::fixtures::UploadStorage::Memory => {
82 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 tokio::fs::write(storage_path, &data).await?;
97 }
98 crate::fixtures::UploadStorage::Discard => {
99 }
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 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 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 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 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 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 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}