335 lines
11 KiB
Rust
335 lines
11 KiB
Rust
//! 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<u8>,
|
|
}
|
|
|
|
impl Control {
|
|
pub fn open(
|
|
host_name: String,
|
|
port: u16,
|
|
tls: bool,
|
|
) -> Result<Self, ErrorCode> {
|
|
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<String, ErrorCode> {
|
|
loop {
|
|
if let Some(index) = self.buffer.iter().position(|byte| *byte == b'\n') {
|
|
let line: Vec<u8> = 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<String, ErrorCode> {
|
|
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<host::Tcp, ErrorCode> {
|
|
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<u16> {
|
|
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)));
|
|
}
|
|
}
|