Recover expired FTP control sessions
CI / checks (pull_request) Successful in 2m44s
Agents / bot (pull_request) Successful in 8m37s

This commit is contained in:
Danny Kim
2026-09-30 19:12:44 +09:00
parent cd4b4c5e70
commit 179c17a337
8 changed files with 365 additions and 30 deletions
+162
View File
@@ -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(())
}