Recover expired FTP control sessions
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
//! Real FTP commands over a native test socket, with an async local server.
|
||||
//! This verifies guest recovery; WASM host integration is exercised separately.
|
||||
|
||||
use super::Ftp;
|
||||
use memona_plugin_sdk::{ErrorCode, Guest, Locator, Setting};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||
type Channel = BufReader<TcpStream>;
|
||||
|
||||
fn guest<T>(value: Result<T, ErrorCode>) -> TestResult<T> {
|
||||
value.map_err(|error| format!("guest operation failed: {error:?}").into())
|
||||
}
|
||||
|
||||
fn config(port: u16) -> Vec<(String, Setting)> {
|
||||
vec![
|
||||
("host".into(), Setting::Text("127.0.0.1".into())),
|
||||
("port".into(), Setting::Text(port.to_string())),
|
||||
("username".into(), Setting::Text("fixture".into())),
|
||||
(
|
||||
"password".into(),
|
||||
Setting::Secret(" fixture secret ".into()),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
async fn reply(channel: &mut Channel, text: &str) -> TestResult {
|
||||
channel.get_mut().write_all(text.as_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expect(channel: &mut Channel, expected: &str) -> TestResult {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
channel.read_line(&mut line).await?;
|
||||
if line == "NOOP\r\n" {
|
||||
reply(channel, "200 Still connected\r\n").await?;
|
||||
continue;
|
||||
}
|
||||
assert_eq!(line, format!("{expected}\r\n"));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
async fn login(listener: &TcpListener) -> TestResult<Channel> {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
let mut channel = BufReader::new(stream);
|
||||
reply(&mut channel, "220 Fixture\r\n").await?;
|
||||
expect(&mut channel, "USER fixture").await?;
|
||||
reply(&mut channel, "331 Password required\r\n").await?;
|
||||
expect(&mut channel, "PASS fixture secret ").await?;
|
||||
reply(&mut channel, "230 Signed in\r\n").await?;
|
||||
expect(&mut channel, "TYPE I").await?;
|
||||
reply(&mut channel, "200 Binary\r\n").await?;
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
async fn listing(channel: &mut Channel) -> TestResult {
|
||||
expect(channel, "EPSV").await?;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
reply(
|
||||
channel,
|
||||
&format!("229 Passive (|||{}|)\r\n", listener.local_addr()?.port()),
|
||||
)
|
||||
.await?;
|
||||
expect(channel, "MLSD /").await?;
|
||||
reply(channel, "150 Listing\r\n").await?;
|
||||
let (mut data, _) = listener.accept().await?;
|
||||
data.write_all(b"type=file;size=3; report.md \r\n").await?;
|
||||
data.shutdown().await?;
|
||||
drop(data);
|
||||
reply(channel, "226 Complete\r\n").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_listing_reconnects_after_the_server_expires_the_control_session()
|
||||
-> TestResult {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let (expired, wait_expired) = oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let mut channel = login(&listener).await?;
|
||||
listing(&mut channel).await?;
|
||||
reply(&mut channel, "421 Control connection timed out\r\n").await?;
|
||||
channel.get_mut().shutdown().await?;
|
||||
drop(channel);
|
||||
expired.send(()).map_err(|_| "guest disappeared")?;
|
||||
let mut channel = login(&listener).await?;
|
||||
listing(&mut channel).await?;
|
||||
expect(&mut channel, "QUIT").await?;
|
||||
reply(&mut channel, "221 Bye\r\n").await
|
||||
})
|
||||
.await?
|
||||
});
|
||||
let client = tokio::task::spawn_blocking(move || -> TestResult {
|
||||
guest(Ftp::open("ftp".into(), config(port)))?;
|
||||
let first = guest(Ftp::list(Locator::Path("/".into()), None, 10))?;
|
||||
assert_eq!(first.entries[0].name, " report.md ");
|
||||
wait_expired.blocking_recv()?;
|
||||
let next = guest(Ftp::list(Locator::Path("/".into()), None, 10))?;
|
||||
assert_eq!(next.entries[0].name, " report.md ");
|
||||
Ftp::close();
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
if client.is_err() {
|
||||
server.abort();
|
||||
}
|
||||
client?;
|
||||
server.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_before_a_mutation_never_replays_a_lost_mutation_reply()
|
||||
-> TestResult {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let (expired, wait_expired) = oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let mut channel = login(&listener).await?;
|
||||
channel.get_mut().shutdown().await?;
|
||||
drop(channel);
|
||||
expired.send(()).map_err(|_| "guest disappeared")?;
|
||||
let mut channel = login(&listener).await?;
|
||||
expect(&mut channel, "MKD /new").await?;
|
||||
// The mutation may have succeeded; lose its reply deliberately.
|
||||
channel.get_mut().shutdown().await?;
|
||||
drop(channel);
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(listener)
|
||||
})
|
||||
.await?
|
||||
});
|
||||
let client = tokio::task::spawn_blocking(move || -> TestResult {
|
||||
guest(Ftp::open("ftp".into(), config(port)))?;
|
||||
wait_expired.blocking_recv()?;
|
||||
assert!(matches!(
|
||||
Ftp::mkdir(Locator::Path("/new".into())),
|
||||
Err(ErrorCode::Unavailable)
|
||||
));
|
||||
Ftp::close();
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
if client.is_err() {
|
||||
server.abort();
|
||||
}
|
||||
client?;
|
||||
let listener = server.await??;
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), listener.accept())
|
||||
.await
|
||||
.is_err(),
|
||||
"a mutation must not be replayed on a third control connection"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user