diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36c738f5..f06c367d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,8 @@ jobs: run: python -Im tox -e ${{ matrix.toxenv }} tests: - name: Python ${{ matrix.python-version }} (${{ matrix.os }})${{ matrix.asyncio-debug && ' [asyncio-debug]' || '' }} + name: Python ${{ matrix.python-version }} (${{ matrix.os }})${{ matrix.asyncio-debug && ' [asyncio-debug]' || '' }}${{ matrix.optional && ' [OPTIONAL]' || '' }} + continue-on-error: ${{ matrix.optional || false }} strategy: fail-fast: false matrix: @@ -65,15 +66,22 @@ jobs: - "3.13" include: - # Python 3.14 pre-release + # Python 3.14 pre-release (Linux + Windows) - os: ubuntu-latest python-version: "3.14" + - os: windows-latest + python-version: "3.14" # Python 3.14 with asyncio debug mode - os: ubuntu-latest python-version: "3.14" asyncio-debug: true + # Python 3.15 pre-release (optional) + - os: ubuntu-latest + python-version: "3.15" + optional: true + runs-on: ${{ matrix.os }} steps: @@ -145,9 +153,8 @@ jobs: python -Im coverage report --format=markdown >> $GITHUB_STEP_SUMMARY - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: - token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false verbose: true diff --git a/README.rst b/README.rst index 67e3858c..57457007 100644 --- a/README.rst +++ b/README.rst @@ -38,111 +38,109 @@ The python telnetlib.py_ module removed by Python 3.13 is also re-distributed as See the `Guidebook`_ for examples and the `API documentation`_. -Asyncio Protocol ----------------- - -The core protocol and CLI utilities are written using an `Asyncio Interface`_. - -Blocking API ------------- - -A Synchronous interface, modeled after telnetlib.py_ (client) and miniboa_ (server), with various -enhancements in protocol negotiation is also provided. See `sync API documentation`_ for more. - Command-line Utilities ---------------------- -Two CLI tools are included: ``telnetlib3-client`` for connecting to servers -and ``telnetlib3-server`` for hosting a server. +The CLI utility ``telnetlib3-client`` is provided for connecting to servers and +``telnetlib3-server`` for hosting a server. -Both tools argument ``--shell=my_module.fn_shell`` describing a python -module path to a function of signature ``async def shell(reader, writer)``. -The server also provides ``--pty-exec`` argument to host a stand-alone -program. +Both tools accept the argument ``--shell=my_module.fn_shell`` describing a python module path to a +function of signature ``async def shell(reader, writer)``. The server also provides ``--pty-exec`` +argument to host stand-alone programs. :: - # utf8 roguelike server + # telnet to utf8 roguelike server telnetlib3-client nethack.alt.org - # utf8 bbs + + # or bbs, telnetlib3-client xibalba.l33t.codes 44510 - # automatic communication with telnet server + + # automatic script communicates with a server telnetlib3-client --shell bin.client_wargame.shell 1984.ws 666 - # run a server with default shell + + # run a server bound with the default shell bound to 127.0.0.1 6023 telnetlib3-server - # or custom port and ip and shell + + # or custom ip, port and shell telnetlib3-server 0.0.0.0 1984 --shell=bin.server_wargame.shell - # run an external program with a pseudo-terminal (raw mode is default) - telnetlib3-server --pty-exec /bin/bash -- --login - # or a linemode program, bc (calculator) - telnetlib3-server --pty-exec /bin/bc --line-mode + # host an external program with a pseudo-terminal (raw mode is default) + telnetlib3-server --pty-exec /bin/bash -- --login -There are also fingerprinting CLIs, ``telnetlib3-fingerprint`` and -``telnetlib3-fingerprint-server`` + # or host a program in linemode, + telnetlib3-server --pty-exec /bin/bc --line-mode -:: +There are also two fingerprinting CLIs, ``telnetlib3-fingerprint`` and +``telnetlib3-fingerprint-server``:: # host a server, wait for clients to connect and fingerprint them, telnetlib3-fingerprint-server - # report fingerprint of telnet server on 1984.ws + # report fingerprint of the telnet server on 1984.ws telnetlib3-fingerprint 1984.ws +Encoding +~~~~~~~~ -Legacy telnetlib ----------------- +The default encoding is the system locale, usually UTF-8, and, without negotiation of BINARY +transmission, all Telnet protocol text *should* be limited to ASCII text, by strict compliance of +Telnet. Further, the encoding used *should* be negotiated by CHARSET. -This library contains an *unadulterated copy* of Python 3.12's telnetlib.py_, -from the standard library before it was removed in Python 3.13. +When these conditions are true, telnetlib3-server and telnetlib3-client allow connections of any +encoding supporting by the python language, and additionally specially ``ATASCII`` and ``PETSCII`` +encodings. Any server capable of negotiating CHARSET or LANG through NEW_ENVIRON is also presumed +to support BINARY. -To migrate code, change import statements: +From a February 2026 `census of MUDs `_ and `BBSs servers +`_: -.. code-block:: python +- 2.8% of MUDs support bi-directional CHARSET +- 0.5% of BBSs support bi-directional CHARSET. +- 18.4% of BBSs support BINARY. +- 3.2% of MUDs support BINARY. - # OLD imports: - import telnetlib +For this reason, it is often required to specify the encoding, eg.! - # NEW imports: - import telnetlib3 + telnetlib3-client --encoding=cp437 20forbeers.com 1337 -``telnetlib3`` did not provide server support, while this library also provides -both client and server support through a similar Blocking API interface. +Raw Mode +~~~~~~~~ -See `sync API documentation`_ for details. +Some telnet servers, especially BBS systems or those designed for serial transmission but are +connected to a TCP socket without any telnet negotiation may require "raw" mode argument:: -Encoding --------- + telnetlib3-client --raw-mode area52.tk 5200 --encoding=atascii -Often required, ``--encoding`` and ``--force-binary``:: +Asyncio Protocol +---------------- - telnetlib3-client --encoding=cp437 --force-binary 20forbeers.com 1337 +The core protocol and CLI utilities are written using an `Asyncio Interface`_. -The default encoding is the system locale, usually UTF-8, but all Telnet -protocol text *should* be limited to ASCII until BINARY mode is agreed by -compliance of their respective RFCs. +Blocking API +------------ -However, many clients and servers that are capable of non-ascii encodings like -UTF-8 or CP437 may not be capable of negotiating about BINARY, NEW_ENVIRON, -or CHARSET to negotiate about it. +A Synchronous interface, modeled after telnetlib.py_ (client) and miniboa_ (server), with various +enhancements in protocol negotiation is also provided. See `sync API documentation`_ for more. -In this case, use ``--force-binary`` and ``--encoding`` when the encoding of -the remote end is known. +Legacy telnetlib +---------------- -Go-Ahead (GA) --------------- +This library contains an *unadulterated copy* of Python 3.12's telnetlib.py_, +from the standard library before it was removed in Python 3.13. -When a client does not negotiate Suppress Go-Ahead (SGA), the server sends -``IAC GA`` after output to signal that the client may transmit. This is -correct behavior for MUD clients like Mudlet that expect prompt detection -via GA. +To migrate code, change import statements: -If GA causes unwanted output for your use case, disable it:: +.. code-block:: python - telnetlib3-server --never-send-ga + # OLD imports: + import telnetlib -For PTY shells, GA is sent after 500ms of output idle time to avoid -injecting GA in the middle of streaming output. + # NEW imports: + import telnetlib3 + +This library *also* provides an additional client (and server) API through a similar interface but +offering more advanced negotiation features and options. See `sync API documentation`_ for more. Quick Example ============= @@ -156,7 +154,7 @@ A simple telnet server: async def shell(reader, writer): writer.write('\r\nWould you like to play a game? ') - inp = await reader.read(1) + inp = await reader.readline() if inp: writer.echo(inp) writer.write('\r\nThey say the only way to win ' @@ -170,6 +168,29 @@ A simple telnet server: asyncio.run(main()) +A client that connects and plays the game: + +.. code-block:: python + + import asyncio + import telnetlib3 + + async def shell(reader, writer): + while True: + output = await reader.read(1024) + if not output: + break + if '?' in output: + writer.write('y\r\n') + print(output, end='', flush=True) + print() + + async def main(): + reader, writer = await telnetlib3.open_connection('localhost', 6023) + await shell(reader, writer) + + asyncio.run(main()) + More examples are available in the `Guidebook`_ and the `bin/`_ directory of the repository. Features @@ -185,7 +206,7 @@ The following RFC specifications are implemented: * `rfc-857`_, "Telnet Echo Option", May 1983. * `rfc-858`_, "Telnet Suppress Go Ahead Option", May 1983. * `rfc-859`_, "Telnet Status Option", May 1983. -* `rfc-860`_, "Telnet Timing mark Option", May 1983. +* `rfc-860`_, "Telnet Timing Mark Option", May 1983. * `rfc-885`_, "Telnet End of Record Option", Dec 1983. * `rfc-930`_, "Telnet Terminal Type Option", Jan 1984. * `rfc-1073`_, "Telnet Window Size Option", Oct 1988. @@ -231,18 +252,6 @@ The following RFC specifications are implemented: .. _sync API documentation: https://telnetlib3.readthedocs.io/en/latest/api/sync.html .. _miniboa: https://github.com/shmup/miniboa .. _asyncio: https://docs.python.org/3/library/asyncio.html -.. _wait_for(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.TelnetConnection.wait_for -.. _get_extra_info(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.TelnetConnection.get_extra_info -.. _readline(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.TelnetConnection.readline -.. _read_until(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.TelnetConnection.read_until -.. _active: https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.active -.. _address: https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.address -.. _terminal_type: https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.terminal_type -.. _columns: https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.columns -.. _rows: https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.rows -.. _idle(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.idle -.. _duration(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.duration -.. _deactivate(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.deactivate Further Reading --------------- diff --git a/bin/__init__.py b/bin/__init__.py index d6cb50c3..f8b32293 100644 --- a/bin/__init__.py +++ b/bin/__init__.py @@ -1 +1 @@ -# bin/ package — shell callbacks for telnetlib3 examples. +# bin/ package -- shell callbacks for telnetlib3 examples. diff --git a/bin/server_mud.py b/bin/server_mud.py index e79478aa..788694c0 100755 --- a/bin/server_mud.py +++ b/bin/server_mud.py @@ -3,8 +3,12 @@ Usage:: - $ python bin/server_mud.py - $ telnet localhost 6023 + $ telnetlib3-server --line-mode --shell bin.server_mud.shell + $ telnetlib3-client localhost 6023 + +The default Telnet server negotiates WILL SGA + WILL ECHO, which puts telnetlib3-client into kludge +(raw) mode. MUD clients would prefer that you start the server with ``--line-mode``, to keep +clients in their (default) NVT line mode. """ from __future__ import annotations @@ -15,12 +19,10 @@ import random import asyncio import logging -import argparse import unicodedata from typing import Any # local -import telnetlib3 from telnetlib3.telopt import GMCP, MSDP, MSSP, WILL from telnetlib3.server_shell import readline2 @@ -101,7 +103,7 @@ } -class Weapon: # pylint: disable=too-few-public-methods +class Weapon: """A weapon that can be held or placed in a room.""" def __init__(self, name: str, damage: tuple[int, int], start_room: str) -> None: @@ -117,7 +119,7 @@ def damage_display(self) -> str: return f"{self.damage[0]}-{self.damage[1]}" -class Player: # pylint: disable=too-few-public-methods +class Player: """A connected player.""" def __init__(self, name: str = "Adventurer") -> None: @@ -272,9 +274,7 @@ def on_gmcp(writer: Any, package: str, data: Any) -> None: writer.write(f"[DEBUG GMCP] {package}: {json.dumps(data)}\r\n") -def get_msdp_var( # pylint: disable=too-many-return-statements - player: Player, var: str -) -> dict[str, Any] | None: +def get_msdp_var(player: Player, var: str) -> dict[str, Any] | None: """Return MSDP value dict for *var*, or ``None`` if unknown.""" if var == "CHARACTER_NAME": return {"CHARACTER_NAME": player.name} @@ -834,35 +834,3 @@ async def shell(reader: Any, writer: Any) -> None: broadcast_room(None, player.room, f"{player.name} has left.") update_room_all(player.room) writer.close() - - -def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - """Parse command-line arguments.""" - ap = argparse.ArgumentParser(description="Mini-MUD demo server with telnetlib3") - ap.add_argument("--host", default="127.0.0.1", help="bind address (default: 127.0.0.1)") - ap.add_argument("--port", type=int, default=6023, help="bind port (default: 6023)") - ap.add_argument("--log-level", default="INFO", help="log level (default: INFO)") - return ap.parse_args(argv) - - -async def main(argv: list[str] | None = None) -> None: - """Start the MUD server.""" - args = parse_args(argv) - logging.basicConfig( - level=getattr(logging, args.log_level.upper()), - format="%(asctime)s %(message)s", - datefmt="%H:%M:%S", - ) - server = await telnetlib3.create_server(host=args.host, port=args.port, shell=shell) - log.info("%s running on %s:%d", SERVER_NAME, args.host, args.port) - print( - f"{SERVER_NAME} running on" - f" {args.host}:{args.port}\n" - f"Connect with: telnet {args.host} {args.port}\n" - "Press Ctrl+C to stop" - ) - await server.wait_closed() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/docs/api/session_context.rst b/docs/api/session_context.rst new file mode 100644 index 00000000..65760cc3 --- /dev/null +++ b/docs/api/session_context.rst @@ -0,0 +1,5 @@ +session_context +--------------- + +.. automodule:: telnetlib3._session_context + :members: diff --git a/docs/conf.py b/docs/conf.py index 27dca502..250e25ca 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -68,10 +68,10 @@ # built documents. # # The short X.Y version. -version = "2.6" +version = "3.0" # The full version, including alpha/beta/rc tags. -release = "2.6.1" # keep in sync with pyproject.toml and telnetlib3/accessories.py !! +release = "3.0.0" # keep in sync with pyproject.toml and telnetlib3/accessories.py !! # The language for content auto-generated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/guidebook.rst b/docs/guidebook.rst index 23c29ca3..b5f41864 100644 --- a/docs/guidebook.rst +++ b/docs/guidebook.rst @@ -637,6 +637,20 @@ property:: print(f"Mode: {writer.mode}") # 'local', 'remote', or 'kludge' print(f"ECHO enabled: {writer.remote_option.enabled(ECHO)}") +Go-Ahead (GA) +-------------- + +When a client does not negotiate Suppress Go-Ahead (SGA), the server sends +``IAC GA`` after output to signal that the client may transmit. This is +correct behavior for MUD clients like Mudlet that expect prompt detection +via GA. + +If GA causes unwanted output for your use case, disable it:: + + telnetlib3-server --never-send-ga + +For PTY shells, GA is sent after 500ms of output idle time -- Go ahead (GA) isn't typically used +with interactive programs, it is probably best to disable it. Fingerprinting Server ===================== @@ -760,15 +774,14 @@ Running The repository includes a "mini-MUD" example at `bin/server_mud.py `_ with -rooms, combat, weapons, GMCP/MSDP/MSSP support, and basic persistence. +rooms, combat, weapons, GMCP/MSDP/MSSP support, and basic persistence. MUD +servers usually run in "line mode":: -:: - - telnetlib3-server --shell bin.server_mud.shell + telnetlib3-server --line-mode --shell bin.server_mud.shell -Then, connect with any telnet or MUD client:: +Connect with any telnet or MUD client:: - telnet localhost 6023 + telnetlib3-client localhost 6023 Legacy telnetlib Compatibility ============================== diff --git a/docs/history.rst b/docs/history.rst index f816df69..3862be74 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -1,15 +1,45 @@ History ======= +3.0.0 + * change: :attr:`~telnetlib3.client_base.BaseClient.connect_minwait` default + now 0 (was 1.0 seconds in library API). + * change: ``force_binary`` auto-enabled when CHARSET is negotiated + (:rfc:`2066`) or ``LANG``/``CHARSET`` received via NEW_ENVIRON + (:rfc:`1572`). SyncTERM font detection also enables it unconditionally. + * change: ``--connect-timeout`` default changed from no limit to 10 seconds. + * change: ``--reverse-video`` CLI option from 2.4.0 was removed. + * change: CGA, EGA, and Amiga palettes removed from ``--colormatch``; + only ``vga`` is available at this time. ``ice_colors`` are now True by default. + * bugfix: ``read_some()`` in synchronous API (``TelnetConnection`` and + ``ServerConnection``) blocked until EOF instead of returning available + data. Now returns as soon as any data is available. + * new: ``TelnetSessionContext`` base class + and ``writer.ctx`` attribute for + per-connection session state. Subclass to add application-specific + attributes (e.g. MUD client state). + * new: ``--ice-colors`` (default on) treats SGR 5 (blink) as bright + background for proper 16-color BBS/ANSI art display. + * new: ``--typescript FILE`` records session output to a file, similar to + the Unix ``script(1)`` command. + * new: shared ``TelnetProtocolBase`` mixin extracted from duplicated + server and client protocol code. + * new: ``_atomic_json_write()`` and ``_BytesSafeEncoder`` helpers in + ``_paths`` module for fingerprinting subsystem. + * enhancement: Microsoft Telnet (``telnet.exe``) compatibility refined — server + now sends ``DO NEW_ENVIRON`` but excludes ``USER`` variable instead of + skipping the option entirely, :ghissue:`24`. + * enhancement: comprehensive pylint and mypy cleanup across the codebase. + 2.6.1 - * bugfix: dependency of wcwidth version. + * bugfix: dependency of ``wcwidth`` version. 2.6.0 * new: TLS support (TELNETS). :func:`~telnetlib3.client.open_connection` - accepts an ``ssl`` parameter (``True``, or an :class:`ssl.SSLContext`). - :func:`~telnetlib3.server.create_server` accepts an ``ssl`` parameter + accepts an *ssl* parameter (``True``, or an :class:`ssl.SSLContext`). + :func:`~telnetlib3.server.create_server` accepts an *ssl* parameter (:class:`ssl.SSLContext`). New CLI options: ``--ssl``, ``--ssl-cafile``, ``--ssl-no-verify`` for ``telnetlib3-client``; ``--ssl-certfile``, - ``--ssl-keyfile`` and ``--tls-auto`` for ``telnetlib3-server``. + ``--ssl-keyfile``, and ``--tls-auto`` for ``telnetlib3-server``. * new: the default server shell now displays ``Ready (secure: TLSv1.3).`` for TLS connections (the protocol version shown is negotiated dynamically). * bugfix: ``telnetlib3-client`` now sets terminal mode to the server's @@ -22,25 +52,28 @@ History * bugfix: graceful EOF handling — connection close no longer prints a traceback. 2.5.0 - * change: ``telnetlib3-client`` now defaults to raw terminal mode (no line buffering, no local - echo), which is correct for most servers. Use ``--line-mode`` to restore line-buffered - local-echo behavior. - * change: ``telnetlib3-server --pty-exec`` now defaults to raw PTY mode. Use ``--line-mode`` to - restore cooked PTY mode with echo. + * change: ``telnetlib3-client`` now defaults to raw terminal mode (no line + buffering, no local echo), which is correct for most servers. Use + ``--line-mode`` to restore line-buffered local-echo behavior. + * change: ``telnetlib3-server --pty-exec`` now defaults to raw PTY mode. Use + ``--line-mode`` to restore cooked PTY mode with echo. * change: ``connect_minwait`` default reduced to 0 across - :class:`~telnetlib3.client_base.BaseClient`, :func:`~telnetlib3.client.open_connection`, and - ``telnetlib3-client``. Negotiation continues asynchronously. Use ``--connect-minwait`` to - restore a delay if needed, or, use :meth:`~telnetlib3.stream_writer.TelnetWriter.wait_for` in - server or client shells to await a specific negotiation state. - * new: Color, keyboard input translation and ``--encoding`` support for ATASCII (ATARI ASCII) and - PETSCII (Commodore ASCII). - * new: SyncTERM/CTerm font selection sequence detection (``CSI Ps1 ; Ps2 SP D``). Both - ``telnetlib3-fingerprint`` and ``telnetlib3-client`` detect font switching and auto-switch - encoding to the matching codec (e.g. font 36 = ATASCII, 32-35 = PETSCII, 0 = CP437). Explicit - ``--encoding`` takes precedence. - * new: :data:`~telnetlib3.accessories.TRACE` log level (5, below ``DEBUG``) with - :func:`~telnetlib3.accessories.hexdump` style output for all sent and received bytes. Use - ``--loglevel=trace``. + :class:`~telnetlib3.client_base.BaseClient`, + :func:`~telnetlib3.client.open_connection`, and ``telnetlib3-client``. + Negotiation continues asynchronously. Use ``--connect-minwait`` to restore a + delay if needed, or use + :meth:`~telnetlib3.stream_writer.TelnetWriter.wait_for` in server or client + shells to await a specific negotiation state. + * new: color, keyboard input translation, and ``--encoding`` support for + ATASCII (ATARI ASCII) and PETSCII (Commodore ASCII). + * new: SyncTERM/CTerm font selection sequence detection + (``CSI Ps1 ; Ps2 SP D``). Both ``telnetlib3-fingerprint`` and + ``telnetlib3-client`` detect font switching and auto-switch encoding to the + matching codec (e.g. font 36 = ATASCII, 32--35 = PETSCII, 0 = CP437). + Explicit ``--encoding`` takes precedence. + * new: :data:`~telnetlib3.accessories.TRACE` log level (5, below ``DEBUG``) + with :func:`~telnetlib3.accessories.hexdump`-style output for all sent and + received bytes. Use ``--loglevel=trace``. * bugfix: :func:`~telnetlib3.guard_shells.robot_check` now uses a narrow character (space) instead of a wide Unicode character, allowing retro terminal emulators to pass. @@ -54,22 +87,22 @@ History * bugfix: ``telnetlib3-fingerprint`` re-encodes prompt responses for retro encodings so servers receive the correct EOL byte. * bugfix: ``telnetlib3-fingerprint`` no longer crashes with - ``LookupError`` when the server negotiates an unknown charset. + :exc:`LookupError` when the server negotiates an unknown charset. Banner formatting falls back to ``latin-1``. - * bugfix: :meth:`~telnetlib3.client.TelnetClient.send_charset` normalises + * bugfix: :meth:`~telnetlib3.client.TelnetClient.send_charset` normalizes non-standard encoding names (``iso-8859-02`` to ``iso-8859-2``, ``cp-1250`` to ``cp1250``, etc.). - * enhancement: ``telnetlib3-fingerprint`` responds more like a terminal and to more - y/n prompts about colors, encoding, etc. to collect more banners for https://bbs.modem.xyz/ - project. + * enhancement: ``telnetlib3-fingerprint`` responds more like a terminal and + to more y/n prompts about colors, encoding, etc. to collect more banners + for the `bbs.modem.xyz `_ project. * enhancement: ``telnetlib3-fingerprint`` banner formatting uses ``surrogateescape`` error handler, preserving raw high bytes (e.g. CP437 art) as surrogates instead of replacing them with U+FFFD. 2.4.0 * new: :mod:`telnetlib3.color_filter` module — translates 16-color ANSI SGR - codes to 24-bit RGB from hardware palettes (EGA, CGA, VGA, Amiga, xterm). - Enabled by default. New client CLI options: ``--colormatch``, + codes to 24-bit RGB from hardware palettes (EGA, CGA, VGA, xterm). + Enabled by default. New client CLI options: ``--colormatch``, ``--color-brightness``, ``--color-contrast``, ``--background-color``, ``--reverse-video``. * new: :func:`~telnetlib3.mud.zmp_decode`, @@ -93,33 +126,33 @@ History auto-answers yes/no, color, UTF-8 menu, ``who``, and ``help`` prompts. * enhancement: ``--banner-max-bytes`` option for ``telnetlib3-fingerprint``; default raised from 1024 to 65536. - * new: ATASCII (Atari 8-bit) codec -- ``--encoding=atascii`` for connecting + * new: ATASCII (Atari 8-bit) codec — ``--encoding=atascii`` for connecting to Atari BBS systems. Maps all 256 byte values to Unicode including - graphics characters, card suits, and the inverse-video range (0x80-0xFF). + graphics characters, card suits, and the inverse-video range (0x80--0xFF). ATASCII EOL (0x9B) maps to newline. Aliases: ``atari8bit``, ``atari_8bit``. * enhancement: ``--encoding=atascii``, ``--encoding=petscii``, and ``--encoding=atarist`` now auto-enable ``--force-binary`` for both client - and server, since these encodings use bytes 0x80-0xFF for standard glyphs. + and server, since these encodings use bytes 0x80--0xFF for standard glyphs. * bugfix: rare LINEMODE ACK loop with misbehaving servers that re-send unchanged MODE without ACK. - * bugfix: unknown IAC commands no longer raise ``ValueError``; treated as + * bugfix: unknown IAC commands no longer raise :exc:`ValueError`; treated as data. * bugfix: client no longer asserts on ``TTYPE IS`` from server. - * bugfix: ``request_forwardmask()`` only called on server side. + * bugfix: :meth:`~telnetlib3.stream_writer.TelnetWriter.request_forwardmask` + only called on server side. * change: ``wcwidth`` is now a required dependency. - 2.3.0 - * bugfix: repeat "socket.send() raised exception." exceptions + * bugfix: repeat "socket.send() raised exception." exceptions. * bugfix: server incorrectly accepted ``DO TSPEED`` and ``DO SNDLOC`` - with ``WILL`` responses. These are client-only options per :rfc:`1079` + with ``WILL`` responses. These are client-only options per :rfc:`1079` and :rfc:`779`; the server now correctly rejects them. * bugfix: ``LINEMODE DO FORWARDMASK`` subnegotiation no longer raises - ``NotImplementedError``; the mask is accepted (logged only). + :exc:`NotImplementedError`; the mask is accepted (logged only). * bugfix: echo doubling in ``--pty-exec`` without ``--pty-raw`` (linemode). - * bugfix: missing LICENSE.txt in sdist file. + * bugfix: missing ``LICENSE.txt`` in sdist file. * bugfix: GMCP, MSDP, and MSSP decoding now uses ``--encoding`` when set, - falling back to latin-1 for non-UTF-8 bytes instead of lossy replacement. + falling back to ``latin-1`` for non-UTF-8 bytes instead of lossy replacement. * bugfix: ``NEW_ENVIRON SEND`` with empty payload now correctly interpreted as "send all" per :rfc:`1572`. * new: :mod:`telnetlib3.mud` module with encode/decode functions for @@ -127,52 +160,54 @@ History protocols. * new: :meth:`~telnetlib3.stream_writer.TelnetWriter.send_gmcp`, :meth:`~telnetlib3.stream_writer.TelnetWriter.send_msdp`, and - :meth:`~telnetlib3.stream_writer.TelnetWriter.send_mssp` methods for sending MUD protocol - data, with corresponding :meth:`~telnetlib3.stream_writer.TelnetWriter.handle_gmcp`, + :meth:`~telnetlib3.stream_writer.TelnetWriter.send_mssp` methods for + sending MUD protocol data, with corresponding + :meth:`~telnetlib3.stream_writer.TelnetWriter.handle_gmcp`, :meth:`~telnetlib3.stream_writer.TelnetWriter.handle_msdp`, and :meth:`~telnetlib3.stream_writer.TelnetWriter.handle_mssp` callbacks. - * new: ``connect_timeout`` arguments for client and ``--connect-timeout`` - Client CLI argument, :ghissue:`30`. + * new: ``connect_timeout`` parameter for client and ``--connect-timeout`` + CLI argument, :ghissue:`30`. * new: ``telnetlib3-fingerprint-server`` CLI with extended ``NEW_ENVIRON`` for fingerprinting of connected clients. * new: ``telnetlib3-fingerprint`` CLI for fingerprinting the given remote server, probing telnet option support and capturing banners. * enhancement: reversed ``WILL``/``DO`` for directional options (e.g. ``WILL NAWS`` from server, ``DO TTYPE`` from client) now gracefully refused with - ``DONT``/``WONT`` instead of raising ``ValueError``. - * enhancement: ``NEW_ENVIRON SEND`` and response logging improved -- + ``DONT``/``WONT`` instead of raising :exc:`ValueError`. + * enhancement: ``NEW_ENVIRON SEND`` and response logging improved — ``SEND (all)`` / ``env send: (empty)`` instead of raw byte dumps. * enhancement: ``telnetlib3-fingerprint`` now probes MSDP and MSSP options and captures MSSP server status data in session output. * new: ``--always-will``, ``--always-do``, ``--scan-type``, ``--mssp-wait``, - ``--banner-quiet-time``, ``--banner-max-wait`` options for ``telnetlib3-fingerprint``. + ``--banner-quiet-time``, ``--banner-max-wait`` options for + ``telnetlib3-fingerprint``. 2.2.0 * bugfix: workaround for Microsoft Telnet client crash on - ``SB NEW_ENVIRON SEND``, :ghissue:`24`. Server now defers ``DO + ``SB NEW_ENVIRON SEND``, :ghissue:`24`. Server now defers ``DO NEW_ENVIRON`` until TTYPE cycling identifies the client, skipping it entirely for MS Telnet (ANSI/VT100). - * bugfix: in handling of LINEMODE FORWARDMASK command bytes. + * bugfix: handling of LINEMODE FORWARDMASK command bytes. * bugfix: SLC fingerprinting byte handling. * bugfix: send IAC GA (Go-Ahead) after prompts when SGA is not negotiated. - Fixes hanging for MUD clients like Mudlet. PTY shell uses a 500ms idle - timer. Use ``--never-send-ga`` to suppress like old behavior. - * performance: with 'smarter' negotiation, default ``connect_maxwait`` + Fixes hanging for MUD clients like Mudlet. PTY shell uses a 500ms idle + timer. Use ``--never-send-ga`` to suppress like old behavior. + * performance: with smarter negotiation, default ``connect_maxwait`` reduced from 4.0s to 1.5s. - * performance: both client and server protocol data_received methods - have approximately ~50x throughput improvement in bulk data transfers. + * performance: both client and server protocol ``data_received`` methods + have approximately 50x throughput improvement in bulk data transfers. * new: :class:`~telnetlib3.server.Server` class returned by :func:`~telnetlib3.server.create_server` with :meth:`~telnetlib3.server.Server.wait_for_client` method and :attr:`~telnetlib3.server.Server.clients` property for tracking connected clients. * new: :meth:`~telnetlib3.stream_writer.TelnetWriter.wait_for` and - :meth:`~telnetlib3.stream_writer.TelnetWriter.wait_for_condition` methods for waiting on - telnet option negotiation state. + :meth:`~telnetlib3.stream_writer.TelnetWriter.wait_for_condition` methods + for waiting on telnet option negotiation state. * new: :mod:`telnetlib3.sync` module with blocking (non-asyncio) APIs: :class:`~telnetlib3.sync.TelnetConnection` for clients, :class:`~telnetlib3.sync.BlockingTelnetServer` for servers. - * new: :mod:`~telnetlib3.server_pty_shell` module and demonstrating + * new: :mod:`~telnetlib3.server_pty_shell` module demonstrating ``telnetlib3-server --pty-exec`` CLI argument and related ``--pty-raw`` server CLI option for raw PTY mode, used by most programs that handle their own terminal I/O. @@ -182,134 +217,145 @@ History * new: :mod:`~telnetlib3.fingerprinting` module for telnet client identification and capability probing. * new: ``--send-environ`` client CLI option to control which environment - variables are sent via NEW_ENVIRON. Default no longer includes HOME or + variables are sent via NEW_ENVIRON. Default no longer includes HOME or SHELL. 2.0.8 - * bugfix: object has no attribute '_extra' :ghissue:`100` + * bugfix: object has no attribute ``_extra``, :ghissue:`100`. 2.0.7 - * bugfix: respond WILL CHARSET with DO CHARSET + * bugfix: respond WILL CHARSET with DO CHARSET. 2.0.6 - * bugfix: corrected CHARSET protocol client/server role behavior :ghissue:`59` - * bugfix: allow ``--force-binary`` and ``--encoding`` to be combined to prevent - long ``encoding failed after 4.00s`` delays in ``telnetlib3-server`` with - non-compliant clients, :ghissue:`74`. - * bugfix: reduce ``telnetlib3-client`` connection delay, session begins as - soon as TTYPE and either NEW_ENVIRON or CHARSET negotiation is completed. - * bugfix: remove `'NoneType' object has no attribute 'is_closing'` message - on some types of closed connections - * bugfix: further improve ``telnetlib3-client`` performance, capable of - 11.2 Mbit/s or more. - * bugfix: more gracefully handle unsupported SB STATUS codes. - * feature: ``telnetlib3-client`` now negotiates terminal resize events. + * bugfix: corrected CHARSET protocol client/server role behavior, + :ghissue:`59`. + * bugfix: allow ``--force-binary`` and ``--encoding`` to be combined to + prevent long "encoding failed after 4.00s" delays in ``telnetlib3-server`` + with non-compliant clients, :ghissue:`74`. + * bugfix: reduce ``telnetlib3-client`` connection delay; session begins as + soon as TTYPE and either NEW_ENVIRON or CHARSET negotiation is completed. + * bugfix: remove "'NoneType' object has no attribute 'is_closing'" message + on some types of closed connections. + * bugfix: further improve ``telnetlib3-client`` performance, capable of + 11.2 Mbit/s or more. + * bugfix: more gracefully handle unsupported SB STATUS codes. + * feature: ``telnetlib3-client`` now negotiates terminal resize events. 2.0.5 - * feature: legacy `telnetlib.py` from Python 3.11 now redistributed, - note change to project `LICENSE.txt` file. - * feature: Add :meth:`~telnetlib3.stream_reader.TelnetReader.readuntil_pattern` :ghissue:`92` by - :ghuser:`agicy` - * feature: Add :meth:`~telnetlib3.stream_writer.TelnetWriter.wait_closed` - async method in response to :ghissue:`82`. - * bugfix: README Examples do not work :ghissue:`81` - * bugfix: `TypeError: buf expected bytes, got ` on client timeout - in :class:`~telnetlib3.server.TelnetServer`, :ghissue:`87` - * bugfix: Performance issues with client protocol under heavy load, - demonstrating server `telnet://1984.ws` now documented in README. - * bugfix: annoying `socket.send() raised exception` repeating warning, - :ghissue:`89`. - * bugfix: legacy use of get_event_loop, :ghissue:`85`. - * document: about encoding and force_binary in response to :ghissue:`90` - * feature: add tests to source distribution, :ghissue:`37` - * test coverage increased by ~20% + * feature: legacy ``telnetlib.py`` from Python 3.11 now redistributed; + note change to project ``LICENSE.txt`` file. + * feature: add :meth:`~telnetlib3.stream_reader.TelnetReader.readuntil_pattern`, + :ghissue:`92` by :ghuser:`agicy`. + * feature: add :meth:`~telnetlib3.stream_writer.TelnetWriter.wait_closed` + async method in response to :ghissue:`82`. + * bugfix: README examples do not work, :ghissue:`81`. + * bugfix: ``TypeError: buf expected bytes, got `` on client + timeout in :class:`~telnetlib3.server.TelnetServer`, :ghissue:`87`. + * bugfix: performance issues with client protocol under heavy load; + demonstrating server ``telnet://1984.ws`` now documented in README. + * bugfix: annoying "socket.send() raised exception" repeating warning, + :ghissue:`89`. + * bugfix: legacy use of ``get_event_loop``, :ghissue:`85`. + * document: about encoding and ``force_binary`` in response to :ghissue:`90`. + * feature: add tests to source distribution, :ghissue:`37`. + * test coverage increased by ~20%. 2.0.4 - * change: stop using setuptools library to get current software version + * change: stop using setuptools library to get current software version. 2.0.3 - * bugfix: NameError: when debug=True is used with asyncio.run, :ghissue:`75` + * bugfix: :exc:`NameError` when ``debug=True`` is used with + :func:`asyncio.run`, :ghissue:`75`. 2.0.2 - * bugfix: NameError: name 'sleep' is not defined in stream_writer.py + * bugfix: :exc:`NameError`: name ``sleep`` is not defined in + ``stream_writer.py``. 2.0.1 - * bugfix: "write after close" is disregarded, caused many errors logged in socket.send() - * bugfix: in accessories.repr_mapping() about using shlex.quote on non-str, - `TypeError: expected string or bytes-like object, got 'int'` - * bugfix: about fn_encoding using repr() on :class:`~telnetlib3.stream_reader.TelnetReaderUnicode` - * bugfix: TelnetReader.is_closing() raises AttributeError - * deprecation: ``TelnetReader.close`` and ``TelnetReader.connection_closed`` - emit warning, use :meth:`~telnetlib3.stream_reader.TelnetReader.at_eof` and - :meth:`~telnetlib3.stream_reader.TelnetReader.feed_eof` instead. - * deprecation: the ``loop`` argument is no longer accepted by - :class:`~telnetlib3.stream_reader.TelnetReader`. - * enhancement: Add Generic Mud Communication Protocol support :ghissue:`63` by - :ghuser:`gtaylor`! - * change: :class:`~telnetlib3.stream_reader.TelnetReader` and - :class:`~telnetlib3.stream_writer.TelnetWriter` no longer derive - from :class:`asyncio.StreamReader` and :class:`asyncio.StreamWriter`, this - fixes some TypeError in signatures and runtime + * bugfix: "write after close" is disregarded, caused many errors logged in + ``socket.send()``. + * bugfix: in ``accessories.repr_mapping()`` about using + :func:`shlex.quote` on non-str, ``TypeError: expected string or + bytes-like object, got 'int'``. + * bugfix: about ``fn_encoding`` using :func:`repr` on + :class:`~telnetlib3.stream_reader.TelnetReaderUnicode`. + * bugfix: ``TelnetReader.is_closing()`` raises :exc:`AttributeError`. + * deprecation: :meth:`~telnetlib3.stream_reader.TelnetReader.close` and + :meth:`~telnetlib3.stream_reader.TelnetReader.connection_closed` emit + warning; use :meth:`~telnetlib3.stream_reader.TelnetReader.at_eof` and + :meth:`~telnetlib3.stream_reader.TelnetReader.feed_eof` instead. + * deprecation: the ``loop`` argument is no longer accepted by + :class:`~telnetlib3.stream_reader.TelnetReader`. + * enhancement: add Generic MUD Communication Protocol support, :ghissue:`63` + by :ghuser:`gtaylor`. + * change: :class:`~telnetlib3.stream_reader.TelnetReader` and + :class:`~telnetlib3.stream_writer.TelnetWriter` no longer derive + from :class:`asyncio.StreamReader` and :class:`asyncio.StreamWriter`; + this fixes some :exc:`TypeError` in signatures and runtime. 2.0.0 - * change: Support Python 3.9, 3.10, 3.11. Drop Python 3.6 and earlier, All code - and examples have been updated to the new-style PEP-492 syntax. - * change: the ``loop``, ``event_loop``, and ``log`` arguments are no longer accepted by - any class initializers. - * note: This release has a known memory leak when using the ``_waiter_connected`` and - ``_waiter_closed`` arguments to Client or Shell class initializers, please do - not use them. A replacement "wait_for_negotiation" awaitable is planned for a - future release. - * enhancement: Add COM-PORT-OPTION subnegotiation support :ghissue:`57` by - :ghuser:`albireox` + * change: support Python 3.9, 3.10, 3.11. Drop Python 3.6 and earlier. + All code and examples have been updated to the new-style PEP-492 syntax. + * change: the ``loop``, ``event_loop``, and ``log`` arguments are no longer + accepted by any class initializers. + * note: this release has a known memory leak when using the + ``_waiter_connected`` and ``_waiter_closed`` arguments to Client or Shell + class initializers; please do not use them. A replacement + ``wait_for_negotiation`` awaitable is planned for a future release. + * enhancement: add COM-PORT-OPTION subnegotiation support, :ghissue:`57` by + :ghuser:`albireox`. 1.0.4 - * bugfix: NoneType error on EOF/Timeout, introduced in previous - version 1.0.3, :ghissue:`51` by :ghuser:`zofy`. + * bugfix: :exc:`TypeError` on EOF/Timeout, introduced in previous + version 1.0.3, :ghissue:`51` by :ghuser:`zofy`. 1.0.3 * bugfix: circular reference between transport and protocol, :ghissue:`43` by :ghuser:`fried`. 1.0.2 - * add --speed argument to telnet client :ghissue:`35` by :ghuser:`hughpyle`. + * add ``--speed`` argument to telnet client, :ghissue:`35` by + :ghuser:`hughpyle`. 1.0.1 - * add python3.7 support, drop python 3.4 and earlier, :ghissue:`33` by + * add Python 3.7 support, drop Python 3.4 and earlier, :ghissue:`33` by :ghuser:`AndrewNelis`. 1.0.0 - * First general release for standard API: Instead of encouraging twisted-like + * First general release for standard API: instead of encouraging Twisted-like override of protocol methods, we provide a "shell" callback interface, receiving argument pairs (reader, writer). 0.5.0 * bugfix: linemode MODE is now acknowledged. * bugfix: default stream handler sends 80 x 24 in cols x rows, not 24 x 80. - * bugfix: waiter_closed future on client defaulted to wrong type. + * bugfix: ``waiter_closed`` future on client defaulted to wrong type. * bugfix: telnet shell (TelSh) no longer paints over final exception line. 0.4.0 * bugfix: cannot connect to IPv6 address as client. - * change: TelnetClient.CONNECT_DEFERED class attribute renamed DEFERRED. - Default value changed to 50ms from 100ms. - * change: TelnetClient.waiter renamed to TelnetClient.waiter_closed. - * enhancement: TelnetClient.waiter_connected future added. + * change: ``TelnetClient.CONNECT_DEFERED`` class attribute renamed + ``DEFERRED``. Default value changed to 50ms from 100ms. + * change: ``TelnetClient.waiter`` renamed to ``TelnetClient.waiter_closed``. + * enhancement: ``TelnetClient.waiter_connected`` future added. 0.3.0 - * bugfix: cannot bind to IPv6 address :ghissue:`5`. - * enhancement: Futures waiter_connected, and waiter_closed added to server. - * change: TelSh.feed_slc merged into TelSh.feed_byte as slc_function keyword. - * change: TelnetServer.CONNECT_DEFERED class attribute renamed DEFERRED. - Default value changed to 50ms from 100ms. - * enhancement: Default TelnetServer.PROMPT_IMMEDIATELY = False ensures prompt - is not displayed until negotiation is considered final. It is no longer - "aggressive". - * enhancement: TelnetServer.pause_writing and resume_writing callback wired. - * enhancement: TelSh.pause_writing and resume_writing methods added. + * bugfix: cannot bind to IPv6 address, :ghissue:`5`. + * enhancement: futures ``waiter_connected`` and ``waiter_closed`` added to + server. + * change: ``TelSh.feed_slc`` merged into ``TelSh.feed_byte`` as + ``slc_function`` keyword. + * change: ``TelnetServer.CONNECT_DEFERED`` class attribute renamed + ``DEFERRED``. Default value changed to 50ms from 100ms. + * enhancement: default ``TelnetServer.PROMPT_IMMEDIATELY = False`` ensures + prompt is not displayed until negotiation is considered final. It is no + longer "aggressive". + * enhancement: ``TelnetServer.pause_writing`` and ``resume_writing`` callback + wired. + * enhancement: ``TelSh.pause_writing`` and ``resume_writing`` methods added. 0.2.4 - * bugfix: pip installation issue :ghissue:`8`. + * bugfix: pip installation issue, :ghissue:`8`. 0.2 * enhancement: various example programs were included in this release. diff --git a/pyproject.toml b/pyproject.toml index 1db59d7a..535a918b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "telnetlib3" -version = "2.6.1" +version = "3.0.0" description = " Python Telnet server and client CLI and Protocol library" readme = "README.rst" license = "ISC" @@ -54,8 +54,8 @@ docs = [ "sphinx-autodoc-typehints", ] extras = [ - "ucs-detect>=2", "prettytable", + "ucs-detect>=2,<3", ] [project.scripts] @@ -108,8 +108,51 @@ disable = [ # telnetlib3 specific "too-many-instance-attributes", "too-many-public-methods", + "too-few-public-methods", "too-many-arguments", - "duplicate-code", # intentional re-exports in telnetlib.py and telopt.py + "too-many-positional-arguments", + "too-many-locals", + "too-many-branches", + "too-many-statements", + "too-many-nested-blocks", + "too-many-return-statements", + "too-many-lines", + "too-complex", + # intentional re-exports in telnetlib.py and telopt.py + "duplicate-code", + # cyclic-import is a graph-level diagnostic that cannot be suppressed inline + "cyclic-import", + # deliberate deferred imports throughout the codebase + "import-outside-toplevel", + # internal APIs, common pattern across protocol code + "protected-access", + # boundary/cleanup code throughout + "broad-exception-caught", + "broad-except", + # callback signatures with unused params + "unused-argument", + # codec encode/decode `input` param, __exit__ `type` param + "redefined-builtin", + # module-level singletons pattern + "global-statement", + # theNULL, PTY_SUPPORT, SLC_nosupport, _editor_active, etc. + "invalid-name", + # subprocess re-imports after fork + "reimported", + "redefined-outer-name", + # deliberate override renames/changes + "arguments-renamed", + "arguments-differ", + # optional deps may not be installed + "import-error", + # pylint false-positive family + "overlapping-except", + "redefined-variable-type", + "unsupported-assignment-operation", + "use-implicit-booleaness-not-comparison-to-string", + "ungrouped-imports", + "consider-using-with", + "consider-using-from-import", ] [tool.pylint.format] @@ -117,16 +160,17 @@ max-line-length = 100 good-names = ["fd", "_", "x", "y", "tn", "ip"] [tool.pylint.design] -max-args = 12 -max-attributes = 15 -max-branches = 15 -max-complexity = 15 -max-locals = 20 -max-module-lines = 2000 -max-parents = 7 -max-public-methods = 30 -max-returns = 8 -max-statements = 60 +max-args = 18 +max-attributes = 22 +max-branches = 22 +max-complexity = 22 +max-locals = 30 +max-module-lines = 3000 +max-parents = 10 +max-positional-arguments = 8 +max-public-methods = 45 +max-returns = 12 +max-statements = 90 [tool.pylint.similarities] ignore-imports = true @@ -141,6 +185,7 @@ python_version = "3.9" strict = true disallow_subclassing_any = false ignore_missing_imports = true +disable_error_code = ["union-attr"] [[tool.mypy.overrides]] module = ["telnetlib3.tests.*"] diff --git a/telnetlib3/__init__.py b/telnetlib3/__init__.py index 24183b8c..5f2c6623 100644 --- a/telnetlib3/__init__.py +++ b/telnetlib3/__init__.py @@ -29,6 +29,7 @@ from . import fingerprinting_display # noqa: F401 from . import encodings # noqa: F401 - registers custom codecs (petscii, atarist) from . import sync +from ._session_context import TelnetSessionContext # noqa: F401 from .server_base import * # noqa from .server import * # noqa from .stream_writer import * # noqa @@ -49,10 +50,10 @@ try: from . import server_pty_shell from .server_pty_shell import * # noqa - PTY_SUPPORT = True # pylint: disable=invalid-name + PTY_SUPPORT = True except ImportError: server_pty_shell = None # type: ignore[assignment] - PTY_SUPPORT = False # pylint: disable=invalid-name + PTY_SUPPORT = False from .accessories import get_version as _get_version # isort: on # fmt: on diff --git a/telnetlib3/_base.py b/telnetlib3/_base.py new file mode 100644 index 00000000..39cc58fc --- /dev/null +++ b/telnetlib3/_base.py @@ -0,0 +1,118 @@ +"""Shared base utilities for server and client telnet protocol implementations.""" + +from __future__ import annotations + +# std imports +import sys +import types +import logging +import datetime +import traceback +from typing import Any, Type, Callable, Optional + +# Pre-allocated single-byte cache to avoid per-byte bytes() allocations +_ONE_BYTE = [bytes([i]) for i in range(256)] + + +def _log_exception( + log_fn: Callable[..., Any], + e_type: Optional[Type[BaseException]], + e_value: Optional[BaseException], + e_tb: Optional[types.TracebackType], +) -> None: + """Log an exception's traceback and message via *log_fn*.""" + rows_tbk = [line for line in "\n".join(traceback.format_tb(e_tb)).split("\n") if line] + rows_exc = [line.rstrip() for line in traceback.format_exception_only(e_type, e_value)] + + for line in rows_tbk + rows_exc: + log_fn(line) + + +def _process_data_chunk( + data: bytes, + writer: Any, + reader: Any, + slc_special: frozenset[int] | None, + log_fn: Callable[..., Any], +) -> bool: + """ + Scan *data* for IAC and SLC bytes, feed regular bytes to *reader*. + + :param data: Raw bytes received from the transport. + :param writer: TelnetWriter instance for IAC interpretation. + :param reader: TelnetReader instance for in-band data. + :param slc_special: Frozenset of special byte values (IAC + SLC triggers), + or ``None`` when only IAC (255) is special. + :param log_fn: Callable for logging exceptions (e.g. ``logger.warning``). + :returns: ``True`` if any IAC/SB command was observed. + """ + cmd_received = False + n = len(data) + i = 0 + out_start = 0 + feeding_oob = bool(writer.is_oob) + + while i < n: + if not feeding_oob: + if slc_special is None: + next_iac = data.find(255, i) + if next_iac == -1: + if n > out_start: + reader.feed_data(data[out_start:]) + return cmd_received + i = next_iac + else: + while i < n and data[i] not in slc_special: + i += 1 + if i > out_start: + reader.feed_data(data[out_start:i]) + if i >= n: + break + + try: + recv_inband = writer.feed_byte(_ONE_BYTE[data[i]]) + except ValueError as exc: + logging.getLogger(__name__).debug("Invalid telnet byte: %s", exc) + except BaseException: + _log_exception(log_fn, *sys.exc_info()) + else: + if recv_inband: + reader.feed_data(data[i : i + 1]) + else: + cmd_received = True + i += 1 + out_start = i + feeding_oob = bool(writer.is_oob) + + return cmd_received + + +class TelnetProtocolBase: + """Mixin providing properties and helpers shared by server and client protocols.""" + + _when_connected: Optional[datetime.datetime] = None + _last_received: Optional[datetime.datetime] = None + _transport: Any = None + _extra: dict[str, Any] + + @property + def duration(self) -> float: + """Time elapsed since client connected, in seconds as float.""" + assert self._when_connected is not None + return (datetime.datetime.now() - self._when_connected).total_seconds() + + @property + def idle(self) -> float: + """Time elapsed since data last received, in seconds as float.""" + assert self._last_received is not None + return (datetime.datetime.now() - self._last_received).total_seconds() + + def __repr__(self) -> str: + hostport = self.get_extra_info("peername", ["-", "closing"])[:2] + return f"" + + def get_extra_info(self, name: str, default: Any = None) -> Any: + """Get optional protocol or transport information.""" + if self._transport: + default = self._transport.get_extra_info(name, default) + return self._extra.get(name, default) diff --git a/telnetlib3/_paths.py b/telnetlib3/_paths.py new file mode 100644 index 00000000..15ad55d9 --- /dev/null +++ b/telnetlib3/_paths.py @@ -0,0 +1,32 @@ +""" +Filesystem utility helpers for telnetlib3. + +Provides atomic write operations and JSON encoding used by the fingerprinting subsystem. +""" + +from __future__ import annotations + +# std imports +import os +import json +from typing import Any + + +class _BytesSafeEncoder(json.JSONEncoder): + """JSON encoder that converts bytes to str (UTF-8) or hex.""" + + def default(self, o: Any) -> Any: + if isinstance(o, bytes): + try: + return o.decode("utf-8") + except UnicodeDecodeError: + return o.hex() + return super().default(o) + + +def _atomic_json_write(filepath: str, data: dict[str, Any]) -> None: + """Atomically write JSON data to file via write-to-new + rename.""" + tmp_path = os.path.splitext(filepath)[0] + ".json.new" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, sort_keys=True, cls=_BytesSafeEncoder) + os.replace(tmp_path, filepath) diff --git a/telnetlib3/_session_context.py b/telnetlib3/_session_context.py new file mode 100644 index 00000000..cc9ed283 --- /dev/null +++ b/telnetlib3/_session_context.py @@ -0,0 +1,38 @@ +"""Base session context for telnet client connections.""" + +from __future__ import annotations + +# std imports +from typing import IO, Any, Callable, Optional, Awaitable + +__all__ = ("TelnetSessionContext",) + + +class TelnetSessionContext: + r""" + Base session context for telnet client connections. + + Holds per-connection state that the shell layer needs. Subclass this to + add application-specific attributes (e.g. MUD client state, macros, room + graphs). + + A default instance is created for every :class:`~telnetlib3.stream_writer.TelnetWriter`; + applications may replace it with a subclass via ``writer.ctx = MyCtx()``. + + :param raw_mode: Terminal raw mode override. ``None`` = auto-detect + from server negotiation, ``True`` = force raw, ``False`` = force + line mode. + :param ascii_eol: When ``True``, translate ATASCII CR/LF glyphs to + ASCII ``\r`` / ``\n``. + """ + + def __init__(self) -> None: + """Initialize session context with default attribute values.""" + self.raw_mode: Optional[bool] = None + self.ascii_eol: bool = False + self.input_filter: Optional[Any] = None + self.color_filter: Optional[Any] = None + self.autoreply_engine: Optional[Any] = None + self.autoreply_wait_fn: Optional[Callable[..., Awaitable[None]]] = None + self.typescript_file: Optional[IO[str]] = None + self.gmcp_data: dict[str, Any] = {} diff --git a/telnetlib3/accessories.py b/telnetlib3/accessories.py index a73071b3..41527d28 100644 --- a/telnetlib3/accessories.py +++ b/telnetlib3/accessories.py @@ -42,7 +42,7 @@ def get_version() -> str: """Return the current version of telnetlib3.""" - return "2.6.1" # keep in sync with pyproject.toml and docs/conf.py !! + return "3.0.0" # keep in sync with pyproject.toml and docs/conf.py !! def encoding_from_lang(lang: str) -> Optional[str]: diff --git a/telnetlib3/client.py b/telnetlib3/client.py index 163b82a9..d0aa84cf 100755 --- a/telnetlib3/client.py +++ b/telnetlib3/client.py @@ -22,6 +22,17 @@ __all__ = ("TelnetClient", "TelnetTerminalClient", "open_connection") +#: Default GMCP modules requested via ``Core.Supports.Set``. +_DEFAULT_GMCP_MODULES = [ + "Char 1", + "Char.Vitals 1", + "Char.Items 1", + "Room 1", + "Room.Info 1", + "Comm 1", + "Comm.Channel 1", +] + class TelnetClient(client_base.BaseClient): """ @@ -40,7 +51,7 @@ class TelnetClient(client_base.BaseClient): #: Default environment variables to send via NEW_ENVIRON DEFAULT_SEND_ENVIRON = ("TERM", "LANG", "COLUMNS", "LINES", "COLORTERM") - def __init__( # pylint: disable=too-many-positional-arguments + def __init__( self, term: str = "unknown", cols: int = 80, @@ -52,11 +63,13 @@ def __init__( # pylint: disable=too-many-positional-arguments encoding: Union[str, bool] = "utf8", encoding_errors: str = "strict", force_binary: bool = False, - connect_minwait: float = 1.0, + connect_minwait: float = 0, connect_maxwait: float = 4.0, limit: Optional[int] = None, waiter_closed: Optional[asyncio.Future[None]] = None, _waiter_connected: Optional[asyncio.Future[None]] = None, + gmcp_modules: Optional[List[str]] = None, + gmcp_log: bool = False, ) -> None: """Initialize TelnetClient with terminal parameters.""" super().__init__( @@ -70,6 +83,9 @@ def __init__( # pylint: disable=too-many-positional-arguments waiter_closed=waiter_closed, _waiter_connected=_waiter_connected, ) + self._gmcp_modules = gmcp_modules or list(_DEFAULT_GMCP_MODULES) + self._gmcp_log = gmcp_log + self._gmcp_hello_sent = False self._send_environ = set(send_environ or self.DEFAULT_SEND_ENVIRON) self._extra.update( { @@ -98,7 +114,6 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None: Wire up telnet option callbacks for terminal type, speed, display, environment, window size, and character set negotiation. """ - # pylint: disable=import-outside-toplevel from telnetlib3.telopt import NAWS, TTYPE, TSPEED, CHARSET, XDISPLOC, NEW_ENVIRON super().connection_made(transport) @@ -116,11 +131,18 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None: self.writer.set_ext_send_callback(opt, func) # Override the default handle_will method to detect when both sides support CHARSET + # Store the original only on first connection to prevent chain growth on reconnect. + if not hasattr(self.writer, "_original_handle_will"): + self.writer._original_handle_will = self.writer.handle_will + else: + self.writer.handle_will = ( # type: ignore[method-assign] + self.writer._original_handle_will + ) original_handle_will = self.writer.handle_will writer = self.writer def enhanced_handle_will(opt: bytes) -> None: - result = original_handle_will(opt) + original_handle_will(opt) # If this was a WILL CHARSET from the server, and we also have WILL CHARSET enabled, # log that both sides support CHARSET. The server should initiate the actual REQUEST. @@ -131,10 +153,51 @@ def enhanced_handle_will(opt: bytes) -> None: ): self.log.debug("Both sides support CHARSET, ready for server to initiate REQUEST") - return result - self.writer.handle_will = enhanced_handle_will # type: ignore[method-assign] + self._setup_gmcp() + + def _setup_gmcp(self) -> None: + """Wire GMCP callback and WILL-detection for Core.Hello handshake.""" + from telnetlib3.telopt import GMCP + + self.writer.set_ext_callback(GMCP, self._on_gmcp) + + # Capture current handle_will (already includes CHARSET wrapper). + # On reconnect, _original_handle_will was already restored in connection_made, + # so this always wraps exactly once. + original_handle_will_gmcp = self.writer.handle_will + + def _detect_gmcp_will(opt: bytes) -> None: + original_handle_will_gmcp(opt) + if opt == GMCP and self.writer.remote_option.enabled(GMCP): + self._send_gmcp_hello() + + self.writer.handle_will = _detect_gmcp_will # type: ignore[method-assign] + + def _send_gmcp_hello(self) -> None: + """Send ``Core.Hello`` and ``Core.Supports.Set`` after GMCP negotiation.""" + if self._gmcp_hello_sent: + return + self._gmcp_hello_sent = True + from telnetlib3.accessories import get_version + + self.writer.send_gmcp("Core.Hello", {"client": "telnetlib3", "version": get_version()}) + self.writer.send_gmcp("Core.Supports.Set", self._gmcp_modules) + self.log.info("GMCP handshake: Core.Hello + Core.Supports.Set %s", self._gmcp_modules) + + def _on_gmcp(self, package: str, data: Any) -> None: + """Store incoming GMCP data on ``writer.ctx``, merging dict updates.""" + gmcp = self.writer.ctx.gmcp_data + if isinstance(data, dict) and isinstance(gmcp.get(package), dict): + gmcp[package].update(data) + else: + gmcp[package] = data + if self._gmcp_log: + self.log.info("GMCP: %s %r", package, data) + else: + self.log.debug("GMCP: %s %r", package, data) + def send_ttype(self) -> str: """Callback for responding to TTYPE requests.""" result: str = self._extra["term"] @@ -189,20 +252,20 @@ def _normalize_charset_name(name: str) -> str: codec registry does not recognise. This tries progressively simpler variations until one resolves: - 1. Original name (spaces → hyphens) - 2. Leading zeros stripped from numeric parts (``iso-8859-02`` → ``iso-8859-2``) - 3. Hyphens removed entirely (``cp-1250`` → ``cp1250``) + 1. Original name (spaces -> hyphens) + 2. Leading zeros stripped from numeric parts (``iso-8859-02`` -> ``iso-8859-2``) + 3. Hyphens removed entirely (``cp-1250`` -> ``cp1250``) 4. Hyphens removed from all but the first segment (``iso-8859-2`` kept) :param name: Raw charset name from the server. :returns: Normalized name suitable for :func:`codecs.lookup`. """ - import re # pylint: disable=import-outside-toplevel + import re base = name.strip().replace(" ", "-") - # Strip leading zeros from numeric segments: iso-8859-02 → iso-8859-2 + # Strip leading zeros from numeric segments: iso-8859-02 -> iso-8859-2 no_leading_zeros = re.sub(r"-0+(\d)", r"-\1", base) - # All hyphens removed: cp-1250 → cp1250 + # All hyphens removed: cp-1250 -> cp1250 no_hyphens = base.replace("-", "") # Keep first hyphen-segment, collapse the rest: iso-8859-2 stays parts = no_leading_zeros.split("-") @@ -241,7 +304,7 @@ def send_charset(self, offered: List[str]) -> str: """ # Get client's desired encoding canonical name desired_name = None - if self.default_encoding: + if self.default_encoding and isinstance(self.default_encoding, str): try: desired_name = codecs.lookup(self.default_encoding).name except LookupError: @@ -334,13 +397,13 @@ def encoding(self, outgoing: Optional[bool] = None, incoming: Optional[bool] = N ) # may we encode in the direction indicated? - _outgoing_only = outgoing and not incoming - _incoming_only = not outgoing and incoming - _bidirectional = outgoing and incoming + outgoing_only = outgoing and not incoming + incoming_only = not outgoing and incoming + bidirectional = outgoing and incoming may_encode = ( - (_outgoing_only and self.writer.outbinary) - or (_incoming_only and self.writer.inbinary) - or (_bidirectional and self.writer.outbinary and self.writer.inbinary) + (outgoing_only and self.writer.outbinary) + or (incoming_only and self.writer.inbinary) + or (bidirectional and self.writer.outbinary and self.writer.inbinary) ) if self.force_binary or may_encode: @@ -377,8 +440,8 @@ def send_env(self, keys: Sequence[str]) -> Dict[str, Any]: @staticmethod def _winsize() -> Tuple[int, int]: try: - import fcntl # pylint: disable=import-outside-toplevel - import termios # pylint: disable=import-outside-toplevel + import fcntl + import termios fmt = "hhhh" buf = b"\x00" * struct.calcsize(fmt) @@ -386,11 +449,10 @@ def _winsize() -> Tuple[int, int]: rows, cols, _, _ = struct.unpack(fmt, val) return rows, cols except (ImportError, IOError): - # TODO: mock import error, or test on windows or other non-posix. return (int(os.environ.get("LINES", 25)), int(os.environ.get("COLUMNS", 80))) -async def open_connection( # pylint: disable=too-many-locals +async def open_connection( host: Optional[str] = None, port: int = 23, *, @@ -533,12 +595,13 @@ def connection_factory() -> client_base.BaseClient: f"TCP connection to {host or 'localhost'}:{port}" f" timed out after {connect_timeout}s" ) from exc - await protocol._waiter_connected # pylint: disable=protected-access + await protocol._waiter_connected + assert protocol.reader is not None and protocol.writer is not None return protocol.reader, protocol.writer -async def run_client() -> None: # pylint: disable=too-many-locals,too-many-statements,too-complex +async def run_client() -> None: """Command-line 'telnetlib3-client' entry point, via setuptools.""" args = _transform_args(_get_argument_parser().parse_args()) config_msg = f"Client configuration: {accessories.repr_mapping(args)}" @@ -554,9 +617,13 @@ async def run_client() -> None: # pylint: disable=too-many-locals,too-many-stat # Wrap client factory to inject always_will/always_do and encoding # flags before negotiation starts. encoding_explicit = args["encoding"] not in ("utf8", "utf-8", False) + gmcp_modules: Optional[List[str]] = args.get("gmcp_modules") + gmcp_log: bool = args.get("gmcp_log", False) def _client_factory(**kwargs: Any) -> client_base.BaseClient: client: TelnetClient + kwargs["gmcp_modules"] = gmcp_modules + kwargs["gmcp_log"] = gmcp_log if sys.platform != "win32" and sys.stdin.isatty(): client = TelnetTerminalClient(**kwargs) else: @@ -567,9 +634,11 @@ def _patched_connection_made(transport: asyncio.BaseTransport) -> None: orig_connection_made(transport) if always_will: client.writer.always_will = always_will - if always_do: - client.writer.always_do = always_do - client.writer._encoding_explicit = encoding_explicit # pylint: disable=protected-access + client.writer.always_do = always_do + from .telopt import GMCP as _GMCP + + client.writer.passive_do = {_GMCP} + client.writer._encoding_explicit = encoding_explicit client.connection_made = _patched_connection_made # type: ignore[method-assign] return client @@ -580,7 +649,7 @@ def _patched_connection_made(transport: asyncio.BaseTransport) -> None: colormatch: str = args["colormatch"] shell_callback = args["shell"] if colormatch.lower() != "none": - from .color_filter import ( # pylint: disable=import-outside-toplevel + from .color_filter import ( PALETTES, ColorConfig, ColorFilter, @@ -608,7 +677,7 @@ def _patched_connection_made(transport: asyncio.BaseTransport) -> None: brightness=args["color_brightness"], contrast=args["color_contrast"], background_color=args["background_color"], - reverse_video=args["reverse_video"], + ice_colors=args["ice_colors"], ) if is_petscii or colormatch == "c64": color_filter_obj: object = PetsciiColorFilter(color_config) @@ -622,8 +691,7 @@ async def _color_shell( reader: Union[TelnetReader, TelnetReaderUnicode], writer_arg: Union[TelnetWriter, TelnetWriterUnicode], ) -> None: - # pylint: disable-next=protected-access - writer_arg._color_filter = color_filter_obj # type: ignore[union-attr] + writer_arg.ctx.color_filter = color_filter_obj await original_shell(reader, writer_arg) shell_callback = _color_shell @@ -631,16 +699,11 @@ async def _color_shell( # Wrap shell to inject raw_mode flag and input translation for retro encodings raw_mode_val: Optional[bool] = args.get("raw_mode", False) if raw_mode_val is not False: - from .client_shell import ( # pylint: disable=import-outside-toplevel - _INPUT_XLAT, - _INPUT_SEQ_XLAT, - InputFilter, - ) + from .client_shell import _INPUT_XLAT, _INPUT_SEQ_XLAT, InputFilter enc_key = (args.get("encoding", "") or "").lower() byte_xlat = dict(_INPUT_XLAT.get(enc_key, {})) if args.get("ascii_eol"): - # --ascii-eol: don't translate CR/LF to encoding-native EOL byte_xlat.pop(0x0D, None) byte_xlat.pop(0x0A, None) seq_xlat = {} if args.get("ansi_keys") else _INPUT_SEQ_XLAT.get(enc_key, {}) @@ -654,18 +717,36 @@ async def _raw_shell( reader: Union[TelnetReader, TelnetReaderUnicode], writer_arg: Union[TelnetWriter, TelnetWriterUnicode], ) -> None: - # pylint: disable-next=protected-access - writer_arg._raw_mode = raw_mode_val # type: ignore[union-attr] + ctx = writer_arg.ctx + ctx.raw_mode = raw_mode_val if ascii_eol: - # pylint: disable-next=protected-access - writer_arg._ascii_eol = True # type: ignore[union-attr] + ctx.ascii_eol = True if input_filter is not None: - # pylint: disable-next=protected-access - writer_arg._input_filter = input_filter # type: ignore[union-attr] + ctx.input_filter = input_filter await _inner_shell(reader, writer_arg) shell_callback = _raw_shell + # Wrap shell to inject typescript recording file handle + typescript_path: Optional[str] = args.get("typescript") + if typescript_path: + _ts_inner = shell_callback + + async def _typescript_shell( + reader: Union[TelnetReader, TelnetReaderUnicode], + writer_arg: Union[TelnetWriter, TelnetWriterUnicode], + ) -> None: + ctx = writer_arg.ctx + assert typescript_path is not None + ts_file = open(typescript_path, "a", encoding="utf-8") # noqa: SIM115 + ctx.typescript_file = ts_file + try: + await _ts_inner(reader, writer_arg) + finally: + ts_file.close() + + shell_callback = _typescript_shell + # Build connection kwargs explicitly to avoid pylint false positive connection_kwargs: Dict[str, Any] = { "encoding": args["encoding"], @@ -692,13 +773,14 @@ async def _raw_shell( def _get_argument_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="Telnet protocol client", formatter_class=argparse.ArgumentDefaultsHelpFormatter + prog="telnetlib3-client", + description="Telnet protocol client", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument("host", action="store", help="hostname") parser.add_argument("port", nargs="?", default=23, type=int, help="port number") parser.add_argument("--term", default=os.environ.get("TERM", "unknown"), help="terminal type") parser.add_argument("--loglevel", default="warn", help="log level") - # pylint: disable=protected-access parser.add_argument("--logfmt", default=accessories._DEFAULT_LOGFMT, help="log format") parser.add_argument("--logfile", help="filepath") parser.add_argument( @@ -738,9 +820,9 @@ def _get_argument_parser() -> argparse.ArgumentParser: ) parser.add_argument( "--connect-timeout", - default=None, + default=10, type=float, - help="timeout for TCP connection (seconds, default: no timeout)", + help="timeout for TCP connection in seconds (default: 10)", ) parser.add_argument( "--send-environ", @@ -769,34 +851,35 @@ def _get_argument_parser() -> argparse.ArgumentParser: "translate basic 16-color ANSI codes to exact 24-bit RGB values" " from a named hardware palette, bypassing the terminal's custom" " palette to preserve intended MUD/BBS artwork colors" - " (ega, cga, vga, amiga, xterm, none)" + " (vga, xterm, none)" ), ) parser.add_argument( "--color-brightness", - default=0.9, + default=1.0, type=float, metavar="FLOAT", help="color brightness scale [0.0..1.0], where 1.0 is original", ) parser.add_argument( "--color-contrast", - default=0.8, + default=1.0, type=float, metavar="FLOAT", help="color contrast scale [0.0..1.0], where 1.0 is original", ) parser.add_argument( "--background-color", - default="#101010", + default="#000000", metavar="#RRGGBB", help="forced background color as hex RGB (near-black by default)", ) parser.add_argument( - "--reverse-video", - action="store_true", - default=False, - help="swap foreground/background for light-background terminals", + "--ice-colors", + action=argparse.BooleanOptionalAction, + default=True, + help="treat SGR 5 (blink) as bright background (iCE colors)" + " for BBS/ANSI art (default: enabled)", ) parser.add_argument( "--ascii-eol", @@ -832,6 +915,26 @@ def _get_argument_parser() -> argparse.ArgumentParser: "the server identity is not verified, allowing " "man-in-the-middle attacks", ) + parser.add_argument( + "--gmcp-modules", + default=None, + metavar="MODULES", + help="comma-separated GMCP module specs to request " + '(e.g. "Char 1,Room 1,IRE.Rift 1"). ' + "When provided, replaces the built-in defaults.", + ) + parser.add_argument( + "--gmcp-log", + action="store_true", + default=False, + help="log all incoming GMCP messages at INFO level " "(default: DEBUG only)", + ) + parser.add_argument( + "--typescript", + default=None, + metavar="FILE", + help="record session to FILE (like Unix script(1))", + ) return parser @@ -843,7 +946,7 @@ def _parse_option_arg(value: str) -> bytes: :returns: Single-byte option value. :raises ValueError: When *value* is not a known name or valid integer. """ - from .telopt import option_from_name # pylint: disable=import-outside-toplevel + from .telopt import option_from_name try: return option_from_name(value) @@ -866,8 +969,8 @@ def _parse_background_color(value: str) -> Tuple[int, int, int]: def _transform_args(args: argparse.Namespace) -> Dict[str, Any]: - # Auto-enable force_binary for retro BBS encodings that use high-bit bytes. - from .encodings import FORCE_BINARY_ENCODINGS # pylint: disable=import-outside-toplevel + # Auto-enable force_binary for any non-ASCII encoding that uses high-bit bytes. + from .encodings import FORCE_BINARY_ENCODINGS force_binary = args.force_binary # Three-state: True (forced raw), False (forced line), None (auto-detect) @@ -877,8 +980,10 @@ def _transform_args(args: argparse.Namespace) -> Dict[str, Any]: raw_mode = False else: raw_mode = None - if args.encoding.lower().replace("-", "_") in FORCE_BINARY_ENCODINGS: + enc_key = args.encoding.lower().replace("-", "_") + if enc_key not in ("us_ascii", "ascii"): force_binary = True + if enc_key in FORCE_BINARY_ENCODINGS: raw_mode = True # Build TLS context from --ssl / --ssl-cafile / --ssl-no-verify @@ -906,7 +1011,7 @@ def _transform_args(args: argparse.Namespace) -> Dict[str, Any]: "force_binary": force_binary, "encoding_errors": args.encoding_errors, "connect_minwait": args.connect_minwait, - "connect_timeout": args.connect_timeout, + "connect_timeout": args.connect_timeout or None, "send_environ": tuple(v.strip() for v in args.send_environ.split(",") if v.strip()), "always_will": {_parse_option_arg(v) for v in args.always_will}, "always_do": {_parse_option_arg(v) for v in args.always_do}, @@ -914,11 +1019,18 @@ def _transform_args(args: argparse.Namespace) -> Dict[str, Any]: "color_brightness": args.color_brightness, "color_contrast": args.color_contrast, "background_color": _parse_background_color(args.background_color), - "reverse_video": args.reverse_video, + "ice_colors": args.ice_colors, "raw_mode": raw_mode, "ascii_eol": args.ascii_eol, "ansi_keys": args.ansi_keys, "ssl": ssl_ctx, + "gmcp_modules": ( + [m.strip() for m in args.gmcp_modules.split(",") if m.strip()] + if args.gmcp_modules + else None + ), + "gmcp_log": args.gmcp_log, + "typescript": args.typescript, } @@ -926,6 +1038,8 @@ def main() -> None: """Entry point for telnetlib3-client command.""" try: asyncio.run(run_client()) + except KeyboardInterrupt: + pass except OSError as err: print(f"Error: {err}", file=sys.stderr) sys.exit(1) @@ -951,7 +1065,6 @@ def _get_fingerprint_argument_parser() -> argparse.ArgumentParser: "--connect-timeout", default=10, type=float, help="TCP connection timeout in seconds" ) parser.add_argument("--loglevel", default="warn", help="log level") - # pylint: disable=protected-access parser.add_argument("--logfmt", default=accessories._DEFAULT_LOGFMT, help="log format") parser.add_argument("--logfile", default=None, help="filepath") parser.add_argument( @@ -1047,7 +1160,6 @@ async def run_fingerprint_client() -> None: :func:`~telnetlib3.server_fingerprinting.fingerprinting_client_shell` via :func:`functools.partial`, and runs the connection. """ - # pylint: disable=import-outside-toplevel from . import fingerprinting, server_fingerprinting args = _get_fingerprint_argument_parser().parse_args() @@ -1106,8 +1218,8 @@ def fingerprint_client_factory(**kwargs: Any) -> client_base.BaseClient: def patched_connection_made(transport: asyncio.BaseTransport) -> None: orig_connection_made(transport) + assert client.writer is not None client.writer.environ_encoding = environ_encoding - # pylint: disable-next=protected-access client.writer._encoding_explicit = environ_encoding != "ascii" mud_opts = {opt for opt, _, _ in fingerprinting.EXTENDED_OPTIONS} client.writer.always_will = fp_always_will | mud_opts @@ -1146,7 +1258,7 @@ def patched_send_env(keys: Sequence[str]) -> Dict[str, Any]: "term": ttype, "connect_minwait": 0, "connect_maxwait": 4.0, - "connect_timeout": args.connect_timeout, + "connect_timeout": args.connect_timeout or None, "waiter_closed": waiter_closed, } if fp_ssl is not None: diff --git a/telnetlib3/client_base.py b/telnetlib3/client_base.py index 4f52d5c0..fc7a3578 100644 --- a/telnetlib3/client_base.py +++ b/telnetlib3/client_base.py @@ -3,17 +3,15 @@ from __future__ import annotations # std imports -import sys -import types import asyncio import logging import weakref import datetime -import traceback import collections -from typing import Any, Type, Union, Callable, Optional, cast +from typing import Any, Union, Optional, cast # local +from ._base import TelnetProtocolBase, _log_exception, _process_data_chunk from ._types import ShellCallback from .telopt import DO, WILL, theNULL, name_commands from .accessories import TRACE, hexdump @@ -22,15 +20,10 @@ __all__ = ("BaseClient",) -# Pre-allocated single-byte cache to avoid per-byte bytes() allocations -_ONE_BYTE = [bytes([i]) for i in range(256)] - -class BaseClient(asyncio.streams.FlowControlMixin, asyncio.Protocol): +class BaseClient(TelnetProtocolBase, asyncio.streams.FlowControlMixin, asyncio.Protocol): """Base Telnet Client Protocol.""" - _when_connected: Optional[datetime.datetime] = None - _last_received: Optional[datetime.datetime] = None _transport: Optional[asyncio.Transport] = None _closing = False _reader_factory = TelnetReader @@ -39,7 +32,7 @@ class BaseClient(asyncio.streams.FlowControlMixin, asyncio.Protocol): _writer_factory_encoding = TelnetWriterUnicode _check_later: Optional[asyncio.Handle] = None - def __init__( # pylint: disable=too-many-positional-arguments + def __init__( self, shell: Optional[ShellCallback] = None, encoding: Union[str, bool] = "utf8", @@ -170,7 +163,7 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None: # Attach transport so TelnetReader can apply pause_reading/resume_reading try: self.reader.set_transport(_transport) - except Exception: # pylint: disable=broad-exception-caught + except Exception: # Reader may not support transport coupling; ignore. pass @@ -189,6 +182,7 @@ def begin_shell(self, future: asyncio.Future[None]) -> None: if future.cancelled() or future.exception() is not None: return if self.shell is not None: + assert self.reader is not None and self.writer is not None coro = self.shell(self.reader, self.writer) if asyncio.iscoroutine(coro): # When a shell is defined as a coroutine, we must ensure @@ -239,7 +233,7 @@ def data_received(self, data: bytes) -> None: try: self._transport.pause_reading() self._reading_paused = True - except Exception: # pylint: disable=broad-exception-caught + except Exception: # Some transports may not support pause_reading; ignore. pass @@ -253,10 +247,7 @@ def _detect_syncterm_font(self, data: bytes) -> None: """ if self.writer is None: return - from .server_fingerprinting import ( # pylint: disable=import-outside-toplevel - _SYNCTERM_BINARY_ENCODINGS, - detect_syncterm_font, - ) + from .server_fingerprinting import detect_syncterm_font encoding = detect_syncterm_font(data) if encoding is not None: @@ -267,33 +258,10 @@ def _detect_syncterm_font(self, data: bytes) -> None: ) else: self.writer.environ_encoding = encoding - if encoding in _SYNCTERM_BINARY_ENCODINGS: - self.force_binary = True + self.force_binary = True # public properties - @property - def duration(self) -> float: - """Time elapsed since client connected, in seconds as float.""" - return (datetime.datetime.now() - self._when_connected).total_seconds() - - @property - def idle(self) -> float: - """Time elapsed since data last received, in seconds as float.""" - return (datetime.datetime.now() - self._last_received).total_seconds() - - # public protocol methods - - def __repr__(self) -> str: - hostport = self.get_extra_info("peername", ["-", "closing"])[:2] - return f"" - - def get_extra_info(self, name: str, default: Any = None) -> Any: - """Get optional client protocol or transport information.""" - if self._transport: - default = self._transport.get_extra_info(name, default) - return self._extra.get(name, default) - def begin_negotiation(self) -> None: """ Begin on-connect negotiation. @@ -322,7 +290,6 @@ def encoding(self, outgoing: bool = False, incoming: bool = False) -> Union[str, The base implementation **always** returns ``encoding`` argument given to class initializer or, when unset (``None``), ``US-ASCII``. """ - # pylint: disable=unused-argument return self.default_encoding or "US-ASCII" # pragma: no cover def check_negotiation(self, final: bool = False) -> bool: @@ -346,7 +313,6 @@ def check_negotiation(self, final: bool = False) -> bool: Ensure ``super().check_negotiation()`` is called and conditionally combined when derived. """ - # pylint: disable=import-outside-toplevel from .telopt import TTYPE, CHARSET, NEW_ENVIRON # First check if there are any pending options @@ -373,80 +339,23 @@ def check_negotiation(self, final: bool = False) -> bool: # private methods - def _process_chunk(self, data: bytes) -> bool: # pylint: disable=too-many-branches,too-complex + def _process_chunk(self, data: bytes) -> bool: """Process a chunk of received bytes; return True if any IAC/SB cmd observed.""" - # This mirrors the previous optimized logic, but is called from an async task. self._last_received = datetime.datetime.now() - writer = self.writer - reader = self.reader - - # Snapshot whether SLC snooping is required for this chunk try: - mode = writer.mode # property - except Exception: # pylint: disable=broad-exception-caught + mode = self.writer.mode + except Exception: mode = "local" - slc_needed = (mode == "remote") or (mode == "kludge" and writer.slc_simulated) + slc_needed = (mode == "remote") or (mode == "kludge" and self.writer.slc_simulated) - cmd_received = False - - # Precompute SLC trigger set if needed - slc_vals = None if slc_needed: - slc_vals = {defn.val[0] for defn in writer.slctab.values() if defn.val != theNULL} - - n = len(data) - i = 0 - out_start = 0 - feeding_oob = bool(writer.is_oob) - - # Build set of special bytes for fast lookup - special_bytes = frozenset({255} | (slc_vals or set())) - - while i < n: - if not feeding_oob: - # Scan forward until next special byte (IAC or SLC trigger) - if not slc_vals: - # Fast path: only IAC (255) is special - use C-level find - next_iac = data.find(255, i) - if next_iac == -1: - # No IAC found, consume rest of chunk - if n > out_start: - reader.feed_data(data[out_start:]) - return cmd_received - i = next_iac - else: - # Slow path: SLC bytes also special - scan byte by byte - while i < n and data[i] not in special_bytes: - i += 1 - # Flush non-special run - if i > out_start: - reader.feed_data(data[out_start:i]) - if i >= n: - out_start = i - break - # At a special byte or in the middle of an IAC sequence - b = data[i] - try: - recv_inband = writer.feed_byte(_ONE_BYTE[b]) - except Exception: # pylint: disable=broad-exception-caught - self._log_exception(self.log.warning, *sys.exc_info()) - else: - if recv_inband: - # Only forward the single-byte SLC or in-band special - reader.feed_data(data[i : i + 1]) - else: - cmd_received = True - i += 1 - out_start = i - # Continue per-byte feeding while writer indicates out-of-band processing - feeding_oob = bool(writer.is_oob) - - # Any trailing non-special bytes - if out_start < n: - reader.feed_data(data[out_start:]) - - return cmd_received + slc_vals = {defn.val[0] for defn in self.writer.slctab.values() if defn.val != theNULL} + slc_special: frozenset[int] | None = frozenset({255} | slc_vals) + else: + slc_special = None + + return _process_data_chunk(data, self.writer, self.reader, slc_special, self.log.warning) async def _process_rx(self) -> None: """Async processor for receive queue that yields control and applies backpressure.""" @@ -473,7 +382,7 @@ async def _process_rx(self) -> None: try: self._transport.resume_reading() self._reading_paused = False - except Exception: # pylint: disable=broad-exception-caught + except Exception: pass # Yield periodically to keep loop responsive without excessive context switching @@ -517,15 +426,4 @@ def _check_negotiation_timer(self) -> None: ) self._tasks.append(self._check_later) - @staticmethod - def _log_exception( - logger: Callable[..., Any], - e_type: Optional[Type[BaseException]], - e_value: Optional[BaseException], - e_tb: Optional[types.TracebackType], - ) -> None: - rows_tbk = [line for line in "\n".join(traceback.format_tb(e_tb)).split("\n") if line] - rows_exc = [line.rstrip() for line in traceback.format_exception_only(e_type, e_value)] - - for line in rows_tbk + rows_exc: - logger(line) + _log_exception = staticmethod(_log_exception) diff --git a/telnetlib3/client_shell.py b/telnetlib3/client_shell.py index 11583680..a28ea29c 100644 --- a/telnetlib3/client_shell.py +++ b/telnetlib3/client_shell.py @@ -1,18 +1,24 @@ """Telnet client shell implementations for interactive terminal sessions.""" -# pylint: disable=too-complex - # std imports import sys import asyncio +import logging +import threading import collections -from typing import Any, Dict, Tuple, Union, Optional +from typing import Any, Dict, Tuple, Union, Callable, Optional +from dataclasses import dataclass # local from . import accessories -from .accessories import TRACE -from .stream_reader import TelnetReader, TelnetReaderUnicode -from .stream_writer import TelnetWriter, TelnetWriterUnicode +from ._session_context import TelnetSessionContext + +log = logging.getLogger(__name__) + +# local +from .accessories import TRACE # noqa: E402 +from .stream_reader import TelnetReader, TelnetReaderUnicode # noqa: E402 +from .stream_writer import TelnetWriter, TelnetWriterUnicode # noqa: E402 __all__ = ("InputFilter", "telnet_client_shell") @@ -29,14 +35,14 @@ # shares its Unicode codepoint U+25C0 with 0xFE). _INPUT_XLAT: Dict[str, Dict[int, int]] = { "atascii": { - 0x7F: 0x7E, # DEL → ATASCII backspace (byte 0x7E) - 0x08: 0x7E, # BS → ATASCII backspace (byte 0x7E) - 0x0D: 0x9B, # CR → ATASCII EOL (byte 0x9B) - 0x0A: 0x9B, # LF → ATASCII EOL (byte 0x9B) + 0x7F: 0x7E, # DEL -> ATASCII backspace (byte 0x7E) + 0x08: 0x7E, # BS -> ATASCII backspace (byte 0x7E) + 0x0D: 0x9B, # CR -> ATASCII EOL (byte 0x9B) + 0x0A: 0x9B, # LF -> ATASCII EOL (byte 0x9B) }, "petscii": { - 0x7F: 0x14, # DEL → PETSCII DEL (byte 0x14) - 0x08: 0x14, # BS → PETSCII DEL (byte 0x14) + 0x7F: 0x14, # DEL -> PETSCII DEL (byte 0x14) + 0x08: 0x14, # BS -> PETSCII DEL (byte 0x14) }, } @@ -54,8 +60,8 @@ b"\x1bOB": b"\x1d", # cursor down b"\x1bOC": b"\x1f", # cursor right b"\x1bOD": b"\x1e", # cursor left - b"\x1b[3~": b"\x7e", # delete → ATASCII backspace - b"\t": b"\x7f", # tab → ATASCII tab + b"\x1b[3~": b"\x7e", # delete -> ATASCII backspace + b"\t": b"\x7f", # tab -> ATASCII tab }, "petscii": { b"\x1b[A": b"\x91", # cursor up (CSI) @@ -66,9 +72,9 @@ b"\x1bOB": b"\x11", # cursor down b"\x1bOC": b"\x1d", # cursor right b"\x1bOD": b"\x9d", # cursor left - b"\x1b[3~": b"\x14", # delete → PETSCII DEL - b"\x1b[H": b"\x13", # home → PETSCII HOME - b"\x1b[2~": b"\x94", # insert → PETSCII INSERT + b"\x1b[3~": b"\x14", # delete -> PETSCII DEL + b"\x1b[H": b"\x13", # home -> PETSCII HOME + b"\x1b[2~": b"\x94", # insert -> PETSCII INSERT }, } @@ -86,8 +92,8 @@ class InputFilter: becomes ``True``. The caller should start an ``esc_delay`` timer and call :meth:`flush` if no further input arrives before the timer fires. - :param seq_xlat: Multi-byte escape sequence → replacement bytes. - :param byte_xlat: Single input byte → replacement byte. + :param seq_xlat: Multi-byte escape sequence -> replacement bytes. + :param byte_xlat: Single input byte -> replacement byte. :param esc_delay: Seconds to wait before flushing a buffered prefix (default 0.35, matching blessed's ``DEFAULT_ESCDELAY``). """ @@ -153,7 +159,7 @@ def feed(self, data: bytes) -> bytes: break if matched: continue - # Check if buffer is a prefix of any known sequence — wait for more + # Check if buffer is a prefix of any known sequence -- wait for more if self._buf in self._prefixes: break # No sequence match, emit single byte with translation @@ -163,6 +169,17 @@ def feed(self, data: bytes) -> bytes: return bytes(result) +@dataclass +class _RawLoopState: + """Mutable state bundle for :func:`_raw_event_loop`.""" + + switched_to_raw: bool + last_will_echo: bool + local_echo: bool + linesep: str + reactivate_repl: bool = False + + if sys.platform == "win32": async def telnet_client_shell( @@ -196,63 +213,46 @@ def __init__(self, telnet_writer: Union[TelnetWriter, TelnetWriterUnicode]) -> N self._save_mode: Optional[Terminal.ModeDef] = None self.software_echo = False self._remove_winch = False - self._winch_handle: Optional[asyncio.TimerHandle] = None + self._resize_pending = threading.Event() + self.on_resize: Optional[Callable[[int, int], None]] = None + self._stdin_transport: Optional[asyncio.BaseTransport] = None def setup_winch(self) -> None: - """Register SIGWINCH handler to send NAWS on terminal resize.""" + """Register SIGWINCH handler to set ``_resize_pending`` flag.""" if not self._istty or not hasattr(signal, "SIGWINCH"): return try: loop = asyncio.get_event_loop() - writer = self.telnet_writer - - def _send_naws() -> None: - from .telopt import NAWS # pylint: disable=import-outside-toplevel - - try: - if writer.local_option.enabled(NAWS) and not writer.is_closing(): - writer._send_naws() # pylint: disable=protected-access - except Exception: # pylint: disable=broad-exception-caught - pass def _on_winch() -> None: - if self._winch_handle is not None and not self._winch_handle.cancelled(): - try: - self._winch_handle.cancel() - except Exception: # pylint: disable=broad-exception-caught - pass - self._winch_handle = loop.call_later(0.05, _send_naws) + self._resize_pending.set() loop.add_signal_handler(signal.SIGWINCH, _on_winch) self._remove_winch = True - except Exception: # pylint: disable=broad-exception-caught + except Exception: self._remove_winch = False def cleanup_winch(self) -> None: - """Remove SIGWINCH handler and cancel pending timer.""" + """Remove SIGWINCH handler.""" if self._istty and self._remove_winch: try: asyncio.get_event_loop().remove_signal_handler(signal.SIGWINCH) - except Exception: # pylint: disable=broad-exception-caught + except Exception: pass self._remove_winch = False - if self._winch_handle is not None: - try: - self._winch_handle.cancel() - except Exception: # pylint: disable=broad-exception-caught - pass - self._winch_handle = None def __enter__(self) -> "Terminal": self._save_mode = self.get_mode() if self._istty: + assert self._save_mode is not None self.set_mode(self.determine_mode(self._save_mode)) return self def __exit__(self, *_: Any) -> None: self.cleanup_winch() if self._istty: - termios.tcsetattr(self._fileno, termios.TCSAFLUSH, list(self._save_mode)) + assert self._save_mode is not None + termios.tcsetattr(self._fileno, termios.TCSADRAIN, list(self._save_mode)) def get_mode(self) -> Optional["Terminal.ModeDef"]: """Return current terminal mode if attached to a tty, otherwise None.""" @@ -262,7 +262,20 @@ def get_mode(self) -> Optional["Terminal.ModeDef"]: def set_mode(self, mode: "Terminal.ModeDef") -> None: """Set terminal mode attributes.""" - termios.tcsetattr(sys.stdin.fileno(), termios.TCSAFLUSH, list(mode)) + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, list(mode)) + + @staticmethod + def _suppress_echo(mode: "Terminal.ModeDef") -> "Terminal.ModeDef": + """Return copy of *mode* with local ECHO disabled, keeping ICANON.""" + return Terminal.ModeDef( + iflag=mode.iflag, + oflag=mode.oflag, + cflag=mode.cflag, + lflag=mode.lflag & ~termios.ECHO, + ispeed=mode.ispeed, + ospeed=mode.ospeed, + cc=mode.cc, + ) def _make_raw( self, mode: "Terminal.ModeDef", suppress_echo: bool = True @@ -298,7 +311,7 @@ def _make_raw( def _server_will_sga(self) -> bool: """Whether server has negotiated WILL SGA.""" - from .telopt import SGA # pylint: disable=import-outside-toplevel + from .telopt import SGA return bool(self.telnet_writer.client and self.telnet_writer.remote_option.enabled(SGA)) @@ -315,23 +328,33 @@ def check_auto_mode( """ if not self._istty: return None - _wecho = self.telnet_writer.will_echo - _wsga = self._server_will_sga() - _should_switch = not switched_to_raw and (_wecho or _wsga) - _echo_changed = switched_to_raw and _wecho != last_will_echo - if not (_should_switch or _echo_changed): + wecho = self.telnet_writer.will_echo + wsga = self._server_will_sga() + # WILL ECHO alone = line mode with server echo (suppress local echo) + # WILL SGA (with or without ECHO) = raw/character-at-a-time + should_go_raw = not switched_to_raw and wsga + should_suppress_echo = not switched_to_raw and wecho and not wsga + echo_changed = switched_to_raw and wecho != last_will_echo + if not (should_go_raw or should_suppress_echo or echo_changed): return None + assert self._save_mode is not None + if should_suppress_echo: + self.set_mode(self._suppress_echo(self._save_mode)) + self.telnet_writer.log.debug( + "auto: server echo without SGA, line mode (server WILL ECHO)" + ) + return (False, wecho, False) self.set_mode(self._make_raw(self._save_mode, suppress_echo=True)) self.telnet_writer.log.debug( "auto: %s (server %s ECHO)", ( "switching to raw mode" - if _should_switch - else ("disabling" if _wecho else "enabling") + " software echo" + if should_go_raw + else ("disabling" if wecho else "enabling") + " software echo" ), - "WILL" if _wecho else "WONT", + "WILL" if wecho else "WONT", ) - return (True if _should_switch else switched_to_raw, _wecho, not _wecho) + return (True if should_go_raw else switched_to_raw, wecho, not wecho) def determine_mode(self, mode: "Terminal.ModeDef") -> "Terminal.ModeDef": """ @@ -344,11 +367,11 @@ def determine_mode(self, mode: "Terminal.ModeDef") -> "Terminal.ModeDef": ================= ======== ========== ================================ Nothing on on Line mode, local echo WILL SGA only **off** on Character-at-a-time, local echo - WILL ECHO only **off** **off** Raw mode, server echoes (rare) + WILL ECHO only on **off** Line mode, server echoes WILL SGA + ECHO **off** **off** Full kludge mode (most common) ================= ======== ========== ================================ """ - raw_mode = getattr(self.telnet_writer, "_raw_mode", False) + raw_mode = _get_raw_mode(self.telnet_writer) will_echo = self.telnet_writer.will_echo will_sga = self._server_will_sga() # Auto mode (None): follow server negotiation @@ -357,8 +380,8 @@ def determine_mode(self, mode: "Terminal.ModeDef") -> "Terminal.ModeDef": self.telnet_writer.log.debug("auto: server echo + SGA, kludge mode") return self._make_raw(mode) if will_echo: - self.telnet_writer.log.debug("auto: server echo, raw mode") - return self._make_raw(mode) + self.telnet_writer.log.debug("auto: server echo without SGA, line mode") + return self._suppress_echo(mode) if will_sga: self.telnet_writer.log.debug("auto: SGA without echo, character-at-a-time") self.software_echo = True @@ -376,20 +399,14 @@ def determine_mode(self, mode: "Terminal.ModeDef") -> "Terminal.ModeDef": self.telnet_writer.log.debug("server echo, kludge mode") return self._make_raw(mode) - async def make_stdio(self) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: - """Return (reader, writer) pair for sys.stdin, sys.stdout.""" - reader = asyncio.StreamReader() - reader_protocol = asyncio.StreamReaderProtocol(reader) + async def make_stdout(self) -> asyncio.StreamWriter: + """ + Return an asyncio StreamWriter for local terminal output. - # Thanks: - # - # https://gist.github.com/nathan-hoad/8966377 - # - # After some experimentation, this 'sameopenfile' conditional seems - # allow us to handle stdin as a pipe or a keyboard. In the case of - # a tty, 0 and 1 are the same open file, we use: - # - # https://github.com/orochimarufan/.files/blob/master/bin/mpr + This does **not** connect stdin -- call :meth:`connect_stdin` + separately when an asyncio stdin reader is needed (the REPL + manages its own stdin via blessed async_inkey). + """ write_fobj = sys.stdout if self._istty: write_fobj = sys.stdin @@ -397,12 +414,36 @@ async def make_stdio(self) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: writer_transport, writer_protocol = await loop.connect_write_pipe( asyncio.streams.FlowControlMixin, write_fobj ) + return asyncio.StreamWriter(writer_transport, writer_protocol, None, loop) + + async def connect_stdin(self) -> asyncio.StreamReader: + """ + Connect sys.stdin to an asyncio StreamReader. - writer = asyncio.StreamWriter(writer_transport, writer_protocol, None, loop) + Must be called **after** any REPL session has finished, because the REPL and asyncio + cannot both own the stdin file descriptor at the same time. + """ + reader = asyncio.StreamReader() + reader_protocol = asyncio.StreamReaderProtocol(reader) + transport, _ = await asyncio.get_event_loop().connect_read_pipe( + lambda: reader_protocol, sys.stdin + ) + self._stdin_transport = transport + return reader - await loop.connect_read_pipe(lambda: reader_protocol, sys.stdin) + def disconnect_stdin(self, reader: asyncio.StreamReader) -> None: + """Disconnect stdin pipe so the REPL can reclaim it.""" + transport = getattr(self, "_stdin_transport", None) + if transport is not None: + transport.close() + self._stdin_transport = None + reader.feed_eof() - return reader, writer + async def make_stdio(self) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """Return (reader, writer) pair for sys.stdin, sys.stdout.""" + stdout = await self.make_stdout() + stdin = await self.connect_stdin() + return stdin, stdout def _transform_output( out: str, writer: Union[TelnetWriter, TelnetWriterUnicode], in_raw_mode: bool @@ -411,20 +452,21 @@ def _transform_output( Apply color filter, ASCII EOL substitution, and CRLF normalization. :param out: Server output text to transform. - :param writer: Telnet writer (checked for ``_color_filter`` and ``_ascii_eol``). + :param writer: Telnet writer (``ctx`` provides color filter and ascii_eol). :param in_raw_mode: When ``True``, normalize line endings to ``\r\n``. :returns: Transformed output string. """ - _cf = getattr(writer, "_color_filter", None) - if _cf is not None: - out = _cf.filter(out) - if getattr(writer, "_ascii_eol", False): + ctx: TelnetSessionContext = writer.ctx + cf = ctx.color_filter + if cf is not None: + out = cf.filter(out) + if ctx.ascii_eol: out = out.replace(_ATASCII_CR_CHAR, "\r").replace(_ATASCII_LF_CHAR, "\n") if in_raw_mode: out = out.replace("\r\n", "\n").replace("\n", "\r\n") else: - # Cooked mode: PTY ONLCR converts \n → \r\n, so strip \r before \n - # to avoid doubling (\r\n → \r\r\n). + # Cooked mode: PTY ONLCR converts \n -> \r\n, so strip \r before \n + # to avoid doubling (\r\n -> \r\r\n). out = out.replace("\r\n", "\n") return out @@ -443,42 +485,164 @@ def _send_stdin( :param local_echo: When ``True``, echo input bytes to stdout. :returns: ``(esc_timer_task_or_None, has_pending)`` tuple. """ - _inf = getattr(telnet_writer, "_input_filter", None) + ctx: TelnetSessionContext = telnet_writer.ctx + inf = ctx.input_filter pending = False new_timer: Optional[asyncio.Task[None]] = None - if _inf is not None: - translated = _inf.feed(inp) + if inf is not None: + translated = inf.feed(inp) if translated: - telnet_writer._write(translated) # pylint: disable=protected-access - if _inf.has_pending: + telnet_writer._write(translated) + if inf.has_pending: pending = True - new_timer = asyncio.ensure_future(asyncio.sleep(_inf.esc_delay)) + new_timer = asyncio.ensure_future(asyncio.sleep(inf.esc_delay)) else: - telnet_writer._write(inp) # pylint: disable=protected-access + telnet_writer._write(inp) if local_echo: - _echo_buf = bytearray() - for _b in inp: - if _b in (0x7F, 0x08): - _echo_buf.extend(b"\b \b") - elif _b == 0x0D: - _echo_buf.extend(b"\r\n") - elif _b >= 0x20: - _echo_buf.append(_b) - if _echo_buf: - stdout.write(bytes(_echo_buf)) + echo_buf = bytearray() + for b in inp: + if b in (0x7F, 0x08): + echo_buf.extend(b"\b \b") + elif b == 0x0D: + echo_buf.extend(b"\r\n") + elif b >= 0x20: + echo_buf.append(b) + if echo_buf: + stdout.write(bytes(echo_buf)) return new_timer, pending + def _get_raw_mode(writer: Union[TelnetWriter, TelnetWriterUnicode]) -> "bool | None": + """Return the writer's ``ctx.raw_mode`` (``None``, ``True``, or ``False``).""" + return writer.ctx.raw_mode + def _flush_color_filter( writer: Union[TelnetWriter, TelnetWriterUnicode], stdout: asyncio.StreamWriter ) -> None: """Flush any pending color filter output to stdout.""" - _cf = getattr(writer, "_color_filter", None) - if _cf is not None: - _flush = _cf.flush() - if _flush: - stdout.write(_flush.encode()) + cf = writer.ctx.color_filter + if cf is not None: + flush = cf.flush() + if flush: + stdout.write(flush.encode()) + + def _ensure_autoreply_engine( + telnet_writer: Union[TelnetWriter, TelnetWriterUnicode], + ) -> "Optional[Any]": + """Return the autoreply engine from the writer's context, if set.""" + return telnet_writer.ctx.autoreply_engine + + async def _raw_event_loop( + telnet_reader: Union[TelnetReader, TelnetReaderUnicode], + telnet_writer: Union[TelnetWriter, TelnetWriterUnicode], + tty_shell: "Terminal", + stdin: asyncio.StreamReader, + stdout: asyncio.StreamWriter, + keyboard_escape: str, + state: _RawLoopState, + handle_close: Callable[[str], None], + want_repl: Callable[[], bool], + ) -> None: + """Standard byte-at-a-time event loop (mutates *state* in-place).""" + stdin_task = accessories.make_reader_task(stdin) + telnet_task = accessories.make_reader_task(telnet_reader, size=2**24) + esc_timer_task: Optional[asyncio.Task[None]] = None + wait_for: set[asyncio.Task[Any]] = {stdin_task, telnet_task} + + while wait_for: + done, _ = await asyncio.wait(wait_for, return_when=asyncio.FIRST_COMPLETED) + if stdin_task in done: + task = stdin_task + done.discard(task) + else: + task = done.pop() + wait_for.discard(task) + + telnet_writer.log.log(TRACE, "task=%s, wait_for=%s", task, wait_for) + + # ESC_DELAY timer fired -- flush buffered partial sequence + if task is esc_timer_task: + esc_timer_task = None + inf = telnet_writer.ctx.input_filter + if inf is not None and inf.has_pending: + flushed = inf.flush() + if flushed: + telnet_writer._write(flushed) + continue + + # client input + if task == stdin_task: + if esc_timer_task is not None and esc_timer_task in wait_for: + esc_timer_task.cancel() + wait_for.discard(esc_timer_task) + esc_timer_task = None + inp = task.result() + if not inp: + telnet_writer.log.debug("EOF from client stdin") + continue + if keyboard_escape in inp.decode(): + try: + telnet_writer.close() + except Exception: + pass + if telnet_task in wait_for: + telnet_task.cancel() + wait_for.remove(telnet_task) + handle_close("Connection closed.") + break + new_timer, has_pending = _send_stdin(inp, telnet_writer, stdout, state.local_echo) + if has_pending and esc_timer_task not in wait_for: + esc_timer_task = new_timer + if esc_timer_task is not None: + wait_for.add(esc_timer_task) + stdin_task = accessories.make_reader_task(stdin) + wait_for.add(stdin_task) + + # server output + elif task == telnet_task: + out = task.result() + if not out and telnet_reader.at_eof(): + if stdin_task in wait_for: + stdin_task.cancel() + wait_for.remove(stdin_task) + handle_close("Connection closed by foreign host.") + continue + raw_mode = _get_raw_mode(telnet_writer) + in_raw = raw_mode is True or (raw_mode is None and state.switched_to_raw) + out = _transform_output(out, telnet_writer, in_raw) + ar_engine = _ensure_autoreply_engine(telnet_writer) + if ar_engine is not None: + ar_engine.feed(out) + if raw_mode is None: + mode_result = tty_shell.check_auto_mode( + state.switched_to_raw, state.last_will_echo + ) + if mode_result is not None: + if not state.switched_to_raw: + state.linesep = "\r\n" + state.switched_to_raw, state.last_will_echo, state.local_echo = mode_result + # When transitioning cooked -> raw, the data was + # processed for ONLCR (\r\n -> \n) but the terminal + # now has ONLCR disabled. Re-normalize so bare \n + # becomes \r\n for correct display. + if state.switched_to_raw and not in_raw: + out = out.replace("\n", "\r\n") + if want_repl(): + state.reactivate_repl = True + stdout.write(out.encode()) + _ts_file = telnet_writer.ctx.typescript_file + if _ts_file is not None: + _ts_file.write(out) + _ts_file.flush() + if state.reactivate_repl: + telnet_writer.log.debug("mode returned to local, reactivating REPL") + if stdin_task in wait_for: + stdin_task.cancel() + wait_for.discard(stdin_task) + state.switched_to_raw = False + break + telnet_task = accessories.make_reader_task(telnet_reader, size=2**24) + wait_for.add(telnet_task) - # pylint: disable=too-many-locals,too-many-branches,too-many-statements async def telnet_client_shell( telnet_reader: Union[TelnetReader, TelnetReaderUnicode], telnet_writer: Union[TelnetWriter, TelnetWriterUnicode], @@ -494,100 +658,83 @@ async def telnet_client_shell( """ keyboard_escape = "\x1d" - with Terminal(telnet_writer=telnet_writer) as term: + with Terminal(telnet_writer=telnet_writer) as tty_shell: linesep = "\n" switched_to_raw = False last_will_echo = False - local_echo = term.software_echo - if term._istty: # pylint: disable=protected-access - raw_mode = getattr(telnet_writer, "_raw_mode", False) + local_echo = tty_shell.software_echo + if tty_shell._istty: + raw_mode = _get_raw_mode(telnet_writer) if telnet_writer.will_echo or raw_mode is True: linesep = "\r\n" - stdin, stdout = await term.make_stdio() + stdout = await tty_shell.make_stdout() + tty_shell.setup_winch() + + # EOR/GA-based command pacing for raw-mode autoreplies. + prompt_ready_raw = asyncio.Event() + prompt_ready_raw.set() + ga_detected_raw = False + + _sh_ctx: TelnetSessionContext = telnet_writer.ctx + + def _on_prompt_signal_raw(_cmd: bytes) -> None: + nonlocal ga_detected_raw + ga_detected_raw = True + prompt_ready_raw.set() + ar = _sh_ctx.autoreply_engine + if ar is not None: + ar.on_prompt() + + from .telopt import GA, CMD_EOR + + telnet_writer.set_iac_callback(GA, _on_prompt_signal_raw) + telnet_writer.set_iac_callback(CMD_EOR, _on_prompt_signal_raw) + + async def _wait_for_prompt_raw() -> None: + if not ga_detected_raw: + return + try: + await asyncio.wait_for(prompt_ready_raw.wait(), timeout=2.0) + except asyncio.TimeoutError: + pass + prompt_ready_raw.clear() + + _sh_ctx.autoreply_wait_fn = _wait_for_prompt_raw + escape_name = accessories.name_unicode(keyboard_escape) - stdout.write(f"Escape character is '{escape_name}'.{linesep}".encode()) - term.setup_winch() + banner_sep = "\r\n" if tty_shell._istty else linesep + stdout.write(f"Escape character is '{escape_name}'.{banner_sep}".encode()) def _handle_close(msg: str) -> None: _flush_color_filter(telnet_writer, stdout) stdout.write(f"\033[m{linesep}{msg}{linesep}".encode()) - term.cleanup_winch() - - stdin_task = accessories.make_reader_task(stdin) - telnet_task = accessories.make_reader_task(telnet_reader, size=2**24) - esc_timer_task: Optional[asyncio.Task[None]] = None - wait_for = set([stdin_task, telnet_task]) - # -- event loop: multiplex stdin, server output, and ESC_DELAY timer -- - while wait_for: - done, _ = await asyncio.wait(wait_for, return_when=asyncio.FIRST_COMPLETED) - - # Prefer handling stdin events first to avoid starvation - if stdin_task in done: - task = stdin_task - done.discard(task) - else: - task = done.pop() - wait_for.discard(task) - - telnet_writer.log.log(TRACE, "task=%s, wait_for=%s", task, wait_for) - - # ESC_DELAY timer fired — flush buffered partial sequence - if task is esc_timer_task: - esc_timer_task = None - _inf = getattr(telnet_writer, "_input_filter", None) - if _inf is not None and _inf.has_pending: - flushed = _inf.flush() - if flushed: - telnet_writer._write(flushed) # pylint: disable=protected-access - continue - - # client input - if task == stdin_task: - # Cancel ESC_DELAY timer — new input resolves buffering - if esc_timer_task is not None and esc_timer_task in wait_for: - esc_timer_task.cancel() - wait_for.discard(esc_timer_task) - esc_timer_task = None - inp = task.result() - if not inp: - telnet_writer.log.debug("EOF from client stdin") - continue - if keyboard_escape in inp.decode(): - try: - telnet_writer.close() - except Exception: # pylint: disable=broad-exception-caught - pass - if telnet_task in wait_for: - telnet_task.cancel() - wait_for.remove(telnet_task) - _handle_close("Connection closed.") - break - new_timer, has_pending = _send_stdin(inp, telnet_writer, stdout, local_echo) - if has_pending and esc_timer_task not in wait_for: - esc_timer_task = new_timer - if esc_timer_task is not None: - wait_for.add(esc_timer_task) - stdin_task = accessories.make_reader_task(stdin) - wait_for.add(stdin_task) - - # server output - elif task == telnet_task: - out = task.result() - if not out and telnet_reader._eof: # pylint: disable=protected-access - if stdin_task in wait_for: - stdin_task.cancel() - wait_for.remove(stdin_task) - _handle_close("Connection closed by foreign host.") - continue - raw_mode = getattr(telnet_writer, "_raw_mode", False) - in_raw = raw_mode is True or (raw_mode is None and switched_to_raw) - out = _transform_output(out, telnet_writer, in_raw) - if raw_mode is None: - mode_result = term.check_auto_mode(switched_to_raw, last_will_echo) - if mode_result is not None: - if not switched_to_raw: - linesep = "\r\n" - switched_to_raw, last_will_echo, local_echo = mode_result - stdout.write(out.encode() or b":?!?:") - telnet_task = accessories.make_reader_task(telnet_reader, size=2**24) - wait_for.add(telnet_task) + tty_shell.cleanup_winch() + + def _want_repl() -> bool: + return False + + # Standard event loop (byte-at-a-time). + if not switched_to_raw and tty_shell._istty and tty_shell._save_mode is not None: + tty_shell.set_mode(tty_shell._make_raw(tty_shell._save_mode, suppress_echo=True)) + switched_to_raw = True + local_echo = not telnet_writer.will_echo + linesep = "\r\n" + stdin = await tty_shell.connect_stdin() + state = _RawLoopState( + switched_to_raw=switched_to_raw, + last_will_echo=last_will_echo, + local_echo=local_echo, + linesep=linesep, + ) + await _raw_event_loop( + telnet_reader, + telnet_writer, + tty_shell, + stdin, + stdout, + keyboard_escape, + state, + _handle_close, + _want_repl, + ) + tty_shell.disconnect_stdin(stdin) diff --git a/telnetlib3/color_filter.py b/telnetlib3/color_filter.py index 0415dcd9..6e3accba 100644 --- a/telnetlib3/color_filter.py +++ b/telnetlib3/color_filter.py @@ -4,32 +4,29 @@ Most modern terminals use custom palette colors for ANSI colors 0-15 (e.g. Solarized, Dracula, Gruvbox themes). When connecting to MUDs and BBS systems, the artwork and text colors were designed for specific hardware palettes such as -IBM EGA, VGA, or Amiga. The terminal's custom palette distorts the intended +IBM VGA or Commodore 64. The terminal's custom palette distorts the intended colors, often ruining ANSI artwork. By translating basic 16-color SGR codes into their exact 24-bit RGB equivalents from named hardware palettes, we bypass the terminal's palette entirely and display the colors the artist intended. -This feature is enabled by default using the EGA palette. Use +This feature is enabled by default using the VGA palette. Use ``--colormatch=none`` on the ``telnetlib3-client`` command line to disable it. Example usage:: - # Default EGA palette with brightness/contrast adjustment + # Default VGA palette with brightness/contrast adjustment telnetlib3-client mud.example.com 4000 - # Use VGA palette instead - telnetlib3-client --colormatch=vga mud.example.com + # Use xterm palette instead + telnetlib3-client --colormatch=xterm mud.example.com # Disable color translation entirely telnetlib3-client --colormatch=none mud.example.com # Custom brightness and contrast telnetlib3-client --color-brightness=0.7 --color-contrast=0.6 mud.example.com - - # White-background terminal (reverse video) - telnetlib3-client --reverse-video mud.example.com """ from __future__ import annotations @@ -46,68 +43,12 @@ # Type alias for a 16-color palette: 16 (R, G, B) tuples indexed 0-15. # Index 0-7: normal colors (black, red, green, yellow, blue, magenta, cyan, white) # Index 8-15: bright variants of the same order. -PaletteRGB = Tuple[ - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], - Tuple[int, int, int], -] +PaletteRGB = tuple[tuple[int, int, int], ...] # Hardware color palettes. Each defines exact RGB values for ANSI colors 0-15. PALETTES: Dict[str, PaletteRGB] = { - # IBM Enhanced Graphics Adapter -- the classic DOS palette used by most + # IBM VGA text-mode palette -- the classic DOS palette used by most # BBS and MUD ANSI artwork. - "ega": ( - (0, 0, 0), - (170, 0, 0), - (0, 170, 0), - (170, 85, 0), - (0, 0, 170), - (170, 0, 170), - (0, 170, 170), - (170, 170, 170), - (85, 85, 85), - (255, 85, 85), - (85, 255, 85), - (255, 255, 85), - (85, 85, 255), - (255, 85, 255), - (85, 255, 255), - (255, 255, 255), - ), - # IBM Color Graphics Adapter -- earlier, more saturated palette. - "cga": ( - (0, 0, 0), - (170, 0, 0), - (0, 170, 0), - (170, 170, 0), - (0, 0, 170), - (170, 0, 170), - (0, 170, 170), - (170, 170, 170), - (85, 85, 85), - (255, 85, 85), - (85, 255, 85), - (255, 255, 85), - (85, 85, 255), - (255, 85, 255), - (85, 255, 255), - (255, 255, 255), - ), - # VGA / DOS standard palette -- the most common DOS palette, very close - # to EGA but with a brighter dark yellow. "vga": ( (0, 0, 0), (170, 0, 0), @@ -126,26 +67,6 @@ (85, 255, 255), (255, 255, 255), ), - # Amiga Workbench 1.x palette -- warmer tones characteristic of the - # Commodore Amiga. - "amiga": ( - (0, 0, 0), - (170, 0, 0), - (0, 170, 0), - (170, 170, 0), - (0, 0, 170), - (170, 0, 170), - (0, 170, 170), - (187, 187, 187), - (85, 85, 85), - (255, 85, 85), - (85, 255, 85), - (255, 255, 85), - (85, 85, 255), - (255, 85, 255), - (85, 255, 255), - (255, 255, 255), - ), # xterm default palette -- the standard xterm color table. "xterm": ( (0, 0, 0), @@ -165,28 +86,30 @@ (0, 255, 255), (255, 255, 255), ), - # VIC-II C64 palette (Pepto's colodore reference). + # VIC-II C64 palette -- Colodore (Pepto) reference from VICE + # (colodore.vpl, https://www.colodore.com). # Indexed by VIC-II color register 0-15, NOT ANSI SGR order. "c64": ( (0, 0, 0), # 0 black (255, 255, 255), # 1 white - (136, 0, 0), # 2 red - (170, 255, 238), # 3 cyan - (204, 68, 204), # 4 purple - (0, 204, 85), # 5 green - (0, 0, 170), # 6 blue - (238, 238, 119), # 7 yellow - (221, 136, 85), # 8 orange - (102, 68, 0), # 9 brown - (255, 119, 119), # 10 pink / light red - (51, 51, 51), # 11 dark grey - (119, 119, 119), # 12 grey - (170, 255, 102), # 13 light green - (0, 136, 255), # 14 light blue - (187, 187, 187), # 15 light grey + (150, 40, 46), # 2 red + (91, 214, 206), # 3 cyan + (159, 45, 173), # 4 purple + (65, 185, 54), # 5 green + (39, 36, 196), # 6 blue + (239, 243, 71), # 7 yellow + (159, 72, 21), # 8 orange + (94, 53, 0), # 9 brown + (218, 95, 102), # 10 pink / light red + (71, 71, 71), # 11 dark grey + (120, 120, 120), # 12 grey + (145, 255, 132), # 13 light green + (104, 100, 255), # 14 light blue + (174, 174, 174), # 15 light grey ), } + # Detect potentially incomplete escape sequence at end of a chunk. _TRAILING_ESC = re.compile(r"\x1b(\[[\d;:]*)?$") @@ -199,14 +122,15 @@ class ColorConfig(NamedTuple): :param brightness: Brightness scale factor [0.0..1.0], where 1.0 is original. :param contrast: Contrast scale factor [0.0..1.0], where 1.0 is original. :param background_color: Forced background RGB as (R, G, B) tuple. - :param reverse_video: When True, swap fg/bg for light-background terminals. + :param ice_colors: When True, treat SGR 5 (blink) as bright background + (iCE colors), promoting background 40-47 to palette 8-15. """ - palette_name: str = "ega" - brightness: float = 0.9 - contrast: float = 0.8 - background_color: Tuple[int, int, int] = (16, 16, 16) - reverse_video: bool = False + palette_name: str = "vga" + brightness: float = 1.0 + contrast: float = 1.0 + background_color: Tuple[int, int, int] = (0, 0, 0) + ice_colors: bool = True def _sgr_code_to_palette_index(code: int) -> Optional[int]: @@ -286,12 +210,16 @@ def __init__(self, config: ColorConfig) -> None: _adjust_color(r, g, b, config.brightness, config.contrast) for r, g, b in palette ] bg = config.background_color - if config.reverse_video: - bg = (255 - bg[0], 255 - bg[1], 255 - bg[2]) self._bg_sgr = f"\x1b[48;2;{bg[0]};{bg[1]};{bg[2]}m" + fg = self._adjusted[7] # default fg = white (palette index 7) + self._fg_sgr = f"\x1b[38;2;{fg[0]};{fg[1]};{fg[2]}m" + self._reset_bg_parts = ["48", "2", str(bg[0]), str(bg[1]), str(bg[2])] + self._reset_fg_parts = ["38", "2", str(fg[0]), str(fg[1]), str(fg[2])] self._buffer = "" self._initial = True self._bold = False + self._blink = False + self._fg_idx = 7 # current fg palette index (0-15), -1 for extended def filter(self, text: str) -> str: """ @@ -322,7 +250,6 @@ def filter(self, text: str) -> str: result = self._bg_sgr + result return result - # pylint: disable-next=too-complex,too-many-branches,too-many-statements def _replace_sgr(self, match: Match[str]) -> str: # noqa: C901 r""" Regex replacement callback for a single SGR sequence. @@ -334,24 +261,29 @@ def _replace_sgr(self, match: Match[str]) -> str: # noqa: C901 """ params_str = match.group(1) - # Empty params or bare "0" → reset + # Empty params or bare "0" -> reset if not params_str: self._bold = False - return f"\x1b[0m{self._bg_sgr}" + self._blink = False + self._fg_idx = 7 + return f"\x1b[0m{self._bg_sgr}{self._fg_sgr}" - # Colon-separated extended colors (ITU T.416) — pass through unchanged + # Colon-separated extended colors (ITU T.416) -- pass through unchanged if ":" in params_str: return match.group() parts = params_str.split(";") output_parts: List[str] = [] i = 0 - has_reset = False - # Pre-scan: check if bold (1) appears in this sequence so that a - # color code *before* the bold in the same sequence still gets the - # bright treatment, e.g. \x1b[31;1m should brighten red. + # Pre-scan: check if bold (1), blink (5), or explicit foreground + # colors appear in this sequence so that a color code *before* the + # attribute in the same sequence still gets the bright treatment, + # e.g. \x1b[31;1m should brighten red and \x1b[41;5m should + # brighten background. seq_sets_bold = False + seq_sets_blink = False + seq_has_fg = False for part in parts: try: val = int(part) if part else 0 @@ -359,10 +291,15 @@ def _replace_sgr(self, match: Match[str]) -> str: # noqa: C901 continue if val == 1: seq_sets_bold = True - break + elif val == 5: + seq_sets_blink = True + if (30 <= val <= 37) or (90 <= val <= 97) or val in (38, 39): + seq_has_fg = True - # Effective bold for color lookups in this sequence + # Effective bold/blink for color lookups in this sequence bold = self._bold or seq_sets_bold + ice = self._config.ice_colors + blink = self._blink or (seq_sets_blink and ice) while i < len(parts): try: @@ -373,25 +310,53 @@ def _replace_sgr(self, match: Match[str]) -> str: # noqa: C901 continue if p == 0: - has_reset = True bold = False + blink = False output_parts.append("0") + output_parts.extend(self._reset_bg_parts) + output_parts.extend(self._reset_fg_parts) + self._fg_idx = 7 i += 1 continue # Track bold state if p == 1: + bold = True output_parts.append("1") + if not seq_has_fg and 0 <= self._fg_idx <= 7: + bright_idx = self._fg_idx + 8 + r, g, b = self._adjusted[bright_idx] + output_parts.extend(["38", "2", str(r), str(g), str(b)]) i += 1 continue if p == 22: bold = False output_parts.append("22") + if not seq_has_fg and 0 <= self._fg_idx <= 7: + r, g, b = self._adjusted[self._fg_idx] + output_parts.extend(["38", "2", str(r), str(g), str(b)]) i += 1 continue - # Extended color — pass through 38;5;N or 38;2;R;G;B verbatim + # Track blink state + if p == 5: + if ice: + blink = True + else: + output_parts.append("5") + i += 1 + continue + if p == 25: + blink = False + if not ice: + output_parts.append("25") + i += 1 + continue + + # Extended color -- pass through 38;5;N or 38;2;R;G;B verbatim if p in (38, 48): + if p == 38: + self._fg_idx = -1 start_i = i i += 1 if i < len(parts): @@ -407,21 +372,31 @@ def _replace_sgr(self, match: Match[str]) -> str: # noqa: C901 output_parts.extend(parts[start_i:i]) continue - # Default fg/bg — pass through - if p in (39, 49): - output_parts.append(str(p)) + # Default fg -> palette white; default bg -> configured bg + if p == 39: + self._fg_idx = 7 + r, g, b = self._adjusted[7] + output_parts.extend(["38", "2", str(r), str(g), str(b)]) + i += 1 + continue + if p == 49: + bg = self._config.background_color + output_parts.extend(["48", "2", str(bg[0]), str(bg[1]), str(bg[2])]) i += 1 continue idx = _sgr_code_to_palette_index(p) if idx is not None: is_fg = _is_foreground_code(p) + if is_fg: + self._fg_idx = idx # Bold-as-bright: promote normal fg 30-37 to bright 8-15 if is_fg and bold and 30 <= p <= 37: idx += 8 + # iCE colors: promote normal bg 40-47 to bright 8-15 + if not is_fg and blink and 40 <= p <= 47: + idx += 8 r, g, b = self._adjusted[idx] - if self._config.reverse_video: - is_fg = not is_fg if is_fg: output_parts.extend(["38", "2", str(r), str(g), str(b)]) else: @@ -430,12 +405,11 @@ def _replace_sgr(self, match: Match[str]) -> str: # noqa: C901 output_parts.append(str(p)) i += 1 - # Update persistent bold state for subsequent sequences + # Update persistent bold/blink state for subsequent sequences self._bold = bold + self._blink = blink result = f"\x1b[{';'.join(output_parts)}m" if output_parts else "" - if has_reset: - result += self._bg_sgr return result def flush(self) -> str: @@ -451,7 +425,7 @@ def flush(self) -> str: return result -# PETSCII decoded control character → VIC-II palette index (0-15). +# PETSCII decoded control character -> VIC-II palette index (0-15). _PETSCII_COLOR_CODES: Dict[str, int] = { "\x05": 1, # WHT (white) "\x1c": 2, # RED @@ -471,7 +445,7 @@ def flush(self) -> str: "\x9f": 3, # CYN (cyan) } -# PETSCII cursor/screen control codes → ANSI escape sequences. +# PETSCII cursor/screen control codes -> ANSI escape sequences. _PETSCII_CURSOR_CODES: Dict[str, str] = { "\x11": "\x1b[B", # cursor down "\x91": "\x1b[A", # cursor up @@ -500,14 +474,14 @@ class PetsciiColorFilter: color changes, cursor movement, and screen control. This filter translates them to ANSI equivalents: - - **Colors**: 16 VIC-II palette colors → ``\x1b[38;2;R;G;Bm`` (24-bit RGB) - - **Reverse video**: RVS ON/OFF → ``\x1b[7m`` / ``\x1b[27m`` - - **Cursor**: up/down/left/right → ``\x1b[A/B/C/D`` - - **Screen**: HOME → ``\x1b[H``, CLR → ``\x1b[2J`` - - **DEL**: destructive backspace → ``\x08\x1b[P`` + - **Colors**: 16 VIC-II palette colors -> ``\x1b[38;2;R;G;Bm`` (24-bit RGB) + - **Reverse video**: RVS ON/OFF -> ``\x1b[7m`` / ``\x1b[27m`` + - **Cursor**: up/down/left/right -> ``\x1b[A/B/C/D`` + - **Screen**: HOME -> ``\x1b[H``, CLR -> ``\x1b[2J`` + - **DEL**: destructive backspace -> ``\x08\x1b[P`` :param config: Color configuration (uses ``brightness`` and ``contrast`` - for palette adjustment; ``palette_name`` is ignored — always C64). + for palette adjustment; ``palette_name`` is ignored -- always C64). """ def __init__(self, config: Optional[ColorConfig] = None) -> None: @@ -517,8 +491,8 @@ def __init__(self, config: Optional[ColorConfig] = None) -> None: brightness = config.brightness contrast = config.contrast else: - brightness = 0.9 - contrast = 0.8 + brightness = 1.0 + contrast = 1.0 self._adjusted: List[Tuple[int, int, int]] = [ _adjust_color(r, g, b, brightness, contrast) for r, g, b in palette ] @@ -568,7 +542,7 @@ def flush(self) -> str: return "" -# ATASCII decoded control character glyphs → ANSI terminal sequences. +# ATASCII decoded control character glyphs -> ANSI terminal sequences. # The atascii codec decodes control bytes to Unicode glyphs; this map # translates those glyphs to the terminal actions they represent. _ATASCII_CONTROL_CODES: Dict[str, str] = { @@ -578,7 +552,7 @@ def flush(self) -> str: "\u2191": "\x1b[A", # ↑ cursor up (0x1C / 0x9C) "\u2193": "\x1b[B", # ↓ cursor down (0x1D / 0x9D) "\u2190": "\x1b[D", # ← cursor left (0x1E / 0x9E) - "\u2192": "\x1b[C", # → cursor right (0x1F / 0x9F) + "\u2192": "\x1b[C", # -> cursor right (0x1F / 0x9F) } _ATASCII_CTRL_RE = re.compile("[" + re.escape("".join(sorted(_ATASCII_CONTROL_CODES))) + "]") @@ -589,13 +563,13 @@ class AtasciiControlFilter: Translate decoded ATASCII control character glyphs to ANSI sequences. The ``atascii`` codec decodes ATASCII control bytes into Unicode glyphs - (e.g. byte 0x7E → U+25C0 ◀). This filter replaces those glyphs with + (e.g. byte 0x7E -> U+25C0 ◀). This filter replaces those glyphs with the ANSI terminal sequences that produce the intended effect: - - **Backspace/delete**: ◀ → ``\x08\x1b[P`` (destructive backspace) - - **Tab**: ▶ → ``\t`` - - **Clear screen**: ↰ → ``\x1b[2J\x1b[H`` - - **Cursor movement**: ↑↓←→ → ``\x1b[A/B/D/C`` + - **Backspace/delete**: ◀ -> ``\x08\x1b[P`` (destructive backspace) + - **Tab**: ▶ -> ``\t`` + - **Clear screen**: ↰ -> ``\x1b[2J\x1b[H`` + - **Cursor movement**: ↑↓←-> -> ``\x1b[A/B/D/C`` """ def filter(self, text: str) -> str: diff --git a/telnetlib3/encodings/__init__.py b/telnetlib3/encodings/__init__.py index 30fb7bfe..e35fc6dd 100644 --- a/telnetlib3/encodings/__init__.py +++ b/telnetlib3/encodings/__init__.py @@ -47,8 +47,8 @@ def _search_function(encoding: str) -> Optional[codecs.CodecInfo]: return info -#: Encoding names (and aliases) that require BINARY mode for high-bit bytes. -#: Used by CLI entry points to auto-enable ``--force-binary``. +#: Retro BBS encoding names (and aliases) that additionally require raw mode. +#: Used by the client CLI entry point to auto-enable ``--raw-mode``. FORCE_BINARY_ENCODINGS = frozenset( { "atascii", diff --git a/telnetlib3/encodings/atarist.py b/telnetlib3/encodings/atarist.py index 2d3a35f5..09f303d3 100644 --- a/telnetlib3/encodings/atarist.py +++ b/telnetlib3/encodings/atarist.py @@ -4,8 +4,6 @@ Generated from ftp://ftp.unicode.org/Public/MAPPINGS/VENDORS/MISC/ATARIST.TXT """ -# pylint: disable=redefined-builtin - # std imports import codecs diff --git a/telnetlib3/encodings/atascii.py b/telnetlib3/encodings/atascii.py index 8ef9e25b..1e0fac0c 100644 --- a/telnetlib3/encodings/atascii.py +++ b/telnetlib3/encodings/atascii.py @@ -311,29 +311,25 @@ def _normalize_eol(text: str) -> str: class Codec(codecs.Codec): """ATASCII character map codec.""" - def encode( # pylint: disable=redefined-builtin - self, input: str, errors: str = "strict" - ) -> Tuple[bytes, int]: + def encode(self, input: str, errors: str = "strict") -> Tuple[bytes, int]: """Encode input string using ATASCII character map.""" input = _normalize_eol(input) return codecs.charmap_encode(input, errors, ENCODING_TABLE) - def decode( # pylint: disable=redefined-builtin - self, input: bytes, errors: str = "strict" - ) -> Tuple[str, int]: + def decode(self, input: bytes, errors: str = "strict") -> Tuple[str, int]: """Decode input bytes using ATASCII character map.""" return codecs.charmap_decode(input, errors, DECODING_TABLE) # type: ignore[arg-type] class IncrementalEncoder(codecs.IncrementalEncoder): - """ATASCII incremental encoder with CR/CRLF → LF normalization.""" + """ATASCII incremental encoder with CR/CRLF -> LF normalization.""" def __init__(self, errors: str = "strict") -> None: """Initialize encoder with pending CR state.""" super().__init__(errors) self._pending_cr = False - def encode(self, input: str, final: bool = False) -> bytes: # pylint: disable=redefined-builtin + def encode(self, input: str, final: bool = False) -> bytes: """Encode input string incrementally.""" if self._pending_cr: input = "\r" + input @@ -360,9 +356,7 @@ def setstate(self, state: Union[int, str]) -> None: class IncrementalDecoder(codecs.IncrementalDecoder): """ATASCII incremental decoder.""" - def decode( # type: ignore[override] # pylint: disable=redefined-builtin - self, input: bytes, final: bool = False - ) -> str: + def decode(self, input: bytes, final: bool = False) -> str: # type: ignore[override] """Decode input bytes incrementally.""" return codecs.charmap_decode(input, self.errors, DECODING_TABLE)[ # type: ignore[arg-type] 0 diff --git a/telnetlib3/encodings/petscii.py b/telnetlib3/encodings/petscii.py index edeeca35..fc878874 100644 --- a/telnetlib3/encodings/petscii.py +++ b/telnetlib3/encodings/petscii.py @@ -303,15 +303,11 @@ class Codec(codecs.Codec): """PETSCII character map codec.""" - def encode( # pylint: disable=redefined-builtin - self, input: str, errors: str = "strict" - ) -> tuple[bytes, int]: + def encode(self, input: str, errors: str = "strict") -> tuple[bytes, int]: """Encode input string using PETSCII character map.""" return codecs.charmap_encode(input, errors, ENCODING_TABLE) - def decode( # pylint: disable=redefined-builtin - self, input: bytes, errors: str = "strict" - ) -> tuple[str, int]: + def decode(self, input: bytes, errors: str = "strict") -> tuple[str, int]: """Decode input bytes using PETSCII character map.""" return codecs.charmap_decode(input, errors, DECODING_TABLE) # type: ignore[arg-type] @@ -319,7 +315,7 @@ def decode( # pylint: disable=redefined-builtin class IncrementalEncoder(codecs.IncrementalEncoder): """PETSCII incremental encoder.""" - def encode(self, input: str, final: bool = False) -> bytes: # pylint: disable=redefined-builtin + def encode(self, input: str, final: bool = False) -> bytes: """Encode input string incrementally.""" return codecs.charmap_encode(input, self.errors, ENCODING_TABLE)[0] @@ -327,9 +323,7 @@ def encode(self, input: str, final: bool = False) -> bytes: # pylint: disable=r class IncrementalDecoder(codecs.IncrementalDecoder): """PETSCII incremental decoder.""" - def decode( # type: ignore[override] # pylint: disable=redefined-builtin - self, input: bytes, final: bool = False - ) -> str: + def decode(self, input: bytes, final: bool = False) -> str: # type: ignore[override] """Decode input bytes incrementally.""" return codecs.charmap_decode(input, self.errors, DECODING_TABLE)[ # type: ignore[arg-type] 0 diff --git a/telnetlib3/fingerprinting.py b/telnetlib3/fingerprinting.py index 566b6572..8a201dfc 100644 --- a/telnetlib3/fingerprinting.py +++ b/telnetlib3/fingerprinting.py @@ -1,9 +1,13 @@ """ Fingerprint shell for telnet client identification. -This module probes telnet protocol capabilities, collects session data, -and saves fingerprint files. Display, REPL, and post-script code live -in ``telnetlib3.fingerprinting_display``. +This module runs **server-side**: it is the shell callback for a telnetlib3 +server that probes connecting *clients* for protocol capabilities, collects +session data, and saves fingerprint files. Despite the generic name, it +fingerprints the remote *client*, not the server. + +Display, REPL, and post-script code live in +``telnetlib3.fingerprinting_display``. """ from __future__ import annotations @@ -22,7 +26,8 @@ # local from . import slc -from .server import TelnetServer # pylint: disable=cyclic-import +from ._paths import _atomic_json_write +from .server import TelnetServer from .telopt import ( BM, DO, @@ -178,7 +183,7 @@ class ProbeResult(TypedDict, total=False): logger = logging.getLogger("telnetlib3.fingerprint") -class FingerprintingTelnetServer: # pylint: disable=too-few-public-methods +class FingerprintingTelnetServer: """ Mixin that extends ``on_request_environ`` with :data:`ENVIRON_EXTENDED`. @@ -197,10 +202,10 @@ def on_request_environ(self) -> list[Union[str, bytes]]: """Return base environ keys plus :data:`ENVIRON_EXTENDED`.""" if not isinstance(self, TelnetServer): raise TypeError("FingerprintingTelnetServer must be combined with TelnetServer") - # pylint: disable-next=no-member + # pylint: disable=no-member base: list[Union[str, bytes]] = super().on_request_environ() # type: ignore[misc] # Insert extended keys before the trailing VAR/USERVAR sentinels - from .telopt import VAR, USERVAR # pylint: disable=import-outside-toplevel + from .telopt import VAR, USERVAR extra = [k for k in ENVIRON_EXTENDED if k not in base] # Find where VAR/USERVAR sentinels start and insert before them @@ -496,7 +501,7 @@ def _collect_extra_info(writer: Union[TelnetWriter, TelnetWriterUnicode]) -> dic protocol = _get_protocol(writer) if protocol and hasattr(protocol, "_extra"): - for key, value in protocol._extra.items(): # pylint: disable=protected-access + for key, value in protocol._extra.items(): if isinstance(value, tuple): extra[key] = list(value) elif isinstance(value, bytes): @@ -550,7 +555,7 @@ def _collect_protocol_timing(writer: Union[TelnetWriter, TelnetWriterUnicode]) - if hasattr(protocol, "idle"): timing["idle"] = protocol.idle if hasattr(protocol, "_connect_time"): - timing["connect_time"] = protocol._connect_time # pylint: disable=protected-access + timing["connect_time"] = protocol._connect_time return timing @@ -848,7 +853,7 @@ def _validate_suggestion(text: str) -> Optional[str]: def _cooked_input(prompt: str) -> str: """Call :func:`input` with echo and canonical mode temporarily enabled.""" - import termios # pylint: disable=import-outside-toplevel + import termios fd = sys.stdin.fileno() old_attrs = termios.tcgetattr(fd) @@ -863,26 +868,6 @@ def _cooked_input(prompt: str) -> str: termios.tcsetattr(fd, termios.TCSANOW, old_attrs) -class _BytesSafeEncoder(json.JSONEncoder): - """JSON encoder that converts bytes to str (UTF-8) or hex.""" - - def default(self, o: Any) -> Any: - if isinstance(o, bytes): - try: - return o.decode("utf-8") - except UnicodeDecodeError: - return o.hex() - return super().default(o) - - -def _atomic_json_write(filepath: str, data: dict[str, Any]) -> None: - """Atomically write JSON data to file via write-to-new + rename.""" - tmp_path = os.path.splitext(filepath)[0] + ".json.new" - with open(tmp_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, sort_keys=True, cls=_BytesSafeEncoder) - os.replace(tmp_path, filepath) - - def _build_session_fingerprint( writer: Union[TelnetWriter, TelnetWriterUnicode], probe_results: dict[str, ProbeResult], @@ -1041,7 +1026,6 @@ async def fingerprinting_server_shell( :param reader: TelnetReader instance. :param writer: TelnetWriter instance. """ - # pylint: disable=import-outside-toplevel from .server_pty_shell import pty_shell writer = cast(TelnetWriterUnicode, writer) @@ -1093,7 +1077,6 @@ def fingerprinting_post_script(filepath: str) -> None: :param filepath: Path to the saved fingerprint JSON file. """ - # pylint: disable-next=import-outside-toplevel,cyclic-import from .fingerprinting_display import fingerprinting_post_script as _fps _fps(filepath) @@ -1111,7 +1094,6 @@ def fingerprint_server_main() -> None: Accepts ``--data-dir`` to set the fingerprint data directory. Falls back to the ``TELNETLIB3_DATA_DIR`` environment variable. """ - # pylint: disable=import-outside-toplevel,global-statement # local import is required to prevent circular imports from .server import _config, run_server, parse_server_args # noqa: PLC0415 diff --git a/telnetlib3/fingerprinting_display.py b/telnetlib3/fingerprinting_display.py index 4444b8db..314e5239 100644 --- a/telnetlib3/fingerprinting_display.py +++ b/telnetlib3/fingerprinting_display.py @@ -20,9 +20,14 @@ import functools import contextlib import subprocess -from typing import Any, Dict, List, Tuple, Optional, Generator +from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Optional, Generator + +if TYPE_CHECKING: + import blessed + import prettytable # local +from ._paths import _atomic_json_write from .accessories import PATIENCE_MESSAGES from .fingerprinting import ( DATA_DIR, @@ -30,7 +35,6 @@ AMBIGUOUS_WIDTH_UNKNOWN, _cooked_input, _hash_fingerprint, - _atomic_json_write, _resolve_hash_name, _validate_suggestion, _load_fingerprint_names, @@ -171,7 +175,7 @@ def _wrap_options(options: List[str], max_width: int = 30) -> str: return "\n".join(textwrap.wrap(", ".join(options), width=max_width)) -def _color_yes_no(term: Any, value: bool) -> str: +def _color_yes_no(term: "blessed.Terminal", value: bool) -> str: """Apply green/red coloring to boolean value.""" if value: return str(term.forestgreen("Yes")) @@ -235,8 +239,7 @@ def _format_encoding( return None -# pylint: disable-next=too-complex,too-many-locals,too-many-branches,too-many-statements -def _build_terminal_rows(term: Any, data: Dict[str, Any]) -> List[Tuple[str, str]]: +def _build_terminal_rows(term: "blessed.Terminal", data: Dict[str, Any]) -> List[Tuple[str, str]]: """Build (key, value) tuples for terminal capabilities table.""" pairs: List[Tuple[str, str]] = [] terminal_probe = data.get("terminal-probe", {}) @@ -357,9 +360,7 @@ def _build_terminal_rows(term: Any, data: Dict[str, Any]) -> List[Tuple[str, str return pairs -def _build_telnet_rows( # pylint: disable=too-many-locals,unused-argument - term: Any, data: Dict[str, Any] -) -> List[Tuple[str, str]]: +def _build_telnet_rows(term: "blessed.Terminal", data: Dict[str, Any]) -> List[Tuple[str, str]]: """Build (key, value) tuples for telnet protocol table.""" pairs: List[Tuple[str, str]] = [] telnet_probe = data.get("telnet-probe", {}) @@ -413,9 +414,9 @@ def _build_telnet_rows( # pylint: disable=too-many-locals,unused-argument return pairs -def _make_terminal(**kwargs: Any) -> Any: +def _make_terminal(**kwargs: Any) -> "blessed.Terminal": """Create a blessed Terminal, falling back to ``ansi`` on setupterm failure.""" - from blessed import Terminal # pylint: disable=import-outside-toplevel,import-error + from blessed import Terminal with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") @@ -494,17 +495,17 @@ def _has_truecolor(data: Dict[str, Any]) -> bool: return n is not None and n >= 16777216 -def _hotkey(term: Any, key: str) -> str: +def _hotkey(term: "blessed.Terminal", key: str) -> str: """Format a hotkey as ``key-`` with key and dash in magenta.""" return f"{term.bold_magenta(key)}{term.bold_magenta('-')}" -def _bracket_key(term: Any, key: str) -> str: +def _bracket_key(term: "blessed.Terminal", key: str) -> str: """Format a hotkey as ``[key]`` with brackets in cyan, key in magenta.""" return f"{term.cyan('[')}{term.bold_magenta(key)}{term.cyan(']')}" -def _apply_unicode_borders(tbl: Any) -> None: +def _apply_unicode_borders(tbl: "prettytable.PrettyTable") -> None: """Apply double-line box-drawing characters to a PrettyTable.""" tbl.horizontal_char = "\u2550" tbl.vertical_char = "\u2551" @@ -519,15 +520,13 @@ def _apply_unicode_borders(tbl: Any) -> None: tbl.bottom_right_junction_char = "\u255d" -def _display_compact_summary( # pylint: disable=too-complex,too-many-branches - data: Dict[str, Any], term: Any = None +def _display_compact_summary( + data: Dict[str, Any], term: Optional["blessed.Terminal"] = None ) -> bool: """Display compact fingerprint summary using prettytable.""" try: - from ucs_detect import ( # pylint: disable=import-outside-toplevel - _collect_side_by_side_lines, - ) - from prettytable import PrettyTable # pylint: disable=import-outside-toplevel + from ucs_detect import _collect_side_by_side_lines + from prettytable import PrettyTable except ImportError: return False @@ -628,9 +627,7 @@ def _fingerprint_similarity(a: Dict[str, Any], b: Dict[str, Any]) -> float: return sum(scores) / len(scores) if scores else 1.0 -def _load_known_fingerprints( # pylint: disable=too-complex - probe_type: str, -) -> Dict[str, Dict[str, Any]]: +def _load_known_fingerprints(probe_type: str) -> Dict[str, Dict[str, Any]]: """ Load one fingerprint-data dict per unique hash from the data directory. @@ -701,8 +698,10 @@ def _find_nearest_match( return (best_name, best_score) -def _build_seen_counts( # pylint: disable=too-many-locals - data: Dict[str, Any], names: Optional[Dict[str, str]] = None, term: Any = None +def _build_seen_counts( + data: Dict[str, Any], + names: Optional[Dict[str, str]] = None, + term: Optional["blessed.Terminal"] = None, ) -> str: """Build friendly "seen before" text from folder and session counts.""" if DATA_DIR is None or not os.path.exists(DATA_DIR): @@ -771,7 +770,7 @@ def _build_seen_counts( # pylint: disable=too-many-locals return "" -def _color_match(term: Any, name: str, score: float) -> str: +def _color_match(term: "blessed.Terminal", name: str, score: float) -> str: """ Color a nearest-match result by confidence threshold. @@ -791,7 +790,7 @@ def _color_match(term: Any, name: str, score: float) -> str: def _nearest_match_lines( data: Dict[str, Any], names: Dict[str, str], - term: Any, + term: "blessed.Terminal", telnet_unknown: bool = False, terminal_unknown: bool = False, ) -> List[str]: @@ -817,7 +816,7 @@ def _nearest_match_lines( return result_lines -def _repl_prompt(term: Any) -> None: +def _repl_prompt(term: "blessed.Terminal") -> None: """Write the REPL prompt with hotkey legend.""" bk = _bracket_key legend = ( @@ -827,13 +826,13 @@ def _repl_prompt(term: Any) -> None: echo(f"\r{term.clear_eos}{term.normal}{legend}") -def _paginate(term: Any, text: str, **_kw: Any) -> None: # pylint: disable=unused-argument +def _paginate(term: "blessed.Terminal", text: str, **_kw: Any) -> None: """Display text.""" for line in text.split("\n"): echo(line + "\n") -def _colorize_json(data: Any, term: Any = None) -> str: +def _colorize_json(data: Any, term: Optional["blessed.Terminal"] = None) -> str: """ Format JSON with color, preferring bat/batcat over jq. @@ -882,18 +881,13 @@ def _strip_empty_features(d: Dict[str, Any]) -> None: def _normalize_color_hex(hex_color: str) -> str: """Normalize X11 color hex to standard 6-digit format.""" - from blessed.colorspace import ( # pylint: disable=import-outside-toplevel,import-error - hex_to_rgb, - rgb_to_hex, - ) + from blessed.colorspace import hex_to_rgb, rgb_to_hex r, g, b = hex_to_rgb(hex_color) return str(rgb_to_hex(r, g, b)) -def _filter_terminal_detail( # pylint: disable=too-complex,too-many-branches - detail: Optional[Dict[str, Any]], -) -> Optional[Dict[str, Any]]: +def _filter_terminal_detail(detail: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: """Filter terminal session data for display.""" if not detail: return detail @@ -978,7 +972,7 @@ def _filter_telnet_detail(detail: Optional[Dict[str, Any]]) -> Optional[Dict[str return result -def _show_detail(term: Any, data: Dict[str, Any], section: str) -> None: +def _show_detail(term: "blessed.Terminal", data: Dict[str, Any], section: str) -> None: """Show detailed JSON for a fingerprint section with pagination.""" if section == "terminal": terminal_probe = data.get("terminal-probe", {}) @@ -1006,7 +1000,7 @@ def _client_ip(data: Dict[str, Any]) -> str: return "unknown" -def _build_database_entries( # pylint: disable=too-many-locals +def _build_database_entries( names: Optional[Dict[str, str]] = None, ) -> List[Tuple[str, str, int, int]]: """ @@ -1065,11 +1059,11 @@ def _build_database_entries( # pylint: disable=too-many-locals def _show_database( - term: Any, data: Dict[str, Any], entries: List[Tuple[str, str, int, int]] + term: "blessed.Terminal", data: Dict[str, Any], entries: List[Tuple[str, str, int, int]] ) -> None: """Display scrollable database of all known fingerprints.""" try: - from prettytable import PrettyTable # pylint: disable=import-outside-toplevel + from prettytable import PrettyTable except ImportError: echo("prettytable not installed.\n") return @@ -1097,7 +1091,7 @@ def _show_database( def _fingerprint_repl( - term: Any, + term: "blessed.Terminal", data: Dict[str, Any], seen_counts: str = "", filepath: Optional[str] = None, @@ -1165,8 +1159,8 @@ def _has_unknown_hashes(data: Dict[str, Any], names: Dict[str, str]) -> bool: return False -def _prompt_fingerprint_identification( # pylint: disable=too-many-branches - term: Any, data: Dict[str, Any], filepath: str, names: Dict[str, str] +def _prompt_fingerprint_identification( + term: "blessed.Terminal", data: Dict[str, Any], filepath: str, names: Dict[str, str] ) -> None: """Prompt user to identify unknown fingerprint hashes.""" telnet_probe = data.get("telnet-probe", {}) @@ -1275,7 +1269,7 @@ def _process_client_fingerprint(filepath: str, data: Dict[str, Any]) -> None: _setup_term_environ(data) try: - import blessed # noqa: F401 # pylint: disable=import-outside-toplevel,unused-import + import blessed # noqa: F401 except ImportError: print(json.dumps(data, indent=2, sort_keys=True)) return diff --git a/telnetlib3/guard_shells.py b/telnetlib3/guard_shells.py index 8c9d1d46..3091799d 100644 --- a/telnetlib3/guard_shells.py +++ b/telnetlib3/guard_shells.py @@ -56,12 +56,12 @@ def _latin1_reading( return orig_fn = reader.fn_encoding reader.fn_encoding = lambda **kw: "latin-1" - reader._decoder = None # pylint: disable=protected-access + reader._decoder = None try: yield finally: reader.fn_encoding = orig_fn - reader._decoder = None # pylint: disable=protected-access + reader._decoder = None class ConnectionCounter: diff --git a/telnetlib3/mud.py b/telnetlib3/mud.py index f6afd6c2..412ed378 100644 --- a/telnetlib3/mud.py +++ b/telnetlib3/mud.py @@ -312,7 +312,7 @@ def atcp_decode(buf: bytes, encoding: str = "utf-8") -> tuple[str, str]: return (package, value) -# Aardwolf channel byte meanings (server → client). +# Aardwolf channel byte meanings (server -> client). _AARDWOLF_CHANNELS: dict[int, str] = { 100: "status", 101: "tick", diff --git a/telnetlib3/server.py b/telnetlib3/server.py index a73106df..8f881ce3 100755 --- a/telnetlib3/server.py +++ b/telnetlib3/server.py @@ -51,7 +51,7 @@ class CONFIG(NamedTuple): port: int = 6023 loglevel: str = "info" logfile: Optional[str] = None - logfmt: str = accessories._DEFAULT_LOGFMT # pylint: disable=protected-access + logfmt: str = accessories._DEFAULT_LOGFMT shell: Callable[..., Any] = accessories.function_lookup("telnetlib3.telnet_server_shell") encoding: str = "utf8" force_binary: bool = False @@ -64,6 +64,7 @@ class CONFIG(NamedTuple): pty_fork_limit: int = 0 status_interval: int = 20 never_send_ga: bool = False + line_mode: bool = False # Default config instance - use this to access default values @@ -82,7 +83,7 @@ class TelnetServer(server_base.BaseServer): # Derived methods from base class - def __init__( # pylint: disable=too-many-positional-arguments + def __init__( self, term: str = "unknown", cols: int = 80, @@ -94,6 +95,7 @@ def __init__( # pylint: disable=too-many-positional-arguments encoding_errors: str = "strict", force_binary: bool = False, never_send_ga: bool = False, + line_mode: bool = False, connect_maxwait: float = 4.0, limit: Optional[int] = None, reader_factory: type = TelnetReader, @@ -109,6 +111,7 @@ def __init__( # pylint: disable=too-many-positional-arguments encoding_errors=encoding_errors, force_binary=force_binary, never_send_ga=never_send_ga, + line_mode=line_mode, connect_maxwait=connect_maxwait, limit=limit, reader_factory=reader_factory, @@ -134,14 +137,7 @@ def __init__( # pylint: disable=too-many-positional-arguments def connection_made(self, transport: asyncio.BaseTransport) -> None: """Handle new connection and wire up telnet option callbacks.""" - from .telopt import ( # pylint: disable=import-outside-toplevel - NAWS, - TTYPE, - TSPEED, - CHARSET, - XDISPLOC, - NEW_ENVIRON, - ) + from .telopt import NAWS, TTYPE, TSPEED, CHARSET, XDISPLOC, NEW_ENVIRON super().connection_made(transport) @@ -175,7 +171,7 @@ def data_received(self, data: bytes) -> None: def begin_negotiation(self) -> None: """Begin telnet negotiation by requesting terminal type.""" - from .telopt import DO, TTYPE # pylint: disable=import-outside-toplevel + from .telopt import DO, TTYPE super().begin_negotiation() self.writer.iac(DO, TTYPE) @@ -192,17 +188,11 @@ def begin_advanced_negotiation(self) -> None: MUD clients (Mudlet, TinTin++, etc.) interpret ``WILL ECHO`` as "password mode" and mask input. See ``_negotiate_echo()``. """ - from .telopt import ( # pylint: disable=import-outside-toplevel - DO, - SGA, - NAWS, - WILL, - BINARY, - CHARSET, - ) + from .telopt import DO, SGA, NAWS, WILL, BINARY, CHARSET super().begin_advanced_negotiation() - self.writer.iac(WILL, SGA) + if not self.line_mode: + self.writer.iac(WILL, SGA) # WILL ECHO is deferred -- see _negotiate_echo() self.writer.iac(WILL, BINARY) # DO NEW_ENVIRON is deferred -- see _negotiate_environ() @@ -212,13 +202,7 @@ def begin_advanced_negotiation(self) -> None: def check_negotiation(self, final: bool = False) -> bool: """Check if negotiation is complete including encoding.""" - from .telopt import ( # pylint: disable=import-outside-toplevel - DO, - SB, - TTYPE, - CHARSET, - NEW_ENVIRON, - ) + from .telopt import DO, SB, TTYPE, CHARSET, NEW_ENVIRON # If TTYPE cycle stalled or client refused TTYPE, trigger # deferred ECHO and NEW_ENVIRON negotiation now. Only when @@ -420,23 +404,38 @@ def on_request_environ(self) -> List[Union[str, bytes]]: session setup. Override this method or see :data:`~.fingerprinting.ENVIRON_EXTENDED` for a larger set used during client fingerprinting. + + .. note:: + + ``USER`` is excluded when the client is Microsoft telnet + (ttype1=ANSI, ttype2=VT100) because requesting it crashes + ``telnet.exe``. See GitHub issue #24. """ - from .telopt import VAR, USERVAR # pylint: disable=import-outside-toplevel + from .telopt import VAR, USERVAR - return [ - "USER", - "LOGNAME", - "DISPLAY", - "LANG", - "TERM", - "COLUMNS", - "LINES", - "COLORTERM", - "EDITOR", - # Request any other VAR/USERVAR the client wants to send - VAR, - USERVAR, - ] + ttype1 = self.get_extra_info("ttype1") or "" + ttype2 = self.get_extra_info("ttype2") or "" + is_ms_telnet = ttype1 == "ANSI" and ttype2 == "VT100" + + result: List[Union[str, bytes]] = [] + if not is_ms_telnet: + result.append("USER") + result.extend( + [ + "LOGNAME", + "DISPLAY", + "LANG", + "TERM", + "COLUMNS", + "LINES", + "COLORTERM", + "EDITOR", + # Request any other VAR/USERVAR the client wants to send + VAR, + USERVAR, + ] + ) + return result def on_environ(self, mapping: Dict[str, str]) -> None: """Callback receives NEW_ENVIRON response, :rfc:`1572`.""" @@ -457,6 +456,14 @@ def on_environ(self, mapping: Dict[str, str]) -> None: self._extra.update(u_mapping) + # When the client provides LANG (with encoding suffix) or CHARSET, + # presume BINARY capability even without explicit BINARY negotiation. + has_charset = bool(u_mapping.get("CHARSET")) + lang_val = u_mapping.get("LANG", "") + has_lang_encoding = "." in lang_val and lang_val != "C" + if (has_charset or has_lang_encoding) and self.writer is not None: + self.writer._force_binary_on_protocol() + def on_request_charset(self) -> List[str]: """ Definition for CHARSET request by client, :rfc:`2066`. @@ -563,41 +570,35 @@ def on_xdisploc(self, xdisploc: str) -> None: def _negotiate_environ(self) -> None: """ - Send ``DO NEW_ENVIRON`` unless the client is Microsoft telnet. + Send ``DO NEW_ENVIRON``. Called from :meth:`on_ttype` as soon as we have enough information: - After ``ttype1`` when it is not ``"ANSI"``. - - After ``ttype2`` when ``ttype1`` *is* ``"ANSI"`` -- if ``ttype2`` - is ``"VT100"`` the client is Microsoft Windows telnet and - ``NEW_ENVIRON`` is skipped entirely (GitHub issue #24). + - After ``ttype2`` when ``ttype1`` *is* ``"ANSI"`` -- this gives + :meth:`on_request_environ` enough context to detect Microsoft + telnet and exclude ``USER`` (GitHub issue #24). - From :meth:`check_negotiation` when TTYPE stalls or is refused. """ if self._environ_requested: return self._environ_requested = True - from .telopt import DO, NEW_ENVIRON # pylint: disable=import-outside-toplevel - - ttype1 = self.get_extra_info("ttype1") or "" - ttype2 = self.get_extra_info("ttype2") or "" - - if ttype1 == "ANSI" and ttype2 == "VT100": - logger.info( - "skipping NEW_ENVIRON for Microsoft telnet (ttype1=%r, ttype2=%r)", ttype1, ttype2 - ) - return + from .telopt import DO, NEW_ENVIRON self.writer.iac(DO, NEW_ENVIRON) def _negotiate_echo(self) -> None: """ - Send ``WILL ECHO`` unless the client is a MUD client. + Send ``WILL ECHO`` unless the client is a MUD client or line mode. MUD clients (Mudlet, TinTin++, etc.) interpret ``WILL ECHO`` as "password mode" and mask the input bar. We defer ECHO negotiation until TTYPE arrives so MUD clients are detected first. + When :attr:`line_mode` is ``True``, ECHO is never sent so the + client stays in NVT local (line) mode. + Called from :meth:`on_ttype` on each TTYPE response, and from :meth:`check_negotiation` when TTYPE stalls or is refused. """ @@ -605,9 +606,13 @@ def _negotiate_echo(self) -> None: return self._echo_negotiated = True - from .telopt import ECHO, WILL # pylint: disable=import-outside-toplevel - from .fingerprinting import _is_maybe_mud # pylint: disable=import-outside-toplevel + if self.line_mode: + return + + from .telopt import ECHO, WILL + from .fingerprinting import _is_maybe_mud + assert self.writer is not None if _is_maybe_mud(self.writer): logger.info("skipping WILL ECHO for MUD client") return @@ -615,7 +620,7 @@ def _negotiate_echo(self) -> None: def _check_encoding(self) -> bool: # Periodically check for completion of ``waiter_encoding``. - from .telopt import DO, SB, BINARY, CHARSET # pylint: disable=import-outside-toplevel + from .telopt import DO, SB, BINARY, CHARSET # Check if we need to request client to use BINARY mode for client-to-server communication if ( @@ -663,8 +668,8 @@ def __init__( def connection_made(self, transport: asyncio.BaseTransport) -> None: """Pause reading and schedule a peek to detect TLS.""" - self._transport = transport - transport.pause_reading() + self._transport = transport # type: ignore[assignment] + transport.pause_reading() # type: ignore[attr-defined] asyncio.get_event_loop().call_soon(self._detect_tls) def _detect_tls(self) -> None: @@ -702,6 +707,7 @@ async def _upgrade_to_tls(self) -> None: https://github.com/python/cpython/issues/79156 """ loop = asyncio.get_event_loop() + assert self._transport is not None protocol = self._real_factory() try: # start_tls uses call_connection_made=False, so we must call @@ -714,17 +720,19 @@ async def _upgrade_to_tls(self) -> None: if not self._transport.is_closing(): self._transport.close() return + assert ssl_transport is not None protocol.connection_made(ssl_transport) def _handoff_plain(self) -> None: """Hand off to the real protocol as a plain telnet connection.""" + assert self._transport is not None protocol = self._real_factory() self._transport.set_protocol(protocol) protocol.connection_made(self._transport) self._transport.resume_reading() def data_received(self, data: bytes) -> None: # pragma: no cover - """Not expected — reading is paused during detection.""" + """Not expected -- reading is paused during detection.""" def connection_lost(self, exc: Optional[Exception]) -> None: """Connection dropped before detection completed.""" @@ -750,7 +758,6 @@ def close(self) -> None: self._server.close() # Close all connected client transports for protocol in list(self._protocols): - # pylint: disable=protected-access if hasattr(protocol, "_transport") and protocol._transport is not None: protocol._transport.close() @@ -798,7 +805,6 @@ async def wait_for_client(self) -> server_base.BaseServer: def _register_protocol(self, protocol: asyncio.Protocol) -> None: """Register a new protocol instance (called by factory).""" - # pylint: disable=protected-access self._protocols.append(protocol) # type: ignore[arg-type] # Only register callbacks if protocol has the required waiters # (custom protocols like plain asyncio.Protocol won't have these) @@ -877,7 +883,7 @@ def stop(self) -> None: self._task.cancel() -async def create_server( # pylint: disable=too-many-positional-arguments +async def create_server( host: Optional[Union[str, Sequence[str]]] = None, port: int = 23, protocol_factory: Optional[Type[asyncio.Protocol]] = TelnetServer, @@ -886,6 +892,7 @@ async def create_server( # pylint: disable=too-many-positional-arguments encoding_errors: str = "strict", force_binary: bool = False, never_send_ga: bool = False, + line_mode: bool = False, connect_maxwait: float = 4.0, limit: Optional[int] = None, term: str = "unknown", @@ -935,6 +942,10 @@ async def create_server( # pylint: disable=too-many-positional-arguments may be no problem at all. If an encoding is assumed, as in many MUD and BBS systems, the combination of ``force_binary`` with a default ``encoding`` is often preferred. + :param line_mode: When ``True``, the server does not send ``WILL SGA`` + or ``WILL ECHO`` during negotiation. This keeps the client in NVT + local (line) mode, where the client performs its own line editing + and sends complete lines. Default is ``False`` (kludge mode). :param term: Value returned for ``writer.get_extra_info('term')`` until negotiated by TTYPE :rfc:`930`, or NAWS :rfc:`1572`. Default value is ``'unknown'``. @@ -981,6 +992,7 @@ def _make_telnet_protocol() -> asyncio.Protocol: encoding_errors=encoding_errors, force_binary=force_binary, never_send_ga=never_send_ga, + line_mode=line_mode, connect_maxwait=connect_maxwait, limit=limit, term=term, @@ -995,27 +1007,27 @@ def _make_telnet_protocol() -> asyncio.Protocol: encoding_errors=encoding_errors, force_binary=force_binary, never_send_ga=never_send_ga, + line_mode=line_mode, connect_maxwait=connect_maxwait, limit=limit, ) else: protocol = protocol_factory() - telnet_server._register_protocol(protocol) # pylint: disable=protected-access + telnet_server._register_protocol(protocol) return protocol if tls_auto: + assert ssl is not None def factory() -> asyncio.Protocol: return _TLSAutoDetectProtocol(ssl, _make_telnet_protocol) - # pylint: disable=protected-access telnet_server._server = await asyncio.get_event_loop().create_server(factory, host, port) else: def factory() -> asyncio.Protocol: return _make_telnet_protocol() - # pylint: disable=protected-access telnet_server._server = await asyncio.get_event_loop().create_server( factory, host, port, ssl=ssl ) @@ -1083,18 +1095,18 @@ def parse_server_args() -> Dict[str, Any]: default=_config.pty_fork_limit, help="limit concurrent PTY connections (0 disables)", ) - parser.add_argument( - "--line-mode", - action="store_true", - default=False, - help="use cooked PTY mode with echo for --pty-exec instead of raw " - "mode. By default PTY echo is disabled (raw mode), which is " - "correct for programs that handle their own terminal I/O " - "(curses, blessed, ucs-detect).", - ) # Hidden backwards-compat: --pty-raw was the default since 2.5, # keep it as a silent no-op so existing scripts don't break. parser.add_argument("--pty-raw", action="store_true", default=False, help=argparse.SUPPRESS) + parser.add_argument( + "--line-mode", + action="store_true", + default=_config.line_mode, + help="keep clients in NVT line mode by not sending WILL SGA or " + "WILL ECHO during negotiation. Clients perform their own line " + "editing and send complete lines. Also sets cooked PTY mode " + "when combined with --pty-exec.", + ) parser.add_argument( "--robot-check", action="store_true", @@ -1137,18 +1149,17 @@ def parse_server_args() -> Dict[str, Any]: result = vars(parser.parse_args(argv)) result["pty_args"] = pty_args if PTY_SUPPORT else None # --pty-raw is a hidden no-op (raw is now the default); - # --line-mode opts out of raw mode. + # --line-mode opts out of raw mode and suppresses WILL SGA/ECHO. result.pop("pty_raw", None) - result["pty_raw"] = not result.pop("line_mode", False) + result["pty_raw"] = not result.get("line_mode", False) if not PTY_SUPPORT: result["pty_exec"] = None result["pty_fork_limit"] = 0 result["pty_raw"] = False - # Auto-enable force_binary for retro BBS encodings that use high-bit bytes. - from .encodings import FORCE_BINARY_ENCODINGS # pylint: disable=import-outside-toplevel - - if result["encoding"].lower().replace("-", "_") in FORCE_BINARY_ENCODINGS: + # Auto-enable force_binary for any non-ASCII encoding that uses high-bit bytes. + enc_key = result["encoding"].lower().replace("-", "_") + if enc_key not in ("us_ascii", "ascii"): result["force_binary"] = True # Build SSLContext from --ssl-certfile / --ssl-keyfile @@ -1166,7 +1177,7 @@ def parse_server_args() -> Dict[str, Any]: return result -async def run_server( # pylint: disable=too-many-positional-arguments,too-many-locals +async def run_server( host: str = _config.host, port: int = _config.port, loglevel: str = _config.loglevel, @@ -1184,6 +1195,7 @@ async def run_server( # pylint: disable=too-many-positional-arguments,too-many- pty_fork_limit: int = _config.pty_fork_limit, status_interval: int = _config.status_interval, never_send_ga: bool = _config.never_send_ga, + line_mode: bool = _config.line_mode, protocol_factory: Optional[Type[asyncio.Protocol]] = None, ssl: Optional[ssl_module.SSLContext] = None, tls_auto: bool = False, @@ -1201,16 +1213,15 @@ async def run_server( # pylint: disable=too-many-positional-arguments,too-many- if pty_exec: if not PTY_SUPPORT: raise NotImplementedError("PTY support is not available on this platform (Windows?)") - from .server_pty_shell import make_pty_shell # pylint: disable=import-outside-toplevel + from .server_pty_shell import make_pty_shell shell = make_pty_shell(pty_exec, pty_args, raw_mode=pty_raw) # Wrap shell with guards if enabled if robot_check or pty_fork_limit: - # pylint: disable=import-outside-toplevel - from .guard_shells import robot_shell # pylint: disable=import-outside-toplevel from .guard_shells import ConnectionCounter, busy_shell from .guard_shells import robot_check as do_robot_check + from .guard_shells import robot_shell counter = ConnectionCounter(pty_fork_limit) if pty_fork_limit else None inner_shell = shell @@ -1268,6 +1279,7 @@ async def guarded_shell( encoding=encoding, force_binary=force_binary, never_send_ga=never_send_ga, + line_mode=line_mode, timeout=timeout, connect_maxwait=connect_maxwait, ssl=ssl, diff --git a/telnetlib3/server_base.py b/telnetlib3/server_base.py index 1a7c1519..e5116261 100644 --- a/telnetlib3/server_base.py +++ b/telnetlib3/server_base.py @@ -3,15 +3,13 @@ from __future__ import annotations # std imports -import sys -import types import asyncio import logging import datetime -import traceback -from typing import Any, Type, Union, Callable, Optional +from typing import Any, Union, Optional # local +from ._base import TelnetProtocolBase, _log_exception, _process_data_chunk from ._types import ShellCallback from .telopt import theNULL from .accessories import TRACE, hexdump @@ -20,26 +18,19 @@ __all__ = ("BaseServer",) -# Pre-allocated single-byte cache to avoid per-byte bytes() allocations -_ONE_BYTE = [bytes([i]) for i in range(256)] - - logger = logging.getLogger("telnetlib3.server_base") -class BaseServer(asyncio.streams.FlowControlMixin, asyncio.Protocol): +class BaseServer(TelnetProtocolBase, asyncio.streams.FlowControlMixin, asyncio.Protocol): """Base Telnet Server Protocol.""" - _when_connected: Optional[datetime.datetime] = None - _last_received: Optional[datetime.datetime] = None - _transport = None _advanced = False _closing = False _check_later = None _rx_bytes = 0 _tx_bytes = 0 - def __init__( # pylint: disable=too-many-positional-arguments + def __init__( self, shell: Optional[ShellCallback] = None, _waiter_connected: Optional[asyncio.Future[None]] = None, @@ -47,6 +38,7 @@ def __init__( # pylint: disable=too-many-positional-arguments encoding_errors: str = "strict", force_binary: bool = False, never_send_ga: bool = False, + line_mode: bool = False, connect_maxwait: float = 4.0, limit: Optional[int] = None, reader_factory: type = TelnetReader, @@ -60,6 +52,7 @@ def __init__( # pylint: disable=too-many-positional-arguments self._encoding_errors = encoding_errors self.force_binary = force_binary self.never_send_ga = never_send_ga + self.line_mode = line_mode self._extra: dict[str, Any] = {} self._reader_factory = reader_factory @@ -115,13 +108,13 @@ def connection_lost(self, exc: Optional[Exception]) -> None: for task in self._tasks: try: task.cancel() - except Exception: # pylint: disable=broad-exception-caught + except Exception: pass # drop references to scheduled tasks/callbacks self._tasks.clear() try: self._waiter_connected.remove_done_callback(self.begin_shell) - except Exception: # pylint: disable=broad-exception-caught + except Exception: pass # close transport (may already be closed), cancel Future _waiter_connected. @@ -130,7 +123,7 @@ def connection_lost(self, exc: Optional[Exception]) -> None: try: if hasattr(self._transport, "set_protocol"): self._transport.set_protocol(asyncio.Protocol()) - except Exception: # pylint: disable=broad-exception-caught + except Exception: pass self._transport.close() if not self._waiter_connected.cancelled() and not self._waiter_connected.done(): @@ -186,102 +179,38 @@ def begin_shell(self, future: asyncio.Future[None]) -> None: if future.cancelled() or future.exception() is not None: return if self.shell is not None: + assert self.reader is not None and self.writer is not None coro = self.shell(self.reader, self.writer) if asyncio.iscoroutine(coro): loop = asyncio.get_event_loop() loop.create_task(coro) - def data_received(self, data: bytes) -> None: # pylint: disable=too-complex + def data_received(self, data: bytes) -> None: """ Process bytes received by transport. - This may seem strange; feeding all bytes received to the **writer**, and, only if they test - positive, duplicating to the **reader**. - - The writer receives a copy of all raw bytes because, as an IAC interpreter, it may likely - **write** a responding reply. + Feeds raw bytes through the writer's IAC interpreter, forwarding in-band data to the reader. """ - # pylint: disable=too-many-branches - # This is a "hot path" method, and so it is not broken into "helper functions" to help with - # performance. Uses batched processing: scans for IAC (255) and SLC bytes, batching regular - # data into single feed_data() calls for performance. This can be done, and previously was, - # more simply by processing a "byte at a time", but, this "batch and seek" solution can be - # hundreds of times faster though much more complicated. - # if logger.isEnabledFor(TRACE): logger.log(TRACE, "recv %d bytes\n%s", len(data), hexdump(data, prefix="<< ")) self._last_received = datetime.datetime.now() self._rx_bytes += len(data) - writer = self.writer - reader = self.reader - # Build set of special bytes: IAC + SLC values when simulation enabled - if writer.slc_simulated: - slc_vals = {defn.val[0] for defn in writer.slctab.values() if defn.val != theNULL} - special = frozenset({255} | slc_vals) + if self.writer.slc_simulated: + slc_vals = {defn.val[0] for defn in self.writer.slctab.values() if defn.val != theNULL} + slc_special: frozenset[int] | None = frozenset({255} | slc_vals) else: - special = None # Only IAC is special - - cmd_received = False - n = len(data) - i = 0 - out_start = 0 - feeding_oob = bool(writer.is_oob) - - while i < n: - if not feeding_oob: - # Scan forward to next special byte - if special is None: - # Fast path: only IAC (255) is special - next_special = data.find(255, i) - if next_special == -1: - # No IAC found - batch entire remainder - if n > out_start: - reader.feed_data(data[out_start:]) - break - i = next_special - else: - # SLC bytes also special - while i < n and data[i] not in special: - i += 1 - # Flush non-special bytes - if i > out_start: - reader.feed_data(data[out_start:i]) - if i >= n: - break - - # Process special byte - try: - recv_inband = writer.feed_byte(_ONE_BYTE[data[i]]) - except ValueError as exc: - logger.debug("Invalid telnet byte from %s: %s", self, exc) - except BaseException: # pylint: disable=broad-exception-caught - self._log_exception(logger.warning, *sys.exc_info()) - else: - if recv_inband: - reader.feed_data(data[i : i + 1]) - else: - cmd_received = True - i += 1 - out_start = i - feeding_oob = bool(writer.is_oob) - - # Re-check negotiation on command receipt + slc_special = None + + cmd_received = _process_data_chunk( + data, self.writer, self.reader, slc_special, logger.warning + ) + if not self._waiter_connected.done() and cmd_received: self._check_negotiation_timer() # public properties - @property - def duration(self) -> float: - """Time elapsed since client connected, in seconds as float.""" - return (datetime.datetime.now() - self._when_connected).total_seconds() - - @property - def idle(self) -> float: - """Time elapsed since data last received, in seconds as float.""" - return (datetime.datetime.now() - self._last_received).total_seconds() - @property def rx_bytes(self) -> int: """Total bytes received from client.""" @@ -294,16 +223,6 @@ def tx_bytes(self) -> int: # public protocol methods - def __repr__(self) -> str: - hostport = self.get_extra_info("peername", ["-", "closing"])[:2] - return f"" - - def get_extra_info(self, name: str, default: Any = None) -> Any: - """Get optional server protocol or transport information.""" - if self._transport: - default = self._transport.get_extra_info(name, default) - return self._extra.get(name, default) - def begin_negotiation(self) -> None: """ Begin on-connect negotiation. @@ -335,7 +254,6 @@ def encoding(self, outgoing: bool = False, incoming: bool = False) -> Union[str, The base implementation **always** returns the encoding given to class initializer, or, when unset (None), ``US-ASCII``. """ - # pylint: disable=unused-argument return self.default_encoding or "US-ASCII" def negotiation_should_advance(self) -> bool: @@ -354,7 +272,7 @@ def negotiation_should_advance(self) -> bool: client_will = sum(enabled for _, enabled in self.writer.local_option.items()) return bool(server_do or client_will) - def check_negotiation(self, final: bool = False) -> bool: # pylint: disable=unused-argument + def check_negotiation(self, final: bool = False) -> bool: """ Callback, return whether negotiation is complete. @@ -402,15 +320,4 @@ def _check_negotiation_timer(self) -> None: ) self._tasks.append(self._check_later) - @staticmethod - def _log_exception( - log: Callable[..., Any], - e_type: Optional[Type[BaseException]], - e_value: Optional[BaseException], - e_tb: Optional[types.TracebackType], - ) -> None: - rows_tbk = [line for line in "\n".join(traceback.format_tb(e_tb)).split("\n") if line] - rows_exc = [line.rstrip() for line in traceback.format_exception_only(e_type, e_value)] - - for line in rows_tbk + rows_exc: - log(line) + _log_exception = staticmethod(_log_exception) diff --git a/telnetlib3/server_fingerprinting.py b/telnetlib3/server_fingerprinting.py index 2f81fbc7..783df84f 100644 --- a/telnetlib3/server_fingerprinting.py +++ b/telnetlib3/server_fingerprinting.py @@ -1,10 +1,13 @@ """ Fingerprint shell for telnet server identification. -This module probes remote telnet servers for protocol capabilities, -collects banner data and session information, and saves fingerprint -files. It mirrors :mod:`telnetlib3.fingerprinting` but operates as -a client connecting *to* a server. +This module runs **client-side**: it connects *to* a remote telnet server and +probes it for protocol capabilities, collects banner data and session +information, and saves fingerprint files. Despite the ``server_`` prefix in +the module name, it fingerprints the remote *server*, not the local client. + +It mirrors :mod:`telnetlib3.fingerprinting` (which fingerprints clients from +the server side). """ from __future__ import annotations @@ -28,6 +31,7 @@ # local from . import fingerprinting as _fps +from ._paths import _atomic_json_write from .telopt import ( VAR, MSSP, @@ -50,7 +54,6 @@ QUICK_PROBE_OPTIONS, _hash_fingerprint, _opt_byte_to_name, - _atomic_json_write, _save_fingerprint_name, _save_fingerprint_to_dir, probe_client_capabilities, @@ -59,7 +62,7 @@ __all__ = ("fingerprinting_client_shell", "probe_server_capabilities") # Options where only the client sends WILL (in response to a server's DO). -# A server should never WILL these — they describe client-side properties. +# A server should never WILL these -- they describe client-side properties. # The probe must not send DO for these; their state is already captured # in ``server_requested`` (what the server sent DO for). _CLIENT_ONLY_WILL = frozenset({TTYPE, TSPEED, NAWS, XDISPLOC, NEW_ENVIRON, LFLOW, LINEMODE, SNDLOC}) @@ -81,7 +84,7 @@ rb"|[(\[][yY][nN][)\]]" ) -# Match "color?" prompts — many MUDs ask if the user wants color. +# Match "color?" prompts -- many MUDs ask if the user wants color. _COLOR_RE = re.compile(rb"(?i)color\s*\?") # Match numbered menu items offering UTF-8, e.g. "5) UTF-8", "[3] UTF-8", @@ -114,7 +117,7 @@ # Common on Worldgroup/MajorBBS and other vintage BBS systems. _RETURN_PROMPT_RE = re.compile(rb"(?i)(?:hit|press)\s+(?:return|enter)\s*[:\.]?") -# Match "Press the BACKSPACE key" prompts — standard telnet terminal +# Match "Press the BACKSPACE key" prompts -- standard telnet terminal # detection (e.g. TelnetBible.com). Respond with ASCII BS (0x08). _BACKSPACE_KEY_RE = re.compile(rb"(?i)press\s+the\s+backspace\s+key") @@ -185,9 +188,6 @@ 42: "cp437", } -#: Encodings that require ``force_binary`` for high-bit bytes. -_SYNCTERM_BINARY_ENCODINGS = frozenset({"petscii", "atascii"}) - log = logging.getLogger(__name__) @@ -212,7 +212,7 @@ def detect_syncterm_font(data: bytes) -> str | None: #: Encodings where standard telnet CR+LF must be re-encoded to the #: codec's native EOL byte. The codec's ``encode()`` handles the -#: actual CR → LF normalization; we just gate the re-encoding step. +#: actual CR -> LF normalization; we just gate the re-encoding step. _RETRO_EOL_ENCODINGS = frozenset({"atascii", "atari8bit", "atari_8bit"}) @@ -249,7 +249,7 @@ class _VirtualCursor: the scanner produces CPR responses that satisfy the width check. When *encoding* is set to a single-byte encoding like ``cp437``, raw - bytes are decoded with that encoding before measuring — this gives + bytes are decoded with that encoding before measuring -- this gives correct column widths for servers that use SyncTERM font switching where the raw bytes are not valid UTF-8. """ @@ -277,7 +277,6 @@ def advance(self, data: bytes) -> None: stripped = _ANSI_STRIP_RE.sub(b"", data) try: text = stripped.decode(self.encoding, errors="replace") - # pylint: disable-next=broad-exception-caught,overlapping-except except (LookupError, Exception): text = stripped.decode("latin-1") for ch in text: @@ -297,7 +296,6 @@ def advance(self, data: bytes) -> None: def _is_display_worthy(v: Any) -> bool: """Return True if *v* should be kept in culled display output.""" - # pylint: disable-next=use-implicit-booleaness-not-comparison-to-string return v is not False and v != {} and v != [] and v != "" and v != b"" @@ -334,7 +332,7 @@ class _PromptResult(NamedTuple): encoding: str | None = None -def _detect_yn_prompt(banner: bytes) -> _PromptResult: # pylint: disable=too-many-return-statements +def _detect_yn_prompt(banner: bytes) -> _PromptResult: r""" Return an appropriate first-prompt response based on banner content. @@ -342,7 +340,7 @@ def _detect_yn_prompt(banner: bytes) -> _PromptResult: # pylint: disable=too-ma embedded color/cursor codes do not interfere with detection. Returns a :class:`_PromptResult` whose *response* is ``None`` when - no recognizable prompt is found — the caller should fall back to + no recognizable prompt is found -- the caller should fall back to sending a bare ``\r\n``. When a UTF-8 charset menu is selected, *encoding* is set to ``"utf-8"`` so the caller can update the session encoding. @@ -426,7 +424,7 @@ async def fingerprinting_client_shell( :param banner_max_bytes: Maximum bytes per banner read call. """ writer.environ_encoding = environ_encoding - writer._encoding_explicit = environ_encoding != "ascii" # pylint: disable=protected-access + writer._encoding_explicit = environ_encoding != "ascii" try: await _fingerprint_session( reader, @@ -447,7 +445,7 @@ async def fingerprinting_client_shell( writer.close() -async def _fingerprint_session( # noqa: E501 ; pylint: disable=too-many-locals,too-many-branches,too-many-statements,too-complex +async def _fingerprint_session( reader: TelnetReader, writer: TelnetWriter, *, @@ -466,7 +464,7 @@ async def _fingerprint_session( # noqa: E501 ; pylint: disable=too-many-locals, start_time = time.time() cursor = _VirtualCursor(encoding=writer.environ_encoding) - # 1. Let straggler negotiation settle — read (and respond to DSR) + # 1. Let straggler negotiation settle -- read (and respond to DSR) # instead of sleeping blind so early DSR requests get a CPR reply. settle_data = await _read_banner_until_quiet( reader, @@ -477,7 +475,7 @@ async def _fingerprint_session( # noqa: E501 ; pylint: disable=too-many-locals, cursor=cursor, ) - # 2. Read banner (pre-return) — wait until output stops + # 2. Read banner (pre-return) -- wait until output stops banner_before_raw = await _read_banner_until_quiet( reader, quiet_time=banner_quiet_time, @@ -488,7 +486,7 @@ async def _fingerprint_session( # noqa: E501 ; pylint: disable=too-many-locals, ) banner_before = settle_data + banner_before_raw - # 3. Respond to prompts — some servers ask multiple questions in + # 3. Respond to prompts -- some servers ask multiple questions in # sequence (e.g. "color?" then a UTF-8 charset menu). Loop up to # _MAX_PROMPT_REPLIES times, stopping early when no prompt is detected # or the connection is lost. @@ -500,9 +498,12 @@ async def _fingerprint_session( # noqa: E501 ; pylint: disable=too-many-locals, # Skip if the ESC response was already sent inline during banner # collection (time-sensitive botcheck countdowns). if detected in (b"\x1b\x1b", b"\x1b") and getattr(writer, "_esc_inline", False): - # pylint: disable-next=protected-access writer._esc_inline = False # type: ignore[attr-defined] detected = None + # Skip if the charset menu response was already sent inline. + if prompt_result.encoding and getattr(writer, "_menu_inline", False): + writer._menu_inline = False # type: ignore[attr-defined] + detected = None prompt_response = _reencode_prompt( detected if detected is not None else b"\r\n", writer.environ_encoding ) @@ -529,7 +530,7 @@ async def _fingerprint_session( # noqa: E501 ; pylint: disable=too-many-locals, after_chunks.append(latest_banner) if writer.is_closing() or not latest_banner: break - # Stop when the server repeats the same banner — it is not + # Stop when the server repeats the same banner -- it is not # advancing through prompts, just re-displaying the login screen. if latest_banner == previous_banner: break @@ -571,16 +572,7 @@ async def _fingerprint_session( # noqa: E501 ; pylint: disable=too-many-locals, ) if writer.mssp_data is not None: session_data["mssp"] = writer.mssp_data - if writer.zmp_data: - session_data["zmp"] = writer.zmp_data - if writer.atcp_data: - session_data["atcp"] = [{"package": pkg, "value": val} for pkg, val in writer.atcp_data] - if writer.aardwolf_data: - session_data["aardwolf"] = writer.aardwolf_data - if writer.mxp_data: - session_data["mxp"] = [d.hex() if d else "activated" for d in writer.mxp_data] - if writer.comport_data: - session_data["comport"] = writer.comport_data + session_data.update(_collect_mud_data(writer)) session_entry: dict[str, Any] = { "host": host, @@ -589,7 +581,11 @@ async def _fingerprint_session( # noqa: E501 ; pylint: disable=too-many-locals, "connected": datetime.datetime.now(datetime.timezone.utc).isoformat(), } - # 7. Save + # 7. Compute fingerprint once for save/name/display + protocol_fp = _create_server_protocol_fingerprint(writer, probe_results, scan_type=scan_type) + protocol_hash = _hash_fingerprint(protocol_fp) + + # 8. Save _save_server_fingerprint_data( writer=writer, probe_results=probe_results, @@ -597,26 +593,20 @@ async def _fingerprint_session( # noqa: E501 ; pylint: disable=too-many-locals, session_entry=session_entry, save_path=save_path, scan_type=scan_type, + protocol_fp=protocol_fp, + protocol_hash=protocol_hash, ) - # 8. Set name in fingerprint_names.json + # 9. Set name in fingerprint_names.json if set_name is not None: - protocol_fp = _create_server_protocol_fingerprint( - writer, probe_results, scan_type=scan_type - ) - protocol_hash = _hash_fingerprint(protocol_fp) try: _save_fingerprint_name(protocol_hash, set_name) logger.info("set name %r for %s", set_name, protocol_hash) except ValueError: logger.warning("--set-name requires --data-dir or $TELNETLIB3_DATA_DIR") - # 9. Display + # 10. Display if not silent: - protocol_fp = _create_server_protocol_fingerprint( - writer, probe_results, scan_type=scan_type - ) - protocol_hash = _hash_fingerprint(protocol_fp) _print_json( { "server-probe": { @@ -774,6 +764,22 @@ def _create_server_protocol_fingerprint( } +def _collect_mud_data(writer: TelnetWriter) -> dict[str, Any]: + """Collect MUD protocol data from *writer* into a dict.""" + result: dict[str, Any] = {} + if writer.zmp_data: + result["zmp"] = writer.zmp_data + if writer.atcp_data: + result["atcp"] = [{"package": pkg, "value": val} for pkg, val in writer.atcp_data] + if writer.aardwolf_data: + result["aardwolf"] = writer.aardwolf_data + if writer.mxp_data: + result["mxp"] = [d.hex() if d else "activated" for d in writer.mxp_data] + if writer.comport_data: + result["comport"] = writer.comport_data + return result + + def _save_server_fingerprint_data( writer: TelnetWriter, probe_results: dict[str, _fps.ProbeResult], @@ -782,6 +788,8 @@ def _save_server_fingerprint_data( *, save_path: str | None = None, scan_type: str = "quick", + protocol_fp: dict[str, Any] | None = None, + protocol_hash: str | None = None, ) -> str | None: """ Save server fingerprint data to a JSON file. @@ -790,16 +798,23 @@ def _save_server_fingerprint_data( :param writer: :class:`~telnetlib3.stream_writer.TelnetWriter` instance. :param probe_results: Results from :func:`probe_server_capabilities`. - :param session_data: Pre-built dict with ``option_states``, ``banner_before_return``, - ``banner_after_return``, and ``timing`` keys. - :param session_entry: Pre-built dict with ``host``, ``ip``, ``port``, - and ``connected`` keys. + :param session_data: Pre-built dict with ``option_states``, + ``banner_before_return``, ``banner_after_return``, and + ``timing`` keys. + :param session_entry: Pre-built dict with ``host``, ``ip``, + ``port``, and ``connected`` keys. :param save_path: If set, write directly to this path. :param scan_type: ``"quick"`` or ``"full"`` probe depth used. + :param protocol_fp: Pre-computed protocol fingerprint dict. + :param protocol_hash: Pre-computed fingerprint hash string. :returns: Path to saved file, or ``None`` if saving was skipped. """ - protocol_fp = _create_server_protocol_fingerprint(writer, probe_results, scan_type=scan_type) - protocol_hash = _hash_fingerprint(protocol_fp) + if protocol_fp is None: + protocol_fp = _create_server_protocol_fingerprint( + writer, probe_results, scan_type=scan_type + ) + if protocol_hash is None: + protocol_hash = _hash_fingerprint(protocol_fp) data: dict[str, Any] = { "server-probe": { @@ -853,7 +868,7 @@ def _format_banner(data: bytes, encoding: str = "utf-8") -> str: regardless of ``environ_encoding``; callers may override. Uses ``surrogateescape`` so high bytes (common in CP437 BBS art) - are preserved as surrogates (e.g. byte ``0xB1`` → ``U+DCB1``) + are preserved as surrogates (e.g. byte ``0xB1`` -> ``U+DCB1``) rather than replaced with ``U+FFFD``. JSON serialization escapes them as ``\udcXX``, which round-trips through :func:`json.load`. @@ -874,7 +889,7 @@ def _format_banner(data: bytes, encoding: str = "utf-8") -> str: text = data.decode("latin-1") if encoding.lower() in ("petscii", "cbm", "commodore", "c64", "c128"): - from .color_filter import PetsciiColorFilter # pylint: disable=import-outside-toplevel + from .color_filter import PetsciiColorFilter text = PetsciiColorFilter().filter(text) # PETSCII uses CR (0x0D) as line terminator; normalize to LF. @@ -935,7 +950,7 @@ def _respond_to_dsr(chunk: bytes, writer: TelnetWriter, cursor: _VirtualCursor | cursor.advance(chunk[pos:]) -async def _read_banner_until_quiet( # noqa: E501 ; pylint: disable=too-many-positional-arguments,too-complex,too-many-nested-blocks +async def _read_banner_until_quiet( reader: TelnetReader, quiet_time: float = 2.0, max_wait: float = 8.0, @@ -960,9 +975,9 @@ async def _read_banner_until_quiet( # noqa: E501 ; pylint: disable=too-many-pos printable character). This defeats robot-check guards that verify cursor movement after writing a test character. - Time-sensitive prompts — ``Press [.ESC.] twice`` botcheck countdowns - — are detected inline and responded to immediately so the reply - arrives before the countdown expires. + Time-sensitive prompts -- ``Press [.ESC.] twice`` botcheck countdowns + and charset selection menus -- are detected inline and responded to + immediately so the reply arrives before the server times out. :param reader: :class:`~telnetlib3.stream_reader.TelnetReader` instance. :param quiet_time: Seconds of silence before considering banner complete. @@ -974,7 +989,9 @@ async def _read_banner_until_quiet( # noqa: E501 ; pylint: disable=too-many-pos :returns: Banner bytes (may be empty). """ chunks: list[bytes] = [] + stripped_accum = bytearray() esc_responded = False + menu_responded = False loop = asyncio.get_event_loop() deadline = loop.time() + max_wait while loop.time() < deadline: @@ -1003,22 +1020,37 @@ async def _read_banner_until_quiet( # noqa: E501 ; pylint: disable=too-many-pos if cursor is not None: cursor.encoding = font_enc protocol = writer.protocol - if protocol is not None and font_enc in _SYNCTERM_BINARY_ENCODINGS: + if protocol is not None: protocol.force_binary = True + stripped_chunk = _ANSI_STRIP_RE.sub(b"", chunk) + stripped_accum.extend(stripped_chunk) if not esc_responded: - stripped_chunk = _ANSI_STRIP_RE.sub(b"", chunk) if _ESC_TWICE_RE.search(stripped_chunk): writer.write(b"\x1b\x1b") await writer.drain() esc_responded = True - # pylint: disable-next=protected-access writer._esc_inline = True # type: ignore[attr-defined] elif _ESC_ONCE_RE.search(stripped_chunk): writer.write(b"\x1b") await writer.drain() esc_responded = True - # pylint: disable-next=protected-access writer._esc_inline = True # type: ignore[attr-defined] + if not menu_responded: + menu_match = _MENU_UTF8_RE.search(stripped_accum) + if menu_match: + response = menu_match.group(1) + b"\r\n" + writer.write(_reencode_prompt(response, writer.environ_encoding)) + await writer.drain() + menu_responded = True + log.debug("inline UTF-8 menu response: %r", response) + if not getattr(writer, "_encoding_explicit", False): + writer.environ_encoding = "utf-8" + if cursor is not None: + cursor.encoding = "utf-8" + protocol = writer.protocol + if protocol is not None: + protocol.force_binary = True + writer._menu_inline = True # type: ignore[attr-defined] chunks.append(chunk) except (asyncio.TimeoutError, EOFError): break diff --git a/telnetlib3/server_pty_shell.py b/telnetlib3/server_pty_shell.py index 2aa2f976..8c13276e 100644 --- a/telnetlib3/server_pty_shell.py +++ b/telnetlib3/server_pty_shell.py @@ -104,7 +104,6 @@ def start(self) -> None: :raises PTYSpawnError: If the child process fails to exec. """ - # pylint: disable=import-outside-toplevel import pty import fcntl @@ -130,7 +129,7 @@ def start(self) -> None: if self.preexec_fn is not None: try: child_cov = self.preexec_fn() - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: self._write_exec_error(exec_err_pipe_write, e) os._exit(1) self._setup_child(env, rows, cols, exec_err_pipe_write, child_cov=child_cov) @@ -220,7 +219,6 @@ def _setup_child( ) -> None: """Child process setup before exec.""" # Note: pty.fork() already calls setsid() for the child, so we don't need to - # pylint: disable=import-outside-toplevel import fcntl import termios @@ -260,9 +258,9 @@ def _setup_child( def _setup_parent(self) -> None: """Parent process setup after fork.""" - # pylint: disable=import-outside-toplevel import fcntl + assert self.master_fd is not None flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL) fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) self.writer.set_ext_callback(NAWS, self._on_naws) @@ -290,7 +288,6 @@ def _fire_naws_update(self) -> None: def _set_window_size(self, rows: int, cols: int) -> None: """Set PTY window size and send SIGWINCH to child.""" - # pylint: disable=import-outside-toplevel import fcntl import signal import termios @@ -306,13 +303,14 @@ def _set_window_size(self, rows: int, cols: int) -> None: async def run(self) -> None: """Bridge loop between telnet and PTY.""" - # pylint: disable=import-outside-toplevel import errno loop = asyncio.get_event_loop() pty_read_event = asyncio.Event() pty_data_queue: asyncio.Queue[bytes] = asyncio.Queue() + assert self.child_pid is not None + assert self.master_fd is not None pid, _ = os.waitpid(self.child_pid, os.WNOHANG) if pid: return @@ -396,7 +394,7 @@ async def _bridge_loop( # EAGAIN was hit - flush any remaining partial line self._flush_remaining() pty_read_event.clear() - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: logger.debug("bridge loop error: %s", e) self._closing = True break @@ -523,12 +521,12 @@ def _terminate(self, force: bool = False) -> bool: :param force: If True, use SIGKILL as last resort. :returns: True if child was terminated, False otherwise. """ - # pylint: disable=import-outside-toplevel import signal if not self._isalive(): return True + assert self.child_pid is not None signals = [signal.SIGHUP, signal.SIGCONT, signal.SIGINT] if force: signals.append(signal.SIGKILL) @@ -596,7 +594,7 @@ async def _wait_for_terminal_info( await asyncio.sleep(_TERMINAL_INFO_POLL) -async def pty_shell( # pylint: disable=too-many-positional-arguments +async def pty_shell( reader: Union[TelnetReader, TelnetReaderUnicode], writer: Union[TelnetWriter, TelnetWriterUnicode], program: str, @@ -621,7 +619,7 @@ async def pty_shell( # pylint: disable=too-many-positional-arguments # Echo handling depends on raw_mode: # - Normal mode: Send WONT ECHO so client does local echo, PTY handles - # echo with proper ONLCR translation (\n → \r\n) for input() display. + # echo with proper ONLCR translation (\n -> \r\n) for input() display. # - Raw mode: Keep WILL ECHO so client doesn't local-echo, but PTY echo # is disabled. This prevents terminal responses (CPR, etc.) from being # echoed back. The program handles its own output. diff --git a/telnetlib3/server_shell.py b/telnetlib3/server_shell.py index 42cc10cd..85565f2c 100644 --- a/telnetlib3/server_shell.py +++ b/telnetlib3/server_shell.py @@ -69,7 +69,7 @@ async def filter_ansi(reader: TelnetReaderUnicode, _writer: TelnetWriterUnicode) buf += seq_char match = _ZERO_WIDTH_PATTERN.match(buf) # Skip spurious 2-byte Fe matches on the - # ESC+starter prefix — the real sequence is + # ESC+starter prefix -- the real sequence is # longer (CSI 3+, charset 3, OSC/DCS/APC/PM 4+) if match and match.end() > 2: if match.end() < len(buf): @@ -99,7 +99,7 @@ def _visible_width(text: str) -> int: return max(0, result) -class _LineEditor: # pylint: disable=too-few-public-methods +class _LineEditor: """Shared line-editing state machine for readline and readline_async.""" def __init__(self, max_visible_width: int = 0) -> None: @@ -140,7 +140,7 @@ def feed(self, char: str) -> tuple[str, Optional[str]]: __all__ = ("telnet_server_shell", "readline_async", "readline") -async def telnet_server_shell( # pylint: disable=too-complex,too-many-branches,too-many-statements +async def telnet_server_shell( reader: Union[TelnetReader, TelnetReaderUnicode], writer: Union[TelnetWriter, TelnetWriterUnicode], ) -> None: diff --git a/telnetlib3/slc.py b/telnetlib3/slc.py index 68fc65a4..54c74960 100644 --- a/telnetlib3/slc.py +++ b/telnetlib3/slc.py @@ -178,7 +178,7 @@ def __str__(self) -> str: return f"({value_str}, {'|'.join(flags)})" -class SLC_nosupport(SLC): # pylint: disable=invalid-name +class SLC_nosupport(SLC): """SLC definition inferring our unwillingness to support the option.""" def __init__(self) -> None: diff --git a/telnetlib3/stream_reader.py b/telnetlib3/stream_reader.py index 81f3c834..849cec36 100644 --- a/telnetlib3/stream_reader.py +++ b/telnetlib3/stream_reader.py @@ -98,7 +98,7 @@ def set_transport(self, transport: asyncio.BaseTransport) -> None: def _maybe_resume_transport(self) -> None: if self._paused and len(self._buffer) <= self._limit: self._paused = False - self._transport.resume_reading() # type: ignore[attr-defined] + self._transport.resume_reading() def feed_eof(self) -> None: """ @@ -161,7 +161,7 @@ async def _wait_for_data(self, func_name: str) -> None: # This is essential for readexactly(n) for case when n > self._limit. if self._paused: self._paused = False - self._transport.resume_reading() # type: ignore[attr-defined] + self._transport.resume_reading() self._waiter = asyncio.get_running_loop().create_future() try: diff --git a/telnetlib3/stream_writer.py b/telnetlib3/stream_writer.py index 684758ac..f85072ad 100644 --- a/telnetlib3/stream_writer.py +++ b/telnetlib3/stream_writer.py @@ -9,10 +9,6 @@ import collections from typing import TYPE_CHECKING, Any, Dict, Callable, Optional, Sequence -# pylint: disable=too-many-lines -# pylint: disable=duplicate-code - - if TYPE_CHECKING: # pragma: no cover from .stream_reader import TelnetReader @@ -100,6 +96,7 @@ option_from_name, ) from .accessories import TRACE, hexdump +from ._session_context import TelnetSessionContext __all__ = ("TelnetWriter", "TelnetWriterUnicode") @@ -235,11 +232,20 @@ def __init__( #: DONT rejection in :meth:`handle_will`. self.always_do: set[bytes] = set() + #: Set of option byte(s) for which the client sends DO only + #: in response to a server WILL (passive negotiation). + self.passive_do: set[bytes] = set() + #: Whether the encoding was explicitly set (not just the default #: ``"ascii"``). Used by fingerprinting and client connection logic #: to decide whether to negotiate CHARSET. self._encoding_explicit: bool = False + #: Per-connection session context. Applications may replace this + #: with a subclass of :class:`~telnetlib3._session_context.TelnetSessionContext` to carry + #: additional state (e.g. MUD client macros, room graphs). + self.ctx: TelnetSessionContext = TelnetSessionContext() + #: Set of option byte(s) for WILL received from remote end #: that were rejected with DONT (unhandled options). self.rejected_will: set[bytes] = set() @@ -394,7 +400,7 @@ def close(self) -> None: if self._protocol is not None: try: self._protocol.connection_lost(None) - except Exception: # pylint: disable=broad-exception-caught + except Exception: pass if self._transport is not None: self._transport.close() @@ -626,11 +632,10 @@ async def drain(self) -> None: # would not see an error when the socket is closed. await asyncio.sleep(0) if self._protocol is not None: - await self._protocol._drain_helper() # pylint: disable=protected-access + await self._protocol._drain_helper() # proprietary write helper - # pylint: disable=too-many-branches,too-many-statements,too-complex def feed_byte(self, byte: bytes) -> bool: """ Feed a single byte into Telnet option state machine. @@ -709,7 +714,7 @@ def feed_byte(self, byte: bytes) -> bool: elif self.cmd_received: # parse 3rd and final byte of IAC DO, DONT, WILL, WONT. - cmd, opt = self.cmd_received, byte + cmd, opt = self.cmd_received, byte # type: ignore[assignment] self.log.debug("recv IAC %s %s", name_command(cmd), name_option(opt)) try: if cmd == DO: @@ -748,7 +753,7 @@ def feed_byte(self, byte: bytes) -> bool: finally: # toggle iac_received on any ValueErrors/AssertionErrors raised self.iac_received = False - self.cmd_received = (opt, byte) # pylint: disable=redefined-variable-type + self.cmd_received = (opt, byte) elif self.mode == "remote" or self.mode == "kludge" and self.slc_simulated: # 'byte' is tested for SLC characters @@ -760,7 +765,7 @@ def feed_byte(self, byte: bytes) -> bool: self.log.debug( "slc.snoop(%r): %s, callback is %s.", byte, - slc.name_slc_command(slc_name), + slc.name_slc_command(slc_name), # type: ignore[arg-type] callback.__name__, ) callback(slc_name) @@ -789,6 +794,16 @@ def protocol(self) -> Any: """The (Telnet) protocol attached to this stream.""" return self._protocol + def _force_binary_on_protocol(self) -> None: + """ + Enable ``force_binary`` on the attached protocol. + + Called when CHARSET is negotiated or LANG is received via NEW_ENVIRON, implying that the + peer can handle non-ASCII bytes regardless of whether BINARY mode was explicitly negotiated. + """ + if self._protocol is not None and hasattr(self._protocol, "force_binary"): + self._protocol.force_binary = True + @property def server(self) -> bool: """Whether this stream is of the server's point of view.""" @@ -1292,15 +1307,15 @@ def set_iac_callback(self, cmd: bytes, func: Callable[..., Any]) -> None: """ self._iac_callback[cmd] = func - def handle_nop(self, cmd: bytes) -> None: # pylint:disable=unused-argument + def handle_nop(self, cmd: bytes) -> None: """Handle IAC No-Operation (NOP).""" self.log.debug("IAC NOP: Null Operation (unhandled).") - def handle_ga(self, cmd: bytes) -> None: # pylint:disable=unused-argument + def handle_ga(self, cmd: bytes) -> None: """Handle IAC Go-Ahead (GA).""" self.log.debug("IAC GA: Go-Ahead (unhandled).") - def handle_dm(self, cmd: bytes) -> None: # pylint:disable=unused-argument + def handle_dm(self, cmd: bytes) -> None: """Handle IAC Data-Mark (DM).""" self.log.debug("IAC DM: Data-Mark (unhandled).") @@ -1826,7 +1841,6 @@ def handle_dont(self, opt: bytes) -> None: # Correctly, a DONT can not be declined, so there is no need to # affirm in the negative. - # pylint: disable=too-many-branches,too-complex def handle_will(self, opt: bytes) -> None: """ Process byte 3 of series (IAC, WILL, opt) received by remote end. @@ -1882,7 +1896,7 @@ def handle_will(self, opt: bytes) -> None: return # Client declines MUD protocols unless explicitly opted in. if self.client and opt in _MUD_PROTOCOL_OPTIONS: - if opt in self.always_do: + if opt in self.always_do or opt in self.passive_do: if not self.remote_option.enabled(opt): self.iac(DO, opt) self.remote_option[opt] = True @@ -2111,10 +2125,12 @@ def _handle_sb_charset(self, buf: collections.deque[bytes]) -> None: self.log.debug("send IAC SB CHARSET ACCEPTED %s IAC SE", selected) self.send_iac(b"".join(response)) self.environ_encoding = selected + self._force_binary_on_protocol() elif opt == ACCEPTED: charset = b"".join(buf).decode("ascii") self.log.debug("recv IAC SB CHARSET ACCEPTED %s IAC SE", charset) self.environ_encoding = charset + self._force_binary_on_protocol() self._ext_callback[CHARSET](charset) elif opt == REJECTED: self.log.warning("recv IAC SB CHARSET REJECTED IAC SE") @@ -3027,9 +3043,7 @@ def encode(self, string: str, errors: Optional[str] = None) -> bytes: encoding = self.fn_encoding(outgoing=True) return bytes(string, encoding, errors or self.encoding_errors) - def write( # type: ignore[override] # pylint: disable=arguments-renamed - self, string: str, errors: Optional[str] = None - ) -> None: + def write(self, string: str, errors: Optional[str] = None) -> None: # type: ignore[override] """ Write unicode string to transport, using protocol-preferred encoding. @@ -3059,9 +3073,7 @@ def writelines( # type: ignore[override] """ self.write(string="".join(lines), errors=errors) - def echo( # type: ignore[override] # pylint: disable=arguments-renamed - self, string: str, errors: Optional[str] = None - ) -> None: + def echo(self, string: str, errors: Optional[str] = None) -> None: # type: ignore[override] """ Conditionally write ``string`` to transport when "remote echo" enabled. diff --git a/telnetlib3/sync.py b/telnetlib3/sync.py index 8d9f45f3..a29f99e0 100644 --- a/telnetlib3/sync.py +++ b/telnetlib3/sync.py @@ -122,17 +122,18 @@ def connect(self) -> None: def _run_loop(self) -> None: """Run event loop in background thread.""" + assert self._loop is not None asyncio.set_event_loop(self._loop) self._loop.run_forever() async def _async_connect(self) -> None: """Async connection coroutine.""" kwargs = dict(self._kwargs) - # Default to TelnetClient (not TelnetTerminalClient) — the blocking API + # Default to TelnetClient (not TelnetTerminalClient) -- the blocking API # is programmatic, not a terminal app, so it should use the cols/rows # parameters rather than reading the real terminal size. if "client_factory" not in kwargs: - from .client import TelnetClient # pylint: disable=import-outside-toplevel + from .client import TelnetClient kwargs["client_factory"] = TelnetClient self._reader, self._writer = await _open_connection( @@ -160,10 +161,12 @@ def read(self, n: int = -1, timeout: Optional[float] = None) -> Union[str, bytes :raises EOFError: If connection closed. """ self._ensure_connected() + assert self._reader is not None + assert self._loop is not None timeout = timeout if timeout is not None else self._timeout future = asyncio.run_coroutine_threadsafe(self._reader.read(n), self._loop) try: - result = future.result(timeout=timeout) + result: Union[str, bytes] = future.result(timeout=timeout) if not result: raise EOFError("Connection closed") return result @@ -173,14 +176,15 @@ def read(self, n: int = -1, timeout: Optional[float] = None) -> Union[str, bytes def read_some(self, timeout: Optional[float] = None) -> Union[str, bytes]: """ - Read some data from the connection. + Read some available data from the connection. - Alias for :meth:`read` for compatibility with old telnetlib. + Unlike :meth:`read` with ``n=-1``, this returns as soon as any data is + available rather than waiting for EOF. - :param timeout: Timeout in seconds. + :param timeout: Timeout in seconds (uses default if None). :returns: Data read from connection. """ - return self.read(-1, timeout=timeout) + return self.read(self._reader._limit, timeout=timeout) def readline(self, timeout: Optional[float] = None) -> Union[str, bytes]: """ @@ -194,10 +198,12 @@ def readline(self, timeout: Optional[float] = None) -> Union[str, bytes]: :raises EOFError: If connection closed before line complete. """ self._ensure_connected() + assert self._reader is not None + assert self._loop is not None timeout = timeout if timeout is not None else self._timeout future = asyncio.run_coroutine_threadsafe(self._reader.readline(), self._loop) try: - result = future.result(timeout=timeout) + result: Union[str, bytes] = future.result(timeout=timeout) if not result: raise EOFError("Connection closed") return result @@ -220,13 +226,16 @@ def read_until( :raises EOFError: If connection closed before match found. """ self._ensure_connected() + assert self._reader is not None + assert self._loop is not None timeout = timeout if timeout is not None else self._timeout # readuntil expects bytes, encode if string if isinstance(match, str): match = match.encode(self._encoding or "utf-8") future = asyncio.run_coroutine_threadsafe(self._reader.readuntil(match), self._loop) try: - return future.result(timeout=timeout) + result: Union[str, bytes] = future.result(timeout=timeout) + return result except concurrent.futures.TimeoutError as exc: future.cancel() raise TimeoutError("Read until timed out") from exc @@ -243,6 +252,8 @@ def write(self, data: Union[str, bytes]) -> None: :param data: String or bytes to write. """ self._ensure_connected() + assert self._writer is not None + assert self._loop is not None # writer may be TelnetWriter (bytes) or TelnetWriterUnicode (str) self._loop.call_soon_threadsafe(self._writer.write, data) # type: ignore[arg-type] @@ -256,6 +267,8 @@ def flush(self, timeout: Optional[float] = None) -> None: :raises TimeoutError: If timeout expires. """ self._ensure_connected() + assert self._writer is not None + assert self._loop is not None timeout = timeout if timeout is not None else self._timeout coro = self._writer.drain() try: @@ -283,7 +296,7 @@ def _cleanup(self) -> None: future = asyncio.run_coroutine_threadsafe(self._async_cleanup(), self._loop) try: future.result(timeout=2.0) - except Exception: # pylint: disable=broad-exception-caught + except Exception: pass # Cleanup should not raise if self._loop and self._loop.is_running(): self._loop.call_soon_threadsafe(self._loop.stop) @@ -299,7 +312,7 @@ async def _async_cleanup(self) -> None: self._writer.close() try: await self._writer.wait_closed() - except Exception: # pylint: disable=broad-exception-caught + except Exception: pass # Cleanup should not raise def get_extra_info(self, name: str, default: Any = None) -> Any: @@ -319,6 +332,7 @@ def get_extra_info(self, name: str, default: Any = None) -> Any: :returns: Information value or default. """ self._ensure_connected() + assert self._writer is not None return self._writer.get_extra_info(name, default) def wait_for( @@ -359,6 +373,8 @@ def wait_for( print(f"Terminal: {term} ({cols}x{rows})") """ self._ensure_connected() + assert self._writer is not None + assert self._loop is not None timeout = timeout if timeout is not None else self._timeout future = asyncio.run_coroutine_threadsafe( self._writer.wait_for(remote=remote, local=local, pending=pending), self._loop @@ -384,6 +400,7 @@ def writer(self) -> TelnetWriter: :returns: The underlying TelnetWriter instance. """ self._ensure_connected() + assert self._writer is not None return self._writer def __enter__(self) -> "TelnetConnection": @@ -466,6 +483,7 @@ def start(self) -> None: def _run_loop(self) -> None: """Run event loop in background thread.""" + assert self._loop is not None asyncio.set_event_loop(self._loop) self._loop.run_until_complete(self._start_server()) self._started.set() @@ -473,6 +491,7 @@ def _run_loop(self) -> None: async def _start_server(self) -> None: """Start the async server.""" + assert self._loop is not None loop = self._loop # Capture for closure async def shell(reader: TelnetReader, writer: TelnetWriter) -> None: @@ -480,7 +499,6 @@ async def shell(reader: TelnetReader, writer: TelnetWriter) -> None: conn = ServerConnection(reader, writer, loop) self._client_queue.put(conn) # Wait until the sync handler closes the connection - # pylint: disable=protected-access await conn._wait_closed() self._server = await _create_server(self._host, self._port, shell=shell, **self._kwargs) @@ -529,10 +547,11 @@ def serve_forever(self) -> None: def _handle_client(self, conn: "ServerConnection") -> None: """Handle a client in the handler function.""" + assert self._handler is not None try: self._handler(conn) finally: - if not conn._closed: # pylint: disable=protected-access + if not conn._closed: conn.close() def shutdown(self) -> None: @@ -547,7 +566,7 @@ def shutdown(self) -> None: future = asyncio.run_coroutine_threadsafe(self._async_shutdown(), self._loop) try: future.result(timeout=2.0) - except Exception: # pylint: disable=broad-exception-caught + except Exception: pass # Cleanup should not raise if self._loop and self._loop.is_running(): self._loop.call_soon_threadsafe(self._loop.stop) @@ -563,7 +582,7 @@ async def _async_shutdown(self) -> None: self._server.close() try: await self._server.wait_closed() - except Exception: # pylint: disable=broad-exception-caught + except Exception: pass # Cleanup should not raise # Cancel all pending tasks to avoid "Task was destroyed but pending" warnings for task in asyncio.all_tasks(self._loop): @@ -631,14 +650,15 @@ def read(self, n: int = -1, timeout: Optional[float] = None) -> Union[str, bytes def read_some(self, timeout: Optional[float] = None) -> Union[str, bytes]: """ - Read some data from the connection. + Read some available data from the connection. - Alias for :meth:`read` for compatibility with old telnetlib. + Unlike :meth:`read` with ``n=-1``, this returns as soon as any data is + available rather than waiting for EOF. :param timeout: Timeout in seconds. :returns: Data read from connection. """ - return self.read(-1, timeout=timeout) + return self.read(self._reader._limit, timeout=timeout) def readline(self, timeout: Optional[float] = None) -> Union[str, bytes]: """ diff --git a/telnetlib3/telnetlib.py b/telnetlib3/telnetlib.py index e27c1741..4a370a67 100644 --- a/telnetlib3/telnetlib.py +++ b/telnetlib3/telnetlib.py @@ -135,7 +135,7 @@ DO = bytes([253]) WONT = bytes([252]) WILL = bytes([251]) -theNULL = bytes([0]) # pylint: disable=invalid-name +theNULL = bytes([0]) SE = bytes([240]) # Subnegotiation End NOP = bytes([241]) # No Operation @@ -300,9 +300,7 @@ def __init__(self, host=None, port=0, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): if host is not None: self.open(host, port, timeout) - def open( - self, host, port=0, timeout=socket._GLOBAL_DEFAULT_TIMEOUT - ): # pylint: disable=protected-access + def open(self, host, port=0, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): """ Connect to a host. @@ -501,7 +499,7 @@ def set_option_negotiation_callback(self, callback): """Provide a callback function called after each receipt of a telnet option.""" self.option_callback = callback - def process_rawq(self): # pylint: disable=too-complex,too-many-branches + def process_rawq(self): """ Transfer from raw queue to cooked queue. @@ -657,7 +655,7 @@ def listener(self): else: sys.stdout.flush() - def expect(self, list, timeout=None): # pylint: disable=redefined-builtin + def expect(self, list, timeout=None): """ Read until one from a list of a regular expressions matches. @@ -687,7 +685,7 @@ def expect(self, list, timeout=None): # pylint: disable=redefined-builtin for i in indices: if not hasattr(list[i], "search"): if not re: - import re # pylint: disable=import-outside-toplevel + import re list[i] = re.compile(list[i]) if timeout is not None: deadline = _time() + timeout @@ -718,7 +716,7 @@ def expect(self, list, timeout=None): # pylint: disable=redefined-builtin def __enter__(self): return self - def __exit__(self, type, value, traceback): # pylint: disable=redefined-builtin + def __exit__(self, type, value, traceback): self.close() diff --git a/telnetlib3/telopt.py b/telnetlib3/telopt.py index 538d1d2d..30d2aafc 100644 --- a/telnetlib3/telopt.py +++ b/telnetlib3/telopt.py @@ -37,7 +37,7 @@ LOGOUT = b"\x12" CHARSET = b"*" SNDLOC = b"\x17" -theNULL = b"\x00" # pylint: disable=invalid-name +theNULL = b"\x00" ENCRYPT = b"&" AUTHENTICATION = b"%" TN3270E = b"(" diff --git a/telnetlib3/tests/accessories.py b/telnetlib3/tests/accessories.py index 606a339b..f4f8cfe3 100644 --- a/telnetlib3/tests/accessories.py +++ b/telnetlib3/tests/accessories.py @@ -1,5 +1,7 @@ """Test accessories for telnetlib3 project.""" +from __future__ import annotations + # std imports import os import asyncio @@ -158,7 +160,66 @@ class TrackingProtocol(_TrackingProtocol, protocol_factory): await server.wait_closed() +class MockTransport: + """Mock transport for unit tests that records written bytes.""" + + def __init__(self) -> None: + """Initialize mock transport with empty write buffer.""" + self._closing = False + self.writes: list[bytes] = [] + self.extra: dict[str, object] = {} + + def write(self, data: bytes) -> None: + """Record *data* to the write buffer.""" + self.writes.append(bytes(data)) + + def is_closing(self) -> bool: + """Return whether :meth:`close` has been called.""" + return self._closing + + def get_extra_info(self, name: str, default: object = None) -> object: + """Return extra info by *name*, or *default*.""" + return self.extra.get(name, default) + + def get_write_buffer_size(self) -> int: + """Return 0 (no buffering).""" + return 0 + + def pause_reading(self) -> None: + """No-op.""" + + def resume_reading(self) -> None: + """No-op.""" + + def close(self) -> None: + """Mark transport as closing.""" + self._closing = True + + +class MockProtocol: + """Mock protocol for unit tests with drain helper and extra info.""" + + def __init__(self, info: dict[str, object] | None = None) -> None: + """Initialize mock protocol with optional extra *info*.""" + self.info: dict[str, object] = info or {} + self.drain_called = False + self.conn_lost_called = False + + def get_extra_info(self, name: str, default: object = None) -> object: + """Return extra info by *name*, or *default*.""" + return self.info.get(name, default) + + async def _drain_helper(self) -> None: + self.drain_called = True + + def connection_lost(self, exc: BaseException | None) -> None: + """Record that connection_lost was called.""" + self.conn_lost_called = True + + __all__ = ( + "MockProtocol", + "MockTransport", "asyncio_connection", "asyncio_server", "bind_host", diff --git a/telnetlib3/tests/conftest.py b/telnetlib3/tests/conftest.py index ec8dc728..5e05f83d 100644 --- a/telnetlib3/tests/conftest.py +++ b/telnetlib3/tests/conftest.py @@ -1,7 +1,38 @@ """Pytest configuration and fixtures.""" +# std imports +import os +import asyncio + # 3rd party import pytest +from pytest_asyncio.plugin import unused_tcp_port # noqa: F401 + +try: + import xdist # noqa: F401 + + def pytest_xdist_auto_num_workers(config): + """Return 2 in CI, otherwise max(6, ncpu // 2).""" + if os.environ.get("CI"): + return 2 + return max(6, os.cpu_count() // 2) + +except ImportError: + pass + + +@pytest.fixture(scope="module", params=["127.0.0.1"]) +def bind_host(request): + """Localhost bind address.""" + return request.param + + +@pytest.fixture +def fast_sleep(monkeypatch): + """Replace ``asyncio.sleep`` with a zero-delay yield to the event loop.""" + _real_sleep = asyncio.sleep + monkeypatch.setattr(asyncio, "sleep", lambda _: _real_sleep(0)) + try: from pytest_codspeed import BenchmarkFixture # noqa: F401 pylint:disable=unused-import diff --git a/telnetlib3/tests/test_accessories.py b/telnetlib3/tests/test_accessories.py index 645db55c..e25ec79b 100644 --- a/telnetlib3/tests/test_accessories.py +++ b/telnetlib3/tests/test_accessories.py @@ -1,57 +1,62 @@ +# 3rd party +import pytest + # local from telnetlib3.accessories import eightbits, name_unicode, encoding_from_lang -def test_name_unicode(): +@pytest.mark.parametrize( + "given,expected", + sorted( + { + chr(0): r"^@", + chr(1): r"^A", + chr(26): r"^Z", + chr(29): r"^]", + chr(31): r"^_", + chr(32): r" ", + chr(126): r"~", + chr(127): r"^?", + chr(128): r"\x80", + chr(254): r"\xfe", + chr(255): r"\xff", + }.items() + ), +) +def test_name_unicode(given, expected): """Test mapping of ascii table to name_unicode result.""" - given_expected = { - chr(0): r"^@", - chr(1): r"^A", - chr(26): r"^Z", - chr(29): r"^]", - chr(31): r"^_", - chr(32): r" ", - chr(126): r"~", - chr(127): r"^?", - chr(128): r"\x80", - chr(254): r"\xfe", - chr(255): r"\xff", - } - for given, expected in sorted(given_expected.items()): - # exercise, - result = name_unicode(given) - - # verify, - assert result == expected - - -def test_eightbits(): - """Test mapping of bit values to binary appearance string.""" - given_expected = {0: "0b00000000", 127: "0b01111111", 128: "0b10000000", 255: "0b11111111"} - for given, expected in sorted(given_expected.items()): - # exercise, - result = eightbits(given) - - # verify - assert result == expected + assert name_unicode(given) == expected -def test_encoding_from_lang(): +@pytest.mark.parametrize( + "given,expected", + sorted({0: "0b00000000", 127: "0b01111111", 128: "0b10000000", 255: "0b11111111"}.items()), +) +def test_eightbits(given, expected): + """Test mapping of bit values to binary appearance string.""" + assert eightbits(given) == expected + + +@pytest.mark.parametrize( + "given,expected", + sorted( + { + "en_US.UTF-8@misc": "UTF-8", + "en_US.UTF-8": "UTF-8", + "abc.def": "def", + ".def@ghi": "def", + }.items() + ), +) +def test_encoding_from_lang(given, expected): """Test inference of encoding from LANG value.""" - given_expected = { - "en_US.UTF-8@misc": "UTF-8", - "en_US.UTF-8": "UTF-8", - "abc.def": "def", - ".def@ghi": "def", - } - for given, expected in sorted(given_expected.items()): - result = encoding_from_lang(given) - assert result == expected - - -def test_encoding_from_lang_no_encoding(): + assert encoding_from_lang(given) == expected + + +@pytest.mark.parametrize( + "given,expected", + sorted({"en_IL": None, "en_US": None, "C": None, "POSIX": None, "UTF-8": None}.items()), +) +def test_encoding_from_lang_no_encoding(given, expected): """Test LANG values without encoding suffix return None.""" - given_expected = {"en_IL": None, "en_US": None, "C": None, "POSIX": None, "UTF-8": None} - for given, expected in sorted(given_expected.items()): - result = encoding_from_lang(given) - assert result == expected + assert encoding_from_lang(given) == expected diff --git a/telnetlib3/tests/test_accessories_extra.py b/telnetlib3/tests/test_accessories_extra.py index 4b10fb96..af092141 100644 --- a/telnetlib3/tests/test_accessories_extra.py +++ b/telnetlib3/tests/test_accessories_extra.py @@ -25,17 +25,14 @@ def test_trace_level_registered(): def test_hexdump_short(): - data = b"Hello World\r\n" - result = hexdump(data) + result = hexdump(b"Hello World\r\n") assert "48 65 6c 6c 6f 20 57 6f" in result assert "72 6c 64 0d 0a" in result assert "|Hello World..|" in result def test_hexdump_two_rows(): - data = bytes(range(32)) - result = hexdump(data) - lines = result.splitlines() + lines = hexdump(bytes(range(32))).splitlines() assert len(lines) == 2 assert lines[0].startswith("00000000") assert lines[1].startswith("00000010") @@ -59,7 +56,6 @@ def test_make_logger_trace_level(): def test_make_logger_no_file(): logger = make_logger("acc_no_file", loglevel="info") assert logger.name == "acc_no_file" - # ensure level applied assert logger.level == logging.INFO assert logger.isEnabledFor(logging.INFO) @@ -70,15 +66,13 @@ def test_make_logger_with_file(tmp_path): assert logger.name == "acc_with_file" assert logger.level == logging.WARNING assert logger.isEnabledFor(logging.WARNING) - # emit (do not assert file contents to avoid coupling with global logging config) logger.warning("file logging branch executed") def test_repr_mapping_quotes_roundtrip(): mapping = OrderedDict([("a", "simple"), ("b", "needs space"), ("c", "quote'"), ("d", 42)]) - result = repr_mapping(mapping) expected = " ".join(f"{k}={shlex.quote(str(v))}" for k, v in mapping.items()) - assert result == expected + assert repr_mapping(mapping) == expected def test_function_lookup_success_and_not_callable(): diff --git a/telnetlib3/tests/test_atascii_codec.py b/telnetlib3/tests/test_atascii_codec.py index c7c81420..a796c35a 100644 --- a/telnetlib3/tests/test_atascii_codec.py +++ b/telnetlib3/tests/test_atascii_codec.py @@ -18,6 +18,7 @@ def test_codec_lookup(): @pytest.mark.parametrize("alias", ["atari8bit", "atari_8bit"]) def test_codec_aliases(alias): + codecs.lookup("atascii") info = codecs.lookup(alias) assert info.name == "atascii" diff --git a/telnetlib3/tests/test_benchmarks.py b/telnetlib3/tests/test_benchmarks.py index ca58ec27..6f81142a 100644 --- a/telnetlib3/tests/test_benchmarks.py +++ b/telnetlib3/tests/test_benchmarks.py @@ -177,7 +177,6 @@ async def shell(reader, writer): host="127.0.0.1", port=port, encoding=False, - connect_minwait=0.05, connect_maxwait=0.1, client_factory=telnetlib3.TelnetClient, ) diff --git a/telnetlib3/tests/test_charset.py b/telnetlib3/tests/test_charset.py index 8175a365..f6e26ba8 100644 --- a/telnetlib3/tests/test_charset.py +++ b/telnetlib3/tests/test_charset.py @@ -4,45 +4,30 @@ import asyncio import collections +# 3rd party +import pytest + # local import telnetlib3 import telnetlib3.stream_writer from telnetlib3.telopt import DO, SB, SE, IAC, WILL, WONT, TTYPE, CHARSET, REQUEST, ACCEPTED from telnetlib3.stream_writer import TelnetWriter from telnetlib3.tests.accessories import ( - bind_host, + MockTransport, create_server, asyncio_server, open_connection, - unused_tcp_port, asyncio_connection, ) -# --- Common Mock Classes --- - -class MockTransport: +class MockProtocol: def __init__(self): - self.writes = [] - self._closing = False - - def write(self, data): - self.writes.append(bytes(data)) - - def is_closing(self): - return self._closing - - def close(self): - self._closing = True + self.force_binary = False def get_extra_info(self, name, default=None): return default - -class MockProtocol: - def get_extra_info(self, name, default=None): - return default - async def _drain_helper(self): pass @@ -81,9 +66,6 @@ def send_charset(self, offered): return super().send_charset(offered) -# --- Basic CHARSET Tests --- - - async def test_telnet_server_on_charset(bind_host, unused_tcp_port): """Test Server's callback method on_charset().""" _waiter = asyncio.Future() @@ -137,7 +119,6 @@ def send_charset(self, offered): host=bind_host, port=unused_tcp_port, encoding="latin1", - connect_minwait=0.05, ) as (reader, writer): val = await asyncio.wait_for(_waiter, 1.5) assert val == "cp437" @@ -175,7 +156,6 @@ def send_charset(self, offered): host=bind_host, port=unused_tcp_port, encoding="latin1", - connect_minwait=0.05, ) as (reader, writer): val = await asyncio.wait_for(_waiter, 0.5) assert not val @@ -186,9 +166,6 @@ def send_charset(self, offered): await server_instance["protocol"].writer.wait_closed() -# --- Negotiation Protocol Tests --- - - def test_server_sends_do_and_will_charset(): """Test server can send both DO CHARSET and WILL CHARSET per RFC 2066.""" ws, ts, _ = new_writer(server=True) @@ -317,9 +294,6 @@ def test_server_does_not_send_duplicate_will_charset(): assert ws.remote_option.enabled(CHARSET) -# --- Bug Fix Tests --- - - def test_client_responds_with_do_to_will_charset(): """Test client responds with DO CHARSET when receiving WILL CHARSET from server.""" # Create client writer instance @@ -330,18 +304,8 @@ def test_client_responds_with_do_to_will_charset(): # Simulate server sending WILL CHARSET client_writer.handle_will(CHARSET) - # Verify client sent DO CHARSET in response - # The fix ensures this happens automatically in handle_will - sent_do_charset = False - for write in transport.writes: - if write == IAC + DO + CHARSET: - sent_do_charset = True - break - - assert sent_do_charset, "Client did not send IAC DO CHARSET in response to IAC WILL CHARSET" - assert client_writer.remote_option.enabled( - CHARSET - ), "Client did not enable remote_option[CHARSET]" + assert IAC + DO + CHARSET in transport.writes + assert client_writer.remote_option.enabled(CHARSET) def test_unit_charset_negotiation_sequence(): @@ -372,19 +336,13 @@ def test_unit_charset_negotiation_sequence(): # Server should respond with its own IAC WILL CHARSET (bi-directional exchange) # Note: The server also immediately sends CHARSET REQUEST after WILL CHARSET # when both sides have CHARSET capability, so we need to check if WILL CHARSET is in the writes - will_charset_sent = False - for write in server_transport.writes: - if write == IAC + WILL + CHARSET: - will_charset_sent = True - break - assert will_charset_sent, "Server did not send IAC WILL CHARSET" + assert IAC + WILL + CHARSET in server_transport.writes # 4. Client receives IAC WILL CHARSET and should respond with IAC DO CHARSET client_transport.writes.clear() # Clear previous writes client_writer.handle_will(CHARSET) - # Verify that client sent IAC DO CHARSET (this would have failed before the fix) - assert IAC + DO + CHARSET in client_transport.writes, "Client failed to send IAC DO CHARSET" + assert IAC + DO + CHARSET in client_transport.writes # After this exchange, both sides should have CHARSET capability enabled assert server_writer.remote_option.enabled(CHARSET) @@ -393,48 +351,53 @@ def test_unit_charset_negotiation_sequence(): assert client_writer.local_option.enabled(CHARSET) -# --- Edge Case Tests --- - - -async def test_charset_send_unknown_encoding(bind_host, unused_tcp_port): - """Test client with unknown encoding value.""" +@pytest.mark.parametrize( + "charset_behavior", + [ + pytest.param("unknown_encoding", id="unknown-encoding"), + pytest.param("no_viable_offers", id="no-viable-offers"), + pytest.param("explicit_non_latin1", id="explicit-non-latin1"), + ], +) +async def test_charset_send_edge_cases(bind_host, unused_tcp_port, charset_behavior): + """Test client charset edge cases all fall back to US-ASCII.""" async with asyncio_server(asyncio.Protocol, bind_host, unused_tcp_port): async with open_connection( client_factory=lambda **kwargs: CustomTelnetClient( - charset_behavior="unknown_encoding", **kwargs + charset_behavior=charset_behavior, **kwargs ), host=bind_host, port=unused_tcp_port, - connect_minwait=0.05, + connect_maxwait=0.25, ) as (reader, writer): assert writer.protocol.encoding(incoming=True) == "US-ASCII" -async def test_charset_send_no_viable_offers(bind_host, unused_tcp_port): - """Test client with no viable encoding offers.""" - async with asyncio_server(asyncio.Protocol, bind_host, unused_tcp_port): - async with open_connection( - client_factory=lambda **kwargs: CustomTelnetClient( - charset_behavior="no_viable_offers", **kwargs - ), - host=bind_host, - port=unused_tcp_port, - connect_minwait=0.05, - connect_maxwait=0.25, - ) as (reader, writer): - assert writer.protocol.encoding(incoming=True) == "US-ASCII" +def test_charset_accepted_sets_force_binary_on_request_side(): + """CHARSET ACCEPTED by peer sets force_binary on the requesting side.""" + w, t, p = new_writer(server=True) + w.handle_will(CHARSET) + w.remote_option[CHARSET] = True + w.local_option[CHARSET] = True + buf = collections.deque([CHARSET, ACCEPTED, b"UTF-8"]) + w._handle_sb_charset(buf) -async def test_charset_explicit_non_latin1_encoding(bind_host, unused_tcp_port): - """Test client rejecting offered encodings when explicit non-latin1 is set.""" - async with asyncio_server(asyncio.Protocol, bind_host, unused_tcp_port): - async with open_connection( - client_factory=lambda **kwargs: CustomTelnetClient( - charset_behavior="explicit_non_latin1", **kwargs - ), - host=bind_host, - port=unused_tcp_port, - connect_minwait=0.05, - connect_maxwait=0.25, - ) as (reader, writer): - assert writer.protocol.encoding(incoming=True) == "US-ASCII" + assert w.environ_encoding == "UTF-8" + assert p.force_binary is True + + +def test_charset_accepted_sets_force_binary_on_accepting_side(): + """Selecting a charset from a REQUEST sets force_binary on the accepting side.""" + w, t, p = new_writer(server=False, client=True) + w.handle_do(CHARSET) + w.local_option[CHARSET] = True + w.remote_option[CHARSET] = True + + sep = b";" + w._ext_send_callback[CHARSET] = lambda offers: "UTF-8" + buf = collections.deque([CHARSET, REQUEST, sep, b"UTF-8"]) + w._handle_sb_charset(buf) + + assert w.environ_encoding == "UTF-8" + assert p.force_binary is True diff --git a/telnetlib3/tests/test_client_shell.py b/telnetlib3/tests/test_client_shell.py index e410b8ba..14bca35e 100644 --- a/telnetlib3/tests/test_client_shell.py +++ b/telnetlib3/tests/test_client_shell.py @@ -1,4 +1,4 @@ -"""Tests for telnetlib3.client_shell — Terminal mode handling.""" +"""Tests for telnetlib3.client_shell -- Terminal mode handling.""" # std imports import sys @@ -10,6 +10,9 @@ import pytest import pexpect +# local +from telnetlib3._session_context import TelnetSessionContext + if sys.platform == "win32": pytest.skip("POSIX-only tests", allow_module_level=True) @@ -41,16 +44,19 @@ def _make_writer( will_echo: bool = False, raw_mode: "bool | None" = False, will_sga: bool = False ) -> object: """Build a minimal mock writer with the attributes Terminal needs.""" - from telnetlib3.telopt import SGA # pylint: disable=import-outside-toplevel + from telnetlib3.telopt import SGA + ctx = TelnetSessionContext() + ctx.raw_mode = False writer = types.SimpleNamespace( will_echo=will_echo, client=True, remote_option=_MockOption({SGA: will_sga}), log=types.SimpleNamespace(debug=lambda *a, **kw: None), + ctx=ctx, ) if raw_mode is not False: - writer._raw_mode = raw_mode + writer.ctx.raw_mode = raw_mode return writer @@ -94,13 +100,7 @@ def test_determine_mode_unchanged(will_echo: bool, raw_mode: "bool | None", will @pytest.mark.parametrize( "will_echo,raw_mode,will_sga", - [ - (True, None, False), - (False, None, True), - (True, None, True), - (False, True, False), - (True, True, False), - ], + [(False, None, True), (True, None, True), (False, True, False), (True, True, False)], ) def test_determine_mode_goes_raw(will_echo: bool, raw_mode: "bool | None", will_sga: bool) -> None: term = _make_term(_make_writer(will_echo=will_echo, raw_mode=raw_mode, will_sga=will_sga)) @@ -111,6 +111,16 @@ def test_determine_mode_goes_raw(will_echo: bool, raw_mode: "bool | None", will_ assert not result.lflag & termios.ECHO +def test_determine_mode_echo_only_stays_linemode() -> None: + """WILL ECHO without WILL SGA keeps ICANON (line mode) but suppresses local ECHO.""" + term = _make_term(_make_writer(will_echo=True, raw_mode=None, will_sga=False)) + mode = _cooked_mode() + result = term.determine_mode(mode) + assert result is not mode + assert result.lflag & termios.ICANON + assert not result.lflag & termios.ECHO + + def test_determine_mode_sga_sets_software_echo() -> None: term = _make_term(_make_writer(will_sga=True, raw_mode=None)) term.determine_mode(_cooked_mode()) @@ -146,7 +156,7 @@ def test_echo_toggle_password_flow() -> None: writer.will_echo = True r2 = term.determine_mode(mode) assert not r2.lflag & termios.ECHO - assert not r2.lflag & termios.ICANON + assert r2.lflag & termios.ICANON writer.will_echo = False r3 = term.determine_mode(mode) @@ -318,12 +328,13 @@ def test_filter_without_eol_xlat(data: bytes, expected: bytes) -> None: import os # noqa: E402 import time as _time # noqa: E402 import select # noqa: E402 +import signal # noqa: E402 import tempfile # noqa: E402 import contextlib # noqa: E402 import subprocess # noqa: E402 # local -from telnetlib3.tests.accessories import bind_host, asyncio_server, unused_tcp_port # noqa: E402 +from telnetlib3.tests.accessories import asyncio_server _IAC = b"\xff" _WILL = b"\xfb" @@ -348,14 +359,7 @@ def _strip_iac(data: bytes) -> bytes: def _client_cmd(host: str, port: int, extra: "list[str] | None" = None) -> "list[str]": prog = pexpect.which("telnetlib3-client") assert prog is not None - args = [ - prog, - host, - str(port), - "--connect-minwait=0.05", - "--connect-maxwait=0.5", - "--colormatch=none", - ] + args = [prog, host, str(port), "--connect-maxwait=0.5", "--colormatch=none"] if extra: args.extend(extra) return args @@ -454,7 +458,7 @@ def _pty_client(cmd: "list[str]"): ( b"hello from server\r\n", 0.5, - None, + [], [b"Escape character", b"hello from server", b"Connection closed by foreign host."], ), (b"raw server\r\n", 0.1, ["--raw-mode"], [b"raw server"]), @@ -485,8 +489,8 @@ def connection_made(self, transport): @pytest.mark.parametrize( "prompt,response,extra_args,send,expected", [ - (b"login: ", b"\r\nwelcome!\r\n", None, b"user\r", [b"login:", b"welcome!"]), - (b"prompt> ", b"\r\ngot it\r\n", ["--line-mode"], b"hello\r", [b"got it", b"hello"]), + (b"login: ", b"\r\nwelcome!\r\n", [], b"user\r", [b"login:", b"welcome!"]), + (b"prompt> ", b"\r\ngot it\r\n", ["--line-mode"], b"hello\r", [b"got it"]), ], ) async def test_echo_sga_interaction( @@ -551,7 +555,7 @@ def data_received(self, data): asyncio.get_event_loop().call_later(0.2, self._transport.close) async with asyncio_server(Proto, bind_host, unused_tcp_port): - cmd = _client_cmd(bind_host, unused_tcp_port) + cmd = _client_cmd(bind_host, unused_tcp_port, []) def _interact(master_fd, proc): buf = _pty_read(master_fd, marker=b"Name:", timeout=10.0) @@ -596,7 +600,7 @@ def data_received(self, data): asyncio.get_event_loop().call_later(0.2, self._transport.close) async with asyncio_server(Proto, bind_host, unused_tcp_port): - cmd = _client_cmd(bind_host, unused_tcp_port) + cmd = _client_cmd(bind_host, unused_tcp_port, []) def _interact(master_fd, proc): buf = _pty_read(master_fd, marker=b"login:", timeout=10.0) @@ -621,6 +625,41 @@ def test_check_auto_mode_not_istty() -> None: assert term.check_auto_mode(switched_to_raw=False, last_will_echo=False) is None +def test_check_auto_mode_echo_only_stays_linemode() -> None: + """WILL ECHO without SGA suppresses local echo but does not switch to raw.""" + writer = _make_writer(will_echo=True, will_sga=False, raw_mode=None) + term = _make_term(writer) + term._istty = True + term._save_mode = _cooked_mode() + _set_modes: list[Terminal.ModeDef] = [] + term.set_mode = _set_modes.append # type: ignore[method-assign] + result = term.check_auto_mode(switched_to_raw=False, last_will_echo=False) + assert result is not None + switched_to_raw, last_will_echo, local_echo = result + assert switched_to_raw is False + assert last_will_echo is True + assert local_echo is False + assert len(_set_modes) == 1 + assert _set_modes[0].lflag & termios.ICANON + assert not _set_modes[0].lflag & termios.ECHO + + +def test_check_auto_mode_sga_goes_raw() -> None: + """WILL SGA switches to raw mode.""" + writer = _make_writer(will_echo=False, will_sga=True, raw_mode=None) + term = _make_term(writer) + term._istty = True + term._save_mode = _cooked_mode() + _set_modes: list[Terminal.ModeDef] = [] + term.set_mode = _set_modes.append # type: ignore[method-assign] + result = term.check_auto_mode(switched_to_raw=False, last_will_echo=False) + assert result is not None + switched_to_raw, _, _ = result + assert switched_to_raw is True + assert len(_set_modes) == 1 + assert not _set_modes[0].lflag & termios.ICANON + + async def test_setup_winch_registers_handler() -> None: """setup_winch registers SIGWINCH handler when istty is True.""" writer = _make_writer() @@ -628,19 +667,177 @@ async def test_setup_winch_registers_handler() -> None: writer.is_closing = lambda: False term = _make_term(writer) term._istty = True - term._winch_handle = None + term._resize_pending = __import__("threading").Event() term.setup_winch() assert term._remove_winch is True term.cleanup_winch() assert term._remove_winch is False +async def test_winch_handler_sets_resize_pending() -> None: + """SIGWINCH handler sets _resize_pending flag.""" + writer = _make_writer() + writer.local_option = _MockOption({}) + writer.is_closing = lambda: False + + term = _make_term(writer) + term._istty = True + term._resize_pending = __import__("threading").Event() + + term.setup_winch() + assert term._remove_winch is True + + os.kill(os.getpid(), signal.SIGWINCH) + await asyncio.sleep(0.05) + + assert term._resize_pending.is_set() + term.cleanup_winch() + + +@pytest.mark.asyncio +async def test_raw_event_loop_reactivates_repl() -> None: + """_raw_event_loop breaks when want_repl() returns True.""" + from telnetlib3.client_shell import _RawLoopState, _raw_event_loop + + class _StrReader: + def __init__(self) -> None: + self._data = ["server data"] + self._eof = False + + async def read(self, n: int) -> str: + if self._data: + return self._data.pop(0) + self._eof = True + return "" + + def at_eof(self) -> bool: + return self._eof + + reader = _StrReader() + stdin = asyncio.StreamReader() + + writer = _make_writer() + writer.log = types.SimpleNamespace(debug=lambda *a, **kw: None, log=lambda *a, **kw: None) + writer.ctx.raw_mode = None + writer.ctx.color_filter = None + writer.ctx.ascii_eol = False + writer.ctx.autoreply_engine = None + writer.ctx.autoreply_rules = [] + writer.ctx.input_filter = None + writer.is_closing = lambda: False + + term = _make_term(writer) + term.check_auto_mode = lambda switched_to_raw, last_will_echo: None + + stdout = mock.Mock() + stdout.write = mock.Mock() + + close_calls: list[str] = [] + + state = _RawLoopState( + switched_to_raw=True, last_will_echo=False, local_echo=False, linesep="\r\n" + ) + await _raw_event_loop( + telnet_reader=reader, + telnet_writer=writer, + tty_shell=term, + stdin=stdin, + stdout=stdout, + keyboard_escape="\x1d", + state=state, + handle_close=close_calls.append, + want_repl=lambda: True, + ) + assert state.reactivate_repl is True + assert state.switched_to_raw is False + + +@pytest.mark.asyncio +async def test_raw_event_loop_typescript_recording() -> None: + """_raw_event_loop writes server output to ctx.typescript_file when set.""" + import io + + from telnetlib3.client_shell import _RawLoopState, _raw_event_loop + + class _StrReader: + def __init__(self) -> None: + self._data = ["hello world"] + self._eof = False + + async def read(self, n: int) -> str: + if self._data: + return self._data.pop(0) + self._eof = True + return "" + + def at_eof(self) -> bool: + return self._eof + + reader = _StrReader() + stdin = asyncio.StreamReader() + + writer = _make_writer() + writer.log = types.SimpleNamespace(debug=lambda *a, **kw: None, log=lambda *a, **kw: None) + writer.ctx.raw_mode = True + writer.ctx.color_filter = None + writer.ctx.ascii_eol = False + writer.ctx.autoreply_engine = None + writer.ctx.input_filter = None + writer.is_closing = lambda: False + + ts_buf = io.StringIO() + writer.ctx.typescript_file = ts_buf + + term = _make_term(writer) + term.check_auto_mode = lambda switched_to_raw, last_will_echo: None + + stdout = mock.Mock() + stdout.write = mock.Mock() + + state = _RawLoopState( + switched_to_raw=True, last_will_echo=False, local_echo=False, linesep="\r\n" + ) + await _raw_event_loop( + telnet_reader=reader, + telnet_writer=writer, + tty_shell=term, + stdin=stdin, + stdout=stdout, + keyboard_escape="\x1d", + state=state, + handle_close=lambda msg: None, + want_repl=lambda: False, + ) + assert "hello world" in ts_buf.getvalue() + + +@pytest.mark.asyncio +async def test_winch_resize_pending_cleared_after_consumption() -> None: + """_resize_pending flag can be set and cleared.""" + writer = _make_writer() + writer.local_option = _MockOption({}) + writer.is_closing = lambda: False + + term = _make_term(writer) + term._istty = True + term._resize_pending = __import__("threading").Event() + + term.setup_winch() + os.kill(os.getpid(), signal.SIGWINCH) + await asyncio.sleep(0.05) + + assert term._resize_pending.is_set() + term._resize_pending.clear() + assert not term._resize_pending.is_set() + term.cleanup_winch() + + async def test_send_stdin_with_input_filter() -> None: """_send_stdin feeds bytes through input filter and writes translated.""" inf = InputFilter(_INPUT_SEQ_XLAT["atascii"], _INPUT_XLAT["atascii"]) writer = _make_writer() - writer._input_filter = inf + writer.ctx.input_filter = inf writer._write = mock.Mock() stdout = mock.Mock() @@ -655,7 +852,7 @@ async def test_send_stdin_with_pending_sequence() -> None: inf = InputFilter(_INPUT_SEQ_XLAT["atascii"], _INPUT_XLAT["atascii"]) writer = _make_writer() - writer._input_filter = inf + writer.ctx.input_filter = inf writer._write = mock.Mock() stdout = mock.Mock() @@ -679,7 +876,7 @@ async def test_send_stdin_no_filter() -> None: def _make_transform_writer(**kwargs: object) -> object: """Build a minimal writer for _transform_output tests.""" - return types.SimpleNamespace(**kwargs) + return types.SimpleNamespace(ctx=TelnetSessionContext(), **kwargs) @pytest.mark.parametrize( @@ -709,3 +906,46 @@ def test_transform_output_bare_cr_preserved_raw() -> None: out = _transform_output("\x1b[34;1H\x1b[K\r\x1b[38;2;17;17;17mX\x1b[6n", writer, True) assert "\r\n" not in out assert "\r" in out + + +def test_transform_output_empty_string() -> None: + writer = _make_transform_writer() + assert not _transform_output("", writer, True) + + +@pytest.mark.parametrize("raw_mode,expected", [(False, False), (None, None), (True, True)]) +def test_get_raw_mode(raw_mode: "bool | None", expected: "bool | None") -> None: + from telnetlib3.client_shell import _get_raw_mode + + ctx = TelnetSessionContext() + ctx.raw_mode = raw_mode + writer = types.SimpleNamespace(ctx=ctx) + assert _get_raw_mode(writer) is expected + + +async def test_cooked_to_raw_transition_preserves_crlf( + bind_host: str, unused_tcp_port: int +) -> None: + """First data chunk during cooked->raw transition must keep \\r\\n line endings.""" + + class Proto(asyncio.Protocol): + def connection_made(self, transport): + super().connection_made(transport) + transport.write( + _IAC + _WILL + _SGA + _IAC + _WILL + _ECHO + b"line1\r\nline2\r\nline3\r\n" + ) + asyncio.get_event_loop().call_later(0.5, transport.close) + + async with asyncio_server(Proto, bind_host, unused_tcp_port): + cmd = _client_cmd(bind_host, unused_tcp_port, []) + with _pty_client(cmd) as (proc, master_fd): + output = await asyncio.to_thread(_pty_read, master_fd, proc=proc) + assert b"line1" in output + assert b"line2" in output + assert b"line3" in output + text = output.decode("utf-8", errors="replace") + for line in ("line1", "line2", "line3"): + idx = text.find(line) + assert idx != -1 + after = text[idx + len(line) :] + assert after.startswith("\r\n") diff --git a/telnetlib3/tests/test_client_unit.py b/telnetlib3/tests/test_client_unit.py index 92d76a56..df63d839 100644 --- a/telnetlib3/tests/test_client_unit.py +++ b/telnetlib3/tests/test_client_unit.py @@ -1,18 +1,22 @@ # std imports import sys +import types +import asyncio # 3rd party import pytest # local from telnetlib3 import client as cl -from telnetlib3.tests.accessories import bind_host, create_server # noqa: F401 +from telnetlib3 import accessories +from telnetlib3.client_base import BaseClient +from telnetlib3._session_context import TelnetSessionContext +from telnetlib3.tests.accessories import create_server _CLIENT_DEFAULTS = { "encoding": "utf8", "encoding_errors": "strict", "force_binary": False, - "connect_minwait": 0.01, "connect_maxwait": 0.02, } @@ -180,6 +184,27 @@ def test_argument_parser(): assert defaults.port == 23 and defaults.force_binary is True and defaults.speed == 38400 +def test_argument_parser_prog_name(): + parser = cl._get_argument_parser() + assert parser.prog == "telnetlib3-client" + + +def test_argument_parser_typescript(): + parser = cl._get_argument_parser() + args = parser.parse_args(["myhost", "--typescript", "/tmp/session.log"]) + assert args.typescript == "/tmp/session.log" + + defaults = parser.parse_args(["myhost"]) + assert defaults.typescript is None + + +def test_argument_parser_ice_colors(): + parser = cl._get_argument_parser() + args = parser.parse_args(["host", "--typescript", "out.log", "--no-ice-colors"]) + assert args.typescript == "out.log" + assert args.ice_colors is False + + def test_transform_args(): parser = cl._get_argument_parser() result = cl._transform_args( @@ -193,17 +218,22 @@ def test_transform_args(): assert result2["send_environ"] == ("TERM", "LANG") +def test_transform_args_typescript(): + parser = cl._get_argument_parser() + result = cl._transform_args(parser.parse_args(["myhost", "--typescript", "/tmp/sess.log"])) + assert result["typescript"] == "/tmp/sess.log" + + defaults = cl._transform_args(parser.parse_args(["myhost"])) + assert defaults["typescript"] is None + + @pytest.mark.asyncio async def test_open_connection_default_factory(bind_host, unused_tcp_port, monkeypatch): monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05): + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5): reader, writer = await cl.open_connection( - host=bind_host, - port=unused_tcp_port, - connect_minwait=0.05, - connect_maxwait=0.1, - encoding=False, + host=bind_host, port=unused_tcp_port, connect_maxwait=0.1, encoding=False ) assert isinstance(writer.protocol, cl.TelnetClient) assert not isinstance(writer.protocol, cl.TelnetTerminalClient) @@ -215,13 +245,335 @@ async def test_open_connection_default_factory(bind_host, unused_tcp_port, monke async def test_open_connection_tty_factory(bind_host, unused_tcp_port, monkeypatch): monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05): + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5): reader, writer = await cl.open_connection( - host=bind_host, - port=unused_tcp_port, - connect_minwait=0.05, - connect_maxwait=0.1, - encoding=False, + host=bind_host, port=unused_tcp_port, connect_maxwait=0.1, encoding=False ) assert isinstance(writer.protocol, cl.TelnetTerminalClient) writer.close() + + +def test_detect_syncterm_font_sets_force_binary(): + client = BaseClient.__new__(BaseClient) + client.log = types.SimpleNamespace(debug=lambda *a, **kw: None, isEnabledFor=lambda _: False) + client.force_binary = False + client.writer = types.SimpleNamespace(environ_encoding="utf-8") + client._detect_syncterm_font(b"\x1b[0;0 D") + assert client.force_binary is True + + +@pytest.mark.parametrize("extra_args,expected", [([], "vga"), (["--colormatch", "xterm"], "xterm")]) +def test_transform_args_colormatch(extra_args, expected): + parser = cl._get_argument_parser() + assert cl._transform_args(parser.parse_args(["myhost"] + extra_args))["colormatch"] == expected + + +def test_guard_shells_connection_counter(): + from telnetlib3.guard_shells import ConnectionCounter + + counter = ConnectionCounter(2) + assert counter.try_acquire() is True + assert counter.try_acquire() is True + assert counter.try_acquire() is False + assert counter.count == 2 + counter.release() + assert counter.count == 1 + assert counter.try_acquire() is True + counter.release() + counter.release() + counter.release() + assert counter.count == 0 + + +@pytest.mark.asyncio +async def test_guard_shells_busy_shell(): + from telnetlib3.guard_shells import busy_shell + + class MockWriter: + def __init__(self): + self.output = [] + self._extra = {"peername": ("127.0.0.1", 12345)} + + def write(self, data): + self.output.append(data) + + async def drain(self): + pass + + def get_extra_info(self, key, default=None): + return self._extra.get(key, default) + + class MockReader: + async def read(self, n): + return "" + + reader = MockReader() + writer = MockWriter() + await busy_shell(reader, writer) + + output = "".join(writer.output) + assert "Machine is busy" in output + + +@pytest.mark.asyncio +async def test_guard_shells_robot_check_timeout(): + from telnetlib3.guard_shells import robot_check + + class MockWriter: + def __init__(self): + self.output = [] + self._extra = {"peername": ("127.0.0.1", 12345)} + + def write(self, data): + self.output.append(data) + + async def drain(self): + pass + + def get_extra_info(self, key, default=None): + return self._extra.get(key, default) + + class MockReader: + def fn_encoding(self, **kw): + return "utf-8" + + _decoder = None + + async def read(self, n): + return "" + + assert await robot_check(MockReader(), MockWriter(), timeout=0.1) is False + + +async def _noop_shell(reader, writer): + pass + + +def _fake_open_connection_factory(loop): + """Build a mock open_connection that captures the shell callback.""" + captured_kwargs: dict = {} + writer_obj = types.SimpleNamespace( + protocol=types.SimpleNamespace(waiter_closed=loop.create_future()), + ctx=TelnetSessionContext(), + ) + writer_obj.protocol.waiter_closed.set_result(None) + reader_obj = types.SimpleNamespace() + + async def _fake_open_connection(*args, **kwargs): + captured_kwargs.update(kwargs) + shell = kwargs["shell"] + await shell(reader_obj, writer_obj) + return reader_obj, writer_obj + + return _fake_open_connection, captured_kwargs, writer_obj + + +@pytest.mark.asyncio +async def test_run_client_unknown_palette(monkeypatch): + """run_client exits with error on unknown palette.""" + monkeypatch.setattr(sys, "argv", ["telnetlib3-client", "localhost", "--colormatch", "bogus"]) + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + + with pytest.raises(SystemExit) as exc_info: + await cl.run_client() + assert exc_info.value.code == 1 + + +@pytest.mark.parametrize( + "argv_extra,filter_cls_name", + [ + pytest.param( + ["--encoding", "petscii", "--colormatch", "vga"], + "PetsciiColorFilter", + id="petscii_selects_c64", + ), + pytest.param( + ["--encoding", "atascii", "--colormatch", "vga"], + "AtasciiControlFilter", + id="atascii_filter", + ), + pytest.param(["--colormatch", "vga"], "ColorFilter", id="colormatch_vga"), + pytest.param( + ["--colormatch", "petscii"], "PetsciiColorFilter", id="colormatch_petscii_alias" + ), + ], +) +@pytest.mark.asyncio +async def test_run_client_color_filter(monkeypatch, argv_extra, filter_cls_name): + monkeypatch.setattr(sys, "argv", ["telnetlib3-client", "localhost"] + argv_extra) + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + monkeypatch.setattr(accessories, "function_lookup", lambda _: _noop_shell) + + loop = asyncio.get_event_loop() + fake_oc, captured, writer_obj = _fake_open_connection_factory(loop) + monkeypatch.setattr(cl, "open_connection", fake_oc) + await cl.run_client() + + assert type(writer_obj.ctx.color_filter).__name__ == filter_cls_name + + +@pytest.mark.asyncio +async def test_connection_made_reader_set_transport_exception(): + client = _make_client(encoding=False) + + class BadReader: + def set_transport(self, t): + raise RuntimeError("no transport support") + + def exception(self): + return None + + client._reader_factory = lambda **kw: BadReader() + transport = types.SimpleNamespace( + get_extra_info=lambda name, default=None: default, + write=lambda data: None, + is_closing=lambda: False, + close=lambda: None, + ) + client.connection_made(transport) + assert isinstance(client.reader, BadReader) + + +def test_detect_syncterm_font_returns_early_when_writer_none(): + client = BaseClient.__new__(BaseClient) + client.log = types.SimpleNamespace(debug=lambda *a, **kw: None, isEnabledFor=lambda _: False) + client.writer = None + client._detect_syncterm_font(b"\x1b[0;0 D") + + +@pytest.mark.asyncio +async def test_begin_shell_cancelled_future(): + client = BaseClient.__new__(BaseClient) + client.log = types.SimpleNamespace(debug=lambda *a, **kw: None, isEnabledFor=lambda _: False) + client.shell = lambda r, w: None + fut = asyncio.get_event_loop().create_future() + fut.cancel() + client.begin_shell(fut) + + +@pytest.mark.asyncio +async def test_data_received_trace_log(caplog): + import logging + + client = _make_client(encoding=False) + transport = types.SimpleNamespace( + get_extra_info=lambda name, default=None: default, + write=lambda data: None, + is_closing=lambda: False, + close=lambda: None, + pause_reading=lambda: None, + ) + client.connection_made(transport) + with caplog.at_level(5): + client.data_received(b"\xff\xfb\x01") + await asyncio.sleep(0.05) + + +@pytest.mark.asyncio +async def test_data_received_pauses_at_high_watermark(): + client = _make_client(encoding=False) + paused = [] + transport = types.SimpleNamespace( + get_extra_info=lambda name, default=None: default, + write=lambda data: None, + is_closing=lambda: False, + close=lambda: None, + pause_reading=lambda: paused.append(True), + resume_reading=lambda: paused.append(False), + ) + client.connection_made(transport) + big_data = b"\x00" * (client._read_high + 100) + client.data_received(big_data) + assert client._reading_paused is True + await asyncio.sleep(0.05) + + +@pytest.mark.asyncio +async def test_data_received_pause_reading_exception(): + client = _make_client(encoding=False) + + def bad_pause(): + raise RuntimeError("pause not supported") + + transport = types.SimpleNamespace( + get_extra_info=lambda name, default=None: default, + write=lambda data: None, + is_closing=lambda: False, + close=lambda: None, + pause_reading=bad_pause, + ) + client.connection_made(transport) + big_data = b"\x00" * (client._read_high + 100) + client.data_received(big_data) + assert client._reading_paused is False + await asyncio.sleep(0.05) + + +@pytest.mark.asyncio +async def test_process_rx_resumes_reading_on_drain(): + client = _make_client(encoding=False) + resumed = [] + transport = types.SimpleNamespace( + get_extra_info=lambda name, default=None: default, + write=lambda data: None, + is_closing=lambda: False, + close=lambda: None, + pause_reading=lambda: None, + resume_reading=lambda: resumed.append(True), + ) + client.connection_made(transport) + client._reading_paused = True + client._rx_queue.append(b"\x00" * 10) + client._rx_bytes = 10 + await client._process_rx() + assert len(resumed) >= 1 + + +class _MockTransport: + def __init__(self): + self.data = bytearray() + self._closing = False + + def write(self, data): + self.data.extend(data) + + def is_closing(self): + return self._closing + + def close(self): + self._closing = True + + def get_extra_info(self, name, default=None): + return default + + +def _make_connected_client(**kwargs): + client = _make_client(**kwargs) + transport = _MockTransport() + client.connection_made(transport) + return client, transport + + +@pytest.mark.asyncio +async def test_on_gmcp_stores_on_writer_ctx(): + client, _ = _make_connected_client() + client._on_gmcp("Room.Info", {"name": "Town Square"}) + assert client.writer.ctx.gmcp_data["Room.Info"] == {"name": "Town Square"} + + +@pytest.mark.asyncio +async def test_on_gmcp_merges_dicts_on_writer_ctx(): + client, _ = _make_connected_client() + client._on_gmcp("Char.Vitals", {"hp": 100, "maxhp": 100}) + client._on_gmcp("Char.Vitals", {"hp": 63}) + assert client.writer.ctx.gmcp_data["Char.Vitals"] == {"hp": 63, "maxhp": 100} + + +def test_fingerprint_main_oserror(monkeypatch): + async def _bad_fp(): + raise OSError("connection refused") + + monkeypatch.setattr(cl, "run_fingerprint_client", _bad_fp) + with pytest.raises(SystemExit) as exc_info: + cl.fingerprint_main() + assert exc_info.value.code == 1 diff --git a/telnetlib3/tests/test_color_filter.py b/telnetlib3/tests/test_color_filter.py index 2ec475c3..de8a1d67 100644 --- a/telnetlib3/tests/test_color_filter.py +++ b/telnetlib3/tests/test_color_filter.py @@ -1,4 +1,4 @@ -"""Tests for telnetlib3.color_filter — ANSI color palette translation.""" +"""Tests for telnetlib3.color_filter -- ANSI color palette translation.""" # 3rd party import pytest @@ -16,584 +16,648 @@ ) -class TestPaletteData: - @pytest.mark.parametrize("name", list(PALETTES.keys())) - def test_palette_has_16_entries(self, name: str) -> None: - assert len(PALETTES[name]) == 16 - - @pytest.mark.parametrize("name", list(PALETTES.keys())) - def test_palette_rgb_in_range(self, name: str) -> None: - for r, g, b in PALETTES[name]: - assert 0 <= r <= 255 - assert 0 <= g <= 255 - assert 0 <= b <= 255 - - def test_all_expected_palettes_exist(self) -> None: - assert set(PALETTES.keys()) == {"ega", "cga", "vga", "amiga", "xterm", "c64"} - - -class TestColorConfig: - def test_defaults(self) -> None: - cfg = ColorConfig() - assert cfg.palette_name == "ega" - assert cfg.brightness == 0.9 - assert cfg.contrast == 0.8 - assert cfg.background_color == (16, 16, 16) - assert cfg.reverse_video is False - - -class TestSgrCodeToPaletteIndex: - @pytest.mark.parametrize( - "code,expected", [(30, 0), (31, 1), (32, 2), (33, 3), (34, 4), (35, 5), (36, 6), (37, 7)] - ) - def test_normal_foreground(self, code: int, expected: int) -> None: - assert _sgr_code_to_palette_index(code) == expected - - @pytest.mark.parametrize( - "code,expected", [(40, 0), (41, 1), (42, 2), (43, 3), (44, 4), (45, 5), (46, 6), (47, 7)] - ) - def test_normal_background(self, code: int, expected: int) -> None: - assert _sgr_code_to_palette_index(code) == expected - - @pytest.mark.parametrize( - "code,expected", - [(90, 8), (91, 9), (92, 10), (93, 11), (94, 12), (95, 13), (96, 14), (97, 15)], - ) - def test_bright_foreground(self, code: int, expected: int) -> None: - assert _sgr_code_to_palette_index(code) == expected - - @pytest.mark.parametrize( - "code,expected", - [(100, 8), (101, 9), (102, 10), (103, 11), (104, 12), (105, 13), (106, 14), (107, 15)], - ) - def test_bright_background(self, code: int, expected: int) -> None: - assert _sgr_code_to_palette_index(code) == expected - - @pytest.mark.parametrize("code", [0, 1, 4, 7, 22, 38, 39, 48, 49, 128]) - def test_non_color_returns_none(self, code: int) -> None: - assert _sgr_code_to_palette_index(code) is None - - -class TestIsForegroundCode: - @pytest.mark.parametrize("code", list(range(30, 38)) + list(range(90, 98))) - def test_foreground_codes(self, code: int) -> None: - assert _is_foreground_code(code) is True - - @pytest.mark.parametrize("code", list(range(40, 48)) + list(range(100, 108))) - def test_background_codes(self, code: int) -> None: - assert _is_foreground_code(code) is False - - -class TestAdjustColor: - def test_identity(self) -> None: - assert _adjust_color(170, 85, 0, 1.0, 1.0) == (170, 85, 0) - - def test_full_brightness_zero_contrast(self) -> None: - r, g, b = _adjust_color(200, 100, 50, 1.0, 0.0) - assert r == 128 - assert g == 128 - assert b == 128 - - def test_zero_brightness(self) -> None: - r, g, b = _adjust_color(200, 100, 50, 0.0, 1.0) - assert r == 0 - assert g == 0 - assert b == 0 - - def test_half_brightness(self) -> None: - r, g, b = _adjust_color(200, 100, 0, 0.5, 1.0) - assert r == 100 - assert g == 50 - assert b == 0 - - def test_clamp_high(self) -> None: - r, _, _ = _adjust_color(255, 255, 255, 1.0, 2.0) - assert r == 255 - - def test_clamp_low(self) -> None: - r, _, _ = _adjust_color(0, 0, 0, 1.0, 2.0) - assert r == 0 - - def test_default_config_values(self) -> None: - r, g, b = _adjust_color(170, 0, 0, 0.9, 0.8) +def _make_filter(**kwargs: object) -> ColorFilter: + cfg = ColorConfig(brightness=1.0, contrast=1.0, **kwargs) # type: ignore[arg-type] + return ColorFilter(cfg) + + +@pytest.mark.parametrize("name", list(PALETTES.keys())) +def test_palette_has_16_entries(name: str) -> None: + assert len(PALETTES[name]) == 16 + + +@pytest.mark.parametrize("name", list(PALETTES.keys())) +def test_palette_rgb_in_range(name: str) -> None: + for r, g, b in PALETTES[name]: assert 0 <= r <= 255 assert 0 <= g <= 255 assert 0 <= b <= 255 -class TestColorFilterBasicTranslation: - def _make_filter(self, **kwargs: object) -> ColorFilter: - cfg = ColorConfig(brightness=1.0, contrast=1.0, **kwargs) # type: ignore[arg-type] - return ColorFilter(cfg) - - def test_red_foreground(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[31m") - ega_red = PALETTES["ega"][1] - expected_color = f"\x1b[38;2;{ega_red[0]};{ega_red[1]};{ega_red[2]}m" - assert expected_color in result - - def test_red_background(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[41m") - ega_red = PALETTES["ega"][1] - expected_color = f"\x1b[48;2;{ega_red[0]};{ega_red[1]};{ega_red[2]}m" - assert expected_color in result - - def test_bright_red_foreground(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[91m") - ega_bright_red = PALETTES["ega"][9] - expected = f"\x1b[38;2;{ega_bright_red[0]};" f"{ega_bright_red[1]};{ega_bright_red[2]}m" - assert expected in result - - def test_bright_red_background(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[101m") - ega_bright_red = PALETTES["ega"][9] - expected = f"\x1b[48;2;{ega_bright_red[0]};" f"{ega_bright_red[1]};{ega_bright_red[2]}m" - assert expected in result - - @pytest.mark.parametrize( - "code,idx", [(30, 0), (31, 1), (32, 2), (33, 3), (34, 4), (35, 5), (36, 6), (37, 7)] - ) - def test_all_normal_foreground_colors(self, code: int, idx: int) -> None: - f = self._make_filter() - result = f.filter(f"\x1b[{code}m") - rgb = PALETTES["ega"][idx] - assert f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result - - @pytest.mark.parametrize( - "code,idx", [(40, 0), (41, 1), (42, 2), (43, 3), (44, 4), (45, 5), (46, 6), (47, 7)] - ) - def test_all_normal_background_colors(self, code: int, idx: int) -> None: - f = self._make_filter() - result = f.filter(f"\x1b[{code}m") - rgb = PALETTES["ega"][idx] - assert f"48;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result - - -class TestColorFilterReset: - def _make_filter(self) -> ColorFilter: - cfg = ColorConfig(brightness=1.0, contrast=1.0, background_color=(16, 16, 16)) - return ColorFilter(cfg) - - def test_explicit_reset(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[0m") - assert "\x1b[0m" in result - assert "\x1b[48;2;16;16;16m" in result - - def test_empty_reset(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[m") - assert "\x1b[0m" in result - assert "\x1b[48;2;16;16;16m" in result - - def test_reset_in_compound_sequence(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[0;31m") - assert "\x1b[48;2;16;16;16m" in result - - -class TestColorFilterPassThrough: - def _make_filter(self) -> ColorFilter: - return ColorFilter(ColorConfig(brightness=1.0, contrast=1.0)) - - def test_256_color_foreground(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[38;5;196m") - assert "38;5;196" in result - - def test_24bit_color_foreground(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[38;2;100;200;50m") - assert "38;2;100;200;50" in result - - def test_256_color_background(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[48;5;42m") - assert "48;5;42" in result - - def test_24bit_color_background(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[48;2;10;20;30m") - assert "48;2;10;20;30" in result - - def test_bold_pass_through(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[1m") - assert "\x1b[1m" in result - - def test_underline_pass_through(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[4m") - assert "\x1b[4m" in result - - def test_default_fg_pass_through(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[39m") - assert "39" in result - - def test_default_bg_pass_through(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[49m") - assert "49" in result - - def test_non_sgr_escape_pass_through(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[2J") - assert "\x1b[2J" in result - - def test_cursor_home_pass_through(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[H") - assert "\x1b[H" in result - - def test_colon_extended_color_pass_through(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[38:2::255:0:0m") - assert "\x1b[38:2::255:0:0m" in result - - -class TestColorFilterCompound: - def _make_filter(self) -> ColorFilter: - return ColorFilter(ColorConfig(brightness=1.0, contrast=1.0)) - - def test_bold_plus_red_uses_bright(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[1;31m") - bright_red = PALETTES["ega"][9] - assert f"38;2;{bright_red[0]};{bright_red[1]};{bright_red[2]}" in result - - def test_red_fg_green_bg(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[31;42m") - fg_rgb = PALETTES["ega"][1] - bg_rgb = PALETTES["ega"][2] - assert f"38;2;{fg_rgb[0]};{fg_rgb[1]};{fg_rgb[2]}" in result - assert f"48;2;{bg_rgb[0]};{bg_rgb[1]};{bg_rgb[2]}" in result - - -class TestColorFilterBoldAsBright: - def _make_filter(self) -> ColorFilter: - return ColorFilter(ColorConfig(brightness=1.0, contrast=1.0)) - - def test_bold_black_uses_bright_black(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[1;30m") - bright_black = PALETTES["ega"][8] - assert f"38;2;{bright_black[0]};{bright_black[1]};{bright_black[2]}" in result - - def test_color_before_bold_in_same_seq(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[30;1m") - bright_black = PALETTES["ega"][8] - assert f"38;2;{bright_black[0]};{bright_black[1]};{bright_black[2]}" in result - - def test_bold_persists_across_sequences(self) -> None: - f = self._make_filter() - f.filter("\x1b[1m") - result = f.filter("\x1b[30m") - bright_black = PALETTES["ega"][8] - assert f"38;2;{bright_black[0]};{bright_black[1]};{bright_black[2]}" in result - - def test_bold_off_reverts_to_normal(self) -> None: - f = self._make_filter() - f.filter("\x1b[1m") - f.filter("\x1b[22m") - result = f.filter("\x1b[30m") - normal_black = PALETTES["ega"][0] - assert f"38;2;{normal_black[0]};{normal_black[1]};{normal_black[2]}" in result - - def test_reset_clears_bold(self) -> None: - f = self._make_filter() - f.filter("\x1b[1m") - f.filter("\x1b[0m") - result = f.filter("\x1b[30m") - normal_black = PALETTES["ega"][0] - assert f"38;2;{normal_black[0]};{normal_black[1]};{normal_black[2]}" in result - - def test_bold_does_not_affect_bright_colors(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[1;90m") - bright_black = PALETTES["ega"][8] - assert f"38;2;{bright_black[0]};{bright_black[1]};{bright_black[2]}" in result - - def test_bold_does_not_affect_background(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[1;40m") - normal_black = PALETTES["ega"][0] - assert f"48;2;{normal_black[0]};{normal_black[1]};{normal_black[2]}" in result - - @pytest.mark.parametrize( - "code,normal_idx", [(30, 0), (31, 1), (32, 2), (33, 3), (34, 4), (35, 5), (36, 6), (37, 7)] - ) - def test_all_bold_fg_use_bright_palette(self, code: int, normal_idx: int) -> None: - f = self._make_filter() - result = f.filter(f"\x1b[1;{code}m") - bright_rgb = PALETTES["ega"][normal_idx + 8] - assert f"38;2;{bright_rgb[0]};{bright_rgb[1]};{bright_rgb[2]}" in result - - -class TestColorFilterChunkedInput: - def _make_filter(self) -> ColorFilter: - return ColorFilter(ColorConfig(brightness=1.0, contrast=1.0)) - - def test_split_at_esc(self) -> None: - f = self._make_filter() - result1 = f.filter("hello\x1b") - assert "hello" in result1 - assert result1.endswith("hello") - result2 = f.filter("[31mworld") - rgb = PALETTES["ega"][1] - assert f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result2 - assert "world" in result2 - - def test_split_mid_params(self) -> None: - f = self._make_filter() - result1 = f.filter("hello\x1b[3") - assert "hello" in result1 - result2 = f.filter("1mworld") - rgb = PALETTES["ega"][1] - assert f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result2 - assert "world" in result2 - - def test_flush_returns_buffer(self) -> None: - f = self._make_filter() - f.filter("hello\x1b[3") - flushed = f.flush() - assert flushed == "\x1b[3" - - def test_flush_empty_when_no_buffer(self) -> None: - f = self._make_filter() - f.filter("hello") - assert not f.flush() - - -class TestColorFilterInitialBackground: - def test_first_output_has_background(self) -> None: - f = ColorFilter(ColorConfig(brightness=1.0, contrast=1.0, background_color=(16, 16, 16))) - result = f.filter("hello") - assert result.startswith("\x1b[48;2;16;16;16m") - assert result.endswith("hello") - - def test_second_output_no_extra_background(self) -> None: - f = ColorFilter(ColorConfig(brightness=1.0, contrast=1.0, background_color=(16, 16, 16))) - f.filter("hello") - result2 = f.filter("world") - assert not result2.startswith("\x1b[48;2;") - assert result2 == "world" - - -class TestColorFilterPlainText: - def test_plain_text_pass_through(self) -> None: - f = ColorFilter(ColorConfig(brightness=1.0, contrast=1.0)) - result = f.filter("hello world") - assert "hello world" in result - - def test_empty_string(self) -> None: - f = ColorFilter(ColorConfig(brightness=1.0, contrast=1.0)) - # First call sets initial, but empty input returns "" - result = f.filter("") - assert not result - - -class TestColorFilterReverseVideo: - def _make_filter(self) -> ColorFilter: - return ColorFilter( - ColorConfig( - brightness=1.0, contrast=1.0, reverse_video=True, background_color=(16, 16, 16) - ) - ) - - def test_fg_becomes_bg(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[31m") - rgb = PALETTES["ega"][1] - assert f"48;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result - - def test_bg_becomes_fg(self) -> None: - f = self._make_filter() - result = f.filter("\x1b[41m") - rgb = PALETTES["ega"][1] - assert f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result - - def test_background_is_inverted(self) -> None: - f = self._make_filter() - result = f.filter("x") - assert "\x1b[48;2;239;239;239m" in result - - -class TestColorFilterBrightnessContrast: - def test_reduced_brightness(self) -> None: - f = ColorFilter(ColorConfig(brightness=0.5, contrast=1.0)) - result = f.filter("\x1b[37m") - ega_white = PALETTES["ega"][7] - adjusted = _adjust_color(*ega_white, 0.5, 1.0) - assert f"38;2;{adjusted[0]};{adjusted[1]};{adjusted[2]}" in result - - def test_reduced_contrast(self) -> None: - f = ColorFilter(ColorConfig(brightness=1.0, contrast=0.5)) - result = f.filter("\x1b[31m") - ega_red = PALETTES["ega"][1] - adjusted = _adjust_color(*ega_red, 1.0, 0.5) - assert f"38;2;{adjusted[0]};{adjusted[1]};{adjusted[2]}" in result - - -class TestColorFilterCustomBackground: - def test_custom_background_in_reset(self) -> None: - f = ColorFilter(ColorConfig(brightness=1.0, contrast=1.0, background_color=(32, 32, 48))) - result = f.filter("\x1b[0m") - assert "\x1b[48;2;32;32;48m" in result - - def test_custom_background_on_initial(self) -> None: - f = ColorFilter(ColorConfig(brightness=1.0, contrast=1.0, background_color=(32, 32, 48))) - result = f.filter("hello") - assert result.startswith("\x1b[48;2;32;32;48m") - - -class TestColorFilterDifferentPalettes: - @pytest.mark.parametrize("name", [n for n in PALETTES if n != "c64"]) - def test_palette_red_foreground(self, name: str) -> None: - f = ColorFilter(ColorConfig(palette_name=name, brightness=1.0, contrast=1.0)) - result = f.filter("\x1b[31m") - rgb = PALETTES[name][1] - assert f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result - - -class TestPetsciiColorFilter: - def _make_filter(self, **kwargs: object) -> PetsciiColorFilter: - cfg = ColorConfig(brightness=1.0, contrast=1.0, **kwargs) # type: ignore[arg-type] - return PetsciiColorFilter(cfg) - - @pytest.mark.parametrize( - "ctrl_char,palette_idx", - [ - ("\x05", 1), - ("\x1c", 2), - ("\x1e", 5), - ("\x1f", 6), - ("\x81", 8), - ("\x90", 0), - ("\x95", 9), - ("\x96", 10), - ("\x97", 11), - ("\x98", 12), - ("\x99", 13), - ("\x9a", 14), - ("\x9b", 15), - ("\x9c", 4), - ("\x9e", 7), - ("\x9f", 3), - ], - ) - def test_color_code_to_24bit(self, ctrl_char: str, palette_idx: int) -> None: - f = self._make_filter() - result = f.filter(f"hello{ctrl_char}world") - rgb = PALETTES["c64"][palette_idx] - assert f"\x1b[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m" in result - assert ctrl_char not in result - assert "hello" in result - assert "world" in result - - def test_rvs_on(self) -> None: - f = self._make_filter() - result = f.filter("before\x12after") - assert "\x1b[7m" in result - assert "\x12" not in result - - def test_rvs_off(self) -> None: - f = self._make_filter() - result = f.filter("before\x92after") - assert "\x1b[27m" in result - assert "\x92" not in result - - def test_mixed_colors_and_rvs(self) -> None: - f = self._make_filter() - result = f.filter("\x1c\x12hello\x92\x05world") - red_rgb = PALETTES["c64"][2] - white_rgb = PALETTES["c64"][1] - assert f"\x1b[38;2;{red_rgb[0]};{red_rgb[1]};{red_rgb[2]}m" in result - assert "\x1b[7m" in result - assert "\x1b[27m" in result - assert f"\x1b[38;2;{white_rgb[0]};{white_rgb[1]};{white_rgb[2]}m" in result - assert "hello" in result - assert "world" in result - - def test_plain_text_unchanged(self) -> None: - f = self._make_filter() - assert f.filter("hello world") == "hello world" - - def test_non_petscii_control_chars_unchanged(self) -> None: - f = self._make_filter() - result = f.filter("A\x07B\x0bC") - assert "A\x07B\x0bC" == result - - def test_cursor_controls_translated(self) -> None: - f = self._make_filter() - assert f.filter("A\x13B") == "A\x1b[HB" - assert f.filter("A\x93B") == "A\x1b[2JB" - assert f.filter("A\x11B") == "A\x1b[BB" - assert f.filter("A\x91B") == "A\x1b[AB" - assert f.filter("A\x1dB") == "A\x1b[CB" - assert f.filter("A\x9dB") == "A\x1b[DB" - assert f.filter("A\x14B") == "A\x08\x1b[PB" - - def test_flush_returns_empty(self) -> None: - f = self._make_filter() - assert not f.flush() - - def test_brightness_contrast_applied(self) -> None: - f_full = PetsciiColorFilter(ColorConfig(brightness=1.0, contrast=1.0)) - f_dim = PetsciiColorFilter(ColorConfig(brightness=0.5, contrast=0.5)) - result_full = f_full.filter("\x1c") - result_dim = f_dim.filter("\x1c") - assert result_full != result_dim - - def test_default_config(self) -> None: - f = PetsciiColorFilter() - result = f.filter("\x1c") - assert "\x1b[38;2;" in result - - -class TestAtasciiControlFilter: - @pytest.mark.parametrize( - "glyph,expected", - [ - ("\u25c0", "\x08\x1b[P"), - ("\u25b6", "\t"), - ("\u21b0", "\x1b[2J\x1b[H"), - ("\u2191", "\x1b[A"), - ("\u2193", "\x1b[B"), - ("\u2190", "\x1b[D"), - ("\u2192", "\x1b[C"), - ], - ) - def test_control_glyph_translated(self, glyph: str, expected: str) -> None: - f = AtasciiControlFilter() - result = f.filter(f"before{glyph}after") - assert f"before{expected}after" == result - - def test_backspace_erases(self) -> None: - f = AtasciiControlFilter() - result = f.filter("DINGO\u25c0\u25c0\u25c0\u25c0\u25c0") - assert result == "DINGO" + "\x08\x1b[P" * 5 - - def test_plain_text_unchanged(self) -> None: - f = AtasciiControlFilter() - assert f.filter("hello world") == "hello world" - - def test_atascii_graphics_unchanged(self) -> None: - f = AtasciiControlFilter() - text = "\u2663\u2665\u2666\u2660" - assert f.filter(text) == text - - def test_flush_returns_empty(self) -> None: - f = AtasciiControlFilter() - assert not f.flush() - - def test_multiple_controls_in_one_string(self) -> None: - f = AtasciiControlFilter() - result = f.filter("\u2191\u2193\u2190\u2192") - assert result == "\x1b[A\x1b[B\x1b[D\x1b[C" +def test_all_expected_palettes_exist() -> None: + assert set(PALETTES.keys()) == {"vga", "xterm", "c64"} + + +def test_color_config_defaults() -> None: + cfg = ColorConfig() + assert cfg.palette_name == "vga" + assert cfg.brightness == 1.0 + assert cfg.contrast == 1.0 + assert cfg.background_color == (0, 0, 0) + assert cfg.ice_colors is True + + +@pytest.mark.parametrize( + "code,expected", + [ + (30, 0), + (31, 1), + (32, 2), + (33, 3), + (34, 4), + (35, 5), + (36, 6), + (37, 7), + (40, 0), + (41, 1), + (42, 2), + (43, 3), + (44, 4), + (45, 5), + (46, 6), + (47, 7), + (90, 8), + (91, 9), + (92, 10), + (93, 11), + (94, 12), + (95, 13), + (96, 14), + (97, 15), + (100, 8), + (101, 9), + (102, 10), + (103, 11), + (104, 12), + (105, 13), + (106, 14), + (107, 15), + ], +) +def test_color_code_maps_to_palette_index(code: int, expected: int) -> None: + assert _sgr_code_to_palette_index(code) == expected + + +@pytest.mark.parametrize("code", [0, 1, 4, 7, 22, 38, 39, 48, 49, 128]) +def test_non_color_returns_none(code: int) -> None: + assert _sgr_code_to_palette_index(code) is None + + +@pytest.mark.parametrize("code", list(range(30, 38)) + list(range(90, 98))) +def test_is_foreground_code_true(code: int) -> None: + assert _is_foreground_code(code) is True + + +@pytest.mark.parametrize("code", list(range(40, 48)) + list(range(100, 108))) +def test_is_foreground_code_false(code: int) -> None: + assert _is_foreground_code(code) is False + + +@pytest.mark.parametrize( + "r,g,b,brightness,contrast,expected", + [ + (170, 85, 0, 1.0, 1.0, (170, 85, 0)), + (200, 100, 50, 1.0, 0.0, (128, 128, 128)), + (200, 100, 50, 0.0, 1.0, (0, 0, 0)), + (200, 100, 0, 0.5, 1.0, (100, 50, 0)), + ], +) +def test_adjust_color_values( + r: int, g: int, b: int, brightness: float, contrast: float, expected: tuple[int, int, int] +) -> None: + assert _adjust_color(r, g, b, brightness, contrast) == expected + + +def test_adjust_color_clamp_high() -> None: + r, _, _ = _adjust_color(255, 255, 255, 1.0, 2.0) + assert r == 255 + + +def test_adjust_color_clamp_low() -> None: + r, _, _ = _adjust_color(0, 0, 0, 1.0, 2.0) + assert r == 0 + + +def test_adjust_color_in_range() -> None: + r, g, b = _adjust_color(170, 0, 0, 0.9, 0.8) + assert 0 <= r <= 255 + assert 0 <= g <= 255 + assert 0 <= b <= 255 + + +@pytest.mark.parametrize( + "sgr,palette_idx,prefix", + [("31", 1, "38;2"), ("41", 1, "48;2"), ("91", 9, "38;2"), ("101", 9, "48;2")], +) +def test_color_filter_basic_translation(sgr: str, palette_idx: int, prefix: str) -> None: + f = _make_filter() + result = f.filter(f"\x1b[{sgr}m") + rgb = PALETTES["vga"][palette_idx] + assert f"{prefix};{rgb[0]};{rgb[1]};{rgb[2]}" in result + + +@pytest.mark.parametrize( + "code,idx", [(30, 0), (31, 1), (32, 2), (33, 3), (34, 4), (35, 5), (36, 6), (37, 7)] +) +def test_all_normal_foreground_colors(code: int, idx: int) -> None: + f = _make_filter() + result = f.filter(f"\x1b[{code}m") + rgb = PALETTES["vga"][idx] + assert f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result + + +@pytest.mark.parametrize( + "code,idx", [(40, 0), (41, 1), (42, 2), (43, 3), (44, 4), (45, 5), (46, 6), (47, 7)] +) +def test_all_normal_background_colors(code: int, idx: int) -> None: + f = _make_filter() + result = f.filter(f"\x1b[{code}m") + rgb = PALETTES["vga"][idx] + assert f"48;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result + + +def test_explicit_reset() -> None: + f = _make_filter(background_color=(0, 0, 0)) + result = f.filter("\x1b[0m") + assert "48;2;0;0;0" in result + assert "38;2;170;170;170" in result + + +def test_empty_reset() -> None: + f = _make_filter(background_color=(0, 0, 0)) + result = f.filter("\x1b[m") + assert "\x1b[0m" in result + assert "48;2;0;0;0" in result + assert "38;2;170;170;170" in result + + +def test_reset_in_compound_sequence() -> None: + f = _make_filter(background_color=(0, 0, 0)) + assert "48;2;0;0;0" in f.filter("\x1b[0;31m") + + +def test_reset_with_bg_preserves_explicit_bg() -> None: + """SGR 0;30;42 must not override green bg with configured black bg.""" + f = _make_filter(background_color=(0, 0, 0)) + result = f.filter("\x1b[0;30;42m") + assert "48;2;0;170;0" in result + last_bg = result.rfind("48;2;") + assert result[last_bg:].startswith("48;2;0;170;0") + + +def test_reset_with_fg_preserves_explicit_fg() -> None: + """SGR 0;31 must use red, not the injected default white.""" + f = _make_filter(background_color=(0, 0, 0)) + result = f.filter("\x1b[0;31m") + last_fg = result.rfind("38;2;") + assert result[last_fg:].startswith("38;2;170;0;0") + + +def test_bold_after_reset_emits_bright_white() -> None: + """ESC[0m ESC[1m should produce bright white (palette 15).""" + f = _make_filter(background_color=(0, 0, 0)) + f.filter("\x1b[0m") + assert "38;2;255;255;255" in f.filter("\x1b[1m") + + +def test_bold_after_explicit_fg_emits_bright_color() -> None: + """ESC[31m ESC[1m should produce bright red (palette 9).""" + f = _make_filter(background_color=(0, 0, 0)) + f.filter("\x1b[31m") + assert "38;2;255;85;85" in f.filter("\x1b[1m") + + +def test_unbold_restores_normal_fg() -> None: + """ESC[31m ESC[1m ESC[22m should restore normal red (palette 1).""" + f = _make_filter(background_color=(0, 0, 0)) + f.filter("\x1b[31m") + f.filter("\x1b[1m") + assert "38;2;170;0;0" in f.filter("\x1b[22m") + + +def test_bold_with_explicit_fg_in_same_seq_no_double_inject() -> None: + """ESC[1;31m should not inject default bright fg.""" + f = _make_filter(background_color=(0, 0, 0)) + result = f.filter("\x1b[1;31m") + assert "255;85;85" in result + assert "255;255;255" not in result + + +@pytest.mark.parametrize( + "seq,needle", + [ + ("\x1b[38;5;196m", "38;5;196"), + ("\x1b[38;2;100;200;50m", "38;2;100;200;50"), + ("\x1b[48;5;42m", "48;5;42"), + ("\x1b[48;2;10;20;30m", "48;2;10;20;30"), + ], +) +def test_extended_color_pass_through(seq: str, needle: str) -> None: + f = _make_filter() + assert needle in f.filter(seq) + + +def test_bold_emits_bright_default_fg() -> None: + f = _make_filter() + result = f.filter("\x1b[1m") + assert "1" in result + assert "38;2;255;255;255" in result + + +@pytest.mark.parametrize( + "seq,needle", + [ + ("\x1b[4m", "\x1b[4m"), + ("\x1b[2J", "\x1b[2J"), + ("\x1b[H", "\x1b[H"), + ("\x1b[38:2::255:0:0m", "\x1b[38:2::255:0:0m"), + ], +) +def test_non_sgr_pass_through(seq: str, needle: str) -> None: + f = _make_filter() + assert needle in f.filter(seq) + + +@pytest.mark.parametrize( + "sgr_code,expected_prefix", [("39", "38;2;170;170;170"), ("49", "48;2;0;0;0")] +) +def test_default_color_translated(sgr_code: str, expected_prefix: str) -> None: + f = _make_filter() + assert expected_prefix in f.filter(f"\x1b[{sgr_code}m") + + +def test_bold_plus_red_uses_bright() -> None: + f = _make_filter() + result = f.filter("\x1b[1;31m") + bright_red = PALETTES["vga"][9] + assert f"38;2;{bright_red[0]};{bright_red[1]};{bright_red[2]}" in result + + +def test_red_fg_green_bg() -> None: + f = _make_filter() + result = f.filter("\x1b[31;42m") + fg_rgb = PALETTES["vga"][1] + bg_rgb = PALETTES["vga"][2] + assert f"38;2;{fg_rgb[0]};{fg_rgb[1]};{fg_rgb[2]}" in result + assert f"48;2;{bg_rgb[0]};{bg_rgb[1]};{bg_rgb[2]}" in result + + +@pytest.mark.parametrize("seq", ["\x1b[1;30m", "\x1b[30;1m"]) +def test_bold_single_seq_uses_bright(seq: str) -> None: + f = _make_filter() + result = f.filter(seq) + bright_black = PALETTES["vga"][8] + assert f"38;2;{bright_black[0]};{bright_black[1]};{bright_black[2]}" in result + + +@pytest.mark.parametrize( + "setup_seqs,test_seq,palette_idx", + [ + (["\x1b[1m"], "\x1b[30m", 8), + (["\x1b[1m", "\x1b[22m"], "\x1b[30m", 0), + (["\x1b[1m", "\x1b[0m"], "\x1b[30m", 0), + ], +) +def test_bold_state_across_sequences( + setup_seqs: list[str], test_seq: str, palette_idx: int +) -> None: + f = _make_filter() + for seq in setup_seqs: + f.filter(seq) + result = f.filter(test_seq) + rgb = PALETTES["vga"][palette_idx] + assert f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result + + +def test_bold_does_not_affect_bright_colors() -> None: + f = _make_filter() + result = f.filter("\x1b[1;90m") + bright_black = PALETTES["vga"][8] + assert f"38;2;{bright_black[0]};{bright_black[1]};{bright_black[2]}" in result + + +def test_bold_does_not_affect_background() -> None: + f = _make_filter() + result = f.filter("\x1b[1;40m") + normal_black = PALETTES["vga"][0] + assert f"48;2;{normal_black[0]};{normal_black[1]};{normal_black[2]}" in result + + +@pytest.mark.parametrize( + "code,normal_idx", [(30, 0), (31, 1), (32, 2), (33, 3), (34, 4), (35, 5), (36, 6), (37, 7)] +) +def test_all_bold_fg_use_bright_palette(code: int, normal_idx: int) -> None: + f = _make_filter() + result = f.filter(f"\x1b[1;{code}m") + bright_rgb = PALETTES["vga"][normal_idx + 8] + assert f"38;2;{bright_rgb[0]};{bright_rgb[1]};{bright_rgb[2]}" in result + + +def test_reset_bold_color_in_same_seq() -> None: + f = _make_filter() + result = f.filter("\x1b[0;1;34m") + bright_blue = PALETTES["vga"][12] + assert f"38;2;{bright_blue[0]};{bright_blue[1]};{bright_blue[2]}" in result + + +def _make_ice_filter(ice_colors: bool = True) -> ColorFilter: + return ColorFilter(ColorConfig(brightness=1.0, contrast=1.0, ice_colors=ice_colors)) + + +@pytest.mark.parametrize("seq", ["\x1b[5;40m", "\x1b[40;5m"]) +def test_ice_blink_single_seq_uses_bright(seq: str) -> None: + f = _make_ice_filter() + result = f.filter(seq) + bright_black = PALETTES["vga"][8] + assert f"48;2;{bright_black[0]};{bright_black[1]};{bright_black[2]}" in result + + +@pytest.mark.parametrize( + "setup_seqs,test_seq,palette_idx", + [ + (["\x1b[5m"], "\x1b[40m", 8), + (["\x1b[5m", "\x1b[25m"], "\x1b[40m", 0), + (["\x1b[5m", "\x1b[0m"], "\x1b[40m", 0), + ], +) +def test_ice_blink_state_across_sequences( + setup_seqs: list[str], test_seq: str, palette_idx: int +) -> None: + f = _make_ice_filter() + for seq in setup_seqs: + f.filter(seq) + result = f.filter(test_seq) + rgb = PALETTES["vga"][palette_idx] + assert f"48;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result + + +def test_ice_blink_does_not_affect_foreground() -> None: + f = _make_ice_filter() + result = f.filter("\x1b[5;30m") + normal_black = PALETTES["vga"][0] + assert f"38;2;{normal_black[0]};{normal_black[1]};{normal_black[2]}" in result + + +def test_ice_blink_does_not_affect_bright_bg() -> None: + f = _make_ice_filter() + result = f.filter("\x1b[5;100m") + bright_black = PALETTES["vga"][8] + assert f"48;2;{bright_black[0]};{bright_black[1]};{bright_black[2]}" in result + + +@pytest.mark.parametrize( + "code,normal_idx", [(40, 0), (41, 1), (42, 2), (43, 3), (44, 4), (45, 5), (46, 6), (47, 7)] +) +def test_all_blink_bg_use_bright_palette(code: int, normal_idx: int) -> None: + f = _make_ice_filter() + result = f.filter(f"\x1b[5;{code}m") + bright_rgb = PALETTES["vga"][normal_idx + 8] + assert f"48;2;{bright_rgb[0]};{bright_rgb[1]};{bright_rgb[2]}" in result + + +def test_ice_reset_blink_bg_in_same_seq() -> None: + f = _make_ice_filter() + result = f.filter("\x1b[0;5;41m") + bright_red = PALETTES["vga"][9] + assert f"48;2;{bright_red[0]};{bright_red[1]};{bright_red[2]}" in result + + +def test_ice_colors_disabled() -> None: + f = _make_ice_filter(ice_colors=False) + f.filter("x") + result = f.filter("\x1b[5;40m") + normal_black = PALETTES["vga"][0] + assert f"48;2;{normal_black[0]};{normal_black[1]};{normal_black[2]}" in result + params = result.split("\x1b[")[1].split("m")[0] + assert "5" in params.split(";") + + +def test_chunked_split_at_esc() -> None: + f = _make_filter() + result1 = f.filter("hello\x1b") + assert "hello" in result1 + assert result1.endswith("hello") + result2 = f.filter("[31mworld") + rgb = PALETTES["vga"][1] + assert f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result2 + assert "world" in result2 + + +def test_chunked_split_mid_params() -> None: + f = _make_filter() + result1 = f.filter("hello\x1b[3") + assert "hello" in result1 + result2 = f.filter("1mworld") + rgb = PALETTES["vga"][1] + assert f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result2 + assert "world" in result2 + + +def test_chunked_flush_returns_buffer() -> None: + f = _make_filter() + f.filter("hello\x1b[3") + assert f.flush() == "\x1b[3" + + +def test_chunked_flush_empty_when_no_buffer() -> None: + f = _make_filter() + f.filter("hello") + assert not f.flush() + + +def test_color_filter_initial_background_first_output() -> None: + f = ColorFilter(ColorConfig(brightness=1.0, contrast=1.0, background_color=(0, 0, 0))) + result = f.filter("hello") + assert result.startswith("\x1b[48;2;0;0;0m") + assert result.endswith("hello") + + +def test_color_filter_initial_background_second_output() -> None: + f = ColorFilter(ColorConfig(brightness=1.0, contrast=1.0, background_color=(0, 0, 0))) + f.filter("hello") + result2 = f.filter("world") + assert not result2.startswith("\x1b[48;2;") + assert result2 == "world" + + +def test_color_filter_plain_text_pass_through() -> None: + f = _make_filter() + assert "hello world" in f.filter("hello world") + + +def test_color_filter_empty_string() -> None: + f = _make_filter() + assert not f.filter("") + + +def test_color_filter_reduced_brightness() -> None: + f = ColorFilter(ColorConfig(brightness=0.5, contrast=1.0)) + result = f.filter("\x1b[37m") + ega_white = PALETTES["vga"][7] + adjusted = _adjust_color(*ega_white, 0.5, 1.0) + assert f"38;2;{adjusted[0]};{adjusted[1]};{adjusted[2]}" in result + + +def test_color_filter_reduced_contrast() -> None: + f = ColorFilter(ColorConfig(brightness=1.0, contrast=0.5)) + result = f.filter("\x1b[31m") + ega_red = PALETTES["vga"][1] + adjusted = _adjust_color(*ega_red, 1.0, 0.5) + assert f"38;2;{adjusted[0]};{adjusted[1]};{adjusted[2]}" in result + + +def test_color_filter_custom_background_in_reset() -> None: + f = ColorFilter(ColorConfig(brightness=1.0, contrast=1.0, background_color=(32, 32, 48))) + assert "\x1b[48;2;32;32;48m" in f.filter("\x1b[0m") + + +def test_color_filter_custom_background_on_initial() -> None: + f = ColorFilter(ColorConfig(brightness=1.0, contrast=1.0, background_color=(32, 32, 48))) + assert f.filter("hello").startswith("\x1b[48;2;32;32;48m") + + +@pytest.mark.parametrize("name", [n for n in PALETTES if n != "c64"]) +def test_color_filter_palette_red_foreground(name: str) -> None: + f = ColorFilter(ColorConfig(palette_name=name, brightness=1.0, contrast=1.0)) + result = f.filter("\x1b[31m") + rgb = PALETTES[name][1] + assert f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}" in result + + +def _make_petscii_filter(**kwargs: object) -> PetsciiColorFilter: + cfg = ColorConfig(brightness=1.0, contrast=1.0, **kwargs) # type: ignore[arg-type] + return PetsciiColorFilter(cfg) + + +@pytest.mark.parametrize( + "ctrl_char,palette_idx", + [ + ("\x05", 1), + ("\x1c", 2), + ("\x1e", 5), + ("\x1f", 6), + ("\x81", 8), + ("\x90", 0), + ("\x95", 9), + ("\x96", 10), + ("\x97", 11), + ("\x98", 12), + ("\x99", 13), + ("\x9a", 14), + ("\x9b", 15), + ("\x9c", 4), + ("\x9e", 7), + ("\x9f", 3), + ], +) +def test_petscii_color_code_to_24bit(ctrl_char: str, palette_idx: int) -> None: + f = _make_petscii_filter() + result = f.filter(f"hello{ctrl_char}world") + rgb = PALETTES["c64"][palette_idx] + assert f"\x1b[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m" in result + assert ctrl_char not in result + assert "hello" in result + assert "world" in result + + +def test_petscii_rvs_on() -> None: + f = _make_petscii_filter() + result = f.filter("before\x12after") + assert "\x1b[7m" in result + assert "\x12" not in result + + +def test_petscii_rvs_off() -> None: + f = _make_petscii_filter() + result = f.filter("before\x92after") + assert "\x1b[27m" in result + assert "\x92" not in result + + +def test_petscii_mixed_colors_and_rvs() -> None: + f = _make_petscii_filter() + result = f.filter("\x1c\x12hello\x92\x05world") + red_rgb = PALETTES["c64"][2] + white_rgb = PALETTES["c64"][1] + assert f"\x1b[38;2;{red_rgb[0]};{red_rgb[1]};{red_rgb[2]}m" in result + assert "\x1b[7m" in result + assert "\x1b[27m" in result + assert f"\x1b[38;2;{white_rgb[0]};{white_rgb[1]};{white_rgb[2]}m" in result + assert "hello" in result + assert "world" in result + + +def test_petscii_plain_text_unchanged() -> None: + f = _make_petscii_filter() + assert f.filter("hello world") == "hello world" + + +def test_petscii_non_petscii_control_chars_unchanged() -> None: + f = _make_petscii_filter() + assert f.filter("A\x07B\x0bC") == "A\x07B\x0bC" + + +@pytest.mark.parametrize( + "ctrl_char,expected", + [ + ("\x13", "\x1b[H"), + ("\x93", "\x1b[2J"), + ("\x11", "\x1b[B"), + ("\x91", "\x1b[A"), + ("\x1d", "\x1b[C"), + ("\x9d", "\x1b[D"), + ("\x14", "\x08\x1b[P"), + ], +) +def test_petscii_cursor_controls_translated(ctrl_char: str, expected: str) -> None: + f = _make_petscii_filter() + assert f.filter(f"A{ctrl_char}B") == f"A{expected}B" + + +def test_petscii_flush_returns_empty() -> None: + f = _make_petscii_filter() + assert not f.flush() + + +def test_petscii_brightness_contrast_applied() -> None: + f_full = PetsciiColorFilter(ColorConfig(brightness=1.0, contrast=1.0)) + f_dim = PetsciiColorFilter(ColorConfig(brightness=0.5, contrast=0.5)) + assert f_full.filter("\x1c") != f_dim.filter("\x1c") + + +def test_petscii_default_config() -> None: + f = PetsciiColorFilter() + assert "\x1b[38;2;" in f.filter("\x1c") + + +@pytest.mark.parametrize( + "glyph,expected", + [ + ("\u25c0", "\x08\x1b[P"), + ("\u25b6", "\t"), + ("\u21b0", "\x1b[2J\x1b[H"), + ("\u2191", "\x1b[A"), + ("\u2193", "\x1b[B"), + ("\u2190", "\x1b[D"), + ("\u2192", "\x1b[C"), + ], +) +def test_atascii_control_glyph_translated(glyph: str, expected: str) -> None: + f = AtasciiControlFilter() + assert f.filter(f"before{glyph}after") == f"before{expected}after" + + +def test_atascii_backspace_erases() -> None: + f = AtasciiControlFilter() + assert f.filter("DINGO\u25c0\u25c0\u25c0\u25c0\u25c0") == ("DINGO" + "\x08\x1b[P" * 5) + + +def test_atascii_plain_text_unchanged() -> None: + f = AtasciiControlFilter() + assert f.filter("hello world") == "hello world" + + +def test_atascii_graphics_unchanged() -> None: + f = AtasciiControlFilter() + text = "\u2663\u2665\u2666\u2660" + assert f.filter(text) == text + + +def test_atascii_flush_returns_empty() -> None: + f = AtasciiControlFilter() + assert not f.flush() + + +def test_atascii_multiple_controls_in_one_string() -> None: + f = AtasciiControlFilter() + assert f.filter("\u2191\u2193\u2190\u2192") == "\x1b[A\x1b[B\x1b[D\x1b[C" diff --git a/telnetlib3/tests/test_core.py b/telnetlib3/tests/test_core.py index fde7458c..2a318b98 100644 --- a/telnetlib3/tests/test_core.py +++ b/telnetlib3/tests/test_core.py @@ -14,13 +14,11 @@ # local import telnetlib3 -from telnetlib3.telopt import DO, SB, IAC, SGA, NAWS, WILL, WONT, TTYPE, BINARY, CHARSET +from telnetlib3.telopt import DO, SB, IAC, SGA, ECHO, NAWS, WILL, WONT, TTYPE, BINARY, CHARSET from telnetlib3.tests.accessories import ( - bind_host, create_server, asyncio_server, open_connection, - unused_tcp_port, asyncio_connection, ) @@ -56,7 +54,6 @@ def connection_made(self, transport): async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): await asyncio.sleep(0.01) assert call_tracker["called"] - # Close server-side transport before server closes if call_tracker["transport"]: call_tracker["transport"].close() await asyncio.sleep(0) @@ -79,10 +76,7 @@ async def test_telnet_server_open_close(bind_host, unused_tcp_port): async def test_telnet_client_open_close_by_write(bind_host, unused_tcp_port): """Exercise BaseClient.connection_lost() on writer closed.""" async with asyncio_server(asyncio.Protocol, bind_host, unused_tcp_port): - async with open_connection(host=bind_host, port=unused_tcp_port, connect_minwait=0.05) as ( - reader, - writer, - ): + async with open_connection(host=bind_host, port=unused_tcp_port) as (reader, writer): writer.close() await writer.wait_closed() assert not await reader.read() @@ -94,17 +88,11 @@ async def test_telnet_client_open_closed_by_peer(bind_host, unused_tcp_port): class DisconnecterProtocol(asyncio.Protocol): def connection_made(self, transport): - # disconnect on connect transport.close() async with asyncio_server(DisconnecterProtocol, bind_host, unused_tcp_port): - async with open_connection(host=bind_host, port=unused_tcp_port, connect_minwait=0.05) as ( - reader, - writer, - ): - # read until EOF, no data received. - data_received = await reader.read() - assert not data_received + async with open_connection(host=bind_host, port=unused_tcp_port) as (reader, writer): + assert not await reader.read() async def test_telnet_server_advanced_negotiation(bind_host, unused_tcp_port): @@ -125,13 +113,8 @@ def begin_advanced_negotiation(self): assert srv_instance.writer.remote_option[TTYPE] is True assert srv_instance.writer.pending_option == { - # server's request to negotiation TTYPE affirmed DO + TTYPE: False, - # server's request for TTYPE value unreplied SB + TTYPE: True, - # remaining unreplied values from begin_advanced_negotiation() - # DO NEW_ENVIRON is deferred until TTYPE cycle completes - # WILL ECHO is deferred until TTYPE reveals client identity DO + CHARSET: True, DO + NAWS: True, WILL + SGA: True, @@ -139,35 +122,56 @@ def begin_advanced_negotiation(self): } -async def test_telnet_server_closed_by_client(bind_host, unused_tcp_port): - """Exercise TelnetServer.connection_lost.""" - async with create_server(host=bind_host, port=unused_tcp_port) as server: +@pytest.mark.parametrize("option,trigger_echo", [(SGA, False), (ECHO, True)]) +async def test_line_mode_skips_will_option(bind_host, unused_tcp_port, option, trigger_echo): + """Server with line_mode=True does not send WILL SGA or WILL ECHO.""" + _waiter = asyncio.Future() + + class ServerTestLineMode(telnetlib3.TelnetServer): + def begin_advanced_negotiation(self): + super().begin_advanced_negotiation() + _waiter.set_result(self) + + async with create_server( + protocol_factory=ServerTestLineMode, host=bind_host, port=unused_tcp_port, line_mode=True + ): async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): - # Read server's negotiation request and send minimal reply - expect_hello = IAC + DO + TTYPE - hello = await reader.readexactly(len(expect_hello)) - assert hello == expect_hello - writer.write(IAC + WONT + TTYPE) + writer.write(IAC + WILL + TTYPE) + srv_instance = await asyncio.wait_for(_waiter, 0.5) - srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) + if trigger_echo: + srv_instance._negotiate_echo() - # Verify negotiation state: client refused TTYPE - assert srv_instance.writer.remote_option[TTYPE] is False - assert srv_instance.writer.pending_option.get(TTYPE) is not True + pending = srv_instance.writer.pending_option + assert WILL + option not in pending - writer.close() - await writer.wait_closed() - # Wait for server to notice client disconnect - await asyncio.sleep(0.05) - assert srv_instance._closing +async def test_default_sends_will_sga(bind_host, unused_tcp_port): + """Default server (line_mode=False) sends WILL SGA.""" + _waiter = asyncio.Future() + + class ServerTestDefault(telnetlib3.TelnetServer): + def begin_advanced_negotiation(self): + super().begin_advanced_negotiation() + _waiter.set_result(self) + + async with create_server( + protocol_factory=ServerTestDefault, host=bind_host, port=unused_tcp_port + ): + async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + writer.write(IAC + WILL + TTYPE) + srv_instance = await asyncio.wait_for(_waiter, 0.5) + + pending = srv_instance.writer.pending_option + assert WILL + SGA in pending + assert pending[WILL + SGA] is True -async def test_telnet_server_eof_by_client(bind_host, unused_tcp_port): - """Exercise TelnetServer.eof_received().""" +@pytest.mark.parametrize("use_eof", [False, True]) +async def test_telnet_server_disconnect_by_client(bind_host, unused_tcp_port, use_eof): + """Exercise TelnetServer.connection_lost and eof_received.""" async with create_server(host=bind_host, port=unused_tcp_port) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): - # Read server's negotiation request and send minimal reply expect_hello = IAC + DO + TTYPE hello = await reader.readexactly(len(expect_hello)) assert hello == expect_hello @@ -175,13 +179,15 @@ async def test_telnet_server_eof_by_client(bind_host, unused_tcp_port): srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) - # Verify negotiation state: client refused TTYPE assert srv_instance.writer.remote_option[TTYPE] is False assert srv_instance.writer.pending_option.get(TTYPE) is not True - writer.write_eof() + if use_eof: + writer.write_eof() + else: + writer.close() + await writer.wait_closed() - # Wait for server to notice EOF await asyncio.sleep(0.05) assert srv_instance._closing @@ -199,18 +205,15 @@ async def test_telnet_server_closed_by_server(bind_host, unused_tcp_port): writer.write(hello_reply) srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) - # Verify negotiation state: client refused TTYPE assert srv_instance.writer.remote_option[TTYPE] is False assert srv_instance.writer.pending_option.get(TTYPE) is not True - # Verify in-band data was received data = await asyncio.wait_for(srv_instance.reader.readline(), 0.5) assert data == "quit\r\n" srv_instance.writer.close() await srv_instance.writer.wait_closed() - # Wait for server to process connection close await asyncio.sleep(0.05) assert srv_instance._closing @@ -240,7 +243,7 @@ async def test_telnet_client_idle_duration_minwait(bind_host, unused_tcp_port): ) as (reader, writer): elapsed_ms = int((time.time() - stime) * 1e3) expected_ms = int(given_minwait * 1e3) - assert expected_ms <= elapsed_ms <= expected_ms + 50 + assert expected_ms <= elapsed_ms <= expected_ms + 150 assert 0 <= writer.protocol.idle <= 0.5 assert 0 <= writer.protocol.duration <= 0.5 @@ -270,10 +273,7 @@ class GivenException(Exception): pass async with asyncio_server(asyncio.Protocol, bind_host, unused_tcp_port): - async with open_connection(host=bind_host, port=unused_tcp_port, connect_minwait=0.05) as ( - reader, - writer, - ): + async with open_connection(host=bind_host, port=unused_tcp_port) as (reader, writer): writer.protocol.connection_lost(GivenException("candy corn 4 everyone")) with pytest.raises(GivenException): await reader.read() @@ -281,7 +281,7 @@ class GivenException(Exception): async def test_telnet_server_negotiation_fail(bind_host, unused_tcp_port): """Test telnetlib3.TelnetServer() negotiation failure with client.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): await reader.readexactly(3) # IAC DO TTYPE, we ignore it! @@ -319,7 +319,7 @@ def connection_made(self, transport): ) as (reader, writer): elapsed_ms = int((time.time() - stime) * 1e3) expected_ms = int(given_maxwait * 1e3) - assert expected_ms <= elapsed_ms <= expected_ms + 50 + assert expected_ms <= elapsed_ms <= expected_ms + 150 async def test_telnet_server_as_module(): @@ -330,7 +330,6 @@ async def test_telnet_server_as_module(): *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) - # we would expect the script to display help output and exit help_output, _ = await proc.communicate() assert b"usage:" in help_output and b"server" in help_output await proc.wait() @@ -376,7 +375,6 @@ async def test_telnet_client_as_module(): *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) - # we would expect the script to display help output and exit help_output, _ = await proc.communicate() assert b"usage:" in help_output and b"client" in help_output await proc.wait() @@ -391,7 +389,6 @@ async def test_telnet_client_cmdline(bind_host, unused_tcp_port): bind_host, str(unused_tcp_port), "--loglevel=info", - "--connect-minwait=0.05", "--connect-maxwait=0.05", "--colormatch=none", ] @@ -434,7 +431,6 @@ async def test_telnet_client_tty_cmdline(bind_host, unused_tcp_port): bind_host, str(unused_tcp_port), "--loglevel=warning", - "--connect-minwait=0.05", "--connect-maxwait=0.05", "--colormatch=none", ] @@ -448,7 +444,8 @@ def connection_made(self, transport): async with asyncio_server(HelloServer, bind_host, unused_tcp_port): proc = pexpect.spawn(prog, args) await proc.expect(pexpect.EOF, async_=True, timeout=5) - assert proc.before == ( + normalized = proc.before.replace(b"\r\r\n", b"\r\n") + assert normalized == ( b"Escape character is '^]'.\r\n" b"hello, space cadet.\r\n" b"\x1b[m\r\n" @@ -468,7 +465,6 @@ async def test_telnet_client_cmdline_stdin_pipe(bind_host, unused_tcp_port): bind_host, str(unused_tcp_port), "--loglevel=info", - "--connect-minwait=0.15", "--connect-maxwait=0.15", f"--logfile={logfile}", "--colormatch=none", @@ -484,7 +480,7 @@ async def shell(reader, writer): await writer.wait_closed() async with create_server( - host=bind_host, port=unused_tcp_port, shell=shell, connect_maxwait=0.05 + host=bind_host, port=unused_tcp_port, shell=shell, connect_maxwait=0.5 ): proc = await asyncio.create_subprocess_exec( *args, diff --git a/telnetlib3/tests/test_encoding.py b/telnetlib3/tests/test_encoding.py index 0b3b9aa4..f79a52c4 100644 --- a/telnetlib3/tests/test_encoding.py +++ b/telnetlib3/tests/test_encoding.py @@ -11,18 +11,16 @@ import telnetlib3.stream_writer from telnetlib3.telopt import DO, IS, SB, SE, IAC, WILL, WONT, TTYPE, BINARY, NEW_ENVIRON from telnetlib3.tests.accessories import ( - bind_host, create_server, asyncio_server, open_connection, - unused_tcp_port, asyncio_connection, ) async def test_telnet_server_encoding_default(bind_host, unused_tcp_port): """Default encoding US-ASCII unless it can be negotiated/confirmed!""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WONT + TTYPE) @@ -37,10 +35,7 @@ async def test_telnet_server_encoding_default(bind_host, unused_tcp_port): async def test_telnet_client_encoding_default(bind_host, unused_tcp_port): """Default encoding US-ASCII unless it can be negotiated/confirmed!""" async with asyncio_server(asyncio.Protocol, bind_host, unused_tcp_port): - async with open_connection(host=bind_host, port=unused_tcp_port, connect_minwait=0.05) as ( - reader, - writer, - ): + async with open_connection(host=bind_host, port=unused_tcp_port) as (reader, writer): assert writer.protocol.encoding(incoming=True) == "US-ASCII" assert writer.protocol.encoding(outgoing=True) == "US-ASCII" assert writer.protocol.encoding(incoming=True, outgoing=True) == "US-ASCII" @@ -77,13 +72,13 @@ async def test_telnet_server_encoding_server_do(bind_host, unused_tcp_port): async def test_telnet_server_encoding_bidirectional(bind_host, unused_tcp_port): """Server's default encoding with bi-directional BINARY negotiation.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=2.0) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + DO + BINARY) writer.write(IAC + WILL + BINARY) writer.write(IAC + WONT + TTYPE) - srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 5.0) assert srv_instance.encoding(incoming=True) == "utf8" assert srv_instance.encoding(outgoing=True) == "utf8" assert srv_instance.encoding(incoming=True, outgoing=True) == "utf8" @@ -94,9 +89,10 @@ async def test_telnet_client_and_server_encoding_bidirectional(bind_host, unused async with create_server( host=bind_host, port=unused_tcp_port, encoding="latin1", connect_maxwait=1.0 ) as server: - async with open_connection( - host=bind_host, port=unused_tcp_port, encoding="cp437", connect_minwait=1.0 - ) as (reader, writer): + async with open_connection(host=bind_host, port=unused_tcp_port, encoding="cp437") as ( + reader, + writer, + ): srv_instance = await asyncio.wait_for(server.wait_for_client(), 1.5) assert srv_instance.encoding(incoming=True) == "cp437" @@ -107,7 +103,11 @@ async def test_telnet_client_and_server_encoding_bidirectional(bind_host, unused assert writer.protocol.encoding(incoming=True, outgoing=True) == "cp437" -async def test_telnet_server_encoding_by_LANG(bind_host, unused_tcp_port): +@pytest.mark.parametrize( + "lang,expected_encoding", + [("uk_UA.KOI8-U", "KOI8-U"), ("en_IL", "utf8"), ("en_US.BOGUS-ENCODING", "utf8")], +) +async def test_telnet_server_encoding_by_LANG(bind_host, unused_tcp_port, lang, expected_encoding): """Server's encoding negotiated by LANG value.""" async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): @@ -119,63 +119,15 @@ async def test_telnet_server_encoding_by_LANG(bind_host, unused_tcp_port): + SB + NEW_ENVIRON + IS - + telnetlib3.stream_writer._encode_env_buf({"LANG": "uk_UA.KOI8-U"}) - + IAC - + SE - ) - writer.write(IAC + WONT + TTYPE) - - srv_instance = await asyncio.wait_for(server.wait_for_client(), 2.0) - assert srv_instance.encoding(incoming=True) == "KOI8-U" - assert srv_instance.encoding(outgoing=True) == "KOI8-U" - assert srv_instance.encoding(incoming=True, outgoing=True) == "KOI8-U" - assert srv_instance.get_extra_info("LANG") == "uk_UA.KOI8-U" - - -async def test_telnet_server_encoding_LANG_no_encoding_suffix(bind_host, unused_tcp_port): - """Server falls back to default when LANG has no encoding suffix.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: - async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): - writer.write(IAC + DO + BINARY) - writer.write(IAC + WILL + BINARY) - writer.write(IAC + WILL + NEW_ENVIRON) - writer.write( - IAC - + SB - + NEW_ENVIRON - + IS - + telnetlib3.stream_writer._encode_env_buf({"LANG": "en_IL"}) + + telnetlib3.stream_writer._encode_env_buf({"LANG": lang}) + IAC + SE ) writer.write(IAC + WONT + TTYPE) srv_instance = await asyncio.wait_for(server.wait_for_client(), 2.0) - assert srv_instance.encoding(incoming=True) == "utf8" - assert srv_instance.get_extra_info("LANG") == "en_IL" - - -async def test_telnet_server_encoding_LANG_invalid_encoding(bind_host, unused_tcp_port): - """Server falls back to default when LANG has unknown encoding.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: - async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): - writer.write(IAC + DO + BINARY) - writer.write(IAC + WILL + BINARY) - writer.write(IAC + WILL + NEW_ENVIRON) - writer.write( - IAC - + SB - + NEW_ENVIRON - + IS - + telnetlib3.stream_writer._encode_env_buf({"LANG": "en_US.BOGUS-ENCODING"}) - + IAC - + SE - ) - writer.write(IAC + WONT + TTYPE) - - srv_instance = await asyncio.wait_for(server.wait_for_client(), 2.0) - assert srv_instance.encoding(incoming=True) == "utf8" - assert srv_instance.get_extra_info("LANG") == "en_US.BOGUS-ENCODING" + assert srv_instance.encoding(incoming=True) == expected_encoding + assert srv_instance.get_extra_info("LANG") == lang async def test_telnet_server_binary_mode(bind_host, unused_tcp_port): @@ -215,11 +167,12 @@ async def test_telnet_client_and_server_escape_iac_encoding(bind_host, unused_tc given_string = "".join(chr(val) for val in list(range(256))) * 2 async with create_server( - host=bind_host, port=unused_tcp_port, encoding="iso8859-1", connect_maxwait=0.05 + host=bind_host, port=unused_tcp_port, encoding="iso8859-1", connect_maxwait=0.5 ) as server: - async with open_connection( - host=bind_host, port=unused_tcp_port, encoding="iso8859-1", connect_minwait=0.05 - ) as (client_reader, client_writer): + async with open_connection(host=bind_host, port=unused_tcp_port, encoding="iso8859-1") as ( + client_reader, + client_writer, + ): srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) srv_instance.writer.write(given_string) @@ -236,11 +189,12 @@ async def test_telnet_client_and_server_escape_iac_binary(bind_host, unused_tcp_ given_string = bytes(range(256)) * 2 async with create_server( - host=bind_host, port=unused_tcp_port, encoding=False, connect_maxwait=0.05 + host=bind_host, port=unused_tcp_port, encoding=False, connect_maxwait=0.5 ) as server: - async with open_connection( - host=bind_host, port=unused_tcp_port, encoding=False, connect_minwait=0.05 - ) as (client_reader, client_writer): + async with open_connection(host=bind_host, port=unused_tcp_port, encoding=False) as ( + client_reader, + client_writer, + ): srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) srv_instance.writer.write(given_string) @@ -250,3 +204,80 @@ async def test_telnet_client_and_server_escape_iac_binary(bind_host, unused_tcp_ await srv_instance.writer.wait_closed() eof = await asyncio.wait_for(client_reader.read(), 0.5) assert eof == b"" + + +# local +from telnetlib3.encodings.atarist import Codec as AtariCodec +from telnetlib3.encodings.atarist import StreamReader as AtariStreamReader +from telnetlib3.encodings.atarist import StreamWriter as AtariStreamWriter +from telnetlib3.encodings.atarist import IncrementalDecoder as AtariIncrementalDecoder +from telnetlib3.encodings.atarist import IncrementalEncoder as AtariIncrementalEncoder +from telnetlib3.encodings.atarist import getaliases as atari_getaliases +from telnetlib3.encodings.atarist import getregentry as atari_getregentry + + +def test_atarist_roundtrip(): + codec = AtariCodec() + text = "Hello, World! 0123456789" + encoded, length = codec.encode(text) + assert length == len(text) + decoded, dec_length = codec.decode(encoded) + assert decoded == text + assert dec_length == len(encoded) + + +def test_atarist_high_chars_roundtrip(): + codec = AtariCodec() + # Byte 0x80 = U+00C7 (C-cedilla), 0x81 = U+00FC (u-umlaut) + text = "\u00c7\u00fc" + encoded, _ = codec.encode(text) + assert encoded == bytes([0x80, 0x81]) + decoded, _ = codec.decode(encoded) + assert decoded == text + + +def test_atarist_incremental_encoder(): + enc = AtariIncrementalEncoder() + result = enc.encode("AB") + assert result == b"AB" + result2 = enc.encode("\u00c7", final=True) + assert result2 == bytes([0x80]) + + +def test_atarist_incremental_decoder(): + dec = AtariIncrementalDecoder() + result = dec.decode(b"AB") + assert result == "AB" + result2 = dec.decode(bytes([0x80]), final=True) + assert result2 == "\u00c7" + + +def test_atarist_stream_writer(): + import io + + stream = io.BytesIO() + writer = AtariStreamWriter(stream) + writer.write("Hello") + assert stream.getvalue() == b"Hello" + + +def test_atarist_stream_reader(): + import io + + stream = io.BytesIO(b"Hello") + reader = AtariStreamReader(stream) + result = reader.read() + assert result == "Hello" + + +def test_atarist_getregentry(): + import codecs + + info = atari_getregentry() + assert isinstance(info, codecs.CodecInfo) + assert info.name == "atarist" + + +def test_atarist_getaliases(): + aliases = atari_getaliases() + assert aliases == ("atari",) diff --git a/telnetlib3/tests/test_environ.py b/telnetlib3/tests/test_environ.py index 9ee8e79c..1ebefdaf 100644 --- a/telnetlib3/tests/test_environ.py +++ b/telnetlib3/tests/test_environ.py @@ -10,13 +10,7 @@ import telnetlib3 import telnetlib3.stream_writer from telnetlib3.telopt import DO, IS, SB, SE, IAC, VAR, WILL, TTYPE, USERVAR, NEW_ENVIRON -from telnetlib3.tests.accessories import ( - bind_host, - create_server, - open_connection, - unused_tcp_port, - asyncio_connection, -) +from telnetlib3.tests.accessories import create_server, open_connection, asyncio_connection async def test_telnet_server_on_environ(bind_host, unused_tcp_port): @@ -74,16 +68,12 @@ def on_environ(self, mapping): rows=given_rows, encoding=given_encoding, term=given_term, - connect_minwait=0.05, ) as (reader, writer): mapping = await asyncio.wait_for(_waiter, 0.5) - # Check expected values are present assert mapping["COLUMNS"] == str(given_cols) assert mapping["LANG"] == "en_US." + given_encoding assert mapping["LINES"] == str(given_rows) assert mapping["TERM"] == "vt220" - # Additional env vars may be present (USER, HOME, SHELL, COLORTERM) - # but their values depend on the test environment async def test_telnet_client_send_var_uservar_environ(bind_host, unused_tcp_port): @@ -112,8 +102,7 @@ def on_request_environ(self): rows=given_rows, encoding=given_encoding, term=given_term, - connect_minwait=0.05, - connect_maxwait=0.05, + connect_maxwait=0.5, ) as (reader, writer): mapping = await asyncio.wait_for(_waiter, 0.5) assert mapping == {} @@ -168,42 +157,52 @@ def _make_server(): @pytest.mark.parametrize( - "ttype1,ttype2,expect_skip", + "ttype1,ttype2", [ - ("ANSI", "VT100", True), - ("ANSI", "ANSI", False), - ("ansi", "vt100", False), - ("xterm", "xterm", False), - ("xterm", "xterm-256color", False), + ("ANSI", "VT100"), + ("ANSI", "ANSI"), + ("ansi", "vt100"), + ("xterm", "xterm"), + ("xterm", "xterm-256color"), ], ) -async def test_negotiate_environ_ms_telnet(ttype1, ttype2, expect_skip): - """NEW_ENVIRON is skipped for Microsoft telnet (ANSI + VT100).""" +async def test_negotiate_environ_always_sent(ttype1, ttype2): + """DO NEW_ENVIRON is always sent regardless of client identity.""" server = _make_server() server._extra["ttype1"] = ttype1 server._extra["ttype2"] = ttype2 server._negotiate_environ() - if expect_skip: - assert not server.writer.pending_option.get(DO + NEW_ENVIRON) - else: - assert server.writer.pending_option.get(DO + NEW_ENVIRON) + assert server.writer.pending_option.get(DO + NEW_ENVIRON) -async def test_check_negotiation_ttype_refused_triggers_environ(): - """check_negotiation sends DO NEW_ENVIRON when TTYPE is refused.""" +@pytest.mark.parametrize( + "ttype1,ttype2,expect_user", + [ + ("ANSI", "VT100", False), + ("ANSI", "ANSI", True), + ("ansi", "vt100", True), + ("xterm", "xterm", True), + ("xterm", "xterm-256color", True), + ], +) +async def test_on_request_environ_user_excluded_for_ms_telnet(ttype1, ttype2, expect_user): + """USER is excluded from NEW_ENVIRON request for Microsoft telnet.""" server = _make_server() - server._advanced = True - server.writer.remote_option[TTYPE] = False - server.check_negotiation(final=False) - assert server._environ_requested - assert server.writer.pending_option.get(DO + NEW_ENVIRON) + server._extra["ttype1"] = ttype1 + server._extra["ttype2"] = ttype2 + result = server.on_request_environ() + assert ("USER" in result) is expect_user + assert "LOGNAME" in result -async def test_check_negotiation_final_triggers_environ(): - """check_negotiation sends DO NEW_ENVIRON on final timeout.""" +@pytest.mark.parametrize("ttype_refused,final", [(True, False), (False, True)]) +async def test_check_negotiation_triggers_environ(ttype_refused, final): + """check_negotiation sends DO NEW_ENVIRON on TTYPE refusal or final.""" server = _make_server() server._advanced = True - server.check_negotiation(final=True) + if ttype_refused: + server.writer.remote_option[TTYPE] = False + server.check_negotiation(final=final) assert server._environ_requested assert server.writer.pending_option.get(DO + NEW_ENVIRON) @@ -217,17 +216,31 @@ async def test_check_negotiation_no_advanced_skips_environ(): assert not server.writer.pending_option.get(DO + NEW_ENVIRON) -async def test_on_ttype_non_ansi_triggers_environ(): - """on_ttype sends DO NEW_ENVIRON immediately for non-ANSI ttype1.""" +@pytest.mark.parametrize("ttype,expect_requested", [("xterm", True), ("ANSI", False)]) +async def test_on_ttype_environ_behavior(ttype, expect_requested): + """on_ttype sends DO NEW_ENVIRON for non-ANSI, defers for ANSI.""" server = _make_server() - server.on_ttype("xterm") - assert server._environ_requested - assert server.writer.pending_option.get(DO + NEW_ENVIRON) + server.on_ttype(ttype) + assert server._environ_requested is expect_requested + assert bool(server.writer.pending_option.get(DO + NEW_ENVIRON)) is expect_requested -async def test_on_ttype_ansi_defers_environ(): - """on_ttype defers DO NEW_ENVIRON when ttype1 is ANSI.""" +@pytest.mark.parametrize( + "env,expect_force", + [ + ({"LANG": "en_US.UTF-8"}, True), + ({"LANG": "ja_JP.EUC-JP"}, True), + ({"CHARSET": "UTF-8"}, True), + ({"CHARSET": "ISO-8859-1", "USER": "test"}, True), + ({"LANG": "en_US"}, False), + ({"LANG": "C"}, False), + ({"USER": "test", "TERM": "xterm"}, False), + ({}, False), + ], +) +async def test_on_environ_force_binary(env, expect_force): + """on_environ sets force_binary when LANG has encoding or CHARSET is present.""" server = _make_server() - server.on_ttype("ANSI") - assert not server._environ_requested - assert not server.writer.pending_option.get(DO + NEW_ENVIRON) + assert server.force_binary is False + server.on_environ(dict(env)) + assert server.force_binary is expect_force diff --git a/telnetlib3/tests/test_fingerprinting.py b/telnetlib3/tests/test_fingerprinting.py index 128ce481..16a53445 100644 --- a/telnetlib3/tests/test_fingerprinting.py +++ b/telnetlib3/tests/test_fingerprinting.py @@ -21,12 +21,7 @@ server_pty_shell = None # type: ignore[assignment] # local -from telnetlib3.tests.accessories import ( # noqa: F401 - bind_host, - create_server, - open_connection, - unused_tcp_port, -) +from telnetlib3.tests.accessories import create_server, open_connection @pytest.fixture(autouse=True) @@ -243,7 +238,6 @@ def test_prompt_stores_suggestions(tmp_path, monkeypatch, capsys): filepath.write_text(json.dumps(data)) inputs = iter(["Ghostty", "GNU Telnet"]) - # pylint: disable=possibly-used-before-assignment monkeypatch.setattr(fpd, "_cooked_input", lambda prompt: next(inputs)) fpd._prompt_fingerprint_identification(MockTerm(), data, str(filepath), {}) assert data["suggestions"]["terminal-emulator"] == "Ghostty" @@ -427,9 +421,10 @@ async def test_fingerprint_probe_integration(bind_host, unused_tcp_port): shell=fps.fingerprinting_server_shell, connect_maxwait=0.5, ): - async with open_connection( - host=bind_host, port=unused_tcp_port, connect_minwait=0.2, connect_maxwait=0.5 - ) as (reader, writer): + async with open_connection(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as ( + reader, + writer, + ): try: await asyncio.wait_for(reader.read(100), timeout=1.0) except asyncio.TimeoutError: @@ -631,8 +626,8 @@ def test_collect_rejected_options_with_data(): writer = MockWriter() writer.rejected_will = {fps.BINARY, fps.SGA} writer.rejected_do = {fps.ECHO} - result = fps._collect_rejected_options(writer) - assert len(result["will"]) == 2 and len(result["do"]) == 1 + rejected = fps._collect_rejected_options(writer) + assert len(rejected["will"]) == 2 and len(rejected["do"]) == 1 def test_collect_extra_info_tuples_and_bytes(): @@ -640,9 +635,9 @@ def test_collect_extra_info_tuples_and_bytes(): writer._protocol = MockProtocol( {"tspeed": (38400, 38400), "raw_data": b"\x01\x02\x03", "name": "test"} ) - result = fps._collect_extra_info(writer) - assert result["tspeed"] == [38400, 38400] - assert result["raw_data"] == "010203" and result["name"] == "test" + info = fps._collect_extra_info(writer) + assert info["tspeed"] == [38400, 38400] + assert info["raw_data"] == "010203" and info["name"] == "test" def test_collect_extra_info_removes_duplicate_keys(): @@ -658,10 +653,10 @@ def test_collect_extra_info_removes_duplicate_keys(): "ttype1": "xterm", } ) - result = fps._collect_extra_info(writer) + info = fps._collect_extra_info(writer) for key in ("term", "cols", "rows", "ttype1"): - assert key not in result - assert result["TERM"] == "xterm" + assert key not in info + assert info["TERM"] == "xterm" def test_collect_ttype_cycle(): @@ -699,8 +694,8 @@ def test_collect_slc_tab_with_data(): tab[slc.SLC_EC] = slc.SLC(mask=slc.SLC_DEFAULT, value=slc.theNULL) tab[slc.SLC_IP] = slc.SLC(mask=slc.SLC_DEFAULT, value=b"\x04") writer.slctab = tab - result = fps._collect_slc_tab(writer) - assert "nosupport" in result and "unset" in result and "set" in result + slc_tab = fps._collect_slc_tab(writer) + assert "nosupport" in slc_tab and "unset" in slc_tab and "set" in slc_tab def test_collect_slc_tab_empty(): @@ -972,10 +967,10 @@ def test_atomic_json_write_bytes_values(tmp_path): filepath, {"text": b"hello", "binary": b"\x80\xff", "nested": {"val": b"\x01"}} ) with open(filepath, encoding="utf-8") as f: - result = json.load(f) - assert result["text"] == "hello" - assert result["binary"] == "80ff" - assert result["nested"]["val"] == "\x01" + data = json.load(f) + assert data["text"] == "hello" + assert data["binary"] == "80ff" + assert data["nested"]["val"] == "\x01" def test_fingerprinting_main(monkeypatch, tmp_path): @@ -1031,6 +1026,7 @@ def test_protocol_fingerprint_hash_stability(): def test_fingerprinting_server_on_request_environ(): """FingerprintingServer includes HOME and SHELL in environ request.""" srv = fps.FingerprintingServer.__new__(fps.FingerprintingServer) + srv._extra = {} env = srv.on_request_environ() assert "HOME" in env assert "SHELL" in env @@ -1092,3 +1088,73 @@ def test_fingerprint_server_main_env_fallback(monkeypatch): assert fps.DATA_DIR == "/original" finally: fps.DATA_DIR = old_data_dir + + +def test_bytes_safe_encoder_non_serializable(): + with pytest.raises(TypeError): + from telnetlib3._paths import _BytesSafeEncoder + + json.dumps({"x": object()}, cls=_BytesSafeEncoder) + + +def test_fingerprinting_mixin_without_telnet_server(): + class Standalone(fps.FingerprintingTelnetServer): + pass + + obj = Standalone() + with pytest.raises(TypeError, match="must be combined with TelnetServer"): + obj.on_request_environ() + + +def test_build_session_fingerprint_comport(): + writer = _probe_writer() + writer.comport_data = {"signature": "COM1"} + writer.slctab = None + writer.rejected_will = set() + writer.rejected_do = set() + probe_results = {"BINARY": fps.ProbeResult(status="WILL", opt=fps.BINARY)} + session = fps._build_session_fingerprint(writer, probe_results, 0.5) + assert session["comport"] == {"signature": "COM1"} + + +def test_save_fingerprint_data_existing_non_unknown_subdir(tmp_path, monkeypatch): + monkeypatch.setattr(fps, "DATA_DIR", str(tmp_path)) + + writer = _probe_writer() + writer.slctab = None + writer.comport_data = None + writer.rejected_will = set() + writer.rejected_do = set() + probe_results = {"BINARY": fps.ProbeResult(status="WILL", opt=fps.BINARY)} + + protocol_fp = fps._create_protocol_fingerprint(writer, probe_results) + telnet_hash = fps._hash_fingerprint(protocol_fp) + telnet_dir = tmp_path / "client" / telnet_hash + known_dir = telnet_dir / "known-terminal" + known_dir.mkdir(parents=True) + + filepath = fps._save_fingerprint_data(writer, probe_results, 0.5) + assert filepath is not None + assert "known-terminal" in filepath + + +@pytest.mark.asyncio +async def test_probe_client_capabilities_timeout_status(): + """Probed option that never responds gets 'timeout' status.""" + from telnetlib3.telopt import LOGOUT + + writer = _probe_writer() + options = [(LOGOUT, "LOGOUT", "Logout")] + + results = await fps.probe_client_capabilities(writer, options=options, timeout=0.01) + assert results["LOGOUT"]["status"] == "timeout" + + +@pytest.mark.skipif(sys.platform == "win32", reason="fingerprinting_display requires termios") +def test_fingerprinting_post_script_delegates(): + """fingerprinting_post_script delegates to fingerprinting_display.""" + from unittest.mock import patch + + with patch("telnetlib3.fingerprinting_display.fingerprinting_post_script") as mock_fps: + fps.fingerprinting_post_script("/tmp/test.json") + mock_fps.assert_called_once_with("/tmp/test.json") diff --git a/telnetlib3/tests/test_guard_integration.py b/telnetlib3/tests/test_guard_integration.py index a1d3d1eb..5fa61d91 100644 --- a/telnetlib3/tests/test_guard_integration.py +++ b/telnetlib3/tests/test_guard_integration.py @@ -1,3 +1,5 @@ +from __future__ import annotations + # std imports import asyncio import functools @@ -161,7 +163,7 @@ async def guarded_shell(reader, writer): assert counter.count == 0 -async def test_guarded_shell_pattern_robot_check(): # pylint: disable=too-complex +async def test_guarded_shell_pattern_robot_check(): counter = ConnectionCounter(5) shell_calls = [] robot_shell_calls = [] @@ -240,7 +242,7 @@ async def guarded_shell(reader, writer): assert counter.count == 0 -async def test_full_guarded_shell_flow(): # pylint: disable=too-complex +async def test_full_guarded_shell_flow(): counter = ConnectionCounter(2) shell_calls = [] busy_calls = [] @@ -409,7 +411,7 @@ def enc(**kw): async def test_fingerprint_scanner_defeats_robot_check(unused_tcp_port): """Fingerprint scanner's virtual cursor defeats the server's robot_check.""" from telnetlib3.guard_shells import _TEST_CHAR, _measure_width # noqa: PLC0415 - from telnetlib3.tests.accessories import create_server # noqa: PLC0415 + from telnetlib3.tests.accessories import create_server measured_width: list[int | None] = [] @@ -437,9 +439,9 @@ async def guarded_shell(reader, writer): banner_max_wait=5.0, ) reader, writer = await telnetlib3.open_connection( - host="127.0.0.1", port=unused_tcp_port, encoding=False, shell=shell, connect_minwait=0.5 + host="127.0.0.1", port=unused_tcp_port, encoding=False, shell=shell ) - # Shell runs as a background task — wait for it to finish. + # Shell runs as a background task -- wait for it to finish. await asyncio.wait_for(writer.protocol.waiter_closed, timeout=10.0) assert measured_width, "server shell never ran" diff --git a/telnetlib3/tests/test_linemode.py b/telnetlib3/tests/test_linemode.py index 5d817775..abd4fd3c 100644 --- a/telnetlib3/tests/test_linemode.py +++ b/telnetlib3/tests/test_linemode.py @@ -8,12 +8,7 @@ import telnetlib3.stream_writer from telnetlib3.slc import LMODE_MODE, LMODE_MODE_ACK, LMODE_MODE_LOCAL from telnetlib3.telopt import DO, SB, SE, IAC, WILL, LINEMODE -from telnetlib3.tests.accessories import ( - bind_host, - create_server, - unused_tcp_port, - asyncio_connection, -) +from telnetlib3.tests.accessories import create_server, asyncio_connection async def test_server_demands_remote_linemode_client_agrees(bind_host, unused_tcp_port): diff --git a/telnetlib3/tests/test_mud.py b/telnetlib3/tests/test_mud.py index bfe5dbc0..7aa06a1d 100644 --- a/telnetlib3/tests/test_mud.py +++ b/telnetlib3/tests/test_mud.py @@ -305,3 +305,46 @@ def test_aardwolf_decode_long_payload() -> None: result = aardwolf_decode(bytes([100, 3, 4, 5])) assert result["channel"] == "status" assert result["data_bytes"] == bytes([3, 4, 5]) + + +def test_gmcp_decode_empty_json_text(): + pkg, data = gmcp_decode(b"Char.Vitals ") + assert pkg == "Char.Vitals" + assert data is None + + +def test_msdp_decode_skips_garbage_bytes(): + buf = b"\x42" + MSDP_VAR + b"KEY" + MSDP_VAL + b"val" + result = msdp_decode(buf) + assert result == {"KEY": "val"} + + +def test_mssp_decode_duplicate_var_to_list(): + buf = MSSP_VAR + b"PORT" + MSSP_VAL + b"6023" + MSSP_VAR + b"PORT" + MSSP_VAL + b"6024" + result = mssp_decode(buf) + assert result["PORT"] == ["6023", "6024"] + + +def test_mssp_decode_triple_value_appends(): + buf = ( + MSSP_VAR + + b"PORT" + + MSSP_VAL + + b"6023" + + MSSP_VAR + + b"PORT" + + MSSP_VAL + + b"6024" + + MSSP_VAR + + b"PORT" + + MSSP_VAL + + b"6025" + ) + result = mssp_decode(buf) + assert result["PORT"] == ["6023", "6024", "6025"] + + +def test_mssp_decode_skips_garbage_bytes(): + buf = b"\x42" + MSSP_VAR + b"NAME" + MSSP_VAL + b"TestMUD" + result = mssp_decode(buf) + assert result == {"NAME": "TestMUD"} diff --git a/telnetlib3/tests/test_mud_negotiation.py b/telnetlib3/tests/test_mud_negotiation.py index ea0907dc..1f918525 100644 --- a/telnetlib3/tests/test_mud_negotiation.py +++ b/telnetlib3/tests/test_mud_negotiation.py @@ -25,102 +25,39 @@ AARDWOLF, ) from telnetlib3.stream_writer import TelnetWriter - - -class MockTransport: - def __init__(self): - self._closing = False - self.writes = [] - self.extra = {} - - def write(self, data): - self.writes.append(bytes(data)) - - def is_closing(self): - return self._closing - - def get_extra_info(self, name, default=None): - return self.extra.get(name, default) - - def close(self): - self._closing = True - - -class ProtocolBase: - def __init__(self, info=None): - self.info = info or {} - self.drain_called = False - self.conn_lost_called = False - - def get_extra_info(self, name, default=None): - return self.info.get(name, default) - - async def _drain_helper(self): - self.drain_called = True - - def connection_lost(self, exc): - self.conn_lost_called = True +from telnetlib3.tests.accessories import MockProtocol, MockTransport def new_writer(server=True, client=False, reader=None): t = MockTransport() - p = ProtocolBase() + p = MockProtocol() w = TelnetWriter(t, p, server=server, client=client, reader=reader) return w, t, p -def test_handle_will_gmcp(): - w, t, p = new_writer(server=True) - w.handle_will(GMCP) - assert IAC + DO + GMCP in t.writes - assert w.remote_option.get(GMCP) is True - - -def test_handle_will_msdp(): - w, t, p = new_writer(server=True) - w.handle_will(MSDP) - assert IAC + DO + MSDP in t.writes - assert w.remote_option.get(MSDP) is True - - -def test_handle_will_mssp(): - w, t, p = new_writer(server=True) - w.handle_will(MSSP) - assert IAC + DO + MSSP in t.writes - assert w.remote_option.get(MSSP) is True +_MUD_CORE = [GMCP, MSDP, MSSP] +_MUD_CORE_IDS = ["GMCP", "MSDP", "MSSP"] -def test_handle_do_gmcp(): - w, t, p = new_writer(server=True) - w.handle_do(GMCP) - assert IAC + WILL + GMCP in t.writes - - -def test_handle_do_msdp(): - w, t, p = new_writer(server=True) - w.handle_do(MSDP) - assert IAC + WILL + MSDP in t.writes - - -def test_handle_do_mssp(): - w, t, p = new_writer(server=True) - w.handle_do(MSSP) - assert IAC + WILL + MSSP in t.writes - - -def test_set_ext_callback_gmcp(): - w, t, p = new_writer(server=True) - w.set_ext_callback(GMCP, lambda *a: None) +@pytest.mark.parametrize("opt", _MUD_CORE, ids=_MUD_CORE_IDS) +def test_handle_will_core(opt): + w, t, _p = new_writer(server=True) + w.handle_will(opt) + assert IAC + DO + opt in t.writes + assert w.remote_option.get(opt) is True -def test_set_ext_callback_msdp(): - w, t, p = new_writer(server=True) - w.set_ext_callback(MSDP, lambda *a: None) +@pytest.mark.parametrize("opt", _MUD_CORE, ids=_MUD_CORE_IDS) +def test_handle_do_core(opt): + w, t, _p = new_writer(server=True) + w.handle_do(opt) + assert IAC + WILL + opt in t.writes -def test_set_ext_callback_mssp(): - w, t, p = new_writer(server=True) - w.set_ext_callback(MSSP, lambda *a: None) +@pytest.mark.parametrize("opt", _MUD_CORE, ids=_MUD_CORE_IDS) +def test_set_ext_callback_core(opt): + w, _t, _p = new_writer(server=True) + w.set_ext_callback(opt, lambda *a: None) def test_sb_gmcp_dispatch(): diff --git a/telnetlib3/tests/test_naws.py b/telnetlib3/tests/test_naws.py index 9152b9b2..8e1e62fc 100644 --- a/telnetlib3/tests/test_naws.py +++ b/telnetlib3/tests/test_naws.py @@ -17,13 +17,7 @@ # local import telnetlib3 from telnetlib3.telopt import SB, SE, IAC, NAWS, WILL -from telnetlib3.tests.accessories import ( - bind_host, - create_server, - open_connection, - unused_tcp_port, - asyncio_connection, -) +from telnetlib3.tests.accessories import create_server, open_connection, asyncio_connection async def test_telnet_server_on_naws(bind_host, unused_tcp_port): @@ -37,7 +31,7 @@ def on_naws(self, rows, cols): _waiter.set_result(self) async with create_server( - protocol_factory=ServerTestNaws, host=bind_host, port=unused_tcp_port, connect_maxwait=0.05 + protocol_factory=ServerTestNaws, host=bind_host, port=unused_tcp_port, connect_maxwait=0.5 ): async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WILL + NAWS) @@ -59,14 +53,10 @@ def on_naws(self, rows, cols): _waiter.set_result((rows, cols)) async with create_server( - protocol_factory=ServerTestNaws, host=bind_host, port=unused_tcp_port, connect_maxwait=0.05 + protocol_factory=ServerTestNaws, host=bind_host, port=unused_tcp_port, connect_maxwait=0.5 ): async with open_connection( - host=bind_host, - port=unused_tcp_port, - cols=given_cols, - rows=given_rows, - connect_minwait=0.05, + host=bind_host, port=unused_tcp_port, cols=given_cols, rows=given_rows ) as (reader, writer): recv_rows, recv_cols = await asyncio.wait_for(_waiter, 0.5) assert recv_cols == given_cols @@ -86,7 +76,6 @@ async def test_telnet_client_send_tty_naws(bind_host, unused_tcp_port): bind_host, str(unused_tcp_port), "--loglevel=warning", - "--connect-minwait=0.005", "--connect-maxwait=0.010", ] @@ -97,7 +86,7 @@ def on_naws(self, rows, cols): asyncio.get_event_loop().call_soon(self.connection_lost, None) async with create_server( - protocol_factory=ServerTestNaws, host=bind_host, port=unused_tcp_port, connect_maxwait=0.05 + protocol_factory=ServerTestNaws, host=bind_host, port=unused_tcp_port, connect_maxwait=0.5 ): proc = pexpect.spawn(prog, args, dimensions=(given_rows, given_cols)) await proc.expect(pexpect.EOF, async_=True, timeout=5) @@ -120,14 +109,10 @@ def on_naws(self, rows, cols): _waiter.set_result((cols, rows)) async with create_server( - protocol_factory=ServerTestNaws, host=bind_host, port=unused_tcp_port, connect_maxwait=0.05 + protocol_factory=ServerTestNaws, host=bind_host, port=unused_tcp_port, connect_maxwait=0.5 ): async with open_connection( - host=bind_host, - port=unused_tcp_port, - cols=given_cols, - rows=given_rows, - connect_minwait=0.05, + host=bind_host, port=unused_tcp_port, cols=given_cols, rows=given_rows ) as (reader, writer): recv_cols, recv_rows = await asyncio.wait_for(_waiter, 0.5) assert recv_cols == expect_cols @@ -145,7 +130,7 @@ def on_naws(self, rows, cols): _waiter.set_result(self) async with create_server( - protocol_factory=ServerTestNaws, host=bind_host, port=unused_tcp_port, connect_maxwait=0.05 + protocol_factory=ServerTestNaws, host=bind_host, port=unused_tcp_port, connect_maxwait=0.5 ): async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + SB + NAWS + struct.pack("!HH", given_cols, given_rows) + IAC + SE) diff --git a/telnetlib3/tests/test_petscii_codec.py b/telnetlib3/tests/test_petscii_codec.py index 9e89a4e3..f6cc6000 100644 --- a/telnetlib3/tests/test_petscii_codec.py +++ b/telnetlib3/tests/test_petscii_codec.py @@ -18,6 +18,7 @@ def test_codec_lookup(): @pytest.mark.parametrize("alias", ["cbm", "commodore", "c64", "c128"]) def test_codec_aliases(alias): + codecs.lookup("petscii") info = codecs.lookup(alias) assert info.name == "petscii" diff --git a/telnetlib3/tests/test_platform.py b/telnetlib3/tests/test_platform.py new file mode 100644 index 00000000..e4a07e34 --- /dev/null +++ b/telnetlib3/tests/test_platform.py @@ -0,0 +1,15 @@ +# std imports +import sys + +# 3rd party +import pytest + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only code path") +@pytest.mark.asyncio +async def test_client_shell_win32_not_implemented(): + """telnet_client_shell raises NotImplementedError on Windows.""" + from telnetlib3.client_shell import telnet_client_shell + + with pytest.raises(NotImplementedError, match="win32"): + await telnet_client_shell(None, None) diff --git a/telnetlib3/tests/test_pty_shell.py b/telnetlib3/tests/test_pty_shell.py index c0537460..fd8aa09c 100644 --- a/telnetlib3/tests/test_pty_shell.py +++ b/telnetlib3/tests/test_pty_shell.py @@ -6,6 +6,7 @@ import time import struct import asyncio +import logging from unittest.mock import MagicMock, patch # 3rd party @@ -14,21 +15,17 @@ # local import telnetlib3 from telnetlib3 import server_pty_shell as sps +from telnetlib3.telopt import ECHO, WONT from telnetlib3.server_pty_shell import ( _BSU, _ESU, PTYSession, PTYSpawnError, + pty_shell, _platform_check, _wait_for_terminal_info, ) -from telnetlib3.tests.accessories import ( - bind_host, - create_server, - open_connection, - unused_tcp_port, - make_preexec_coverage, -) +from telnetlib3.tests.accessories import create_server, open_connection, make_preexec_coverage pytestmark = [pytest.mark.skipif(sys.platform == "win32", reason="PTY not supported on Windows")] @@ -104,16 +101,24 @@ def begin_shell(self, result): ), connect_maxwait=0.15, ): - async with open_connection( - host=bind_host, port=unused_tcp_port, cols=80, rows=25, connect_minwait=0.05 - ) as (reader, writer): + async with open_connection(host=bind_host, port=unused_tcp_port, cols=80, rows=25) as ( + reader, + writer, + ): await asyncio.wait_for(_waiter, 2.0) await asyncio.sleep(0.1) writer.write("hello world\n") await writer.drain() - result = await asyncio.wait_for(reader.read(50), 2.0) + result = "" + deadline = asyncio.get_event_loop().time() + 2.0 + while "hello world" not in result: + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + break + chunk = await asyncio.wait_for(reader.read(50), remaining) + result += chunk assert "hello world" in result # Test 2: env mode - verify TERM propagation @@ -136,13 +141,7 @@ async def client_shell(reader, writer): connect_maxwait=0.15, ): async with open_connection( - host=bind_host, - port=unused_tcp_port, - cols=80, - rows=25, - term="vt220", - shell=client_shell, - connect_minwait=0.05, + host=bind_host, port=unused_tcp_port, cols=80, rows=25, term="vt220", shell=client_shell ) as (reader, writer): output = await asyncio.wait_for(_output, 5.0) assert "vt220" in output or "xterm" in output @@ -159,9 +158,10 @@ async def client_shell(reader, writer): ), connect_maxwait=0.15, ): - async with open_connection( - host=bind_host, port=unused_tcp_port, cols=80, rows=25, connect_minwait=0.05 - ) as (reader, writer): + async with open_connection(host=bind_host, port=unused_tcp_port, cols=80, rows=25) as ( + reader, + writer, + ): await asyncio.wait_for(_waiter, 2.0) await asyncio.sleep(0.1) @@ -192,9 +192,10 @@ def begin_shell(self, result): ), connect_maxwait=0.15, ): - async with open_connection( - host=bind_host, port=unused_tcp_port, cols=80, rows=25, connect_minwait=0.05 - ) as (reader, writer): + async with open_connection(host=bind_host, port=unused_tcp_port, cols=80, rows=25) as ( + reader, + writer, + ): await asyncio.wait_for(_waiter, 2.0) await asyncio.sleep(0.1) @@ -228,9 +229,10 @@ def connection_lost(self, exc): ), connect_maxwait=0.15, ): - async with open_connection( - host=bind_host, port=unused_tcp_port, cols=80, rows=25, connect_minwait=0.05 - ) as (reader, writer): + async with open_connection(host=bind_host, port=unused_tcp_port, cols=80, rows=25) as ( + reader, + writer, + ): await asyncio.wait_for(_waiter, 2.0) await asyncio.sleep(0.1) @@ -444,15 +446,9 @@ def mock_write(fd, data): assert len(write_calls) == 0 -async def test_pty_session_cleanup_flushes_remaining_buffer(): +async def test_pty_session_cleanup_flushes_remaining_buffer(mock_session): """Test that cleanup flushes remaining buffer with final=True.""" - reader = MagicMock() - writer = MagicMock() - written = [] - writer.write = written.append - writer.get_extra_info = MagicMock(return_value="utf-8") - - session = PTYSession(reader, writer, "/nonexistent.program", []) + session, written = mock_session({"charset": "utf-8"}, capture_writes=True) session._output_buffer = b"remaining data" session.master_fd = 99 session.child_pid = 12345 @@ -537,14 +533,10 @@ async def test_pty_session_set_window_size_behavior(mock_session): ], ) async def test_pty_session_cleanup_error_recovery( - close_effect, kill_effect, waitpid_effect, check_attr + mock_session, close_effect, kill_effect, waitpid_effect, check_attr ): """Test cleanup handles various error conditions gracefully.""" - reader = MagicMock() - writer = MagicMock() - writer.get_extra_info = MagicMock(return_value="utf-8") - - session = PTYSession(reader, writer, "/nonexistent.program", []) + session, _ = mock_session({"charset": "utf-8"}) session.master_fd = 99 session.child_pid = 12345 @@ -564,16 +556,10 @@ async def test_pty_session_cleanup_error_recovery( "in_sync_update,expected_writes,expected_buffer", [(False, 1, b""), (True, 0, b"partial line")] ) async def test_pty_session_flush_remaining_scenarios( - in_sync_update, expected_writes, expected_buffer + mock_session, in_sync_update, expected_writes, expected_buffer ): """Test _flush_remaining behavior based on sync update state.""" - reader = MagicMock() - writer = MagicMock() - written = [] - writer.write = written.append - writer.get_extra_info = MagicMock(return_value="utf-8") - - session = PTYSession(reader, writer, "/nonexistent.program", []) + session, written = mock_session({"charset": "utf-8"}, capture_writes=True) session._output_buffer = b"partial line" session._in_sync_update = in_sync_update @@ -585,15 +571,9 @@ async def test_pty_session_flush_remaining_scenarios( assert session._output_buffer == expected_buffer -async def test_pty_session_flush_output_empty_data(): +async def test_pty_session_flush_output_empty_data(mock_session): """Test _flush_output does nothing with empty data.""" - reader = MagicMock() - writer = MagicMock() - written = [] - writer.write = written.append - writer.get_extra_info = MagicMock(return_value="utf-8") - - session = PTYSession(reader, writer, "/nonexistent.program", []) + session, written = mock_session({"charset": "utf-8"}, capture_writes=True) session._flush_output(b"") session._flush_output(b"", final=True) @@ -601,15 +581,9 @@ async def test_pty_session_flush_output_empty_data(): assert len(written) == 0 -async def test_pty_session_write_to_telnet_pre_bsu_content(): +async def test_pty_session_write_to_telnet_pre_bsu_content(mock_session): """Test content before BSU is flushed.""" - reader = MagicMock() - writer = MagicMock() - written = [] - writer.write = written.append - writer.get_extra_info = MagicMock(return_value="utf-8") - - session = PTYSession(reader, writer, "/nonexistent.program", []) + session, written = mock_session({"charset": "utf-8"}, capture_writes=True) session._write_to_telnet(b"before\n" + _BSU + b"during" + _ESU) assert len(written) == 2 @@ -629,15 +603,12 @@ async def test_pty_spawn_error(): [ (b"FileNotFoundError:2:No such file", ["FileNotFoundError", "No such file"]), (b"just some error text", ["Exec failed"]), + (b"\xff\xfe", ["Exec failed"]), ], ) -async def test_pty_session_exec_error_parsing(error_data, expected_substrings): +async def test_pty_session_exec_error_parsing(mock_session, error_data, expected_substrings): """Test _handle_exec_error parses various error formats.""" - reader = MagicMock() - writer = MagicMock() - writer.get_extra_info = MagicMock(return_value=None) - - session = PTYSession(reader, writer, "/nonexistent.program", []) + session, _ = mock_session() with pytest.raises(PTYSpawnError) as exc_info: session._handle_exec_error(error_data) @@ -646,17 +617,72 @@ async def test_pty_session_exec_error_parsing(error_data, expected_substrings): assert substring in str(exc_info.value) +async def test_write_exec_error_to_pipe(mock_session): + """Test _write_exec_error writes exception info to pipe and closes it.""" + session, _ = mock_session() + r_fd, w_fd = os.pipe() + try: + exc = OSError(2, "No such file") + session._write_exec_error(w_fd, exc) + data = os.read(r_fd, 4096) + assert b"Error" in data + assert b"No such file" in data + finally: + os.close(r_fd) + + +async def test_fire_naws_update_noop_when_no_pending(mock_session): + """Test _fire_naws_update does nothing when no update is pending.""" + session, _ = mock_session() + session._naws_pending = None + session._fire_naws_update() + + +async def test_set_window_size_with_real_pty(mock_session): + """Test _set_window_size calls ioctl on a real PTY fd.""" + import fcntl + import signal + import termios + + session, _ = mock_session() + master_fd, slave_fd = os.openpty() + try: + session.master_fd = master_fd + session.child_pid = os.getpid() + + ioctl_calls = [] + orig_ioctl = fcntl.ioctl + + def _track_ioctl(fd, req, data=None): + if req == termios.TIOCSWINSZ: + ioctl_calls.append((fd, req)) + return orig_ioctl(fd, req, data) + + kill_calls = [] + + def _fake_killpg(pgid, sig): + kill_calls.append((pgid, sig)) + + with patch.object(fcntl, "ioctl", side_effect=_track_ioctl): + with patch.object(os, "killpg", side_effect=_fake_killpg): + session._set_window_size(30, 120) + + assert len(ioctl_calls) == 1 + assert ioctl_calls[0][0] == master_fd + assert len(kill_calls) == 1 + assert kill_calls[0][1] == signal.SIGWINCH + finally: + os.close(master_fd) + os.close(slave_fd) + + @pytest.mark.parametrize( "child_pid,waitpid_behavior,expected", [(None, None, False), (99999, ChildProcessError, False), (12345, (0, 0), True)], ) -async def test_pty_session_isalive_scenarios(child_pid, waitpid_behavior, expected): +async def test_pty_session_isalive_scenarios(mock_session, child_pid, waitpid_behavior, expected): """Test _isalive returns correct values for various child states.""" - reader = MagicMock() - writer = MagicMock() - writer.get_extra_info = MagicMock(return_value=None) - - session = PTYSession(reader, writer, "/nonexistent.program", []) + session, _ = mock_session() session.child_pid = child_pid if waitpid_behavior is None: @@ -669,21 +695,17 @@ async def test_pty_session_isalive_scenarios(child_pid, waitpid_behavior, expect assert session._isalive() is expected -async def test_pty_session_terminate_scenarios(): +async def test_pty_session_terminate_scenarios(mock_session): """Test _terminate handles various termination scenarios.""" import signal - reader = MagicMock() - writer = MagicMock() - writer.get_extra_info = MagicMock(return_value=None) - # Scenario 1: No child pid - returns True immediately - session = PTYSession(reader, writer, "/nonexistent.program", []) + session, _ = mock_session() session.child_pid = None assert session._terminate() is True # Scenario 2: Child alive, sends signals, child dies - session = PTYSession(reader, writer, "/nonexistent.program", []) + session, _ = mock_session() session.child_pid = 12345 kill_calls = [] isalive_calls = [True, True, False] @@ -699,14 +721,12 @@ def mock_isalive(): patch.object(session, "_isalive", side_effect=mock_isalive), patch("time.sleep"), ): - result = session._terminate() - - assert result is True + assert session._terminate() is True assert len(kill_calls) >= 1 assert kill_calls[0][1] == signal.SIGHUP # Scenario 3: ProcessLookupError - child already gone - session = PTYSession(reader, writer, "/nonexistent.program", []) + session, _ = mock_session() session.child_pid = 12345 isalive_returns = [True] @@ -717,9 +737,7 @@ def mock_isalive_2(): patch.object(os, "kill", side_effect=ProcessLookupError), patch.object(session, "_isalive", side_effect=mock_isalive_2), ): - result = session._terminate() - - assert result is True + assert session._terminate() is True async def test_pty_session_ga_timer_fires_after_idle(mock_session): @@ -765,25 +783,14 @@ async def test_pty_session_ga_timer_cancelled_by_new_output(mock_session): assert len(ga_calls) == 0 -async def test_pty_session_ga_timer_suppressed_by_never_send_ga(mock_session): - """GA timer is not scheduled when never_send_ga is set.""" - session, written = mock_session({"charset": "utf-8"}, capture_writes=True) - protocol = MagicMock() - protocol.never_send_ga = True - session.writer.protocol = protocol - - session._output_buffer = b"prompt> " - session._flush_remaining() - assert session._ga_timer is None - - -async def test_pty_session_ga_timer_suppressed_in_raw_mode(mock_session): - """GA timer is not scheduled in raw_mode (e.g. fingerprinting display).""" +@pytest.mark.parametrize("never_send_ga,raw_mode", [(True, False), (False, True)]) +async def test_pty_session_ga_timer_suppressed(mock_session, never_send_ga, raw_mode): + """GA timer is not scheduled when never_send_ga is set or in raw_mode.""" session, _ = mock_session({"charset": "utf-8"}, capture_writes=True) protocol = MagicMock() - protocol.never_send_ga = False + protocol.never_send_ga = never_send_ga session.writer.protocol = protocol - session.raw_mode = True + session.raw_mode = raw_mode session._output_buffer = b"prompt> " session._flush_remaining() @@ -816,3 +823,140 @@ async def test_pty_session_ga_timer_cancelled_on_cleanup(mock_session): assert session._ga_timer is None await asyncio.sleep(0.1) session.writer.send_ga.assert_not_called() + + +def test_handle_exec_error_non_decodable(mock_session): + """_handle_exec_error handles data that causes unexpected exceptions.""" + session, _ = mock_session() + + class BadBytes(bytes): + def decode(self, *args, **kwargs): + raise TypeError("mocked decode failure") + + with pytest.raises(PTYSpawnError, match="Exec failed"): + session._handle_exec_error(BadBytes(b"test")) + + +def test_build_environment_no_rows_cols(mock_session, monkeypatch): + """_build_environment skips LINES/COLUMNS when rows/cols are falsy.""" + monkeypatch.delenv("LINES", raising=False) + monkeypatch.delenv("COLUMNS", raising=False) + session, _ = mock_session({"TERM": "vt100", "rows": 0, "cols": 0}) + env = session._build_environment() + assert "LINES" not in env + assert "COLUMNS" not in env + + +def test_build_environment_no_lang_no_charset(mock_session): + """_build_environment handles missing LANG and charset.""" + session, _ = mock_session({"TERM": "vt100"}) + env = session._build_environment() + assert "LC_ALL" not in env + + +def test_build_environment_optional_keys(mock_session): + """_build_environment copies optional env keys when present.""" + session, _ = mock_session( + { + "TERM": "xterm", + "USER": "testuser", + "DISPLAY": ":0", + "COLORTERM": "truecolor", + "HOME": "/home/test", + "SHELL": "/bin/bash", + "LOGNAME": "testuser", + } + ) + env = session._build_environment() + assert env["USER"] == "testuser" + assert env["DISPLAY"] == ":0" + assert env["COLORTERM"] == "truecolor" + assert env["HOME"] == "/home/test" + assert env["SHELL"] == "/bin/bash" + assert env["LOGNAME"] == "testuser" + + +async def test_run_remove_reader_error(mock_session): + """Run() handles ValueError from remove_reader gracefully.""" + session, _ = mock_session({"charset": "utf-8"}) + session.master_fd = 99 + session.child_pid = 1234 + session._closing = True + + mock_loop = MagicMock() + mock_loop.add_reader = MagicMock() + mock_loop.remove_reader = MagicMock(side_effect=ValueError("fd not found")) + + async def noop_bridge(*a): + pass + + with ( + patch("os.waitpid", return_value=(0, 0)), + patch("asyncio.get_event_loop", return_value=mock_loop), + patch.object(session, "_bridge_loop", side_effect=noop_bridge), + ): + await session.run() + + mock_loop.remove_reader.assert_called_once_with(99) + + +async def test_bridge_loop_exception(mock_session): + """_bridge_loop handles unexpected exceptions by setting _closing.""" + session, _ = mock_session({"charset": "utf-8"}) + session._closing = False + session.writer.is_closing = MagicMock(return_value=False) + + async def bad_read(size): + raise RuntimeError("unexpected") + + session.reader.read = bad_read + + pty_read_event = asyncio.Event() + pty_data_queue: asyncio.Queue = asyncio.Queue() + + await session._bridge_loop(pty_read_event, pty_data_queue) + assert session._closing is True + + +async def test_fire_ga_writer_closing(mock_session): + """_fire_ga does not send GA when writer is closing.""" + session, _ = mock_session({"charset": "utf-8"}) + session.writer.is_closing = MagicMock(return_value=True) + ga_calls = [] + session.writer.send_ga = lambda: ga_calls.append(True) + + session._fire_ga() + assert len(ga_calls) == 0 + + +async def test_flush_output_decoder_returns_empty(mock_session): + """_flush_output handles decoder returning empty text.""" + session, written = mock_session({"charset": "utf-8"}, capture_writes=True) + session._flush_output(b"\xc3") + assert len(written) == 0 + + +@pytest.mark.parametrize("will_echo,expect_wont_echo", [(False, False), (True, True)]) +async def test_pty_shell_wont_echo_behavior(will_echo, expect_wont_echo): + """pty_shell sends WONT ECHO only when will_echo is True.""" + reader = MagicMock() + writer = MagicMock() + writer.will_echo = will_echo + writer.get_extra_info = MagicMock( + side_effect=lambda k, d=None: {"TERM": "xterm", "rows": 25}.get(k, d) + ) + writer.is_closing = MagicMock(return_value=False) + + async def noop_drain(): + pass + + writer.drain = MagicMock(side_effect=noop_drain) + + iac_calls = [] + writer.iac = lambda *args: iac_calls.append(args) + + with patch.object(PTYSession, "start", side_effect=PTYSpawnError("mocked")): + with pytest.raises(PTYSpawnError): + await pty_shell(reader, writer, "/nonexistent", raw_mode=False) + + assert ((WONT, ECHO) in iac_calls) is expect_wont_echo diff --git a/telnetlib3/tests/test_reader.py b/telnetlib3/tests/test_reader.py index 887fd87c..66a465ee 100644 --- a/telnetlib3/tests/test_reader.py +++ b/telnetlib3/tests/test_reader.py @@ -8,69 +8,36 @@ # local import telnetlib3 -from telnetlib3.tests.accessories import bind_host, create_server, open_connection, unused_tcp_port +from telnetlib3.tests.accessories import create_server, open_connection -def test_reader_instantiation_safety(): - """On instantiation, one of server or client must be specified.""" - - # given, - def fn_encoding(incoming): - return "def-ENC" +def _fn_encoding(incoming): + return "def-ENC" - reader = telnetlib3.TelnetReader(limit=1999) - # exercise, - result = repr(reader) - - # verify. - assert result == "" +def test_reader_instantiation_safety(): + assert repr(telnetlib3.TelnetReader(limit=1999)) == ("") def test_reader_with_encoding_instantiation_safety(): - # given, - def fn_encoding(incoming): - return "def-ENC" - - expected_result = "" - - reader = telnetlib3.TelnetReaderUnicode(fn_encoding=fn_encoding, limit=1999) - - # exercise, - result = repr(reader) - - # verify. - assert result == expected_result + reader = telnetlib3.TelnetReaderUnicode(fn_encoding=_fn_encoding, limit=1999) + assert repr(reader) == ( + "" + ) def test_reader_eof_safety(): - """Check side-effects of feed_eof.""" - # given, reader = telnetlib3.TelnetReader(limit=1999) reader.feed_eof() - - # exercise, - result = repr(reader) - - # verify. - assert result == "" + assert repr(reader) == "" def test_reader_unicode_eof_safety(): - # given, - def fn_encoding(incoming): - return "def-ENC" - - expected_result = "" - - reader = telnetlib3.TelnetReaderUnicode(fn_encoding=fn_encoding) + reader = telnetlib3.TelnetReaderUnicode(fn_encoding=_fn_encoding) reader.feed_eof() - - # exercise, - result = repr(reader) - - # verify. - assert result == expected_result + assert repr(reader) == ( + "" + ) async def test_telnet_reader_using_readline_unicode(bind_host, unused_tcp_port): @@ -94,22 +61,19 @@ async def shell(reader, writer): writer.close() async with create_server( - host=bind_host, port=unused_tcp_port, connect_maxwait=0.05, shell=shell + host=bind_host, port=unused_tcp_port, connect_maxwait=0.5, shell=shell ): - async with open_connection(host=bind_host, port=unused_tcp_port, connect_minwait=0.05) as ( + async with open_connection(host=bind_host, port=unused_tcp_port) as ( client_reader, client_writer, ): for given, expected in sorted(given_expected.items()): - result = await asyncio.wait_for(client_reader.readline(), 0.5) - assert result == expected + assert await asyncio.wait_for(client_reader.readline(), 0.5) == expected - eof = await asyncio.wait_for(client_reader.read(), 0.5) - assert not eof + assert not await asyncio.wait_for(client_reader.read(), 0.5) async def test_telnet_reader_using_readline_bytes(bind_host, unused_tcp_port): - """Ensure strict RFC interpretation of newlines in readline method.""" given_expected = { b"alpha\r\x00": b"alpha\r", b"bravo\r\n": b"bravo\r\n", @@ -128,17 +92,16 @@ def shell(reader, writer): writer.close() async with create_server( - host=bind_host, port=unused_tcp_port, connect_maxwait=0.05, shell=shell, encoding=False + host=bind_host, port=unused_tcp_port, connect_maxwait=0.5, shell=shell, encoding=False ): - async with open_connection( - host=bind_host, port=unused_tcp_port, connect_minwait=0.05, encoding=False - ) as (client_reader, client_writer): + async with open_connection(host=bind_host, port=unused_tcp_port, encoding=False) as ( + client_reader, + client_writer, + ): for given, expected in sorted(given_expected.items()): - result = await asyncio.wait_for(client_reader.readline(), 0.5) - assert result == expected + assert await asyncio.wait_for(client_reader.readline(), 0.5) == expected - eof = await asyncio.wait_for(client_reader.read(), 0.5) - assert eof == b"" + assert await asyncio.wait_for(client_reader.read(), 0.5) == b"" async def test_telnet_reader_read_exactly_unicode(bind_host, unused_tcp_port): @@ -152,25 +115,22 @@ def shell(reader, writer): writer.close() async with create_server( - host=bind_host, port=unused_tcp_port, connect_maxwait=0.05, shell=shell + host=bind_host, port=unused_tcp_port, connect_maxwait=0.5, shell=shell ): - async with open_connection(host=bind_host, port=unused_tcp_port, connect_minwait=0.05) as ( + async with open_connection(host=bind_host, port=unused_tcp_port) as ( client_reader, client_writer, ): - result = await asyncio.wait_for(client_reader.readexactly(len(given)), 0.5) - assert result == given + assert await asyncio.wait_for(client_reader.readexactly(len(given)), 0.5) == given - given_readsize = len(given_partial) + 1 with pytest.raises(asyncio.IncompleteReadError) as exc_info: - await asyncio.wait_for(client_reader.readexactly(given_readsize), 0.5) + await asyncio.wait_for(client_reader.readexactly(len(given_partial) + 1), 0.5) assert exc_info.value.partial == given_partial - assert exc_info.value.expected == given_readsize + assert exc_info.value.expected == len(given_partial) + 1 async def test_telnet_reader_read_exactly_bytes(bind_host, unused_tcp_port): - """Ensure TelnetReader.readexactly, especially IncompleteReadError.""" given = string.ascii_letters.encode("ascii") given_partial = b"zzz" @@ -179,36 +139,24 @@ def shell(reader, writer): writer.close() async with create_server( - host=bind_host, port=unused_tcp_port, connect_maxwait=0.05, shell=shell, encoding=False + host=bind_host, port=unused_tcp_port, connect_maxwait=0.5, shell=shell, encoding=False ): - async with open_connection( - host=bind_host, port=unused_tcp_port, connect_minwait=0.05, encoding=False - ) as (client_reader, client_writer): - result = await asyncio.wait_for(client_reader.readexactly(len(given)), 0.5) - assert result == given + async with open_connection(host=bind_host, port=unused_tcp_port, encoding=False) as ( + client_reader, + client_writer, + ): + assert await asyncio.wait_for(client_reader.readexactly(len(given)), 0.5) == given - given_readsize = len(given_partial) + 1 with pytest.raises(asyncio.IncompleteReadError) as exc_info: - await asyncio.wait_for(client_reader.readexactly(given_readsize), 0.5) + await asyncio.wait_for(client_reader.readexactly(len(given_partial) + 1), 0.5) assert exc_info.value.partial == given_partial - assert exc_info.value.expected == given_readsize + assert exc_info.value.expected == len(given_partial) + 1 async def test_telnet_reader_read_0(bind_host, unused_tcp_port): - """Ensure TelnetReader.read(0) returns nothing.""" - - # given - def fn_encoding(incoming): - return "def-ENC" - - reader = telnetlib3.TelnetReaderUnicode(fn_encoding=fn_encoding) - - # exercise - value = await reader.read(0) - - # verify - assert not value + reader = telnetlib3.TelnetReaderUnicode(fn_encoding=_fn_encoding) + assert not await reader.read(0) async def test_telnet_reader_read_beyond_limit_unicode(bind_host, unused_tcp_port): @@ -222,11 +170,12 @@ def shell(reader, writer): writer.close() async with create_server( - host=bind_host, port=unused_tcp_port, connect_maxwait=0.05, shell=shell, limit=limit + host=bind_host, port=unused_tcp_port, connect_maxwait=0.5, shell=shell, limit=limit ): - async with open_connection( - host=bind_host, port=unused_tcp_port, connect_minwait=0.05, limit=limit - ) as (client_reader, client_writer): + async with open_connection(host=bind_host, port=unused_tcp_port, limit=limit) as ( + client_reader, + client_writer, + ): assert client_reader._limit == limit value = await asyncio.wait_for(client_reader.read(), 0.5) assert value == "x" * (limit + 1) @@ -245,13 +194,13 @@ def shell(reader, writer): async with create_server( host=bind_host, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, shell=shell, encoding=False, limit=limit, ): async with open_connection( - host=bind_host, port=unused_tcp_port, connect_minwait=0.05, encoding=False, limit=limit + host=bind_host, port=unused_tcp_port, encoding=False, limit=limit ) as (client_reader, client_writer): assert client_reader._limit == limit value = await asyncio.wait_for(client_reader.read(), 0.5) @@ -280,13 +229,13 @@ async def shell(_, writer): async with create_server( host=bind_host, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, shell=shell, encoding=False, limit=limit, ): async with open_connection( - host=bind_host, port=unused_tcp_port, connect_minwait=0.05, encoding=False, limit=limit + host=bind_host, port=unused_tcp_port, encoding=False, limit=limit ) as (client_reader, _): # Test successful reads within limit result = await client_reader.readuntil_pattern(pattern) @@ -326,13 +275,13 @@ async def shell(_, writer): async with create_server( host=bind_host, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, shell=shell, encoding=False, limit=limit, ): async with open_connection( - host=bind_host, port=unused_tcp_port, connect_minwait=0.05, encoding=False, limit=limit + host=bind_host, port=unused_tcp_port, encoding=False, limit=limit ) as (client_reader, _): # First successful read result = await client_reader.readuntil_pattern(pattern) @@ -374,13 +323,13 @@ async def shell(_, writer): async with create_server( host=bind_host, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, shell=shell, encoding=False, limit=limit, ): async with open_connection( - host=bind_host, port=unused_tcp_port, connect_minwait=0.05, encoding=False, limit=limit + host=bind_host, port=unused_tcp_port, encoding=False, limit=limit ) as (client_reader, _): # First read the Router> prompt result = await client_reader.readuntil_pattern(pattern) @@ -410,13 +359,13 @@ async def shell(_, writer): async with create_server( host=bind_host, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, shell=shell, encoding=False, limit=limit, ): async with open_connection( - host=bind_host, port=unused_tcp_port, connect_minwait=0.05, encoding=False, limit=limit + host=bind_host, port=unused_tcp_port, encoding=False, limit=limit ) as (client_reader, _): # First successful read result = await client_reader.readuntil_pattern(pattern) @@ -464,13 +413,13 @@ async def shell(_, writer): async with create_server( host=bind_host, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, shell=shell, encoding=False, limit=limit, ): async with open_connection( - host=bind_host, port=unused_tcp_port, connect_minwait=0.05, encoding=False, limit=limit + host=bind_host, port=unused_tcp_port, encoding=False, limit=limit ) as (client_reader, _): # Set exception and test it's properly raised client_reader.set_exception(asyncio.CancelledError()) diff --git a/telnetlib3/tests/test_relay_server.py b/telnetlib3/tests/test_relay_server.py index 2f584c53..22ce921b 100644 --- a/telnetlib3/tests/test_relay_server.py +++ b/telnetlib3/tests/test_relay_server.py @@ -80,18 +80,12 @@ def close(self): @pytest.mark.asyncio -async def test_relay_shell_wrong_passcode_closes(monkeypatch): +async def test_relay_shell_wrong_passcode_closes(monkeypatch, fast_sleep): """Relay shell should prompt for passcode 3 times and close on failure.""" # Prepare fake client I/O client_reader = SeqReader("bad1\rbad2\rbad3\r") client_writer = FakeWriter() - # Avoid 1-second sleeps in loop - async def _no_sleep(_): - return None - - monkeypatch.setattr(asyncio, "sleep", _no_sleep) - await relay_shell(client_reader, client_writer) out = "".join(client_writer.buffer) @@ -105,7 +99,7 @@ async def _no_sleep(_): @pytest.mark.asyncio -async def test_relay_shell_success_relays_and_closes(monkeypatch): +async def test_relay_shell_success_relays_and_closes(monkeypatch, fast_sleep): """Relay shell should connect on correct passcode and relay server output.""" # Client enters correct passcode then EOF from client client_reader = PayloadReader( @@ -115,12 +109,6 @@ async def test_relay_shell_success_relays_and_closes(monkeypatch): ) client_writer = FakeWriter() - # Avoid 1-second sleeps in loop - async def _no_sleep(_): - return None - - monkeypatch.setattr(asyncio, "sleep", _no_sleep) - # Mock open_connection to a dummy server that sends "hello" then EOF server_reader = PayloadReader(["hello", ""]) server_writer = DummyServerWriter() @@ -144,3 +132,97 @@ async def _fake_open_connection(host, port, cols=None, rows=None): # Both sides closed assert client_writer.closed is True assert server_writer.closed is True + + +@pytest.mark.asyncio +async def test_relay_shell_client_eof_during_passcode(monkeypatch, fast_sleep): + """Client disconnects (EOF) while entering passcode.""" + client_reader = SeqReader("") # immediate EOF + client_writer = FakeWriter() + + await relay_shell(client_reader, client_writer) + + out = "".join(client_writer.buffer) + assert "Telnet Relay shell ready." in out + assert "Connecting to" not in out + + +@pytest.mark.asyncio +async def test_relay_shell_client_eof_during_relay(monkeypatch, fast_sleep): + """Client sends EOF during active relay; server writer should close.""" + client_reader = PayloadReader(list("867-5309\r") + [""]) # EOF from client during relay + client_writer = FakeWriter() + + server_reader = PayloadReader([]) # server also yields nothing initially + server_writer = DummyServerWriter() + + # Make server_reader wait so client EOF fires first + orig_server_read = server_reader.read + + async def _slow_server_read(n): + await asyncio.sleep(10) + return "" + + server_reader.read = _slow_server_read + + async def _fake_open_connection(host, port, cols=None, rows=None): + return server_reader, server_writer + + monkeypatch.setattr("telnetlib3.relay_server.open_connection", _fake_open_connection) + + await relay_shell(client_reader, client_writer) + + assert server_writer.closed is True + + +@pytest.mark.asyncio +async def test_relay_shell_server_eof_closes_client(monkeypatch): + """Server sends EOF during relay; client writer should close.""" + # Client reader that sends passcode then blocks forever + passcode_reader = PayloadReader(list("867-5309\r")) + + server_reader = PayloadReader(["server data", ""]) # server sends data then EOF + server_writer = DummyServerWriter() + + async def _fake_open_connection(host, port, cols=None, rows=None): + return server_reader, server_writer + + monkeypatch.setattr("telnetlib3.relay_server.open_connection", _fake_open_connection) + + # Make client reader block after passcode so server EOF fires first + orig_payloads = passcode_reader.payloads + + async def _blocking_read(n): + if orig_payloads: + return orig_payloads.pop(0) + await asyncio.sleep(10) + return "" + + passcode_reader.read = _blocking_read + + client_writer = FakeWriter() + + await relay_shell(passcode_reader, client_writer) + + out = "".join(client_writer.buffer) + assert "server data" in out + assert client_writer.closed is True + + +@pytest.mark.asyncio +async def test_relay_shell_client_data_forwarded_to_server(monkeypatch): + """Client input during relay is forwarded to server writer.""" + client_reader = PayloadReader(list("867-5309\r") + ["client typing", ""]) + client_writer = FakeWriter() + + server_reader = PayloadReader(["welcome", ""]) + server_writer = DummyServerWriter() + + async def _fake_open_connection(host, port, cols=None, rows=None): + return server_reader, server_writer + + monkeypatch.setattr("telnetlib3.relay_server.open_connection", _fake_open_connection) + + await relay_shell(client_reader, client_writer) + + assert "client typing" in server_writer.writes diff --git a/telnetlib3/tests/test_server.py b/telnetlib3/tests/test_server.py new file mode 100644 index 00000000..04d6d6bd --- /dev/null +++ b/telnetlib3/tests/test_server.py @@ -0,0 +1,397 @@ +# std imports +import ssl as ssl_module +import socket +import asyncio +import logging +from unittest.mock import MagicMock, patch + +# 3rd party +import pytest + +# local +from telnetlib3.server import StatusLogger, TelnetServer, parse_server_args, _TLSAutoDetectProtocol +from telnetlib3.server_base import BaseServer + + +@pytest.mark.asyncio +async def test_connection_lost_closes_transport_despite_set_protocol_error(): + server = BaseServer.__new__(BaseServer) + server.log = __import__("logging").getLogger("test_server") + server._tasks = [] + server._waiter_connected = asyncio.get_event_loop().create_future() + server._extra = {} + server.shell = None + + closed = [] + + class BadTransport: + def set_protocol(self, proto): + raise RuntimeError("set_protocol failed") + + def close(self): + closed.append(True) + + def get_extra_info(self, name, default=None): + return default + + class FakeReader: + def feed_eof(self): + pass + + server._transport = BadTransport() + server.reader = FakeReader() + server.connection_lost(None) + assert len(closed) == 1 + assert server._transport is None + + +@pytest.mark.asyncio +async def test_connection_lost_remove_done_callback_raises(): + server = BaseServer.__new__(BaseServer) + server.log = __import__("logging").getLogger("test_server") + server._tasks = [] + server._extra = {} + server.shell = None + server._closing = False + + waiter = asyncio.get_event_loop().create_future() + + class _BadWaiter: + done = waiter.done + cancelled = waiter.cancelled + cancel = waiter.cancel + + def remove_done_callback(self, cb): + raise ValueError("already removed") + + server._waiter_connected = _BadWaiter() + + class FakeReader: + def feed_eof(self): + pass + + class FakeTransport: + def close(self): + pass + + def get_extra_info(self, name, default=None): + return default + + server._transport = FakeTransport() + server.reader = FakeReader() + server.connection_lost(None) + assert server._transport is None + + +@pytest.mark.asyncio +async def test_data_received_trace_log(): + from telnetlib3.accessories import TRACE + + server = BaseServer(encoding=False) + + class FakeTransport: + def get_extra_info(self, name, default=None): + return ("127.0.0.1", 9999) if name == "peername" else default + + def write(self, data): + pass + + def is_closing(self): + return False + + def close(self): + pass + + server.connection_made(FakeTransport()) + from telnetlib3 import server_base + + old_level = server_base.logger.level + server_base.logger.setLevel(TRACE) + try: + server.data_received(b"hello") + finally: + server_base.logger.setLevel(old_level) + + +@pytest.mark.asyncio +async def test_data_received_fast_path_no_iac(): + server = BaseServer(encoding=False) + + class FakeTransport: + def get_extra_info(self, name, default=None): + return ("127.0.0.1", 9999) if name == "peername" else default + + def write(self, data): + pass + + def is_closing(self): + return False + + def close(self): + pass + + server.connection_made(FakeTransport()) + server.writer.slc_simulated = False + server.data_received(b"hello world") + assert len(server.reader._buffer) >= len(b"hello world") + + +def _make_telnet_server(**kwargs): + """Create a TelnetServer with a FakeTransport for unit testing.""" + defaults = {"encoding": False, "connect_maxwait": 0.01} + defaults.update(kwargs) + server = TelnetServer(**defaults) + + class FakeTransport: + def get_extra_info(self, name, default=None): + return ("127.0.0.1", 9999) if name == "peername" else default + + def write(self, data): + pass + + def is_closing(self): + return False + + def close(self): + pass + + server.connection_made(FakeTransport()) + return server + + +@pytest.mark.asyncio +async def test_check_negotiation_deferred_echo_environ(): + """check_negotiation triggers deferred ECHO/NEW_ENVIRON when TTYPE refused.""" + from telnetlib3.telopt import TTYPE + + server = _make_telnet_server() + server._advanced = True + server._echo_negotiated = False + server._environ_requested = False + server.writer.remote_option[TTYPE] = False + + echo_calls = [] + environ_calls = [] + server._negotiate_echo = lambda: echo_calls.append(True) + server._negotiate_environ = lambda: environ_calls.append(True) + + server.check_negotiation() + assert len(echo_calls) == 1 + assert len(environ_calls) == 1 + + +@pytest.mark.asyncio +async def test_check_negotiation_final_subneg_timeout_warning(caplog): + """check_negotiation warns when critical subneg times out.""" + from telnetlib3.telopt import SB, NEW_ENVIRON + + server = _make_telnet_server() + server._advanced = True + server._echo_negotiated = True + server._environ_requested = True + server.writer.pending_option[SB + NEW_ENVIRON] = True + + with caplog.at_level(logging.WARNING, logger="telnetlib3.server"): + server.check_negotiation(final=True) + + assert "critical subnegotiation" in caplog.text.lower() or "environ" in caplog.text.lower() + + +@pytest.mark.asyncio +async def test_check_encoding_binary_incoming_request(): + """_check_encoding sends DO BINARY when outbinary set but not inbinary.""" + from telnetlib3.telopt import DO, BINARY + + server = _make_telnet_server() + server.writer.local_option[BINARY] = True + server.writer.remote_option[BINARY] = False + + iac_calls = [] + orig_iac = server.writer.iac + + def track_iac(*args): + iac_calls.append(args) + return orig_iac(*args) + + server.writer.iac = track_iac + result = server._check_encoding() + assert result is False + assert any(args == (DO, BINARY) for args in iac_calls) + + +@pytest.mark.asyncio +async def test_tls_autodetect_empty_peek(): + """TLS auto-detect closes transport on empty peek.""" + proto = _TLSAutoDetectProtocol(ssl_module.SSLContext(ssl_module.PROTOCOL_TLS_SERVER), MagicMock) + transport = MagicMock() + mock_sock = MagicMock() + transport.get_extra_info = MagicMock( + side_effect=lambda name, **kw: mock_sock if name == "socket" else None + ) + proto._transport = transport + + dup_sock = MagicMock() + dup_sock.recv.return_value = b"" + with patch("socket.fromfd", return_value=dup_sock): + proto._detect_tls() + + transport.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_tls_upgrade_handshake_failure(): + """_upgrade_to_tls handles SSLError gracefully.""" + proto = _TLSAutoDetectProtocol(ssl_module.SSLContext(ssl_module.PROTOCOL_TLS_SERVER), MagicMock) + transport = MagicMock() + transport.is_closing.return_value = False + proto._transport = transport + + loop = asyncio.get_event_loop() + with patch.object(loop, "start_tls", side_effect=ssl_module.SSLError("handshake failed")): + await proto._upgrade_to_tls() + + transport.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_status_logger_run_loop(): + """StatusLogger._run() logs when status changes.""" + mock_server = MagicMock() + mock_server.sockets = [] + logger_obj = StatusLogger(mock_server, interval=0.01) + + call_count = [0] + + def fake_status(): + call_count[0] += 1 + return {"count": call_count[0], "clients": []} + + logger_obj._get_status = fake_status + logger_obj.start() + await asyncio.sleep(0.1) + logger_obj.stop() + assert call_count[0] >= 2 + + +def test_parse_server_args_force_binary_auto(): + """parse_server_args auto-enables force_binary for non-ASCII encoding.""" + with patch("sys.argv", ["test", "--encoding", "cp437"]): + result = parse_server_args() + assert result["force_binary"] is True + + +def test_parse_server_args_ascii_no_force_binary(): + """parse_server_args does not auto-enable force_binary for ASCII.""" + with patch("sys.argv", ["test", "--encoding", "us-ascii"]): + result = parse_server_args() + assert result["force_binary"] is False + + +@pytest.mark.asyncio +async def test_run_server_guarded_shell_wrapping(): + """run_server wraps shell with robot_check and pty_fork_limit guards.""" + from telnetlib3.server import run_server, create_server + + created_server = MagicMock() + created_server.wait_closed = MagicMock(side_effect=asyncio.CancelledError) + + async def mock_create_server(*args, **kwargs): + created_server.shell = kwargs.get("shell") + return created_server + + with patch("telnetlib3.server.create_server", side_effect=mock_create_server): + with patch("asyncio.get_event_loop") as mock_loop: + mock_loop.return_value = asyncio.get_event_loop() + try: + await run_server( + host="127.0.0.1", + port=0, + shell=lambda r, w: None, + robot_check=True, + pty_fork_limit=2, + ) + except (asyncio.CancelledError, OSError): + pass + + assert created_server.shell is not None + + +@pytest.mark.asyncio +async def test_run_server_status_logger_lifecycle(): + """run_server starts and stops StatusLogger when status_interval > 0.""" + from telnetlib3.server import run_server + + created_server = MagicMock() + wait_future = asyncio.get_event_loop().create_future() + wait_future.set_result(None) + created_server.wait_closed = MagicMock(return_value=wait_future) + created_server.sockets = [] + + async def mock_create_server(*args, **kwargs): + return created_server + + with patch("telnetlib3.server.create_server", side_effect=mock_create_server): + loop = asyncio.get_event_loop() + with patch.object(loop, "add_signal_handler"): + with patch.object(loop, "remove_signal_handler"): + await run_server( + host="127.0.0.1", port=0, shell=lambda r, w: None, status_interval=1 + ) + + +@pytest.mark.asyncio +async def test_check_negotiation_ttype_resolved_no_pending(): + """check_negotiation triggers environ when TTYPE resolved with no pending.""" + from telnetlib3.telopt import TTYPE + + server = _make_telnet_server() + server._advanced = True + server._echo_negotiated = True + server._environ_requested = False + server.writer.remote_option[TTYPE] = True + + environ_calls = [] + server._negotiate_environ = lambda: environ_calls.append(True) + + server.check_negotiation() + assert len(environ_calls) == 1 + + +@pytest.mark.asyncio +async def test_check_encoding_charset_request(): + """_check_encoding sends CHARSET REQUEST when both sides support it.""" + from telnetlib3.telopt import CHARSET + + server = _make_telnet_server() + server.writer.remote_option[CHARSET] = True + server.writer.local_option[CHARSET] = True + + charset_calls = [] + server.writer.request_charset = lambda: charset_calls.append(True) + server._check_encoding() + assert len(charset_calls) == 1 + + +@pytest.mark.asyncio +async def test_data_received_no_iac_batch(): + """data_received fast path batches data without IAC bytes.""" + server = BaseServer(encoding=False) + + class FakeTransport: + def get_extra_info(self, name, default=None): + return ("127.0.0.1", 9999) if name == "peername" else default + + def write(self, data): + pass + + def is_closing(self): + return False + + def close(self): + pass + + server.connection_made(FakeTransport()) + server.writer.slc_simulated = False + data = b"plain text no iac" + server.data_received(data) + assert bytes(server.reader._buffer).endswith(data) diff --git a/telnetlib3/tests/test_server_api.py b/telnetlib3/tests/test_server_api.py index 1455d898..10be0905 100644 --- a/telnetlib3/tests/test_server_api.py +++ b/telnetlib3/tests/test_server_api.py @@ -3,14 +3,12 @@ # local from telnetlib3.telopt import IAC, WILL, WONT, TTYPE, BINARY -from telnetlib3.tests.accessories import bind_host # pytest fixture -from telnetlib3.tests.accessories import unused_tcp_port # pytest fixture from telnetlib3.tests.accessories import create_server, asyncio_connection async def test_server_wait_for_client(bind_host, unused_tcp_port): """Test Server.wait_for_client() returns protocol after negotiation.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WONT + TTYPE) client = await asyncio.wait_for(server.wait_for_client(), 0.5) @@ -21,7 +19,7 @@ async def test_server_wait_for_client(bind_host, unused_tcp_port): async def test_server_clients_list(bind_host, unused_tcp_port): """Test Server.clients property returns list of connected protocols.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: assert server.clients == [] async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): @@ -34,7 +32,7 @@ async def test_server_clients_list(bind_host, unused_tcp_port): async def test_server_client_disconnect_cleanup(bind_host, unused_tcp_port): """Test that clients are removed from list on disconnect.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WONT + TTYPE) client = await asyncio.wait_for(server.wait_for_client(), 0.5) @@ -63,22 +61,22 @@ async def test_server_sockets(bind_host, unused_tcp_port): async def test_server_with_wait_for(bind_host, unused_tcp_port): """Test integration of Server.wait_for_client() with writer.wait_for().""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=2.0) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): # Send WILL BINARY and WONT TTYPE writer.write(IAC + WILL + BINARY) writer.write(IAC + WONT + TTYPE) - client = await asyncio.wait_for(server.wait_for_client(), 0.5) + client = await asyncio.wait_for(server.wait_for_client(), 5.0) # Use wait_for to check specific negotiation state - await asyncio.wait_for(client.writer.wait_for(remote={"BINARY": True}), 0.5) + await asyncio.wait_for(client.writer.wait_for(remote={"BINARY": True}), 5.0) assert client.writer.remote_option[BINARY] is True async def test_server_multiple_sequential_clients(bind_host, unused_tcp_port): """Test wait_for_client() works for multiple sequential connections.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: # First client async with asyncio_connection(bind_host, unused_tcp_port) as (reader1, writer1): writer1.write(IAC + WONT + TTYPE) @@ -94,3 +92,24 @@ async def test_server_multiple_sequential_clients(bind_host, unused_tcp_port): client2 = await asyncio.wait_for(server.wait_for_client(), 0.5) assert client2 is not None assert client2 is not client1 + + +async def test_create_server_line_mode_param(bind_host, unused_tcp_port): + """create_server() accepts line_mode=True without error.""" + async with create_server( + host=bind_host, port=unused_tcp_port, line_mode=True, connect_maxwait=0.5 + ) as server: + async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + writer.write(IAC + WONT + TTYPE) + client = await asyncio.wait_for(server.wait_for_client(), 0.5) + assert client is not None + assert client.line_mode is True + + +async def test_create_server_line_mode_default_false(bind_host, unused_tcp_port): + """create_server() defaults to line_mode=False.""" + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: + async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + writer.write(IAC + WONT + TTYPE) + client = await asyncio.wait_for(server.wait_for_client(), 0.5) + assert client.line_mode is False diff --git a/telnetlib3/tests/test_server_cli.py b/telnetlib3/tests/test_server_cli.py index 9a827f20..56561f9f 100644 --- a/telnetlib3/tests/test_server_cli.py +++ b/telnetlib3/tests/test_server_cli.py @@ -27,9 +27,9 @@ def test_parse_server_args_includes_pty_options_when_supported(): pytest.skip("PTY not supported on this platform") with mock.patch.object(sys, "argv", ["server"]): - result = server.parse_server_args() - assert "pty_exec" in result - assert "pty_fork_limit" in result + args = server.parse_server_args() + assert "pty_exec" in args + assert "pty_fork_limit" in args def test_parse_server_args_excludes_pty_options_when_not_supported(): @@ -38,10 +38,10 @@ def test_parse_server_args_excludes_pty_options_when_not_supported(): try: server.PTY_SUPPORT = False with mock.patch.object(sys, "argv", ["server"]): - result = server.parse_server_args() - assert result["pty_exec"] is None - assert result["pty_fork_limit"] == 0 - assert result["pty_args"] is None + args = server.parse_server_args() + assert args["pty_exec"] is None + assert args["pty_fork_limit"] == 0 + assert args["pty_args"] is None finally: server.PTY_SUPPORT = original_support @@ -73,12 +73,22 @@ def test_telnetlib3_pty_shell_exports_conditional(): assert "pty_shell" not in telnetlib3.__all__ -def test_parse_server_args_never_send_ga(): +@pytest.mark.parametrize( + "argv,expected", [(["server"], False), (["server", "--never-send-ga"], True)] +) +def test_parse_server_args_never_send_ga(argv, expected): """--never-send-ga flag is parsed correctly.""" - with mock.patch.object(sys, "argv", ["server"]): - result = server.parse_server_args() - assert result["never_send_ga"] is False - - with mock.patch.object(sys, "argv", ["server", "--never-send-ga"]): - result = server.parse_server_args() - assert result["never_send_ga"] is True + with mock.patch.object(sys, "argv", argv): + assert server.parse_server_args()["never_send_ga"] is expected + + +@pytest.mark.parametrize( + "argv,expected_line_mode,expected_pty_raw", + [(["server"], False, server.PTY_SUPPORT), (["server", "--line-mode"], True, False)], +) +def test_parse_server_args_line_mode(argv, expected_line_mode, expected_pty_raw): + """--line-mode flag sets both line_mode and pty_raw.""" + with mock.patch.object(sys, "argv", argv): + args = server.parse_server_args() + assert args["line_mode"] is expected_line_mode + assert args["pty_raw"] is expected_pty_raw diff --git a/telnetlib3/tests/test_server_fingerprinting.py b/telnetlib3/tests/test_server_fingerprinting.py index 685ee2fe..efbb5cb4 100644 --- a/telnetlib3/tests/test_server_fingerprinting.py +++ b/telnetlib3/tests/test_server_fingerprinting.py @@ -1,3 +1,5 @@ +from __future__ import annotations + # std imports import json import asyncio @@ -52,6 +54,7 @@ def __init__(self, extra=None, will_options=None, wont_options=None): self.comport_data: dict[str, object] | None = None self.protocol = _MockProtocol() self._closing = False + self._menu_inline: bool = False def get_extra_info(self, key, default=None): return self._extra.get(key, default) @@ -118,6 +121,16 @@ async def read(self, n): _BINARY_PROBE = {"BINARY": {"status": "WILL", "opt": fps.BINARY}} +_FP_KWARGS = {"silent": True, "banner_quiet_time": 0.01, "banner_max_wait": 0.01, "mssp_wait": 0.01} + + +async def _run_fp(reader, writer, tmp_path, **extra): + save_path = str(tmp_path / "result.json") + await sfp.fingerprinting_client_shell( + reader, writer, host="localhost", port=23, save_path=save_path, **_FP_KWARGS, **extra + ) + return save_path + def _save(writer=None, save_path=None, **overrides): session_data = { @@ -242,18 +255,32 @@ def test_server_fingerprint_hash_consistency(): assert h1 == h2 and len(h1) == 16 -def test_format_banner(): - assert sfp._format_banner(b"Hello\r\nWorld") == "Hello\r\nWorld" - assert not sfp._format_banner(b"") - - -def test_format_banner_surrogateescape(): - """High bytes are preserved as surrogates, not replaced with U+FFFD.""" - result = sfp._format_banner(b"\xff\xfe\xb1") - assert "\ufffd" not in result - assert result == "\udcff\udcfe\udcb1" - raw = result.encode("ascii", errors="surrogateescape") - assert raw == b"\xff\xfe\xb1" +@pytest.mark.parametrize( + "data,encoding,checks", + [ + (b"Hello\r\nWorld", None, [("eq", "Hello\r\nWorld")]), + (b"", None, [("falsy", None)]), + (b"\xff\xfe\xb1", None, [("not_in", "\ufffd"), ("eq", "\udcff\udcfe\udcb1")]), + (b"Hello\xb1World", "x-no-such-codec", [("eq", "Hello\xb1World")]), + (b"Hello\x9b", "atascii", [("eq", "Hello\n")]), + (b"\x1c\xc8\xc9", "petscii", [("in", "\x1b[38;2;"), ("in", "HI")]), + (b"\x12\xc8\xc9\x92", "petscii", [("in", "\x1b[7m"), ("in", "\x1b[27m")]), + (b"\xc8\xc9\x0d\xca\xcb", "petscii", [("eq", "HI\nJK")]), + (b"\x13\xc8\xc9", "petscii", [("in", "\x1b[H"), ("in", "HI")]), + ], +) +def test_format_banner(data, encoding, checks): + kwargs = {"encoding": encoding} if encoding else {} + result = sfp._format_banner(data, **kwargs) + for check_type, check_val in checks: + if check_type == "eq": + assert result == check_val + elif check_type == "in": + assert check_val in result + elif check_type == "not_in": + assert check_val not in result + elif check_type == "falsy": + assert not result def test_format_banner_json_roundtrip(): @@ -266,47 +293,6 @@ def test_format_banner_json_roundtrip(): assert raw == b"Hello\xb1\xb2World" -def test_format_banner_unknown_encoding_fallback(): - """Unknown encoding falls back to latin-1 instead of raising LookupError.""" - result = sfp._format_banner(b"Hello\xb1World", encoding="x-no-such-codec") - assert result == "Hello\xb1World" - assert result == b"Hello\xb1World".decode("latin-1") - - -def test_format_banner_atascii(): - """ATASCII encoding decodes banner bytes through the registered codec.""" - result = sfp._format_banner(b"Hello\x9b", encoding="atascii") - assert result == "Hello\n" - - -def test_format_banner_petscii_color(): - """PETSCII color codes are translated to ANSI 24-bit RGB in banners.""" - result = sfp._format_banner(b"\x1c\xc8\xc9", encoding="petscii") - assert "\x1b[38;2;" in result - assert "HI" in result - assert "\x1c" not in result - - -def test_format_banner_petscii_rvs(): - """PETSCII RVS ON/OFF are translated to ANSI reverse in banners.""" - result = sfp._format_banner(b"\x12\xc8\xc9\x92", encoding="petscii") - assert "\x1b[7m" in result - assert "\x1b[27m" in result - - -def test_format_banner_petscii_newline(): - """PETSCII CR line terminators are normalized to LF in banners.""" - result = sfp._format_banner(b"\xc8\xc9\x0d\xca\xcb", encoding="petscii") - assert "HI\nJK" == result - - -def test_format_banner_petscii_cursor(): - """PETSCII cursor controls are translated to ANSI in banners.""" - result = sfp._format_banner(b"\x13\xc8\xc9", encoding="petscii") - assert "\x1b[H" in result - assert "HI" in result - - @pytest.mark.parametrize( "data,expected", [ @@ -341,8 +327,7 @@ async def test_read_banner(): async def test_read_banner_max_bytes(): big = b"A" * 200 reader = MockReader([big]) - result = await sfp._read_banner(reader, timeout=0.1, max_bytes=50) - assert result == b"A" * 50 + assert await sfp._read_banner(reader, timeout=0.1, max_bytes=50) == b"A" * 50 @pytest.mark.asyncio @@ -449,21 +434,10 @@ def test_banner_data_in_saved_fingerprint(tmp_path): @pytest.mark.asyncio async def test_fingerprinting_client_shell(tmp_path): - save_path = str(tmp_path / "result.json") reader = MockReader([b"Welcome to BBS\r\nLogin: "]) writer = MockWriter(will_options=[fps.SGA, fps.ECHO]) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + save_path = await _run_fp(reader, writer, tmp_path) assert writer._closing with open(save_path, encoding="utf-8") as f: @@ -479,14 +453,7 @@ async def test_fingerprinting_client_shell_no_save(monkeypatch): writer = MockWriter() await sfp.fingerprinting_client_shell( - MockReader([]), - writer, - host="localhost", - port=23, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, + MockReader([]), writer, host="localhost", port=23, **_FP_KWARGS ) assert writer._closing @@ -576,22 +543,10 @@ async def test_fingerprinting_client_shell_set_name(tmp_path, monkeypatch): monkeypatch.setattr(fps, "DATA_DIR", str(tmp_path)) - save_path = str(tmp_path / "result.json") reader = MockReader([b"Welcome"]) writer = MockWriter(will_options=[fps.SGA, fps.ECHO]) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - set_name="my-bbs", - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + save_path = await _run_fp(reader, writer, tmp_path, set_name="my-bbs") with open(save_path, encoding="utf-8") as f: data = json.load(f) @@ -604,21 +559,9 @@ async def test_fingerprinting_client_shell_set_name(tmp_path, monkeypatch): @pytest.mark.asyncio async def test_fingerprinting_client_shell_encoding(tmp_path): - save_path = str(tmp_path / "result.json") writer = MockWriter(will_options=[fps.SGA]) - await sfp.fingerprinting_client_shell( - MockReader([]), - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - environ_encoding="cp037", - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + save_path = await _run_fp(MockReader([]), writer, tmp_path, environ_encoding="cp037") with open(save_path, encoding="utf-8") as f: data = json.load(f) @@ -632,15 +575,7 @@ async def test_fingerprinting_client_shell_set_name_no_data_dir(monkeypatch): writer = MockWriter() await sfp.fingerprinting_client_shell( - MockReader([]), - writer, - host="localhost", - port=23, - silent=True, - set_name="should-warn", - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, + MockReader([]), writer, host="localhost", port=23, set_name="should-warn", **_FP_KWARGS ) assert writer._closing @@ -682,20 +617,9 @@ async def test_fingerprinting_client_shell_mssp(tmp_path, monkeypatch, capsys): @pytest.mark.asyncio async def test_fingerprinting_client_shell_no_mssp(tmp_path): - save_path = str(tmp_path / "result.json") writer = MockWriter(will_options=[fps.SGA]) - await sfp.fingerprinting_client_shell( - MockReader([]), - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + save_path = await _run_fp(MockReader([]), writer, tmp_path) with open(save_path, encoding="utf-8") as f: data = json.load(f) @@ -727,14 +651,7 @@ async def test_fingerprinting_client_shell_connection_error(exc): writer = MockWriter() await sfp.fingerprinting_client_shell( - ErrorReader(exc), - writer, - host="192.0.2.1", - port=23, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, + ErrorReader(exc), writer, host="192.0.2.1", port=23, **_FP_KWARGS ) assert writer._closing @@ -767,22 +684,10 @@ async def test_scan_type_recorded_in_fingerprint(tmp_path): """scan_type appears in both session_data and fingerprint-data.""" for scan_type in ("quick", "full"): - save_path = str(tmp_path / f"{scan_type}.json") reader = MockReader([b"Welcome"]) writer = MockWriter(will_options=[fps.SGA]) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - scan_type=scan_type, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + save_path = await _run_fp(reader, writer, tmp_path, scan_type=scan_type) with open(save_path, encoding="utf-8") as f: data = json.load(f) @@ -802,21 +707,10 @@ def test_parse_environ_send_empty_payload(): async def test_probe_skipped_when_closing(tmp_path): """Probe burst is skipped when the connection is already closed.""" - save_path = str(tmp_path / "result.json") writer = MockWriter(will_options=[fps.SGA]) writer._closing = True - await sfp.fingerprinting_client_shell( - MockReader([]), - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + save_path = await _run_fp(MockReader([]), writer, tmp_path) assert not writer._iac_calls with open(save_path, encoding="utf-8") as f: @@ -826,240 +720,193 @@ async def test_probe_skipped_when_closing(tmp_path): @pytest.mark.parametrize( - "banner,expected", + "banner,expected_response,expected_encoding", [ - pytest.param(b"Welcome\r\n", None, id="no_prompt"), - pytest.param(b"", None, id="empty"), - pytest.param(b"Continue? (yes/no) ", b"yes\r\n", id="yes_no_parens"), - pytest.param(b"Continue? (y/n) ", b"y\r\n", id="y_n_parens"), - pytest.param(b"Accept terms? [Yes/No]:", b"yes\r\n", id="yes_no_brackets"), - pytest.param(b"Accept? [Y/N]:", b"y\r\n", id="y_n_brackets"), - pytest.param(b"Accept YES/NO now", b"yes\r\n", id="yes_no_uppercase"), - pytest.param(b"Confirm y/n\r\n> ", b"y\r\n", id="y_n_trailing_newline"), - pytest.param(b"Type yes/no please", b"yes\r\n", id="yes_no_space_delimited"), - pytest.param(b"Continue? (Yes|No) ", b"yes\r\n", id="yes_pipe_no_parens"), - pytest.param(b"Accept? (YES|NO):", b"yes\r\n", id="yes_pipe_no_upper"), - pytest.param(b"systemd/network", None, id="false_positive_word"), - pytest.param(b"beyond", None, id="substring_y_n_not_matched"), - pytest.param(b"Enter your name:", None, id="name_prompt_no_who"), - pytest.param(b"Color? ", b"y\r\n", id="color_question"), - pytest.param(b"Do you want color? ", b"y\r\n", id="color_in_sentence"), - pytest.param(b"ANSI COLOR? ", b"y\r\n", id="color_uppercase"), - pytest.param(b"color ? ", b"y\r\n", id="color_space_before_question"), - pytest.param(b"colorful display", None, id="color_no_question_mark"), + pytest.param(b"Welcome\r\n", None, None, id="no_prompt"), + pytest.param(b"", None, None, id="empty"), + pytest.param(b"nothing special", None, None, id="none_no_encoding"), + pytest.param(b"Continue? (yes/no) ", b"yes\r\n", None, id="yes_no_parens"), + pytest.param(b"Continue? (y/n) ", b"y\r\n", None, id="y_n_parens"), + pytest.param(b"Accept terms? [Yes/No]:", b"yes\r\n", None, id="yes_no_brackets"), + pytest.param(b"Accept? [Y/N]:", b"y\r\n", None, id="y_n_brackets"), + pytest.param(b"Accept YES/NO now", b"yes\r\n", None, id="yes_no_uppercase"), + pytest.param(b"Confirm y/n\r\n> ", b"y\r\n", None, id="y_n_trailing_newline"), + pytest.param(b"Type yes/no please", b"yes\r\n", None, id="yes_no_space_delimited"), + pytest.param(b"Continue? (Yes|No) ", b"yes\r\n", None, id="yes_pipe_no_parens"), + pytest.param(b"Accept? (YES|NO):", b"yes\r\n", None, id="yes_pipe_no_upper"), + pytest.param(b"yes/no", b"yes\r\n", None, id="yn_no_encoding"), + pytest.param(b"systemd/network", None, None, id="false_positive_word"), + pytest.param(b"beyond", None, None, id="substring_y_n_not_matched"), + pytest.param(b"Enter your name:", None, None, id="name_prompt_no_who"), + pytest.param(b"Color? ", b"y\r\n", None, id="color_question"), + pytest.param(b"Do you want color? ", b"y\r\n", None, id="color_in_sentence"), + pytest.param(b"ANSI COLOR? ", b"y\r\n", None, id="color_uppercase"), + pytest.param(b"color ? ", b"y\r\n", None, id="color_space_before_question"), + pytest.param(b"colorful display", None, None, id="color_no_question_mark"), pytest.param( b"Select charset:\r\n1) ASCII\r\n2) ISO-8859-1\r\n5) UTF-8\r\n", b"5\r\n", + "utf-8", id="menu_utf8", ), - pytest.param(b"3) utf-8\r\nChoose: ", b"3\r\n", id="menu_utf8_lowercase"), - pytest.param(b"Choose encoding: 1) UTF8", b"1\r\n", id="menu_utf8_no_hyphen"), - pytest.param(b"12) UTF-8\r\nSelect: ", b"12\r\n", id="menu_utf8_multidigit"), - pytest.param(b"[5] UTF-8\r\nSelect: ", b"5\r\n", id="menu_utf8_brackets"), - pytest.param(b"[2] utf-8\r\n", b"2\r\n", id="menu_utf8_brackets_lower"), - pytest.param(b"3. UTF-8\r\n", b"3\r\n", id="menu_utf8_dot"), - pytest.param(b" 5 ... UTF-8\r\n", b"5\r\n", id="menu_utf8_ellipsis"), - pytest.param(b"1) ASCII\r\n2) Latin-1\r\n", None, id="menu_no_utf8"), - pytest.param(b"(1) Ansi\r\n(2) VT100\r\n", b"1\r\n", id="menu_ansi_parens"), - pytest.param(b"[1] ANSI\r\n[2] VT100\r\n", b"1\r\n", id="menu_ansi_brackets"), - pytest.param(b"(3) ansi\r\n", b"3\r\n", id="menu_ansi_lowercase"), - pytest.param(b"[12] Ansi\r\n", b"12\r\n", id="menu_ansi_multidigit"), - pytest.param(b"(1] ANSI\r\n", b"1\r\n", id="menu_ansi_mixed_brackets"), - pytest.param(b"3. ANSI\r\n", b"3\r\n", id="menu_ansi_dot"), - pytest.param(b"3. English/ANSI\r\n", b"3\r\n", id="menu_english_ansi"), - pytest.param(b"2. English/ANSI\r\n", b"2\r\n", id="menu_english_ansi_2"), + pytest.param(b"3) utf-8\r\nChoose: ", b"3\r\n", "utf-8", id="menu_utf8_lowercase"), + pytest.param(b"Choose encoding: 1) UTF8", b"1\r\n", "utf-8", id="menu_utf8_no_hyphen"), + pytest.param(b"12) UTF-8\r\nSelect: ", b"12\r\n", "utf-8", id="menu_utf8_multidigit"), + pytest.param(b"[5] UTF-8\r\nSelect: ", b"5\r\n", "utf-8", id="menu_utf8_brackets"), + pytest.param(b"[2] utf-8\r\n", b"2\r\n", "utf-8", id="menu_utf8_brackets_lower"), + pytest.param(b"3. UTF-8\r\n", b"3\r\n", "utf-8", id="menu_utf8_dot"), + pytest.param(b" 5 ... UTF-8\r\n", b"5\r\n", "utf-8", id="menu_utf8_ellipsis"), + pytest.param(b"1) ASCII\r\n2) Latin-1\r\n", None, None, id="menu_no_utf8"), + pytest.param(b"(1) Ansi\r\n", b"1\r\n", None, id="ansi_no_encoding"), + pytest.param(b"(1) Ansi\r\n(2) VT100\r\n", b"1\r\n", None, id="menu_ansi_parens"), + pytest.param(b"[1] ANSI\r\n[2] VT100\r\n", b"1\r\n", None, id="menu_ansi_brackets"), + pytest.param(b"(3) ansi\r\n", b"3\r\n", None, id="menu_ansi_lowercase"), + pytest.param(b"[12] Ansi\r\n", b"12\r\n", None, id="menu_ansi_multidigit"), + pytest.param(b"(1] ANSI\r\n", b"1\r\n", None, id="menu_ansi_mixed_brackets"), + pytest.param(b"3. ANSI\r\n", b"3\r\n", None, id="menu_ansi_dot"), + pytest.param(b"3. English/ANSI\r\n", b"3\r\n", None, id="menu_english_ansi"), + pytest.param(b"2. English/ANSI\r\n", b"2\r\n", None, id="menu_english_ansi_2"), pytest.param( - b" 1 ... English/ANSI The standard\r\n", b"1\r\n", id="menu_ansi_ellipsis" + b" 1 ... English/ANSI The standard\r\n", b"1\r\n", None, id="menu_ansi_ellipsis" ), - pytest.param(b" 2 .. English/ANSI\r\n", b"2\r\n", id="menu_ansi_double_dot"), + pytest.param(b" 2 .. English/ANSI\r\n", b"2\r\n", None, id="menu_ansi_double_dot"), pytest.param( - b"1) ASCII\r\n2) UTF-8\r\n(3) Ansi\r\n", b"2\r\n", id="menu_utf8_preferred_over_ansi" + b"1) ASCII\r\n2) UTF-8\r\n(3) Ansi\r\n", + b"2\r\n", + "utf-8", + id="menu_utf8_preferred_over_ansi", ), pytest.param( b"1. ASCII\r\n2. UTF-8\r\n3. English/ANSI\r\n", b"2\r\n", + "utf-8", id="menu_utf8_dot_preferred_over_ansi_dot", ), - pytest.param(b"gb/big5", b"big5\r\n", id="gb_big5"), - pytest.param(b"GB/Big5\r\n", b"big5\r\n", id="gb_big5_mixed_case"), - pytest.param(b"Select: GB / Big5 ", b"big5\r\n", id="gb_big5_spaces"), - pytest.param(b"gb/big 5\r\n", b"big5\r\n", id="gb_big5_space_before_5"), - pytest.param(b"bigfoot5", None, id="big5_inside_word_not_matched"), + pytest.param(b"gb/big5", b"big5\r\n", "big5", id="gb_big5"), + pytest.param(b"GB/Big5\r\n", b"big5\r\n", "big5", id="gb_big5_mixed_case"), + pytest.param(b"Select: GB / Big5 ", b"big5\r\n", "big5", id="gb_big5_spaces"), + pytest.param(b"gb/big 5\r\n", b"big5\r\n", "big5", id="gb_big5_space_before_5"), + pytest.param(b"bigfoot5", None, None, id="big5_inside_word_not_matched"), pytest.param( b"Press [.ESC.] twice within 15 seconds to CONTINUE...", b"\x1b\x1b", + None, id="esc_twice_mystic", ), - pytest.param(b"Press [ESC] twice to continue", b"\x1b\x1b", id="esc_twice_no_dots"), - pytest.param(b"Press ESC twice to continue", b"\x1b\x1b", id="esc_twice_bare"), + pytest.param(b"Press [ESC] twice to continue", b"\x1b\x1b", None, id="esc_twice_no_dots"), + pytest.param(b"Press ESC twice to continue", b"\x1b\x1b", None, id="esc_twice_bare"), pytest.param( - b"Press twice for the BBS ... ", b"\x1b\x1b", id="esc_twice_angle_brackets" + b"Press twice for the BBS ... ", b"\x1b\x1b", None, id="esc_twice_angle_brackets" ), pytest.param( b"\x1b[33mPress [.ESC.] twice within 10 seconds\x1b[0m", b"\x1b\x1b", + None, id="esc_twice_ansi_wrapped", ), pytest.param( - b"\x1b[1;1H\x1b[2JPress [.ESC.] twice within 15 seconds to CONTINUE...", + b"\x1b[1;1H\x1b[2JPress [.ESC.] twice within 15 seconds" b" to CONTINUE...", b"\x1b\x1b", + None, id="esc_twice_after_clear_screen", ), - pytest.param(b"Please press [ESC] to continue", b"\x1b", id="esc_once_brackets"), - pytest.param(b"Press ESC to continue", b"\x1b", id="esc_once_bare"), - pytest.param(b"press to continue", b"\x1b", id="esc_once_angle_brackets"), + pytest.param(b"Please press [ESC] to continue", b"\x1b", None, id="esc_once_brackets"), + pytest.param(b"Press ESC to continue", b"\x1b", None, id="esc_once_bare"), + pytest.param(b"press to continue", b"\x1b", None, id="esc_once_angle_brackets"), pytest.param( - b"\x1b[33mPress [ESC] to continue\x1b[0m", b"\x1b", id="esc_once_ansi_wrapped" + b"\x1b[33mPress [ESC] to continue\x1b[0m", b"\x1b", None, id="esc_once_ansi_wrapped" ), - pytest.param(b"HIT RETURN:", b"\r\n", id="hit_return"), - pytest.param(b"Hit Return.", b"\r\n", id="hit_return_lower"), - pytest.param(b"PRESS RETURN:", b"\r\n", id="press_return"), - pytest.param(b"Press Enter:", b"\r\n", id="press_enter"), - pytest.param(b"press enter", b"\r\n", id="press_enter_lower"), - pytest.param(b"Hit Enter to continue", b"\r\n", id="hit_enter"), - pytest.param(b"\x1b[1mHIT RETURN:\x1b[0m", b"\r\n", id="hit_return_ansi_wrapped"), - pytest.param(b"\x1b[31mColor? \x1b[0m", b"y\r\n", id="color_ansi_wrapped"), - pytest.param(b"\x1b[1mContinue? (y/n)\x1b[0m ", b"y\r\n", id="yn_ansi_wrapped"), + pytest.param(b"HIT RETURN:", b"\r\n", None, id="hit_return"), + pytest.param(b"Hit Return.", b"\r\n", None, id="hit_return_lower"), + pytest.param(b"PRESS RETURN:", b"\r\n", None, id="press_return"), + pytest.param(b"Press Enter:", b"\r\n", None, id="press_enter"), + pytest.param(b"press enter", b"\r\n", None, id="press_enter_lower"), + pytest.param(b"Hit Enter to continue", b"\r\n", None, id="hit_enter"), + pytest.param(b"\x1b[1mHIT RETURN:\x1b[0m", b"\r\n", None, id="hit_return_ansi_wrapped"), + pytest.param(b"\x1b[31mColor? \x1b[0m", b"y\r\n", None, id="color_ansi_wrapped"), + pytest.param(b"\x1b[1mContinue? (y/n)\x1b[0m ", b"y\r\n", None, id="yn_ansi_wrapped"), pytest.param( - b"Do you support the ANSI color standard (Yn)? ", b"y\r\n", id="yn_paren_capital_y" + b"Do you support the ANSI color standard (Yn)? ", + b"y\r\n", + None, + id="yn_paren_capital_y", ), - pytest.param(b"Continue? [Yn]", b"y\r\n", id="yn_bracket_capital_y"), - pytest.param(b"Do something (yN)", b"y\r\n", id="yn_paren_capital_n"), - pytest.param(b"More: (Y)es, (N)o, (C)ontinuous?", b"C\r\n", id="more_continuous"), + pytest.param(b"Continue? [Yn]", b"y\r\n", None, id="yn_bracket_capital_y"), + pytest.param(b"Do something (yN)", b"y\r\n", None, id="yn_paren_capital_n"), + pytest.param(b"More: (Y)es, (N)o, (C)ontinuous?", b"C\r\n", None, id="more_continuous"), pytest.param( - b"\x1b[33mMore: (Y)es, (N)o, (C)ontinuous?\x1b[0m", b"C\r\n", id="more_continuous_ansi" + b"\x1b[33mMore: (Y)es, (N)o, (C)ontinuous?\x1b[0m", + b"C\r\n", + None, + id="more_continuous_ansi", ), - pytest.param(b"more (Y/N/C)ontinuous: ", b"C\r\n", id="more_ync_compact"), + pytest.param(b"more (Y/N/C)ontinuous: ", b"C\r\n", None, id="more_ync_compact"), pytest.param( b"Press the BACKSPACE key to detect your terminal type: ", b"\x08", + None, id="backspace_key_telnetbible", ), pytest.param( - b"\x1b[1mPress the BACKSPACE key\x1b[0m", b"\x08", id="backspace_key_ansi_wrapped" + b"\x1b[1mPress the BACKSPACE key\x1b[0m", b"\x08", None, id="backspace_key_ansi_wrapped" + ), + pytest.param(b"\x0cpress del/backspace:", b"\x14", None, id="petscii_del_backspace"), + pytest.param( + b"\x0c\r\npress del/backspace:", b"\x14", None, id="petscii_del_backspace_crlf" ), - pytest.param(b"\x0cpress del/backspace:", b"\x14", id="petscii_del_backspace"), - pytest.param(b"\x0c\r\npress del/backspace:", b"\x14", id="petscii_del_backspace_crlf"), - pytest.param(b"press backspace:", b"\x14", id="petscii_backspace_only"), - pytest.param(b"press del:", b"\x14", id="petscii_del_only"), - pytest.param(b"PRESS DEL/BACKSPACE.", b"\x14", id="petscii_del_backspace_upper"), - pytest.param(b"press backspace/del:", b"\x14", id="petscii_backspace_del_reversed"), + pytest.param(b"press backspace:", b"\x14", None, id="petscii_backspace_only"), + pytest.param(b"press del:", b"\x14", None, id="petscii_del_only"), + pytest.param(b"PRESS DEL/BACKSPACE.", b"\x14", None, id="petscii_del_backspace_upper"), + pytest.param(b"press backspace/del:", b"\x14", None, id="petscii_backspace_del_reversed"), pytest.param( b"PLEASE HIT YOUR BACKSPACE/DELETE\r\nKEY FOR C/G DETECT:", b"\x14", + None, id="petscii_hit_your_backspace_delete", ), pytest.param( - b"hit your delete/backspace key:", b"\x14", id="petscii_hit_your_delete_backspace_key" + b"hit your delete/backspace key:", + b"\x14", + None, + id="petscii_hit_your_delete_backspace_key", ), ], ) -def test_detect_yn_prompt(banner, expected): - assert sfp._detect_yn_prompt(banner).response == expected +def test_detect_yn_prompt(banner, expected_response, expected_encoding): + result = sfp._detect_yn_prompt(banner) + assert result.response == expected_response + assert result.encoding == expected_encoding @pytest.mark.parametrize( - "banner, expected_encoding", + "banner,expected_write", [ - pytest.param(b"5) UTF-8\r\n", "utf-8", id="utf8_menu"), - pytest.param(b"[2] utf-8\r\n", "utf-8", id="utf8_brackets"), - pytest.param(b"1) UTF8", "utf-8", id="utf8_no_hyphen"), - pytest.param(b"gb/big5", "big5", id="gb_big5"), - pytest.param(b"GB/Big5\r\n", "big5", id="gb_big5_mixed"), - pytest.param(b"(1) Ansi\r\n", None, id="ansi_no_encoding"), - pytest.param(b"yes/no", None, id="yn_no_encoding"), - pytest.param(b"Color? ", None, id="color_no_encoding"), - pytest.param(b"nothing special", None, id="none_no_encoding"), + pytest.param(b"Do you accept? (y/n) ", b"y\r\n", id="yn_prompt"), + pytest.param(b"Continue? (yes/no) ", b"yes\r\n", id="yes_no_prompt"), + pytest.param( + b"Press [.ESC.] twice within 15 seconds to CONTINUE...", + b"\x1b\x1b", + id="esc_twice_prompt", + ), ], ) -def test_detect_yn_prompt_encoding(banner, expected_encoding): - assert sfp._detect_yn_prompt(banner).encoding == expected_encoding - - @pytest.mark.asyncio -async def test_fingerprinting_shell_yn_prompt(tmp_path): - """Banner with y/n prompt causes 'y\\r\\n' instead of bare '\\r\\n'.""" - save_path = str(tmp_path / "result.json") - reader = MockReader([b"Do you accept? (y/n) "]) +async def test_fingerprinting_shell_prompt_response(tmp_path, banner, expected_write): + reader = MockReader([banner]) writer = MockWriter(will_options=[fps.SGA]) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) - - assert b"y\r\n" in writer._writes - - -@pytest.mark.asyncio -async def test_fingerprinting_shell_yes_no_prompt(tmp_path): - """Banner with yes/no prompt causes 'yes\\r\\n' instead of bare '\\r\\n'.""" - save_path = str(tmp_path / "result.json") - reader = MockReader([b"Continue? (yes/no) "]) - writer = MockWriter(will_options=[fps.SGA]) - - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) - - assert b"yes\r\n" in writer._writes - - -@pytest.mark.asyncio -async def test_fingerprinting_shell_esc_twice_prompt(tmp_path): - """Banner with ESC-twice botcheck sends two raw ESC bytes.""" - save_path = str(tmp_path / "result.json") - reader = MockReader([b"Press [.ESC.] twice within 15 seconds to CONTINUE..."]) - writer = MockWriter(will_options=[fps.SGA]) - - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + await _run_fp(reader, writer, tmp_path) - assert b"\x1b\x1b" in writer._writes + assert expected_write in writer._writes @pytest.mark.asyncio async def test_fingerprinting_shell_no_yn_prompt(tmp_path): """Banner without y/n prompt sends bare '\\r\\n'.""" - save_path = str(tmp_path / "result.json") reader = MockReader([b"Welcome to BBS\r\n"]) writer = MockWriter(will_options=[fps.SGA]) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + await _run_fp(reader, writer, tmp_path) assert b"\r\n" in writer._writes assert b"y\r\n" not in writer._writes @@ -1069,23 +916,12 @@ async def test_fingerprinting_shell_no_yn_prompt(tmp_path): @pytest.mark.asyncio async def test_fingerprinting_shell_multi_prompt(tmp_path): """Server asks color first, then presents a UTF-8 charset menu.""" - save_path = str(tmp_path / "result.json") writer = MockWriter(will_options=[fps.SGA]) reader = InteractiveMockReader( [b"Color? ", b"Select charset:\r\n1) ASCII\r\n2) UTF-8\r\n", b"Welcome!\r\n"], writer ) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + await _run_fp(reader, writer, tmp_path) assert b"y\r\n" in writer._writes assert b"2\r\n" in writer._writes @@ -1096,21 +932,10 @@ async def test_fingerprinting_shell_multi_prompt(tmp_path): @pytest.mark.asyncio async def test_fingerprinting_shell_multi_prompt_stops_on_bare_return(tmp_path): """Loop stops after a bare \\r\\n response (no prompt detected).""" - save_path = str(tmp_path / "result.json") writer = MockWriter(will_options=[fps.SGA]) reader = InteractiveMockReader([b"Color? ", b"Welcome!\r\n"], writer) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + await _run_fp(reader, writer, tmp_path) assert b"y\r\n" in writer._writes prompt_writes = [w for w in writer._writes if w in (b"y\r\n", b"\r\n")] @@ -1121,108 +946,70 @@ async def test_fingerprinting_shell_multi_prompt_stops_on_bare_return(tmp_path): @pytest.mark.asyncio async def test_fingerprinting_shell_multi_prompt_max_replies(tmp_path): """Loop does not exceed _MAX_PROMPT_REPLIES rounds.""" - save_path = str(tmp_path / "result.json") writer = MockWriter(will_options=[fps.SGA]) banners = [f"Color? (round {i}) ".encode() for i in range(sfp._MAX_PROMPT_REPLIES + 1)] reader = InteractiveMockReader(banners, writer) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + await _run_fp(reader, writer, tmp_path) y_writes = [w for w in writer._writes if w == b"y\r\n"] assert len(y_writes) == sfp._MAX_PROMPT_REPLIES -class TestCullDisplay: - """Tests for _cull_display bytes conversion.""" - - def test_bytes_utf8(self): - assert sfp._cull_display(b"hello") == "hello" - - def test_bytes_binary(self): - assert sfp._cull_display(b"\x80\xff") == "80ff" +def test_cull_display_bytes_utf8(): + assert sfp._cull_display(b"hello") == "hello" - def test_bytes_in_dict(self): - result = sfp._cull_display({"data_bytes": b"\x01"}) - assert result == {"data_bytes": "\x01"} - json.dumps(result) - def test_bytes_in_nested_list(self): - result = sfp._cull_display({"items": [{"val": b"\xfe\xed"}]}) - assert result == {"items": [{"val": "feed"}]} - json.dumps(result) +def test_cull_display_bytes_binary(): + assert sfp._cull_display(b"\x80\xff") == "80ff" - def test_empty_bytes_culled(self): - result = sfp._cull_display({"data_bytes": b""}) - assert result == {} - -@pytest.mark.asyncio -async def test_read_banner_until_quiet_responds_to_dsr(): - """DSR (ESC[6n) in banner data triggers a CPR response (ESC[1;1R).""" - reader = MockReader([b"Hello\x1b[6nWorld"]) - writer = MockWriter() - result = await sfp._read_banner_until_quiet( - reader, quiet_time=0.01, max_wait=0.05, writer=writer - ) - assert result == b"Hello\x1b[6nWorld" - assert b"\x1b[1;1R" in writer._writes +def test_cull_display_bytes_in_dict(): + result = sfp._cull_display({"data_bytes": b"\x01"}) + assert result == {"data_bytes": "\x01"} + json.dumps(result) -@pytest.mark.asyncio -async def test_read_banner_until_quiet_multiple_dsr(): - """Multiple DSR requests each get a CPR response.""" - reader = MockReader([b"\x1b[6n", b"banner\x1b[6n"]) - writer = MockWriter() - await sfp._read_banner_until_quiet(reader, quiet_time=0.01, max_wait=0.05, writer=writer) - cpr_count = sum(1 for w in writer._writes if w == b"\x1b[1;1R") - assert cpr_count == 2 +def test_cull_display_bytes_in_nested_list(): + result = sfp._cull_display({"items": [{"val": b"\xfe\xed"}]}) + assert result == {"items": [{"val": "feed"}]} + json.dumps(result) -@pytest.mark.asyncio -async def test_read_banner_until_quiet_no_dsr_no_write(): - """No DSR in banner means no CPR writes.""" - reader = MockReader([b"Welcome to BBS\r\n"]) - writer = MockWriter() - await sfp._read_banner_until_quiet(reader, quiet_time=0.01, max_wait=0.05, writer=writer) - assert not writer._writes +def test_cull_display_empty_bytes_culled(): + assert sfp._cull_display({"data_bytes": b""}) == {} +@pytest.mark.parametrize( + "chunks,has_writer,expected_cpr_count", + [ + pytest.param([b"Hello\x1b[6nWorld"], True, 1, id="single_dsr"), + pytest.param([b"\x1b[6n", b"banner\x1b[6n"], True, 2, id="multiple_dsr"), + pytest.param([b"Welcome to BBS\r\n"], True, 0, id="no_dsr"), + pytest.param([b"Hello\x1b[6n"], False, 0, id="no_writer"), + ], +) @pytest.mark.asyncio -async def test_read_banner_until_quiet_no_writer_ignores_dsr(): - """Without a writer, DSR is silently ignored.""" - reader = MockReader([b"Hello\x1b[6n"]) - result = await sfp._read_banner_until_quiet(reader, quiet_time=0.01, max_wait=0.05) - assert result == b"Hello\x1b[6n" +async def test_read_banner_until_quiet_dsr(chunks, has_writer, expected_cpr_count): + reader = MockReader(chunks) + writer = MockWriter() if has_writer else None + kwargs = {"quiet_time": 0.01, "max_wait": 0.05} + if writer is not None: + kwargs["writer"] = writer + result = await sfp._read_banner_until_quiet(reader, **kwargs) + assert result == b"".join(chunks) + if writer is not None: + cpr_count = sum(1 for w in writer._writes if w == b"\x1b[1;1R") + assert cpr_count == expected_cpr_count @pytest.mark.asyncio async def test_fingerprinting_shell_dsr_response(tmp_path): """Full session responds to DSR in the pre-return banner.""" - save_path = str(tmp_path / "result.json") reader = MockReader([b"\x1b[6nWelcome to BBS\r\n"]) writer = MockWriter(will_options=[fps.SGA]) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + await _run_fp(reader, writer, tmp_path) assert b"\x1b[1;1R" in writer._writes @@ -1230,21 +1017,10 @@ async def test_fingerprinting_shell_dsr_response(tmp_path): @pytest.mark.asyncio async def test_fingerprinting_settle_dsr_response(tmp_path): """DSR arriving during negotiation settle gets an immediate CPR reply.""" - save_path = str(tmp_path / "result.json") reader = MockReader([b"\x1b[6nWelcome\r\n"]) writer = MockWriter(will_options=[fps.SGA]) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + await _run_fp(reader, writer, tmp_path) assert b"\x1b[1;1R" in writer._writes @@ -1252,7 +1028,6 @@ async def test_fingerprinting_settle_dsr_response(tmp_path): @pytest.mark.asyncio async def test_fingerprinting_shell_ansi_ellipsis_menu(tmp_path): """Worldgroup/MajorBBS ellipsis-menu selects first numbered option.""" - save_path = str(tmp_path / "result.json") writer = MockWriter(will_options=[fps.SGA, fps.ECHO]) reader = InteractiveMockReader( [ @@ -1267,17 +1042,7 @@ async def test_fingerprinting_shell_ansi_ellipsis_menu(tmp_path): writer, ) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + await _run_fp(reader, writer, tmp_path) assert b"1\r\n" in writer._writes @@ -1311,7 +1076,6 @@ async def test_read_banner_inline_esc_once(): @pytest.mark.asyncio async def test_fingerprinting_shell_esc_inline_no_duplicate(tmp_path): """Inline ESC response prevents duplicate in the prompt loop.""" - save_path = str(tmp_path / "result.json") writer = MockWriter(will_options=[fps.SGA]) reader = InteractiveMockReader( [ @@ -1321,17 +1085,7 @@ async def test_fingerprinting_shell_esc_inline_no_duplicate(tmp_path): writer, ) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + await _run_fp(reader, writer, tmp_path) esc_writes = [w for w in writer._writes if w == b"\x1b\x1b"] assert len(esc_writes) == 1 @@ -1340,7 +1094,6 @@ async def test_fingerprinting_shell_esc_inline_no_duplicate(tmp_path): @pytest.mark.asyncio async def test_fingerprinting_shell_delayed_prompt(tmp_path): """Bare-return banner followed by ESC-twice prompt still gets answered.""" - save_path = str(tmp_path / "result.json") writer = MockWriter(will_options=[fps.SGA]) reader = InteractiveMockReader( [ @@ -1351,82 +1104,43 @@ async def test_fingerprinting_shell_delayed_prompt(tmp_path): writer, ) - await sfp.fingerprinting_client_shell( - reader, - writer, - host="localhost", - port=23, - save_path=save_path, - silent=True, - banner_quiet_time=0.01, - banner_max_wait=0.01, - mssp_wait=0.01, - ) + await _run_fp(reader, writer, tmp_path) assert b"\x1b\x1b" in writer._writes +@pytest.mark.parametrize( + "chunks,expected_cprs", + [ + pytest.param([b"\x1b[6n \x1b[6n"], [b"\x1b[1;1R", b"\x1b[1;2R"], id="single_chunk"), + pytest.param([b"\x1b[6n", b" \x1b[6n"], [b"\x1b[1;1R", b"\x1b[1;2R"], id="separate_chunks"), + pytest.param([b"\x1b[6n\xe4\xb8\xad\x1b[6n"], [b"\x1b[1;1R", b"\x1b[1;3R"], id="wide_char"), + ], +) @pytest.mark.asyncio -async def test_read_banner_virtual_cursor_defeats_robot_check(): - """DSR-space-DSR produces CPR col=1 then col=2 (width=1).""" - reader = MockReader([b"\x1b[6n \x1b[6n"]) - writer = MockWriter() - cursor = sfp._VirtualCursor() - await sfp._read_banner_until_quiet( - reader, quiet_time=0.01, max_wait=0.05, writer=writer, cursor=cursor - ) - cpr_writes = [w for w in writer._writes if b"R" in w] - assert cpr_writes[0] == b"\x1b[1;1R" - assert cpr_writes[1] == b"\x1b[1;2R" - - -@pytest.mark.asyncio -async def test_read_banner_virtual_cursor_separate_chunks(): - """DSR in separate chunks still tracks cursor correctly.""" - reader = MockReader([b"\x1b[6n", b" \x1b[6n"]) - writer = MockWriter() - cursor = sfp._VirtualCursor() - await sfp._read_banner_until_quiet( - reader, quiet_time=0.01, max_wait=0.05, writer=writer, cursor=cursor - ) - cpr_writes = [w for w in writer._writes if b"R" in w] - assert cpr_writes[0] == b"\x1b[1;1R" - assert cpr_writes[1] == b"\x1b[1;2R" - - -@pytest.mark.asyncio -async def test_read_banner_virtual_cursor_wide_char(): - """Wide CJK character advances cursor by 2.""" - reader = MockReader([b"\x1b[6n\xe4\xb8\xad\x1b[6n"]) +async def test_read_banner_virtual_cursor(chunks, expected_cprs): + reader = MockReader(chunks) writer = MockWriter() cursor = sfp._VirtualCursor() await sfp._read_banner_until_quiet( reader, quiet_time=0.01, max_wait=0.05, writer=writer, cursor=cursor ) cpr_writes = [w for w in writer._writes if b"R" in w] - assert cpr_writes[0] == b"\x1b[1;1R" - assert cpr_writes[1] == b"\x1b[1;3R" - - -def test_virtual_cursor_backspace(): - """Backspace moves cursor left.""" - cursor = sfp._VirtualCursor() - cursor.advance(b"AB\x08") - assert cursor.col == 2 + assert cpr_writes == expected_cprs -def test_virtual_cursor_cr(): - """Carriage return resets cursor to column 1.""" - cursor = sfp._VirtualCursor() - cursor.advance(b"Hello\r") - assert cursor.col == 1 - - -def test_virtual_cursor_ansi_stripped(): - """ANSI color codes do not advance cursor.""" +@pytest.mark.parametrize( + "data,expected_col", + [ + pytest.param(b"AB\x08", 2, id="backspace"), + pytest.param(b"Hello\r", 1, id="cr"), + pytest.param(b"\x1b[31mX\x1b[0m", 2, id="ansi_stripped"), + ], +) +def test_virtual_cursor(data, expected_col): cursor = sfp._VirtualCursor() - cursor.advance(b"\x1b[31mX\x1b[0m") - assert cursor.col == 2 + cursor.advance(data) + assert cursor.col == expected_col @pytest.mark.parametrize( @@ -1445,3 +1159,169 @@ def test_reencode_prompt(response, encoding, expected): import telnetlib3 # noqa: F401 assert sfp._reencode_prompt(response, encoding) == expected + + +@pytest.mark.asyncio +async def test_read_banner_inline_utf8_menu(): + """UTF-8 charset menu is responded to inline during banner collection.""" + reader = MockReader([b"Welcome to BBS!\r\n", b"Select codepage:\r\n(1) UTF-8\r\n(2) CP437\r\n"]) + writer = MockWriter() + await sfp._read_banner_until_quiet(reader, quiet_time=0.01, max_wait=0.05, writer=writer) + assert b"1\r\n" in writer._writes + assert writer.environ_encoding == "utf-8" + assert writer._menu_inline is True + + +@pytest.mark.asyncio +async def test_read_banner_inline_utf8_menu_split_chunks(): + """UTF-8 menu text split across chunk boundaries is still detected.""" + reader = MockReader([b"Select codepage:\r\n(1) UT", b"F-8\r\n(2) CP437\r\n"]) + writer = MockWriter() + await sfp._read_banner_until_quiet(reader, quiet_time=0.01, max_wait=0.05, writer=writer) + assert b"1\r\n" in writer._writes + assert writer.environ_encoding == "utf-8" + + +@pytest.mark.asyncio +async def test_read_banner_inline_utf8_menu_only_once(): + """UTF-8 menu response is sent only once even with multiple menu chunks.""" + reader = MockReader([b"(1) UTF-8\r\n(2) CP437\r\n", b"Select again:\r\n(1) UTF-8\r\n"]) + writer = MockWriter() + await sfp._read_banner_until_quiet(reader, quiet_time=0.01, max_wait=0.05, writer=writer) + menu_writes = [w for w in writer._writes if w == b"1\r\n"] + assert len(menu_writes) == 1 + + +@pytest.mark.asyncio +async def test_fingerprinting_shell_utf8_inline_no_duplicate(tmp_path): + """Inline UTF-8 menu response prevents duplicate in the prompt loop.""" + writer = MockWriter(will_options=[fps.SGA]) + reader = InteractiveMockReader([b"(1) UTF-8\r\n(2) CP437\r\n", b"Welcome!\r\nLogin: "], writer) + + await _run_fp(reader, writer, tmp_path) + + menu_writes = [w for w in writer._writes if w == b"1\r\n"] + assert len(menu_writes) == 1 + + +@pytest.mark.asyncio +async def test_banner_loop_repeated_banner(tmp_path): + """Banner loop exits when server repeats the same banner.""" + banner = b"Login: " + writer = MockWriter(will_options=[fps.SGA]) + reader = InteractiveMockReader([banner, banner, banner], writer) + + save_path = await _run_fp(reader, writer, tmp_path) + assert (tmp_path / "result.json").exists() + + +@pytest.mark.asyncio +async def test_banner_loop_no_prompt_detected(tmp_path): + """Banner loop exits when no prompt is detected in consecutive banners.""" + writer = MockWriter(will_options=[fps.SGA]) + reader = InteractiveMockReader([b"Welcome to server\r\n", b"MOTD line 1\r\n"], writer) + + save_path = await _run_fp(reader, writer, tmp_path) + assert (tmp_path / "result.json").exists() + + +@pytest.mark.asyncio +async def test_session_data_mud_protocol_fields(tmp_path): + """Session data includes MUD protocol fields when present.""" + writer = MockWriter(will_options=[fps.SGA]) + writer.zmp_data = [["check", "telnetlib3"]] + writer.atcp_data = [("Auth.Request", "ON")] + writer.aardwolf_data = [{"type": "stats"}] + writer.mxp_data = [None, b"\x01\x02"] + writer.comport_data = {"baud": 9600} + reader = InteractiveMockReader([b"Login: "], writer) + + save_path = await _run_fp(reader, writer, tmp_path) + with open(save_path, encoding="utf-8") as f: + data = json.load(f) + session = data["server-probe"]["session_data"] + assert session["zmp"] == [["check", "telnetlib3"]] + assert session["atcp"] == [{"package": "Auth.Request", "value": "ON"}] + assert session["aardwolf"] == [{"type": "stats"}] + assert session["mxp"] == ["activated", "0102"] + assert session["comport"] == {"baud": 9600} + + +def test_parse_environ_send_with_value_byte(): + """_parse_environ_send parses VALUE byte in environ data.""" + value_byte = 0x01 + raw = bytes([0x00]) + b"USER" + bytes([value_byte]) + b"jq" + result = sfp._parse_environ_send(raw) + assert len(result) == 1 + assert result[0]["name"] == "USER" + assert result[0]["value_hex"] == b"jq".hex() + + +def test_parse_environ_send_value_byte_empty_val(): + """_parse_environ_send handles VALUE with no actual value.""" + raw = bytes([0x00]) + b"TERM" + result = sfp._parse_environ_send(raw) + assert len(result) == 1 + assert result[0]["name"] == "TERM" + assert "value_hex" not in result[0] + + +@pytest.mark.asyncio +async def test_save_fingerprint_creates_directory(tmp_path): + """Fingerprint save creates intermediate directories.""" + save_path = str(tmp_path / "subdir" / "deep" / "result.json") + writer = MockWriter(will_options=[fps.SGA]) + reader = InteractiveMockReader([b"Login: "], writer) + + await sfp.fingerprinting_client_shell( + reader, writer, host="localhost", port=23, save_path=save_path, **_FP_KWARGS + ) + + assert (tmp_path / "subdir" / "deep" / "result.json").exists() + + +@pytest.mark.asyncio +async def test_await_mssp_timeout(): + """_await_mssp_data waits then returns when data doesn't arrive.""" + import time + + writer = MockWriter() + writer.remote_option[fps.MSSP] = True + writer.mssp_data = None + + deadline = time.time() + 0.05 + await sfp._await_mssp_data(writer, deadline) + assert writer.mssp_data is None + + +@pytest.mark.asyncio +async def test_read_banner_syncterm_font_switch(): + """SyncTERM font escape triggers encoding switch.""" + font_chunk = b"\x1b[0;40 D" + b"Hello" + reader = MockReader([font_chunk]) + writer = MockWriter() + await sfp._read_banner_until_quiet(reader, quiet_time=0.01, max_wait=0.05, writer=writer) + assert writer.protocol.force_binary is True + + +@pytest.mark.asyncio +async def test_read_banner_syncterm_font_explicit_encoding(): + """SyncTERM font escape is ignored when encoding is explicit.""" + font_chunk = b"\x1b[0;40 D" + b"Hello" + reader = MockReader([font_chunk]) + writer = MockWriter() + writer._encoding_explicit = True + writer.environ_encoding = "cp437" + await sfp._read_banner_until_quiet(reader, quiet_time=0.01, max_wait=0.05, writer=writer) + assert writer.environ_encoding == "cp437" + + +@pytest.mark.asyncio +async def test_read_banner_utf8_menu_explicit_encoding(): + """UTF-8 menu does not switch encoding when explicit.""" + reader = MockReader([b"(1) UTF-8\r\n(2) CP437\r\n"]) + writer = MockWriter() + writer._encoding_explicit = True + writer.environ_encoding = "cp437" + await sfp._read_banner_until_quiet(reader, quiet_time=0.01, max_wait=0.05, writer=writer) + assert writer.environ_encoding == "cp437" diff --git a/telnetlib3/tests/test_server_mud.py b/telnetlib3/tests/test_server_mud.py index 5ba93e38..bed2e830 100644 --- a/telnetlib3/tests/test_server_mud.py +++ b/telnetlib3/tests/test_server_mud.py @@ -9,7 +9,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "bin")) # 3rd party -import server_mud as mud # pylint: disable=import-error,wrong-import-position +import server_mud as mud # local from telnetlib3.telopt import GMCP, MSDP, MSSP, WILL diff --git a/telnetlib3/tests/test_server_shell_unit.py b/telnetlib3/tests/test_server_shell_unit.py index 6980e496..88d51c8a 100644 --- a/telnetlib3/tests/test_server_shell_unit.py +++ b/telnetlib3/tests/test_server_shell_unit.py @@ -11,6 +11,7 @@ from telnetlib3 import client_shell as cs from telnetlib3 import guard_shells as gs from telnetlib3 import server_shell as ss +from telnetlib3._session_context import TelnetSessionContext class DummyWriter: @@ -94,23 +95,22 @@ def send_ga(self): return True -def test_readline_basic_and_crlf_and_backspace(): - cmds, echos = _run_readline("foo\r") - assert cmds == ["foo"] - assert "".join(echos).endswith("foo") - - cmds, _ = _run_readline("bar\r\n") - assert cmds == ["bar"] - - cmds, _ = _run_readline("baz\n") - assert cmds == ["baz"] - - cmds, _ = _run_readline("zip\r\x00zap\r\n") - assert cmds == ["zip", "zap"] - - cmds, echos = _run_readline("\bhel\blp\r") - assert cmds == ["help"] - assert "\b \b" in "".join(echos) +@pytest.mark.parametrize( + "input_data, expected_cmds, check_echos", + [ + ("foo\r", ["foo"], lambda e: "".join(e).endswith("foo")), + ("bar\r\n", ["bar"], None), + ("baz\n", ["baz"], None), + ("zip\r\x00zap\r\n", ["zip", "zap"], None), + ("\bhel\blp\r", ["help"], lambda e: "\b \b" in "".join(e)), + ], + ids=["cr", "crlf", "lf", "crnul_multi", "backspace"], +) +def test_readline_basic(input_data, expected_cmds, check_echos): + cmds, echos = _run_readline(input_data) + assert cmds == expected_cmds + if check_echos is not None: + assert check_echos(echos) def test_character_dump_yields_patterns_and_summary(): @@ -135,16 +135,19 @@ async def test_terminal_determine_mode(monkeypatch): _mock_opt = types.SimpleNamespace(enabled=lambda key: False) tw = types.SimpleNamespace( will_echo=False, - _raw_mode=None, client=True, remote_option=_mock_opt, log=types.SimpleNamespace(debug=lambda *a, **k: None), + ctx=TelnetSessionContext(), ) term = cs.Terminal(tw) mode = cs.Terminal.ModeDef(0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 38400, 38400, [0] * 32) assert term.determine_mode(mode) is mode + from telnetlib3.telopt import SGA + tw.will_echo = True + tw.remote_option = types.SimpleNamespace(enabled=lambda key: key == SGA) t = cs.termios cc = [0] * 32 cc[t.VMIN] = 0 @@ -316,8 +319,12 @@ async def test_get_cursor_position_success(): @pytest.mark.asyncio -async def test_get_cursor_position_failure(): +async def test_get_cursor_position_timeout(): assert await gs._get_cursor_position(SlowReader(), MockWriter(), timeout=0.01) == (None, None) + + +@pytest.mark.asyncio +async def test_get_cursor_position_eof(): assert await gs._get_cursor_position(MockReader([b""]), MockWriter(), timeout=1.0) == ( None, None, @@ -460,16 +467,10 @@ def write(self, data): assert "1 OK" not in "".join(w2.written) -# -- readline2 is alias for readline_async -- - - def test_readline2_is_readline_async(): assert ss.readline2 is ss.readline_async -# -- _LineEditor tests -- - - @pytest.mark.parametrize( "chars,expected_cmds", [ @@ -520,9 +521,6 @@ def test_line_editor_maxvis_ascii(): assert cmd == "abc" -# -- _backspace_grapheme tests -- - - @pytest.mark.parametrize( "command,expected_cmd,expected_echo", [ @@ -555,9 +553,6 @@ def test_backspace_grapheme_wcwidth(command, expected_cmd, expected_echo): assert echo == expected_echo -# -- _visible_width tests -- - - @pytest.mark.parametrize( "text,expected", [pytest.param("hello", 5, id="ascii"), pytest.param("", 0, id="empty")] ) @@ -577,9 +572,6 @@ def test_visible_width_wcwidth(text, expected): assert ss._visible_width(text) == expected -# -- readline (blocking) grapheme + maxvis tests -- - - def test_readline_backspace_wide(): cmds, echos = _run_readline("a\u30b3\b\r") assert cmds == ["a"] @@ -606,9 +598,6 @@ def test_readline_maxvis_wide(): assert cmds == ["a\u30b3"] -# -- readline_async grapheme + maxvis tests -- - - @pytest.mark.asyncio async def test_readline_async_backspace_wide(): result = await ss.readline_async( @@ -656,9 +645,6 @@ async def test_readline_async_ss3_filtered(): assert result == "hello" -# -- filter_ansi enhanced tests -- - - @pytest.mark.parametrize( "input_chars,expected", [ @@ -675,9 +661,6 @@ async def test_filter_ansi_wcwidth_sequences(input_chars, expected): assert result == expected -# -- telnet_server_shell with LF-terminated input -- - - @pytest.mark.asyncio async def test_telnet_server_shell_lf_input(): reader = MockReader(list("quit\n")) @@ -688,8 +671,7 @@ async def test_telnet_server_shell_lf_input(): @pytest.mark.asyncio -async def test_telnet_server_shell_tls_banner(): - """Plain connection shows 'Ready.', TLS shows 'Ready (secure: ...)'.""" +async def test_telnet_server_shell_plain_banner(): reader = MockReader(list("quit\r")) writer = MockWriter() await ss.telnet_server_shell(reader, writer) @@ -697,13 +679,23 @@ async def test_telnet_server_shell_tls_banner(): assert "Ready.\r\n" in written assert "secure" not in written + +@pytest.mark.asyncio +async def test_telnet_server_shell_tls_banner(): class _FakeSSL: def version(self): return "TLSv1.3" - reader2 = MockReader(list("quit\r")) - writer2 = MockWriter() - writer2._extra["ssl_object"] = _FakeSSL() - await ss.telnet_server_shell(reader2, writer2) - written2 = "".join(writer2.written) - assert "Ready (secure: TLSv1.3)." in written2 + reader = MockReader(list("quit\r")) + writer = MockWriter() + writer._extra["ssl_object"] = _FakeSSL() + await ss.telnet_server_shell(reader, writer) + written = "".join(writer.written) + assert "Ready (secure: TLSv1.3)." in written + + +@pytest.mark.asyncio +async def test_filter_ansi_esc_then_eof(): + reader = MockReader(["\x1b", ""]) + result = await ss.filter_ansi(reader, MockWriter()) + assert not result diff --git a/telnetlib3/tests/test_shell.py b/telnetlib3/tests/test_shell.py index 521ff678..21425ae0 100644 --- a/telnetlib3/tests/test_shell.py +++ b/telnetlib3/tests/test_shell.py @@ -8,11 +8,9 @@ from telnetlib3 import accessories, telnet_server_shell from telnetlib3.telopt import DO, IAC, SGA, ECHO, WILL, WONT, TTYPE, BINARY from telnetlib3.tests.accessories import ( - bind_host, create_server, asyncio_server, open_connection, - unused_tcp_port, asyncio_connection, ) @@ -67,9 +65,10 @@ async def shell(reader, writer): # a server that doesn't care async with asyncio_server(asyncio.Protocol, bind_host, unused_tcp_port): - async with open_connection( - host=bind_host, port=unused_tcp_port, shell=shell, connect_minwait=0.05 - ) as (reader, writer): + async with open_connection(host=bind_host, port=unused_tcp_port, shell=shell) as ( + reader, + writer, + ): await asyncio.wait_for(_waiter, 0.5) @@ -110,7 +109,7 @@ async def test_telnet_server_given_shell(bind_host, unused_tcp_port): host=bind_host, port=unused_tcp_port, shell=telnet_server_shell, - connect_maxwait=0.05, + connect_maxwait=0.5, timeout=1.25, limit=13377, never_send_ga=True, @@ -289,7 +288,7 @@ async def test_telnet_server_shell_eof(bind_host, unused_tcp_port): async def test_telnet_server_shell_version_command(bind_host, unused_tcp_port): """Test version command in telnet_server_shell.""" async with create_server( - host=bind_host, port=unused_tcp_port, shell=telnet_server_shell, connect_maxwait=0.05 + host=bind_host, port=unused_tcp_port, shell=telnet_server_shell, connect_maxwait=0.5 ): async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): expected = IAC + DO + TTYPE @@ -324,7 +323,7 @@ async def test_telnet_server_shell_version_command(bind_host, unused_tcp_port): async def test_telnet_server_shell_dump_with_kb_limit(bind_host, unused_tcp_port): """Test dump command with explicit kb_limit.""" async with create_server( - host=bind_host, port=unused_tcp_port, shell=telnet_server_shell, connect_maxwait=0.05 + host=bind_host, port=unused_tcp_port, shell=telnet_server_shell, connect_maxwait=0.5 ): async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): expected = IAC + DO + TTYPE @@ -356,7 +355,7 @@ async def test_telnet_server_shell_dump_with_kb_limit(bind_host, unused_tcp_port async def test_telnet_server_shell_dump_with_all_options(bind_host, unused_tcp_port): """Test dump command with all options including close.""" async with create_server( - host=bind_host, port=unused_tcp_port, shell=telnet_server_shell, connect_maxwait=0.05 + host=bind_host, port=unused_tcp_port, shell=telnet_server_shell, connect_maxwait=0.5 ): async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): expected = IAC + DO + TTYPE @@ -387,7 +386,7 @@ async def test_telnet_server_shell_dump_with_all_options(bind_host, unused_tcp_p async def test_telnet_server_shell_dump_nodrain(bind_host, unused_tcp_port): """Test dump command with nodrain option.""" async with create_server( - host=bind_host, port=unused_tcp_port, shell=telnet_server_shell, connect_maxwait=0.05 + host=bind_host, port=unused_tcp_port, shell=telnet_server_shell, connect_maxwait=0.5 ): async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): expected = IAC + DO + TTYPE @@ -419,7 +418,7 @@ async def test_telnet_server_shell_dump_nodrain(bind_host, unused_tcp_port): async def test_telnet_server_shell_dump_large_output(bind_host, unused_tcp_port): """Test dump command with larger output.""" async with create_server( - host=bind_host, port=unused_tcp_port, shell=telnet_server_shell, connect_maxwait=0.05 + host=bind_host, port=unused_tcp_port, shell=telnet_server_shell, connect_maxwait=0.5 ): async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): expected = IAC + DO + TTYPE diff --git a/telnetlib3/tests/test_slc.py b/telnetlib3/tests/test_slc.py new file mode 100644 index 00000000..7429ffcd --- /dev/null +++ b/telnetlib3/tests/test_slc.py @@ -0,0 +1,30 @@ +# std imports + +# 3rd party +import pytest + +# local +from telnetlib3.slc import Forwardmask + + +def test_forwardmask_description_table_nonzero_byte(): + value = b"\x00" * 31 + b"\x01" + fm = Forwardmask(value, ack=False) + lines = fm.description_table() + assert any("[31]" in line for line in lines) + assert any("0b" in line for line in lines) + + +def test_forwardmask_str_binary(): + value = b"\xff" + b"\x00" * 31 + fm = Forwardmask(value, ack=False) + assert str(fm).startswith("0b") + assert "1" in str(fm) + + +def test_forwardmask_contains(): + value = bytearray(32) + value[0] = 0x80 + fm = Forwardmask(bytes(value), ack=False) + assert 0 in fm + assert 1 not in fm diff --git a/telnetlib3/tests/test_status_logger.py b/telnetlib3/tests/test_status_logger.py index 691ab497..1d6552fb 100644 --- a/telnetlib3/tests/test_status_logger.py +++ b/telnetlib3/tests/test_status_logger.py @@ -5,14 +5,12 @@ # local from telnetlib3.server import StatusLogger, parse_server_args from telnetlib3.telopt import IAC, WONT, TTYPE -from telnetlib3.tests.accessories import bind_host # pytest fixture -from telnetlib3.tests.accessories import unused_tcp_port # pytest fixture from telnetlib3.tests.accessories import create_server, asyncio_connection async def test_rx_bytes_tracking(bind_host, unused_tcp_port): """rx_bytes increments when data is received from client.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WONT + TTYPE) client = await asyncio.wait_for(server.wait_for_client(), 0.5) @@ -27,7 +25,7 @@ async def test_rx_bytes_tracking(bind_host, unused_tcp_port): async def test_tx_bytes_tracking(bind_host, unused_tcp_port): """tx_bytes increments when data is sent to client.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WONT + TTYPE) client = await asyncio.wait_for(server.wait_for_client(), 0.5) @@ -42,7 +40,7 @@ async def test_tx_bytes_tracking(bind_host, unused_tcp_port): async def test_status_logger_get_status(bind_host, unused_tcp_port): """StatusLogger._get_status() returns correct client data.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: status_logger = StatusLogger(server, 60) status = status_logger._get_status() assert status["count"] == 0 @@ -63,7 +61,7 @@ async def test_status_logger_get_status(bind_host, unused_tcp_port): async def test_status_logger_status_changed(bind_host, unused_tcp_port): """StatusLogger._status_changed() detects changes correctly.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: status_logger = StatusLogger(server, 60) status_empty = status_logger._get_status() @@ -108,7 +106,7 @@ def clients(self): async def test_status_logger_start_stop(bind_host, unused_tcp_port): """StatusLogger.start() and stop() manage task lifecycle.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: status_logger = StatusLogger(server, 60) assert status_logger._task is None @@ -123,7 +121,7 @@ async def test_status_logger_start_stop(bind_host, unused_tcp_port): async def test_status_logger_disabled_with_zero_interval(bind_host, unused_tcp_port): """StatusLogger with interval=0 does not create task.""" - async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.05) as server: + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: status_logger = StatusLogger(server, 0) status_logger.start() assert status_logger._task is None diff --git a/telnetlib3/tests/test_stream_reader_extra.py b/telnetlib3/tests/test_stream_reader_extra.py index 5db88818..781d9c2b 100644 --- a/telnetlib3/tests/test_stream_reader_extra.py +++ b/telnetlib3/tests/test_stream_reader_extra.py @@ -38,7 +38,6 @@ async def test_readuntil_success_consumes_and_returns(): r.feed_data(b"abc\nrest") out = await r.readuntil(b"\n") assert out == b"abc\n" - # buffer consumed up to and including separator assert bytes(r._buffer) == b"rest" @@ -50,18 +49,15 @@ async def test_readuntil_eof_incomplete_raises_and_clears(): with pytest.raises(asyncio.IncompleteReadError) as exc: await r.readuntil(b"\n") assert exc.value.partial == b"partial" - # buffer cleared on EOF path assert r._buffer == bytearray() @pytest.mark.asyncio async def test_readuntil_limit_overrun_leaves_buffer(): r = TelnetReader(limit=5) - # 7 bytes, no separator, should exceed limit r.feed_data(b"abcdefg") with pytest.raises(asyncio.LimitOverrunError): await r.readuntil(b"\n") - # buffer left intact on limit overrun assert bytes(r._buffer) == b"abcdefg" @@ -74,7 +70,6 @@ async def test_readuntil_pattern_success_and_eof_incomplete(): assert out == b"aaXYZ" assert bytes(r._buffer) == b"bb" - # EOF incomplete for pattern r2 = TelnetReader(limit=64) r2.feed_data(b"aaaa") r2.feed_eof() @@ -99,11 +94,8 @@ async def test_pause_and_resume_transport_based_on_buffer_limit(): r = TelnetReader(limit=4) t = MockTransport() r.set_transport(t) - # exceed 2*limit (8) to pause r.feed_data(b"123456789") assert t.paused is True - - # consume enough to drop buffer length <= limit and trigger resume got = await r.read(5) assert got == b"12345" assert t.resumed is True @@ -113,12 +105,10 @@ async def test_pause_and_resume_transport_based_on_buffer_limit(): async def test_anext_iterates_lines_and_stops_on_eof(): r = TelnetReader() r.feed_data(b"Line1\nLine2\n") - one = await r.__anext__() # anext() is 3.10+ + one = await r.__anext__() assert one == b"Line1\n" - # second line two = await r.__anext__() assert two == b"Line2\n" - # signal EOF then StopAsyncIteration on next r.feed_eof() with pytest.raises(StopAsyncIteration): await r.__anext__() @@ -134,10 +124,8 @@ async def test_exception_propagates_to_read_calls(): def test_deprecated_close_and_connection_closed_warns(): r = TelnetReader() - # property warns with pytest.warns(DeprecationWarning): _ = r.connection_closed - # close warns and sets eof with pytest.warns(DeprecationWarning): r.close() assert r._eof is True @@ -163,14 +151,10 @@ def enc(incoming): return "ascii" ur = TelnetReaderUnicode(fn_encoding=enc) - # read(0) yields empty string - out0 = await ur.read(0) - assert not out0 - + assert not await ur.read(0) ur.feed_data(b"abc") out2 = await ur.read(2) assert out2 == "ab" - # remaining one char out1 = await ur.read(10) assert out1 == "c" @@ -181,10 +165,213 @@ def enc(incoming): return "utf-8" ur = TelnetReaderUnicode(fn_encoding=enc) - s = "☭ab" # first is multibyte in utf-8 - ur.feed_data(s.encode("utf-8")) + ur.feed_data("☭ab".encode("utf-8")) out = await ur.readexactly(2) assert out == "☭a" - # next call should return remaining 'b' out2 = await ur.readexactly(1) assert out2 == "b" + + +@pytest.mark.asyncio +async def test_feed_data_empty_returns_early(): + r = TelnetReader(limit=64) + r.feed_data(b"existing") + r.feed_data(b"") + assert bytes(r._buffer) == b"existing" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "make_reader, method, args", + [ + (TelnetReader, "readuntil", (b"\n",)), + (TelnetReader, "readexactly", (5,)), + (lambda: TelnetReaderUnicode(fn_encoding=lambda incoming=True: "ascii"), "readline", ()), + ( + lambda: TelnetReaderUnicode(fn_encoding=lambda incoming=True: "ascii"), + "readexactly", + (3,), + ), + ], + ids=["readuntil", "readexactly", "unicode-readline", "unicode-readexactly"], +) +async def test_read_method_raises_stored_exception(make_reader, method, args): + reader = make_reader() + reader.set_exception(RuntimeError("boom")) + with pytest.raises(RuntimeError, match="boom"): + await getattr(reader, method)(*args) + + +def test_aiter_returns_self(): + r = TelnetReader() + assert r.__aiter__() is r + + +class PauseNIErrorTransport: + def __init__(self): + self.paused = False + self.resumed = False + self._closing = False + + def pause_reading(self): + raise NotImplementedError + + def resume_reading(self): + self.resumed = True + + def is_closing(self): + return self._closing + + def get_extra_info(self, name, default=None): + return default + + +class ResumeTransport: + def __init__(self): + self.paused = False + self.resumed = False + self._closing = False + + def pause_reading(self): + self.paused = True + + def resume_reading(self): + self.resumed = True + + def is_closing(self): + return self._closing + + def get_extra_info(self, name, default=None): + return default + + +def test_repr_shows_key_fields(): + r = TelnetReader(limit=1234) + r.feed_data(b"abc") + r.feed_eof() + r.set_exception(RuntimeError("boom")) + r.set_transport(ResumeTransport()) + r._paused = True + + rep = repr(r) + assert "TelnetReader" in rep + assert "3 bytes" in rep + assert "eof" in rep + assert "limit=1234" in rep + assert "exception=" in rep + assert "transport=" in rep + assert "paused" in rep + assert "encoding=False" in rep + + +async def test_set_exception_and_wakeup_waiter(): + r = TelnetReader() + loop = asyncio.get_running_loop() + fut = loop.create_future() + r._waiter = fut + err = RuntimeError("oops") + r.set_exception(err) + assert r.exception() is err + assert fut.done() + with pytest.raises(RuntimeError): + fut.result() + + fut2 = loop.create_future() + r._waiter = fut2 + r._wakeup_waiter() + assert fut2.done() + assert fut2.result() is None + + +@pytest.mark.asyncio +async def test_wait_for_data_resumes_when_paused_and_data_arrives(): + r = TelnetReader(limit=4) + t = ResumeTransport() + r.set_transport(t) + r.feed_data(b"123456789") + assert t.paused is True or len(r._buffer) > 2 * r._limit + r._paused = True + + async def feeder(): + await asyncio.sleep(0.01) + r.feed_data(b"x") + + feeder_task = asyncio.create_task(feeder()) + await asyncio.wait_for(r._wait_for_data("read"), 0.5) + await feeder_task + assert t.resumed is True + + +@pytest.mark.asyncio +async def test_concurrent_reads_raise_runtimeerror(): + r = TelnetReader() + + async def first(): + return await r.read(1) + + async def second(): + with pytest.raises(RuntimeError, match="already waiting"): + await r.read(1) + + t1 = asyncio.create_task(first()) + await asyncio.sleep(0) + t2 = asyncio.create_task(second()) + await asyncio.sleep(0.01) + r.feed_data(b"A") + res = await asyncio.wait_for(t1, 0.5) + assert res == b"A" + await t2 + + +def test_feed_data_notimplemented_pause_drops_transport(): + r = TelnetReader(limit=1) + t = PauseNIErrorTransport() + r.set_transport(t) + r.feed_data(b"ABCD") + assert r._transport is None + + +@pytest.mark.asyncio +async def test_read_zero_returns_empty_bytes(): + r = TelnetReader() + out = await r.read(0) + assert out == b"" + + +@pytest.mark.asyncio +async def test_read_until_wait_path_then_data_arrives(): + r = TelnetReader() + + async def waiter(): + return await r.read(3) + + task = asyncio.create_task(waiter()) + await asyncio.sleep(0.01) + r.feed_data(b"xyz") + out = await asyncio.wait_for(task, 0.5) + assert out == b"xyz" + + +@pytest.mark.asyncio +async def test_readexactly_exact_and_split_paths(): + r = TelnetReader() + r.feed_data(b"abcd") + got = await r.readexactly(4) + assert got == b"abcd" + r2 = TelnetReader() + r2.feed_data(b"abcde") + got2 = await r2.readexactly(3) + assert got2 == b"abc" + assert bytes(r2._buffer) == b"de" + + +async def test_readuntil_separator_empty_raises(): + r = TelnetReader() + with pytest.raises(ValueError): + await r.readuntil(b"") + + +async def test_readuntil_pattern_invalid_types(): + r = TelnetReader() + with pytest.raises(ValueError, match="pattern should be a re\\.Pattern"): + await r.readuntil_pattern(None) diff --git a/telnetlib3/tests/test_stream_reader_more.py b/telnetlib3/tests/test_stream_reader_more.py deleted file mode 100644 index c30147b0..00000000 --- a/telnetlib3/tests/test_stream_reader_more.py +++ /dev/null @@ -1,197 +0,0 @@ -# std imports -import asyncio - -# 3rd party -import pytest - -# local -from telnetlib3.stream_reader import TelnetReader - - -class PauseNIErrorTransport: - """Transport that raises NotImplementedError on pause_reading().""" - - def __init__(self): - self.paused = False - self.resumed = False - self._closing = False - - def pause_reading(self): - raise NotImplementedError - - def resume_reading(self): - self.resumed = True - - def is_closing(self): - return self._closing - - def get_extra_info(self, name, default=None): - return default - - -class ResumeTransport: - def __init__(self): - self.paused = False - self.resumed = False - self._closing = False - - def pause_reading(self): - self.paused = True - - def resume_reading(self): - self.resumed = True - - def is_closing(self): - return self._closing - - def get_extra_info(self, name, default=None): - return default - - -def test_repr_shows_key_fields(): - r = TelnetReader(limit=1234) - # populate buffer and state bits - r.feed_data(b"abc") - r.feed_eof() - # set exception, transport and paused - r.set_exception(RuntimeError("boom")) - r.set_transport(ResumeTransport()) - r._paused = True - - rep = repr(r) - # sanity: contains these tokens - assert "TelnetReader" in rep - assert "3 bytes" in rep - assert "eof" in rep - assert "limit=1234" in rep - assert "exception=" in rep - assert "transport=" in rep - assert "paused" in rep - assert "encoding=False" in rep - - -async def test_set_exception_and_wakeup_waiter(): - r = TelnetReader() - loop = asyncio.get_running_loop() - fut = loop.create_future() - r._waiter = fut - err = RuntimeError("oops") - r.set_exception(err) - assert r.exception() is err - assert fut.done() - with pytest.raises(RuntimeError): - fut.result() - - # also verify _wakeup_waiter sets result when not cancelled - fut2 = loop.create_future() - r._waiter = fut2 - r._wakeup_waiter() - assert fut2.done() - assert fut2.result() is None - - -@pytest.mark.asyncio -async def test_wait_for_data_resumes_when_paused_and_data_arrives(): - r = TelnetReader(limit=4) - t = ResumeTransport() - r.set_transport(t) - # capture paused state by pushing > 2*limit - r.feed_data(b"123456789") - assert t.paused is True or len(r._buffer) > 2 * r._limit - # mark manually paused and ensure resume happens in _wait_for_data - r._paused = True - - async def feeder(): - await asyncio.sleep(0.01) - r.feed_data(b"x") - - feeder_task = asyncio.create_task(feeder()) - # this will set a waiter, see paused=True, and resume_reading() - await asyncio.wait_for(r._wait_for_data("read"), 0.5) - await feeder_task - assert t.resumed is True - - -@pytest.mark.asyncio -async def test_concurrent_reads_raise_runtimeerror(): - r = TelnetReader() - - async def first(): - # will block until data or eof - return await r.read(1) - - async def second(): - # should raise RuntimeError because first is already waiting - with pytest.raises(RuntimeError, match="already waiting"): - await r.read(1) - - t1 = asyncio.create_task(first()) - await asyncio.sleep(0) # allow t1 to start and set _waiter - t2 = asyncio.create_task(second()) - # wake first so it can complete - await asyncio.sleep(0.01) - r.feed_data(b"A") - res = await asyncio.wait_for(t1, 0.5) - assert res == b"A" - await t2 # assertion inside - - -def test_feed_data_notimplemented_pause_drops_transport(): - r = TelnetReader(limit=1) - t = PauseNIErrorTransport() - r.set_transport(t) - # force > 2*limit -> pause_reading raises NotImplementedError and - # implementation should set _transport to None - r.feed_data(b"ABCD") - assert r._transport is None - - -@pytest.mark.asyncio -async def test_read_zero_returns_empty_bytes(): - r = TelnetReader() - out = await r.read(0) - assert out == b"" - - -@pytest.mark.asyncio -async def test_read_until_wait_path_then_data_arrives(): - r = TelnetReader() - - # start waiting - async def waiter(): - return await r.read(3) - - task = asyncio.create_task(waiter()) - await asyncio.sleep(0.01) - r.feed_data(b"xyz") - out = await asyncio.wait_for(task, 0.5) - assert out == b"xyz" - - -@pytest.mark.asyncio -async def test_readexactly_exact_and_split_paths(): - r = TelnetReader() - r.feed_data(b"abcd") - got = await r.readexactly(4) # exact path - assert got == b"abcd" - # split path (buffer > n or needs to wait) - r2 = TelnetReader() - r2.feed_data(b"abcde") - got2 = await r2.readexactly(3) - assert got2 == b"abc" - assert bytes(r2._buffer) == b"de" - - -async def test_readuntil_separator_empty_raises(): - r = TelnetReader() - with pytest.raises(ValueError): - # empty separator not allowed - await r.readuntil(b"") - - -async def test_readuntil_pattern_invalid_types(): - r = TelnetReader() - with pytest.raises(ValueError, match="pattern should be a re\\.Pattern"): - await r.readuntil_pattern(None) - - # pattern compiled diff --git a/telnetlib3/tests/test_stream_writer_extra.py b/telnetlib3/tests/test_stream_writer_extra.py deleted file mode 100644 index f9ba3d8a..00000000 --- a/telnetlib3/tests/test_stream_writer_extra.py +++ /dev/null @@ -1,694 +0,0 @@ -# std imports -import struct -import logging -import collections - -# 3rd party -import pytest - -# local -from telnetlib3 import slc -from telnetlib3.telopt import ( - DO, - IS, - SB, - SE, - EOR, - IAC, - SGA, - ECHO, - NAWS, - SEND, - WILL, - WONT, - LFLOW, - TTYPE, - BINARY, - SNDLOC, - STATUS, - TSPEED, - CHARSET, - CMD_EOR, - REQUEST, - LFLOW_ON, - LINEMODE, - XDISPLOC, - LFLOW_OFF, - NEW_ENVIRON, - LFLOW_RESTART_ANY, - LFLOW_RESTART_XON, -) -from telnetlib3.client_base import BaseClient -from telnetlib3.server_base import BaseServer -from telnetlib3.stream_writer import TelnetWriter, _encode_env_buf, _format_sb_status - - -class MockTransport: - def __init__(self): - self._closing = False - self.writes = [] - self.extra = {} - - def write(self, data): - self.writes.append(bytes(data)) - - def is_closing(self): - return self._closing - - def get_extra_info(self, name, default=None): - return self.extra.get(name, default) - - def pause_reading(self): - pass - - def resume_reading(self): - pass - - def close(self): - self._closing = True - - -class MockProtocol: - def __init__(self, info=None): - self.info = info or {} - - def get_extra_info(self, name, default=None): - return self.info.get(name, default) - - async def _drain_helper(self): - pass - - -def new_writer(server=True, client=False): - t = MockTransport() - p = MockProtocol() - w = TelnetWriter(t, p, server=server, client=client) - return w, t, p - - -def test_write_escapes_iac_and_send_iac_verbatim(): - w, t, _ = new_writer(server=True) - - # write escapes IAC - w.write(b"A" + IAC + b"B") - assert t.writes[-1] == b"A" + IAC + IAC + b"B" - - # send_iac writes verbatim starting with IAC - w.send_iac(IAC + CMD_EOR) - assert t.writes[-1] == IAC + CMD_EOR - - -def test_iac_skip_when_option_already_enabled_remote_and_local(): - w, t, _ = new_writer(server=True) - - # remote option already enabled -> DO should be skipped - w.remote_option[BINARY] = True - sent = w.iac(DO, BINARY) - assert sent is False - assert not t.writes - - # local option already enabled -> WILL should be skipped - w.local_option[ECHO] = True - sent2 = w.iac(WILL, ECHO) - assert sent2 is False - assert not t.writes - - -def test_iac_do_sets_pending_and_writes_when_not_enabled(): - w, t, _ = new_writer(server=True) - - assert w.remote_option.enabled(BINARY) is False - sent = w.iac(DO, BINARY) - assert sent is True - assert DO + BINARY in w.pending_option - assert t.writes[-1] == IAC + DO + BINARY - - -def test_send_eor_requires_local_option_enabled(): - w, t, _ = new_writer(server=True) - - # not enabled -> returns False, no write - assert w.send_eor() is False - assert not t.writes - - # enable and try again - w.local_option[EOR] = True - assert w.send_eor() is True - assert t.writes[-1] == IAC + CMD_EOR - - -def test_echo_server_only_and_will_echo_controls_write(): - w, t, _ = new_writer(server=True) - - # will_echo depends on local ECHO for server perspective - w.local_option[ECHO] = True - w.echo(b"x") - assert t.writes[-1] == b"x" - - # client perspective: echo is a no-op (will_echo is False) - w2, t2, _ = new_writer(server=False, client=True) - w2.echo(b"x") - assert not t2.writes - - -def test_mode_property_transitions(): - w, _, _ = new_writer(server=True) - - # default server: local - assert w.mode == "local" - - # server with ECHO and SGA -> kludge - w.local_option[ECHO] = True - w.local_option[SGA] = True - assert w.mode == "kludge" - - # remote LINEMODE enabled -> remote - w.remote_option[LINEMODE] = True - assert w.mode == "remote" - - -def test_request_status_sends_and_pends(): - w, t, _ = new_writer(server=True) - w.remote_option[STATUS] = True - - sent = w.request_status() - assert sent is True - assert t.writes[-1] == IAC + SB + STATUS + SEND + IAC + SE - # second request while pending -> False - sent2 = w.request_status() - assert sent2 is False - - -def test_send_status_requires_privilege_then_minimal_frame(): - w, t, _ = new_writer(server=True) - - with pytest.raises(ValueError): - w._send_status() - - # allow by setting local STATUS True - w.local_option[STATUS] = True - w._send_status() - assert t.writes[-1] == IAC + SB + STATUS + IS + IAC + SE - - -def test_receive_status_matches_local_and_remote_states(): - w, _, _ = new_writer(server=True) - # local DO BINARY should match when local_option[BINARY] True - w.local_option[BINARY] = True - # remote WILL ECHO should match when remote_option[ECHO] True - w.remote_option[ECHO] = True - buf = collections.deque([DO, BINARY, WILL, ECHO]) - # should not raise - w._receive_status(buf) - - -def test_request_tspeed_and_handle_send_and_is(): - # request_tspeed from server when remote declared WILL TSPEED - ws, ts, _ = new_writer(server=True) - ws.remote_option[TSPEED] = True - assert ws.request_tspeed() is True - assert ts.writes[-1] == IAC + SB + TSPEED + SEND + IAC + SE - - # client receives SEND and responds IS rx,tx - wc, tc, _ = new_writer(server=False, client=True) - wc.set_ext_send_callback(TSPEED, lambda: (9600, 9600)) - buf = collections.deque([TSPEED, SEND]) - wc._handle_sb_tspeed(buf) - assert tc.writes[-1] == IAC + SB + TSPEED + IS + b"9600" + b"," + b"9600" + IAC + SE - - # server receives IS values - seen = {} - ws2, _, _ = new_writer(server=True) - ws2.set_ext_callback(TSPEED, lambda rx, tx: seen.setdefault("v", (rx, tx))) - payload = b"57600,115200" - # feed payload as individual bytes, matching expected subnegotiation format - buf2 = collections.deque([TSPEED, IS] + [payload[i : i + 1] for i in range(len(payload))]) - ws2._handle_sb_tspeed(buf2) - assert seen["v"] == (57600, 115200) - - -def test_handle_sb_charset_request_accept_reject_and_accepted(): - # REQUEST -> REJECTED - w, t, _ = new_writer(server=True) - w.set_ext_send_callback(CHARSET, lambda offers=None: None) - sep = b" " - offers = b"UTF-8 ASCII" - buf = collections.deque([CHARSET, REQUEST, sep, offers]) - w._handle_sb_charset(buf) - assert t.writes[-1] == IAC + SB + CHARSET + b"\x03" + IAC + SE # REJECTED = 3 - - # REQUEST -> ACCEPTED UTF-8 - w2, t2, _ = new_writer(server=True) - w2.set_ext_send_callback(CHARSET, lambda offers=None: "UTF-8") - buf2 = collections.deque([CHARSET, REQUEST, sep, offers]) - w2._handle_sb_charset(buf2) - assert t2.writes[-1] == IAC + SB + CHARSET + b"\x02" + b"UTF-8" + IAC + SE # ACCEPTED = 2 - - # ACCEPTED -> callback fired - seen = {} - w3, _, _ = new_writer(server=True) - w3.set_ext_callback(CHARSET, lambda cs: seen.setdefault("cs", cs)) - buf3 = collections.deque([CHARSET, b"\x02", b"UTF-8"]) # ACCEPTED - w3._handle_sb_charset(buf3) - assert seen["cs"] == "UTF-8" - - # REJECTED path (warning only) - w4, _, _ = new_writer(server=True) - buf4 = collections.deque([CHARSET, b"\x03"]) # REJECTED - w4._handle_sb_charset(buf4) - - -def test_handle_sb_xdisploc_is_and_send(): - # IS -> server callback - seen = {} - ws, _, _ = new_writer(server=True) - ws.set_ext_callback(XDISPLOC, lambda val: seen.setdefault("x", val)) - buf = collections.deque([XDISPLOC, IS, b"host:0"]) - ws._handle_sb_xdisploc(buf) - assert seen["x"] == "host:0" - - # SEND -> client response from ext_send_callback - wc, tc, _ = new_writer(server=False, client=True) - wc.set_ext_send_callback(XDISPLOC, lambda: "disp:1") - buf2 = collections.deque([XDISPLOC, SEND]) - wc._handle_sb_xdisploc(buf2) - assert tc.writes[-1] == IAC + SB + XDISPLOC + IS + b"disp:1" + IAC + SE - - -def test_handle_sb_ttype_is_and_send(): - # IS -> server callback - seen = {} - ws, _, _ = new_writer(server=True) - ws.set_ext_callback(TTYPE, lambda s: seen.setdefault("t", s)) - buf = collections.deque([TTYPE, IS, b"xterm-256color"]) - ws._handle_sb_ttype(buf) - assert seen["t"] == "xterm-256color" - - # SEND -> client response - wc, tc, _ = new_writer(server=False, client=True) - wc.set_ext_send_callback(TTYPE, lambda: "vt100") - buf2 = collections.deque([TTYPE, SEND]) - wc._handle_sb_ttype(buf2) - assert tc.writes[-1] == IAC + SB + TTYPE + IS + b"vt100" + IAC + SE - - -def _encode_env(env): - """Helper to encode env dict like _encode_env_buf would, for tests.""" - return _encode_env_buf(env) - - -def test_handle_sb_environ_send_and_is(): - # client SEND -> respond with IS encoded from ext_send_callback - wc, tc, _ = new_writer(server=False, client=True) - wc.set_ext_send_callback(NEW_ENVIRON, lambda keys: {"USER": "root"}) - # SEND with asking for USER - send_payload = _encode_env({"USER": ""}) - buf = collections.deque([NEW_ENVIRON, SEND, send_payload]) - wc._handle_sb_environ(buf) - frame = tc.writes[-1] - assert frame.startswith(IAC + SB + NEW_ENVIRON + IS) - assert frame.endswith(IAC + SE) - assert b"USER" in frame and b"root" in frame - - # server IS -> callback receives dict - seen = {} - ws, _, _ = new_writer(server=True) - ws.set_ext_callback(NEW_ENVIRON, lambda env: seen.setdefault("env", env)) - is_payload = _encode_env({"TERM": "xterm", "LANG": "C"}) - buf2 = collections.deque([NEW_ENVIRON, IS, is_payload]) - ws._handle_sb_environ(buf2) - assert seen["env"]["TERM"] == "xterm" - assert seen["env"]["LANG"] == "C" - - -def test_request_environ_server_side_conditions(): - ws, ts, _ = new_writer(server=True) - # without WILL NEW_ENVIRON -> False - assert ws.request_environ() is False - - # with WILL NEW_ENVIRON but empty request list from callback -> False - ws.remote_option[NEW_ENVIRON] = True - ws.set_ext_send_callback(NEW_ENVIRON, lambda: []) - assert ws.request_environ() is False - - # non-empty request list -> sends SB NEW_ENVIRON SEND ... SE - ws.set_ext_send_callback(NEW_ENVIRON, lambda: ["USER", "LANG"]) - assert ws.request_environ() is True - frame = ts.writes[-1] - assert frame.startswith(IAC + SB + NEW_ENVIRON + SEND) - assert frame.endswith(IAC + SE) - - -def test_request_charset_and_xdisploc_and_ttype(): - ws, ts, _ = new_writer(server=True) - # charset requires WILL CHARSET - assert ws.request_charset() is False - ws.remote_option[CHARSET] = True - ws.set_ext_send_callback(CHARSET, lambda: ["UTF-8", "ASCII"]) - assert ws.request_charset() is True - assert ts.writes[-1].startswith(IAC + SB + CHARSET + b"\x01") # REQUEST = 1 - - # xdisploc requires WILL XDISPLOC, then sends and sets pending - assert ws.request_xdisploc() is False - ws.remote_option[XDISPLOC] = True - assert ws.request_xdisploc() is True - assert ts.writes[-1] == IAC + SB + XDISPLOC + SEND + IAC + SE - # subsequent call suppressed while pending - assert ws.request_xdisploc() is False - - # ttype requires WILL TTYPE, then sends and sets pending - assert ws.request_ttype() is False - ws.remote_option[TTYPE] = True - assert ws.request_ttype() is True - assert ts.writes[-1] == IAC + SB + TTYPE + SEND + IAC + SE - # subsequent call suppressed while pending - assert ws.request_ttype() is False - - -def test_send_lineflow_mode_server_only_and_modes(): - ws, ts, _ = new_writer(server=True) - # without WILL LFLOW -> error path returns False - assert ws.send_lineflow_mode() is False - - # client should error-return as well - wc, _, _ = new_writer(server=False, client=True) - assert wc.send_lineflow_mode() is False - - # with WILL LFLOW, xon_any False -> RESTART_XON - ws.remote_option[LFLOW] = True - ws.xon_any = False - assert ws.send_lineflow_mode() is True - assert ts.writes[-1] == IAC + SB + LFLOW + LFLOW_RESTART_XON + IAC + SE - - # xon_any True -> RESTART_ANY - ws.xon_any = True - assert ws.send_lineflow_mode() is True - assert ts.writes[-1] == IAC + SB + LFLOW + LFLOW_RESTART_ANY + IAC + SE - - -def test_send_ga_respects_sga(): - ws, ts, _ = new_writer(server=True) - # default: DO SGA not received -> GA allowed - assert ws.send_ga() is True - assert ts.writes[-1] == IAC + b"\xf9" # GA - - # after DO SGA (local_option[SGA] True), GA suppressed - ws.local_option[SGA] = True - assert ws.send_ga() is False - - -def test_send_naws_and_handle_naws(): - # client path for sending NAWS - wc, tc, _ = new_writer(server=False, client=True) - wc.set_ext_send_callback(NAWS, lambda: (24, 80)) # rows, cols - wc._send_naws() - frame = tc.writes[-1] - assert frame.startswith(IAC + SB + NAWS) - assert frame.endswith(IAC + SE) - # payload is packed (cols, rows) - payload = frame[3:-2] - data = payload.replace(IAC + IAC, IAC) - assert len(data) == 4 - cols, rows = struct.unpack("!HH", data) - assert (rows, cols) == (24, 80) - - # server receive NAWS -> callback(rows, cols) - seen = {} - ws, _, _ = new_writer(server=True) - ws.remote_option[NAWS] = True - ws.set_ext_callback(NAWS, lambda r, c: seen.setdefault("sz", (r, c))) - payload2 = struct.pack("!HH", 100, 200) - buf2 = collections.deque([NAWS, payload2[0:1], payload2[1:2], payload2[2:3], payload2[3:4]]) - ws._handle_sb_naws(buf2) - assert seen["sz"] == (200, 100) - - -def test_handle_sb_lflow_toggles(): - ws, _, _ = new_writer(server=True) - # must have DO LFLOW received - ws.local_option[LFLOW] = True - - # OFF - buf = collections.deque([LFLOW, LFLOW_OFF]) - ws._handle_sb_lflow(buf) - assert ws.lflow is False - - # ON - buf = collections.deque([LFLOW, LFLOW_ON]) - ws._handle_sb_lflow(buf) - assert ws.lflow is True - - # RESTART_ANY -> xon_any False - buf = collections.deque([LFLOW, LFLOW_RESTART_ANY]) - ws._handle_sb_lflow(buf) - assert ws.xon_any is False - - # RESTART_XON -> xon_any True - buf = collections.deque([LFLOW, LFLOW_RESTART_XON]) - ws._handle_sb_lflow(buf) - assert ws.xon_any is True - - -def test_handle_sb_status_send_and_is(): - ws, ts, _ = new_writer(server=True) - # prepare privilege for _send_status - ws.local_option[STATUS] = True - - # SEND -> calls _send_status writes minimal frame - buf = collections.deque([STATUS, SEND]) - ws._handle_sb_status(buf) - assert ts.writes[-1] == IAC + SB + STATUS + IS + IAC + SE - - # IS -> pass a matching pair DO/WILL to _receive_status - ws2, _, _ = new_writer(server=True) - ws2.local_option[BINARY] = True - ws2.remote_option[SGA] = True - payload = collections.deque([DO, BINARY, WILL, SGA]) - buf2 = collections.deque([STATUS, IS] + list(payload)) - ws2._handle_sb_status(buf2) - - -def test_handle_sb_forwardmask_do_accepted(): - wc, _, _ = new_writer(server=False, client=True) - wc.local_option[LINEMODE] = True - wc._handle_sb_forwardmask(DO, collections.deque([b"x", b"y"])) - opt = SB + LINEMODE + slc.LMODE_FORWARDMASK - assert wc.local_option[opt] is True - - -def test_handle_sb_linemode_mode_empty_buffer(): - ws, _, _ = new_writer(server=True) - ws.local_option[LINEMODE] = True - ws.remote_option[LINEMODE] = True - with pytest.raises(ValueError, match="missing mode byte"): - ws._handle_sb_linemode_mode(collections.deque()) - - -def test_handle_sb_linemode_switches(): - ws, ts, _ = new_writer(server=True) - - # LMODE_MODE without ACK -> triggers send_linemode (ACK set) - ws.local_option[LINEMODE] = True # allow send_linemode assertion - ws.remote_option[LINEMODE] = True - ws._handle_sb_linemode_mode(collections.deque([bytes([3])])) # suggest EDIT|TRAPSIG - # send_linemode writes two frames (SB LINEMODE LMODE_MODE ... SE) - assert ts.writes[-1].endswith(IAC + SE) - - # Client: ACK set and mode differs -> ignore change (no write, local unchanged) - wc, tc, _ = new_writer(server=False, client=True) - wc._linemode = slc.Linemode(bytes([0])) # local - suggest_ack = bytes([ord(bytes([1])) | ord(slc.LMODE_MODE_ACK)]) - wc._handle_sb_linemode_mode(collections.deque([suggest_ack])) - # nothing written - assert not tc.writes - - # Client: ACK set and mode matches -> set and no write - wc2, tc2, _ = new_writer(server=False, client=True) - same = slc.Linemode(bytes([1])) - wc2._linemode = same - suggest_ack2 = bytes([ord(same.mask) | ord(slc.LMODE_MODE_ACK)]) - wc2._handle_sb_linemode_mode(collections.deque([suggest_ack2])) - assert wc2._linemode == same - assert not tc2.writes - - -def test_handle_sb_linemode_suppresses_duplicate_mode(): - """Redundant MODE without ACK matching current mode is not re-ACKed.""" - ws, ts, _ = new_writer(server=True) - ws.local_option[LINEMODE] = True - ws.remote_option[LINEMODE] = True - - mode_val = bytes([3]) # EDIT | TRAPSIG - mode_with_ack = bytes([3 | 4]) # same + ACK bit - - # first proposal: should be ACKed - ws._handle_sb_linemode_mode(collections.deque([mode_val])) - assert len(ts.writes) > 0 - first_write_count = len(ts.writes) - # verify our linemode is now set with ACK - assert ws._linemode.mask == mode_with_ack - - # same proposal again: should be suppressed (no new writes) - ws._handle_sb_linemode_mode(collections.deque([mode_val])) - assert len(ts.writes) == first_write_count - - # different proposal: should be ACKed - ws._handle_sb_linemode_mode(collections.deque([bytes([1])])) - assert len(ts.writes) > first_write_count - - -def test_handle_sb_linemode_suppresses_duplicate_mode_client(): - """Client also suppresses redundant MODE proposals.""" - wc, tc, _ = new_writer(server=False, client=True) - wc.local_option[LINEMODE] = True - wc.remote_option[LINEMODE] = True - - mode_val = bytes([3]) - mode_with_ack = bytes([3 | 4]) - - # first proposal: ACKed - wc._handle_sb_linemode_mode(collections.deque([mode_val])) - first_write_count = len(tc.writes) - assert first_write_count > 0 - assert wc._linemode.mask == mode_with_ack - - # same proposal repeated 3 times: all suppressed - for _ in range(3): - wc._handle_sb_linemode_mode(collections.deque([mode_val])) - assert len(tc.writes) == first_write_count - - -def test_handle_subnegotiation_dispatch_and_unhandled(): - ws, _, _ = new_writer(server=True) - # dispatch to NAWS handler (will log unsolicited), ensure no exception - # must reflect receipt of WILL NAWS prior to NAWS subnegotiation - ws.remote_option[NAWS] = True - payload = struct.pack("!HH", 10, 20) - buf = collections.deque([NAWS, payload[0:1], payload[1:2], payload[2:3], payload[3:4]]) - ws._handle_sb_naws(buf) - - # unhandled command - with pytest.raises(ValueError, match="SB unhandled"): - ws.handle_subnegotiation(collections.deque([b"\x99", b"\x00"])) - - -async def test_server_data_received_split_sb_linemode(): - class NoNegServer(BaseServer): - def begin_negotiation(self): - pass - - def _check_negotiation_timer(self): - pass - - transport = MockTransport() - server = NoNegServer(encoding=False) - server.connection_made(transport) - - server.writer.remote_option[LINEMODE] = True - server.writer.local_option[LINEMODE] = True - - transport.writes.clear() - - chunk1 = IAC + SB + LINEMODE + slc.LMODE_MODE - server.data_received(chunk1) - assert server.writer.is_oob - - mask_byte = b"\x10" - chunk2 = mask_byte + IAC + SE - server.data_received(chunk2) - - response = b"".join(transport.writes) - assert IAC + SB + LINEMODE + slc.LMODE_MODE in response - - -async def test_client_process_chunk_split_sb_linemode(): - transport = MockTransport() - client = BaseClient(encoding=False) - client.connection_made(transport) - - client.writer.remote_option[LINEMODE] = True - client.writer.local_option[LINEMODE] = True - - transport.writes.clear() - - chunk1 = IAC + SB + LINEMODE + slc.LMODE_MODE - client._process_chunk(chunk1) - assert client.writer.is_oob - - mask_byte = b"\x10" - chunk2 = mask_byte + IAC + SE - client._process_chunk(chunk2) - - response = b"".join(transport.writes) - assert IAC + SB + LINEMODE + slc.LMODE_MODE in response - - -@pytest.mark.parametrize( - "opt, data, expected", - [ - (NAWS, b"\x00\x50\x00\x19", "NAWS 80x25"), - (NAWS, b"\x01\x00\x00\xc8", "NAWS 256x200"), - (TTYPE, IS + b"VT100", "TTYPE IS VT100"), - (TTYPE, SEND + b"xterm", "TTYPE SEND xterm"), - (XDISPLOC, IS + b"host:0.0", "XDISPLOC IS host:0.0"), - (SNDLOC, IS + b"Building4", "SNDLOC IS Building4"), - (TTYPE, b"\x99" + b"data", "TTYPE 99 data"), - (STATUS, b"\xab\xcd", "STATUS abcd"), - (NAWS, b"\x00\x50\x00", "NAWS 005000"), - (STATUS, b"", "STATUS"), - (BINARY, b"", "BINARY"), - ], -) -def test_format_sb_status(opt, data, expected): - """Test _format_sb_status output for each branch.""" - assert _format_sb_status(opt, data) == expected - - -def _make_status_is_buf(*parts): - """Build a deque for _handle_sb_status from raw byte sequences.""" - buf = collections.deque() - buf.append(STATUS) - buf.append(IS) - for part in parts: - for byte_val in part: - buf.append(bytes([byte_val])) - return buf - - -def test_receive_status_sb_naws(caplog): - """STATUS IS with embedded SB NAWS data SE.""" - ws, _, _ = new_writer(server=True) - ws.local_option[NAWS] = True - naws_payload = struct.pack("!HH", 80, 25) - buf = _make_status_is_buf(SB + NAWS + naws_payload + SE) - with caplog.at_level(logging.DEBUG): - ws._handle_sb_status(buf) - assert any("NAWS 80x25" in msg for msg in caplog.messages) - - -def test_receive_status_sb_missing_se(caplog): - """STATUS IS with SB block missing SE consumes rest of buffer.""" - ws, _, _ = new_writer(server=True) - naws_payload = struct.pack("!HH", 80, 25) - buf = _make_status_is_buf(SB + NAWS + naws_payload) - with caplog.at_level(logging.DEBUG): - ws._handle_sb_status(buf) - assert any("subneg" in msg for msg in caplog.messages) - - -def test_receive_status_mixed_do_will_and_sb(caplog): - """STATUS IS with DO/WILL pairs intermixed with SB blocks.""" - ws, _, _ = new_writer(server=True) - ws.local_option[BINARY] = True - ws.remote_option[SGA] = True - ws.remote_option[ECHO] = True - ws.local_option[NAWS] = True - naws_payload = struct.pack("!HH", 132, 43) - buf = _make_status_is_buf( - DO + BINARY + WILL + SGA + SB + NAWS + naws_payload + SE + WONT + ECHO - ) - with caplog.at_level(logging.DEBUG): - ws._handle_sb_status(buf) - assert any("agreed" in msg.lower() for msg in caplog.messages) - assert any("NAWS 132x43" in msg for msg in caplog.messages) - assert any("disagree" in msg.lower() for msg in caplog.messages) diff --git a/telnetlib3/tests/test_stream_writer_full.py b/telnetlib3/tests/test_stream_writer_full.py index 51dd164e..4909925e 100644 --- a/telnetlib3/tests/test_stream_writer_full.py +++ b/telnetlib3/tests/test_stream_writer_full.py @@ -1,4 +1,6 @@ # std imports +import struct +import asyncio import logging import collections @@ -15,6 +17,7 @@ SB, SE, TM, + EOR, ESC, IAC, NOP, @@ -37,9 +40,11 @@ STATUS, TSPEED, CHARSET, + CMD_EOR, REQUEST, USERVAR, ACCEPTED, + LFLOW_ON, LINEMODE, REJECTED, XDISPLOC, @@ -48,8 +53,12 @@ NEW_ENVIRON, AUTHENTICATION, COM_PORT_OPTION, + LFLOW_RESTART_ANY, + LFLOW_RESTART_XON, theNULL, ) +from telnetlib3.client_base import BaseClient +from telnetlib3.server_base import BaseServer from telnetlib3.stream_writer import ( Option, TelnetWriter, @@ -57,95 +66,51 @@ _decode_env_buf, _encode_env_buf, _escape_environ, + _format_sb_status, _unescape_environ, ) - - -class MockTransport: - def __init__(self): - self._closing = False - self.writes = [] - self.extra = {} - - def write(self, data): - # store a copy - self.writes.append(bytes(data)) - - def is_closing(self): - return self._closing - - def get_extra_info(self, name, default=None): - return self.extra.get(name, default) - - def close(self): - self._closing = True - - -class ProtocolBase: - def __init__(self, info=None): - self.info = info or {} - self.drain_called = False - self.conn_lost_called = False - - def get_extra_info(self, name, default=None): - return self.info.get(name, default) - - async def _drain_helper(self): - self.drain_called = True - - # optional - def connection_lost(self, exc): - self.conn_lost_called = True +from telnetlib3.tests.accessories import MockProtocol, MockTransport def new_writer(server=True, client=False, reader=None): t = MockTransport() - p = ProtocolBase() + p = MockProtocol() w = TelnetWriter(t, p, server=server, client=client, reader=reader) return w, t, p def test_close_idempotent_and_cleanup(): w, t, p = new_writer(server=True) - # before assert not w.connection_closed w.close() - # transport closed and refs cleared assert w.connection_closed is True assert w._transport is None assert w._protocol is None assert t._closing is True assert w._closed_fut is None or w._closed_fut.done() - # callbacks cleared assert not w._ext_callback assert not w._ext_send_callback assert not w._slc_callback assert not w._iac_callback - # connection_lost was invoked assert p.conn_lost_called is True - # idempotent - w.close() # should not raise - # write after close is ignored + w.close() + t2 = MockTransport() - p2 = ProtocolBase() + p2 = MockProtocol() w2 = TelnetWriter(t2, p2, server=True) w2.close() w2.write(b"ignored") assert not t2.writes -def test_send_iac_skipped_when_closing(): - """send_iac() drops writes when transport is closing.""" +@pytest.mark.parametrize( + "setup", + [lambda w, t: setattr(t, "_closing", True), lambda w, t: w.close()], + ids=["closing", "closed"], +) +def test_send_iac_skipped_when_closing_or_closed(setup): w, t, _ = new_writer(server=True) - t._closing = True - w.send_iac(IAC + NOP) - assert not t.writes - - -def test_send_iac_skipped_when_closed(): - """send_iac() drops writes after close().""" - w, t, _ = new_writer(server=True) - w.close() + setup(w, t) w.send_iac(IAC + NOP) assert not t.writes @@ -201,22 +166,17 @@ def exception(self): @pytest.mark.asyncio async def test_drain_waits_on_transport_closing_and_calls_drain_helper(): w, t, p = new_writer(server=True) - # simulate closing transport t._closing = True await w.drain() assert p.drain_called is True def test_request_forwardmask_writes_mask_between_frames(): - # server with remote WILL LINEMODE w, t, _ = new_writer(server=True) w.remote_option[LINEMODE] = True - sent = w.request_forwardmask() - assert sent is True - # should have 3 writes: header, mask, footer + assert w.request_forwardmask() is True assert len(t.writes) >= 3 assert t.writes[-3] == IAC + SB + LINEMODE + DO + slc.LMODE_FORWARDMASK - # outbinary defaults False -> 16-byte mask assert len(t.writes[-2]) in (16, 32) assert t.writes[-1] == IAC + SE @@ -227,87 +187,87 @@ def test_send_linemode_asserts_when_not_negotiated(): w.send_linemode() -def test_handle_logout_paths(): - # server DO -> close - ws, ts, _ = new_writer(server=True) - ws.handle_logout(DO) - assert ts._closing is True - # server DONT -> no write, no crash - ws2, ts2, _ = new_writer(server=True) - ws2.handle_logout(DONT) - assert not ts2.writes - # client WILL -> send DONT LOGOUT - wc, tc, _ = new_writer(server=False, client=True) - wc.handle_logout(WILL) - assert tc.writes[-1] == IAC + DONT + LOGOUT - # client WONT -> just logs - wc2, tc2, _ = new_writer(server=False, client=True) - wc2.handle_logout(WONT) - assert not tc2.writes +@pytest.mark.parametrize( + "server, client, cmd, check", + [ + (True, False, DO, lambda t: t._closing is True), + (True, False, DONT, lambda t: not t.writes), + (False, True, WILL, lambda t: t.writes[-1] == IAC + DONT + LOGOUT), + (False, True, WONT, lambda t: not t.writes), + ], + ids=["server_do_closes", "server_dont_noop", "client_will_dont", "client_wont_noop"], +) +def test_handle_logout(server, client, cmd, check): + w, t, _ = new_writer(server=server, client=client) + w.handle_logout(cmd) + assert check(t) + + +@pytest.mark.parametrize( + "server,client,handler,opt,response", + [ + (True, False, "handle_do", LINEMODE, IAC + WONT + LINEMODE), + (False, True, "handle_do", ECHO, IAC + WONT + ECHO), + (False, True, "handle_will", NAWS, IAC + DONT + NAWS), + ], +) +def test_handle_option_refused(server, client, handler, opt, response): + w, t, _ = new_writer(server=server, client=client) + getattr(w, handler)(opt) + assert t.writes[-1] == response -def test_handle_do_variants_and_tm_and_logout(): - # server receiving reversed DO LINEMODE -> WONT refusal - ws, ts, _ = new_writer(server=True) - ws.handle_do(LINEMODE) - assert ts.writes[-1] == IAC + WONT + LINEMODE - # client receiving DO LOGOUT -> ValueError +def test_handle_do_client_logout_raises(): wc, *_ = new_writer(server=False, client=True) with pytest.raises(ValueError, match="cannot recv DO LOGOUT"): wc.handle_do(LOGOUT) - # client DO ECHO triggers WONT ECHO - wc2, tc2, _ = new_writer(server=False, client=True) - wc2.handle_do(ECHO) - assert tc2.writes[-1] == IAC + WONT + ECHO - # TM special: sends WILL TM and calls TM callback with DO + + +def test_handle_do_tm_callback(): called = {} wtm, ttm, _ = new_writer(server=True) wtm.set_iac_callback(TM, lambda cmd: called.setdefault("cmd", cmd)) wtm.handle_do(TM) assert ttm.writes[-1] == IAC + WILL + TM assert called["cmd"] == DO - # DO LOGOUT -> ext callback invoked - seen = {} - ws2, *_ = new_writer(server=True) - ws2.set_ext_callback(LOGOUT, lambda cmd: seen.setdefault("v", cmd)) - ws2.handle_do(LOGOUT) - assert seen["v"] == DO -def test_handle_dont_logout_calls_callback_on_server(): +@pytest.mark.parametrize( + "server,client,handler,expected_cmd", + [ + (True, False, "handle_do", DO), + (True, False, "handle_dont", DONT), + (True, False, "handle_will", WILL), + (False, True, "handle_wont", WONT), + ], +) +def test_handle_logout_callback(server, client, handler, expected_cmd): seen = {} - w, *_ = new_writer(server=True) + w, *_ = new_writer(server=server, client=client) w.set_ext_callback(LOGOUT, lambda cmd: seen.setdefault("v", cmd)) - w.handle_dont(LOGOUT) - assert seen["v"] == DONT + getattr(w, handler)(LOGOUT) + assert seen["v"] == expected_cmd -def test_handle_will_invalid_cases_and_else_unhandled(): - # server WILL ECHO invalid +def test_handle_will_server_echo_raises(): ws, *_ = new_writer(server=True) with pytest.raises(ValueError, match="cannot recv WILL ECHO"): ws.handle_will(ECHO) - # client receiving reversed WILL NAWS -> DONT refusal - wc, tc, _ = new_writer(server=False, client=True) - wc.handle_will(NAWS) - assert tc.writes[-1] == IAC + DONT + NAWS - # WILL TM requires pending DO TM + + +def test_handle_will_server_tm_raises(): wtm, *_ = new_writer(server=True) with pytest.raises(ValueError, match="cannot recv WILL TM"): wtm.handle_will(TM) - # server receiving WILL LOGOUT -> ext callback - seen = {} - w3, *_ = new_writer(server=True) - w3.set_ext_callback(LOGOUT, lambda cmd: seen.setdefault("v", cmd)) - w3.handle_will(LOGOUT) - assert seen["v"] == WILL - # ELSE branch (unhandled) -> DONT sent, pending cleared, rejected tracked - w4, t4, _ = new_writer(server=True) - w4.pending_option[DO + AUTHENTICATION] = True - w4.handle_will(AUTHENTICATION) - assert t4.writes[-1] == IAC + DONT + AUTHENTICATION - assert not w4.pending_option.get(DO + AUTHENTICATION, False) - assert AUTHENTICATION in w4.rejected_will + + +def test_handle_will_pending_authentication_rejected(): + w, t, _ = new_writer(server=True) + w.pending_option[DO + AUTHENTICATION] = True + w.handle_will(AUTHENTICATION) + assert t.writes[-1] == IAC + DONT + AUTHENTICATION + assert not w.pending_option.get(DO + AUTHENTICATION, False) + assert AUTHENTICATION in w.rejected_will def test_handle_will_then_do_unsupported_sends_both_dont_and_wont(): @@ -321,81 +281,76 @@ def test_handle_will_then_do_unsupported_sends_both_dont_and_wont(): assert AUTHENTICATION in w.rejected_do -def test_handle_wont_tm_and_logout_paths(): - # WONT TM w/o pending DO TM -> error +def test_handle_wont_tm_unsolicited_raises(): w, *_ = new_writer(server=True) with pytest.raises(ValueError, match="WONT TM"): w.handle_wont(TM) - # with pending DO TM -> toggles False - w2, *_ = new_writer(server=True) - w2.pending_option[DO + TM] = True - w2.handle_wont(TM) - assert w2.remote_option[TM] is False - # client WONT LOGOUT -> ext callback - seen = {} - wc, *_ = new_writer(server=False, client=True) - wc.set_ext_callback(LOGOUT, lambda cmd: seen.setdefault("v", cmd)) - wc.handle_wont(LOGOUT) - assert seen["v"] == WONT + + +def test_handle_wont_tm_pending_clears(): + w, *_ = new_writer(server=True) + w.pending_option[DO + TM] = True + w.handle_wont(TM) + assert w.remote_option[TM] is False def test_handle_subnegotiation_comport_and_gmcp_and_errors(): w, *_ = new_writer(server=True) - # GMCP w.handle_subnegotiation(collections.deque([GMCP, b"a", b"b"])) - # COM PORT OPTION: SIGNATURE response (subcmd 100 = server response) w.handle_subnegotiation(collections.deque([COM_PORT_OPTION, b"\x64", b"T", b"e", b"s", b"t"])) assert w.comport_data is not None assert w.comport_data["signature"] == "Test" - # errors + with pytest.raises(ValueError, match="SE: buffer empty"): w.handle_subnegotiation(collections.deque([])) with pytest.raises(ValueError, match="SE: buffer is NUL"): w.handle_subnegotiation(collections.deque([theNULL, b"x"])) with pytest.raises(ValueError, match="SE: buffer too short"): w.handle_subnegotiation(collections.deque([NAWS])) - # unknown command raises - unknown = bytes([0x7F]) with pytest.raises(ValueError, match="SB unhandled"): - w.handle_subnegotiation(collections.deque([unknown, b"x"])) + w.handle_subnegotiation(collections.deque([bytes([0x7F]), b"x"])) -def test_handle_sb_charset_paths_and_notimpl_and_illegal(): - # REQUEST -> REJECTED +def test_handle_sb_charset_request_rejected(): w, t, _ = new_writer(server=True) w.set_ext_send_callback(CHARSET, lambda offers=None: None) - sep = b" " - offers = b"UTF-8 ASCII" - w._handle_sb_charset(collections.deque([CHARSET, REQUEST, sep, offers])) + w._handle_sb_charset(collections.deque([CHARSET, REQUEST, b" ", b"UTF-8 ASCII"])) assert t.writes[-1] == IAC + SB + CHARSET + REJECTED + IAC + SE - # REQUEST -> ACCEPTED - w2, t2, _ = new_writer(server=True) - w2.set_ext_send_callback(CHARSET, lambda offers=None: "UTF-8") - w2._handle_sb_charset(collections.deque([CHARSET, REQUEST, sep, offers])) - assert t2.writes[-1] == IAC + SB + CHARSET + ACCEPTED + b"UTF-8" + IAC + SE - # ACCEPTED -> callback + + +def test_handle_sb_charset_request_accepted(): + w, t, _ = new_writer(server=True) + w.set_ext_send_callback(CHARSET, lambda offers=None: "UTF-8") + w._handle_sb_charset(collections.deque([CHARSET, REQUEST, b" ", b"UTF-8 ASCII"])) + assert t.writes[-1] == (IAC + SB + CHARSET + ACCEPTED + b"UTF-8" + IAC + SE) + + +def test_handle_sb_charset_accepted_callback(): seen = {} - w3, *_ = new_writer(server=True) - w3.set_ext_callback(CHARSET, lambda cs: seen.setdefault("cs", cs)) - w3._handle_sb_charset(collections.deque([CHARSET, ACCEPTED, b"UTF-8"])) + w, *_ = new_writer(server=True) + w.set_ext_callback(CHARSET, lambda cs: seen.setdefault("cs", cs)) + w._handle_sb_charset(collections.deque([CHARSET, ACCEPTED, b"UTF-8"])) assert seen["cs"] == "UTF-8" - # TTABLE_* -> NotImplementedError - w4, *_ = new_writer(server=True) + + +def test_handle_sb_charset_ttable_not_implemented(): + w, *_ = new_writer(server=True) with pytest.raises(NotImplementedError): - w4._handle_sb_charset(collections.deque([CHARSET, TTABLE_IS])) - # illegal option - w5, *_ = new_writer(server=True) + w._handle_sb_charset(collections.deque([CHARSET, TTABLE_IS])) + + +def test_handle_sb_charset_illegal_raises(): + w, *_ = new_writer(server=True) with pytest.raises(ValueError): - w5._handle_sb_charset(collections.deque([CHARSET, b"\x99"])) + w._handle_sb_charset(collections.deque([CHARSET, b"\x99"])) def test_handle_sb_xdisploc_wrong_side_asserts_and_send_and_is(): - # client SEND -> IS response wc, tc, _ = new_writer(server=False, client=True) wc.set_ext_send_callback(XDISPLOC, lambda: "host:0") wc._handle_sb_xdisploc(collections.deque([XDISPLOC, SEND])) assert tc.writes[-1] == IAC + SB + XDISPLOC + IS + b"host:0" + IAC + SE - # server IS -> callback + seen = {} ws2, *_ = new_writer(server=True) ws2.set_ext_callback(XDISPLOC, lambda x: seen.setdefault("x", x)) @@ -404,12 +359,11 @@ def test_handle_sb_xdisploc_wrong_side_asserts_and_send_and_is(): def test_handle_sb_tspeed_wrong_side_asserts_and_send_and_is(): - # client SEND -> IS response wc, tc, _ = new_writer(server=False, client=True) wc.set_ext_send_callback(TSPEED, lambda: (9600, 9600)) wc._handle_sb_tspeed(collections.deque([TSPEED, SEND])) assert tc.writes[-1] == IAC + SB + TSPEED + IS + b"9600" + b"," + b"9600" + IAC + SE - # server IS -> parse and callback + seen = {} ws2, *_ = new_writer(server=True) ws2.set_ext_callback(TSPEED, lambda rx, tx: seen.setdefault("v", (rx, tx))) @@ -421,14 +375,13 @@ def test_handle_sb_tspeed_wrong_side_asserts_and_send_and_is(): def test_handle_sb_environ_wrong_side_send_and_is(): - # client SEND -> respond IS using ext_send_callback wc, tc, _ = new_writer(server=False, client=True) wc.set_ext_send_callback(NEW_ENVIRON, lambda keys: {"USER": "root"}) send_payload = _encode_env_buf({"USER": ""}) wc._handle_sb_environ(collections.deque([NEW_ENVIRON, SEND, send_payload])) assert tc.writes[-1].startswith(IAC + SB + NEW_ENVIRON + IS) assert tc.writes[-1].endswith(IAC + SE) - # server IS -> decoded dict + seen = {} ws2, *_ = new_writer(server=True) ws2.set_ext_callback(NEW_ENVIRON, lambda env: seen.setdefault("env", env)) @@ -441,20 +394,14 @@ def test_handle_sb_environ_wrong_side_send_and_is(): def test_handle_sb_status_invalid_opt_and_receive_status_errors(): w, t, _ = new_writer(server=True) w.local_option[STATUS] = True - # invalid option after STATUS with pytest.raises(ValueError): w._handle_sb_status(collections.deque([STATUS, b"\x99"])) - # _receive_status now gracefully handles invalid cmd by logging warning - # instead of raising exception, this was changed to handle probably some - # unsupported "MUD" when testing with telnet://unitopia.de - w._receive_status(collections.deque([NOP, BINARY])) # should not raise - # odd-length payload leaves remainder; implementation logs warning and continues - w._receive_status(collections.deque([DO])) # should not raise + w._receive_status(collections.deque([NOP, BINARY])) + w._receive_status(collections.deque([DO])) def test_handle_sb_lflow_requires_do_lflow(): w, *_ = new_writer(server=True) - # must have DO LFLOW received with pytest.raises(ValueError): w._handle_sb_lflow(collections.deque([LFLOW, LFLOW_OFF])) @@ -467,35 +414,25 @@ def test_handle_sb_linemode_illegal_option_raises(): def test_is_oob_and_feed_byte_progression(): w, *_ = new_writer(server=True) - # register NOP to avoid ValueError w.set_iac_callback(NOP, lambda c: None) - # feed IAC - r1 = w.feed_byte(IAC) - assert r1 is False + assert w.feed_byte(IAC) is False + assert w.is_oob + assert w.feed_byte(NOP) is False assert w.is_oob - # feed 2nd byte NOP - r2 = w.feed_byte(NOP) - assert r2 is False - assert w.is_oob # cmd_received still truthy during this call - # now a normal byte resumes in-band - r3 = w.feed_byte(b"A") - assert r3 is True + assert w.feed_byte(b"A") is True assert not w.is_oob def test_iac_pending_and_dont_paths(): w, t, _ = new_writer(server=True) - # pending DO suppresses send w.pending_option[DO + ECHO] = True assert w.iac(DO, ECHO) is False - # DONT path when no prior key -> set remote False and send - sent = w.iac(DONT, ECHO) - assert sent is True + + assert w.iac(DONT, ECHO) is True assert w.remote_option[ECHO] is False assert t.writes[-1] == IAC + DONT + ECHO - # DONT path when already remote False -> suppressed - sent2 = w.iac(DONT, ECHO) - assert sent2 is False + + assert w.iac(DONT, ECHO) is False def test_telnetwriterunicode_write_and_echo_and_encoding_errors(): @@ -503,12 +440,10 @@ def fn_encoding(outgoing=True): return "ascii" t = MockTransport() - p = ProtocolBase() + p = MockProtocol() w = TelnetWriterUnicode(t, p, fn_encoding, server=True) - # write unicode w.write("hi") assert t.writes[-1] == b"hi" - # echo only if server will_echo -> needs local ECHO w.local_option[ECHO] = True w.echo("X") assert t.writes[-1] == b"X" @@ -519,24 +454,19 @@ def fn_encoding(outgoing=True): def test_option_enabled_and_setitem_debug_path(): opt = Option("testopt", log=type("L", (), {"debug": lambda *a, **k: None})()) - # not set -> enabled False assert opt.enabled(ECHO) is False - # set True opt[ECHO] = True assert opt.enabled(ECHO) is True - # set False opt[ECHO] = False assert opt.enabled(ECHO) is False def test_escape_unescape_and_env_encode_decode_roundtrip(): - # escaping VAR/USERVAR buf = b"A" + VAR + b"B" + USERVAR + b"C" esc = _escape_environ(buf) assert VAR in esc and USERVAR in esc and esc.count(ESC) == 2 unesc = _unescape_environ(esc) assert unesc == buf - # encode/decode env env = {"USER": "root", "LANG": "C.UTF-8"} enc = _encode_env_buf(env) dec = _decode_env_buf(enc) @@ -591,17 +521,14 @@ def can_write_eof(self): return True w, t, p = new_writer(server=True) - # transport property assert w.transport is t - # substitute transport with eof support t2 = MT2() w2 = TelnetWriter(t2, p, server=True) assert w2.can_write_eof() is True w2.write_eof() assert t2.eof_called is True - # is_closing: early True via transport.is_closing() assert w2.is_closing() is False t2._closing = True assert w2.is_closing() is True @@ -609,9 +536,7 @@ def can_write_eof(self): def test_repr_covers_flags_and_wills_and_failed_reply(): w, t, p = new_writer(server=True) - # pending failed-reply w.pending_option[DO + ECHO] = True - # local and remote enabled w.local_option[ECHO] = True w.local_option[SGA] = True w.remote_option[BINARY] = True @@ -621,7 +546,6 @@ def test_repr_covers_flags_and_wills_and_failed_reply(): assert "server-will:" in s assert "client-will:" in s - # client perspective too wc, tc, pc = new_writer(server=False, client=True) wc.pending_option[WILL + SGA] = True wc.remote_option[ECHO] = True @@ -632,12 +556,10 @@ def test_repr_covers_flags_and_wills_and_failed_reply(): def test_request_tspeed_and_charset_pending_branches(): w, t, p = new_writer(server=True) - # TSPEED: request pending suppresses second send w.remote_option[TSPEED] = True assert w.request_tspeed() is True assert w.request_tspeed() is False - # CHARSET: requires active WILL/DO (local_option True); pending suppresses second send w.local_option[CHARSET] = True w.set_ext_send_callback(CHARSET, lambda: ["UTF-8"]) assert w.request_charset() is True @@ -657,7 +579,7 @@ def test_tspeed_is_malformed_values_logged_and_ignored(): seen = {} w, t, p = new_writer(server=True) w.set_ext_callback(TSPEED, lambda rx, tx: seen.setdefault("v", (rx, tx))) - payload = b"x,y" # not integers, triggers ValueError path + payload = b"x,y" buf = collections.deque([TSPEED, IS] + [payload[i : i + 1] for i in range(len(payload))]) w._handle_sb_tspeed(buf) assert "v" not in seen @@ -671,20 +593,17 @@ def test_handle_sb_lflow_unknown_raises(): def test_ttype_xdisploc_tspeed_pending_flags_cleared(): - # TTYPE pending cleared on SEND wc, tc, pc = new_writer(server=False, client=True) wc.set_ext_send_callback(TTYPE, lambda: "vt100") wc.pending_option[WILL + TTYPE] = True wc._handle_sb_ttype(collections.deque([TTYPE, SEND])) assert not wc.pending_option.enabled(WILL + TTYPE) - # XDISPLOC pending cleared on SEND wc.set_ext_send_callback(XDISPLOC, lambda: "host:0") wc.pending_option[WILL + XDISPLOC] = True wc._handle_sb_xdisploc(collections.deque([XDISPLOC, SEND])) assert not wc.pending_option.enabled(WILL + XDISPLOC) - # TSPEED pending cleared on SEND wc.set_ext_send_callback(TSPEED, lambda: (9600, 9600)) wc.pending_option[WILL + TSPEED] = True wc._handle_sb_tspeed(collections.deque([TSPEED, SEND])) @@ -692,7 +611,6 @@ def test_ttype_xdisploc_tspeed_pending_flags_cleared(): def test_environ_pending_typo_branch_cleared(): - # The implementation clears WILL+TTYPE in environ SEND path; ensure executed wc, tc, pc = new_writer(server=False, client=True) wc.set_ext_send_callback(NEW_ENVIRON, lambda keys: {"USER": "root"}) wc.pending_option[WILL + TTYPE] = True @@ -705,18 +623,15 @@ def test_sndloc_callback(): seen = {} ws, ts, ps = new_writer(server=True) ws.set_ext_callback(SNDLOC, lambda s: seen.setdefault("loc", s)) - # Dispatch via handle_subnegotiation to cover that path too ws.handle_subnegotiation(collections.deque([SNDLOC, b"Room 641-A"])) assert seen["loc"] == "Room 641-A" def test_simple_handlers_cover_logging(): w, t, p = new_writer(server=True) - # IAC-level handlers w.handle_nop(NOP) w.handle_ga(GA) w.handle_dm(DM) - # mixed-mode byte handlers (accept any byte) w.handle_eor(b"\x00") w.handle_abort(b"\x00") w.handle_eof(b"\x00") @@ -726,40 +641,33 @@ def test_simple_handlers_cover_logging(): w.handle_ip(b"\x00") w.handle_ao(b"\x00") w.handle_ec(b"\x00") - w.handle_tm(DO) # use DO for logging + w.handle_tm(DO) def test_feed_byte_clears_pending_dont_on_will(): - # Client receiving WILL ECHO with pending DONT+ECHO clears pending wc, tc, pc = new_writer(server=False, client=True) wc.pending_option[DONT + ECHO] = True wc.feed_byte(IAC) wc.feed_byte(WILL) wc.feed_byte(ECHO) assert not wc.pending_option.enabled(DONT + ECHO) - # should have replied DO ECHO and enabled remote option assert wc.remote_option[ECHO] is True assert tc.writes[-1] == IAC + DO + ECHO def test_send_status_composes_both_local_and_remote_entries(): w, t, p = new_writer(server=True) - # grant privilege to send status w.local_option[STATUS] = True - # local: one True (BINARY), one False (ECHO) w.local_option[BINARY] = True w.local_option[ECHO] = False - # remote: one True (SGA), one False (LINEMODE) w.remote_option[SGA] = True w.remote_option[LINEMODE] = False - # include pending DO and DONT flags to exercise branches w.pending_option[DO + ECHO] = True w.pending_option[DONT + NAWS] = True w._send_status() frame = t.writes[-1] assert frame.startswith(IAC + SB + STATUS + IS) and frame.endswith(IAC + SE) - # ensure there is at least one WILL/WONT and DO/DONT in payload payload = frame[4:-2] assert any(b in payload for b in (DO, DONT)) assert any(b in payload for b in (WILL, WONT)) @@ -767,10 +675,10 @@ def test_send_status_composes_both_local_and_remote_entries(): def test_reader_requires_exception_callable(): class BadReader2: - exception = 42 # not callable + exception = 42 t = MockTransport() - p = ProtocolBase() + p = MockProtocol() with pytest.raises(TypeError): TelnetWriter(t, p, server=True, reader=BadReader2()) @@ -782,9 +690,8 @@ def test_request_status_without_will_returns_false(): def test_receive_status_mismatch_logs_no_exception(): w, t, p = new_writer(server=True) - # local DO BINARY but local_option[BINARY] False causes mismatch logging buf = collections.deque([DO, BINARY]) - w._receive_status(buf) # should not raise + w._receive_status(buf) def test_inbinary_outbinary_properties(): @@ -802,23 +709,20 @@ def fn(outgoing=True): return "ascii" t = MockTransport() - p = ProtocolBase() + p = MockProtocol() wu = TelnetWriterUnicode(t, p, fn, server=True) wu.close() wu.write("ignored") - # no writes performed after close assert not t.writes def test_handle_sb_forwardmask_server_will_and_client_do(): - # server WILL path sets remote_option[SB+LINEMODE+FORWARDMASK] ws, ts, ps = new_writer(server=True) ws.remote_option[LINEMODE] = True ws._handle_sb_forwardmask(WILL, collections.deque()) opt = SB + LINEMODE + slc.LMODE_FORWARDMASK assert ws.remote_option[opt] is True - # client DO path -> forwardmask logged, local_option set wc, tc, pc = new_writer(server=False, client=True) wc.local_option[LINEMODE] = True wc._handle_sb_forwardmask(DO, collections.deque([b"x"])) @@ -858,83 +762,61 @@ def test_handle_sb_linemode_passes_opt_to_forwardmask(): def test_slc_add_buffer_full_raises(): w, t, p = new_writer(server=True) - # fill buffer to maximum for _ in range(slc.NSLC * 6): w._slc_buffer.append(b"x") with pytest.raises(ValueError): w._slc_add(slc.SLC_IP) - # clear to avoid side effects w._slc_buffer.clear() def test_handle_sb_linemode_slc_various(): w, t, p = new_writer(server=True) - # out-of-range func triggers nosupport add w._slc_process(bytes([255]), slc.SLC(slc.SLC_VARIABLE, b"\x01")) - - # func == theNULL with SLC_DEFAULT -> send default tab w._slc_process(theNULL, slc.SLC(slc.SLC_DEFAULT, theNULL)) - # func == theNULL with SLC_VARIABLE -> send current tab w._slc_process(theNULL, slc.SLC(slc.SLC_VARIABLE, theNULL)) - # equal level and ack set -> return func = slc.SLC_IP mydef = w.slctab[func] ack_mask = bytes([ord(mydef.mask) | ord(slc.SLC_ACK)]) w._slc_process(func, slc.SLC(ack_mask, mydef.val)) - # ack set with mismatched value -> debug and return diff_val = b"\x00" if mydef.val != b"\x00" else b"\x01" w._slc_process(func, slc.SLC(ack_mask, diff_val)) - # hislevel NOSUPPORT -> set nosupport + ack w._slc_process(slc.SLC_AO, slc.SLC(slc.SLC_NOSUPPORT, theNULL)) - - # hislevel DEFAULT with mylevel DEFAULT -> mask to NOSUPPORT w._slc_process(slc.SLC_SYNCH, slc.SLC(slc.SLC_DEFAULT, b"\x7f")) - - # self.slctab[func].val != theNULL -> accept change and ack w._slc_process(slc.SLC_EC, slc.SLC(slc.SLC_VARIABLE, b"\x08")) - - # mylevel DEFAULT and our val theNULL -> store & ack whatever was sent w._slc_process(slc.SLC_BRK, slc.SLC(slc.SLC_VARIABLE, b"\x02")) - # degenerate to NOSUPPORT when both CANTCHANGE f = slc.SLC_EOF w.slctab[f] = slc.SLC(slc.SLC_CANTCHANGE, theNULL) w._slc_process(f, slc.SLC(slc.SLC_CANTCHANGE, b"\x04")) - # else: mask current level to levelbits, with mylevel CANTCHANGE f2 = slc.SLC_EL w.slctab[f2] = slc.SLC(slc.SLC_CANTCHANGE, theNULL) w._slc_process(f2, slc.SLC(slc.SLC_VARIABLE, b"\x15")) - # Full SLC handler path with a proper triplet trip = collections.deque([slc.SLC_IP, slc.SLC_VARIABLE, b"\x03"]) w._handle_sb_linemode_slc(trip) def test_request_forwardmask_returns_false_without_will_linemode(): - w, t, p = new_writer(server=True) - # no WILL LINEMODE + w, _, _ = new_writer(server=True) assert w.request_forwardmask() is False def test_mode_client_kludge_and_server_kludge_and_remote_local(): - # server kludge when local ECHO and SGA ws, ts, ps = new_writer(server=True) ws.local_option[ECHO] = True ws.local_option[SGA] = True assert ws.mode == "kludge" - # client kludge when remote ECHO and SGA wc, tc, pc = new_writer(server=False, client=True) wc.remote_option[ECHO] = True wc.remote_option[SGA] = True assert wc.mode == "kludge" - # remote mode when remote LINEMODE enabled and not local wc.remote_option[LINEMODE] = True - wc._linemode = slc.Linemode(bytes([0])) # remote + wc._linemode = slc.Linemode(bytes([0])) assert wc.mode == "remote" @@ -968,12 +850,10 @@ def test_charset_request_accepted_updates_environ_encoding(): def test_iac_wont_and_dont_suppressed_when_remote_false(): w, t, p = new_writer(server=True) - # WONT sets local option False and writes frame w.local_option[ECHO] = True assert w.iac(WONT, ECHO) is True assert w.local_option[ECHO] is False assert t.writes[-1] == IAC + WONT + ECHO - # DONT suppressed when remote has key and is False w.remote_option[ECHO] = False assert w.iac(DONT, ECHO) is False @@ -986,9 +866,8 @@ def test_send_status_clears_pending_will_status(): def test_handle_sb_linemode_forwardmask_wrong_sb_opt_raises(): - w, t, p = new_writer(server=True) + w, _, _ = new_writer(server=True) with pytest.raises(ValueError, match="expected LMODE_FORWARDMASK"): - # DO followed by wrong sb_opt value -> ValueError w._handle_sb_linemode(collections.deque([LINEMODE, DO, b"\x99"])) @@ -996,10 +875,8 @@ def test_handle_sb_environ_info_warning_path(): seen = [] ws, ts, ps = new_writer(server=True) ws.set_ext_callback(NEW_ENVIRON, seen.append) - # First IS sets pending_option[SB + NEW_ENVIRON] = False is_payload = _encode_env_buf({"USER": "root"}) ws._handle_sb_environ(collections.deque([NEW_ENVIRON, IS, is_payload])) - # Then INFO path with pending False triggers warning path and callback info_payload = _encode_env_buf({"LANG": "C"}) ws._handle_sb_environ(collections.deque([NEW_ENVIRON, INFO, info_payload])) assert any("USER" in d for d in seen) @@ -1016,7 +893,6 @@ def test_handle_will_tm_success_sets_remote_option_and_calls_cb(): called = {} wtm, tt, pp = new_writer(server=True) wtm.set_iac_callback(TM, lambda cmd: called.setdefault("cmd", cmd)) - # mark DO+TM pending so WILL TM is accepted wtm.pending_option[DO + TM] = True wtm.handle_will(TM) assert wtm.remote_option[TM] is True @@ -1032,9 +908,7 @@ def test_handle_send_helpers_return_values(): def test_miscellaneous_handle_logs_cover_remaining_handlers(): - # server writer for server-side handlers ws, ts, ps = new_writer(server=True) - # simple extension/info handlers ws.handle_xdisploc("host:0") ws.handle_sndloc("Room 1") ws.handle_ttype("xterm") @@ -1042,7 +916,6 @@ def test_miscellaneous_handle_logs_cover_remaining_handlers(): ws.handle_environ({"USER": "root"}) ws.handle_tspeed(9600, 9600) ws.handle_charset("UTF-8") - # SLC related debug handlers ws.handle_lnext(b"\x00") ws.handle_rp(b"\x00") ws.handle_ew(b"\x00") @@ -1053,19 +926,16 @@ def test_miscellaneous_handle_logs_cover_remaining_handlers(): def test_sb_interrupted_logs_warning_with_context(caplog): """SB interruption logs WARNING (not ERROR) with option name and byte count.""" w, t, _ = new_writer(server=True) - # Enter SB mode: IAC SB CHARSET w.feed_byte(IAC) w.feed_byte(SB) w.feed_byte(CHARSET) w.feed_byte(b"\x01") w.feed_byte(b"\x02") - # Interrupt with IAC WONT (instead of IAC SE) with caplog.at_level(logging.WARNING): w.feed_byte(IAC) w.feed_byte(WONT) assert any("SB CHARSET (3 bytes) interrupted by IAC WONT" in r.message for r in caplog.records) assert all(r.levelno != logging.ERROR for r in caplog.records) - # The WONT command is still parsed: next byte is its option w.feed_byte(ECHO) @@ -1086,7 +956,6 @@ def test_handle_will_comport_accepted_and_signature_requested(): assert t.writes[-2] == IAC + DO + COM_PORT_OPTION assert w.remote_option.enabled(COM_PORT_OPTION) assert COM_PORT_OPTION not in w.rejected_will - # SIGNATURE request: IAC SB COM_PORT_OPTION \x00 IAC SE assert t.writes[-1] == IAC + SB + COM_PORT_OPTION + b"\x00" + IAC + SE @@ -1103,7 +972,6 @@ def test_comport_sb_signature_response(): def test_comport_sb_baudrate_response(): """COM-PORT-OPTION SET-BAUDRATE response is parsed.""" w, *_ = new_writer(server=False, client=True) - # subcmd 101 = SET-BAUDRATE response, 4-byte big-endian 9600 w.handle_subnegotiation( collections.deque( [COM_PORT_OPTION, bytes([101]), *[bytes([b]) for b in (0, 0, 0x25, 0x80)]] @@ -1112,24 +980,22 @@ def test_comport_sb_baudrate_response(): assert w.comport_data["baudrate"] == 9600 -def test_comport_sb_datasize_parity_stopsize(): - """COM-PORT-OPTION datasize, parity, stopsize responses are parsed.""" +@pytest.mark.parametrize( + "subcmd, payload_byte, key, expected", + [(102, 8, "datasize", 8), (103, 1, "parity", "NONE"), (104, 1, "stopsize", "1")], + ids=["datasize", "parity", "stopsize"], +) +def test_comport_sb_datasize_parity_stopsize(subcmd, payload_byte, key, expected): w, *_ = new_writer(server=False, client=True) - # datasize=8 - w.handle_subnegotiation(collections.deque([COM_PORT_OPTION, bytes([102]), bytes([8])])) - assert w.comport_data["datasize"] == 8 - # parity=NONE (1) - w.handle_subnegotiation(collections.deque([COM_PORT_OPTION, bytes([103]), bytes([1])])) - assert w.comport_data["parity"] == "NONE" - # stopsize=1 (1) - w.handle_subnegotiation(collections.deque([COM_PORT_OPTION, bytes([104]), bytes([1])])) - assert w.comport_data["stopsize"] == "1" + w.handle_subnegotiation( + collections.deque([COM_PORT_OPTION, bytes([subcmd]), bytes([payload_byte])]) + ) + assert w.comport_data[key] == expected def test_comport_sb_empty_subcmd_payload(): """COM-PORT-OPTION SIGNATURE with no payload does not store a signature.""" w, *_ = new_writer(server=False, client=True) - # subcmd 0 = SIGNATURE with no payload (server requesting our signature) w.handle_subnegotiation(collections.deque([COM_PORT_OPTION, b"\x00"])) assert "signature" not in (w.comport_data or {}) @@ -1149,7 +1015,7 @@ def test_linemode_slc_no_forwardmask_on_client(): flag = bytes([slc.SLC_LEVELBITS | ord(slc.SLC_FLUSHIN)]) value = b"\x03" # ^C w._handle_sb_linemode_slc(collections.deque([func, flag, value])) - # no AssertionError raised — forwardmask not requested on client + # no AssertionError raised -- forwardmask not requested on client def test_linemode_mode_without_negotiation_ignored(): @@ -1157,22 +1023,625 @@ def test_linemode_mode_without_negotiation_ignored(): w, t, _ = new_writer(server=False, client=True) mode_byte = bytes([0x03]) w._handle_sb_linemode_mode(collections.deque([mode_byte])) - # no AssertionError — the mode is silently ignored + # no AssertionError -- the mode is silently ignored + + +@pytest.mark.parametrize( + "func_name, byte_val, expected", + [ + ("name_option", WONT, repr(WONT)), + ("name_option", DO, repr(DO)), + ("name_option", DONT, repr(DONT)), + ("name_option", WILL, repr(WILL)), + ("name_option", IAC, repr(IAC)), + ("name_option", SB, repr(SB)), + ("name_option", SE, repr(SE)), + ("name_option", SGA, "SGA"), + ("name_option", TTYPE, "TTYPE"), + ("name_option", NAWS, "NAWS"), + ("name_command", WONT, "WONT"), + ("name_command", SGA, "SGA"), + ], +) +def test_name_option_distinguishes_commands_from_options(func_name, byte_val, expected): + from telnetlib3.telopt import name_option, name_command + fn = name_option if func_name == "name_option" else name_command + assert fn(byte_val) == expected -def test_name_option_distinguishes_commands_from_options(): - """name_option renders IAC command bytes as repr, not their command names.""" - from telnetlib3.telopt import name_option, name_command - assert name_option(WONT) == repr(WONT) - assert name_option(DO) == repr(DO) - assert name_option(DONT) == repr(DONT) - assert name_option(WILL) == repr(WILL) - assert name_option(IAC) == repr(IAC) - assert name_option(SB) == repr(SB) - assert name_option(SE) == repr(SE) - assert name_option(SGA) == "SGA" - assert name_option(TTYPE) == "TTYPE" - assert name_option(NAWS) == "NAWS" - assert name_command(WONT) == "WONT" - assert name_command(SGA) == "SGA" +@pytest.mark.parametrize( + "method, args, expected", + [ + ("handle_send_sndloc", (), ""), + ("handle_send_client_environ", ({},), {}), + ("handle_send_tspeed", (), (9600, 9600)), + ], + ids=["sndloc", "client_environ", "tspeed"], +) +def test_handle_send_default_returns(method, args, expected): + w, _, _ = new_writer(server=True) + assert getattr(w, method)(*args) == expected + + +def test_handle_msdp_logs_debug(caplog): + w, t, p = new_writer(server=True) + with caplog.at_level(logging.DEBUG): + w.handle_msdp({"HP": "100"}) + assert any("MSDP" in r.message for r in caplog.records) + + +def test_send_iac_trace_log(caplog): + w, t, p = new_writer(server=True) + with caplog.at_level(5): + w.send_iac(IAC + NOP) + assert len(t.writes) == 1 + + +@pytest.mark.parametrize( + "method, data, log_substr", + [ + ("send_msdp", {"HP": "100"}, "cannot send MSDP"), + ("send_mssp", {"NAME": "TestMUD"}, "cannot send MSSP"), + ], + ids=["msdp", "mssp"], +) +def test_send_mud_protocol_returns_early_without_negotiation(caplog, method, data, log_substr): + w, t, _ = new_writer(server=True) + with caplog.at_level(logging.DEBUG): + getattr(w, method)(data) + assert not t.writes + assert any(log_substr in r.message for r in caplog.records) + + +def test_handle_will_always_do_sends_do(): + w, t, p = new_writer(server=True) + w.always_do.add(AUTHENTICATION) + w.handle_will(AUTHENTICATION) + assert t.writes[-1] == IAC + DO + AUTHENTICATION + assert w.remote_option.enabled(AUTHENTICATION) + assert AUTHENTICATION not in w.rejected_will + + +def test_write_non_bytes_raises_type_error(): + w, t, p = new_writer(server=True) + with pytest.raises(TypeError, match="buf expected bytes"): + w.write("not bytes") + + +def test_slc_send_skips_func_zero_on_client(): + wc, tc, pc = new_writer(server=False, client=True) + wc.local_option[LINEMODE] = True + wc.remote_option[LINEMODE] = True + initial_buffer_len = len(wc._slc_buffer) + wc._slc_send() + assert len(wc._slc_buffer) >= initial_buffer_len + + +@pytest.mark.asyncio +async def test_wait_for_expected_false_registers_waiter(): + w, t, p = new_writer(server=True) + w.remote_option[ECHO] = True + + async def waiter(): + return await w.wait_for(remote={"ECHO": False}) + + task = asyncio.create_task(waiter()) + await asyncio.sleep(0) + assert len(w._waiters) == 1 + + w.remote_option[ECHO] = False + w._check_waiters() + result = await task + assert result is True + + +def test_write_escapes_iac_and_send_iac_verbatim(): + w, t, _ = new_writer(server=True) + w.write(b"A" + IAC + b"B") + assert t.writes[-1] == b"A" + IAC + IAC + b"B" + w.send_iac(IAC + CMD_EOR) + assert t.writes[-1] == IAC + CMD_EOR + + +def test_iac_skip_when_option_already_enabled_remote_and_local(): + w, t, _ = new_writer(server=True) + w.remote_option[BINARY] = True + assert w.iac(DO, BINARY) is False + assert not t.writes + + w.local_option[ECHO] = True + assert w.iac(WILL, ECHO) is False + assert not t.writes + + +def test_iac_do_sets_pending_and_writes_when_not_enabled(): + w, t, _ = new_writer(server=True) + assert w.remote_option.enabled(BINARY) is False + assert w.iac(DO, BINARY) is True + assert DO + BINARY in w.pending_option + assert t.writes[-1] == IAC + DO + BINARY + + +def test_send_eor_requires_local_option_enabled(): + w, t, _ = new_writer(server=True) + assert w.send_eor() is False + assert not t.writes + + w.local_option[EOR] = True + assert w.send_eor() is True + assert t.writes[-1] == IAC + CMD_EOR + + +def test_echo_server_only_and_will_echo_controls_write(): + w, t, _ = new_writer(server=True) + w.local_option[ECHO] = True + w.echo(b"x") + assert t.writes[-1] == b"x" + + w2, t2, _ = new_writer(server=False, client=True) + w2.echo(b"x") + assert not t2.writes + + +def test_mode_property_transitions(): + w, _, _ = new_writer(server=True) + assert w.mode == "local" + + w.local_option[ECHO] = True + w.local_option[SGA] = True + assert w.mode == "kludge" + + w.remote_option[LINEMODE] = True + assert w.mode == "remote" + + +def test_request_status_sends_and_pends(): + w, t, _ = new_writer(server=True) + w.remote_option[STATUS] = True + assert w.request_status() is True + assert t.writes[-1] == IAC + SB + STATUS + SEND + IAC + SE + assert w.request_status() is False + + +def test_send_status_requires_privilege_then_minimal_frame(): + w, t, _ = new_writer(server=True) + with pytest.raises(ValueError): + w._send_status() + + w.local_option[STATUS] = True + w._send_status() + assert t.writes[-1] == IAC + SB + STATUS + IS + IAC + SE + + +def test_receive_status_matches_local_and_remote_states(): + w, _, _ = new_writer(server=True) + w.local_option[BINARY] = True + w.remote_option[ECHO] = True + buf = collections.deque([DO, BINARY, WILL, ECHO]) + w._receive_status(buf) + + +def test_request_tspeed_and_handle_send_and_is(): + ws, ts, _ = new_writer(server=True) + ws.remote_option[TSPEED] = True + assert ws.request_tspeed() is True + assert ts.writes[-1] == IAC + SB + TSPEED + SEND + IAC + SE + + wc, tc, _ = new_writer(server=False, client=True) + wc.set_ext_send_callback(TSPEED, lambda: (9600, 9600)) + buf = collections.deque([TSPEED, SEND]) + wc._handle_sb_tspeed(buf) + assert tc.writes[-1] == IAC + SB + TSPEED + IS + b"9600" + b"," + b"9600" + IAC + SE + + seen = {} + ws2, _, _ = new_writer(server=True) + ws2.set_ext_callback(TSPEED, lambda rx, tx: seen.setdefault("v", (rx, tx))) + payload = b"57600,115200" + buf2 = collections.deque([TSPEED, IS] + [payload[i : i + 1] for i in range(len(payload))]) + ws2._handle_sb_tspeed(buf2) + assert seen["v"] == (57600, 115200) + + +def test_handle_sb_charset_request_accept_reject_and_accepted(): + w, t, _ = new_writer(server=True) + w.set_ext_send_callback(CHARSET, lambda offers=None: None) + sep = b" " + offers = b"UTF-8 ASCII" + buf = collections.deque([CHARSET, REQUEST, sep, offers]) + w._handle_sb_charset(buf) + assert t.writes[-1] == IAC + SB + CHARSET + b"\x03" + IAC + SE + + w2, t2, _ = new_writer(server=True) + w2.set_ext_send_callback(CHARSET, lambda offers=None: "UTF-8") + buf2 = collections.deque([CHARSET, REQUEST, sep, offers]) + w2._handle_sb_charset(buf2) + assert t2.writes[-1] == IAC + SB + CHARSET + b"\x02" + b"UTF-8" + IAC + SE + + seen = {} + w3, _, _ = new_writer(server=True) + w3.set_ext_callback(CHARSET, lambda cs: seen.setdefault("cs", cs)) + buf3 = collections.deque([CHARSET, b"\x02", b"UTF-8"]) + w3._handle_sb_charset(buf3) + assert seen["cs"] == "UTF-8" + + w4, _, _ = new_writer(server=True) + buf4 = collections.deque([CHARSET, b"\x03"]) + w4._handle_sb_charset(buf4) + + +def test_handle_sb_xdisploc_is_and_send(): + seen = {} + ws, _, _ = new_writer(server=True) + ws.set_ext_callback(XDISPLOC, lambda val: seen.setdefault("x", val)) + buf = collections.deque([XDISPLOC, IS, b"host:0"]) + ws._handle_sb_xdisploc(buf) + assert seen["x"] == "host:0" + + wc, tc, _ = new_writer(server=False, client=True) + wc.set_ext_send_callback(XDISPLOC, lambda: "disp:1") + buf2 = collections.deque([XDISPLOC, SEND]) + wc._handle_sb_xdisploc(buf2) + assert tc.writes[-1] == IAC + SB + XDISPLOC + IS + b"disp:1" + IAC + SE + + +def test_handle_sb_ttype_is_and_send(): + seen = {} + ws, _, _ = new_writer(server=True) + ws.set_ext_callback(TTYPE, lambda s: seen.setdefault("t", s)) + buf = collections.deque([TTYPE, IS, b"xterm-256color"]) + ws._handle_sb_ttype(buf) + assert seen["t"] == "xterm-256color" + + wc, tc, _ = new_writer(server=False, client=True) + wc.set_ext_send_callback(TTYPE, lambda: "vt100") + buf2 = collections.deque([TTYPE, SEND]) + wc._handle_sb_ttype(buf2) + assert tc.writes[-1] == IAC + SB + TTYPE + IS + b"vt100" + IAC + SE + + +def test_handle_sb_environ_send_and_is(): + wc, tc, _ = new_writer(server=False, client=True) + wc.set_ext_send_callback(NEW_ENVIRON, lambda keys: {"USER": "root"}) + send_payload = _encode_env_buf({"USER": ""}) + buf = collections.deque([NEW_ENVIRON, SEND, send_payload]) + wc._handle_sb_environ(buf) + frame = tc.writes[-1] + assert frame.startswith(IAC + SB + NEW_ENVIRON + IS) + assert frame.endswith(IAC + SE) + assert b"USER" in frame and b"root" in frame + + seen = {} + ws, _, _ = new_writer(server=True) + ws.set_ext_callback(NEW_ENVIRON, lambda env: seen.setdefault("env", env)) + is_payload = _encode_env_buf({"TERM": "xterm", "LANG": "C"}) + buf2 = collections.deque([NEW_ENVIRON, IS, is_payload]) + ws._handle_sb_environ(buf2) + assert seen["env"]["TERM"] == "xterm" + assert seen["env"]["LANG"] == "C" + + +def test_request_environ_server_side_conditions(): + ws, ts, _ = new_writer(server=True) + assert ws.request_environ() is False + + ws.remote_option[NEW_ENVIRON] = True + ws.set_ext_send_callback(NEW_ENVIRON, lambda: []) + assert ws.request_environ() is False + + ws.set_ext_send_callback(NEW_ENVIRON, lambda: ["USER", "LANG"]) + assert ws.request_environ() is True + frame = ts.writes[-1] + assert frame.startswith(IAC + SB + NEW_ENVIRON + SEND) + assert frame.endswith(IAC + SE) + + +def test_request_charset_and_xdisploc_and_ttype(): + ws, ts, _ = new_writer(server=True) + assert ws.request_charset() is False + ws.remote_option[CHARSET] = True + ws.set_ext_send_callback(CHARSET, lambda: ["UTF-8", "ASCII"]) + assert ws.request_charset() is True + assert ts.writes[-1].startswith(IAC + SB + CHARSET + b"\x01") + + assert ws.request_xdisploc() is False + ws.remote_option[XDISPLOC] = True + assert ws.request_xdisploc() is True + assert ts.writes[-1] == IAC + SB + XDISPLOC + SEND + IAC + SE + assert ws.request_xdisploc() is False + + assert ws.request_ttype() is False + ws.remote_option[TTYPE] = True + assert ws.request_ttype() is True + assert ts.writes[-1] == IAC + SB + TTYPE + SEND + IAC + SE + assert ws.request_ttype() is False + + +def test_send_lineflow_mode_server_only_and_modes(): + ws, ts, _ = new_writer(server=True) + assert ws.send_lineflow_mode() is False + + wc, _, _ = new_writer(server=False, client=True) + assert wc.send_lineflow_mode() is False + + ws.remote_option[LFLOW] = True + ws.xon_any = False + assert ws.send_lineflow_mode() is True + assert ts.writes[-1] == IAC + SB + LFLOW + LFLOW_RESTART_XON + IAC + SE + + ws.xon_any = True + assert ws.send_lineflow_mode() is True + assert ts.writes[-1] == IAC + SB + LFLOW + LFLOW_RESTART_ANY + IAC + SE + + +def test_send_ga_respects_sga(): + ws, ts, _ = new_writer(server=True) + assert ws.send_ga() is True + assert ts.writes[-1] == IAC + b"\xf9" + + ws.local_option[SGA] = True + assert ws.send_ga() is False + + +def test_send_naws_and_handle_naws(): + wc, tc, _ = new_writer(server=False, client=True) + wc.set_ext_send_callback(NAWS, lambda: (24, 80)) + wc._send_naws() + frame = tc.writes[-1] + assert frame.startswith(IAC + SB + NAWS) + assert frame.endswith(IAC + SE) + payload = frame[3:-2] + data = payload.replace(IAC + IAC, IAC) + assert len(data) == 4 + cols, rows = struct.unpack("!HH", data) + assert (rows, cols) == (24, 80) + + seen = {} + ws, _, _ = new_writer(server=True) + ws.remote_option[NAWS] = True + ws.set_ext_callback(NAWS, lambda r, c: seen.setdefault("sz", (r, c))) + payload2 = struct.pack("!HH", 100, 200) + buf2 = collections.deque([NAWS, payload2[0:1], payload2[1:2], payload2[2:3], payload2[3:4]]) + ws._handle_sb_naws(buf2) + assert seen["sz"] == (200, 100) + + +def test_handle_sb_lflow_toggles(): + ws, _, _ = new_writer(server=True) + ws.local_option[LFLOW] = True + + buf = collections.deque([LFLOW, LFLOW_OFF]) + ws._handle_sb_lflow(buf) + assert ws.lflow is False + + buf = collections.deque([LFLOW, LFLOW_ON]) + ws._handle_sb_lflow(buf) + assert ws.lflow is True + + buf = collections.deque([LFLOW, LFLOW_RESTART_ANY]) + ws._handle_sb_lflow(buf) + assert ws.xon_any is False + + buf = collections.deque([LFLOW, LFLOW_RESTART_XON]) + ws._handle_sb_lflow(buf) + assert ws.xon_any is True + + +def test_handle_sb_status_send_and_is(): + ws, ts, _ = new_writer(server=True) + ws.local_option[STATUS] = True + + buf = collections.deque([STATUS, SEND]) + ws._handle_sb_status(buf) + assert ts.writes[-1] == IAC + SB + STATUS + IS + IAC + SE + + ws2, _, _ = new_writer(server=True) + ws2.local_option[BINARY] = True + ws2.remote_option[SGA] = True + payload = collections.deque([DO, BINARY, WILL, SGA]) + buf2 = collections.deque([STATUS, IS] + list(payload)) + ws2._handle_sb_status(buf2) + + +def test_handle_sb_forwardmask_do_accepted(): + wc, _, _ = new_writer(server=False, client=True) + wc.local_option[LINEMODE] = True + wc._handle_sb_forwardmask(DO, collections.deque([b"x", b"y"])) + opt = SB + LINEMODE + slc.LMODE_FORWARDMASK + assert wc.local_option[opt] is True + + +def test_handle_sb_linemode_mode_empty_buffer(): + ws, _, _ = new_writer(server=True) + ws.local_option[LINEMODE] = True + ws.remote_option[LINEMODE] = True + with pytest.raises(ValueError, match="missing mode byte"): + ws._handle_sb_linemode_mode(collections.deque()) + + +def test_handle_sb_linemode_switches(): + ws, ts, _ = new_writer(server=True) + ws.local_option[LINEMODE] = True + ws.remote_option[LINEMODE] = True + ws._handle_sb_linemode_mode(collections.deque([bytes([3])])) + assert ts.writes[-1].endswith(IAC + SE) + + wc, tc, _ = new_writer(server=False, client=True) + wc._linemode = slc.Linemode(bytes([0])) + suggest_ack = bytes([ord(bytes([1])) | ord(slc.LMODE_MODE_ACK)]) + wc._handle_sb_linemode_mode(collections.deque([suggest_ack])) + assert not tc.writes + + wc2, tc2, _ = new_writer(server=False, client=True) + same = slc.Linemode(bytes([1])) + wc2._linemode = same + suggest_ack2 = bytes([ord(same.mask) | ord(slc.LMODE_MODE_ACK)]) + wc2._handle_sb_linemode_mode(collections.deque([suggest_ack2])) + assert wc2._linemode == same + assert not tc2.writes + + +def test_handle_sb_linemode_suppresses_duplicate_mode(): + ws, ts, _ = new_writer(server=True) + ws.local_option[LINEMODE] = True + ws.remote_option[LINEMODE] = True + + mode_val = bytes([3]) + mode_with_ack = bytes([3 | 4]) + + ws._handle_sb_linemode_mode(collections.deque([mode_val])) + assert len(ts.writes) > 0 + first_write_count = len(ts.writes) + assert ws._linemode.mask == mode_with_ack + + ws._handle_sb_linemode_mode(collections.deque([mode_val])) + assert len(ts.writes) == first_write_count + + ws._handle_sb_linemode_mode(collections.deque([bytes([1])])) + assert len(ts.writes) > first_write_count + + +def test_handle_sb_linemode_suppresses_duplicate_mode_client(): + wc, tc, _ = new_writer(server=False, client=True) + wc.local_option[LINEMODE] = True + wc.remote_option[LINEMODE] = True + + mode_val = bytes([3]) + mode_with_ack = bytes([3 | 4]) + + wc._handle_sb_linemode_mode(collections.deque([mode_val])) + first_write_count = len(tc.writes) + assert first_write_count > 0 + assert wc._linemode.mask == mode_with_ack + + for _ in range(3): + wc._handle_sb_linemode_mode(collections.deque([mode_val])) + assert len(tc.writes) == first_write_count + + +def test_handle_subnegotiation_dispatch_and_unhandled(): + ws, _, _ = new_writer(server=True) + ws.remote_option[NAWS] = True + payload = struct.pack("!HH", 10, 20) + buf = collections.deque([NAWS, payload[0:1], payload[1:2], payload[2:3], payload[3:4]]) + ws._handle_sb_naws(buf) + + with pytest.raises(ValueError, match="SB unhandled"): + ws.handle_subnegotiation(collections.deque([b"\x99", b"\x00"])) + + +async def test_server_data_received_split_sb_linemode(): + class NoNegServer(BaseServer): + def begin_negotiation(self): + pass + + def _check_negotiation_timer(self): + pass + + transport = MockTransport() + server = NoNegServer(encoding=False) + server.connection_made(transport) + + server.writer.remote_option[LINEMODE] = True + server.writer.local_option[LINEMODE] = True + + transport.writes.clear() + + chunk1 = IAC + SB + LINEMODE + slc.LMODE_MODE + server.data_received(chunk1) + assert server.writer.is_oob + + mask_byte = b"\x10" + chunk2 = mask_byte + IAC + SE + server.data_received(chunk2) + + response = b"".join(transport.writes) + assert IAC + SB + LINEMODE + slc.LMODE_MODE in response + + +async def test_client_process_chunk_split_sb_linemode(): + transport = MockTransport() + client = BaseClient(encoding=False) + client.connection_made(transport) + + client.writer.remote_option[LINEMODE] = True + client.writer.local_option[LINEMODE] = True + + transport.writes.clear() + + chunk1 = IAC + SB + LINEMODE + slc.LMODE_MODE + client._process_chunk(chunk1) + assert client.writer.is_oob + + mask_byte = b"\x10" + chunk2 = mask_byte + IAC + SE + client._process_chunk(chunk2) + + response = b"".join(transport.writes) + assert IAC + SB + LINEMODE + slc.LMODE_MODE in response + + +@pytest.mark.parametrize( + "opt, data, expected", + [ + (NAWS, b"\x00\x50\x00\x19", "NAWS 80x25"), + (NAWS, b"\x01\x00\x00\xc8", "NAWS 256x200"), + (TTYPE, IS + b"VT100", "TTYPE IS VT100"), + (TTYPE, SEND + b"xterm", "TTYPE SEND xterm"), + (XDISPLOC, IS + b"host:0.0", "XDISPLOC IS host:0.0"), + (SNDLOC, IS + b"Building4", "SNDLOC IS Building4"), + (TTYPE, b"\x99" + b"data", "TTYPE 99 data"), + (STATUS, b"\xab\xcd", "STATUS abcd"), + (NAWS, b"\x00\x50\x00", "NAWS 005000"), + (STATUS, b"", "STATUS"), + (BINARY, b"", "BINARY"), + ], +) +def test_format_sb_status(opt, data, expected): + assert _format_sb_status(opt, data) == expected + + +def _make_status_is_buf(*parts): + buf = collections.deque() + buf.append(STATUS) + buf.append(IS) + for part in parts: + for byte_val in part: + buf.append(bytes([byte_val])) + return buf + + +def test_receive_status_sb_naws(caplog): + ws, _, _ = new_writer(server=True) + ws.local_option[NAWS] = True + naws_payload = struct.pack("!HH", 80, 25) + buf = _make_status_is_buf(SB + NAWS + naws_payload + SE) + with caplog.at_level(logging.DEBUG): + ws._handle_sb_status(buf) + assert any("NAWS 80x25" in msg for msg in caplog.messages) + + +def test_receive_status_sb_missing_se(caplog): + ws, _, _ = new_writer(server=True) + naws_payload = struct.pack("!HH", 80, 25) + buf = _make_status_is_buf(SB + NAWS + naws_payload) + with caplog.at_level(logging.DEBUG): + ws._handle_sb_status(buf) + assert any("subneg" in msg for msg in caplog.messages) + + +def test_receive_status_mixed_do_will_and_sb(caplog): + ws, _, _ = new_writer(server=True) + ws.local_option[BINARY] = True + ws.remote_option[SGA] = True + ws.remote_option[ECHO] = True + ws.local_option[NAWS] = True + naws_payload = struct.pack("!HH", 132, 43) + buf = _make_status_is_buf( + DO + BINARY + WILL + SGA + SB + NAWS + naws_payload + SE + WONT + ECHO + ) + with caplog.at_level(logging.DEBUG): + ws._handle_sb_status(buf) + assert any("agreed" in msg.lower() for msg in caplog.messages) + assert any("NAWS 132x43" in msg for msg in caplog.messages) + assert any("disagree" in msg.lower() for msg in caplog.messages) diff --git a/telnetlib3/tests/test_sync.py b/telnetlib3/tests/test_sync.py index a0c4caf7..24c09d82 100644 --- a/telnetlib3/tests/test_sync.py +++ b/telnetlib3/tests/test_sync.py @@ -2,6 +2,7 @@ # std imports import time +import socket import threading # 3rd party @@ -9,34 +10,51 @@ # local from telnetlib3.sync import ServerConnection, TelnetConnection, BlockingTelnetServer -from telnetlib3.tests.accessories import bind_host, unused_tcp_port # pytest fixtures -def test_client_connect_and_close(bind_host, unused_tcp_port): - """TelnetConnection connects and closes properly.""" +@pytest.fixture +def started_server(bind_host, unused_tcp_port): + """Yield a started BlockingTelnetServer and shut it down on teardown.""" server = BlockingTelnetServer(bind_host, unused_tcp_port) server.start() + yield server + server.shutdown() + + +@pytest.fixture +def serve_with_handler(bind_host, unused_tcp_port): + """Start a BlockingTelnetServer with a handler, yield (host, port), shutdown on teardown.""" + servers = [] + + def _start(handler, **kwargs): + server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler, **kwargs) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + server._started.wait(timeout=5) + servers.append(server) + return bind_host, unused_tcp_port + + yield _start + + for s in servers: + s.shutdown() + +def test_client_connect_and_close(bind_host, unused_tcp_port, started_server): + """TelnetConnection connects and closes properly.""" conn = TelnetConnection(bind_host, unused_tcp_port, timeout=5) conn.connect() assert conn._connected.is_set() conn.close() - server.shutdown() - -def test_client_context_manager(bind_host, unused_tcp_port): +def test_client_context_manager(bind_host, unused_tcp_port, started_server): """TelnetConnection works as context manager.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() - with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: assert conn._connected.is_set() - server.shutdown() - -def test_client_read_write(bind_host, unused_tcp_port): +def test_client_read_write(serve_with_handler): """TelnetConnection and ServerConnection read/write work correctly.""" def handler(server_conn): @@ -44,74 +62,44 @@ def handler(server_conn): server_conn.write(data.upper()) server_conn.flush(timeout=5) - server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - server._started.wait(timeout=5) - - with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5) as conn: conn.write("hello") conn.flush() assert conn.read(5, timeout=5) == "HELLO" - server.shutdown() - - -def test_client_readline(bind_host, unused_tcp_port): - """TelnetConnection readline works correctly.""" +def test_client_readline(serve_with_handler): def handler(server_conn): server_conn.write("Hello, World!\r\n") server_conn.flush(timeout=5) - server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - server._started.wait(timeout=5) - - with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: - line = conn.readline(timeout=5) - assert "Hello, World!" in line - - server.shutdown() - + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5) as conn: + assert "Hello, World!" in conn.readline(timeout=5) -def test_client_read_until(bind_host, unused_tcp_port): - """TelnetConnection read_until works correctly.""" +def test_client_read_until(serve_with_handler): def handler(server_conn): server_conn.write(">>> ") server_conn.flush(timeout=5) - server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - server._started.wait(timeout=5) + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5) as conn: + assert conn.read_until(">>> ", timeout=5).endswith(b">>> ") - with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: - data = conn.read_until(">>> ", timeout=5) - assert data.endswith(b">>> ") - server.shutdown() - - -def test_client_read_some_alias(bind_host, unused_tcp_port): +def test_client_read_some_alias(serve_with_handler): """TelnetConnection read_some is alias for read.""" def handler(server_conn): server_conn.write("test") server_conn.flush(timeout=5) - server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - server._started.wait(timeout=5) - - with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5) as conn: assert "test" in conn.read_some(timeout=5) - server.shutdown() - def test_client_not_connected_error(): """Operations fail when not connected.""" @@ -122,17 +110,12 @@ def test_client_not_connected_error(): conn.write("test") -def test_client_already_connected_error(bind_host, unused_tcp_port): +def test_client_already_connected_error(bind_host, unused_tcp_port, started_server): """Connect fails if already connected.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() - with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: with pytest.raises(RuntimeError, match="Already connected"): conn.connect() - server.shutdown() - def test_server_start_and_shutdown(bind_host, unused_tcp_port): """BlockingTelnetServer starts and shuts down properly.""" @@ -142,10 +125,8 @@ def test_server_start_and_shutdown(bind_host, unused_tcp_port): server.shutdown() -def test_server_accept(bind_host, unused_tcp_port): +def test_server_accept(bind_host, unused_tcp_port, started_server): """BlockingTelnetServer accepts connections.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() def client_thread(): time.sleep(0.05) @@ -155,13 +136,12 @@ def client_thread(): thread = threading.Thread(target=client_thread, daemon=True) thread.start() - conn = server.accept(timeout=5) + conn = started_server.accept(timeout=5) assert isinstance(conn, ServerConnection) conn.close() - server.shutdown() -def test_server_serve_forever(bind_host, unused_tcp_port): +def test_server_serve_forever(serve_with_handler): """BlockingTelnetServer serve_forever with handler.""" received = [] @@ -170,17 +150,12 @@ def handler(conn): conn.write(received[-1].upper()) conn.flush(timeout=5) - server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - server._started.wait(timeout=5) - - with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5) as conn: conn.write("test") conn.flush() assert conn.read(4, timeout=5) == "TEST" - server.shutdown() assert received == ["test"] @@ -198,19 +173,14 @@ def test_server_accept_not_started_error(bind_host, unused_tcp_port): server.accept() -def test_server_accept_timeout(bind_host, unused_tcp_port): +def test_server_accept_timeout(bind_host, unused_tcp_port, started_server): """Accept times out when no client connects.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() with pytest.raises(TimeoutError, match="Accept timed out"): - server.accept(timeout=0.1) - server.shutdown() + started_server.accept(timeout=0.1) -def test_server_connection_read_write(bind_host, unused_tcp_port): +def test_server_connection_read_write(bind_host, unused_tcp_port, started_server): """ServerConnection read and write work correctly.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() def client_thread(): time.sleep(0.05) @@ -222,20 +192,16 @@ def client_thread(): thread = threading.Thread(target=client_thread) thread.start() - conn = server.accept(timeout=5) - data = conn.read(5, timeout=5) - conn.write(data.upper()) + conn = started_server.accept(timeout=5) + conn.write(conn.read(5, timeout=5).upper()) conn.flush(timeout=5) conn.close() thread.join(timeout=5) - server.shutdown() -def test_server_connection_closed_error(bind_host, unused_tcp_port): +def test_server_connection_closed_error(bind_host, unused_tcp_port, started_server): """Operations fail on closed ServerConnection.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() def client_thread(): time.sleep(0.1) @@ -245,7 +211,7 @@ def client_thread(): thread = threading.Thread(target=client_thread, daemon=True) thread.start() - conn = server.accept(timeout=5) + conn = started_server.accept(timeout=5) conn.close() with pytest.raises(RuntimeError, match="Connection closed"): @@ -253,13 +219,9 @@ def client_thread(): with pytest.raises(RuntimeError, match="Connection closed"): conn.write("test") - server.shutdown() - -def test_server_connection_miniboa_properties(bind_host, unused_tcp_port): +def test_server_connection_miniboa_properties(bind_host, unused_tcp_port, started_server): """ServerConnection has miniboa-compatible properties.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() def client_thread(): time.sleep(0.05) @@ -269,7 +231,7 @@ def client_thread(): thread = threading.Thread(target=client_thread, daemon=True) thread.start() - conn = server.accept(timeout=5) + conn = started_server.accept(timeout=5) assert conn.active is True assert conn.address == bind_host @@ -282,13 +244,10 @@ def client_thread(): conn.close() assert conn.active is False - server.shutdown() -def test_server_connection_miniboa_methods(bind_host, unused_tcp_port): +def test_server_connection_miniboa_methods(bind_host, unused_tcp_port, started_server): """ServerConnection has miniboa-compatible methods.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() def client_thread(): time.sleep(0.05) @@ -300,7 +259,7 @@ def client_thread(): thread = threading.Thread(target=client_thread, daemon=True) thread.start() - conn = server.accept(timeout=5) + conn = started_server.accept(timeout=5) assert f"{bind_host}:" in conn.addrport() assert conn.idle() >= 0 @@ -314,7 +273,6 @@ def client_thread(): conn.deactivate() assert conn.active is False - server.shutdown() @pytest.mark.parametrize( @@ -327,12 +285,9 @@ def client_thread(): ], ) def test_server_connection_send_newline_conversion( - bind_host, unused_tcp_port, send_text, expected_suffix + bind_host, unused_tcp_port, send_text, expected_suffix, started_server ): """Send() normalizes all newline styles to \\r\\n without doubling.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() - received = [] def client_thread(): @@ -343,39 +298,31 @@ def client_thread(): thread = threading.Thread(target=client_thread) thread.start() - conn = server.accept(timeout=5) + conn = started_server.accept(timeout=5) conn.send(send_text) conn.flush(timeout=5) conn.close() thread.join(timeout=5) - server.shutdown() assert len(received) == 1 assert received[0].endswith(expected_suffix) assert "\r\r\n" not in received[0] -def test_client_writer_property(bind_host, unused_tcp_port): - """TelnetConnection.writer exposes underlying TelnetWriter.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() +def _assert_writer_attrs(writer): + assert writer is not None + assert hasattr(writer, "mode") + assert hasattr(writer, "remote_option") + assert hasattr(writer, "local_option") - with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: - writer = conn.writer - assert writer is not None - assert hasattr(writer, "mode") - assert hasattr(writer, "remote_option") - assert hasattr(writer, "local_option") - server.shutdown() +def test_client_writer_property(bind_host, unused_tcp_port, started_server): + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + _assert_writer_attrs(conn.writer) -def test_server_connection_writer_property(bind_host, unused_tcp_port): - """ServerConnection.writer exposes underlying TelnetWriter.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() - +def test_server_connection_writer_property(bind_host, unused_tcp_port, started_server): def client_thread(): time.sleep(0.05) with TelnetConnection(bind_host, unused_tcp_port, timeout=5): @@ -384,40 +331,24 @@ def client_thread(): thread = threading.Thread(target=client_thread, daemon=True) thread.start() - conn = server.accept(timeout=5) - writer = conn.writer - assert writer is not None - assert hasattr(writer, "mode") - assert hasattr(writer, "remote_option") - assert hasattr(writer, "local_option") + conn = started_server.accept(timeout=5) + _assert_writer_attrs(conn.writer) conn.close() - server.shutdown() -def test_client_get_extra_info(bind_host, unused_tcp_port): - """TelnetConnection.get_extra_info returns connection metadata.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() - +def test_client_get_extra_info(bind_host, unused_tcp_port, started_server): with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: peername = conn.get_extra_info("peername") assert peername is not None assert len(peername) == 2 assert isinstance(peername[1], int) - - # Non-existent key returns default assert conn.get_extra_info("nonexistent") is None assert conn.get_extra_info("nonexistent", "default") == "default" - server.shutdown() - -def test_client_operations_after_close_raise(bind_host, unused_tcp_port): +def test_client_operations_after_close_raise(bind_host, unused_tcp_port, started_server): """Operations fail after connection is closed.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() - conn = TelnetConnection(bind_host, unused_tcp_port, timeout=5) conn.connect() conn.close() @@ -435,28 +366,20 @@ def test_client_operations_after_close_raise(bind_host, unused_tcp_port): with pytest.raises(RuntimeError, match="Connection closed"): conn.wait_for(remote={"NAWS": True}) - server.shutdown() - -def test_client_read_timeout(bind_host, unused_tcp_port): +def test_client_read_timeout(serve_with_handler): """TelnetConnection.read times out when no data available.""" def handler(server_conn): time.sleep(5) - server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - server._started.wait(timeout=5) - - with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5) as conn: with pytest.raises(TimeoutError, match="Read timed out"): conn.read(1, timeout=0.1) - server.shutdown() - -def test_client_readline_timeout(bind_host, unused_tcp_port): +def test_client_readline_timeout(serve_with_handler): """TelnetConnection.readline times out when no line available.""" def handler(server_conn): @@ -464,17 +387,11 @@ def handler(server_conn): server_conn.flush(timeout=5) time.sleep(5) - server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - server._started.wait(timeout=5) - - with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5) as conn: with pytest.raises(TimeoutError, match="Readline timed out"): conn.readline(timeout=0.1) - server.shutdown() - @pytest.mark.parametrize( "method,args,error_match", @@ -483,10 +400,10 @@ def handler(server_conn): pytest.param("readline", (), "Readline timed out", id="readline"), ], ) -def test_server_connection_timeout(bind_host, unused_tcp_port, method, args, error_match): +def test_server_connection_timeout( + bind_host, unused_tcp_port, method, args, error_match, started_server +): """ServerConnection methods time out when no data available.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() def client_thread(): time.sleep(0.05) @@ -496,17 +413,14 @@ def client_thread(): thread = threading.Thread(target=client_thread, daemon=True) thread.start() - conn = server.accept(timeout=5) + conn = started_server.accept(timeout=5) with pytest.raises(TimeoutError, match=error_match): getattr(conn, method)(*args, timeout=0.1) conn.close() - server.shutdown() -def test_server_connection_read_until_timeout(bind_host, unused_tcp_port): +def test_server_connection_read_until_timeout(bind_host, unused_tcp_port, started_server): """ServerConnection.read_until times out when match not found.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() def client_thread(): time.sleep(0.05) @@ -518,17 +432,14 @@ def client_thread(): thread = threading.Thread(target=client_thread, daemon=True) thread.start() - conn = server.accept(timeout=5) + conn = started_server.accept(timeout=5) with pytest.raises(TimeoutError, match="Read until timed out"): conn.read_until(">>> ", timeout=0.1) conn.close() - server.shutdown() -def test_server_connection_wait_for_timeout(bind_host, unused_tcp_port): +def test_server_connection_wait_for_timeout(bind_host, unused_tcp_port, started_server): """ServerConnection.wait_for times out when conditions not met.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() def client_thread(): time.sleep(0.05) @@ -538,11 +449,10 @@ def client_thread(): thread = threading.Thread(target=client_thread, daemon=True) thread.start() - conn = server.accept(timeout=5) + conn = started_server.accept(timeout=5) with pytest.raises(TimeoutError, match="Wait for negotiation timed out"): conn.wait_for(remote={"LINEMODE": True}, timeout=0.1) conn.close() - server.shutdown() @pytest.mark.parametrize( @@ -554,10 +464,10 @@ def client_thread(): pytest.param("readline", (), {}, id="readline"), ], ) -def test_server_connection_methods_closed_error(bind_host, unused_tcp_port, method, args, kwargs): +def test_server_connection_methods_closed_error( + bind_host, unused_tcp_port, method, args, kwargs, started_server +): """ServerConnection methods raise RuntimeError when called after close.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() def client_thread(): time.sleep(0.05) @@ -567,55 +477,214 @@ def client_thread(): thread = threading.Thread(target=client_thread, daemon=True) thread.start() - conn = server.accept(timeout=5) + conn = started_server.accept(timeout=5) conn.close() with pytest.raises(RuntimeError, match="Connection closed"): getattr(conn, method)(*args, **kwargs) - server.shutdown() -def test_server_already_started_error(bind_host, unused_tcp_port): +def test_server_already_started_error(bind_host, unused_tcp_port, started_server): """Server start raises if already started.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() with pytest.raises(RuntimeError, match="Server already started"): - server.start() - server.shutdown() + started_server.start() -def test_client_read_until_eof(bind_host, unused_tcp_port): - """TelnetConnection.read_until raises EOFError when connection closes before match.""" +def test_client_read_until_eof(serve_with_handler): + """TelnetConnection.read_until raises EOFError on early close.""" def handler(server_conn): server_conn.write("partial data") server_conn.flush(timeout=5) server_conn.close() - server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - server._started.wait(timeout=5) - - with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5) as conn: with pytest.raises(EOFError, match="Connection closed before match found"): conn.read_until(">>> ", timeout=2) - server.shutdown() - -def test_client_connect_timeout(bind_host, unused_tcp_port): - """TelnetConnection connect_timeout raises ConnectionError on unreachable port.""" +def test_client_connect_timeout_unreachable(bind_host, unused_tcp_port): + """TelnetConnection connect_timeout raises ConnectionError.""" conn = TelnetConnection(bind_host, unused_tcp_port, timeout=5, connect_timeout=0.1) with pytest.raises(ConnectionError): conn.connect() -def test_client_connect_timeout_success(bind_host, unused_tcp_port): - """TelnetConnection connect_timeout does not interfere with successful connection.""" - server = BlockingTelnetServer(bind_host, unused_tcp_port) - server.start() - +def test_client_connect_timeout_success(bind_host, unused_tcp_port, started_server): + """TelnetConnection connect_timeout does not interfere with success.""" with TelnetConnection(bind_host, unused_tcp_port, timeout=5, connect_timeout=5.0) as conn: assert conn._connected.is_set() - server.shutdown() + +def test_client_double_close(bind_host, unused_tcp_port, started_server): + """Closing a TelnetConnection twice is safe (idempotent).""" + conn = TelnetConnection(bind_host, unused_tcp_port, timeout=5) + conn.connect() + conn.close() + conn.close() + assert conn._closed is True + + +def test_client_connect_timeout_fires(bind_host, unused_tcp_port): + """TelnetConnection.connect raises TimeoutError on very short timeout.""" + conn = TelnetConnection(bind_host, unused_tcp_port, timeout=0.001) + with pytest.raises((TimeoutError, ConnectionError, OSError)): + conn.connect() + assert conn._closed is False or conn._thread is None + + +def test_client_wait_for_timeout(bind_host, unused_tcp_port, started_server): + """TelnetConnection.wait_for raises TimeoutError.""" + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + with pytest.raises(TimeoutError, match="Wait for negotiation timed out"): + conn.wait_for(remote={"LINEMODE": True}, timeout=0.1) + + +def test_server_connection_double_close(bind_host, unused_tcp_port, started_server): + """Closing a ServerConnection twice is safe (idempotent).""" + + def client_thread(): + time.sleep(0.05) + with TelnetConnection(bind_host, unused_tcp_port, timeout=5): + time.sleep(0.5) + + thread = threading.Thread(target=client_thread, daemon=True) + thread.start() + + conn = started_server.accept(timeout=5) + conn.close() + conn.close() + assert conn._closed is True + + +def test_client_read_until_timeout(serve_with_handler): + """TelnetConnection.read_until times out when match not found.""" + + def handler(server_conn): + server_conn.write("no match here") + server_conn.flush(timeout=5) + time.sleep(5) + + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5) as conn: + with pytest.raises(TimeoutError, match="Read until timed out"): + conn.read_until(">>> ", timeout=0.1) + + +def test_client_flush_timeout(serve_with_handler): + """TelnetConnection.flush works after writing data.""" + + def handler(server_conn): + server_conn.read(5, timeout=5) + + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5) as conn: + conn.write("hello") + conn.flush(timeout=5) + + +def test_client_connect_timeout_ephemeral(bind_host): + """TelnetConnection raises TimeoutError on connection timeout.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind((bind_host, 0)) + port = sock.getsockname()[1] + sock.close() + + conn = TelnetConnection(bind_host, port, timeout=0.1) + with pytest.raises((TimeoutError, OSError)): + conn.connect() + + +@pytest.mark.parametrize( + "method,args", + [pytest.param("read", (100,), id="read"), pytest.param("readline", (), id="readline")], +) +def test_client_read_eof(serve_with_handler, method, args): + """TelnetConnection.read/readline raises EOFError when server closes.""" + received = threading.Event() + + def handler(server_conn): + received.wait(timeout=5) + server_conn.close() + + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5) as conn: + received.set() + time.sleep(0.2) + with pytest.raises(EOFError): + getattr(conn, method)(*args, timeout=2) + + +def test_client_cleanup_exception_handling(bind_host, unused_tcp_port, started_server): + """TelnetConnection._cleanup swallows exceptions.""" + conn = TelnetConnection(bind_host, unused_tcp_port, timeout=5) + conn.connect() + conn.close() + conn._cleanup() + + +@pytest.mark.timeout(30) +def test_server_connection_read_some(serve_with_handler): + """ServerConnection.read_some returns available data.""" + result = [] + done = threading.Event() + + def handler(server_conn): + data = server_conn.read_some(timeout=10) + result.append(data) + done.set() + + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=10, encoding=False) as conn: + conn.write(b"hello") + conn.flush(timeout=10) + assert done.wait(timeout=10) + + assert len(result) == 1 + + +def test_server_connection_read_until(serve_with_handler): + """ServerConnection.read_until returns data up to match.""" + result = [] + + def handler(server_conn): + data = server_conn.read_until(b">>>", timeout=5) + result.append(data) + + host, port = serve_with_handler(handler, encoding=False) + with TelnetConnection(host, port, timeout=5, encoding=False) as conn: + conn.write(b"hello>>>") + conn.flush(timeout=5) + time.sleep(0.3) + + assert len(result) == 1 + assert b">>>" in result[0] + + +def test_server_connection_send_newline(serve_with_handler): + """ServerConnection.send normalizes newlines to CRLF.""" + result = [] + + def handler(server_conn): + server_conn.send("hello\nworld") + time.sleep(0.2) + + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5, encoding=False) as conn: + time.sleep(0.5) + data = conn.read(-1, timeout=2) + result.append(data) + + assert len(result) == 1 + assert b"\r\n" in result[0] + + +def test_server_shutdown_cancels_tasks(serve_with_handler): + """BlockingTelnetServer.shutdown cancels pending tasks.""" + + def handler(server_conn): + time.sleep(10) + + host, port = serve_with_handler(handler) + with TelnetConnection(host, port, timeout=5, encoding=False) as conn: + time.sleep(0.1) diff --git a/telnetlib3/tests/test_telnetlib.py b/telnetlib3/tests/test_telnetlib.py index dc9bebd8..d97c1a79 100644 --- a/telnetlib3/tests/test_telnetlib.py +++ b/telnetlib3/tests/test_telnetlib.py @@ -18,7 +18,6 @@ except OSError: pytest.skip("Working socket required", allow_module_level=True) -# pylint:disable=consider-using-from-import # local import telnetlib3.telnetlib as telnetlib # noqa: E402 @@ -185,148 +184,147 @@ def make_telnet(reads=(), cls=TelnetAlike): return telnet -class TestGeneral: - def test_basic(self, server_port): - # connects +@pytest.fixture +def _mock_selector(monkeypatch): + monkeypatch.setattr(telnetlib, "_TelnetSelector", MockSelector) + + +def test_basic(server_port): + telnet = telnetlib.Telnet(HOST, server_port) + telnet.sock.close() + + +def test_context_manager(server_port): + with telnetlib.Telnet(HOST, server_port) as tn: + assert tn.get_socket() is not None + assert tn.get_socket() is None + + +def test_timeout_default(server_port): + assert socket.getdefaulttimeout() is None + socket.setdefaulttimeout(30) + try: telnet = telnetlib.Telnet(HOST, server_port) - telnet.sock.close() + finally: + socket.setdefaulttimeout(None) + assert telnet.sock.gettimeout() == 30 + telnet.sock.close() - def test_context_manager(self, server_port): - with telnetlib.Telnet(HOST, server_port) as tn: - assert tn.get_socket() is not None - assert tn.get_socket() is None - def test_timeout_default(self, server_port): - assert socket.getdefaulttimeout() is None - socket.setdefaulttimeout(30) +def test_timeout_none(server_port): + assert socket.getdefaulttimeout() is None + socket.setdefaulttimeout(30) + try: + telnet = telnetlib.Telnet(HOST, server_port, timeout=None) + finally: + socket.setdefaulttimeout(None) + assert telnet.sock.gettimeout() is None + telnet.sock.close() + + +def test_timeout_value(server_port): + telnet = telnetlib.Telnet(HOST, server_port, timeout=30) + assert telnet.sock.gettimeout() == 30 + telnet.sock.close() + + +def test_timeout_open(server_port): + telnet = telnetlib.Telnet() + telnet.open(HOST, server_port, timeout=30) + assert telnet.sock.gettimeout() == 30 + telnet.sock.close() + + +def test_getters(server_port): + telnet = telnetlib.Telnet(HOST, server_port, timeout=30) + t_sock = telnet.sock + assert telnet.get_socket() == t_sock + assert telnet.fileno() == t_sock.fileno() + telnet.sock.close() + + +def _read_eager(func_name): + """Read all data available already queued or on the socket, without blocking.""" + want = b"x" * 100 + telnet = make_telnet([want]) + func = getattr(telnet, func_name) + telnet.sock.block = True + assert b"" == func() + telnet.sock.block = False + data = b"" + while True: try: - telnet = telnetlib.Telnet(HOST, server_port) - finally: - socket.setdefaulttimeout(None) - assert telnet.sock.gettimeout() == 30 - telnet.sock.close() - - def test_timeout_none(self, server_port): - # None, having other default - assert socket.getdefaulttimeout() is None - socket.setdefaulttimeout(30) + data += func() + except EOFError: + break + assert data == want + + +def test_read_until(_mock_selector): + """read_until(expected, timeout=None) test the blocking version of read_util.""" + want = [b"xxxmatchyyy"] + telnet = make_telnet(want) + data = telnet.read_until(b"match") + assert data == b"xxxmatch", (telnet.cookedq, telnet.rawq, telnet.sock.reads) + + reads = [b"x" * 50, b"match", b"y" * 50] + expect = b"".join(reads[:-1]) + telnet = make_telnet(reads) + data = telnet.read_until(b"match") + assert data == expect + + +def test_read_all(_mock_selector): + """read_all() Read all data until EOF; may block.""" + reads = [b"x" * 500, b"y" * 500, b"z" * 500] + expect = b"".join(reads) + telnet = make_telnet(reads) + data = telnet.read_all() + assert data == expect + + +def test_read_some(_mock_selector): + """read_some() Read at least one byte or EOF; may block.""" + telnet = make_telnet([b"x" * 500]) + data = telnet.read_some() + assert len(data) >= 1 + telnet = make_telnet() + data = telnet.read_some() + assert b"" == data + + +def test_read_eager(_mock_selector): + _read_eager("read_eager") + _read_eager("read_very_eager") + + +def read_very_lazy(_mock_selector): + want = b"x" * 100 + telnet = make_telnet([want]) + assert b"" == telnet.read_very_lazy() + while telnet.sock.reads: + telnet.fill_rawq() + data = telnet.read_very_lazy() + assert want == data + with pytest.raises(EOFError): + telnet.read_very_lazy() + + +def test_read_lazy(_mock_selector): + want = b"x" * 100 + telnet = make_telnet([want]) + assert b"" == telnet.read_lazy() + data = b"" + while True: try: - telnet = telnetlib.Telnet(HOST, server_port, timeout=None) - finally: - socket.setdefaulttimeout(None) - assert telnet.sock.gettimeout() is None - telnet.sock.close() - - def test_timeout_value(self, server_port): - telnet = telnetlib.Telnet(HOST, server_port, timeout=30) - assert telnet.sock.gettimeout() == 30 - telnet.sock.close() - - def test_timeout_open(self, server_port): - telnet = telnetlib.Telnet() - telnet.open(HOST, server_port, timeout=30) - assert telnet.sock.gettimeout() == 30 - telnet.sock.close() - - def test_getters(self, server_port): - # Test telnet getter methods - telnet = telnetlib.Telnet(HOST, server_port, timeout=30) - t_sock = telnet.sock - assert telnet.get_socket() == t_sock - assert telnet.fileno() == t_sock.fileno() - telnet.sock.close() - - -class ExpectAndReadBase: - @pytest.fixture(autouse=True) - def _mock_selector(self, monkeypatch): - monkeypatch.setattr(telnetlib, "_TelnetSelector", MockSelector) - - -class TestRead(ExpectAndReadBase): - def test_read_until(self): - """read_until(expected, timeout=None) test the blocking version of read_util.""" - want = [b"xxxmatchyyy"] - telnet = make_telnet(want) - data = telnet.read_until(b"match") - assert data == b"xxxmatch", (telnet.cookedq, telnet.rawq, telnet.sock.reads) - - reads = [b"x" * 50, b"match", b"y" * 50] - expect = b"".join(reads[:-1]) - telnet = make_telnet(reads) - data = telnet.read_until(b"match") - assert data == expect - - def test_read_all(self): - """read_all() Read all data until EOF; may block.""" - reads = [b"x" * 500, b"y" * 500, b"z" * 500] - expect = b"".join(reads) - telnet = make_telnet(reads) - data = telnet.read_all() - assert data == expect - - def test_read_some(self): - """read_some() Read at least one byte or EOF; may block.""" - # test 'at least one byte' - telnet = make_telnet([b"x" * 500]) - data = telnet.read_some() - assert len(data) >= 1 - # test EOF - telnet = make_telnet() - data = telnet.read_some() - assert b"" == data - - def _read_eager(self, func_name): - """read_*_eager() Read all data available already queued or on the socket, without - blocking.""" - want = b"x" * 100 - telnet = make_telnet([want]) - func = getattr(telnet, func_name) - telnet.sock.block = True - assert b"" == func() - telnet.sock.block = False - data = b"" - while True: - try: - data += func() - except EOFError: - break - assert data == want - - def test_read_eager(self): - # read_eager and read_very_eager make the same guarantees - # (they behave differently but we only test the guarantees) - self._read_eager("read_eager") - self._read_eager("read_very_eager") - # NB -- we need to test the IAC block which is mentioned in the - # docstring but not in the module docs - - def read_very_lazy(self): - want = b"x" * 100 - telnet = make_telnet([want]) - assert b"" == telnet.read_very_lazy() - while telnet.sock.reads: - telnet.fill_rawq() - data = telnet.read_very_lazy() - assert want == data - with pytest.raises(EOFError): - telnet.read_very_lazy() - - def test_read_lazy(self): - want = b"x" * 100 - telnet = make_telnet([want]) - assert b"" == telnet.read_lazy() - data = b"" - while True: - try: - read_data = telnet.read_lazy() - data += read_data - if not read_data: - telnet.fill_rawq() - except EOFError: - break - assert want.startswith(data) - assert data == want + read_data = telnet.read_lazy() + data += read_data + if not read_data: + telnet.fill_rawq() + except EOFError: + break + assert want.startswith(data) + assert data == want class nego_collector: @@ -345,109 +343,105 @@ def do_nego(self, sock, cmd, opt): tl = telnetlib -class TestWrite: - """The only thing that write does is replace each tl.IAC for tl.IAC+tl.IAC.""" - - def test_write(self): - data_sample = [ - b"data sample without IAC", - b"data sample with" + tl.IAC + b" one IAC", - b"a few" + tl.IAC + tl.IAC + b" iacs" + tl.IAC, - tl.IAC, - b"", - ] - for data in data_sample: - telnet = make_telnet() - telnet.write(data) - written = b"".join(telnet.sock.writes) - assert data.replace(tl.IAC, tl.IAC + tl.IAC) == written - - -class TestOption: - # RFC 854 commands - cmds = [tl.AO, tl.AYT, tl.BRK, tl.EC, tl.EL, tl.GA, tl.IP, tl.NOP] - - def _test_command(self, data): - """Helper for testing IAC + cmd.""" - telnet = make_telnet(data) - data_len = len(b"".join(data)) - nego = nego_collector() - telnet.set_option_negotiation_callback(nego.do_nego) - txt = telnet.read_all() - cmd = nego.seen - assert len(cmd) > 0 # we expect at least one command - assert cmd[:1] in self.cmds - assert cmd[1:2] == tl.NOOPT - assert data_len == len(txt + cmd) - nego.sb_getter = None # break the nego => telnet cycle - - def test_IAC_commands(self): - for cmd in self.cmds: - self._test_command([tl.IAC, cmd]) - self._test_command([b"x" * 100, tl.IAC, cmd, b"y" * 100]) - self._test_command([b"x" * 10, tl.IAC, cmd, b"y" * 10]) - # all at once - self._test_command([tl.IAC + cmd for cmd in self.cmds]) - - def test_SB_commands(self): - # RFC 855, subnegotiations portion - send = [ - tl.IAC + tl.SB + tl.IAC + tl.SE, - tl.IAC + tl.SB + tl.IAC + tl.IAC + tl.IAC + tl.SE, - tl.IAC + tl.SB + tl.IAC + tl.IAC + b"aa" + tl.IAC + tl.SE, - tl.IAC + tl.SB + b"bb" + tl.IAC + tl.IAC + tl.IAC + tl.SE, - tl.IAC + tl.SB + b"cc" + tl.IAC + tl.IAC + b"dd" + tl.IAC + tl.SE, - ] - telnet = make_telnet(send) - nego = nego_collector(telnet.read_sb_data) - telnet.set_option_negotiation_callback(nego.do_nego) - txt = telnet.read_all() - assert txt == b"" - want_sb_data = tl.IAC + tl.IAC + b"aabb" + tl.IAC + b"cc" + tl.IAC + b"dd" - assert nego.sb_seen == want_sb_data - assert b"" == telnet.read_sb_data() - nego.sb_getter = None # break the nego => telnet cycle - - def test_debuglevel_reads(self): - # test all the various places that self.msg(...) is called - given_a_expect_b = [ - # Telnet.fill_rawq - (b"a", ": recv b''\n"), - # Telnet.process_rawq - (tl.IAC + bytes([88]), ": IAC 88 not recognized\n"), - (tl.IAC + tl.DO + bytes([1]), ": IAC DO 1\n"), - (tl.IAC + tl.DONT + bytes([1]), ": IAC DONT 1\n"), - (tl.IAC + tl.WILL + bytes([1]), ": IAC WILL 1\n"), - (tl.IAC + tl.WONT + bytes([1]), ": IAC WONT 1\n"), - ] - for a, b in given_a_expect_b: - telnet = make_telnet([a]) - telnet.set_debuglevel(1) - _ = telnet.read_all() - assert b in telnet._messages - - def test_debuglevel_write(self): - telnet = make_telnet() - telnet.set_debuglevel(1) - telnet.write(b"xxx") - expected = "send b'xxx'\n" - assert expected in telnet._messages - - def test_debug_accepts_str_port(self): - # Issue 10695 - with mocktest_socket([]): - telnet = TelnetAlike("dummy", "0") - telnet._messages = "" +@pytest.mark.parametrize( + "data", + [ + b"data sample without IAC", + b"data sample with" + tl.IAC + b" one IAC", + b"a few" + tl.IAC + tl.IAC + b" iacs" + tl.IAC, + tl.IAC, + b"", + ], +) +def test_write_doubles_iac(data): + telnet = make_telnet() + telnet.write(data) + written = b"".join(telnet.sock.writes) + assert data.replace(tl.IAC, tl.IAC + tl.IAC) == written + + +_OPTION_CMDS = [tl.AO, tl.AYT, tl.BRK, tl.EC, tl.EL, tl.GA, tl.IP, tl.NOP] + + +def _test_option_command(data): + """Helper for testing IAC + cmd.""" + telnet = make_telnet(data) + data_len = len(b"".join(data)) + nego = nego_collector() + telnet.set_option_negotiation_callback(nego.do_nego) + txt = telnet.read_all() + cmd = nego.seen + assert len(cmd) > 0 # we expect at least one command + assert cmd[:1] in _OPTION_CMDS + assert cmd[1:2] == tl.NOOPT + assert data_len == len(txt + cmd) + nego.sb_getter = None # break the nego => telnet cycle + + +def test_IAC_commands(): + for cmd in _OPTION_CMDS: + _test_option_command([tl.IAC, cmd]) + _test_option_command([b"x" * 100, tl.IAC, cmd, b"y" * 100]) + _test_option_command([b"x" * 10, tl.IAC, cmd, b"y" * 10]) + _test_option_command([tl.IAC + cmd for cmd in _OPTION_CMDS]) + + +def test_SB_commands(): + send = [ + tl.IAC + tl.SB + tl.IAC + tl.SE, + tl.IAC + tl.SB + tl.IAC + tl.IAC + tl.IAC + tl.SE, + tl.IAC + tl.SB + tl.IAC + tl.IAC + b"aa" + tl.IAC + tl.SE, + tl.IAC + tl.SB + b"bb" + tl.IAC + tl.IAC + tl.IAC + tl.SE, + tl.IAC + tl.SB + b"cc" + tl.IAC + tl.IAC + b"dd" + tl.IAC + tl.SE, + ] + telnet = make_telnet(send) + nego = nego_collector(telnet.read_sb_data) + telnet.set_option_negotiation_callback(nego.do_nego) + txt = telnet.read_all() + assert txt == b"" + want_sb_data = tl.IAC + tl.IAC + b"aabb" + tl.IAC + b"cc" + tl.IAC + b"dd" + assert nego.sb_seen == want_sb_data + assert b"" == telnet.read_sb_data() + nego.sb_getter = None # break the nego => telnet cycle + + +def test_debuglevel_reads(): + given_a_expect_b = [ + (b"a", ": recv b''\n"), + (tl.IAC + bytes([88]), ": IAC 88 not recognized\n"), + (tl.IAC + tl.DO + bytes([1]), ": IAC DO 1\n"), + (tl.IAC + tl.DONT + bytes([1]), ": IAC DONT 1\n"), + (tl.IAC + tl.WILL + bytes([1]), ": IAC WILL 1\n"), + (tl.IAC + tl.WONT + bytes([1]), ": IAC WONT 1\n"), + ] + for a, b in given_a_expect_b: + telnet = make_telnet([a]) telnet.set_debuglevel(1) - telnet.msg("test") - assert re.search(r"0.*test", telnet._messages) - - -class TestExpect(ExpectAndReadBase): - def test_expect(self): - """Expect(expected, [timeout]) Read until the expected string has been seen, or a timeout is - hit (default is no timeout); may block.""" - want = [b"x" * 10, b"match", b"y" * 10] - telnet = make_telnet(want) - _, _, data = telnet.expect([b"match"]) - assert data == b"".join(want[:-1]) + _ = telnet.read_all() + assert b in telnet._messages + + +def test_debuglevel_write(): + telnet = make_telnet() + telnet.set_debuglevel(1) + telnet.write(b"xxx") + expected = "send b'xxx'\n" + assert expected in telnet._messages + + +def test_debug_accepts_str_port(): + with mocktest_socket([]): + telnet = TelnetAlike("dummy", "0") + telnet._messages = "" + telnet.set_debuglevel(1) + telnet.msg("test") + assert re.search(r"0.*test", telnet._messages) + + +def test_expect(_mock_selector): + """Expect(expected, [timeout]) Read until the expected string has been seen, or a timeout is hit + (default is no timeout); may block.""" + want = [b"x" * 10, b"match", b"y" * 10] + telnet = make_telnet(want) + _, _, data = telnet.expect([b"match"]) + assert data == b"".join(want[:-1]) diff --git a/telnetlib3/tests/test_timeout.py b/telnetlib3/tests/test_timeout.py index e2c59c6e..fc1ddba1 100644 --- a/telnetlib3/tests/test_timeout.py +++ b/telnetlib3/tests/test_timeout.py @@ -10,13 +10,7 @@ # local from telnetlib3.client import _transform_args, _get_argument_parser from telnetlib3.telopt import DO, IAC, WONT, TTYPE -from telnetlib3.tests.accessories import ( - bind_host, - create_server, - open_connection, - unused_tcp_port, - asyncio_connection, -) +from telnetlib3.tests.accessories import create_server, open_connection, asyncio_connection async def test_telnet_server_default_timeout(bind_host, unused_tcp_port): @@ -49,33 +43,26 @@ async def test_telnet_server_set_timeout(bind_host, unused_tcp_port): assert srv_instance.get_extra_info("timeout") == 0 -async def test_telnet_server_waitfor_timeout(bind_host, unused_tcp_port): +@pytest.mark.parametrize( + "timeout,encoding,elapsed_min,elapsed_max", + [(0.050, "utf-8", 0.035, 0.150), (0.150, False, 0.050, 0.200)], +) +async def test_telnet_server_waitfor_timeout( + bind_host, unused_tcp_port, timeout, encoding, elapsed_min, elapsed_max +): """Test callback on_timeout() as coroutine of create_server().""" expected_output = IAC + DO + TTYPE + b"\r\nTimeout.\r\n" - async with create_server(host=bind_host, port=unused_tcp_port, timeout=0.050): - async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): - writer.write(IAC + WONT + TTYPE) - - stime = time.time() - output = await asyncio.wait_for(reader.read(), 0.5) - elapsed = time.time() - stime - assert 0.035 <= round(elapsed, 3) <= 0.150 - assert output == expected_output - - -async def test_telnet_server_binary_mode(bind_host, unused_tcp_port): - """Test callback on_timeout() in BINARY mode when encoding=False is used.""" - expected_output = IAC + DO + TTYPE + b"\r\nTimeout.\r\n" - - async with create_server(host=bind_host, port=unused_tcp_port, timeout=0.150, encoding=False): + async with create_server( + host=bind_host, port=unused_tcp_port, timeout=timeout, encoding=encoding + ): async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WONT + TTYPE) stime = time.time() output = await asyncio.wait_for(reader.read(), 0.5) elapsed = time.time() - stime - assert 0.050 <= round(elapsed, 3) <= 0.200 + assert elapsed_min <= round(elapsed, 3) <= elapsed_max assert output == expected_output @@ -90,12 +77,7 @@ async def test_open_connection_connect_timeout_success(bind_host, unused_tcp_por """Test connect_timeout does not interfere with successful connection.""" async with create_server(host=bind_host, port=unused_tcp_port): async with open_connection( - bind_host, - unused_tcp_port, - connect_timeout=5.0, - encoding=False, - connect_minwait=0.05, - connect_maxwait=0.5, + bind_host, unused_tcp_port, connect_timeout=5.0, encoding=False, connect_maxwait=0.5 ): pass @@ -104,13 +86,10 @@ def test_cli_connect_timeout_arg(): """Test --connect-timeout CLI argument is parsed.""" parser = _get_argument_parser() args = parser.parse_args(["example.com", "--connect-timeout", "2.5"]) - result = _transform_args(args) - assert result["connect_timeout"] == 2.5 + assert _transform_args(args)["connect_timeout"] == 2.5 def test_cli_connect_timeout_default(): - """Test --connect-timeout defaults to None.""" + """Test --connect-timeout defaults to 10.""" parser = _get_argument_parser() - args = parser.parse_args(["example.com"]) - result = _transform_args(args) - assert result["connect_timeout"] is None + assert _transform_args(parser.parse_args(["example.com"]))["connect_timeout"] == 10 diff --git a/telnetlib3/tests/test_tls.py b/telnetlib3/tests/test_tls.py index 94fa75b2..faf33f95 100644 --- a/telnetlib3/tests/test_tls.py +++ b/telnetlib3/tests/test_tls.py @@ -1,5 +1,7 @@ """Tests for TLS (TELNETS) support.""" +from __future__ import annotations + # std imports import os import ssl @@ -17,13 +19,7 @@ import trustme # local -from telnetlib3.tests.accessories import ( - bind_host, - create_server, - open_connection, - unused_tcp_port, - init_subproc_coverage, -) +from telnetlib3.tests.accessories import create_server, open_connection, init_subproc_coverage @pytest.fixture() @@ -59,7 +55,7 @@ async def shell(reader, writer): return shell, waiter -_FAST_CLIENT = dict(encoding="ascii", connect_minwait=0.05, connect_maxwait=0.5) +_FAST_CLIENT = {"encoding": "ascii", "connect_maxwait": 0.5} async def _ping_pong(bind_host, port, server_kw, client_kw): @@ -156,7 +152,7 @@ async def shell(reader, writer): @pytest.mark.parametrize( "client_ssl", [ - pytest.param(lambda: ssl.create_default_context(), id="untrusted-ctx"), + pytest.param(ssl.create_default_context, id="untrusted-ctx"), pytest.param(lambda: True, id="ssl-true"), ], ) @@ -191,7 +187,7 @@ async def shell(reader, writer): await writer.drain() data = await asyncio.wait_for(reader.read(1024), 2.0) assert data == b"" - except (ConnectionResetError, OSError): + except OSError: pass finally: writer.close() @@ -235,7 +231,6 @@ async def shell(reader, writer): ssl=client_ssl_ctx, server_hostname="localhost", encoding=False, - connect_minwait=0.05, connect_maxwait=0.5, ) as (reader, writer): await asyncio.wait_for(waiter, 2.0) @@ -433,7 +428,7 @@ def _run_in_pty(child_func, timeout: float = _MAX_SUBPROC_SECONDS) -> str: if sys.platform == "win32": pytest.skip("POSIX-only test") - import pty # pylint: disable=import-outside-toplevel + import pty with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -447,7 +442,7 @@ def _run_in_pty(child_func, timeout: float = _MAX_SUBPROC_SECONDS) -> str: except SystemExit: pass except BaseException: - import traceback # pylint: disable=import-outside-toplevel + import traceback traceback.print_exc() exit_code = 1 @@ -488,7 +483,7 @@ def __init__(self, msg: bytes): def connection_made(self, transport: asyncio.BaseTransport) -> None: self._transport = transport transport.write(self._msg) - asyncio.get_event_loop().call_later(0.3, transport.close) + asyncio.get_event_loop().call_later(1.0, transport.close) @contextlib.contextmanager @@ -546,11 +541,10 @@ def _child(): "telnetlib3-client", bind_host, str(port), - "--connect-minwait=0.05", "--connect-maxwait=0.5", "--colormatch=none", ] + extra_argv - from telnetlib3.client import run_client # pylint: disable=import-outside-toplevel + from telnetlib3.client import run_client asyncio.run(run_client()) @@ -564,7 +558,7 @@ def _child(): @pytest.mark.parametrize( "extra_argv, use_ssl", [ - pytest.param(["--ssl-no-verify"], True, id="ssl-no-verify"), + pytest.param(["--ssl-no-verify", "--raw-mode"], True, id="ssl-no-verify"), pytest.param(["--raw-mode"], False, id="raw-mode"), pytest.param(["--raw-mode", "--ascii-eol", "--ansi-keys"], False, id="ascii-eol-ansi-keys"), pytest.param(["--raw-mode", "--encoding=atascii"], False, id="atascii"), @@ -579,7 +573,7 @@ def test_cli_run_client(bind_host, unused_tcp_port, server_ssl_ctx, extra_argv, @pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only tests") def test_cli_run_server_ssl(bind_host, unused_tcp_port, ca, tmp_path, client_ssl_ctx): """run_server() with --ssl-certfile exercises the ssl pass-through.""" - import pty # pylint: disable=import-outside-toplevel + import pty port = unused_tcp_port cert_pem, key_pem = _write_cert_files(ca, tmp_path) @@ -603,13 +597,13 @@ def test_cli_run_server_ssl(bind_host, unused_tcp_port, ca, tmp_path, client_ssl "--connect-maxwait=0.05", "--loglevel=warning", ] - from telnetlib3.server import main # pylint: disable=import-outside-toplevel + from telnetlib3.server import main main() except SystemExit: pass except BaseException: - import traceback # pylint: disable=import-outside-toplevel + import traceback traceback.print_exc() exit_code = 1 diff --git a/telnetlib3/tests/test_tspeed.py b/telnetlib3/tests/test_tspeed.py index d1dd610d..1876ebdb 100644 --- a/telnetlib3/tests/test_tspeed.py +++ b/telnetlib3/tests/test_tspeed.py @@ -7,13 +7,7 @@ import telnetlib3 import telnetlib3.stream_writer from telnetlib3.telopt import DO, IS, SB, SE, IAC, WILL, TSPEED -from telnetlib3.tests.accessories import ( - bind_host, - create_server, - open_connection, - unused_tcp_port, - asyncio_connection, -) +from telnetlib3.tests.accessories import create_server, open_connection, asyncio_connection async def test_telnet_server_on_tspeed(bind_host, unused_tcp_port): @@ -54,7 +48,7 @@ def begin_advanced_negotiation(self): protocol_factory=ServerTestTspeed, host=bind_host, port=unused_tcp_port ): async with open_connection( - host=bind_host, port=unused_tcp_port, tspeed=(given_rx, given_tx), connect_minwait=0.05 + host=bind_host, port=unused_tcp_port, tspeed=(given_rx, given_tx) ) as (reader, writer): recv_rx, recv_tx = await asyncio.wait_for(_waiter, 3.0) assert recv_rx == given_rx diff --git a/telnetlib3/tests/test_ttype.py b/telnetlib3/tests/test_ttype.py index 2630b69f..553fb4a8 100644 --- a/telnetlib3/tests/test_ttype.py +++ b/telnetlib3/tests/test_ttype.py @@ -3,16 +3,14 @@ # std imports import asyncio +# 3rd party +import pytest + # local import telnetlib3 import telnetlib3.stream_writer from telnetlib3.telopt import IS, SB, SE, IAC, WILL, TTYPE -from telnetlib3.tests.accessories import ( - bind_host, - create_server, - unused_tcp_port, - asyncio_connection, -) +from telnetlib3.tests.accessories import create_server, asyncio_connection async def test_telnet_server_on_ttype(bind_host, unused_tcp_port): @@ -73,104 +71,46 @@ def on_ttype(self, ttype): srv_instance = await asyncio.wait_for(_waiter, 0.5) for idx in range(telnetlib3.TelnetServer.TTYPE_LOOPMAX): key = f"ttype{idx + 1}" - expected = given_ttypes[idx] - assert srv_instance.get_extra_info(key) == expected, (idx, key) + assert srv_instance.get_extra_info(key) == given_ttypes[idx] key = f"ttype{telnetlib3.TelnetServer.TTYPE_LOOPMAX + 1}" - expected = given_ttypes[-1] - assert srv_instance.get_extra_info(key) == expected, (idx, key) + assert srv_instance.get_extra_info(key) == given_ttypes[-1] assert srv_instance.get_extra_info("TERM") == given_ttypes[-1] -async def test_telnet_server_on_ttype_empty(bind_host, unused_tcp_port): - """Test Server's callback method on_ttype(): empty value is ignored.""" - _waiter = asyncio.Future() - given_ttypes = ("ALPHA", "", "BETA") - - class ServerTestTtype(telnetlib3.TelnetServer): - def on_ttype(self, ttype): - super().on_ttype(ttype) - if ttype == given_ttypes[-1]: - _waiter.set_result(self) - - async with create_server( - protocol_factory=ServerTestTtype, host=bind_host, port=unused_tcp_port - ): - async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): - writer.write(IAC + WILL + TTYPE) - for send_ttype in given_ttypes: - writer.write(IAC + SB + TTYPE + IS + send_ttype.encode("ascii") + IAC + SE) - - srv_instance = await asyncio.wait_for(_waiter, 0.5) - assert srv_instance.get_extra_info("ttype1") == "ALPHA" - assert srv_instance.get_extra_info("ttype2") == "BETA" - assert srv_instance.get_extra_info("TERM") == "BETA" - - -async def test_telnet_server_on_ttype_looped(bind_host, unused_tcp_port): - """Test Server's callback method on_ttype() when value looped.""" - _waiter = asyncio.Future() - given_ttypes = ("ALPHA", "BETA", "GAMMA", "ALPHA") - - class ServerTestTtype(telnetlib3.TelnetServer): - count = 1 - - def on_ttype(self, ttype): - super().on_ttype(ttype) - if self.count == len(given_ttypes): - _waiter.set_result(self) - self.count += 1 - - async with create_server( - protocol_factory=ServerTestTtype, host=bind_host, port=unused_tcp_port - ): - async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): - writer.write(IAC + WILL + TTYPE) - for send_ttype in given_ttypes: - writer.write(IAC + SB + TTYPE + IS + send_ttype.encode("ascii") + IAC + SE) - - srv_instance = await asyncio.wait_for(_waiter, 0.5) - assert srv_instance.get_extra_info("ttype1") == "ALPHA" - assert srv_instance.get_extra_info("ttype2") == "BETA" - assert srv_instance.get_extra_info("ttype3") == "GAMMA" - assert srv_instance.get_extra_info("ttype4") == "ALPHA" - assert srv_instance.get_extra_info("TERM") == "ALPHA" - - -async def test_telnet_server_on_ttype_repeated(bind_host, unused_tcp_port): - """Test Server's callback method on_ttype() when value repeats.""" - _waiter = asyncio.Future() - given_ttypes = ("ALPHA", "BETA", "GAMMA", "GAMMA") - - class ServerTestTtype(telnetlib3.TelnetServer): - count = 1 - - def on_ttype(self, ttype): - super().on_ttype(ttype) - if self.count == len(given_ttypes): - _waiter.set_result(self) - self.count += 1 - - async with create_server( - protocol_factory=ServerTestTtype, host=bind_host, port=unused_tcp_port - ): - async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): - writer.write(IAC + WILL + TTYPE) - for send_ttype in given_ttypes: - writer.write(IAC + SB + TTYPE + IS + send_ttype.encode("ascii") + IAC + SE) - - srv_instance = await asyncio.wait_for(_waiter, 0.5) - assert srv_instance.get_extra_info("ttype1") == "ALPHA" - assert srv_instance.get_extra_info("ttype2") == "BETA" - assert srv_instance.get_extra_info("ttype3") == "GAMMA" - assert srv_instance.get_extra_info("ttype4") == "GAMMA" - assert srv_instance.get_extra_info("TERM") == "GAMMA" - - -async def test_telnet_server_on_ttype_mud(bind_host, unused_tcp_port): - """Test Server's callback method on_ttype() for MUD clients (MTTS).""" +@pytest.mark.parametrize( + "given_ttypes,expected", + [ + ( + ("ALPHA", "BETA", "GAMMA", "ALPHA"), + { + "ttype1": "ALPHA", + "ttype2": "BETA", + "ttype3": "GAMMA", + "ttype4": "ALPHA", + "TERM": "ALPHA", + }, + ), + ( + ("ALPHA", "BETA", "GAMMA", "GAMMA"), + { + "ttype1": "ALPHA", + "ttype2": "BETA", + "ttype3": "GAMMA", + "ttype4": "GAMMA", + "TERM": "GAMMA", + }, + ), + ( + ("ALPHA", "BETA", "MTTS 137"), + {"ttype1": "ALPHA", "ttype2": "BETA", "ttype3": "MTTS 137", "TERM": "BETA"}, + ), + (("ALPHA", "", "BETA"), {"ttype1": "ALPHA", "ttype2": "BETA", "TERM": "BETA"}), + ], +) +async def test_telnet_server_on_ttype_variants(bind_host, unused_tcp_port, given_ttypes, expected): + """Test Server's callback method on_ttype() with various sequences.""" _waiter = asyncio.Future() - given_ttypes = ("ALPHA", "BETA", "MTTS 137") class ServerTestTtype(telnetlib3.TelnetServer): count = 1 @@ -190,7 +130,5 @@ def on_ttype(self, ttype): writer.write(IAC + SB + TTYPE + IS + send_ttype.encode("ascii") + IAC + SE) srv_instance = await asyncio.wait_for(_waiter, 0.5) - assert srv_instance.get_extra_info("ttype1") == "ALPHA" - assert srv_instance.get_extra_info("ttype2") == "BETA" - assert srv_instance.get_extra_info("ttype3") == "MTTS 137" - assert srv_instance.get_extra_info("TERM") == "BETA" + for key, value in expected.items(): + assert srv_instance.get_extra_info(key) == value diff --git a/telnetlib3/tests/test_uvloop_integration.py b/telnetlib3/tests/test_uvloop_integration.py index 6023b0f6..c6c44b43 100644 --- a/telnetlib3/tests/test_uvloop_integration.py +++ b/telnetlib3/tests/test_uvloop_integration.py @@ -15,7 +15,6 @@ # local import telnetlib3 -from telnetlib3.tests.accessories import bind_host, unused_tcp_port pytestmark = pytest.mark.skipif(not HAS_UVLOOP, reason="uvloop not installed") diff --git a/telnetlib3/tests/test_writer.py b/telnetlib3/tests/test_writer.py index b00dffd2..48a5dfd7 100644 --- a/telnetlib3/tests/test_writer.py +++ b/telnetlib3/tests/test_writer.py @@ -27,10 +27,10 @@ option_from_name, ) from telnetlib3.tests.accessories import ( - bind_host, + MockProtocol, + MockTransport, create_server, open_connection, - unused_tcp_port, asyncio_connection, ) @@ -94,9 +94,7 @@ def test_illegal_2byte_iac(): """Given an illegal 2-byte IAC command, byte is treated as in-band data.""" writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) writer.feed_byte(IAC) - # IAC SGA(b'\x03'): not a legal 2-byte cmd, treated as data - result = writer.feed_byte(SGA) - assert result is True + assert writer.feed_byte(SGA) is True def test_legal_2byte_iac(): @@ -150,8 +148,7 @@ def test_sb_interrupted(): # After interruption, IAC SE outside SB is treated as data. writer.feed_byte(b"x") writer.feed_byte(IAC) - result = writer.feed_byte(SE) - assert result is True + assert writer.feed_byte(SE) is True async def test_iac_do_twice_replies_once(bind_host, unused_tcp_port): @@ -169,12 +166,11 @@ async def shell(reader, writer): host=bind_host, shell=shell, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, ): async with asyncio_connection(bind_host, unused_tcp_port) as (client_reader, client_writer): client_writer.write(given_from_client) - result_from_server = await asyncio.wait_for(client_reader.read(), 0.5) - assert result_from_server == expect_from_server + assert await asyncio.wait_for(client_reader.read(), 0.5) == expect_from_server async def test_iac_dont_dont(bind_host, unused_tcp_port): @@ -184,20 +180,16 @@ async def shell(reader, writer): writer.close() await writer.wait_closed() - given_from_client = IAC + DONT + ECHO + IAC + DONT + ECHO - expect_from_server = b"" - async with create_server( protocol_factory=telnetlib3.BaseServer, host=bind_host, shell=shell, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, ): async with asyncio_connection(bind_host, unused_tcp_port) as (client_reader, client_writer): - client_writer.write(given_from_client) - result_from_server = await asyncio.wait_for(client_reader.read(), 0.5) - assert result_from_server == expect_from_server + client_writer.write(IAC + DONT + ECHO + IAC + DONT + ECHO) + assert await asyncio.wait_for(client_reader.read(), 0.5) == b"" async def test_send_iac_dont_dont(bind_host, unused_tcp_port): @@ -206,18 +198,14 @@ async def test_send_iac_dont_dont(bind_host, unused_tcp_port): protocol_factory=telnetlib3.BaseServer, host=bind_host, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, ) as server: - async with open_connection( - host=bind_host, port=unused_tcp_port, connect_minwait=0.05, connect_maxwait=0.05 - ) as (_, client_writer): - # say it once, - result = client_writer.iac(DONT, ECHO) - assert result - - # say it again (this call is suppressed) - result = client_writer.iac(DONT, ECHO) - assert result is False + async with open_connection(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as ( + _, + client_writer, + ): + assert client_writer.iac(DONT, ECHO) + assert client_writer.iac(DONT, ECHO) is False srv_instance = await asyncio.wait_for(server.wait_for_client(), 3.0) server_writer = srv_instance.writer @@ -225,8 +213,8 @@ async def test_send_iac_dont_dont(bind_host, unused_tcp_port): # Wait for server to process client disconnect await asyncio.sleep(0.1) - assert client_writer.remote_option[ECHO] is False, client_writer.remote_option - assert server_writer.local_option[ECHO] is False, server_writer.local_option + assert client_writer.remote_option[ECHO] is False + assert server_writer.local_option[ECHO] is False async def test_slc_simul(bind_host, unused_tcp_port): @@ -243,11 +231,8 @@ async def test_slc_simul(bind_host, unused_tcp_port): _waiter_input = asyncio.Future() async def shell(reader, writer): - # read everything from client until they hang up. - result = await reader.read() - - # then report what was received and hangup on client - _waiter_input.set_result((writer.protocol.waiters, result)) + data = await reader.read() + _waiter_input.set_result((writer.protocol.waiters, data)) writer.close() server = await telnetlib3.create_server( @@ -255,7 +240,7 @@ async def shell(reader, writer): host=bind_host, shell=shell, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, encoding=False, ) @@ -264,15 +249,12 @@ async def shell(reader, writer): host=bind_host, port=unused_tcp_port ) - # exercise client_writer.write(given_input_outband) client_writer.write(given_input_inband) await client_writer.drain() - result = await client_reader.readexactly(len(expected_from_server)) - assert result == expected_from_server + assert await client_reader.readexactly(len(expected_from_server)) == expected_from_server client_writer.close() - # verify callbacks, data_received = await asyncio.wait_for(_waiter_input, 0.5) for byte, waiter in callbacks.items(): assert waiter.done(), telnetlib3.slc.name_slc_command(byte) @@ -284,49 +266,27 @@ async def shell(reader, writer): async def test_unhandled_do_sends_wont(bind_host, unused_tcp_port): """An unhandled DO is denied by WONT.""" - given_input_outband = IAC + DO + NOP - expected_output = IAC + WONT + NOP - async with create_server( protocol_factory=telnetlib3.BaseServer, host=bind_host, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, encoding=False, ): async with asyncio_connection(bind_host, unused_tcp_port) as (client_reader, client_writer): - client_writer.write(given_input_outband) - result = await asyncio.wait_for(client_reader.readexactly(len(expected_output)), 0.5) - assert result == expected_output - - -async def test_writelines_bytes(bind_host, unused_tcp_port): - """Exercise bytes-only interface of writer.writelines() function.""" - given = (b"a", b"b", b"c", b"d") - expected = b"abcd" - - async def shell(reader, writer): - writer.writelines(given) - writer.close() - await writer.wait_closed() + client_writer.write(IAC + DO + NOP) + assert await asyncio.wait_for(client_reader.readexactly(3), 0.5) == IAC + WONT + NOP - async with create_server( - protocol_factory=telnetlib3.BaseServer, - host=bind_host, - shell=shell, - port=unused_tcp_port, - connect_maxwait=0.05, - encoding=False, - ): - async with asyncio_connection(bind_host, unused_tcp_port) as (client_reader, client_writer): - result = await asyncio.wait_for(client_reader.read(), 0.5) - assert result == expected - -async def test_writelines_unicode(bind_host, unused_tcp_port): - """Exercise unicode interface of writer.writelines() function.""" - given = ("a", "b", "c", "d") - expected = b"abcd" +@pytest.mark.parametrize( + "given,encoding", + [ + pytest.param((b"a", b"b", b"c", b"d"), False, id="bytes"), + pytest.param(("a", "b", "c", "d"), "ascii", id="unicode"), + ], +) +async def test_writelines(bind_host, unused_tcp_port, given, encoding): + """Exercise writer.writelines() for bytes and unicode.""" async def shell(reader, writer): writer.writelines(given) @@ -338,12 +298,11 @@ async def shell(reader, writer): host=bind_host, shell=shell, port=unused_tcp_port, - connect_maxwait=0.05, - encoding="ascii", + connect_maxwait=0.5, + encoding=encoding, ): async with asyncio_connection(bind_host, unused_tcp_port) as (client_reader, client_writer): - result = await asyncio.wait_for(client_reader.read(), 0.5) - assert result == expected + assert await asyncio.wait_for(client_reader.read(), 0.5) == b"abcd" def test_bad_iac(): @@ -355,11 +314,9 @@ def test_bad_iac(): async def test_send_ga(bind_host, unused_tcp_port): """Writer sends IAC + GA when SGA is not negotiated.""" - expected = IAC + GA async def shell(reader, writer): - result = writer.send_ga() - assert result is True + assert writer.send_ga() is True writer.close() await writer.wait_closed() @@ -368,23 +325,17 @@ async def shell(reader, writer): host=bind_host, shell=shell, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, ): async with asyncio_connection(bind_host, unused_tcp_port) as (client_reader, client_writer): - result = await asyncio.wait_for(client_reader.read(), 0.5) - assert result == expected + assert await asyncio.wait_for(client_reader.read(), 0.5) == IAC + GA async def test_not_send_ga(bind_host, unused_tcp_port): """Writer does not send IAC + GA when SGA is negotiated.""" - # we require IAC + DO + SGA, and expect a confirming reply. We also - # call writer.send_ga() from the shell, whose result should be False - # (not sent). The reader never receives an IAC + GA. - expected = IAC + WILL + SGA async def shell(reader, writer): - result = writer.send_ga() - assert result is False + assert writer.send_ga() is False writer.close() await writer.wait_closed() @@ -393,21 +344,18 @@ async def shell(reader, writer): host=bind_host, shell=shell, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, ): async with asyncio_connection(bind_host, unused_tcp_port) as (client_reader, client_writer): client_writer.write(IAC + DO + SGA) - result = await asyncio.wait_for(client_reader.read(), 0.5) - assert result == expected + assert await asyncio.wait_for(client_reader.read(), 0.5) == IAC + WILL + SGA async def test_not_send_eor(bind_host, unused_tcp_port): """Writer does not send IAC + EOR when un-negotiated.""" - expected = b"" async def shell(reader, writer): - result = writer.send_eor() - assert result is False + assert writer.send_eor() is False writer.close() await writer.wait_closed() @@ -416,26 +364,20 @@ async def shell(reader, writer): host=bind_host, shell=shell, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, ): async with asyncio_connection(bind_host, unused_tcp_port) as (client_reader, client_writer): - result = await asyncio.wait_for(client_reader.read(), 0.5) - assert result == expected + assert await asyncio.wait_for(client_reader.read(), 0.5) == b"" async def test_send_eor(bind_host, unused_tcp_port): """Writer sends IAC + EOR if client requests by DO.""" - given = IAC + DO + EOR - expected = IAC + WILL + EOR + b"<" + IAC + CMD_EOR + b">" - - # just verify rfc constants are used appropriately in this context assert EOR == bytes([25]) assert CMD_EOR == bytes([239]) async def shell(reader, writer): writer.write("<") - result = writer.send_eor() - assert result is True + assert writer.send_eor() is True writer.write(">") writer.close() await writer.wait_closed() @@ -445,68 +387,30 @@ async def shell(reader, writer): host=bind_host, shell=shell, port=unused_tcp_port, - connect_maxwait=0.05, + connect_maxwait=0.5, ): async with asyncio_connection(bind_host, unused_tcp_port) as (client_reader, client_writer): - client_writer.write(given) - result = await asyncio.wait_for(client_reader.read(), 0.5) - assert result == expected + client_writer.write(IAC + DO + EOR) + expected = IAC + WILL + EOR + b"<" + IAC + CMD_EOR + b">" + assert await asyncio.wait_for(client_reader.read(), 0.5) == expected async def test_wait_closed(): """Test TelnetWriter.wait_closed() method waits for connection to close.""" - - class MockTransport: - def __init__(self): - self._closing = False - - def close(self): - self._closing = True - - def is_closing(self): - return self._closing - - def write(self, data): - pass - - def get_extra_info(self, name, default=None): - return default - - class MockProtocol: - def get_extra_info(self, name, default=None): - return default - - async def _drain_helper(self): - pass - - # Create a TelnetWriter instance with mock transport and protocol transport = MockTransport() protocol = MockProtocol() writer = telnetlib3.TelnetWriter(transport, protocol, server=True) - # Test that wait_closed() doesn't complete immediately wait_task = asyncio.create_task(writer.wait_closed()) - - # Give it a moment to start await asyncio.sleep(0.01) + assert not wait_task.done() - # Should not be done yet - assert not wait_task.done(), "wait_closed() should not complete before close()" - - # Now close the writer writer.close() - - # Give it a moment to complete await asyncio.sleep(0.01) + assert wait_task.done() - # Now wait_closed() should complete - assert wait_task.done(), "wait_closed() should complete after close()" - - # Wait for the task to complete (should not raise) await wait_task - - # Test calling wait_closed() after close() - should complete immediately - await writer.wait_closed() # Should complete immediately + await writer.wait_closed() def test_option_from_name(): @@ -524,9 +428,7 @@ async def test_wait_for_immediate_return(): """Test wait_for returns immediately when conditions already met.""" writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) writer.remote_option[ECHO] = True - - result = await writer.wait_for(remote={"ECHO": True}) - assert result is True + assert await writer.wait_for(remote={"ECHO": True}) is True async def test_wait_for_remote_option(): @@ -538,8 +440,7 @@ async def set_option_later(): writer.remote_option[ECHO] = True task = asyncio.create_task(set_option_later()) - result = await asyncio.wait_for(writer.wait_for(remote={"ECHO": True}), 0.5) - assert result is True + assert await asyncio.wait_for(writer.wait_for(remote={"ECHO": True}), 0.5) is True await task @@ -552,8 +453,7 @@ async def set_option_later(): writer.local_option[ECHO] = True task = asyncio.create_task(set_option_later()) - result = await asyncio.wait_for(writer.wait_for(local={"ECHO": True}), 0.5) - assert result is True + assert await asyncio.wait_for(writer.wait_for(local={"ECHO": True}), 0.5) is True await task @@ -567,8 +467,7 @@ async def clear_pending_later(): writer.pending_option[DO + TTYPE] = False task = asyncio.create_task(clear_pending_later()) - result = await asyncio.wait_for(writer.wait_for(pending={"TTYPE": False}), 0.5) - assert result is True + assert await asyncio.wait_for(writer.wait_for(pending={"TTYPE": False}), 0.5) is True await task @@ -583,10 +482,10 @@ async def set_options_later(): writer.local_option[NAWS] = True task = asyncio.create_task(set_options_later()) - result = await asyncio.wait_for( - writer.wait_for(remote={"ECHO": True}, local={"NAWS": True}), 0.5 + assert ( + await asyncio.wait_for(writer.wait_for(remote={"ECHO": True}, local={"NAWS": True}), 0.5) + is True ) - assert result is True await task @@ -616,8 +515,7 @@ async def test_wait_for_condition_immediate(): """Test wait_for_condition returns immediately when condition met.""" writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) - result = await writer.wait_for_condition(lambda w: w.server is True) - assert result is True + assert await writer.wait_for_condition(lambda w: w.server is True) is True async def test_wait_for_condition_waits(): @@ -629,10 +527,12 @@ async def set_option_later(): writer.remote_option[ECHO] = True task = asyncio.create_task(set_option_later()) - result = await asyncio.wait_for( - writer.wait_for_condition(lambda w: w.remote_option.enabled(ECHO)), 0.5 + assert ( + await asyncio.wait_for( + writer.wait_for_condition(lambda w: w.remote_option.enabled(ECHO)), 0.5 + ) + is True ) - assert result is True await task diff --git a/telnetlib3/tests/test_xdisploc.py b/telnetlib3/tests/test_xdisploc.py index 0c858e53..3433f69b 100644 --- a/telnetlib3/tests/test_xdisploc.py +++ b/telnetlib3/tests/test_xdisploc.py @@ -7,13 +7,7 @@ import telnetlib3 import telnetlib3.stream_writer from telnetlib3.telopt import DO, IS, SB, SE, IAC, WILL, XDISPLOC -from telnetlib3.tests.accessories import ( - bind_host, - create_server, - open_connection, - unused_tcp_port, - asyncio_connection, -) +from telnetlib3.tests.accessories import create_server, open_connection, asyncio_connection async def test_telnet_server_on_xdisploc(bind_host, unused_tcp_port): @@ -55,7 +49,7 @@ def begin_advanced_negotiation(self): protocol_factory=ServerTestXdisploc, host=bind_host, port=unused_tcp_port ): async with open_connection( - host=bind_host, port=unused_tcp_port, xdisploc=given_xdisploc, connect_minwait=0.05 + host=bind_host, port=unused_tcp_port, xdisploc=given_xdisploc ) as (reader, writer): recv_xdisploc = await asyncio.wait_for(_waiter, 0.5) assert recv_xdisploc == given_xdisploc diff --git a/tox.ini b/tox.ini index 37a5971e..72bb27f2 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ ignore_basepython_conflict = True skip_missing_interpreters = True envlist = - py{39,310,311,312,313,314} + py{39,310,311,312,313,314,315} black docformatter isort @@ -19,6 +19,8 @@ envlist = [testenv] basepython = python3.13 +setenv = + TERM = xterm-256color passenv = PYTHONASYNCIODEBUG deps = @@ -27,12 +29,14 @@ deps = pytest-asyncio>=0.21 pytest-cov pytest-timeout + pytest-xdist trustme usedevelop = True commands = pytest {posargs:--strict-markers --verbose --durations=10} telnetlib3/tests + ####### Linting and Formatting Environments ####### [testenv:black] @@ -119,6 +123,8 @@ commands = [testenv:pylint] deps = pylint + prettytable + ucs-detect>=2 commands = pylint {posargs} telnetlib3 bin --ignore=tests @@ -128,40 +134,37 @@ deps = pylint commands = pylint \ - --disable=invalid-name \ - --disable=import-outside-toplevel \ - --disable=protected-access \ - --disable=unused-argument \ - --disable=redefined-outer-name \ + --disable=attribute-defined-outside-init \ + --disable=comparison-with-callable \ + --disable=missing-class-docstring \ --disable=missing-function-docstring \ --disable=missing-module-docstring \ - --disable=too-few-public-methods \ - --disable=missing-class-docstring \ - --disable=unused-variable \ + --disable=possibly-used-before-assignment \ + --disable=redefined-outer-name \ --disable=unnecessary-dunder-call \ + --disable=unpacking-non-sequence \ --disable=unused-import \ - --disable=attribute-defined-outside-init \ - --disable=too-many-positional-arguments \ + --disable=unused-variable \ {posargs} telnetlib3/tests [testenv:codespell] deps = codespell commands = - codespell --skip="*.pyc,htmlcov*,_build,build,*.egg-info,.tox,.git" \ - --ignore-words-list="wont,nams,flushin,thirdparty,lient,caf,alo" \ + codespell --skip="*.pyc,*.log,*.debug,typescript*,htmlcov*,_build,build,*.egg-info,.tox,.git" \ + --ignore-words-list="wont,nams,flushin,thirdparty,lient,caf,alo,implementor,untils,hel" \ --uri-ignore-words-list "*" \ --summary --count [testenv:format] deps = {[testenv:isort]deps} - {[testenv:docformatter]deps} {[testenv:black]deps} + {[testenv:docformatter]deps} commands = {[testenv:isort]commands} - {[testenv:docformatter]commands} {[testenv:black]commands} + {[testenv:docformatter]commands} [testenv:lint] deps = @@ -225,7 +228,8 @@ max-line-length = 100 exclude = .tox,build # E203: whitespace before ':' - conflicts with black's slice formatting # W503: line break before binary operator - conflicts with black (PEP 8 now recommends this) -ignore = E203, W503 +# E704: abstract method stub on one line - conflicts with black +ignore = E203, W503, E704 [isort] profile = black @@ -259,6 +263,8 @@ addopts = --strict-markers --verbose --capture=no + -n auto + -p no:benchmark --color=yes --cov --cov-append @@ -272,18 +278,4 @@ addopts = --junit-xml=.tox/results.{envname}.xml faulthandler_timeout = 30 filterwarnings = - error - # Ignore ResourceWarnings from asyncio internal sockets during coverage cleanup - # Pattern matches both "family=1" (py312+) and "family=AddressFamily.AF_UNIX" (py38-py311) - ignore:Exception ignored in.*socket\.socket.*AF_UNIX:pytest.PytestUnraisableExceptionWarning - ignore:Exception ignored in.*socket\.socket.*family=1:pytest.PytestUnraisableExceptionWarning - ignore:Exception ignored in.*BaseEventLoop\.__del__:pytest.PytestUnraisableExceptionWarning - ignore:Exception ignored in.*_SelectorTransport\.__del__:pytest.PytestUnraisableExceptionWarning - # Ignore AF_INET socket warnings on Windows - IOCP socket cleanup is asynchronous - # and may not complete before pytest cleanup runs - ignore:Exception ignored in.*socket\.socket.*AF_INET:pytest.PytestUnraisableExceptionWarning - ignore:Exception ignored in.*socket\.socket.*family=2:pytest.PytestUnraisableExceptionWarning - # Daemon handler threads from BlockingTelnetServer.serve_forever may outlive - # the event loop during test teardown; the orphaned drain() coroutine is harmless - ignore:coroutine 'TelnetWriter.drain' was never awaited:RuntimeWarning junit_family = xunit1