mockforge_ftp/
spec_registry.rs

1use crate::fixtures::{FtpFixture, UploadRule};
2use crate::vfs::VirtualFileSystem;
3use mockforge_core::protocol_abstraction::{
4    Protocol, ProtocolRequest, ProtocolResponse, ResponseStatus, SpecOperation, SpecRegistry,
5    ValidationError, ValidationResult,
6};
7use mockforge_core::Result;
8use std::collections::HashMap;
9use std::sync::Arc;
10
11/// Tracked upload information
12#[derive(Debug, Clone)]
13pub struct UploadRecord {
14    pub id: String,
15    pub path: std::path::PathBuf,
16    pub size: u64,
17    pub uploaded_at: chrono::DateTime<chrono::Utc>,
18    pub rule_name: Option<String>,
19}
20
21/// FTP Spec Registry for MockForge
22#[derive(Debug, Clone)]
23pub struct FtpSpecRegistry {
24    pub fixtures: Vec<FtpFixture>,
25    pub vfs: Arc<VirtualFileSystem>,
26    pub uploads: Arc<std::sync::RwLock<Vec<UploadRecord>>>,
27}
28
29impl FtpSpecRegistry {
30    pub fn new() -> Self {
31        Self {
32            fixtures: Vec::new(),
33            vfs: Arc::new(VirtualFileSystem::new(std::path::PathBuf::from("/"))),
34            uploads: Arc::new(std::sync::RwLock::new(Vec::new())),
35        }
36    }
37
38    pub fn with_fixtures(mut self, fixtures: Vec<FtpFixture>) -> Result<Self> {
39        // Load virtual files into VFS fixtures
40        let mut vfs_fixtures = Vec::new();
41        for fixture in &fixtures {
42            for virtual_file in &fixture.virtual_files {
43                vfs_fixtures.push(virtual_file.clone().to_file_fixture());
44            }
45        }
46        self.vfs
47            .load_fixtures(vfs_fixtures)
48            .map_err(|e| mockforge_core::Error::from(e.to_string()))?;
49
50        self.fixtures = fixtures;
51        Ok(self)
52    }
53
54    pub fn with_vfs(mut self, vfs: Arc<VirtualFileSystem>) -> Self {
55        self.vfs = vfs;
56        self
57    }
58
59    pub fn find_upload_rule(&self, path: &str) -> Option<&UploadRule> {
60        for fixture in &self.fixtures {
61            for rule in &fixture.upload_rules {
62                if rule.matches_path(path) {
63                    return Some(rule);
64                }
65            }
66        }
67        None
68    }
69
70    pub fn record_upload(
71        &self,
72        path: std::path::PathBuf,
73        size: u64,
74        rule_name: Option<String>,
75    ) -> Result<String> {
76        let id = uuid::Uuid::new_v4().to_string();
77        let record = UploadRecord {
78            id: id.clone(),
79            path,
80            size,
81            uploaded_at: chrono::Utc::now(),
82            rule_name,
83        };
84
85        let mut uploads = self.uploads.write().unwrap();
86        uploads.push(record);
87
88        Ok(id)
89    }
90
91    pub fn get_uploads(&self) -> Vec<UploadRecord> {
92        self.uploads.read().unwrap().clone()
93    }
94
95    pub fn get_upload(&self, id: &str) -> Option<UploadRecord> {
96        self.uploads.read().unwrap().iter().find(|u| u.id == id).cloned()
97    }
98}
99
100impl Default for FtpSpecRegistry {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl SpecRegistry for FtpSpecRegistry {
107    fn protocol(&self) -> Protocol {
108        Protocol::Ftp
109    }
110
111    fn operations(&self) -> Vec<SpecOperation> {
112        self.fixtures
113            .iter()
114            .flat_map(|fixture| {
115                fixture.virtual_files.iter().map(|file| SpecOperation {
116                    name: format!("{}:{}", fixture.name, file.path.display()),
117                    path: file.path.to_string_lossy().to_string(),
118                    operation_type: "RETR".to_string(),
119                    input_schema: None,
120                    output_schema: None,
121                    metadata: HashMap::from([
122                        (
123                            "description".to_string(),
124                            fixture.description.clone().unwrap_or_default(),
125                        ),
126                        ("permissions".to_string(), file.permissions.clone()),
127                        ("owner".to_string(), file.owner.clone()),
128                    ]),
129                })
130            })
131            .collect()
132    }
133
134    fn find_operation(&self, operation: &str, path: &str) -> Option<SpecOperation> {
135        self.fixtures
136            .iter()
137            .flat_map(|fixture| &fixture.virtual_files)
138            .find(|file| file.path.to_string_lossy() == path)
139            .map(|file| SpecOperation {
140                name: path.to_string(),
141                path: path.to_string(),
142                operation_type: operation.to_string(),
143                input_schema: None,
144                output_schema: None,
145                metadata: HashMap::from([
146                    ("permissions".to_string(), file.permissions.clone()),
147                    ("owner".to_string(), file.owner.clone()),
148                    ("group".to_string(), file.group.clone()),
149                ]),
150            })
151    }
152
153    fn validate_request(&self, request: &ProtocolRequest) -> Result<ValidationResult> {
154        if request.protocol != Protocol::Ftp {
155            return Ok(ValidationResult::failure(vec![ValidationError {
156                message: "Invalid protocol for FTP registry".to_string(),
157                path: Some("protocol".to_string()),
158                code: Some("invalid_protocol".to_string()),
159            }]));
160        }
161
162        // Basic validation - operation should be a valid FTP command
163        let valid_operations = [
164            "RETR", "STOR", "LIST", "DELE", "MKD", "RMD", "CWD", "PWD", "SIZE", "MDTM",
165        ];
166        if !valid_operations.contains(&request.operation.as_str()) {
167            return Ok(ValidationResult::failure(vec![ValidationError {
168                message: format!("Unsupported FTP operation: {}", request.operation),
169                path: Some("operation".to_string()),
170                code: Some("unsupported_operation".to_string()),
171            }]));
172        }
173
174        Ok(ValidationResult::success())
175    }
176
177    fn generate_mock_response(&self, request: &ProtocolRequest) -> Result<ProtocolResponse> {
178        match request.operation.as_str() {
179            "RETR" => {
180                // Download file
181                let path = std::path::Path::new(&request.path);
182                if let Some(file) = self.vfs.get_file(path) {
183                    let content = file
184                        .render_content()
185                        .map_err(|e| mockforge_core::Error::from(e.to_string()))?;
186                    Ok(ProtocolResponse {
187                        status: ResponseStatus::FtpStatus(150), // Opening data connection
188                        body: content,
189                        metadata: HashMap::from([
190                            ("size".to_string(), file.metadata.size.to_string()),
191                            ("path".to_string(), request.path.clone()),
192                        ]),
193                        content_type: "application/octet-stream".to_string(),
194                    })
195                } else {
196                    Ok(ProtocolResponse {
197                        status: ResponseStatus::FtpStatus(550), // File not found
198                        body: b"File not found".to_vec(),
199                        metadata: HashMap::new(),
200                        content_type: "text/plain".to_string(),
201                    })
202                }
203            }
204            "STOR" => {
205                // Upload file
206                let path = &request.path;
207                if let Some(rule) = self.find_upload_rule(path) {
208                    if let Some(body) = &request.body {
209                        // Validate upload
210                        if let Err(validation_error) = rule.validate_file(body, path) {
211                            return Ok(ProtocolResponse {
212                                status: ResponseStatus::FtpStatus(550), // Permission denied
213                                body: validation_error.into_bytes(),
214                                metadata: HashMap::new(),
215                                content_type: "text/plain".to_string(),
216                            });
217                        }
218
219                        if rule.auto_accept {
220                            Ok(ProtocolResponse {
221                                status: ResponseStatus::FtpStatus(226), // Transfer complete
222                                body: b"Transfer complete".to_vec(),
223                                metadata: HashMap::from([
224                                    ("path".to_string(), path.clone()),
225                                    ("size".to_string(), body.len().to_string()),
226                                ]),
227                                content_type: "text/plain".to_string(),
228                            })
229                        } else {
230                            Ok(ProtocolResponse {
231                                status: ResponseStatus::FtpStatus(550), // Permission denied
232                                body: b"Upload rejected by rule".to_vec(),
233                                metadata: HashMap::new(),
234                                content_type: "text/plain".to_string(),
235                            })
236                        }
237                    } else {
238                        Ok(ProtocolResponse {
239                            status: ResponseStatus::FtpStatus(550), // Bad request
240                            body: b"No file data provided".to_vec(),
241                            metadata: HashMap::new(),
242                            content_type: "text/plain".to_string(),
243                        })
244                    }
245                } else {
246                    Ok(ProtocolResponse {
247                        status: ResponseStatus::FtpStatus(550), // Permission denied
248                        body: b"No upload rule matches this path".to_vec(),
249                        metadata: HashMap::new(),
250                        content_type: "text/plain".to_string(),
251                    })
252                }
253            }
254            "LIST" => {
255                // Directory listing
256                let path = std::path::Path::new(&request.path);
257                let files = self.vfs.list_files(path);
258                let listing = files
259                    .iter()
260                    .map(|file| {
261                        format!(
262                            "-rw-r--r-- 1 {} {} {} {} {} {}",
263                            file.metadata.owner,
264                            file.metadata.group,
265                            file.metadata.size,
266                            file.modified_at.format("%b %d %H:%M"),
267                            file.path.file_name().unwrap_or_default().to_string_lossy(),
268                            ""
269                        )
270                    })
271                    .collect::<Vec<_>>()
272                    .join("\n");
273
274                Ok(ProtocolResponse {
275                    status: ResponseStatus::FtpStatus(226), // Transfer complete
276                    body: listing.into_bytes(),
277                    metadata: HashMap::from([
278                        ("path".to_string(), request.path.clone()),
279                        ("count".to_string(), files.len().to_string()),
280                    ]),
281                    content_type: "text/plain".to_string(),
282                })
283            }
284            "DELE" => {
285                // Delete file
286                let path = std::path::Path::new(&request.path);
287                if self.vfs.get_file(path).is_some() {
288                    self.vfs
289                        .remove_file(path)
290                        .map_err(|e| mockforge_core::Error::from(e.to_string()))?;
291                    Ok(ProtocolResponse {
292                        status: ResponseStatus::FtpStatus(250), // File deleted
293                        body: b"File deleted".to_vec(),
294                        metadata: HashMap::from([("path".to_string(), request.path.clone())]),
295                        content_type: "text/plain".to_string(),
296                    })
297                } else {
298                    Ok(ProtocolResponse {
299                        status: ResponseStatus::FtpStatus(550), // File not found
300                        body: b"File not found".to_vec(),
301                        metadata: HashMap::new(),
302                        content_type: "text/plain".to_string(),
303                    })
304                }
305            }
306            "PWD" => {
307                // Print working directory
308                Ok(ProtocolResponse {
309                    status: ResponseStatus::FtpStatus(257), // Current directory
310                    body: format!("\"{}\"", request.path).into_bytes(),
311                    metadata: HashMap::from([("path".to_string(), request.path.clone())]),
312                    content_type: "text/plain".to_string(),
313                })
314            }
315            "SIZE" => {
316                // Get file size
317                let path = std::path::Path::new(&request.path);
318                if let Some(file) = self.vfs.get_file(path) {
319                    Ok(ProtocolResponse {
320                        status: ResponseStatus::FtpStatus(213), // File size
321                        body: file.metadata.size.to_string().into_bytes(),
322                        metadata: HashMap::from([
323                            ("path".to_string(), request.path.clone()),
324                            ("size".to_string(), file.metadata.size.to_string()),
325                        ]),
326                        content_type: "text/plain".to_string(),
327                    })
328                } else {
329                    Ok(ProtocolResponse {
330                        status: ResponseStatus::FtpStatus(550), // File not found
331                        body: b"File not found".to_vec(),
332                        metadata: HashMap::new(),
333                        content_type: "text/plain".to_string(),
334                    })
335                }
336            }
337            _ => {
338                Ok(ProtocolResponse {
339                    status: ResponseStatus::FtpStatus(502), // Command not implemented
340                    body: b"Command not implemented".to_vec(),
341                    metadata: HashMap::new(),
342                    content_type: "text/plain".to_string(),
343                })
344            }
345        }
346    }
347}