Use an in-memory FTP recovery transport
CI / checks (pull_request) Successful in 1m24s
Agents / bot (pull_request) Successful in 5m32s

This commit is contained in:
Danny Kim
2026-09-30 19:57:11 +09:00
parent a12e4d03f7
commit a1c449843f
4 changed files with 179 additions and 230 deletions
+148 -33
View File
@@ -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)
}
}