1pub mod builder;
36pub use builder::FtpBuilder;
37
38use crate::layer::field::{FieldError, FieldValue};
39use crate::layer::{Layer, LayerIndex, LayerKind};
40
41pub const FTP_MIN_HEADER_LEN: usize = 4;
43
44pub const FTP_CONTROL_PORT: u16 = 21;
46
47pub const FTP_DATA_PORT: u16 = 20;
49
50pub const REPLY_RESTART_MARKER: u16 = 110;
54pub const REPLY_SERVICE_READY_IN: u16 = 120;
55pub const REPLY_DATA_OPEN_XFER: u16 = 125;
56pub const REPLY_FILE_STATUS_OK: u16 = 150;
57pub const REPLY_OK: u16 = 200;
58pub const REPLY_COMMAND_NOT_IMPLEMENTED: u16 = 202;
59pub const REPLY_SYSTEM_STATUS: u16 = 211;
60pub const REPLY_DIR_STATUS: u16 = 212;
61pub const REPLY_FILE_STATUS: u16 = 213;
62pub const REPLY_HELP_MSG: u16 = 214;
63pub const REPLY_NAME_SYSTEM: u16 = 215;
64pub const REPLY_SERVICE_READY: u16 = 220;
65pub const REPLY_CLOSING_CONTROL: u16 = 221;
66pub const REPLY_DATA_OPEN: u16 = 225;
67pub const REPLY_CLOSING_DATA: u16 = 226;
68pub const REPLY_PASSIVE: u16 = 227;
69pub const REPLY_LONG_PASSIVE: u16 = 228;
70pub const REPLY_EXTENDED_PASSIVE: u16 = 229;
71pub const REPLY_USER_LOGGED_IN: u16 = 230;
72pub const REPLY_AUTH_OK: u16 = 234;
73pub const REPLY_FILE_ACTION_OK: u16 = 250;
74pub const REPLY_PATHNAME_CREATED: u16 = 257;
75pub const REPLY_USER_OK_NEED_PASS: u16 = 331;
76pub const REPLY_NEED_ACCOUNT: u16 = 332;
77pub const REPLY_PENDING_INFO: u16 = 350;
78pub const REPLY_SERVICE_NOT_AVAIL: u16 = 421;
79pub const REPLY_CANT_OPEN_DATA: u16 = 425;
80pub const REPLY_CONN_CLOSED: u16 = 426;
81pub const REPLY_INVALID_CRED: u16 = 430;
82pub const REPLY_HOST_UNAVAIL: u16 = 434;
83pub const REPLY_FILE_UNAVAIL_BUSY: u16 = 450;
84pub const REPLY_LOCAL_ERROR: u16 = 451;
85pub const REPLY_INSUFF_STORAGE: u16 = 452;
86pub const REPLY_SYNTAX_ERROR: u16 = 500;
87pub const REPLY_ARG_SYNTAX_ERROR: u16 = 501;
88pub const REPLY_CMD_NOT_IMPL: u16 = 502;
89pub const REPLY_BAD_SEQUENCE: u16 = 503;
90pub const REPLY_CMD_NOT_IMPL_PARAM: u16 = 504;
91pub const REPLY_NOT_LOGGED_IN: u16 = 530;
92pub const REPLY_NEED_ACCOUNT_FOR_STOR: u16 = 532;
93pub const REPLY_FILE_UNAVAIL: u16 = 550;
94pub const REPLY_PAGE_TYPE_UNKNOWN: u16 = 551;
95pub const REPLY_EXCEED_STORAGE: u16 = 552;
96pub const REPLY_FILENAME_NOT_ALLOWED: u16 = 553;
97
98pub const CMD_USER: &str = "USER";
102pub const CMD_PASS: &str = "PASS";
103pub const CMD_ACCT: &str = "ACCT";
104pub const CMD_CWD: &str = "CWD";
105pub const CMD_CDUP: &str = "CDUP";
106pub const CMD_SMNT: &str = "SMNT";
107pub const CMD_QUIT: &str = "QUIT";
108pub const CMD_REIN: &str = "REIN";
109pub const CMD_PORT: &str = "PORT";
110pub const CMD_PASV: &str = "PASV";
111pub const CMD_TYPE: &str = "TYPE";
112pub const CMD_STRU: &str = "STRU";
113pub const CMD_MODE: &str = "MODE";
114pub const CMD_RETR: &str = "RETR";
115pub const CMD_STOR: &str = "STOR";
116pub const CMD_STOU: &str = "STOU";
117pub const CMD_APPE: &str = "APPE";
118pub const CMD_ALLO: &str = "ALLO";
119pub const CMD_REST: &str = "REST";
120pub const CMD_RNFR: &str = "RNFR";
121pub const CMD_RNTO: &str = "RNTO";
122pub const CMD_ABOR: &str = "ABOR";
123pub const CMD_DELE: &str = "DELE";
124pub const CMD_RMD: &str = "RMD";
125pub const CMD_MKD: &str = "MKD";
126pub const CMD_PWD: &str = "PWD";
127pub const CMD_LIST: &str = "LIST";
128pub const CMD_NLST: &str = "NLST";
129pub const CMD_SITE: &str = "SITE";
130pub const CMD_SYST: &str = "SYST";
131pub const CMD_STAT: &str = "STAT";
132pub const CMD_HELP: &str = "HELP";
133pub const CMD_NOOP: &str = "NOOP";
134pub const CMD_FEAT: &str = "FEAT";
136pub const CMD_OPTS: &str = "OPTS";
137pub const CMD_EPRT: &str = "EPRT";
138pub const CMD_EPSV: &str = "EPSV";
139pub const CMD_MDTM: &str = "MDTM";
140pub const CMD_SIZE: &str = "SIZE";
141pub const CMD_MLST: &str = "MLST";
142pub const CMD_MLSD: &str = "MLSD";
143pub const CMD_AUTH: &str = "AUTH";
144pub const CMD_PROT: &str = "PROT";
145pub const CMD_PBSZ: &str = "PBSZ";
146
147pub static FTP_COMMANDS: &[&str] = &[
149 "USER", "PASS", "ACCT", "CWD", "CDUP", "SMNT", "QUIT", "REIN", "PORT", "PASV", "TYPE", "STRU",
150 "MODE", "RETR", "STOR", "STOU", "APPE", "ALLO", "REST", "RNFR", "RNTO", "ABOR", "DELE", "RMD",
151 "MKD", "PWD", "LIST", "NLST", "SITE", "SYST", "STAT", "HELP", "NOOP", "FEAT", "OPTS", "EPRT",
152 "EPSV", "MDTM", "SIZE", "MLST", "MLSD", "AUTH", "PROT", "PBSZ",
153];
154
155pub static FTP_FIELD_NAMES: &[&str] = &[
157 "command",
158 "args",
159 "reply_code",
160 "reply_text",
161 "is_response",
162 "is_multiline",
163 "raw",
164];
165
166#[must_use]
176pub fn is_ftp_payload(buf: &[u8]) -> bool {
177 if buf.len() < 3 {
178 return false;
179 }
180 if buf[0].is_ascii_digit() && buf[1].is_ascii_digit() && buf[2].is_ascii_digit() {
182 return buf.len() >= 4 && matches!(buf[3], b' ' | b'-' | b'\r' | b'\n');
183 }
184 if let Ok(text) = std::str::from_utf8(buf) {
186 let upper = text.to_ascii_uppercase();
187 let first_word = upper.split_ascii_whitespace().next().unwrap_or("");
188 let first_word = first_word.trim_end_matches(['\r', '\n']);
190 return FTP_COMMANDS.contains(&first_word);
191 }
192 false
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum FtpMessageKind {
198 Command,
200 Reply,
202 Unknown,
204}
205
206#[must_use]
215#[derive(Debug, Clone)]
216pub struct FtpLayer {
217 pub index: LayerIndex,
218}
219
220impl FtpLayer {
221 pub fn new(index: LayerIndex) -> Self {
222 Self { index }
223 }
224
225 pub fn at_start(len: usize) -> Self {
226 Self {
227 index: LayerIndex::new(LayerKind::Ftp, 0, len),
228 }
229 }
230
231 #[inline]
233 fn slice<'a>(&self, buf: &'a [u8]) -> &'a [u8] {
234 let end = self.index.end.min(buf.len());
235 &buf[self.index.start..end]
236 }
237
238 #[must_use]
240 pub fn message_kind(&self, buf: &[u8]) -> FtpMessageKind {
241 let s = self.slice(buf);
242 if s.len() >= 3 && s[0].is_ascii_digit() && s[1].is_ascii_digit() && s[2].is_ascii_digit() {
243 FtpMessageKind::Reply
244 } else if self.command(buf).is_ok() {
245 FtpMessageKind::Command
246 } else {
247 FtpMessageKind::Unknown
248 }
249 }
250
251 pub fn command(&self, buf: &[u8]) -> Result<String, FieldError> {
259 let s = self.slice(buf);
260 let text = std::str::from_utf8(s)
261 .map_err(|_| FieldError::InvalidValue("non-UTF8 FTP payload".into()))?;
262 let first_word = text.split_ascii_whitespace().next().unwrap_or("");
263 let upper = first_word.to_ascii_uppercase();
264 if FTP_COMMANDS.contains(&upper.as_str()) {
266 Ok(upper)
267 } else if !s.is_empty() && s[0].is_ascii_digit() {
268 Err(FieldError::InvalidValue(
269 "this is a reply, not a command".into(),
270 ))
271 } else {
272 Ok(upper) }
274 }
275
276 pub fn args(&self, buf: &[u8]) -> Result<String, FieldError> {
282 let s = self.slice(buf);
283 let text = std::str::from_utf8(s)
284 .map_err(|_| FieldError::InvalidValue("non-UTF8 FTP payload".into()))?;
285 let first_line = text.lines().next().unwrap_or("");
286 let rest = first_line
288 .split_once(' ')
289 .map_or("", |(_, r)| r)
290 .trim_end_matches(['\r', '\n']);
291 Ok(rest.to_string())
292 }
293
294 pub fn reply_code(&self, buf: &[u8]) -> Result<u16, FieldError> {
301 let s = self.slice(buf);
302 if s.len() < 3 {
303 return Err(FieldError::BufferTooShort {
304 offset: self.index.start,
305 need: 3,
306 have: s.len(),
307 });
308 }
309 if s[0].is_ascii_digit() && s[1].is_ascii_digit() && s[2].is_ascii_digit() {
310 let code =
311 u16::from(s[0] - b'0') * 100 + u16::from(s[1] - b'0') * 10 + u16::from(s[2] - b'0');
312 Ok(code)
313 } else {
314 Err(FieldError::InvalidValue(
315 "payload does not start with a 3-digit reply code".into(),
316 ))
317 }
318 }
319
320 pub fn reply_text(&self, buf: &[u8]) -> Result<String, FieldError> {
327 let s = self.slice(buf);
328 let text = std::str::from_utf8(s)
329 .map_err(|_| FieldError::InvalidValue("non-UTF8 FTP payload".into()))?;
330 let first_line = text.lines().next().unwrap_or("");
332 if first_line.len() >= 4 {
333 let msg = first_line[4..].trim_end_matches(['\r', '\n']).to_string();
334 Ok(msg)
335 } else if first_line.len() == 3 {
336 Ok(String::new())
337 } else {
338 Err(FieldError::InvalidValue(
339 "payload too short for reply format".into(),
340 ))
341 }
342 }
343
344 #[must_use]
346 pub fn is_response(&self, buf: &[u8]) -> bool {
347 let s = self.slice(buf);
348 s.len() >= 3 && s[0].is_ascii_digit() && s[1].is_ascii_digit() && s[2].is_ascii_digit()
349 }
350
351 #[must_use]
353 pub fn is_multiline(&self, buf: &[u8]) -> bool {
354 let s = self.slice(buf);
355 s.len() >= 4
356 && s[0].is_ascii_digit()
357 && s[1].is_ascii_digit()
358 && s[2].is_ascii_digit()
359 && s[3] == b'-'
360 }
361
362 #[must_use]
364 pub fn raw(&self, buf: &[u8]) -> String {
365 let s = self.slice(buf);
366 String::from_utf8_lossy(s).to_string()
367 }
368
369 pub fn get_field(&self, buf: &[u8], name: &str) -> Option<Result<FieldValue, FieldError>> {
371 match name {
372 "command" => Some(self.command(buf).map(FieldValue::Str)),
373 "args" => Some(self.args(buf).map(FieldValue::Str)),
374 "reply_code" => Some(self.reply_code(buf).map(FieldValue::U16)),
375 "reply_text" => Some(self.reply_text(buf).map(FieldValue::Str)),
376 "is_response" => Some(Ok(FieldValue::Bool(self.is_response(buf)))),
377 "is_multiline" => Some(Ok(FieldValue::Bool(self.is_multiline(buf)))),
378 "raw" => Some(Ok(FieldValue::Str(self.raw(buf)))),
379 _ => None,
380 }
381 }
382}
383
384impl Layer for FtpLayer {
385 fn kind(&self) -> LayerKind {
386 LayerKind::Ftp
387 }
388
389 fn summary(&self, buf: &[u8]) -> String {
390 let s = self.slice(buf);
391 let text = String::from_utf8_lossy(s);
392 let first_line = text.lines().next().unwrap_or("").trim_end_matches('\r');
393 format!("FTP {first_line}")
394 }
395
396 fn header_len(&self, buf: &[u8]) -> usize {
397 self.slice(buf).len()
398 }
399
400 fn hashret(&self, buf: &[u8]) -> Vec<u8> {
401 if let Ok(code) = self.reply_code(buf) {
403 code.to_be_bytes().to_vec()
404 } else if let Ok(cmd) = self.command(buf) {
405 cmd.into_bytes()
406 } else {
407 vec![]
408 }
409 }
410
411 fn field_names(&self) -> &'static [&'static str] {
412 FTP_FIELD_NAMES
413 }
414}
415
416#[must_use]
418pub fn ftp_show_fields(l: &FtpLayer, buf: &[u8]) -> Vec<(&'static str, String)> {
419 let mut fields = Vec::new();
420 if l.is_response(buf) {
421 if let Ok(code) = l.reply_code(buf) {
422 fields.push(("reply_code", code.to_string()));
423 }
424 if let Ok(text) = l.reply_text(buf) {
425 fields.push(("reply_text", text));
426 }
427 fields.push(("is_multiline", l.is_multiline(buf).to_string()));
428 } else if let Ok(cmd) = l.command(buf) {
429 fields.push(("command", cmd));
430 if let Ok(args) = l.args(buf)
431 && !args.is_empty()
432 {
433 fields.push(("args", args));
434 }
435 }
436 fields
437}
438
439#[must_use]
445pub fn reply_code_description(code: u16) -> &'static str {
446 match code {
447 110 => "Restart marker reply",
448 120 => "Service ready in N minutes",
449 125 => "Data connection already open; transfer starting",
450 150 => "File status okay; about to open data connection",
451 200 => "Command okay",
452 202 => "Command not implemented, superfluous at this site",
453 211 => "System status, or system help reply",
454 212 => "Directory status",
455 213 => "File status",
456 214 => "Help message",
457 215 => "NAME system type",
458 220 => "Service ready for new user",
459 221 => "Service closing control connection",
460 225 => "Data connection open; no transfer in progress",
461 226 => "Closing data connection; requested file action successful",
462 227 => "Entering Passive Mode",
463 228 => "Entering Long Passive Mode",
464 229 => "Entering Extended Passive Mode",
465 230 => "User logged in, proceed",
466 234 => "Specifying protection mechanism name",
467 250 => "Requested file action okay, completed",
468 257 => "PATHNAME created",
469 331 => "User name okay, need password",
470 332 => "Need account for login",
471 350 => "Requested file action pending further information",
472 421 => "Service not available, closing control connection",
473 425 => "Can't open data connection",
474 426 => "Connection closed; transfer aborted",
475 430 => "Invalid username or password",
476 434 => "Requested host unavailable",
477 450 => "Requested file action not taken; file unavailable",
478 451 => "Requested action aborted; local error in processing",
479 452 => "Requested action not taken; insufficient storage space",
480 500 => "Syntax error, command unrecognized",
481 501 => "Syntax error in parameters or arguments",
482 502 => "Command not implemented",
483 503 => "Bad sequence of commands",
484 504 => "Command not implemented for that parameter",
485 530 => "Not logged in",
486 532 => "Need account for storing files",
487 550 => "Requested action not taken; file unavailable",
488 551 => "Requested action aborted; page type unknown",
489 552 => "Requested file action aborted; exceeded storage allocation",
490 553 => "Requested action not taken; file name not allowed",
491 _ => "Unknown reply code",
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use crate::layer::LayerIndex;
499
500 fn make_layer(data: &[u8]) -> FtpLayer {
501 FtpLayer::new(LayerIndex::new(LayerKind::Ftp, 0, data.len()))
502 }
503
504 #[test]
505 fn test_ftp_detection_reply() {
506 assert!(is_ftp_payload(b"220 Service ready\r\n"));
507 assert!(is_ftp_payload(b"331 Password required\r\n"));
508 assert!(is_ftp_payload(b"230 User logged in\r\n"));
509 assert!(is_ftp_payload(b"550 File not found\r\n"));
510 }
511
512 #[test]
513 fn test_ftp_detection_command() {
514 assert!(is_ftp_payload(b"USER anonymous\r\n"));
515 assert!(is_ftp_payload(b"PASS secret\r\n"));
516 assert!(is_ftp_payload(b"LIST\r\n"));
517 assert!(is_ftp_payload(b"QUIT\r\n"));
518 assert!(is_ftp_payload(b"RETR file.txt\r\n"));
519 }
520
521 #[test]
522 fn test_ftp_detection_negative() {
523 assert!(!is_ftp_payload(b""));
524 assert!(!is_ftp_payload(b"GET / HTTP/1.1\r\n"));
525 assert!(!is_ftp_payload(b"\x00\x00\x00\x01"));
526 }
527
528 #[test]
529 fn test_ftp_layer_reply_code() {
530 let data = b"220 Service ready for new user\r\n";
531 let layer = make_layer(data);
532 assert_eq!(layer.reply_code(data).unwrap(), 220);
533 assert_eq!(
534 layer.reply_text(data).unwrap(),
535 "Service ready for new user"
536 );
537 assert!(layer.is_response(data));
538 assert!(!layer.is_multiline(data));
539 }
540
541 #[test]
542 fn test_ftp_layer_multiline_reply() {
543 let data = b"220-Welcome to FTP\r\n220 Ready\r\n";
544 let layer = make_layer(data);
545 assert_eq!(layer.reply_code(data).unwrap(), 220);
546 assert!(layer.is_multiline(data));
547 }
548
549 #[test]
550 fn test_ftp_layer_command() {
551 let data = b"USER anonymous\r\n";
552 let layer = make_layer(data);
553 assert_eq!(layer.command(data).unwrap(), "USER");
554 assert_eq!(layer.args(data).unwrap(), "anonymous");
555 assert!(!layer.is_response(data));
556 assert_eq!(layer.message_kind(data), FtpMessageKind::Command);
557 }
558
559 #[test]
560 fn test_ftp_layer_command_no_args() {
561 let data = b"QUIT\r\n";
562 let layer = make_layer(data);
563 assert_eq!(layer.command(data).unwrap(), "QUIT");
564 assert_eq!(layer.args(data).unwrap(), "");
565 }
566
567 #[test]
568 fn test_ftp_layer_pasv_response() {
569 let data = b"227 Entering Passive Mode (192,168,1,1,200,50)\r\n";
570 let layer = make_layer(data);
571 assert_eq!(layer.reply_code(data).unwrap(), 227);
572 assert!(layer.reply_text(data).unwrap().contains("Passive Mode"));
573 }
574
575 #[test]
576 fn test_ftp_reply_code_description() {
577 assert_eq!(reply_code_description(220), "Service ready for new user");
578 assert_eq!(reply_code_description(331), "User name okay, need password");
579 assert_eq!(
580 reply_code_description(550),
581 "Requested action not taken; file unavailable"
582 );
583 }
584
585 #[test]
586 fn test_ftp_field_access() {
587 let data = b"230 User logged in, proceed\r\n";
588 let layer = make_layer(data);
589 assert!(matches!(
590 layer.get_field(data, "reply_code"),
591 Some(Ok(FieldValue::U16(230)))
592 ));
593 assert!(matches!(
594 layer.get_field(data, "is_response"),
595 Some(Ok(FieldValue::Bool(true)))
596 ));
597 assert!(layer.get_field(data, "unknown_field").is_none());
598 }
599}