Transfer, list and stat files against a real FTP server without desynchronising the control channel
This commit is contained in:
+55
-11
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user