Files
memona-plugin-ftp/src/test_host.rs
T
Danny Kim a1c449843f
CI / checks (pull_request) Successful in 1m24s
Agents / bot (pull_request) Successful in 5m32s
Use an in-memory FTP recovery transport
2026-09-30 19:57:11 +09:00

167 lines
4.1 KiB
Rust

//! 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::collections::VecDeque;
use std::rc::Rc;
pub use memona_plugin_sdk::HostError;
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 || name != "127.0.0.1" {
return Err(HostError::InvalidInput);
}
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 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> {
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)
}
}