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#[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#[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 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 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 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), 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), body: b"File not found".to_vec(),
199 metadata: HashMap::new(),
200 content_type: "text/plain".to_string(),
201 })
202 }
203 }
204 "STOR" => {
205 let path = &request.path;
207 if let Some(rule) = self.find_upload_rule(path) {
208 if let Some(body) = &request.body {
209 if let Err(validation_error) = rule.validate_file(body, path) {
211 return Ok(ProtocolResponse {
212 status: ResponseStatus::FtpStatus(550), 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), 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), 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), 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), 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 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), 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 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), 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), body: b"File not found".to_vec(),
301 metadata: HashMap::new(),
302 content_type: "text/plain".to_string(),
303 })
304 }
305 }
306 "PWD" => {
307 Ok(ProtocolResponse {
309 status: ResponseStatus::FtpStatus(257), 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 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), 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), 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), body: b"Command not implemented".to_vec(),
341 metadata: HashMap::new(),
342 content_type: "text/plain".to_string(),
343 })
344 }
345 }
346 }
347}