diff --git a/synapse/types/__init__.py b/synapse/types/__init__.py index 79a09ce860..21826bf19b 100644 --- a/synapse/types/__init__.py +++ b/synapse/types/__init__.py @@ -593,6 +593,24 @@ def is_compliant_user_id_localpart(localpart: str) -> bool: return all(0x21 <= ord(c) <= 0x7E for c in localpart) +def is_compliant_user_id(user_id: str) -> bool: + """ + Validates that the localpart of the given user ID is within the "compliant" + range. See `is_compliant_user_id_localpart`. + + Unlike parsing the user ID with `UserID.from_string`, this never raises on a + malformed user ID. + + Args: + user_id: the user ID to be checked + + Returns: + True if the localpart of the user ID is compliant, False otherwise + """ + localpart, _, _ = user_id[1:].partition(":") + return is_compliant_user_id_localpart(localpart) + + UPPER_CASE_PATTERN = re.compile(b"[A-Z_]") # the following is a pattern which matches '=', and bytes which are not allowed in a mxid diff --git a/tests/test_types.py b/tests/test_types.py index 43fd96d6f5..96af7ea0db 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -37,6 +37,7 @@ from synapse.types import ( UserID, get_domain_from_id, get_localpart_from_id, + is_compliant_user_id, map_username_to_mxid_localpart, ) @@ -142,6 +143,21 @@ class MapUsernameTestCase(unittest.TestCase): self.assertEqual(map_username_to_mxid_localpart("têst".encode()), "t=c3=aast") +class IsCompliantUserIdTestCase(unittest.TestCase): + def test_compliant(self) -> None: + self.assertTrue(is_compliant_user_id("@test_1234:test")) + self.assertTrue(is_compliant_user_id("@TEST!~:test")) + + def test_non_compliant(self) -> None: + self.assertFalse(is_compliant_user_id("@têst:test")) + self.assertFalse(is_compliant_user_id("@te st:test")) + self.assertFalse(is_compliant_user_id("@:test")) + + def test_malformed_does_not_raise(self) -> None: + self.assertFalse(is_compliant_user_id("")) + self.assertFalse(is_compliant_user_id("@")) + + @parameterized_class( ("token_type",), [