//! 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>>> = const { RefCell::new(None) }; } pub struct Fixture(Rc>); 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>, kind: Kind, input: RefCell>, } pub fn connect(name: &str, port: u16, tls: bool) -> Result { 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, 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 { 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) } }