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(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 let path_str = path.to_string_lossy();
73
74 if let Some(rule) = self.spec_registry.find_upload_rule(&path_str) {
76 rule.validate_file(&data, &path_str).map_err(|e| anyhow::anyhow!(e))?;
78
79 if rule.auto_accept {
80 match &rule.storage {
82 crate::fixtures::UploadStorage::Memory => {
83 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 tokio::fs::write(storage_path, &data).await?;
98 }
99 crate::fixtures::UploadStorage::Discard => {
100 }
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 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 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 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 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 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 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}