Transfer, list and stat files against a real FTP server without desynchronising the control channel

This commit is contained in:
Danny Kim
2026-09-25 15:46:38 +09:00
parent e3adbf0462
commit 3febf5530e
8 changed files with 462 additions and 122 deletions
+55 -11
View File
@@ -22,14 +22,29 @@ pub fn outcome(code: u16) -> Result<(), ErrorCode> {
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.
530 | 532 => Err(ErrorCode::Denied),
530 | 532 | 553 => Err(ErrorCode::Denied),
550 => Err(ErrorCode::NotFound),
450 | 452 | 552 => Err(ErrorCode::Conflict),
502 | 504 => Err(ErrorCode::Unsupported),
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 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,
@@ -76,21 +91,25 @@ impl Control {
}
}
/// Reads one complete reply, following the `NNN-` continuation form.
/// 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 first = self.line()?;
let code: u16 = first
let mut text = self.line()?;
let code: u16 = text
.get(..3)
.and_then(|text| text.parse().ok())
.and_then(|digits| digits.parse().ok())
.ok_or(ErrorCode::Unavailable)?;
if first.as_bytes().get(3) != Some(&b'-') {
return Ok((code, first));
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()?;
if line.starts_with(&terminator) {
return Ok((code, line));
let last = line.starts_with(&terminator);
text.push('\n');
text.push_str(&line);
if last {
return Ok((code, text));
}
}
Err(ErrorCode::Unavailable)
@@ -130,6 +149,16 @@ impl Control {
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|)`.
@@ -147,7 +176,7 @@ fn extended_port(text: &str) -> Option<u16> {
#[cfg(test)]
mod tests {
use super::{extended_port, outcome};
use super::{extended_port, outcome, preliminary};
use memona_plugin_sdk::ErrorCode;
#[test]
@@ -167,9 +196,24 @@ mod tests {
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_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)));
}
}