Files
memona-plugin-ftp/src/control.rs
T
2026-09-30 15:24:28 +09:00

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)));
}
}