Skip to main content

netget/server/ftp/
actions.rs

1//! FTP protocol actions implementation
2//!
3//! Implements RFC 959 FTP command responses with LLM control.
4
5use crate::llm::actions::{
6    protocol_trait::{ActionResult, Protocol, Server},
7    ActionDefinition, Parameter,
8};
9use crate::protocol::log_template::LogTemplate;
10use crate::protocol::EventType;
11use crate::state::app_state::AppState;
12use anyhow::{Context, Result};
13use serde_json::json;
14use std::sync::LazyLock;
15use tracing::debug;
16
17/// FTP protocol action handler
18pub struct FtpProtocol;
19
20impl FtpProtocol {
21    pub fn new() -> Self {
22        Self
23    }
24
25    fn execute_send_ftp_response(&self, action: serde_json::Value) -> Result<ActionResult> {
26        let code = parse_ftp_code(&action)?;
27
28        let message = action
29            .get("message")
30            .and_then(|v| v.as_str())
31            .context("Missing 'message' parameter (the human-readable text after the code)")?;
32        reject_line_breaks("message", message)?;
33
34        let response = format!("{} {}\r\n", code, message);
35
36        debug!("FTP sending response: {} {}", code, message);
37        Ok(ActionResult::Output(response.as_bytes().to_vec()))
38    }
39
40    fn execute_send_ftp_multiline(&self, action: serde_json::Value) -> Result<ActionResult> {
41        let code = parse_ftp_code(&action)?;
42
43        let lines = action
44            .get("lines")
45            .and_then(|v| v.as_array())
46            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
47            .unwrap_or_default();
48
49        if lines.is_empty() {
50            // Degenerate case: a multiline response with no lines is just a single-line
51            // response. Fall back to 'message' so the client still gets a valid reply
52            // rather than a bare code.
53            let message = action
54                .get("message")
55                .and_then(|v| v.as_str())
56                .unwrap_or("OK");
57            reject_line_breaks("message", message)?;
58            let response = format!("{} {}\r\n", code, message);
59            return Ok(ActionResult::Output(response.as_bytes().to_vec()));
60        }
61
62        for line in &lines {
63            reject_line_breaks("lines", line)?;
64        }
65
66        // Build multiline response
67        let mut response = String::new();
68        for (i, line) in lines.iter().enumerate() {
69            if i == lines.len() - 1 {
70                // Last line uses space separator
71                response.push_str(&format!("{} {}\r\n", code, line));
72            } else {
73                // Intermediate lines use dash separator
74                response.push_str(&format!("{}-{}\r\n", code, line));
75            }
76        }
77
78        debug!("FTP sending multiline response: code {}", code);
79        Ok(ActionResult::Output(response.as_bytes().to_vec()))
80    }
81
82    fn execute_send_ftp_data(&self, action: serde_json::Value) -> Result<ActionResult> {
83        let data = action
84            .get("data")
85            .and_then(|v| v.as_str())
86            .context("Missing 'data' parameter")?;
87
88        // Ensure data ends with exactly one CRLF.
89        let formatted = if data.ends_with("\r\n") {
90            data.to_string()
91        } else if let Some(stripped) = data.strip_suffix('\n') {
92            format!("{}\r\n", stripped)
93        } else {
94            format!("{}\r\n", data)
95        };
96
97        debug!("FTP sending data: {} bytes", formatted.len());
98        Ok(ActionResult::Output(formatted.as_bytes().to_vec()))
99    }
100
101    fn execute_send_ftp_list(&self, action: serde_json::Value) -> Result<ActionResult> {
102        let entries = action
103            .get("entries")
104            .and_then(|v| v.as_array())
105            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
106            .unwrap_or_default();
107
108        for entry in &entries {
109            reject_line_breaks("entries", entry)?;
110        }
111
112        let entry_count = entries.len();
113        let mut response = String::new();
114        for entry in entries {
115            response.push_str(entry);
116            response.push_str("\r\n");
117        }
118
119        debug!("FTP sending LIST data: {} entries", entry_count);
120        Ok(ActionResult::Output(response.as_bytes().to_vec()))
121    }
122}
123
124impl Default for FtpProtocol {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130/// Reject CR or LF inside a piece of reply text the handler supplied.
131///
132/// FTP is line-oriented: a reply is terminated by CRLF and the client reads the next line as
133/// a *new* reply. So a `message`, listing entry or multiline element containing CR or LF does
134/// not produce a longer reply - it forges additional ones, and the client's reply-code state
135/// machine is then reading text the handler never meant as a code. With `send_ftp_response`
136/// that is a full response-splitting primitive: `"ok\r\n230 Logged in"` turns a 331 into a
137/// successful login.
138///
139/// All three action descriptions already promised this ("Must not contain CR or LF", "Do not
140/// include line endings", "the code and separators are added for you"). Nothing enforced it,
141/// so the promise held only for as long as the model chose to keep it. Erroring is right
142/// rather than stripping: a handler that meant several lines wants `send_ftp_multiline`, and
143/// silently rewriting its text would hide the mistake.
144fn reject_line_breaks(field: &str, value: &str) -> Result<()> {
145    if let Some(pos) = value.find(['\r', '\n']) {
146        return Err(anyhow::anyhow!(
147            "FTP '{field}' must not contain CR or LF (found one at byte {pos}): a reply is \
148             terminated by CRLF, so an embedded line break forges a second reply rather than \
149             continuing this one. Use send_ftp_multiline for a reply spanning several lines."
150        ));
151    }
152    Ok(())
153}
154
155/// Read and validate the `code` field of an FTP reply action.
156///
157/// RFC 959 replies are always a three-digit code, so anything outside 100-599 is a
158/// mistake the client cannot interpret. Returning an error (rather than silently
159/// substituting 500) makes the failure visible instead of shipping a wrong reply.
160fn parse_ftp_code(action: &serde_json::Value) -> Result<u64> {
161    let code = action
162        .get("code")
163        .and_then(|v| v.as_u64())
164        .context("Missing 'code' parameter (a three-digit RFC 959 reply code, e.g. 220)")?;
165
166    if !(100..=599).contains(&code) {
167        return Err(anyhow::anyhow!(
168            "Invalid FTP reply code {code}: RFC 959 codes are three digits in the range 100-599 \
169             (e.g. 220 ready, 230 logged in, 331 need password, 550 not found)."
170        ));
171    }
172
173    Ok(code)
174}
175
176// Implement Protocol trait (common functionality)
177impl Protocol for FtpProtocol {
178    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
179        // This server implements the FTP control connection only - there is no PASV/PORT
180        // data connection, so there is no passive port range to configure. It also always
181        // sends the 220 greeting itself, so it declares no `send_first` either.
182        //
183        // The two it does declare are the read deadlines. Their right value is a property of
184        // who is on the other end, which only the operator knows: the defaults serve NetGet's
185        // own FTP client parked at the dashboard waiting for a person, and a listener exposed
186        // to strangers wants the first one much lower.
187        vec![
188            crate::llm::actions::ParameterDefinition {
189                name: "first_byte_timeout_secs".to_string(),
190                type_hint: "number".to_string(),
191                description: "Seconds a connected peer may send no command at all - not even \
192                              a USER after the 220 greeting - before the server closes it. \
193                              Default 300, matching the window a `manual` rule gives a human \
194                              to answer one event. The peer is often NetGet's own FTP client, \
195                              which reads the greeting in its read loop and writes nothing \
196                              until an action or [ send message ] says to. Lower it (60 was \
197                              the old value) for a listener exposed to strangers."
198                    .to_string(),
199                required: false,
200                example: json!(300),
201                default: Some(serde_json::json!(
202                    super::FIRST_COMMAND_READ_TIMEOUT.as_secs()
203                )),
204            },
205            crate::llm::actions::ParameterDefinition {
206                name: "idle_timeout_secs".to_string(),
207                type_hint: "number".to_string(),
208                description: "Seconds an established control connection may go without a \
209                              further command before the server closes it. Default 300, which \
210                              is vsftpd's `idle_session_timeout` default; the silence between \
211                              two commands of an interactive session is someone typing, so \
212                              this is on a human timescale rather than a machine one."
213                    .to_string(),
214                required: false,
215                example: json!(300),
216                default: Some(serde_json::json!(
217                    super::IDLE_BETWEEN_COMMANDS_TIMEOUT.as_secs()
218                )),
219            },
220        ]
221    }
222
223    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
224        // FTP doesn't need async actions for now
225        Vec::new()
226    }
227
228    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
229        vec![
230            send_ftp_response_action(),
231            send_ftp_multiline_action(),
232            send_ftp_data_action(),
233            send_ftp_list_action(),
234            wait_for_more_action(),
235            close_connection_action(),
236        ]
237    }
238
239    fn protocol_name(&self) -> &'static str {
240        "FTP"
241    }
242
243    fn get_event_types(&self) -> Vec<EventType> {
244        get_ftp_event_types()
245    }
246
247    fn stack_name(&self) -> &'static str {
248        "ETH>IP>TCP>FTP"
249    }
250
251    fn keywords(&self) -> Vec<&'static str> {
252        vec!["ftp", "file transfer", "ftp server"]
253    }
254
255    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
256        use crate::protocol::metadata::{
257            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
258        };
259
260        ProtocolMetadataV2::builder()
261            .state(DevelopmentState::Experimental)
262            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(21))
263            .well_known_port(21)
264            .implementation("Manual line-based parsing with tokio")
265            .llm_control("All FTP replies on the control connection")
266            .e2e_testing("raw TCP client (nc); real FTP clients cannot transfer files")
267            .notes(
268                "Control connection only: PASV/PORT are not implemented, so LIST/RETR/STOR \
269                 cannot complete against a real FTP client. No TLS. No E2E test.",
270            )
271            .max_inbound_bytes(crate::server::ftp::MAX_COMMAND_LINE)
272            .build()
273    }
274
275    fn description(&self) -> &'static str {
276        "FTP file transfer server"
277    }
278
279    fn example_prompt(&self) -> &'static str {
280        "Start an FTP server on port 21 that allows anonymous login and lists files"
281    }
282
283    fn group_name(&self) -> &'static str {
284        "Application"
285    }
286
287    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
288        use crate::llm::actions::StartupExamples;
289
290        // Deterministic: acknowledge every command with a 200 reply, no LLM
291        // call. The connect event arrives as command="CONNECTION_ESTABLISHED".
292        let script = r#"import json, sys
293data = json.load(sys.stdin)
294event = data["event"]
295if data["event_type_id"] == "ftp_command":
296    actions = [{"type": "send_ftp_response", "code": 200, "message": "Command okay"}]
297else:
298    actions = []
299print(json.dumps({"actions": actions}))"#;
300
301        // NOTE: no `send_first` here. The server always emits ftp_command with
302        // command="CONNECTION_ESTABLISHED" on connect so the handler can produce the 220
303        // greeting; passing send_first would only produce an "unsupported" warning.
304        StartupExamples::new(
305            // LLM mode: LLM handles all FTP responses intelligently
306            json!({
307                "type": "open_server",
308                "port": 21,
309                "base_stack": "ftp",
310                "instruction": "FTP server that allows anonymous login and responds to FTP commands"
311            }),
312            // Script mode: Code-based deterministic responses
313            json!({
314                "type": "open_server",
315                "port": 21,
316                "base_stack": "ftp",
317                "event_handlers": [{
318                    "event_pattern": "ftp_command",
319                    "handler": {
320                        "type": "script",
321                        "language": "python",
322                        "code": script
323                    }
324                }]
325            }),
326            // Static mode: Fixed responses
327            json!({
328                "type": "open_server",
329                "port": 21,
330                "base_stack": "ftp",
331                "event_handlers": [{
332                    "event_pattern": "ftp_command",
333                    "handler": {
334                        "type": "static",
335                        "actions": [{
336                            "type": "send_ftp_response",
337                            "code": 500,
338                            "message": "Command not recognized"
339                        }]
340                    }
341                }]
342            }),
343        )
344    }
345}
346
347// Implement Server trait (server-specific functionality)
348impl Server for FtpProtocol {
349    fn spawn(
350        &self,
351        ctx: crate::protocol::SpawnContext,
352    ) -> std::pin::Pin<
353        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
354    > {
355        Box::pin(async move {
356            use crate::server::ftp::FtpServer;
357            #[allow(deprecated)]
358            let listen_addr = ctx.socket_addr().unwrap_or(ctx.legacy_listen_addr());
359            // Both read deadlines are the operator's to choose: who is on the other end is
360            // the only thing that decides them, and only the operator knows that.
361            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
362                Ok(ctx
363                    .startup_params
364                    .as_ref()
365                    .map(|p| p.get_optional_u64(name))
366                    .transpose()?
367                    .flatten())
368            };
369            let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
370            let idle_timeout_secs = secs("idle_timeout_secs")?;
371
372            FtpServer::spawn_with_llm_actions(
373                listen_addr,
374                ctx.llm_client,
375                ctx.state,
376                ctx.status_tx,
377                ctx.server_id,
378                first_byte_timeout_secs,
379                idle_timeout_secs,
380            )
381            .await
382        })
383    }
384
385    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
386        let action_type = action
387            .get("type")
388            .and_then(|v| v.as_str())
389            .context("Missing 'type' field in action")?;
390
391        match action_type {
392            "send_ftp_response" => self.execute_send_ftp_response(action),
393            "send_ftp_multiline" => self.execute_send_ftp_multiline(action),
394            "send_ftp_data" => self.execute_send_ftp_data(action),
395            "send_ftp_list" => self.execute_send_ftp_list(action),
396            "wait_for_more" => Ok(ActionResult::WaitForMore),
397            "close_connection" => Ok(ActionResult::CloseConnection),
398            _ => Err(anyhow::anyhow!("Unknown FTP action: {}", action_type)),
399        }
400    }
401}
402
403// Action definitions
404
405fn send_ftp_response_action() -> ActionDefinition {
406    ActionDefinition {
407        name: "send_ftp_response".to_string(),
408        description: "Send a single-line RFC 959 reply on the FTP control connection. The bytes \
409            put on the wire are exactly \"<code> <message>\\r\\n\" - do not include the code in \
410            'message' and do not add your own line ending. This is the normal way to answer every \
411            FTP command."
412            .to_string(),
413        parameters: vec![
414            Parameter {
415                name: "code".to_string(),
416                type_hint: "number".to_string(),
417                description: "Three-digit RFC 959 reply code, 100-599. 220 ready, 221 goodbye, \
418                    230 logged in, 250 command ok, 257 the current directory (the answer to PWD, \
419                    with the path in double quotes: code 257, message \"\\\"/pub\\\" is the \
420                    current directory\"), 331 need password, 500 syntax error, 530 not logged \
421                    in, 550 file unavailable. Any value outside 100-599 is rejected"
422                    .to_string(),
423                required: true,
424            },
425            Parameter {
426                name: "message".to_string(),
427                type_hint: "string".to_string(),
428                description: "Human-readable text placed after the code on the same line. Must \
429                    not contain CR or LF - use send_ftp_multiline for a reply spanning several \
430                    lines"
431                    .to_string(),
432                required: true,
433            },
434        ],
435        example: json!({
436            "type": "send_ftp_response",
437            "code": 220,
438            "message": "FTP Server Ready"
439        }),
440        log_template: Some(
441            LogTemplate::new()
442                .with_info("-> FTP {code} {message}")
443                .with_debug("FTP send_ftp_response: {code} {message}"),
444        ),
445    }
446}
447
448fn send_ftp_multiline_action() -> ActionDefinition {
449    ActionDefinition {
450        name: "send_ftp_multiline".to_string(),
451        description: "Send a multi-line RFC 959 reply, used for FEAT, HELP, STAT and similar. \
452            Every line but the last is written as \"<code>-<line>\\r\\n\" and the last as \
453            \"<code> <line>\\r\\n\", which is what tells the client the reply has ended. Supply \
454            only the text of each line; the code and separators are added for you."
455            .to_string(),
456        parameters: vec![
457            Parameter {
458                name: "code".to_string(),
459                type_hint: "number".to_string(),
460                description: "Three-digit RFC 959 reply code, 100-599, repeated on every line of \
461                    the reply (e.g. 211 for FEAT, 214 for HELP)"
462                    .to_string(),
463                required: true,
464            },
465            Parameter {
466                name: "lines".to_string(),
467                type_hint: "array".to_string(),
468                description: "Array of strings, one per line of the reply, in order. Include the \
469                    closing line (e.g. \"End\") yourself. If this is omitted or empty the action \
470                    falls back to sending a single-line reply using the 'message' field"
471                    .to_string(),
472                required: true,
473            },
474        ],
475        example: json!({
476            "type": "send_ftp_multiline",
477            "code": 211,
478            "lines": ["Features:", "UTF8", "End"]
479        }),
480        log_template: Some(
481            LogTemplate::new()
482                .with_info("-> FTP {code} multiline")
483                .with_debug("FTP send_ftp_multiline: code={code}, lines={lines_len}"),
484        ),
485    }
486}
487
488fn send_ftp_data_action() -> ActionDefinition {
489    ActionDefinition {
490        name: "send_ftp_data".to_string(),
491        description: "Write raw text on the FTP CONTROL connection, terminated with CRLF. \
492            WARNING: this server implements no data connection (PASV/PORT are not supported), so \
493            this does NOT perform an FTP file transfer - the bytes appear inline in the command \
494            channel. A real FTP client will reject or misparse them. Use it only with raw clients \
495            such as `nc`, or to send a reply line that send_ftp_response cannot express."
496            .to_string(),
497        parameters: vec![Parameter {
498            name: "data".to_string(),
499            type_hint: "string".to_string(),
500            description: "Text to write on the control connection. Exactly one trailing CRLF is \
501                ensured: a bare \"\\n\" is upgraded to \"\\r\\n\" and a missing ending is added"
502                .to_string(),
503            required: true,
504        }],
505        example: json!({
506            "type": "send_ftp_data",
507            "data": "Hello from FTP"
508        }),
509        log_template: Some(
510            LogTemplate::new()
511                .with_info("-> FTP data {output_bytes}B")
512                .with_debug("FTP send_ftp_data: {output_bytes}B")
513                .with_trace("FTP data: {preview(data,200)}"),
514        ),
515    }
516}
517
518fn send_ftp_list_action() -> ActionDefinition {
519    ActionDefinition {
520        name: "send_ftp_list".to_string(),
521        description: "Write directory listing lines, one per entry, each terminated with CRLF. \
522            WARNING: a real FTP client expects a LIST/NLST listing on a separate data connection, \
523            and this server has none - the lines go out on the control connection instead, where \
524            a real client will not accept them. Useful for raw clients (`nc`) and for showing a \
525            plausible listing to an attacker; not for interoperating with `ftp`/`lftp`/`curl`."
526            .to_string(),
527        parameters: vec![Parameter {
528            name: "entries".to_string(),
529            type_hint: "array".to_string(),
530            description: "Array of strings, one per directory entry, already formatted as \
531                `ls -l` output (permissions, links, owner, group, size, date, name). Do not \
532                include line endings; CRLF is appended to each entry"
533                .to_string(),
534            required: true,
535        }],
536        example: json!({
537            "type": "send_ftp_list",
538            "entries": [
539                "-rw-r--r-- 1 ftp ftp 1024 Jan 01 00:00 file.txt",
540                "drwxr-xr-x 2 ftp ftp 4096 Jan 01 00:00 subdir"
541            ]
542        }),
543        log_template: Some(
544            LogTemplate::new()
545                .with_info("-> FTP LIST {entries_len} entries")
546                .with_debug("FTP send_ftp_list: {entries_len} entries"),
547        ),
548    }
549}
550
551fn wait_for_more_action() -> ActionDefinition {
552    ActionDefinition {
553        name: "wait_for_more".to_string(),
554        description: "Wait for more data before responding".to_string(),
555        parameters: vec![],
556        example: json!({
557            "type": "wait_for_more"
558        }),
559        log_template: Some(LogTemplate::new().with_debug("FTP waiting for more data")),
560    }
561}
562
563fn close_connection_action() -> ActionDefinition {
564    ActionDefinition {
565        name: "close_connection".to_string(),
566        description: "Close the FTP connection".to_string(),
567        parameters: vec![],
568        example: json!({
569            "type": "close_connection"
570        }),
571        log_template: Some(
572            LogTemplate::new()
573                .with_info("FTP connection closed")
574                .with_debug("FTP close_connection"),
575        ),
576    }
577}
578
579// ============================================================================
580// FTP Action Constants
581// ============================================================================
582
583pub static SEND_FTP_RESPONSE_ACTION: LazyLock<ActionDefinition> =
584    LazyLock::new(send_ftp_response_action);
585pub static SEND_FTP_MULTILINE_ACTION: LazyLock<ActionDefinition> =
586    LazyLock::new(send_ftp_multiline_action);
587pub static SEND_FTP_DATA_ACTION: LazyLock<ActionDefinition> = LazyLock::new(send_ftp_data_action);
588pub static SEND_FTP_LIST_ACTION: LazyLock<ActionDefinition> = LazyLock::new(send_ftp_list_action);
589pub static WAIT_FOR_MORE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(wait_for_more_action);
590pub static CLOSE_CONNECTION_ACTION: LazyLock<ActionDefinition> =
591    LazyLock::new(close_connection_action);
592
593// ============================================================================
594// FTP Event Type Constants
595// ============================================================================
596
597/// FTP command event - triggered on connect and for every command line thereafter
598pub static FTP_COMMAND_EVENT: LazyLock<EventType> =
599    LazyLock::new(|| {
600        EventType::new(
601        "ftp_command",
602        "A client connected, or sent a command line on the FTP control connection. This is the \
603         only FTP event: the connect case is signalled by the literal command \
604         'CONNECTION_ESTABLISHED', for which you must reply with a 220 greeting before the client \
605         will send anything else.",
606        json!({"type": "send_ftp_response", "code": 220, "message": "FTP Server Ready"}),
607    )
608    .with_parameters(vec![Parameter {
609        name: "command".to_string(),
610        type_hint: "string".to_string(),
611        description: "The command line as sent by the client, with the trailing CRLF stripped \
612            (e.g. 'USER anonymous', 'PASS x@y.z', 'SYST', 'PWD', 'LIST', 'RETR file.txt', \
613            'QUIT'). The verb is NOT upper-cased or split out for you. One exception: the \
614            literal value 'CONNECTION_ESTABLISHED' is not from the client - it means the TCP \
615            connection has just been accepted and the server is waiting for your 220 greeting"
616            .to_string(),
617        required: true,
618    },
619    Parameter {
620        name: "answer_with".to_string(),
621        type_hint: "string".to_string(),
622        description: "The reply RFC 959 expects for this command, derived by the server from \
623            its verb. Absent for a verb the server does not classify."
624            .to_string(),
625        required: false,
626    }])
627    .with_actions(vec![
628        SEND_FTP_RESPONSE_ACTION.clone(),
629        SEND_FTP_MULTILINE_ACTION.clone(),
630        SEND_FTP_DATA_ACTION.clone(),
631        SEND_FTP_LIST_ACTION.clone(),
632        WAIT_FOR_MORE_ACTION.clone(),
633        CLOSE_CONNECTION_ACTION.clone(),
634    ])
635    .with_log_template(
636        LogTemplate::new()
637            .with_info("FTP {client_ip}: {command}")
638            .with_debug("FTP command from {client_ip}:{client_port}: {command}")
639            .with_trace("FTP: {json_pretty(.)}"),
640    )
641    });
642
643/// The `answer_with` event field: the reply RFC 959 expects for `command`, by its verb.
644///
645/// The real-model eval saw llama3.1:8b answer `USER` with `331` *and* `230` in one reply (the
646/// first is what the client gets), answer `PASS` with another `331`, and answer `PWD` with
647/// `200 Connected`. The server knows the verb exactly, so it says which reply completes it.
648/// Exactly one completion reply per command is also enforced on the wire (`OneReply` in
649/// `mod.rs`).
650pub fn answer_with_for_command(command: &str) -> Option<&'static str> {
651    let verb = command
652        .split_whitespace()
653        .next()
654        .unwrap_or("")
655        .to_ascii_uppercase();
656    Some(match verb.as_str() {
657        "CONNECTION_ESTABLISHED" => "one 220 greeting",
658        "USER" => {
659            "one reply: 331 to ask for a password, or 230 if this user is logged in with no \
660             password - not both"
661        }
662        "PASS" => "one reply: 230 if the login succeeds, 530 if it does not",
663        "PWD" | "XPWD" => {
664            "one 257 reply whose message starts with the directory in double quotes, e.g. \
665             \"/pub\" is the current directory"
666        }
667        "CWD" | "XCWD" | "CDUP" => {
668            "one reply: 250 if the directory changed, 550 if it does not exist"
669        }
670        "SYST" => "one 215 reply naming the system type, e.g. UNIX Type: L8",
671        "TYPE" | "MODE" | "STRU" | "NOOP" => "one 200 reply",
672        "QUIT" => "one 221 reply, then the server closes",
673        "FEAT" => "a multi-line 211 reply listing features, or 502 if there are none",
674        _ => return None,
675    })
676}
677
678/// Get FTP event types
679pub fn get_ftp_event_types() -> Vec<EventType> {
680    vec![FTP_COMMAND_EVENT.clone()]
681}