163 lines
5.2 KiB
Rust
163 lines
5.2 KiB
Rust
//! 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(())
|
|
}
|