Use an in-memory FTP recovery transport
This commit is contained in:
+148
-33
@@ -1,51 +1,166 @@
|
||||
//! Native test transport for the generated synchronous `Guest` API. It cannot
|
||||
//! await a Tokio socket, so tests confine it to `spawn_blocking` while their FTP
|
||||
//! server remains async. Production WASM always uses the host's async sockets.
|
||||
//! In-memory FTP host for the generated synchronous `Guest` API. Production
|
||||
//! WASM uses Memona's real async socket host; live QA covers that boundary.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{IpAddr, SocketAddr, TcpStream};
|
||||
use std::time::Duration;
|
||||
use std::collections::VecDeque;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub use memona_plugin_sdk::HostError;
|
||||
|
||||
pub struct Tcp(RefCell<TcpStream>);
|
||||
pub const CONTROL_PORT: u16 = 21_210;
|
||||
const DATA_PORT: u16 = 31_310;
|
||||
const LISTING: &[u8] = b"type=file;size=3; report.md \r\n";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Scenario {
|
||||
ExpiredListing,
|
||||
LostMutationReply,
|
||||
}
|
||||
|
||||
struct State {
|
||||
scenario: Scenario,
|
||||
control_connections: usize,
|
||||
listings: usize,
|
||||
mkdir_commands: usize,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ACTIVE: RefCell<Option<Rc<RefCell<State>>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub struct Fixture(Rc<RefCell<State>>);
|
||||
|
||||
pub struct Summary {
|
||||
pub control_connections: usize,
|
||||
pub listings: usize,
|
||||
pub mkdir_commands: usize,
|
||||
}
|
||||
|
||||
pub fn install(scenario: Scenario) -> Fixture {
|
||||
let state = Rc::new(RefCell::new(State {
|
||||
scenario,
|
||||
control_connections: 0,
|
||||
listings: 0,
|
||||
mkdir_commands: 0,
|
||||
}));
|
||||
ACTIVE.set(Some(state.clone()));
|
||||
Fixture(state)
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub fn summary(&self) -> Summary {
|
||||
let state = self.0.borrow();
|
||||
Summary {
|
||||
control_connections: state.control_connections,
|
||||
listings: state.listings,
|
||||
mkdir_commands: state.mkdir_commands,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
ACTIVE.with_borrow_mut(|active| {
|
||||
if active
|
||||
.as_ref()
|
||||
.is_some_and(|state| Rc::ptr_eq(state, &self.0))
|
||||
{
|
||||
*active = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
enum Kind {
|
||||
Control(usize),
|
||||
Data,
|
||||
}
|
||||
|
||||
pub struct Tcp {
|
||||
state: Rc<RefCell<State>>,
|
||||
kind: Kind,
|
||||
input: RefCell<VecDeque<u8>>,
|
||||
}
|
||||
|
||||
pub fn connect(name: &str, port: u16, tls: bool) -> Result<Tcp, HostError> {
|
||||
if tls {
|
||||
if tls || name != "127.0.0.1" {
|
||||
return Err(HostError::InvalidInput);
|
||||
}
|
||||
let ip: IpAddr = name.parse().map_err(|_| HostError::InvalidInput)?;
|
||||
let timeout = Duration::from_secs(2);
|
||||
let stream = TcpStream::connect_timeout(&SocketAddr::new(ip, port), timeout)
|
||||
.map_err(|_| HostError::Unavailable)?;
|
||||
stream
|
||||
.set_read_timeout(Some(timeout))
|
||||
.map_err(|_| HostError::Unavailable)?;
|
||||
stream
|
||||
.set_write_timeout(Some(timeout))
|
||||
.map_err(|_| HostError::Unavailable)?;
|
||||
Ok(Tcp(RefCell::new(stream)))
|
||||
let state = ACTIVE
|
||||
.with_borrow(Clone::clone)
|
||||
.ok_or(HostError::Internal)?;
|
||||
match port {
|
||||
CONTROL_PORT => {
|
||||
let index = {
|
||||
let mut state = state.borrow_mut();
|
||||
let index = state.control_connections;
|
||||
state.control_connections += 1;
|
||||
index
|
||||
};
|
||||
Ok(Tcp {
|
||||
state,
|
||||
kind: Kind::Control(index),
|
||||
input: RefCell::new(VecDeque::from(b"220 Fixture\r\n".to_vec())),
|
||||
})
|
||||
}
|
||||
DATA_PORT => Ok(Tcp {
|
||||
state,
|
||||
kind: Kind::Data,
|
||||
input: RefCell::new(VecDeque::from(LISTING.to_vec())),
|
||||
}),
|
||||
_ => Err(HostError::Denied),
|
||||
}
|
||||
}
|
||||
|
||||
impl Tcp {
|
||||
pub fn read(&self, limit: u32) -> Result<Vec<u8>, HostError> {
|
||||
let mut bytes = vec![0; limit as usize];
|
||||
let count = self
|
||||
.0
|
||||
.borrow_mut()
|
||||
.read(&mut bytes)
|
||||
.map_err(|_| HostError::Unavailable)?;
|
||||
bytes.truncate(count);
|
||||
Ok(bytes)
|
||||
let mut input = self.input.borrow_mut();
|
||||
if input.is_empty() && matches!(self.kind, Kind::Control(_)) {
|
||||
return Err(HostError::Unavailable);
|
||||
}
|
||||
let count = usize::min(limit as usize, input.len());
|
||||
Ok(input.drain(..count).collect())
|
||||
}
|
||||
|
||||
pub fn write(&self, bytes: &[u8]) -> Result<u32, HostError> {
|
||||
self
|
||||
.0
|
||||
.borrow_mut()
|
||||
.write(bytes)
|
||||
.map(|count| count as u32)
|
||||
.map_err(|_| HostError::Unavailable)
|
||||
let Kind::Control(index) = self.kind else {
|
||||
return Err(HostError::InvalidInput);
|
||||
};
|
||||
let command = std::str::from_utf8(bytes)
|
||||
.map_err(|_| HostError::InvalidInput)?
|
||||
.strip_suffix("\r\n")
|
||||
.ok_or(HostError::InvalidInput)?;
|
||||
let response = {
|
||||
let mut state = self.state.borrow_mut();
|
||||
match command {
|
||||
"USER fixture" => "331 Password required\r\n",
|
||||
"PASS fixture secret " => "230 Signed in\r\n",
|
||||
"TYPE I" => "200 Binary\r\n",
|
||||
"QUIT" => "221 Bye\r\n",
|
||||
"EPSV" => "229 Passive (|||31310|)\r\n",
|
||||
"MLSD /" if matches!(state.scenario, Scenario::ExpiredListing) => {
|
||||
state.listings += 1;
|
||||
"150 Listing\r\n226 Complete\r\n"
|
||||
}
|
||||
"NOOP"
|
||||
if index == 0
|
||||
&& (matches!(state.scenario, Scenario::LostMutationReply)
|
||||
|| state.listings > 0) =>
|
||||
{
|
||||
return Err(HostError::Unavailable);
|
||||
}
|
||||
"NOOP" => "200 Still connected\r\n",
|
||||
"MKD /new"
|
||||
if index == 1
|
||||
&& matches!(state.scenario, Scenario::LostMutationReply) =>
|
||||
{
|
||||
state.mkdir_commands += 1;
|
||||
return Ok(bytes.len() as u32);
|
||||
}
|
||||
_ => return Err(HostError::InvalidInput),
|
||||
}
|
||||
};
|
||||
self.input.borrow_mut().extend(response.as_bytes());
|
||||
Ok(bytes.len() as u32)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user