//! The FTP control channel: one socket, line replies, and passive transfers. use memona_plugin_sdk::ErrorCode; use memona_plugin_sdk::memona::filesystem::host; const MAX_LINE: usize = 8192; const MAX_REPLY_LINES: usize = 256; pub fn transport(error: host::HostError) -> ErrorCode { match error { host::HostError::Denied => ErrorCode::Denied, host::HostError::Cancelled => ErrorCode::Cancelled, host::HostError::InvalidInput => ErrorCode::InvalidInput, host::HostError::Internal => ErrorCode::Internal, host::HostError::Unavailable => ErrorCode::Unavailable, } } /// What an FTP reply code means to a filesystem caller. pub fn outcome(code: u16) -> Result<(), ErrorCode> { match code { 200..=399 => Ok(()), // 530 is a refused login and 550 covers both a missing file and a denied // one; the server does not distinguish them, so neither does this. Where // the command itself narrows what a 550 can mean, `refusal` does better. 530 | 532 | 553 => Err(ErrorCode::Denied), 550 => Err(ErrorCode::NotFound), 450 | 452 | 552 => Err(ErrorCode::Conflict), 500 | 502 | 504 => Err(ErrorCode::Unsupported), // An invalid REST offset, such as one past the end of the file. 501 | 554 => Err(ErrorCode::InvalidInput), _ => Err(ErrorCode::Unavailable), } } /// What a refusal of a create or a delete means. Those two commands are the /// only ones whose 550 can be drawn by something already in the way, so they /// are the only ones whose reply text is read. A listing, a stat or a read is /// classified by `outcome` alone, because pure-ftpd answers a path inside a /// folder it will not open with `550 Can't check for file existence`, which /// truly cannot be told apart from absence and must stay `NotFound`. /// /// A server whose wording this does not recognise keeps the plain /// classification, so an unfamiliar phrasing degrades to today's answer /// rather than to a wrong `Conflict`. pub fn refusal(code: u16, text: &str) -> Result<(), ErrorCode> { match outcome(code) { Err(ErrorCode::NotFound) if names_a_conflict(text) => { Err(ErrorCode::Conflict) } verdict => verdict, } } /// Whether a refusal names something already there: a path that exists, or a /// directory that still holds children. Matched on whole words, so the /// `existence` of "Can't check for file existence" is never the `exists` of /// "File exists". fn names_a_conflict(text: &str) -> bool { let lowered = text.to_lowercase(); let words: Vec<&str> = lowered .split(|letter: char| !letter.is_ascii_alphanumeric()) .filter(|word| !word.is_empty()) .collect(); words.contains(&"exists") || words.contains(&"nonempty") || words .windows(2) .any(|pair| matches!(pair, ["not" | "non", "empty"])) } /// What the first reply to a transfer command means. A transfer answers with /// a preliminary reply while the data connection is open; its completion /// comes only after that connection closes and is read separately. pub fn preliminary(code: u16) -> Result<(), ErrorCode> { match code { 100..=199 => Ok(()), // A transfer that skips the preliminary reply leaves nothing to read once // the data connection closes, so it is refused rather than guessed at. 200..=399 => Err(ErrorCode::Unavailable), other => outcome(other), } } pub struct Control { socket: host::Tcp, host: String, buffer: Vec, } impl Control { pub fn open( host_name: String, port: u16, tls: bool, ) -> Result { let socket = host::connect(&host_name, port, tls).map_err(transport)?; let mut control = Self { socket, host: host_name, buffer: Vec::new(), }; // The greeting is a reply like any other, and a 4xx or 5xx one means the // server is refusing the session before a command was even sent. let (code, _) = control.reply()?; outcome(code)?; Ok(control) } fn line(&mut self) -> Result { loop { if let Some(index) = self.buffer.iter().position(|byte| *byte == b'\n') { let line: Vec = self.buffer.drain(..=index).collect(); return Ok( String::from_utf8_lossy(&line) .trim_end_matches(['\r', '\n']) .to_owned(), ); } if self.buffer.len() > MAX_LINE { return Err(ErrorCode::Unavailable); } let chunk = self.socket.read(4096).map_err(transport)?; if chunk.is_empty() { return Err(ErrorCode::Unavailable); } self.buffer.extend_from_slice(&chunk); } } /// Reads one complete reply, following the `NNN-` continuation form. The /// text keeps every line, since an MLST reply carries its facts on one. pub fn reply(&mut self) -> Result<(u16, String), ErrorCode> { let mut text = self.line()?; let code: u16 = text .get(..3) .and_then(|digits| digits.parse().ok()) .ok_or(ErrorCode::Unavailable)?; if text.as_bytes().get(3) != Some(&b'-') { return Ok((code, text)); } let terminator = format!("{code} "); for _ in 0..MAX_REPLY_LINES { let line = self.line()?; let last = line.starts_with(&terminator); text.push('\n'); text.push_str(&line); if last { return Ok((code, text)); } } Err(ErrorCode::Unavailable) } /// Sends one command. A newline in an argument would forge a second command, /// so it is refused rather than escaped. pub fn send(&mut self, command: &str) -> Result<(u16, String), ErrorCode> { if command.contains(['\r', '\n']) || command.len() > MAX_LINE { return Err(ErrorCode::InvalidInput); } let line = format!("{command}\r\n"); let mut sent = 0; while sent < line.len() { let written = self .socket .write(&line.as_bytes()[sent..]) .map_err(transport)? as usize; if written == 0 { return Err(ErrorCode::Unavailable); } sent += written; } self.reply() } pub fn expect(&mut self, command: &str) -> Result { let (code, text) = self.send(command)?; outcome(code)?; Ok(text) } /// Opens a passive data connection, preferring EPSV so the address the /// server names is never one this plugin could be redirected to. pub fn passive(&mut self) -> Result { let text = self.expect("EPSV")?; let port = extended_port(&text).ok_or(ErrorCode::Unavailable)?; host::connect(&self.host, port, false).map_err(transport) } /// Ends a transfer whose completion reply is still owed, once its data /// connection is closed. RFC 959 has the server answer with one reply for /// the transfer and one for the ABOR itself, whether it had finished or /// not, so both are read and the channel is in sync for the next command. pub fn abort(&mut self) -> Result<(), ErrorCode> { self.send("ABOR")?; self.reply()?; Ok(()) } } /// Reads the port out of `229 Entering Extended Passive Mode (|||1234|)`. fn extended_port(text: &str) -> Option { let start = text.find('(')?; let end = text[start..].find(')')? + start; let inner = text.get(start + 1..end)?; let separator = inner.chars().next()?; inner .rsplit(separator) .find(|part| !part.is_empty())? .parse() .ok() } #[cfg(test)] mod tests { use super::{extended_port, outcome, preliminary, refusal}; use memona_plugin_sdk::ErrorCode; #[test] fn reads_the_port_from_an_extended_passive_reply() { assert_eq!( extended_port("229 Entering Extended Passive Mode (|||49152|)"), Some(49152) ); // The separator is whatever the server chose, not always a pipe. assert_eq!(extended_port("229 ok (!!!1024!)"), Some(1024)); assert_eq!(extended_port("229 ok (|||0|)"), Some(0)); assert_eq!(extended_port("229 ok"), None); assert_eq!(extended_port("229 ok (|||not-a-port|)"), None); } #[test] fn reply_codes_keep_refused_and_missing_apart() { assert!(outcome(226).is_ok()); assert!(matches!(outcome(530), Err(ErrorCode::Denied))); assert!(matches!(outcome(553), Err(ErrorCode::Denied))); assert!(matches!(outcome(550), Err(ErrorCode::NotFound))); assert!(matches!(outcome(452), Err(ErrorCode::Conflict))); assert!(matches!(outcome(502), Err(ErrorCode::Unsupported))); assert!(matches!(outcome(554), Err(ErrorCode::InvalidInput))); assert!(matches!(outcome(421), Err(ErrorCode::Unavailable))); // A preliminary reply is not a completion: the plain classification // treats it as a failure, so it must not be used on transfer commands. assert!(matches!(outcome(150), Err(ErrorCode::Unavailable))); } #[test] fn a_refused_create_or_delete_names_what_is_in_the_way() { // The two refusals a person can see through, in pure-ftpd's wording. assert!(matches!( refusal(550, "550 Can't create directory: File exists"), Err(ErrorCode::Conflict) )); assert!(matches!( refusal(550, "550 Can't remove directory: Directory not empty"), Err(ErrorCode::Conflict) )); // The same two conditions as other servers phrase them. assert!(matches!( refusal( 550, "550 Cannot create a file when that file already exists." ), Err(ErrorCode::Conflict) )); assert!(matches!( refusal(550, "550 /work/dir: Directory not empty"), Err(ErrorCode::Conflict) )); assert!(matches!( refusal(550, "550 The directory is non-empty."), Err(ErrorCode::Conflict) )); } #[test] fn a_refusal_that_cannot_be_read_stays_a_missing_path() { // The genuine ambiguity: a path inside a folder that will not open. The // word is "existence", not "exists", and it must not be mistaken for one. assert!(matches!( refusal(550, "550 Can't check for file existence"), Err(ErrorCode::NotFound) )); // A path that really is absent, and one the account may not touch. assert!(matches!( refusal(550, "550 Can't remove directory: No such file or directory"), Err(ErrorCode::NotFound) )); assert!(matches!( refusal(550, "550 Can't create directory: Permission denied"), Err(ErrorCode::NotFound) )); // A server that says nothing useful keeps the plain classification. assert!(matches!( refusal(550, "550 Create directory operation failed."), Err(ErrorCode::NotFound) )); assert!(matches!( refusal(550, "550 Échec de la création du répertoire"), Err(ErrorCode::NotFound) )); } #[test] fn only_a_550_is_reread_for_a_conflict() { assert!(refusal(257, "257 \"/work/dir\" created").is_ok()); assert!(matches!( refusal(530, "530 Login incorrect, the account exists"), Err(ErrorCode::Denied) )); assert!(matches!( refusal(553, "553 File name not allowed, it exists"), Err(ErrorCode::Denied) )); assert!(matches!( refusal(452, "452 Not enough space, and the file exists"), Err(ErrorCode::Conflict) )); assert!(matches!( refusal(421, "421 Service not available"), Err(ErrorCode::Unavailable) )); } #[test] fn a_transfer_starts_on_a_preliminary_reply_only() { assert!(preliminary(125).is_ok()); assert!(preliminary(150).is_ok()); assert!(matches!(preliminary(226), Err(ErrorCode::Unavailable))); assert!(matches!(preliminary(550), Err(ErrorCode::NotFound))); assert!(matches!(preliminary(553), Err(ErrorCode::Denied))); assert!(matches!(preliminary(425), Err(ErrorCode::Unavailable))); } }