167 lines
4.1 KiB
Rust
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)
|
|
}
|
|
}
|