Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Convert stringy power levels to integers on room upgrade #12657

Merged
merged 5 commits into from
May 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/12657.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix a long-standing bug where rooms containing power levels with string values could not be upgraded.
61 changes: 45 additions & 16 deletions synapse/events/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
Iterable,
List,
Mapping,
MutableMapping,
Optional,
Union,
)
Expand Down Expand Up @@ -580,10 +581,20 @@ def serialize_events(
]


def copy_power_levels_contents(
old_power_levels: Mapping[str, Union[int, Mapping[str, int]]]
_PowerLevel = Union[str, int]


def copy_and_fixup_power_levels_contents(
old_power_levels: Mapping[str, Union[_PowerLevel, Mapping[str, _PowerLevel]]]
) -> Dict[str, Union[int, Dict[str, int]]]:
"""Copy the content of a power_levels event, unfreezing frozendicts along the way
"""Copy the content of a power_levels event, unfreezing frozendicts along the way.

We accept as input power level values which are strings, provided they represent an
integer, e.g. `"`100"` instead of 100. Such strings are converted to integers
in the returned dictionary (hence "fixup" in the function name).

Note that future room versions will outlaw such stringy power levels (see
https://github.com/matrix-org/matrix-spec/issues/853).

Raises:
TypeError if the input does not look like a valid power levels event content
Expand All @@ -592,29 +603,47 @@ def copy_power_levels_contents(
raise TypeError("Not a valid power-levels content: %r" % (old_power_levels,))

power_levels: Dict[str, Union[int, Dict[str, int]]] = {}
for k, v in old_power_levels.items():

if isinstance(v, int):
power_levels[k] = v
continue

for k, v in old_power_levels.items():
if isinstance(v, collections.abc.Mapping):
h: Dict[str, int] = {}
power_levels[k] = h
for k1, v1 in v.items():
# we should only have one level of nesting
if not isinstance(v1, int):
raise TypeError(
"Invalid power_levels value for %s.%s: %r" % (k, k1, v1)
)
h[k1] = v1
continue
_copy_power_level_value_as_integer(v1, h, k1)

raise TypeError("Invalid power_levels value for %s: %r" % (k, v))
else:
_copy_power_level_value_as_integer(v, power_levels, k)

return power_levels


def _copy_power_level_value_as_integer(
old_value: object,
power_levels: MutableMapping[str, Any],
key: str,
) -> None:
"""Set `power_levels[key]` to the integer represented by `old_value`.

:raises TypeError: if `old_value` is not an integer, nor a base-10 string
representation of an integer.
"""
if isinstance(old_value, int):
power_levels[key] = old_value
return

if isinstance(old_value, str):
try:
parsed_value = int(old_value, base=10)
except ValueError:
# Fall through to the final TypeError.
pass
else:
power_levels[key] = parsed_value
return

raise TypeError(f"Invalid power_levels value for {key}: {old_value}")


def validate_canonicaljson(value: Any) -> None:
"""
Ensure that the JSON object is valid according to the rules of canonical JSON.
Expand Down
14 changes: 8 additions & 6 deletions synapse/handlers/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
from synapse.api.room_versions import KNOWN_ROOM_VERSIONS, RoomVersion
from synapse.event_auth import validate_event_for_room_version
from synapse.events import EventBase
from synapse.events.utils import copy_power_levels_contents
from synapse.events.utils import copy_and_fixup_power_levels_contents
from synapse.federation.federation_client import InvalidResponseError
from synapse.handlers.federation import get_domains_from_state
from synapse.handlers.relations import BundledAggregations
Expand Down Expand Up @@ -337,13 +337,13 @@ async def _update_upgraded_room_pls(
# 50, but if the default PL in a room is 50 or more, then we set the
# required PL above that.

pl_content = dict(old_room_pl_state.content)
users_default = int(pl_content.get("users_default", 0))
pl_content = copy_and_fixup_power_levels_contents(old_room_pl_state.content)
users_default: int = pl_content.get("users_default", 0) # type: ignore[assignment]
restricted_level = max(users_default + 1, 50)

updated = False
for v in ("invite", "events_default"):
current = int(pl_content.get(v, 0))
current: int = pl_content.get(v, 0) # type: ignore[assignment]
if current < restricted_level:
logger.debug(
"Setting level for %s in %s to %i (was %i)",
Expand Down Expand Up @@ -380,7 +380,9 @@ async def _update_upgraded_room_pls(
"state_key": "",
"room_id": new_room_id,
"sender": requester.user.to_string(),
"content": old_room_pl_state.content,
"content": copy_and_fixup_power_levels_contents(
old_room_pl_state.content
),
},
ratelimit=False,
)
Expand Down Expand Up @@ -471,7 +473,7 @@ async def clone_existing_room(
# dict so we can't just copy.deepcopy it.
initial_state[
(EventTypes.PowerLevels, "")
] = power_levels = copy_power_levels_contents(
] = power_levels = copy_and_fixup_power_levels_contents(
initial_state[(EventTypes.PowerLevels, "")]
)

Expand Down
41 changes: 39 additions & 2 deletions tests/events/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from synapse.events import make_event_from_dict
from synapse.events.utils import (
SerializeEventConfig,
copy_power_levels_contents,
copy_and_fixup_power_levels_contents,
prune_event,
serialize_event,
)
Expand Down Expand Up @@ -529,7 +529,7 @@ def setUp(self) -> None:
}

def _test(self, input):
a = copy_power_levels_contents(input)
a = copy_and_fixup_power_levels_contents(input)

self.assertEqual(a["ban"], 50)
self.assertEqual(a["events"]["m.room.name"], 100)
Expand All @@ -547,3 +547,40 @@ def test_unfrozen(self):
def test_frozen(self):
input = freeze(self.test_content)
self._test(input)

def test_stringy_integers(self):
"""String representations of decimal integers are converted to integers."""
input = {
"a": "100",
"b": {
"foo": 99,
"bar": "-98",
},
"d": "0999",
}
output = copy_and_fixup_power_levels_contents(input)
expected_output = {
"a": 100,
"b": {
"foo": 99,
"bar": -98,
},
"d": 999,
}

self.assertEqual(output, expected_output)

def test_strings_that_dont_represent_decimal_integers(self) -> None:
"""Should raise when given inputs `s` for which `int(s, base=10)` raises."""
for invalid_string in ["0x123", "123.0", "123.45", "hello", "0b1", "0o777"]:
with self.assertRaises(TypeError):
copy_and_fixup_power_levels_contents({"a": invalid_string})

def test_invalid_types_raise_type_error(self) -> None:
with self.assertRaises(TypeError):
copy_and_fixup_power_levels_contents({"a": ["hello", "grandma"]}) # type: ignore[arg-type]
copy_and_fixup_power_levels_contents({"a": None}) # type: ignore[arg-type]

def test_invalid_nesting_raises_type_error(self) -> None:
with self.assertRaises(TypeError):
copy_and_fixup_power_levels_contents({"a": {"b": {"c": 1}}})
44 changes: 44 additions & 0 deletions tests/rest/client/test_upgrade_room.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Optional
from unittest.mock import patch

from twisted.test.proto_helpers import MemoryReactor

Expand Down Expand Up @@ -167,6 +168,49 @@ def test_power_levels_tombstone(self) -> None:
)
self.assertNotIn(self.other, power_levels["users"])

def test_stringy_power_levels(self) -> None:
"""The room upgrade converts stringy power levels to proper integers."""
# Retrieve the room's current power levels.
power_levels = self.helper.get_state(
self.room_id,
"m.room.power_levels",
tok=self.creator_token,
)

# Set creator's power level to the string "100" instead of the integer `100`.
power_levels["users"][self.creator] = "100"

# Synapse refuses to accept new stringy power level events. Bypass this by
# neutering the validation.
with patch("synapse.events.validator.jsonschema.validate"):
# Note: https://github.com/matrix-org/matrix-spec/issues/853 plans to forbid
# string power levels in new rooms. For this test to have a clean
# conscience, we ought to ensure it's upgrading from a sufficiently old
# version of room.
self.helper.send_state(
self.room_id,
"m.room.power_levels",
body=power_levels,
tok=self.creator_token,
)

# Upgrade the room. Check the homeserver reports success.
channel = self._upgrade_room()
self.assertEqual(200, channel.code, channel.result)

# Extract the new room ID.
new_room_id = channel.json_body["replacement_room"]

# Fetch the new room's power level event.
new_power_levels = self.helper.get_state(
new_room_id,
"m.room.power_levels",
tok=self.creator_token,
)

# We should now have an integer power level.
self.assertEqual(new_power_levels["users"][self.creator], 100, new_power_levels)

def test_space(self) -> None:
"""Test upgrading a space."""

Expand Down