Skip to content

Commit 1149ab2

Browse files
authored
MTTS fallback for CHARSET negotiation (jquast#116)
* Add MTTS fallback for CHARSET negotiation When a client sends CHARSET REJECTED but has advertised UTF-8 support via MTTS (MUD Terminal Type Standard), resolve encoding to UTF-8 immediately instead of waiting for connect_maxwait timeout. MTTS can be sent via NEW_ENVIRON (MTTS=2825) or TTYPE round 3 (MTTS 2825). Bit 3 (value 8) indicates UTF-8 capability. * Add tests for MTTS charset fallback Test that when CHARSET REJECTED is received: - UTF-8 is resolved if MTTS bit 3 is set - No resolution occurs if MTTS lacks UTF-8 bit - Both NEW_ENVIRON and TTYPE sources work * Add deferred MTTS fallback for CHARSET rejection timing CHARSET REJECTED can arrive before NEW_ENVIRON or TTYPE round 3 data is available. Set _charset_rejected flag in stream_writer's REJECTED handler, then retry _check_mtts_for_utf8() in two deferred sites: server.on_environ() when MTTS key arrives, and server.on_ttype() when ttype3 contains MTTS data. Also improves _check_mtts_for_utf8 docstring with spec URL and adds debug logging for malformed MTTS values. * Fix MTTS charset fallback tests and add edge cases Fix protocol_factory usage (class not lambda), waiter pattern (set_result(self) for protocol instance), client method names (send_env/send_ttype), and negative test pattern (pytest.raises TimeoutError instead of asyncio.sleep). Remove debug prints. Add unit tests for _check_mtts_for_utf8 edge cases: environ precedence over ttype3, MTTS=0, and malformed MTTS values. * Resolve encoding wait when MTTS confirms UTF-8 The deferred MTTS checks were calling on_charset() but not unblocking the encoding or negotiation waiters. check_negotiation() requires _check_encoding() to return True, which gates on BINARY mode — but MUD clients like tintin++ reject BINARY while advertising UTF-8 via MTTS bit 3. Set force_binary=True when MTTS confirms UTF-8 (the MUD world's replacement for RFC 856 BINARY negotiation) and resolve waiter_encoding directly. This lets check_negotiation() complete immediately instead of waiting for connect_maxwait timeout.
1 parent 86a5958 commit 1149ab2

3 files changed

Lines changed: 349 additions & 0 deletions

File tree

telnetlib3/server.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,22 @@ def on_environ(self, mapping: Dict[str, str]) -> None:
509509

510510
self._extra.update(u_mapping)
511511

