stackforge_core/layer/ftp/
builder.rs1#[must_use]
21#[derive(Debug, Clone)]
22pub struct FtpBuilder {
23 reply_code: Option<u16>,
25 command: Option<String>,
27 text: String,
29 multiline: bool,
31 extra_lines: Vec<String>,
33}
34
35impl Default for FtpBuilder {
36 fn default() -> Self {
37 Self {
38 reply_code: None,
39 command: Some("NOOP".to_string()),
40 text: String::new(),
41 multiline: false,
42 extra_lines: Vec::new(),
43 }
44 }
45}
46
47impl FtpBuilder {
48 pub fn new() -> Self {
49 Self::default()
50 }
51
52 pub fn reply(mut self, code: u16, text: impl Into<String>) -> Self {
58 self.reply_code = Some(code);
59 self.command = None;
60 self.text = text.into();
61 self
62 }
63
64 pub fn multiline(mut self, lines: Vec<String>) -> Self {
66 self.multiline = true;
67 self.extra_lines = lines;
68 self
69 }
70
71 pub fn service_ready(self, text: impl Into<String>) -> Self {
73 self.reply(220, text)
74 }
75
76 pub fn user_logged_in(self, text: impl Into<String>) -> Self {
78 self.reply(230, text.into())
79 }
80
81 pub fn password_required(self) -> Self {
83 self.reply(331, "Password required")
84 }
85
86 pub fn passive_mode(self, h1: u8, h2: u8, h3: u8, h4: u8, p1: u8, p2: u8) -> Self {
88 let text = format!("Entering Passive Mode ({h1},{h2},{h3},{h4},{p1},{p2})");
89 self.reply(227, text)
90 }
91
92 pub fn extended_passive_mode(self, port: u16) -> Self {
94 let text = format!("Entering Extended Passive Mode (|||{port}|)");
95 self.reply(229, text)
96 }
97
98 pub fn transfer_complete(self) -> Self {
100 self.reply(226, "Transfer complete")
101 }
102
103 pub fn file_status_ok(self) -> Self {
105 self.reply(150, "File status okay; about to open data connection")
106 }
107
108 pub fn file_action_ok(self) -> Self {
110 self.reply(250, "Requested file action okay, completed")
111 }
112
113 pub fn pathname_created(self, path: impl Into<String>) -> Self {
115 let path = path.into();
116 let text = format!("\"{path}\" created");
117 self.reply(257, text)
118 }
119
120 pub fn goodbye(self) -> Self {
122 self.reply(221, "Goodbye")
123 }
124
125 pub fn service_not_available(self) -> Self {
127 self.reply(421, "Service not available, closing control connection")
128 }
129
130 pub fn not_logged_in(self) -> Self {
132 self.reply(530, "Not logged in")
133 }
134
135 pub fn file_unavailable(self, text: impl Into<String>) -> Self {
137 self.reply(550, text)
138 }
139
140 pub fn syntax_error(self, text: impl Into<String>) -> Self {
142 self.reply(500, text)
143 }
144
145 pub fn feat_reply(mut self, features: Vec<String>) -> Self {
147 self.reply_code = Some(211);
148 self.command = None;
149 self.multiline = true;
150 self.text = "Features:".to_string();
151 self.extra_lines = features.into_iter().map(|f| format!(" {f}")).collect();
152 self
153 }
154
155 pub fn command(mut self, verb: impl Into<String>, args: impl Into<String>) -> Self {
161 self.command = Some(verb.into().to_ascii_uppercase());
162 self.reply_code = None;
163 self.text = args.into();
164 self
165 }
166
167 pub fn user(self, username: impl Into<String>) -> Self {
169 self.command("USER", username)
170 }
171
172 pub fn pass(self, password: impl Into<String>) -> Self {
174 self.command("PASS", password)
175 }
176
177 pub fn quit(self) -> Self {
179 self.command("QUIT", "")
180 }
181
182 pub fn list(self, path: impl Into<String>) -> Self {
184 self.command("LIST", path)
185 }
186
187 pub fn nlst(self, path: impl Into<String>) -> Self {
189 self.command("NLST", path)
190 }
191
192 pub fn retr(self, filename: impl Into<String>) -> Self {
194 self.command("RETR", filename)
195 }
196
197 pub fn stor(self, filename: impl Into<String>) -> Self {
199 self.command("STOR", filename)
200 }
201
202 pub fn appe(self, filename: impl Into<String>) -> Self {
204 self.command("APPE", filename)
205 }
206
207 pub fn dele(self, filename: impl Into<String>) -> Self {
209 self.command("DELE", filename)
210 }
211
212 pub fn cwd(self, path: impl Into<String>) -> Self {
214 self.command("CWD", path)
215 }
216
217 pub fn cdup(self) -> Self {
219 self.command("CDUP", "")
220 }
221
222 pub fn mkd(self, path: impl Into<String>) -> Self {
224 self.command("MKD", path)
225 }
226
227 pub fn rmd(self, path: impl Into<String>) -> Self {
229 self.command("RMD", path)
230 }
231
232 pub fn pwd(self) -> Self {
234 self.command("PWD", "")
235 }
236
237 pub fn syst(self) -> Self {
239 self.command("SYST", "")
240 }
241
242 pub fn noop(self) -> Self {
244 self.command("NOOP", "")
245 }
246
247 pub fn pasv(self) -> Self {
249 self.command("PASV", "")
250 }
251
252 pub fn epsv(self) -> Self {
254 self.command("EPSV", "")
255 }
256
257 pub fn port(self, h1: u8, h2: u8, h3: u8, h4: u8, p1: u8, p2: u8) -> Self {
259 let args = format!("{h1},{h2},{h3},{h4},{p1},{p2}");
260 self.command("PORT", args)
261 }
262
263 pub fn type_cmd(self, mode: char) -> Self {
265 self.command("TYPE", mode.to_string())
266 }
267
268 pub fn feat(self) -> Self {
270 self.command("FEAT", "")
271 }
272
273 pub fn size(self, filename: impl Into<String>) -> Self {
275 self.command("SIZE", filename)
276 }
277
278 pub fn mdtm(self, filename: impl Into<String>) -> Self {
280 self.command("MDTM", filename)
281 }
282
283 pub fn auth(self, mechanism: impl Into<String>) -> Self {
285 self.command("AUTH", mechanism)
286 }
287
288 pub fn rnfr(self, filename: impl Into<String>) -> Self {
290 self.command("RNFR", filename)
291 }
292
293 pub fn rnto(self, filename: impl Into<String>) -> Self {
295 self.command("RNTO", filename)
296 }
297
298 pub fn rest(self, offset: u64) -> Self {
300 self.command("REST", offset.to_string())
301 }
302
303 pub fn abor(self) -> Self {
305 self.command("ABOR", "")
306 }
307
308 pub fn stat(self, path: impl Into<String>) -> Self {
310 self.command("STAT", path)
311 }
312
313 pub fn help(self, topic: impl Into<String>) -> Self {
315 self.command("HELP", topic)
316 }
317
318 #[must_use]
324 pub fn build(&self) -> Vec<u8> {
325 if let Some(code) = self.reply_code {
326 self.build_reply(code)
327 } else {
328 self.build_command()
329 }
330 }
331
332 fn build_reply(&self, code: u16) -> Vec<u8> {
333 let mut out = Vec::new();
334 if self.multiline {
335 out.extend_from_slice(format!("{:03}-{}\r\n", code, self.text).as_bytes());
337 for line in &self.extra_lines {
339 out.extend_from_slice(format!("{line}\r\n").as_bytes());
340 }
341 out.extend_from_slice(format!("{code:03} End\r\n").as_bytes());
343 } else {
344 out.extend_from_slice(format!("{:03} {}\r\n", code, self.text).as_bytes());
345 }
346 out
347 }
348
349 fn build_command(&self) -> Vec<u8> {
350 let verb = self.command.as_deref().unwrap_or("NOOP");
351 let args = &self.text;
352 let line = if args.is_empty() {
353 format!("{verb}\r\n")
354 } else {
355 format!("{verb} {args}\r\n")
356 };
357 line.into_bytes()
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn test_build_noop_command() {
367 let pkt = FtpBuilder::new().noop().build();
368 assert_eq!(pkt, b"NOOP\r\n");
369 }
370
371 #[test]
372 fn test_build_user_command() {
373 let pkt = FtpBuilder::new().user("anonymous").build();
374 assert_eq!(pkt, b"USER anonymous\r\n");
375 }
376
377 #[test]
378 fn test_build_pass_command() {
379 let pkt = FtpBuilder::new().pass("secret").build();
380 assert_eq!(pkt, b"PASS secret\r\n");
381 }
382
383 #[test]
384 fn test_build_quit_command() {
385 let pkt = FtpBuilder::new().quit().build();
386 assert_eq!(pkt, b"QUIT\r\n");
387 }
388
389 #[test]
390 fn test_build_list_command() {
391 let pkt = FtpBuilder::new().list("/pub").build();
392 assert_eq!(pkt, b"LIST /pub\r\n");
393 }
394
395 #[test]
396 fn test_build_retr_command() {
397 let pkt = FtpBuilder::new().retr("file.txt").build();
398 assert_eq!(pkt, b"RETR file.txt\r\n");
399 }
400
401 #[test]
402 fn test_build_service_ready_reply() {
403 let pkt = FtpBuilder::new().service_ready("FTP Server ready").build();
404 assert_eq!(pkt, b"220 FTP Server ready\r\n");
405 }
406
407 #[test]
408 fn test_build_password_required_reply() {
409 let pkt = FtpBuilder::new().password_required().build();
410 assert_eq!(pkt, b"331 Password required\r\n");
411 }
412
413 #[test]
414 fn test_build_passive_mode_reply() {
415 let pkt = FtpBuilder::new()
416 .passive_mode(192, 168, 1, 1, 200, 50)
417 .build();
418 assert_eq!(pkt, b"227 Entering Passive Mode (192,168,1,1,200,50)\r\n");
419 }
420
421 #[test]
422 fn test_build_multiline_reply() {
423 let pkt = FtpBuilder::new()
424 .feat_reply(vec!["SIZE".to_string(), "MDTM".to_string()])
425 .build();
426 let s = String::from_utf8(pkt).unwrap();
427 assert!(s.starts_with("211-"));
428 assert!(s.contains("SIZE\r\n"));
429 assert!(s.contains("MDTM\r\n"));
430 }
431
432 #[test]
433 fn test_build_port_command() {
434 let pkt = FtpBuilder::new().port(192, 168, 1, 2, 100, 30).build();
435 assert_eq!(pkt, b"PORT 192,168,1,2,100,30\r\n");
436 }
437
438 #[test]
439 fn test_build_type_command() {
440 let pkt = FtpBuilder::new().type_cmd('I').build();
441 assert_eq!(pkt, b"TYPE I\r\n");
442 }
443}