Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 90 additions & 8 deletions crates/suppaftp/src/async_ftp/smol_ftp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1094,20 +1094,26 @@ where
}

let code_word: u32 = self.code_from_buffer(&line, 3)?;
let code = Status::from(code_word);
let mut code = Status::from(code_word);

trace!("Code parsed from response: {} ({})", code, code_word);

// multiple line reply
// loop while the line does not begin with the code and a space (or dash)
// RFC 959 requires the terminal line to repeat the opening code, but some servers,
// including glFTPd, use a different operative code. FEAT remains special because
// `feat` reads its continuation lines after `read_response` returns the `211-` opener.
// M-DOCUMENTED-MAGIC: FTP replies start with a three-digit code and one separator.
let expected = [line[0], line[1], line[2], 0x20];
let alt_expected = if expected_code.contains(&Status::System) {
[line[0], line[1], line[2], b'-']
} else {
expected
let feat_opener = [line[0], line[1], line[2], b'-'];
let is_terminal = |reply: &[u8]| {
reply.len() >= 4
&& reply[0].is_ascii_digit()
&& reply[1].is_ascii_digit()
&& reply[2].is_ascii_digit()
&& (reply[3] == b' '
|| (expected_code.contains(&Status::System) && reply[0..4] == feat_opener))
};
trace!("CC IN: {:?}", line);
while line.len() < 5 || (line[0..4] != expected && line[0..4] != alt_expected) {
while !is_terminal(&line) {
line.clear();
let bytes_read = self.read_line(&mut line).await?;
if bytes_read == 0 {
Expand All @@ -1120,6 +1126,11 @@ where
trace!("CC IN: {:?}", line);
}

if line[0..4] != expected {
code = Status::from(self.code_from_buffer(&line, 3)?);
trace!("Code updated from terminal response: {}", code);
}

let response: Response = Response::new(code, body);
// Return Ok or error with response
if expected_code.contains(&code) {
Expand Down Expand Up @@ -2015,6 +2026,77 @@ mod test {
format!("temp_{}", name)
}

// M-MOCKABLE-SYSCALLS: use a local server for deterministic control-response tests.
async fn connect_with_control_response(
expected_command: &'static str,
response: &'static [u8],
) -> (AsyncFtpStream, smol::Task<()>) {
use smol::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as AsyncBufReader};

let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let port = listener.local_addr().expect("missing local address").port();
let handle = smol::spawn(async move {
let (stream, _) = listener.accept().await.expect("no incoming connection");
let mut writer = stream.clone();
let mut reader = AsyncBufReader::new(stream);

AsyncWriteExt::write_all(&mut writer, b"220 Welcome\r\n")
.await
.unwrap();

let mut line = String::new();
AsyncBufReadExt::read_line(&mut reader, &mut line)
.await
.unwrap();
assert_eq!(line, format!("{expected_command}\r\n"));
AsyncWriteExt::write_all(&mut writer, response)
.await
.unwrap();
});

let tcp = TcpStream::connect(("127.0.0.1", port))
.await
.expect("failed to connect");
let stream = AsyncFtpStream::connect_with_stream(tcp)
.await
.expect("failed handshake");
(stream, handle)
}

#[test]
fn should_accept_mismatched_multiline_terminal_code() {
smol::block_on(async {
let response = b"553-\r\n\
553- Directory excluded (precheck).\r\n\
150 Opening BINARY mode data connection.\r\n";
let (mut stream, handle) =
connect_with_control_response("STOR upload.bin", response).await;

let response = stream
.custom_command("STOR upload.bin", &[Status::AboutToSend])
.await
.expect("mismatched terminal code should be accepted");

assert_eq!(response.status, Status::AboutToSend);
handle.await;
})
}

#[test]
fn should_preserve_feat_multiline_handling() {
smol::block_on(async {
let response = b"211-Features:\r\n UTF8\r\n211 END\r\n";
let (mut stream, handle) = connect_with_control_response("FEAT", response).await;

let features = stream.feat().await.expect("FEAT response should be parsed");

assert!(features.contains_key("UTF8"));
handle.await;
})
}

#[test]
fn should_accept_200_for_file_operations() {
smol::block_on(async {
Expand Down
87 changes: 79 additions & 8 deletions crates/suppaftp/src/async_ftp/tokio_ftp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1079,20 +1079,26 @@ where
}

let code_word: u32 = self.code_from_buffer(&line, 3)?;
let code = Status::from(code_word);
let mut code = Status::from(code_word);

trace!("Code parsed from response: {} ({})", code, code_word);

// multiple line reply
// loop while the line does not begin with the code and a space (or dash)
// RFC 959 requires the terminal line to repeat the opening code, but some servers,
// including glFTPd, use a different operative code. FEAT remains special because
// `feat` reads its continuation lines after `read_response` returns the `211-` opener.
// M-DOCUMENTED-MAGIC: FTP replies start with a three-digit code and one separator.
let expected = [line[0], line[1], line[2], 0x20];
let alt_expected = if expected_code.contains(&Status::System) {
[line[0], line[1], line[2], b'-']
} else {
expected
let feat_opener = [line[0], line[1], line[2], b'-'];
let is_terminal = |reply: &[u8]| {
reply.len() >= 4
&& reply[0].is_ascii_digit()
&& reply[1].is_ascii_digit()
&& reply[2].is_ascii_digit()
&& (reply[3] == b' '
|| (expected_code.contains(&Status::System) && reply[0..4] == feat_opener))
};
trace!("CC IN: {:?}", line);
while line.len() < 5 || (line[0..4] != expected && line[0..4] != alt_expected) {
while !is_terminal(&line) {
line.clear();
let bytes_read = self.read_line(&mut line).await?;
if bytes_read == 0 {
Expand All @@ -1105,6 +1111,11 @@ where
trace!("CC IN: {:?}", line);
}

if line[0..4] != expected {
code = Status::from(self.code_from_buffer(&line, 3)?);
trace!("Code updated from terminal response: {}", code);
}

let response: Response = Response::new(code, body);
// Return Ok or error with response
if expected_code.contains(&code) {
Expand Down Expand Up @@ -1931,6 +1942,66 @@ mod test {
format!("temp_{}", name)
}

// M-MOCKABLE-SYSCALLS: use a local server for deterministic control-response tests.
async fn connect_with_control_response(
expected_command: &'static str,
response: &'static [u8],
) -> (AsyncFtpStream, tokio::task::JoinHandle<()>) {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as TokioBufReader};

let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let port = listener.local_addr().expect("missing local address").port();
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("no incoming connection");
let (read_half, mut writer) = stream.into_split();
let mut reader = TokioBufReader::new(read_half);

writer.write_all(b"220 Welcome\r\n").await.unwrap();

let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
assert_eq!(line, format!("{expected_command}\r\n"));
writer.write_all(response).await.unwrap();
});

let tcp = TcpStream::connect(("127.0.0.1", port))
.await
.expect("failed to connect");
let stream = AsyncFtpStream::connect_with_stream(tcp)
.await
.expect("failed handshake");
(stream, handle)
}

#[tokio::test]
async fn should_accept_mismatched_multiline_terminal_code() {
let response = b"553-\r\n\
553- Directory excluded (precheck).\r\n\
150 Opening BINARY mode data connection.\r\n";
let (mut stream, handle) = connect_with_control_response("STOR upload.bin", response).await;

let response = stream
.custom_command("STOR upload.bin", &[Status::AboutToSend])
.await
.expect("mismatched terminal code should be accepted");

assert_eq!(response.status, Status::AboutToSend);
handle.await.expect("server task panicked");
}

#[tokio::test]
async fn should_preserve_feat_multiline_handling() {
let response = b"211-Features:\r\n UTF8\r\n211 END\r\n";
let (mut stream, handle) = connect_with_control_response("FEAT", response).await;

let features = stream.feat().await.expect("FEAT response should be parsed");

assert!(features.contains_key("UTF8"));
handle.await.expect("server task panicked");
}

#[tokio::test]
async fn should_accept_200_for_file_operations() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as TokioBufReader};
Expand Down
80 changes: 72 additions & 8 deletions crates/suppaftp/src/sync_ftp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -881,20 +881,26 @@ where
}

let code_word: u32 = self.code_from_buffer(&line, 3)?;
let code = Status::from(code_word);
let mut code = Status::from(code_word);

trace!("Code parsed from response: {} ({})", code, code_word);

// multiple line reply
// loop while the line does not begin with the code and a space (or dash)
// RFC 959 requires the terminal line to repeat the opening code, but some servers,
// including glFTPd, use a different operative code. FEAT remains special because
// `feat` reads its continuation lines after `read_response` returns the `211-` opener.
// M-DOCUMENTED-MAGIC: FTP replies start with a three-digit code and one separator.
let expected = [line[0], line[1], line[2], 0x20];
let alt_expected = if expected_code.contains(&Status::System) {
[line[0], line[1], line[2], b'-']
} else {
expected
let feat_opener = [line[0], line[1], line[2], b'-'];
let is_terminal = |reply: &[u8]| {
reply.len() >= 4
&& reply[0].is_ascii_digit()
&& reply[1].is_ascii_digit()
&& reply[2].is_ascii_digit()
&& (reply[3] == b' '
|| (expected_code.contains(&Status::System) && reply[0..4] == feat_opener))
};
trace!("CC IN: {:?}", line);
while line.len() < 5 || (line[0..4] != expected && line[0..4] != alt_expected) {
while !is_terminal(&line) {
line.clear();
let bytes_read = self.read_line(&mut line)?;
if bytes_read == 0 {
Expand All @@ -907,6 +913,11 @@ where
trace!("CC IN: {:?}", line);
}

if line[0..4] != expected {
code = Status::from(self.code_from_buffer(&line, 3)?);
trace!("Code updated from terminal response: {}", code);
}

let response: Response = Response::new(code, body);
// Return Ok or error with response
if expected_code.contains(&code) {
Expand Down Expand Up @@ -1908,6 +1919,33 @@ mod test {
format!("temp_{}", name)
}

// M-MOCKABLE-SYSCALLS: use a local server for deterministic control-response tests.
fn connect_with_control_response(
expected_command: &'static str,
response: &'static [u8],
) -> (FtpStream, std::thread::JoinHandle<()>) {
use std::io::{BufRead as _, BufReader as IoBufReader, Write as _};

let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind");
let port = listener.local_addr().expect("missing local address").port();
let handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().expect("no incoming connection");
let mut writer = stream.try_clone().expect("failed to clone stream");
let mut reader = IoBufReader::new(stream);

writer.write_all(b"220 Welcome\r\n").unwrap();

let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(line, format!("{expected_command}\r\n"));
writer.write_all(response).unwrap();
});

let tcp = TcpStream::connect(("127.0.0.1", port)).expect("failed to connect");
let stream = FtpStream::connect_with_stream(tcp).expect("failed handshake");
(stream, handle)
}

/// Test if the stream is Send
fn is_send<T: Send>(_send: T) {}

Expand Down Expand Up @@ -1942,6 +1980,32 @@ mod test {
is_sync::<FtpStream>(ftp_stream);
}

#[test]
fn should_accept_mismatched_multiline_terminal_code() {
let response = b"553-\r\n\
553- Directory excluded (precheck).\r\n\
150 Opening BINARY mode data connection.\r\n";
let (mut stream, handle) = connect_with_control_response("STOR upload.bin", response);

let response = stream
.custom_command("STOR upload.bin", &[Status::AboutToSend])
.expect("mismatched terminal code should be accepted");

assert_eq!(response.status, Status::AboutToSend);
handle.join().expect("server thread panicked");
}

#[test]
fn should_preserve_feat_multiline_handling() {
let response = b"211-Features:\r\n UTF8\r\n211 END\r\n";
let (mut stream, handle) = connect_with_control_response("FEAT", response);

let features = stream.feat().expect("FEAT response should be parsed");

assert!(features.contains_key("UTF8"));
handle.join().expect("server thread panicked");
}

#[test]
fn should_accept_200_for_file_operations() {
use std::io::{BufRead, BufReader as IoBufReader, Write};
Expand Down
Loading