Skip to main content

stackforge_core/layer/ftp/
builder.rs

1//! FTP packet builder.
2//!
3//! Provides a fluent API for constructing FTP command and reply payloads.
4//!
5//! # Examples
6//!
7//! ```rust
8//! use stackforge_core::layer::ftp::builder::FtpBuilder;
9//!
10//! // Build a USER command
11//! let pkt = FtpBuilder::new().user("anonymous").build();
12//! assert_eq!(pkt, b"USER anonymous\r\n");
13//!
14//! // Build a 220 service-ready reply
15//! let pkt = FtpBuilder::new().service_ready("FTP Server ready").build();
16//! assert_eq!(pkt, b"220 FTP Server ready\r\n");
17//! ```
18
19/// Builder for FTP control connection messages (commands and replies).
20#[must_use]
21#[derive(Debug, Clone)]
22pub struct FtpBuilder {
23    /// If Some, build a server reply with this code.
24    reply_code: Option<u16>,
25    /// If Some, build a client command with this verb.
26    command: Option<String>,
27    /// Arguments for command, or text for reply.
28    text: String,
29    /// If true, use multi-line reply format (code followed by `-`).
30    multiline: bool,
31    /// Additional lines for multi-line replies.
32    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    // ========================================================================
53    // Reply builders (server-side)
54    // ========================================================================
55
56    /// Set the reply code and message text.
57    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    /// Make this a multi-line reply with additional intermediate lines.
65    pub fn multiline(mut self, lines: Vec<String>) -> Self {
66        self.multiline = true;
67        self.extra_lines = lines;
68        self
69    }
70
71    /// Build "220 Service ready" reply.
72    pub fn service_ready(self, text: impl Into<String>) -> Self {
73        self.reply(220, text)
74    }
75
76    /// Build "230 User logged in" reply.
77    pub fn user_logged_in(self, text: impl Into<String>) -> Self {
78        self.reply(230, text.into())
79    }
80
81    /// Build "331 Password required" reply.
82    pub fn password_required(self) -> Self {
83        self.reply(331, "Password required")
84    }
85
86    /// Build "227 Entering Passive Mode" reply.
87    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    /// Build "229 Entering Extended Passive Mode" reply.
93    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    /// Build "226 Transfer complete" reply.
99    pub fn transfer_complete(self) -> Self {
100        self.reply(226, "Transfer complete")
101    }
102
103    /// Build "150 File status okay" reply.
104    pub fn file_status_ok(self) -> Self {
105        self.reply(150, "File status okay; about to open data connection")
106    }
107
108    /// Build "250 Requested file action OK" reply.
109    pub fn file_action_ok(self) -> Self {
110        self.reply(250, "Requested file action okay, completed")
111    }
112
113    /// Build "257 pathname created" reply.
114    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    /// Build "221 Goodbye" reply.
121    pub fn goodbye(self) -> Self {
122        self.reply(221, "Goodbye")
123    }
124
125    /// Build "421 Service not available" reply.
126    pub fn service_not_available(self) -> Self {
127        self.reply(421, "Service not available, closing control connection")
128    }
129
130    /// Build "530 Not logged in" reply.
131    pub fn not_logged_in(self) -> Self {
132        self.reply(530, "Not logged in")
133    }
134
135    /// Build "550 File unavailable" reply.
136    pub fn file_unavailable(self, text: impl Into<String>) -> Self {
137        self.reply(550, text)
138    }
139
140    /// Build "500 Syntax error" reply.
141    pub fn syntax_error(self, text: impl Into<String>) -> Self {
142        self.reply(500, text)
143    }
144
145    /// Build a FEAT reply (RFC 2389 feature list).
146    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    // ========================================================================
156    // Command builders (client-side)
157    // ========================================================================
158
159    /// Set a raw command verb and optional args.
160    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    /// Build "USER <name>" command.
168    pub fn user(self, username: impl Into<String>) -> Self {
169        self.command("USER", username)
170    }
171
172    /// Build "PASS <password>" command.
173    pub fn pass(self, password: impl Into<String>) -> Self {
174        self.command("PASS", password)
175    }
176
177    /// Build "QUIT" command.
178    pub fn quit(self) -> Self {
179        self.command("QUIT", "")
180    }
181
182    /// Build "LIST [path]" command.
183    pub fn list(self, path: impl Into<String>) -> Self {
184        self.command("LIST", path)
185    }
186
187    /// Build "NLST [path]" command.
188    pub fn nlst(self, path: impl Into<String>) -> Self {
189        self.command("NLST", path)
190    }
191
192    /// Build "RETR <filename>" command.
193    pub fn retr(self, filename: impl Into<String>) -> Self {
194        self.command("RETR", filename)
195    }
196
197    /// Build "STOR <filename>" command.
198    pub fn stor(self, filename: impl Into<String>) -> Self {
199        self.command("STOR", filename)
200    }
201
202    /// Build "APPE <filename>" command.
203    pub fn appe(self, filename: impl Into<String>) -> Self {
204        self.command("APPE", filename)
205    }
206
207    /// Build "DELE <filename>" command.
208    pub fn dele(self, filename: impl Into<String>) -> Self {
209        self.command("DELE", filename)
210    }
211
212    /// Build "CWD <path>" command.
213    pub fn cwd(self, path: impl Into<String>) -> Self {
214        self.command("CWD", path)
215    }
216
217    /// Build "CDUP" command.
218    pub fn cdup(self) -> Self {
219        self.command("CDUP", "")
220    }
221
222    /// Build "MKD <path>" command.
223    pub fn mkd(self, path: impl Into<String>) -> Self {
224        self.command("MKD", path)
225    }
226
227    /// Build "RMD <path>" command.
228    pub fn rmd(self, path: impl Into<String>) -> Self {
229        self.command("RMD", path)
230    }
231
232    /// Build "PWD" command.
233    pub fn pwd(self) -> Self {
234        self.command("PWD", "")
235    }
236
237    /// Build "SYST" command.
238    pub fn syst(self) -> Self {
239        self.command("SYST", "")
240    }
241
242    /// Build "NOOP" command.
243    pub fn noop(self) -> Self {
244        self.command("NOOP", "")
245    }
246
247    /// Build "PASV" command.
248    pub fn pasv(self) -> Self {
249        self.command("PASV", "")
250    }
251
252    /// Build "EPSV" command.
253    pub fn epsv(self) -> Self {
254        self.command("EPSV", "")
255    }
256
257    /// Build "PORT h1,h2,h3,h4,p1,p2" command.
258    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    /// Build "TYPE <mode>" command (A=ASCII, I=binary).
264    pub fn type_cmd(self, mode: char) -> Self {
265        self.command("TYPE", mode.to_string())
266    }
267
268    /// Build "FEAT" command.
269    pub fn feat(self) -> Self {
270        self.command("FEAT", "")
271    }
272
273    /// Build "SIZE <filename>" command (RFC 3659).
274    pub fn size(self, filename: impl Into<String>) -> Self {
275        self.command("SIZE", filename)
276    }
277
278    /// Build "MDTM <filename>" command (RFC 3659).
279    pub fn mdtm(self, filename: impl Into<String>) -> Self {
280        self.command("MDTM", filename)
281    }
282
283    /// Build "AUTH <mechanism>" command.
284    pub fn auth(self, mechanism: impl Into<String>) -> Self {
285        self.command("AUTH", mechanism)
286    }
287
288    /// Build "RNFR <filename>" command.
289    pub fn rnfr(self, filename: impl Into<String>) -> Self {
290        self.command("RNFR", filename)
291    }
292
293    /// Build "RNTO <filename>" command.
294    pub fn rnto(self, filename: impl Into<String>) -> Self {
295        self.command("RNTO", filename)
296    }
297
298    /// Build "REST <marker>" command.
299    pub fn rest(self, offset: u64) -> Self {
300        self.command("REST", offset.to_string())
301    }
302
303    /// Build "ABOR" command.
304    pub fn abor(self) -> Self {
305        self.command("ABOR", "")
306    }
307
308    /// Build "STAT [path]" command.
309    pub fn stat(self, path: impl Into<String>) -> Self {
310        self.command("STAT", path)
311    }
312
313    /// Build "HELP [topic]" command.
314    pub fn help(self, topic: impl Into<String>) -> Self {
315        self.command("HELP", topic)
316    }
317
318    // ========================================================================
319    // Build
320    // ========================================================================
321
322    /// Serialize this FTP message to bytes (including CRLF terminator).
323    #[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            // First line: NNN-text
336            out.extend_from_slice(format!("{:03}-{}\r\n", code, self.text).as_bytes());
337            // Intermediate lines (no code prefix required, but common)
338            for line in &self.extra_lines {
339                out.extend_from_slice(format!("{line}\r\n").as_bytes());
340            }
341            // Last line: NNN SP text (or just code)
342            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}