From e4b19dd5d1a61d9f95c16cb65e6dceebef3cea50 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 30 Aug 2026 14:22:43 +0200 Subject: [PATCH] fix: commands resolve when typed on another alphabet's keyboard Co-Authored-By: Claude Opus 5 (1M context) --- fake_signal/scenarios/cyrillic_homoglyph.yaml | 5 + signal_bot/folding.py | 118 ++++++++++++++++++ signal_bot/registry.py | 7 +- tests/test_folding.py | 101 +++++++++++++++ 4 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 fake_signal/scenarios/cyrillic_homoglyph.yaml create mode 100644 signal_bot/folding.py create mode 100644 tests/test_folding.py diff --git a/fake_signal/scenarios/cyrillic_homoglyph.yaml b/fake_signal/scenarios/cyrillic_homoglyph.yaml new file mode 100644 index 0000000..9591dbd --- /dev/null +++ b/fake_signal/scenarios/cyrillic_homoglyph.yaml @@ -0,0 +1,5 @@ +name: "!gecko typed on a Russian keyboard still resolves" +inject: + - { text: "!gеckо", uuid: uuid-bob, name: Bob } +expect: + - { kind: image, to: reply } diff --git a/signal_bot/folding.py b/signal_bot/folding.py new file mode 100644 index 0000000..f7c4dfb --- /dev/null +++ b/signal_bot/folding.py @@ -0,0 +1,118 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +r"""Fold a typed command down to a comparable skeleton. + +A Signal group is full of phone keyboards, and a phone keyboard decides +which alphabet you are in. On 2026-08-29 a member typed `!gecko` on a +Russian layout: the `e` was U+0435 CYRILLIC SMALL LETTER IE and the `o` was +U+043E CYRILLIC SMALL LETTER O. Both are pixel-identical to the Latin +letters, both compare unequal to them, so the command resolved to nothing +and the bot stayed silent -- no error, no cooldown line, nothing to see. +The same trap covers Greek omicron, small capitals, fullwidth forms, +zero-width joiners pasted in from elsewhere, and plain leetspeak. + +The fix is to compare *skeletons* rather than codepoints, and to fold both +sides -- the registered trigger and the incoming text -- through the same +function. Folding both sides is what makes it safe: any alias that already +worked still folds to whatever it folded to before, so this can widen what +matches but can never narrow it. + +Every mapping below is written as an explicit `\\uXXXX` escape. A table of +confusables whose whole point is that you cannot tell the characters apart +must not be reviewed by eye. +""" + +from __future__ import annotations + +import unicodedata +from typing import Final + +# Cyrillic letters that are visually identical to a Latin one. This is the +# set that actually bit us; Russian and Ukrainian layouts reach all of it. +_CYRILLIC: Final = ( + "\u0430\u0432\u0435\u0455\u0456\u0458" + "\u043a\u043c\u043d\u043e\u0440\u0441" + "\u0442\u0443\u0445\u0501\u051b\u051d" +) +_CYRILLIC_LATIN: Final = "abesijkmhopctyxdqw" + +# Greek. Less likely from a keyboard, more likely from a copy-paste. +_GREEK: Final = ( + "\u03b1\u03b2\u03b3\u03b5\u03b6\u03b7" + "\u03b9\u03ba\u03bc\u03bd\u03bf\u03c1" + "\u03c4\u03c5\u03c7\u03c9" +) +_GREEK_LATIN: Final = "abyeznikmvoptuxw" + +# Latin small capitals. NFKC leaves these alone -- they are letters in their +# own right, not compatibility forms -- so aliases.json had to spell out +# "!ᴋᴏᴛ" by hand. With this table it no longer has to. +_SMALL_CAPS: Final = ( + "\u1d00\u0299\u1d04\u1d05\u1d07\u0262" + "\u029c\u026a\u1d0a\u1d0b\u029f\u1d0d" + "\u0274\u1d0f\u1d18\u0280\ua731\u1d1b" + "\u1d1c\u1d20\u1d21\u028f\u1d22" +) +_SMALL_CAPS_LATIN: Final = "abcdeghijklmnoprstuvwyz" + +# Latin letters outside ASCII that read as an ASCII one. Diacritics are +# handled by decomposition instead; these carry their difference in the +# letterform itself, so no amount of normalising removes it. +_LATIN_LOOKALIKE: Final = "\u0131\u0237\u0142\u00f8\u0111\u00f0" +_LATIN_LOOKALIKE_ASCII: Final = "ijlodd" + +# Leetspeak. Deliberately conservative: 1 is read as i (which is what +# "!p1es" wants) and not as l, and 6/9 are left alone because they are +# genuinely ambiguous. The cost of this table is that a command name can +# never contain a digit -- there are none today, and `!ping` is the only +# planned one, so that is a trade worth making. +_LEET: Final = "0134578" +_LEET_LATIN: Final = "oieastb" + +# Invisible characters. They survive NFKC, they are impossible to see in a +# chat client, and they arrive whenever text has been copied out of a web +# page or another messenger. +_INVISIBLE: Final = frozenset("\u00ad\u180e\u200b\u200c\u200d\u200e\u200f\u2060\ufeff") + + +def _table() -> dict[int, str]: + """Build the single translation table, once, at import. + + Raises: + ValueError: a mapping and its replacements have different lengths, + which would silently mis-map every character after the gap. + """ + mapping: dict[str, str] = {} + for source, target in ( + (_CYRILLIC, _CYRILLIC_LATIN), + (_GREEK, _GREEK_LATIN), + (_SMALL_CAPS, _SMALL_CAPS_LATIN), + (_LATIN_LOOKALIKE, _LATIN_LOOKALIKE_ASCII), + (_LEET, _LEET_LATIN), + ): + mapping.update(dict(zip(source, target, strict=True))) + return str.maketrans(mapping) + + +TABLE: Final = _table() + + +def fold(text: str) -> str: + """Reduce text to the skeleton the registry compares against. + + Applied to registered triggers and to incoming messages alike, so the + two can only ever disagree about characters this function does not + touch. + """ + # NFKC first: it collapses the mathematical alphabets, fullwidth forms + # and non-breaking spaces that aliases.json used to enumerate by hand. + compatible = unicodedata.normalize("NFKC", text) + # Then decompose, so an accent becomes a combining mark of its own and + # can be dropped -- this is what turns "papież" into "papiez" and + # what defuses the zalgo-styled alias. + decomposed = unicodedata.normalize("NFD", compatible) + stripped = "".join( + character + for character in decomposed + if not unicodedata.combining(character) and character not in _INVISIBLE + ) + return stripped.casefold().translate(TABLE).strip() diff --git a/signal_bot/registry.py b/signal_bot/registry.py index c1861da..a7960d1 100644 --- a/signal_bot/registry.py +++ b/signal_bot/registry.py @@ -12,6 +12,7 @@ from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass from typing import TYPE_CHECKING +from signal_bot.folding import fold from signal_bot.quoting import quote_of if TYPE_CHECKING: @@ -81,7 +82,7 @@ class CommandRegistry: Raises: DuplicateTriggerError: A trigger is already claimed. """ - normalised = frozenset(t.strip().lower() for t in triggers) + normalised = frozenset(fold(t) for t in triggers) for trigger in sorted(normalised): if trigger in self._by_trigger: msg = f"trigger {trigger!r} is already registered" @@ -96,7 +97,9 @@ class CommandRegistry: """Return the command a message body triggers, or None.""" if not text: return None - candidate = text.strip().lower() + # Folded, not just lowercased: a Cyrillic "o" is a different + # codepoint from a Latin one and used to resolve to nothing at all. + candidate = fold(text) if not candidate.startswith(COMMAND_PREFIX): return None return self._by_trigger.get(candidate) diff --git a/tests/test_folding.py b/tests/test_folding.py new file mode 100644 index 0000000..6242cd1 --- /dev/null +++ b/tests/test_folding.py @@ -0,0 +1,101 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Folding a typed command down to a comparable skeleton. + +Every non-ASCII literal here is an escape. These tests exist because two +characters looked identical, so a reader must be able to see which codepoint +each case actually means. +""" + +from __future__ import annotations + +import pytest + +from signal_bot.folding import TABLE, fold + +# The regression itself: `!gecko` as it arrives from a Russian keyboard, with +# U+0435 for the `e` and U+043E for the `o`. +RUSSIAN_GECKO = "!g\u0435ck\u043e" + + +def test_the_cyrillic_gecko_that_started_this() -> None: + assert fold(RUSSIAN_GECKO) == "!gecko" + assert RUSSIAN_GECKO != "!gecko" + + +@pytest.mark.parametrize( + ("typed", "expected"), + [ + # Cyrillic + ("!\u043a\u043e\u0442", "!kot"), + ("!\u0441\u0430\u0442", "!cat"), + ("!d\u043eg", "!dog"), + # Greek + ("!d\u03bfg", "!dog"), + ("!\u03bau\u03bf\u03c4", "!kuot"), + # Latin small capitals + ("!\u1d0b\u1d0f\u1d1b", "!kot"), + # Latin letters that are not ASCII + ("!d\u00f8g", "!dog"), + ("!p\u0131es", "!pies"), + # Leetspeak + ("!g3ck0", "!gecko"), + ("!d0g", "!dog"), + ("!c4t", "!cat"), + ("!p13s", "!pies"), + ("!5zczek", "!szczek"), + ("!ca7", "!cat"), + # Compatibility forms NFKC already handles + ("!\uff44\uff4f\uff47", "!dog"), + ("!\U0001d5f8\U0001d5fc\U0001d601", "!kot"), + # Diacritics decompose away + ("!papie\u017c", "!papiez"), + ("!k\u00f3t", "!kot"), + # Invisible characters are dropped + ("!d\u200bog", "!dog"), + ("!d\u200dog", "!dog"), + ("!dog\u00ad", "!dog"), + ("!\ufeffdog", "!dog"), + # Case and surrounding whitespace + (" !DOG ", "!dog"), + ("!Dog", "!dog"), + (" !dog ", "!dog"), + ], +) +def test_fold_maps_lookalikes_onto_ascii(typed: str, expected: str) -> None: + assert fold(typed) == expected + + +def test_fold_keeps_internal_whitespace() -> None: + """Argument support (0.0.6) splits on it, so folding must not eat it.""" + assert fold("!suggest !gecko co ma robic") == "!suggest !gecko co ma robic" + + +def test_fold_leaves_ordinary_text_alone() -> None: + assert fold("!dog") == "!dog" + assert fold("hello there") == "hello there" + assert fold("") == "" + + +def test_fold_is_idempotent() -> None: + """Folding twice must equal folding once, or triggers drift from input.""" + for typed in (RUSSIAN_GECKO, "!g3ck0", "!\u1d0b\u1d0f\u1d1b", "!papie\u017c"): + assert fold(fold(typed)) == fold(typed) + + +def test_fold_does_not_collapse_genuinely_different_commands() -> None: + """The table must not be so eager that distinct commands become one.""" + assert fold("!cat") != fold("!dog") + assert fold("!kot") != fold("!kots") + assert fold("!gekon") != fold("!geko") + + +def test_every_mapping_target_is_ascii() -> None: + """A mapping onto another non-ASCII character would need folding again.""" + for replacement in TABLE.values(): + assert replacement.isascii() + + +def test_table_covers_both_alphabets_that_bit_us() -> None: + assert TABLE[0x043E] == "o" + assert TABLE[0x0435] == "e" + assert TABLE[0x03BF] == "o"