512+
# Deferred MTTS fallback: CHARSET REJECTED may arrive before
513+
# NEW_ENVIRON IS, so the immediate check in stream_writer finds
514+
# nothing. Now that environ data is available, retry.
515+
if "MTTS" in u_mapping and getattr(self.writer, "_charset_rejected", False):
516+
charset = self.writer._check_mtts_for_utf8()
517+
if charset:
518+
logger.debug("MTTS indicates UTF-8 (deferred), resolving to UTF-8")
519+
self.on_charset(charset)
520+
self.writer._charset_rejected = False
521+
# MTTS bit 3 is the MUD world's replacement for BINARY
522+
# negotiation — the client is declaring UTF-8 capability
523+
# regardless of RFC 856 BINARY mode.
524+
self.force_binary = True
525+
if not self.waiter_encoding.done():
526+
self.waiter_encoding.set_result(True)
527+
512528
def on_request_charset(self) -> List[str]:
513529
"""
514530
Definition for CHARSET request by client, :rfc:`2066`.
@@ -600,6 +616,20 @@ def on_ttype(self, ttype: str) -> None:
600616
self._extra["TERM"] = val
601617
self._negotiate_environ()
602618

619+
# Deferred MTTS fallback: CHARSET REJECTED may arrive before
620+
# TTYPE round 3 completes, so the immediate check in
621+
# stream_writer finds nothing. Now that ttype3 is stored,
622+
# retry the MTTS check.
623+
if getattr(self.writer, "_charset_rejected", False):
624+
charset = self.writer._check_mtts_for_utf8()
625+
if charset:
626+
logger.debug("MTTS indicates UTF-8 (deferred from ttype3)")
627+
self.on_charset(charset)
628+
self.writer._charset_rejected = False
629+
self.force_binary = True
630+
if not self.waiter_encoding.done():
631+
self.waiter_encoding.set_result(True)
632+
603633
elif ttype == _lastval:
604634
logger.debug("ttype cycle stop at %s: %s, repeated.", key, ttype)
605635
self._negotiate_environ()

telnetlib3/stream_writer.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1866,6 +1866,35 @@ def _write(self, buf: bytes, escape_iac: bool = True) -> None:
18661866

18671867
# Private sub-negotiation (SB) routines
18681868

1869+
def _check_mtts_for_utf8(self) -> str | None:
1870+
"""
1871+
Check MTTS (MUD Terminal Type Standard) data for UTF-8 support.
1872+
1873+
Called as a fallback when CHARSET negotiation is rejected by the client.
1874+
MTTS is a bitmask where bit 3 (value 8) indicates UTF-8 capability.
1875+
Returns "UTF-8" if supported, None otherwise.
1876+
1877+
https://tintin.sourceforge.io/protocols/mtts/
1878+
"""
1879+
# Try to get MTTS from NEW_ENVIRON (e.g., "MTTS=2825")
1880+
mtts_str = self._protocol.get_extra_info("MTTS")
1881+
if not mtts_str:
1882+
# Try TTYPE round 3 (e.g., "MTTS 2825")
1883+
ttype3 = self._protocol.get_extra_info("ttype3")
1884+
if ttype3 and ttype3.upper().startswith("MTTS "):
1885+
mtts_str = ttype3[5:].strip()
1886+
1887+
if mtts_str:
1888+
try:
1889+
mtts_value = int(mtts_str)
1890+
# Bit 3 (value 8) indicates UTF-8 support
1891+
if mtts_value & 8:
1892+
return "UTF-8"
1893+
except (ValueError, TypeError):
1894+
self.log.debug("Failed to parse MTTS value: %r", mtts_str)
1895+
1896+
return None
1897+
18691898
def _handle_sb_charset(self, buf: collections.deque[bytes]) -> None:
18701899
cmd = buf.popleft()
18711900
assert cmd == CHARSET
@@ -1894,6 +1923,18 @@ def _handle_sb_charset(self, buf: collections.deque[bytes]) -> None:
18941923
self._ext_callback[CHARSET](charset)
18951924
elif opt == REJECTED:
18961925
self.log.warning("recv IAC SB CHARSET REJECTED IAC SE")
1926+
# After CHARSET rejection, check MTTS for UTF-8 support.
1927+
# TTYPE round 3 data (ttype3) is available here since TTYPE
1928+
# cycling completes before CHARSET REQUEST is sent. However,
1929+
# NEW_ENVIRON data may not have arrived yet — server.on_environ()
1930+
# handles that case with a deferred check.
1931+
if self.server and CHARSET in self._ext_callback:
1932+
charset_from_mtts = self._check_mtts_for_utf8()
1933+
if charset_from_mtts:
1934+
self.log.debug("MTTS indicates UTF-8 support, resolving to UTF-8")
1935+
self._ext_callback[CHARSET](charset_from_mtts)
1936+
else:
1937+
self._charset_rejected = True
18971938
elif opt in (TTABLE_IS, TTABLE_ACK, TTABLE_NAK, TTABLE_REJECTED):
18981939
raise NotImplementedError(
18991940
f"Translation table command received but not supported: {opt!r}"

telnetlib3/tests/test_charset.py

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import asyncio
55
import collections
66

7+
# 3rd party
8+
import pytest
9+
710
# local
811
import telnetlib3
912
import telnetlib3.stream_writer
@@ -451,3 +454,278 @@ async def test_charset_explicit_non_latin1_encoding(bind_host, unused_tcp_port):
451454
connect_maxwait=0.25,
452455
) as (reader, writer):
453456
assert writer.protocol.encoding(incoming=True) == "US-ASCII"
457+
458+
459+
async def test_charset_rejected_with_mtts_utf8(bind_host, unused_tcp_port):
460+
"""Test CHARSET REJECTED fallback to UTF-8 when MTTS indicates support."""
461+
_waiter = asyncio.Future()
462+
463+
class ServerTestCharsetMTTS(telnetlib3.TelnetServer):
464+
def on_charset(self, charset):
465+
super().on_charset(charset)
466+
if not _waiter.done():
467+
_waiter.set_result(self)
468+
469+
# Create a client that rejects CHARSET but advertises UTF-8 via MTTS
470+
class ClientWithMTTS(telnetlib3.TelnetClient):
471+
def send_charset(self, offered):
472+
# Reject CHARSET negotiation
473+
return None
474+
475+
def send_env(self, keys):
476+
# Include MTTS=2825 which has bit 3 (UTF-8) set
477+
# 2825 = 2048 + 512 + 256 + 8 + 1
478+
env = super().send_env(keys)
479+
env["MTTS"] = "2825"
480+
return env
481+
482+
async with create_server(
483+
protocol_factory=ServerTestCharsetMTTS,
484+
host=bind_host,
485+
port=unused_tcp_port,
486+
connect_maxwait=0.25,
487+
) as server:
488+
async with open_connection(
489+
client_factory=ClientWithMTTS,
490+
host=bind_host,
491+
port=unused_tcp_port,
492+
connect_minwait=0.05,
493+
) as (reader, writer):
494+
srv_instance = await asyncio.wait_for(_waiter, timeout=1.0)
495+
val = srv_instance.get_extra_info("charset")
496+
assert val == "UTF-8", f"Expected 'UTF-8', got {val!r}"
497+
498+
499+
async def test_charset_rejected_with_mtts_ttype3(bind_host, unused_tcp_port):
500+
"""Test CHARSET REJECTED fallback to UTF-8 when MTTS in TTYPE round 3."""
501+
_waiter = asyncio.Future()
502+
503+
class ServerTestCharsetMTTS(telnetlib3.TelnetServer):
504+
def on_charset(self, charset):
505+
super().on_charset(charset)
506+
_waiter.set_result(self)
507+
508+
# Create a client that rejects CHARSET but sends MTTS via TTYPE
509+
class ClientWithMTTSTtype(telnetlib3.TelnetClient):
510+
def __init__(self, *args, **kwargs):
511+
super().__init__(*args, **kwargs)
512+
self._ttype_round = 0
513+
514+
def send_charset(self, offered):
515+
# Reject CHARSET negotiation
516+
return None
517+
518+
def send_ttype(self):
519+
# Cycle through TTYPE responses, ending with MTTS
520+
responses = ["TINTIN++", "xterm-256color", "MTTS 2825"]
521+
self._ttype_round += 1
522+
if self._ttype_round <= len(responses):
523+
return responses[self._ttype_round - 1]
524+
return responses[-1]
525+
526+
async with create_server(
527+
protocol_factory=ServerTestCharsetMTTS,
528+
host=bind_host,
529+
port=unused_tcp_port,
530+
connect_maxwait=0.25,
531+
) as server:
532+
async with open_connection(
533+
client_factory=ClientWithMTTSTtype,
534+
host=bind_host,
535+
port=unused_tcp_port,
536+
connect_minwait=0.05,
537+
) as (reader, writer):
538+
srv_instance = await asyncio.wait_for(_waiter, timeout=1.0)
539+
assert srv_instance.get_extra_info("charset") == "UTF-8"
540+
541+
542+
async def test_charset_rejected_without_mtts_utf8(bind_host, unused_tcp_port):
543+
"""Test CHARSET REJECTED without UTF-8 in MTTS does not resolve."""
544+
_waiter = asyncio.Future()
545+
charset_called = []
546+
547+
class ServerTestCharsetNoMTTS(telnetlib3.TelnetServer):
548+
def on_charset(self, charset):
549+
super().on_charset(charset)
550+
charset_called.append(charset)
551+
_waiter.set_result(charset)
552+
553+
# Create a client that rejects CHARSET and has MTTS without UTF-8 bit
554+
class ClientNoUTF8MTTS(telnetlib3.TelnetClient):
555+
def send_charset(self, offered):
556+
# Reject CHARSET negotiation
557+
return None
558+
559+
def send_env(self, keys):
560+
# MTTS=257 = 256 + 1 (no bit 3 for UTF-8)
561+
env = super().send_env(keys)
562+
env["MTTS"] = "257"
563+
return env
564+
565+
async with create_server(
566+
protocol_factory=ServerTestCharsetNoMTTS,
567+
host=bind_host,
568+
port=unused_tcp_port,
569+
connect_maxwait=0.25,
570+
) as server:
571+
async with open_connection(
572+
client_factory=ClientNoUTF8MTTS,
573+
host=bind_host,
574+
port=unused_tcp_port,
575+
connect_minwait=0.05,
576+
) as (reader, writer):
577+
# on_charset should NOT fire — wait past connect_maxwait and
578+
# confirm the future never resolves
579+
with pytest.raises(asyncio.TimeoutError):
580+
await asyncio.wait_for(_waiter, timeout=0.5)
581+
582+
assert not charset_called, "on_charset should not be called without UTF-8 in MTTS"
583+
584+
# Server charset should remain the default (utf8), not
585+
# upgraded via MTTS fallback since bit 3 is not set
586+
srv_instance = server.clients[0]
587+
assert srv_instance.get_extra_info("charset") == "utf8"
588+
589+
590+
def test_check_mtts_for_utf8_from_environ():
591+
"""Test _check_mtts_for_utf8 with MTTS from NEW_ENVIRON."""
592+
593+
class MockProtocolWithMTTS:
594+
def __init__(self):
595+
self._extra = {"MTTS": "2825"}
596+
597+
def get_extra_info(self, name, default=None):
598+
return self._extra.get(name, default)
599+
600+
async def _drain_helper(self):
601+
pass
602+
603+
w, t, _ = new_writer(server=True, client=False)
604+
w._protocol = MockProtocolWithMTTS()
605+
606+
# MTTS=2825 has bit 3 set (UTF-8)
607+
result = w._check_mtts_for_utf8()
608+
assert result == "UTF-8"
609+
610+
611+
def test_check_mtts_for_utf8_from_ttype3():
612+
"""Test _check_mtts_for_utf8 with MTTS from TTYPE round 3."""
613+
614+
class MockProtocolWithTtype:
615+
def __init__(self):
616+
self._extra = {"ttype3": "MTTS 2825"}
617+
618+
def get_extra_info(self, name, default=None):
619+
return self._extra.get(name, default)
620+
621+
async def _drain_helper(self):
622+
pass
623+
624+
w, t, _ = new_writer(server=True, client=False)
625+
w._protocol = MockProtocolWithTtype()
626+
627+
# MTTS 2825 has bit 3 set (UTF-8)
628+
result = w._check_mtts_for_utf8()
629+
assert result == "UTF-8"
630+
631+
632+
def test_check_mtts_for_utf8_without_utf8_bit():
633+
"""Test _check_mtts_for_utf8 when UTF-8 bit is not set."""
634+
635+
class MockProtocolNoUTF8:
636+
def __init__(self):
637+
# MTTS=257 = 256 + 1 (no bit 3)
638+
self._extra = {"MTTS": "257"}
639+
640+
def get_extra_info(self, name, default=None):
641+
return self._extra.get(name, default)
642+
643+
async def _drain_helper(self):
644+
pass
645+
646+
w, t, _ = new_writer(server=True, client=False)
647+
w._protocol = MockProtocolNoUTF8()
648+
649+
result = w._check_mtts_for_utf8()
650+
assert result is None
651+
652+
653+
def test_check_mtts_for_utf8_no_mtts():
654+
"""Test _check_mtts_for_utf8 when MTTS is not present."""
655+
656+
class MockProtocolNoMTTS:
657+
def get_extra_info(self, name, default=None):
658+
return default
659+
660+
async def _drain_helper(self):
661+
pass
662+
663+
w, t, _ = new_writer(server=True, client=False)
664+
w._protocol = MockProtocolNoMTTS()
665+
666+
result = w._check_mtts_for_utf8()
667+
assert result is None
668+
669+
670+
def test_check_mtts_for_utf8_environ_takes_precedence():
671+
"""Test NEW_ENVIRON MTTS is checked before TTYPE round 3."""
672+
673+
class MockProtocolBothSources:
674+
def __init__(self):
675+
self._extra = {
676+
"MTTS": "257", # no UTF-8 bit
677+
"ttype3": "MTTS 2825", # has UTF-8 bit
678+
}
679+
680+
def get_extra_info(self, name, default=None):
681+
return self._extra.get(name, default)
682+
683+
async def _drain_helper(self):
684+
pass
685+
686+
w, t, _ = new_writer(server=True, client=False)
687+
w._protocol = MockProtocolBothSources()
688+
689+
# NEW_ENVIRON (MTTS=257, no UTF-8) should win over ttype3
690+
result = w._check_mtts_for_utf8()
691+
assert result is None
692+
693+
694+
def test_check_mtts_for_utf8_zero():
695+
"""Test _check_mtts_for_utf8 with MTTS=0."""
696+
697+
class MockProtocolZero:
698+
def __init__(self):
699+
self._extra = {"MTTS": "0"}
700+
701+
def get_extra_info(self, name, default=None):
702+
return self._extra.get(name, default)
703+
704+
async def _drain_helper(self):
705+
pass
706+
707+
w, t, _ = new_writer(server=True, client=False)
708+
w._protocol = MockProtocolZero()
709+
710+
result = w._check_mtts_for_utf8()
711+
assert result is None
712+
713+
714+
def test_check_mtts_for_utf8_malformed():
715+
"""Test _check_mtts_for_utf8 with non-numeric MTTS value."""
716+
717+
class MockProtocolMalformed:
718+
def __init__(self):
719+
self._extra = {"MTTS": "not-a-number"}
720+
721+
def get_extra_info(self, name, default=None):
722+
return self._extra.get(name, default)
723+
724+
async def _drain_helper(self):
725+
pass
726+
727+
w, t, _ = new_writer(server=True, client=False)
728+
w._protocol = MockProtocolMalformed()
729+
730+
result = w._check_mtts_for_utf8()
731+
assert result is None

0 commit comments

Comments
 (0)