def test_reject_negative_negotiation(): opt = StreamParser.OptionNegotiation( OPTIONS.TM, OPTIONS.TM.value, StreamParser.Host.LOCAL, False ) assert opt.refuse() == StreamParser.OptionNegotiation( OPTIONS.TM, OPTIONS.TM.value, StreamParser.Host.LOCAL, True )
def test_accept_negative_negotiation(): opt = StreamParser.OptionNegotiation( OPTIONS.TM, OPTIONS.TM.value, StreamParser.Host.LOCAL, False ) assert opt.accept() == StreamParser.OptionNegotiation( OPTIONS.TM, OPTIONS.TM.value, StreamParser.Host.LOCAL, False )
def test_stream_user_data_crlf(stream_parser): events = stream_parser.stream_updates([Tokenizer.StreamData(b"Hello,\r\nworld!")]) assert events == [ StreamParser.UserData("Hello,"), StreamParser.UserData("\n"), StreamParser.UserData("world!"), ]
def __init__(self, reader, writer): self.reader = reader self.writer = writer self.encoder = StreamStuffer() self.tokenizer = Tokenizer() self.parser = StreamParser() self.line_buffer = LineBuffer() self.update_buffer = [] self.prompt_mgr = None self.echo_state = EchoOptionState()
def test_stream_command_in_crlf(stream_parser): events = stream_parser.stream_updates( [ Tokenizer.StreamData(b"abc\r"), Tokenizer.Command(B.NOP, B.NOP.value), Tokenizer.StreamData(b"\ndef"), ] ) assert events == [ StreamParser.UserData("abc"), StreamParser.Command(B.NOP, B.NOP.value), StreamParser.UserData("\n"), StreamParser.UserData("def"), ]
def test_stuffer_option_recognized(stuffer): stuffed = stuffer.stuff( StreamParser.OptionNegotiation( OPTIONS.TM, OPTIONS.TM.value, StreamParser.Host.PEER, False ) ) assert stuffed == (B.IAC.byte + B.DONT.byte + OPTIONS.TM.byte)
def _write(self, text): if isinstance(text, str): text = StreamParser.UserData(text) out_data = self.encoder.stuff(text) logger.debug("writing", extra={"data": out_data}) self.writer.write(out_data)
def test_stream_sb(stream_parser): events = stream_parser.stream_updates( [ Tokenizer.Option(B.SB, None, 42), Tokenizer.StreamData(b"1234"), Tokenizer.Command(B.SE, B.SE.value), ] ) assert events == [StreamParser.OptionSubnegotiation(None, 42)]
def test_stream_dont(stream_parser): events = stream_parser.stream_updates( [Tokenizer.Option(B.DONT, OPTIONS.TM, OPTIONS.TM.value)] ) assert events == [ StreamParser.OptionNegotiation( OPTIONS.TM, OPTIONS.TM.value, StreamParser.Host.LOCAL, False ) ]
def test_stuffer_text(stuffer): stuffed = stuffer.stuff(StreamParser.UserData("abc")) assert stuffed == b"abc"
def test_stream_user_data(stream_parser): events = stream_parser.stream_updates([Tokenizer.StreamData(b"Hello, world!")]) assert events == [StreamParser.UserData("Hello, world!")]
def stream_parser(): return StreamParser()
def test_integration(tokenizer, stream_parser): data = ( b"Hel" + B.IAC.byte + B.NOP.byte + b"lo,\r" + # start a subneg B.IAC.byte + B.SB.byte + bytes([42]) + b"abc" + # literal IAC SE as subneg data B.IAC.byte + B.IAC.byte + B.SE.byte + b"def" + # finish the subneg B.IAC.byte + B.SE.byte + b"\0wor" + B.IAC.byte + B.DO.byte + bytes([42]) + b"ld!" ) atomized = [bytes([b]) for b in data] # process it one byte at a time toks = sum([tokenizer.tokens(b) for b in atomized], []) events = sum([stream_parser.stream_updates([tok]) for tok in toks], []) assert events == [ StreamParser.UserData("H"), StreamParser.UserData("e"), StreamParser.UserData("l"), StreamParser.Command(B.NOP, B.NOP.value), StreamParser.UserData("l"), StreamParser.UserData("o"), StreamParser.UserData(","), StreamParser.OptionSubnegotiation(None, 42), StreamParser.UserData("\r"), StreamParser.UserData("w"), StreamParser.UserData("o"), StreamParser.UserData("r"), StreamParser.OptionNegotiation(None, 42, StreamParser.Host.LOCAL, True), StreamParser.UserData("l"), StreamParser.UserData("d"), StreamParser.UserData("!"), ]
def test_stuffer_option_unrecognized(stuffer): stuffed = stuffer.stuff( StreamParser.OptionNegotiation(None, 42, StreamParser.Host.PEER, False) ) assert stuffed == (B.IAC.byte + B.DONT.byte + bytes([42]))
def test_stuffer_crlf(stuffer): stuffed = stuffer.stuff(StreamParser.UserData("abc\ndef\rghi")) assert stuffed == b"abc\r\ndef\r\0ghi"
def test_stuffer_nonascii(stuffer): try: stuffed = stuffer.stuff(StreamParser.UserData("abcdéf")) assert False # should have thrown an exception except UnicodeEncodeError: pass # expected behavior under test
def make_negotiation(self, state): return StreamParser.OptionNegotiation(OPTIONS.ECHO, OPTIONS.ECHO.value, StreamParser.Host.LOCAL, state)
def test_stream_user_data_nonascii(stream_parser): events = stream_parser.stream_updates([Tokenizer.StreamData(b"abc\xabdef")]) assert events == [StreamParser.UserData("abcdef")]
def test_stream_wont(stream_parser): events = stream_parser.stream_updates([Tokenizer.Option(B.WONT, None, 42)]) assert events == [ StreamParser.OptionNegotiation(None, 42, StreamParser.Host.PEER, False) ]
def test_stream_will(stream_parser): events = stream_parser.stream_updates([Tokenizer.Option(B.WILL, None, 42)]) assert events == [ StreamParser.OptionNegotiation(None, 42, StreamParser.Host.PEER, True) ]
def test_stream_command(stream_parser): events = stream_parser.stream_updates([Tokenizer.Command(B.NOP, B.NOP.value)]) assert events == [StreamParser.Command(B.NOP, B.NOP.value)]
class Terminal: READ_SIZE = 2**12 # arbitrary pleasant number? def __init__(self, reader, writer): self.reader = reader self.writer = writer self.encoder = StreamStuffer() self.tokenizer = Tokenizer() self.parser = StreamParser() self.line_buffer = LineBuffer() self.update_buffer = [] self.prompt_mgr = None self.echo_state = EchoOptionState() async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_value, traceback): await self.close() async def close(self): await self.writer.drain() self.writer.close() await self.writer.wait_closed() async def sleep(self, secs): await self.writer.drain() await asyncio.sleep(secs) async def write(self, texts, drain=False): if texts is None: texts = [] if not isinstance(texts, list): texts = [texts] for text in texts: self._write(text) if drain: await self.writer.drain() def _write(self, text): if isinstance(text, str): text = StreamParser.UserData(text) out_data = self.encoder.stuff(text) logger.debug("writing", extra={"data": out_data}) self.writer.write(out_data) @contextlib.contextmanager def prompt(self, prompt): self.prompt_mgr = Prompt(prompt) try: yield finally: self.prompt_mgr = None async def input(self, prompt): with self.prompt(prompt): return await self._input_line() async def input_secret(self, prompt): with self.prompt(prompt): async with self.echo_off(): result = await self._input_line() # echo peer's LF await self.write("\n") return result async def _input_line(self): while True: line = await self._handle_input_once() if line: return line async def _handle_input_once(self): await self.require_line_buffer() annotations, line = self.line_buffer.pop() for annotation in annotations: handler_name = "annotation_" + annotation.__class__.__name__ handler = getattr(self, handler_name) await handler(annotation) return line async def require_line_buffer(self): while not self.line_buffer.has_line(): await self.require_update_buffer() update = self.update_buffer.pop(0) await self.write(self.handle_update(update), drain=True) async def require_update_buffer(self): while not self.update_buffer: await self.write(self.prompt_mgr.require_has_prompt(), drain=True) self.update_buffer = await self.fetch_updates() async def fetch_updates(self): data = await self.reader.read(self.READ_SIZE) logger.debug("read", extra={"data": data}) if not data: raise EOFError() toks = self.tokenizer.tokens(data) return self.parser.stream_updates(toks) async def annotation_TimingMark(self, annotation): await self.write(annotation.option.accept(), drain=True) # # locally requested state changes # @contextlib.asynccontextmanager async def echo_off(self): # The way we turn echo off is by turning on the server echo option # and then just not echoing anythiing. await self.write(self.echo_state.local_request(True), drain=True) try: yield finally: await self.write(self.echo_state.local_request(False), drain=True) # # input stream updates # def handle_update(self, update): handle = getattr(self, "update_" + update.__class__.__name__, None) if not handle: logger.info("unhandled update", extra={"update": update}) return return handle(update) def update_UserData(self, update): self.line_buffer.append(update.data) self.prompt_mgr.mark_user_data() # # telnet options # def update_OptionNegotiation(self, request): handler = self.get_option_handler(request) return handler(request) def get_option_handler(self, request): if request.option is None: return self.option_unhandled handler_name = "option_" + request.option.name handler = getattr(self, "option_" + request.option.name, None) return handler or self.option_unhandled def option_TM(self, request): if not request.state: # client is rejecting this option. we only send it when it's # requested, so it's safe to ignore this. logger.debug("ignoring unmatched request", extra={"request": request}) return if request.host == StreamParser.Host.LOCAL: # client is requesting a TM. logger.info(f"ACCEPTING", extra={"request": request}) self.line_buffer.annotate(self.TimingMark(request)) return # otherwise client is sending us a TM, which we didn't request. ignore it. logger.debug("ignoring unrequested request", extra={"request": request}) def option_ECHO(self, request): return self.echo_state.handle_negotiation(request) def option_unhandled(self, request): if request.state: # Peer is requesting an unsupported option. Refuse. logger.info("rejecting option", extra={"request": request}) return request.refuse() # else client is requesting to disable an option. we support no # options, so all are off. ignore it. else: logger.debug("ignoring option", extra={"request": request}) # telnet commands def update_Command(self, command): handler = self.get_command_handler(command) return handler(command) def get_command_handler(self, command): if command.command is None: return self.command_unhandled handler_name = "command_" + command.command.name handler = getattr(self, handler_name, None) return handler or self.command_unhandled def command_unhandled(self, command): logger.info("unhandled command", extra={"command": command}) def command_IP(self, command): logger.debug("interrupt process") # Currently we only ever read peer input during prompting, so # there's no process to interrupt. For bonus complexity there's a # fair chance the peer will send a timing mark request and discard # everything it gets before we acknowledge, so there's no point # sending a prompt unless we're sure they're ready. For no just # clear the input. self.line_buffer.clear() self.prompt_mgr.mark_interrupt() TimingMark = collections.namedtuple("TimingMark", ["option"])