//! 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 = Result>; type Channel = BufReader; fn guest(value: Result) -> TestResult { 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 { 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>(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(()) }