Merge branch 'develop' of github.com:element-hq/synapse into anoa/msc4429

This commit is contained in:
Andrew Morgan committed 2026-05-26 17:02:59 +01:00
commit 8accba6c4a
142 files changed
+4743 -1669

No files matched your search

+58
View File
@@ -1,3 +1,61 @@
# Synapse 1.153.0 (2026-05-19)
No significant changes since 1.153.0rc3.
# Synapse 1.153.0rc3 (2026-05-15)
## Bugfixes
- Revert 'Have [MSC4186: Simplified Sliding Sync](https://github.com/matrix-org/matrix-spec-proposals/pull/4186) return a new response immediately if a room subscription has changed and produced a new response. ([\#19714](https://github.com/element-hq/synapse/issues/19714))' (introduced in 1.153.0rc1) due to performance problems. ([\#19784](https://github.com/element-hq/synapse/issues/19784))
# Synapse 1.153.0rc2 (2026-05-13)
## Bugfixes
- Correctly handle arbitrary precision integers in `unsigned` field of events. The bug was introduced in 1.153.0rc1. ([\#19769](https://github.com/element-hq/synapse/issues/19769))
# Synapse 1.153.0rc1 (2026-05-08)
## Features
- Make ACLs apply to EDUs per [MSC4163](https://github.com/matrix-org/matrix-spec-proposals/pull/4163). ([\#18475](https://github.com/element-hq/synapse/issues/18475))
- Stabilize [MSC3266: Room summary API](https://github.com/matrix-org/matrix-spec-proposals/pull/3266), removing the experimental config flag `msc3266_enabled`. Contributed by @dasha-uwu. ([\#19720](https://github.com/element-hq/synapse/issues/19720))
- Partial [MSC4311](https://github.com/matrix-org/matrix-spec-proposals/pull/4311) implementation: `m.room.create` is now a required part of stripped `invite_state`/`knock_state` . Contributed by @FrenchGithubUser @Famedly. ([\#19722](https://github.com/element-hq/synapse/issues/19722))
- Expose `tombstoned` and `replacement_room` in room details on admin API endpoint `GET /_synapse/admin/v1/rooms/<room_id>`. Contributed by Noah Markert. ([\#19737](https://github.com/element-hq/synapse/issues/19737))
## Bugfixes
- Allow self-requested user erasure (upon account deactivation) to succeed even if Synapse has disabled profile changes. Contributed by Famedly. ([\#19398](https://github.com/element-hq/synapse/issues/19398))
- Fix Synapse not backfilling new history when attempting to use a pagination token near a backward extremity. ([\#19611](https://github.com/element-hq/synapse/issues/19611))
- Have [MSC4186: Simplified Sliding Sync](https://github.com/matrix-org/matrix-spec-proposals/pull/4186) return a new response immediately if a room subscription has changed and produced a new response. ([\#19714](https://github.com/element-hq/synapse/issues/19714))
- Fix a bug where when upgrading a room to room version 12, the power level event in the old room got temporarily mutated to remove the user upgrading the room's power. ([\#19727](https://github.com/element-hq/synapse/issues/19727))
- Fix packaging for Fedora and EPEL caused by unnecessary bumping `authlib` minimum version requirement in `pyproject.toml` file. Contributed by Oleg Girko. ([\#19742](https://github.com/element-hq/synapse/issues/19742))
## Improved Documentation
- Add warning about known problems when configuring `use_frozen_dicts`. ([\#19711](https://github.com/element-hq/synapse/issues/19711))
## Internal Changes
- Port `Event.signatures` field to Rust. ([\#19706](https://github.com/element-hq/synapse/issues/19706))
- Port `Event.unsigned` field to Rust. ([\#19708](https://github.com/element-hq/synapse/issues/19708))
- Add a Rust canonical JSON serializer. ([\#19739](https://github.com/element-hq/synapse/issues/19739), [\#19763](https://github.com/element-hq/synapse/issues/19763))
- Configure Dependabot to only update Python dependencies in the lockfile, unless widening upper bounds. ([\#19743](https://github.com/element-hq/synapse/issues/19743))
- Reduce `WORKER_LOCK_MAX_RETRY_INTERVAL` to 5 seconds to reduce idle time after lock is released. ([\#19755](https://github.com/element-hq/synapse/issues/19755))
- Force keyword-only arguments for `Duration` so time units have to be specified. ([\#19756](https://github.com/element-hq/synapse/issues/19756))
# Synapse 1.152.1 (2026-05-07)
## Security Fixes
- Prevent CPU starvation (Denial of Service) under worker lock contention, additionally capping the `WorkerLock` time out interval to a maximum of 60 seconds. Contributed by Famedly. ([\#19394](https://github.com/element-hq/synapse/issues/19394), ELEMENTSEC-2026-1706, [GHSA-8q93-326v-3m7g](https://github.com/element-hq/synapse/security/advisories/GHSA-8q93-326v-3m7g), CVE-2026-45078)
- Prevent pagination ending when a page is full of rejected events. (ELEMENTSEC-2025-1636, [GHSA-6qf2-7x63-mm6v](https://github.com/element-hq/synapse/security/advisories/GHSA-6qf2-7x63-mm6v), CVE-2026-45076)
# Synapse 1.152.0 (2026-04-28)
No significant changes since 1.152.0rc1.
Generated
+15 -44
View File
@@ -29,12 +29,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.22.1"
@@ -646,12 +640,6 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "indoc"
version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -735,15 +723,6 @@ version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -821,37 +800,34 @@ dependencies = [
[[package]]
name = "pyo3"
version = "0.27.2"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d"
checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
dependencies = [
"anyhow",
"bytes",
"indoc",
"libc",
"memoffset",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
"unindent",
]
[[package]]
name = "pyo3-build-config"
version = "0.27.2"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6"
checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
dependencies = [
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.27.2"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089"
checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
dependencies = [
"libc",
"pyo3-build-config",
@@ -870,9 +846,9 @@ dependencies = [
[[package]]
name = "pyo3-macros"
version = "0.27.2"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02"
checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
@@ -882,9 +858,9 @@ dependencies = [
[[package]]
name = "pyo3-macros-backend"
version = "0.27.2"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9"
checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
dependencies = [
"heck",
"proc-macro2",
@@ -895,12 +871,13 @@ dependencies = [
[[package]]
name = "pythonize"
version = "0.27.0"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3a8f29db331e28c332c63496cfcbb822aca3d7320bc08b655d7fd0c29c50ede"
checksum = "0b79f670c9626c8b651c0581011b57b6ba6970bb69faf01a7c4c0cfc81c43f95"
dependencies = [
"pyo3",
"serde",
"serde_json",
]
[[package]]
@@ -1391,9 +1368,9 @@ dependencies = [
[[package]]
name = "target-lexicon"
version = "0.13.2"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "thiserror"
@@ -1569,12 +1546,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unindent"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
[[package]]
name = "untrusted"
version = "0.9.0"
-1
View File
@@ -1 +0,0 @@
Make ACLs apply to EDUs per [MSC4163](https://github.com/matrix-org/matrix-spec-proposals/pull/4163).
-1
View File
@@ -1 +0,0 @@
Allow user requested erasure to succeed even if Synapse has disabled profile changes. Contributed by Famedly.
+1
View File
@@ -0,0 +1 @@
Fix a bug in [MSC4186: Simplified Sliding Sync](https://github.com/matrix-org/matrix-spec-proposals/pull/4186) that could prevent user avatars from showing if the room had an empty name.
+1
View File
@@ -0,0 +1 @@
Fix access token cache not being invalidated for sessions using refresh tokens. Contributed by @FrenchGithubUser @ Famedly.
+1
View File
@@ -0,0 +1 @@
Added details about how Synapse syncs the picture claim when `update_profile_information` setting is true.
+1
View File
@@ -0,0 +1 @@
Fix bug where Synapse would return 400 (`M_BAD_JSON`) when sending a message with `mentions` field and Synapse module `check_event_allowed` callback registered (frozen event). Contributed by @gaetan-sbt.
+1
View File
@@ -0,0 +1 @@
Fix long-standing but niche bug with sync where it could attempt to fetch data with flawed invalid future tokens.
-1
View File
@@ -1 +0,0 @@
Add warning about known problems when configuring `use_frozen_dicts`.
-1
View File
@@ -1 +0,0 @@
Have SSS return a new response immediately if a room subscription have changed and produced a new response.
+3
View File
@@ -0,0 +1,3 @@
Add support for "MSC4452 Preview URL capabilities API" which exposes a `io.element.msc4452.preview_url` capability.
If `experimental_features.msc4452_enabled` is `true`, the `/_matrix/(client/v1/media|media/v3)/preview_url` endpoint
now responds with a 403 status code when the capability is disabled.
-1
View File
@@ -1 +0,0 @@
Stabilize MSC3266, removing the experimental config flag `msc3266_enabled`. Add support for stable room summary endpoints. Contributed by @dasha-uwu.
-1
View File
@@ -1 +0,0 @@
Partial [MSC4311](https://github.com/matrix-org/matrix-spec-proposals/pull/4311) implementation: `m.room.create` is now a required part of stripped `invite_state`/`knock_state` . Contributed by @FrenchGithubUser @Famedly.
+1
View File
@@ -0,0 +1 @@
Port `Event.content` field to Rust.
-1
View File
@@ -1 +0,0 @@
Fix a bug where when upgrading a room to v12 the power level event in the old room got mutated to remove the user upgrading the room's power.
-1
View File
@@ -1 +0,0 @@
Exposes `tombstoned` and `replacement_room` in room details on admin API endpoint `GET /_synapse/admin/v1/rooms/<room_id>`. Contributed by Noah Markert.
-1
View File
@@ -1 +0,0 @@
Add a Rust canonical JSON serializer.
-1
View File
@@ -1 +0,0 @@
Fix packaging for Fedora and EPEL caused by unnecessary bumping `authlib` minimum version requirement in `pyproject.toml` file. Contributed by Oleg Girko.
-1
View File
@@ -1 +0,0 @@
Configure Dependabot to only update Python dependencies in the lockfile, unless widening upper bounds.
+1
View File
@@ -0,0 +1 @@
Prefer close backfill points (absolute distance).
+1
View File
@@ -0,0 +1 @@
Replace unique `quarantined_media` waiting patterns with standard `wait_for_stream_token(...)`.
+1
View File
@@ -0,0 +1 @@
Improve Synapse logging around when someone encounters `We can't get valid state history.` so you can correlate everything by `event_id`.
+1
View File
@@ -0,0 +1 @@
Tidy up Rust `RoomVersion` structs.
+1
View File
@@ -0,0 +1 @@
Update `WorkerLock` tests to better stress the `WORKER_LOCK_MAX_RETRY_INTERVAL`.
+1
View File
@@ -0,0 +1 @@
Refactor MSC4242 state DAG checks behind a single `TypeIs` helper to avoid scattered `isinstance` casts.
+1
View File
@@ -0,0 +1 @@
Use `StrCollection` for `prev_state_events`.
+1
View File
@@ -0,0 +1 @@
Fix up event-construction in tests ahead of the Rust event port.
+1
View File
@@ -0,0 +1 @@
Revert 'Have [MSC4186: Simplified Sliding Sync](https://github.com/matrix-org/matrix-spec-proposals/pull/4186) return a new response immediately if a room subscription has changed and produced a new response. ([\#19714](https://github.com/element-hq/synapse/issues/19714))' due to performance problems.
+1
View File
@@ -0,0 +1 @@
Fix `/sync` failing when [MSC4354 Sticky Events](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) are enabled and the sync request filters out Ephemeral Data Units (EDUs).
+1
View File
@@ -0,0 +1 @@
Fix packaging for Fedora and EPEL caused by unnecessary bumping `attrs` minimum version requirement in `pyproject.toml` file. Contributed by Oleg Girko.
+1
View File
@@ -0,0 +1 @@
Fix a bug in [MSC4186: Simplified Sliding Sync](https://github.com/matrix-org/matrix-spec-proposals/pull/4186) that could prevent user avatars from showing if the room had an empty name.
+394
View File
@@ -0,0 +1,394 @@
// This file is licensed under the Affero General Public License (AGPL) version 3.
//
// Copyright (C) 2026 Element Creations Ltd
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// See the GNU Affero General Public License for more details:
// <https://www.gnu.org/licenses/agpl-3.0.html>.
package synapse_tests
import (
"encoding/json"
"fmt"
"net/url"
"slices"
"strings"
"testing"
"github.com/matrix-org/complement"
"github.com/matrix-org/complement/b"
"github.com/matrix-org/complement/client"
"github.com/matrix-org/complement/helpers"
"github.com/matrix-org/gomatrixserverlib/spec"
"github.com/tidwall/gjson"
)
func TestMessagesOverFederation(t *testing.T) {
deployment := complement.Deploy(t, 2)
defer deployment.Destroy(t)
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{
LocalpartSuffix: "alice",
})
bob := deployment.Register(t, "hs2", helpers.RegistrationOpts{
LocalpartSuffix: "bob",
})
// The typical convention to find backfill points is from the backward extremities in
// the DAG. Backward extremities are the oldest events we know of in the room but we
// only know of them because some other event referenced them by prev_event and aren't
// known to the homeserver yet (meaning we don't know their depth specifically). So we
// can only do approximate depth comparisons (use the depth of the known events
// they're connected to). And we don't know if those backward extremities point to a
// long chain/fork of history that could stretch back far enough to be visible.
//
// This means a naive homeserver implementation that looks for backward extremities <=
// depth of the `/messages?dir=b&from=xxx` token may overlook a backfill point that could
// reveal more history in the window the user is currently paginating in.
//
// This could be a near miss as this test is specifically stressing or a more deep miss
// as the backward extremity could reveal an entire fork of history that stretches
// back far enough to be visible.
//
// In Synapse, we consider "nearby" as anything within range of the `limit` specified
// in `/messages?dir=b&from=xxx&limit=xxx`.
//
// This test lives in our in-repo Complement tests for Synapse because the Matrix spec
// doesn't have any rules for how a homeserver should backfill. Practically speaking,
// homeservers that don't do anything for this problem will just hide messages from
// clients. This underscores the fact why it's necessary for homeservers to indicate that
// there is a gap (using MSC3871) at the very least.
//
// --------------------------------------------------
//
// Even with MSC3871 gaps, the tested behavior here is necessary as the gap prev/next
// tokens point before/after the event (remember: tokens are positions between
// events), so if you use `/messages?dir=b&from=<gap prev_pagination_token>`, we can't
// rely on naive depth comparison. MSC3871 Complement tests will also exercise this.
// Example:
//
// t0 t1 t2 t3 t4
// [A] <--- [B] <--- [C] <--- [bob join 4]
//
// When Bob calls `/messages?dir=b&backfill=false`, he sees a gap (`{ event_id: "bob
// join 4", prev_pagination_token: "t3", next_pagination_token: "t4" }`) and tries to
// fill it in with `/messages?dir=b&from=t3&limit=10&backfill=true`. To find backfill
// points, Synapse will compare `t3` with the backward extremity at an approximate
// depth of 4. Which is why we take `t3`, add the `limit=10` and then do the
// comparison (find any backfill points with an approximate depth <= 13).
t.Run("Backfill from nearby backward extremities past token", func(t *testing.T) {
// Alice creates the room
roomID := alice.MustCreateRoom(t, map[string]interface{}{
// The `public_chat` preset includes `history_visibility: "shared"` ("Previous
// events are always accessible to newly joined members. All events in the
// room are accessible, even those sent when the member was not a part of the
// room."), which is what we want to test.
"preset": "public_chat",
})
// Keep track of the order
eventIDs := make([]string, 0)
// Map from event_id to event info
eventMap := make(map[string]EventInfo)
// Send some message history into the room
numberOfMessagesToSend := 3
messageDrafts := make([]MessageDraft, 0, numberOfMessagesToSend)
for i := 0; i < numberOfMessagesToSend; i++ {
messageDrafts = append(
messageDrafts,
MessageDraft{alice, fmt.Sprintf("message history %d", i+1)},
)
}
sendAndTrackMessages(t, roomID, messageDrafts, &eventIDs, &eventMap)
// Bob joins the room
bob.MustJoinRoom(t, roomID, []spec.ServerName{
deployment.GetFullyQualifiedHomeserverName(t, "hs1"),
})
bobJoinEventID := getStateID(t, bob, roomID, "m.room.member", bob.UserID)
// Make it easy to cross-reference the events being talked about in the logs
for eventIndex, eventID := range eventIDs {
t.Logf("Message %d -> event_id=%s", eventIndex, eventID)
}
// Use a `/context` request to get a pagination token just before Bob's join event
// (remember: tokens are positions between events)
//
// Usually a client would just use `/messages?dir=b` to start getting history
// after joining but this is valid as well. To illustrate a more real example of
// this, someone can use `/timestamp_to_event` to jump back in history and
// `/context` to start paginating history.
contextRes := bob.MustDo(
t,
"GET",
[]string{"_matrix", "client", "v3", "rooms", roomID, "context", bobJoinEventID},
client.WithContentType("application/json"),
client.WithQueries(url.Values{
"limit": []string{"0"},
}),
)
contextResResBody := client.ParseJSON(t, contextRes)
// > `start`: A token that can be used to paginate backwards with.
// > - https://spec.matrix.org/v1.17/client-server-api/#get_matrixclientv3roomsroomidcontexteventid
paginationToken := client.GetJSONFieldStr(t, contextResResBody, "start")
// Paginate backwards from the join event
messagesRes := bob.MustDo(
t,
"GET",
[]string{"_matrix", "client", "v3", "rooms", roomID, "messages"},
client.WithContentType("application/json"),
client.WithQueries(url.Values{
"dir": []string{"b"},
"limit": []string{"100"},
"from": []string{paginationToken},
}),
)
messagesResBody := client.ParseJSON(t, messagesRes)
// Since `dir=b`, these will be in reverse chronological order
actualEventIDsFromRequest := extractEventIDsFromMessagesResponse(t, messagesResBody)
// Put them in chronological order to match the expected list
chronologicalActualEventIds := slices.Clone(actualEventIDsFromRequest)
slices.Reverse(chronologicalActualEventIds)
// Assert timeline order
assertEventsInOrder(t, chronologicalActualEventIds, eventIDs)
})
// TODO: Backfill test to make sure we backfill from forks when viewing history (see
// docstring above).
//
// 1. Alice (hs1, engineered homeserver) creates a room with events A, B
// 1. Bob (hs2) joins the room
// 1. Bob leaves the room
// 1. Alice creates a fork from A with some history (1, 2, 3) and connects it back with a new event C
// 1. Bob joins back
// 1. Bob paginates `/messages?dir=b&from=<token-after-b>`
// 1. Ensure Bob sees events: B, 2, 1, A
//
// 1 <--- 2 <----- 3
// / \
// A <------- B ▲ <--- C <-- D
// |
// Paginate backwards from this point
// t.Run("Backfill from nearby backward extremities past token (fork)", func(t *testing.T) {
}
// These utilities match what we're using in the Complement repo (see
// `matrix-org/complement` -> `tests/csapi/room_messages_test.go`)
type MessageDraft struct {
Sender *client.CSAPI
Message string
}
type EventInfo struct {
MessageDraft MessageDraft
EventID string
}
func sendMessageDrafts(
t *testing.T,
roomID string,
messageDrafts []MessageDraft,
) []string {
t.Helper()
eventIDs := make([]string, len(messageDrafts))
for messageDraftIndex, messageDraft := range messageDrafts {
eventID := messageDraft.Sender.SendEventSynced(t, roomID, b.Event{
Type: "m.room.message",
Content: map[string]interface{}{
"msgtype": "m.text",
"body": messageDraft.Message,
},
})
eventIDs[messageDraftIndex] = eventID
}
return eventIDs
}
// sendAndTrackMessages sends the given message drafts to the room, keeping track of the
// new events in the list of `eventIDs` and `eventMap`. Returns the list of new event
// IDs that were sent.
func sendAndTrackMessages(
t *testing.T,
roomID string,
messageDrafts []MessageDraft,
eventIDs *[]string,
eventMap *map[string]EventInfo,
) []string {
t.Helper()
newEventIDs := sendMessageDrafts(t, roomID, messageDrafts)
*eventIDs = append(*eventIDs, newEventIDs...)
for i, eventID := range newEventIDs {
(*eventMap)[eventID] = EventInfo{
MessageDraft: messageDrafts[i],
EventID: eventID,
}
}
return newEventIDs
}
// extractEventIDsFromMessagesResponse extracts the event IDs from the given
// `/messages` response body.
func extractEventIDsFromMessagesResponse(
t *testing.T,
messagesResBody json.RawMessage,
) []string {
t.Helper()
wantKey := "chunk"
keyRes := gjson.GetBytes(messagesResBody, wantKey)
if !keyRes.Exists() {
t.Fatalf("extractEventIDsFromMessagesResponse: missing key '%s'", wantKey)
}
if !keyRes.IsArray() {
t.Fatalf(
"extractEventIDsFromMessagesResponse: key '%s' is not an array (was %s)",
wantKey,
keyRes.Type,
)
}
var eventIDs []string
actualEvents := keyRes.Array()
for _, event := range actualEvents {
eventIDs = append(eventIDs, event.Get("event_id").Str)
}
return eventIDs
}
func filterEventIDs(t *testing.T, actualEventIDs []string, expectedEventIDs []string) []string {
t.Helper()
relevantActualEventIDs := make([]string, 0, len(expectedEventIDs))
for _, eventID := range actualEventIDs {
if slices.Contains(expectedEventIDs, eventID) {
relevantActualEventIDs = append(relevantActualEventIDs, eventID)
}
}
return relevantActualEventIDs
}
// assertEventsInOrder asserts all `actualEventIDs` are present and in order according
// to `expectedEventIDs`. Other unrelated events can be in between.
func assertEventsInOrder(t *testing.T, actualEventIDs []string, expectedEventIDs []string) {
t.Helper()
relevantActualEventIDs := filterEventIDs(t, actualEventIDs, expectedEventIDs)
if len(relevantActualEventIDs) != len(expectedEventIDs) {
t.Fatalf(
"expected %d events in timeline (got %d relevant events filtered down from %d events)\n%s",
len(expectedEventIDs),
len(relevantActualEventIDs),
len(actualEventIDs),
generateEventOrderDiffString(relevantActualEventIDs, expectedEventIDs),
)
}
for i, eventID := range relevantActualEventIDs {
if eventID != expectedEventIDs[i] {
t.Fatalf(
"expected event ID %s (got %s) at index %d\n%s",
expectedEventIDs[i],
eventID,
i,
generateEventOrderDiffString(relevantActualEventIDs, expectedEventIDs),
)
}
}
}
func generateEventOrderDiffString(actualEventIDs []string, expectedEventIDs []string) string {
expectedLines := make([]string, len(expectedEventIDs))
for i, expectedEventID := range expectedEventIDs {
isExpectedInActual := slices.Contains(actualEventIDs, expectedEventID)
isMissingIndicatorString := " "
if !isExpectedInActual {
isMissingIndicatorString = "?"
}
expectedLines[i] = fmt.Sprintf("%2d: %s %s", i, isMissingIndicatorString, expectedEventID)
}
expectedDiffString := strings.Join(expectedLines, "\n")
actualLines := make([]string, len(actualEventIDs))
for actualEventIndex, actualEventID := range actualEventIDs {
isActualInExpected := slices.Contains(expectedEventIDs, actualEventID)
isActualInExpectedIndicatorString := " "
if isActualInExpected {
isActualInExpectedIndicatorString = "+"
}
expectedIndex := slices.Index(expectedEventIDs, actualEventID)
expectedIndexString := ""
if actualEventIndex != expectedIndex {
expectedDirectionString := "⬆️"
if expectedIndex > actualEventIndex {
expectedDirectionString = "⬇️"
}
expectedIndexString = fmt.Sprintf(
" (expected index %d %s)",
expectedIndex,
expectedDirectionString,
)
}
actualLines[actualEventIndex] = fmt.Sprintf("%2d: %s %s%s",
actualEventIndex, isActualInExpectedIndicatorString, actualEventID, expectedIndexString,
)
}
actualDiffString := strings.Join(actualLines, "\n")
return fmt.Sprintf(
"Actual events ('+' = found expected items):\n%s\nExpected events ('?' = missing expected items):\n%s",
actualDiffString,
expectedDiffString,
)
}
func getStateID(
t *testing.T,
c *client.CSAPI,
roomID string,
stateType string,
stateKey string,
) string {
t.Helper()
stateRes := c.MustDo(t, "GET", []string{"_matrix", "client", "v3", "rooms", roomID, "state"})
stateResBody := client.ParseJSON(t, stateRes)
eventJSON := gjson.ParseBytes(stateResBody)
if !eventJSON.IsArray() {
t.Fatalf("expected array of state events but found %s", eventJSON.Type)
}
events := eventJSON.Array()
for _, event := range events {
if event.Get("type").Str == stateType && event.Get("state_key").Str == stateKey {
return event.Get("event_id").Str
}
}
t.Fatalf("Unable to find state event for (%s, %s). Room state: %s", stateType, stateKey, events)
return ""
}
+30
View File
@@ -1,3 +1,33 @@
matrix-synapse-py3 (1.153.0) stable; urgency=medium
* New Synapse release 1.153.0.
-- Synapse Packaging team <packages@matrix.org> Tue, 19 May 2026 14:12:51 +0100
matrix-synapse-py3 (1.153.0~rc3) stable; urgency=medium
* New Synapse release 1.153.0rc3.
-- Synapse Packaging team <packages@matrix.org> Fri, 15 May 2026 11:42:29 +0100
matrix-synapse-py3 (1.153.0~rc2) stable; urgency=medium
* New Synapse release 1.153.0rc2.
-- Synapse Packaging team <packages@matrix.org> Wed, 13 May 2026 12:00:39 +0100
matrix-synapse-py3 (1.153.0~rc1) stable; urgency=medium
* New Synapse release 1.153.0rc1.
-- Synapse Packaging team <packages@matrix.org> Fri, 08 May 2026 13:05:08 +0100
matrix-synapse-py3 (1.152.1) stable; urgency=medium
* New Synapse release 1.152.1.
-- Synapse Packaging team <packages@matrix.org> Thu, 07 May 2026 13:29:05 +0100
matrix-synapse-py3 (1.152.0) stable; urgency=medium
* New Synapse release 1.152.0.
@@ -3788,7 +3788,10 @@ This setting has the following sub-options:
Defaults to `null`.
* `update_profile_information` (boolean): Use this setting to keep a user's profile fields in sync with information from the identity provider. Currently only syncing the displayname is supported. Fields are checked on every SSO login, and are updated if necessary. Note that enabling this option will override user profile information, regardless of whether users have opted-out of syncing that information when first signing in. Defaults to `false`.
* `update_profile_information` (boolean): Use this setting to keep a user's profile fields in sync with information from the identity provider. Fields are checked on every SSO login, and are updated if necessary. Note that enabling this option will override user profile information, regardless of whether users have opted-out of syncing that information when first signing in. Fields that will be synced:
* displayname
* picture - only if Synapse media repository is running in the main
process (i.e. not workerized) and media is stored locally Defaults to `false`.
Example configuration:
```yaml
Generated
+150 -156
View File
@@ -26,15 +26,15 @@ files = [
[[package]]
name = "authlib"
version = "1.6.11"
version = "1.6.12"
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"all\" or extra == \"jwt\" or extra == \"oidc\""
files = [
{file = "authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3"},
{file = "authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f"},
{file = "authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab"},
{file = "authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd"},
]
[package.dependencies]
@@ -582,14 +582,14 @@ smmap = ">=3.0.1,<6"
[[package]]
name = "gitpython"
version = "3.1.47"
version = "3.1.50"
description = "GitPython is a Python library used to interact with Git repositories"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905"},
{file = "gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd"},
{file = "gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9"},
{file = "gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc"},
]
[package.dependencies]
@@ -1234,153 +1234,147 @@ files = [
[[package]]
name = "lxml"
version = "6.0.2"
version = "6.1.0"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"all\" or extra == \"url-preview\""
files = [
{file = "lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388"},
{file = "lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153"},
{file = "lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31"},
{file = "lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9"},
{file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8"},
{file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba"},
{file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c"},
{file = "lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c"},
{file = "lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321"},
{file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1"},
{file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34"},
{file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a"},
{file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c"},
{file = "lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b"},
{file = "lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0"},
{file = "lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5"},
{file = "lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607"},
{file = "lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938"},
{file = "lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d"},
{file = "lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438"},
{file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964"},
{file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d"},
{file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7"},
{file = "lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178"},
{file = "lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553"},
{file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb"},
{file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a"},
{file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c"},
{file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7"},
{file = "lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46"},
{file = "lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078"},
{file = "lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285"},
{file = "lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456"},
{file = "lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924"},
{file = "lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f"},
{file = "lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534"},
{file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564"},
{file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f"},
{file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0"},
{file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192"},
{file = "lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0"},
{file = "lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092"},
{file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f"},
{file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8"},
{file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f"},
{file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6"},
{file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322"},
{file = "lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849"},
{file = "lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f"},
{file = "lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6"},
{file = "lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77"},
{file = "lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f"},
{file = "lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452"},
{file = "lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048"},
{file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df"},
{file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1"},
{file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916"},
{file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd"},
{file = "lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6"},
{file = "lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a"},
{file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679"},
{file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659"},
{file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484"},
{file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2"},
{file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314"},
{file = "lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2"},
{file = "lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7"},
{file = "lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf"},
{file = "lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe"},
{file = "lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d"},
{file = "lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d"},
{file = "lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5"},
{file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0"},
{file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba"},
{file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0"},
{file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d"},
{file = "lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37"},
{file = "lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9"},
{file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917"},
{file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f"},
{file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8"},
{file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a"},
{file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c"},
{file = "lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b"},
{file = "lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed"},
{file = "lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8"},
{file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d"},
{file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9"},
{file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e"},
{file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d"},
{file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec"},
{file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272"},
{file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f"},
{file = "lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312"},
{file = "lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca"},
{file = "lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c"},
{file = "lxml-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a656ca105115f6b766bba324f23a67914d9c728dafec57638e2b92a9dcd76c62"},
{file = "lxml-6.0.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c54d83a2188a10ebdba573f16bd97135d06c9ef60c3dc495315c7a28c80a263f"},
{file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:1ea99340b3c729beea786f78c38f60f4795622f36e305d9c9be402201efdc3b7"},
{file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af85529ae8d2a453feee4c780d9406a5e3b17cee0dd75c18bd31adcd584debc3"},
{file = "lxml-6.0.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fe659f6b5d10fb5a17f00a50eb903eb277a71ee35df4615db573c069bcf967ac"},
{file = "lxml-6.0.2-cp38-cp38-win32.whl", hash = "sha256:5921d924aa5468c939d95c9814fa9f9b5935a6ff4e679e26aaf2951f74043512"},
{file = "lxml-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:0aa7070978f893954008ab73bb9e3c24a7c56c054e00566a21b553dc18105fca"},
{file = "lxml-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2c8458c2cdd29589a8367c09c8f030f1d202be673f0ca224ec18590b3b9fb694"},
{file = "lxml-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3fee0851639d06276e6b387f1c190eb9d7f06f7f53514e966b26bae46481ec90"},
{file = "lxml-6.0.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b2142a376b40b6736dfc214fd2902409e9e3857eff554fed2d3c60f097e62a62"},
{file = "lxml-6.0.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6b5b39cc7e2998f968f05309e666103b53e2edd01df8dc51b90d734c0825444"},
{file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4aec24d6b72ee457ec665344a29acb2d35937d5192faebe429ea02633151aad"},
{file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:b42f4d86b451c2f9d06ffb4f8bbc776e04df3ba070b9fe2657804b1b40277c48"},
{file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cdaefac66e8b8f30e37a9b4768a391e1f8a16a7526d5bc77a7928408ef68e93"},
{file = "lxml-6.0.2-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:b738f7e648735714bbb82bdfd030203360cfeab7f6e8a34772b3c8c8b820568c"},
{file = "lxml-6.0.2-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daf42de090d59db025af61ce6bdb2521f0f102ea0e6ea310f13c17610a97da4c"},
{file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:66328dabea70b5ba7e53d94aa774b733cf66686535f3bc9250a7aab53a91caaf"},
{file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:e237b807d68a61fc3b1e845407e27e5eb8ef69bc93fe8505337c1acb4ee300b6"},
{file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:ac02dc29fd397608f8eb15ac1610ae2f2f0154b03f631e6d724d9e2ad4ee2c84"},
{file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:817ef43a0c0b4a77bd166dc9a09a555394105ff3374777ad41f453526e37f9cb"},
{file = "lxml-6.0.2-cp39-cp39-win32.whl", hash = "sha256:bc532422ff26b304cfb62b328826bd995c96154ffd2bac4544f37dbb95ecaa8f"},
{file = "lxml-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:995e783eb0374c120f528f807443ad5a83a656a8624c467ea73781fc5f8a8304"},
{file = "lxml-6.0.2-cp39-cp39-win_arm64.whl", hash = "sha256:08b9d5e803c2e4725ae9e8559ee880e5328ed61aa0935244e0515d7d9dbec0aa"},
{file = "lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6"},
{file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba"},
{file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5"},
{file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4"},
{file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d"},
{file = "lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d"},
{file = "lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700"},
{file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee"},
{file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f"},
{file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9"},
{file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a"},
{file = "lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e"},
{file = "lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62"},
{file = "lxml-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:41dcc4c7b10484257cbd6c37b83ddb26df2b0e5aff5ac00d095689015af868ec"},
{file = "lxml-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a31286dbb5e74c8e9a5344465b77ab4c5bd511a253b355b5ca2fae7e579fafec"},
{file = "lxml-6.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1bc4cc83fb7f66ffb16f74d6dd0162e144333fc36ebcce32246f80c8735b2551"},
{file = "lxml-6.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:20cf4d0651987c906a2f5cba4e3a8d6ba4bfdf973cfe2a96c0d6053888ea2ecd"},
{file = "lxml-6.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb34ea45a82dd637c2c97ae1bbb920850c1e59bcae79ce1c15af531d83e7215"},
{file = "lxml-6.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d9b99e5b2597e4f5aed2484fef835256fa1b68a19e4265c97628ef4bf8bcf4"},
{file = "lxml-6.1.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:d43aa26dcda363f21e79afa0668f5029ed7394b3bb8c92a6927a3d34e8b610ea"},
{file = "lxml-6.1.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:6262b87f9e5c1e5fe501d6c153247289af42eb44ad7660b9b3de17baaf92d6f6"},
{file = "lxml-6.1.0-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1392c569c032f78a11a25d1de1c43fff13294c793b39e19d84fade3045cbbc3"},
{file = "lxml-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:045e387d1f4f42a418380930fa3f45c73c9b392faf67e495e58902e68e8f44a7"},
{file = "lxml-6.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9f93d5b8b07f73e8c77e3c6556a3db269918390c804b5e5fcdd4858232cc8f16"},
{file = "lxml-6.1.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:de550d129f18d8ab819651ffe4f38b1b713c7e116707de3c0c6400d0ef34fbc1"},
{file = "lxml-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c08da09dc003c9e8c70e06b53a11db6fb3b250c21c4236b03c7d7b443c318e7a"},
{file = "lxml-6.1.0-cp310-cp310-win32.whl", hash = "sha256:37448bf9c7d7adfc5254763901e2bbd6bb876228dfc1fc7f66e58c06368a7544"},
{file = "lxml-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:2593a0a6621545b9095b71ad74ed4226eba438a7d9fc3712a99bdb15508cf93a"},
{file = "lxml-6.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80807d72f96b96ad5588cb85c75616e4f2795a7737d4630784c51497beb7776"},
{file = "lxml-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cec05be8c876f92a5aa07b01d60bbb4d11cfbdd654cad0561c0d7b5c043a61b9"},
{file = "lxml-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9c03e048b6ce8e77b09c734e931584894ecd58d08296804ca2d0b184c933ce50"},
{file = "lxml-6.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:942454ff253da14218f972b23dc72fa4edf6c943f37edd19cd697618b626fac5"},
{file = "lxml-6.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d036ee7b99d5148072ac7c9b847193decdfeac633db350363f7bce4fff108f0e"},
{file = "lxml-6.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ae5d8d5427f3cc317e7950f2da7ad276df0cfa37b8de2f5658959e618ea8512"},
{file = "lxml-6.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:363e47283bde87051b821826e71dde47f107e08614e1aa312ba0c5711e77738c"},
{file = "lxml-6.1.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:f504d861d9f2a8f94020130adac88d66de93841707a23a86244263d1e54682f5"},
{file = "lxml-6.1.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:23a5dc68e08ed13331d61815c08f260f46b4a60fdd1640bbeb82cf89a9d90289"},
{file = "lxml-6.1.0-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f15401d8d3dbf239e23c818afc10c7207f7b95f9a307e092122b6f86dd43209a"},
{file = "lxml-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fcf3da95e93349e0647d48d4b36a12783105bcc74cb0c416952f9988410846a3"},
{file = "lxml-6.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0d082495c5fcf426e425a6e28daaba1fcb6d8f854a4ff01effb1f1f381203eb9"},
{file = "lxml-6.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e3c4f84b24a1fcba435157d111c4b755099c6ff00a3daee1ad281817de75ed11"},
{file = "lxml-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976a6b39b1b13e8c354ad8d3f261f3a4ac6609518af91bdb5094760a08f132c4"},
{file = "lxml-6.1.0-cp311-cp311-win32.whl", hash = "sha256:857efde87d365706590847b916baff69c0bc9252dc5af030e378c9800c0b10e3"},
{file = "lxml-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:183bfb45a493081943be7ea2b5adfc2b611e1cf377cefa8b8a8be404f45ef9a7"},
{file = "lxml-6.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:19f4164243fc206d12ed3d866e80e74f5bc3627966520da1a5f97e42c32a3f39"},
{file = "lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d"},
{file = "lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93"},
{file = "lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d"},
{file = "lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a"},
{file = "lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105"},
{file = "lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485"},
{file = "lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814"},
{file = "lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32"},
{file = "lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad"},
{file = "lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54"},
{file = "lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d"},
{file = "lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69"},
{file = "lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d"},
{file = "lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5"},
{file = "lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d"},
{file = "lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f"},
{file = "lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366"},
{file = "lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819"},
{file = "lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45"},
{file = "lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d"},
{file = "lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2"},
{file = "lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491"},
{file = "lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc"},
{file = "lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e"},
{file = "lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2"},
{file = "lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9"},
{file = "lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe"},
{file = "lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88"},
{file = "lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181"},
{file = "lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24"},
{file = "lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e"},
{file = "lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495"},
{file = "lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33"},
{file = "lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62"},
{file = "lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16"},
{file = "lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d"},
{file = "lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8"},
{file = "lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9"},
{file = "lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03"},
{file = "lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb"},
{file = "lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c"},
{file = "lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28"},
{file = "lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086"},
{file = "lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f"},
{file = "lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292"},
{file = "lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb"},
{file = "lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad"},
{file = "lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb"},
{file = "lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f"},
{file = "lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43"},
{file = "lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585"},
{file = "lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f"},
{file = "lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120"},
{file = "lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946"},
{file = "lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c"},
{file = "lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d"},
{file = "lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9"},
{file = "lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9"},
{file = "lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7"},
{file = "lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86"},
{file = "lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb"},
{file = "lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c"},
{file = "lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f"},
{file = "lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773"},
{file = "lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b"},
{file = "lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405"},
{file = "lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690"},
{file = "lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd"},
{file = "lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180"},
{file = "lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2"},
{file = "lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5"},
{file = "lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac"},
{file = "lxml-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b6c2f225662bc5ad416bdd06f72ca301b31b39ce4261f0e0097017fc2891b940"},
{file = "lxml-6.1.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a86f06f059e22a0d574990ee2df24ede03f7f3c68c1336293eee9536c4c776cd"},
{file = "lxml-6.1.0-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:468479e52ecf3ec23799c863336d02c05fc2f7ffd1a1424eeeb9a28d4eb69d13"},
{file = "lxml-6.1.0-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:a02ca8fe48815bddcfca3248efe54451abb9dbf2f7d1c5744c8aa4142d476919"},
{file = "lxml-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bb40648d96157f9081886defe13eac99253e663be969ff938a9289eff6e47b72"},
{file = "lxml-6.1.0-cp38-cp38-win32.whl", hash = "sha256:1dd6a1c3ad4cb674f44525d9957f3e9c209bb6dd9213245195167a281fcc2bdc"},
{file = "lxml-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:4e2c54d6b47361d0f1d3bc8d4e082ad87201e56ccdcca4d3b9ee3644ff595ec8"},
{file = "lxml-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:920354904d1cb86577d4b3cfe2830c2dbe81d6f4449e57ada428f1609b5985f7"},
{file = "lxml-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c871299c595ee004d186f61840f0bfc4941aa3f17c8ba4a565ead7e4f4f820ee"},
{file = "lxml-6.1.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d0d799ff958655781296ec870d5e2448e75150da2b3d07f13ff5b0c2c35beefd"},
{file = "lxml-6.1.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ba11752e346bd804ea312ec2eea2532dfa8b8d3261d81a32ef9e6ab16256280"},
{file = "lxml-6.1.0-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26c5272c6a4bf4cf32d3f5a7890c942b0e04438691157d341616d02cca74d4bd"},
{file = "lxml-6.1.0-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c53fa3a5a52122d590e847a57ccf955557b9634a7f99ff5a35131321b0a85317"},
{file = "lxml-6.1.0-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:76b958b4ea3104483c20f74866d55aa056546e15ebe83dd7aecd63698f43b755"},
{file = "lxml-6.1.0-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:8c11b984b5ce6add4dccc7144c7be5d364d298f15b0c6a57da1991baedc750ce"},
{file = "lxml-6.1.0-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d3829a6e6fd550a219564912d4002c537f65da4c6ae4e093cc34462f4fa027ad"},
{file = "lxml-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:52b0ac6903cf74ebf997eb8c682d2fbac7d1ab7e4c552413eec55868a9b73f39"},
{file = "lxml-6.1.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:29f5c00cb7d752bce2c70ebd2d31b0a42f9499ffdd3ecb2f31a5b73ee43031ad"},
{file = "lxml-6.1.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:c748ebcb6877de89f48ab90ca96642ac458fff5dec291a2b9337cd4d0934e383"},
{file = "lxml-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:08950a23f296b3f83521577274e3d3b0f3d739bf2e68d01a752e4288bc50d286"},
{file = "lxml-6.1.0-cp39-cp39-win32.whl", hash = "sha256:11a873c77a181b4fef9c2e357d08ed399542c2af1390101da66720a19c7c9618"},
{file = "lxml-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:81ff55c70b67d19d52b6fd118a114c0a4c97d799cd3089ff9bd9e2ff4b414ee2"},
{file = "lxml-6.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:481d6e2104285d9add34f41b42b247b76b61c5b5c26c303c2e9707bbf8bd9a64"},
{file = "lxml-6.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:546b66c0dd1bb8d9fa89d7123e5fa19a8aff3a1f2141eb22df96112afb17b842"},
{file = "lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfa1a34df366d9dc0d5eaf420f4cf2bb1e1bebe1066d1c2fc28c179f8a4004c"},
{file = "lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db88156fcf544cdbf0d95588051515cfdfd4c876fc66444eb98bceb5d6db76de"},
{file = "lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07f98f5496f96bf724b1e3c933c107f0cbf2745db18c03d2e13a291c3afd2635"},
{file = "lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4642e04449a1e164b5ff71ffd901ddb772dfabf5c9adf1b7be5dffe1212bc037"},
{file = "lxml-6.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7da13bb6fbadfafb474e0226a30570a3445cfd47c86296f2446dafbd77079ace"},
{file = "lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13"},
]
[package.extras]
@@ -3417,14 +3411,14 @@ types-webencodings = "*"
[[package]]
name = "types-jsonschema"
version = "4.26.0.20260202"
version = "4.26.0.20260508"
description = "Typing stubs for jsonschema"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "types_jsonschema-4.26.0.20260202-py3-none-any.whl", hash = "sha256:41c95343abc4de9264e333a55e95dfb4d401e463856d0164eec9cb182e8746da"},
{file = "types_jsonschema-4.26.0.20260202.tar.gz", hash = "sha256:29831baa4308865a9aec547a61797a06fc152b0dac8dddd531e002f32265cb07"},
{file = "types_jsonschema-4.26.0.20260508-py3-none-any.whl", hash = "sha256:4ec1dea0a757c8c2e2aa7bc085612fb54e1ae9562428d5da6f26dd7a0f24dbc2"},
{file = "types_jsonschema-4.26.0.20260508.tar.gz", hash = "sha256:ae0be85ac6ec0cb94a98f75f876b0620cf2afa3e37fdf8460203f4d05f745acb"},
]
[package.dependencies]
@@ -3586,14 +3580,14 @@ files = [
[[package]]
name = "urllib3"
version = "2.6.3"
version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
python-versions = ">=3.10"
groups = ["main", "dev"]
files = [
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
]
[package.extras]
@@ -3755,4 +3749,4 @@ url-preview = ["lxml"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10.0,<4.0.0"
content-hash = "d97bee07fec0f4048d964aa7127a50813920bce77b00e5191aa1815f83922c85"
content-hash = "ef0540b89c417a69668f551688bd0974256ea7a580044f3954a76bdf0d8fe7c9"
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "matrix-synapse"
version = "1.152.0"
version = "1.153.0"
description = "Homeserver for the Matrix decentralised comms protocol"
readme = "README.rst"
authors = [
@@ -66,7 +66,7 @@ dependencies = [
"prometheus-client>=0.6.0",
# we use `order`, which arrived in attrs 19.2.0.
# Note: 21.1.0 broke `/sync`, see https://github.com/matrix-org/synapse/issues/9936
"attrs>=26.1.0,!=21.1.0",
"attrs>=19.2.0,!=21.1.0",
"netaddr>=0.7.18",
# Jinja 2.x is incompatible with MarkupSafe>=2.1. To ensure that admins do not
# end up with a broken installation, with recent MarkupSafe but old Jinja, we
+11 -9
View File
@@ -30,7 +30,7 @@ http = "1.1.0"
lazy_static = "1.4.0"
log = "0.4.17"
mime = "0.3.17"
pyo3 = { version = "0.27.2", features = [
pyo3 = { version = "0.28.3", features = [
"macros",
"anyhow",
"abi3",
@@ -39,12 +39,18 @@ pyo3 = { version = "0.27.2", features = [
# https://docs.rs/pyo3/latest/pyo3/bytes/index.html
"bytes",
] }
pyo3-log = "0.13.1"
pythonize = "0.27.0"
pyo3-log = "0.13.3"
pythonize = { version = "0.28.0", features = ["arbitrary_precision"] }
regex = "1.6.0"
sha2 = "0.10.8"
serde = { version = "1.0.144", features = ["derive"] }
serde_json = { version = "1.0.85", features = ["raw_value"] }
serde = { version = "1.0.144", features = ["derive", "rc"] }
serde_json = { version = "1.0.85", features = [
"raw_value",
# We need to be able to parse arbitrary precision numbers, as some numbers
# in the database may be out of range of i64 (as Python uses arbitrary
# precision integers).
"arbitrary_precision",
] }
ulid = "1.1.2"
icu_segmenter = "2.0.0"
reqwest = { version = "0.12.15", default-features = false, features = [
@@ -58,10 +64,6 @@ tokio = { version = "1.44.2", features = ["rt", "rt-multi-thread"] }
once_cell = "1.18.0"
itertools = "0.14.0"
[features]
extension-module = ["pyo3/extension-module"]
default = ["extension-module"]
[build-dependencies]
blake2 = "0.10.4"
hex = "0.4.3"
+1 -1
View File
@@ -47,7 +47,7 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()>
}
#[derive(Debug, Clone)]
#[pyclass(frozen)]
#[pyclass(frozen, skip_from_py_object)]
pub struct ServerAclEvaluator {
allow_ip_literals: bool,
allow: Vec<Regex>,
+119 -15
View File
@@ -29,7 +29,7 @@ use std::{
io::{self, Write},
};
use serde::ser::SerializeMap;
use serde::{ser::SerializeMap, Serializer as _};
use serde::{
ser::{Error as _, SerializeStruct},
Serialize,
@@ -37,7 +37,7 @@ use serde::{
use serde_json::{
ser::{Formatter, Serializer},
value::RawValue,
Value,
Number, Value,
};
/// The minimum integer that can be used in canonical JSON.
@@ -46,6 +46,12 @@ pub const MIN_VALID_INTEGER: i64 = -(2i64.pow(53)) + 1;
/// The maximum integer that can be used in canonical JSON.
pub const MAX_VALID_INTEGER: i64 = (2i64.pow(53)) - 1;
/// A token used by `serde_json` to identify its internal `Number` type when the
/// `arbitrary_precision` feature is enabled. This is a copy from serde_json's
/// internal `TOKEN` for `Number`, which unfortunately isn't exported by the
/// crate.
const SERDE_JSON_NUMBER_TOKEN: &str = "$serde_json::private::Number";
/// Options to control how strict JSON canonicalization is.
#[derive(Clone, Debug)]
pub struct CanonicalizationOptions {
@@ -236,7 +242,7 @@ where
type SerializeMap = CanonicalSerializeMap<'a, W>;
type SerializeStruct = CanonicalSerializeMap<'a, W>;
type SerializeStruct = CanonicalSerializeStruct<'a, W>;
type SerializeStructVariant =
<&'a mut Serializer<W, CanonicalFormatter> as serde::Serializer>::SerializeStructVariant;
@@ -426,7 +432,7 @@ where
fn serialize_struct(
self,
name: &'static str,
_len: usize,
len: usize,
) -> Result<Self::SerializeStruct, Self::Error> {
// We want to disallow `RawValue` as we don't know if its contents is
// canonical JSON.
@@ -436,10 +442,7 @@ where
if name == "$serde_json::private::RawValue" {
return Err(Self::Error::custom("`RawValue` is not supported"));
}
Ok(CanonicalSerializeMap::new(
&mut self.inner,
self.options.clone(),
))
CanonicalSerializeStruct::new(name, len, &mut self.inner, self.options.clone())
}
fn serialize_struct_variant(
@@ -554,7 +557,42 @@ where
}
}
impl<'a, W> SerializeStruct for CanonicalSerializeMap<'a, W>
/// A helper type for [`CanonicalSerializer`] that serializes structs in
/// lexicographic order.
#[doc(hidden)]
pub struct CanonicalSerializeStruct<'a, W: Write> {
name: &'static str,
// We buffer up the key and serialized value for each field we see.
// The BTreeMap will then serialize in lexicographic order.
map: BTreeMap<&'static str, Box<RawValue>>,
options: CanonicalizationOptions,
// The serializer to use to write the sorted map too.
struct_serializer:
<&'a mut Serializer<W, CanonicalFormatter> as serde::Serializer>::SerializeStruct,
}
impl<'a, W> CanonicalSerializeStruct<'a, W>
where
W: Write,
{
fn new(
name: &'static str,
len: usize,
ser: &'a mut Serializer<W, CanonicalFormatter>,
options: CanonicalizationOptions,
) -> Result<Self, serde_json::Error> {
let struct_serializer = ser.serialize_struct(name, len)?;
Ok(Self {
name,
map: BTreeMap::new(),
options,
struct_serializer,
})
}
}
impl<'a, W> SerializeStruct for CanonicalSerializeStruct<'a, W>
where
W: Write,
{
@@ -566,20 +604,69 @@ where
where
T: Serialize + ?Sized,
{
let key_string = key.to_string();
// Check if this is the special case of `SERDE_JSON_NUMBER_TOKEN`,
// which is used when serializing numbers with the `arbitrary_precision`
// feature. If so, we can just serialize it directly without
// canonicalizing it first, as `serde_json` will have already serialized
// it in a canonical way.
if key == SERDE_JSON_NUMBER_TOKEN && self.name == SERDE_JSON_NUMBER_TOKEN {
if self.options.enforce_int_range {
// We need to check that the number is in the valid range, as
// `serde_json` won't have done this for us as we're using the
// `arbitrary_precision` feature.
// The value here will be something that serializes to a JSON
// string containing the number, so we first serialize it to a
// Value and pull the string out, then parse it as a `Number`.
let serde_val = serde_json::to_value(value)?;
let serde_json::Value::String(number_str) = serde_val else {
return Err(serde_json::Error::custom("invalid number"));
};
let number: Number = number_str
.parse()
.map_err(|_| serde_json::Error::custom("invalid number"))?;
// Now check that the number is an integer in the valid range.
if let Some(int) = number.as_i64() {
assert_integer_in_range(int)?;
} else {
// Can't be cast to an i64, so it must be out of range.
return Err(serde_json::Error::custom("integer out of range"));
}
}
self.struct_serializer.serialize_field(key, value)?;
return Ok(());
}
// We serialize the value canonically, then store it as a `RawValue` in
// the buffer map.
let value_string = to_string_canonical(value, self.options.clone())?;
self.map
.insert(key_string, RawValue::from_string(value_string)?);
self.map.insert(key, RawValue::from_string(value_string)?);
Ok(())
}
fn end(self) -> Result<Self::Ok, Self::Error> {
self.map.serialize(self.ser)?;
fn end(mut self) -> Result<Self::Ok, Self::Error> {
if self.name == SERDE_JSON_NUMBER_TOKEN {
// Map must be empty in this case, as `SERDE_JSON_NUMBER_TOKEN`
// only has one field and we've handled it in `serialize_field`.
if !self.map.is_empty() {
return Err(Self::Error::custom(format!(
"unexpected fields in `{}`",
SERDE_JSON_NUMBER_TOKEN
)));
}
}
for (key, value) in self.map {
self.struct_serializer.serialize_field(key, &value)?;
}
SerializeStruct::end(self.struct_serializer)?;
Ok(())
}
@@ -737,6 +824,23 @@ mod tests {
assert!(to_string_canonical(&-(2i128.pow(60)), CanonicalizationOptions::strict()).is_err());
}
#[test]
fn bigints() {
// Create a `serde_json::Number` that is too big to be represented as an
// i64, but can be represented as a string.
let bigint_string = "10000000000000000000000000000000000000";
let value: serde_json::Number = bigint_string.parse().unwrap();
// This should work with relaxed option.
assert_eq!(
to_string_canonical(&value, CanonicalizationOptions::relaxed()).unwrap(),
bigint_string
);
// But should fail with strict option, as it's out of range.
assert!(to_string_canonical(&value, CanonicalizationOptions::strict()).is_err());
}
#[test]
fn backwards_compatibility() {
assert_eq!(
@@ -824,7 +928,7 @@ mod tests {
// Serialize the keys in the reverse order.
for (c, _) in ascii_order.iter().rev() {
map_serializer.serialize_entry(c.into(), &1).unwrap();
map_serializer.serialize_entry(c, &1).unwrap();
}
SerializeMap::end(map_serializer).unwrap();
+1 -1
View File
@@ -476,7 +476,7 @@ impl EventInternalMetadataInner {
}
}
#[pyclass(frozen)]
#[pyclass(frozen, skip_from_py_object)]
#[derive(Clone)]
pub struct EventInternalMetadata {
inner: Arc<RwLock<EventInternalMetadataInner>>,
+488
View File
@@ -0,0 +1,488 @@
/*
* This file is licensed under the Affero General Public License (AGPL) version 3.
*
* Copyright (C) 2026 Element Creations Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* See the GNU Affero General Public License for more details:
* <https://www.gnu.org/licenses/agpl-3.0.html>.
*
*/
use std::{collections::BTreeMap, sync::Arc};
use pyo3::{
exceptions::{PyKeyError, PyTypeError},
pyclass, pymethods,
types::{
PyAnyMethods, PyIterator, PyList, PyListMethods, PyMapping, PySet, PySetMethods, PyTuple,
},
Bound, IntoPyObject, IntoPyObjectExt, Py, PyAny, PyResult, Python,
};
use pythonize::{depythonize, pythonize};
use serde::{Deserialize, Serialize};
/// A generic class for representing immutable JSON objects.
///
/// This is used for representing the `content` field of an event.
///
/// The basic architecture here is to optimize for two things:
/// 1. Fast access of top-level keys (e.g. `event.content["key"]`)
/// 2. Pure Rust implementation.
#[derive(Serialize, Deserialize, Clone, Default)]
#[pyclass(mapping, frozen, skip_from_py_object)]
#[serde(transparent)]
pub struct JsonObject {
object: Arc<BTreeMap<Box<str>, serde_json::Value>>,
}
#[pymethods]
impl JsonObject {
#[new]
#[pyo3(signature = (content = None))]
fn new<'a, 'py>(content: Option<&'a Bound<'py, PyAny>>) -> PyResult<Self> {
let Some(content) = content else {
// If no content is provided, default to an empty object.
return Ok(Self::default());
};
if let Ok(content) = content.cast::<JsonObject>() {
// If the content is already a JsonObject, we can just clone the
// underlying map (this is safe as the object is immutable).
return Ok(JsonObject {
object: content.get().object.clone(),
});
}
let Ok(content) = content.cast::<PyMapping>() else {
return Err(PyTypeError::new_err("'content' must be a mapping"));
};
// Use pythonize to try and convert from a mapping.
let content = depythonize(content)?;
Ok(Self {
object: Arc::new(content),
})
}
fn __len__(&self) -> usize {
self.object.len()
}
fn __contains__(&self, key: &Bound<'_, PyAny>) -> bool {
// Match dict semantics: a non-string key is simply "not in" the
// mapping, rather than raising TypeError.
let Ok(key_str) = key.extract::<&str>() else {
return false;
};
self.object.contains_key(key_str)
}
fn __getitem__<'py>(
&self,
py: Python<'py>,
key: Bound<'_, PyAny>,
) -> PyResult<Bound<'py, PyAny>> {
// We only ever store string keys, so any non-string lookup is a miss.
// Raise KeyError (not TypeError) to match dict's behaviour.
let Ok(key_str) = key.extract::<&str>() else {
return Err(PyKeyError::new_err(key.unbind()));
};
let Some(value) = self.object.get(key_str) else {
return Err(PyKeyError::new_err(key.unbind()));
};
Ok(pythonize(py, value)?)
}
fn __iter__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyIterator>> {
// The easiest way to get an iterator over the keys is to create a
// temporary list and call `iter()` on it. This is not the most
// efficient approach, but is much less boilerplate than implementing a
// custom iterator type. Since the keys are typically small in number
// this should be fine in practice.
let list = PyList::new(py, self.object.keys().map(Box::as_ref))?;
PyIterator::from_object(&list)
}
// The view classes below each hold a `JsonObject` clone. This is cheap
// because the underlying map is behind an `Arc`, and lets the view outlive
// the originating object (matching dict_keys/values/items semantics in
// Python, which also keep the dict alive).
fn keys(&self) -> JsonObjectKeysView {
JsonObjectKeysView {
object: self.clone(),
}
}
fn values(&self) -> JsonObjectValuesView {
JsonObjectValuesView {
object: self.clone(),
}
}
fn items(&self) -> JsonObjectItemsView {
JsonObjectItemsView {
object: self.clone(),
}
}
#[pyo3(signature = (key, default=None))]
fn get<'py>(
&self,
py: Python<'py>,
key: Bound<'_, PyAny>,
default: Option<Bound<'py, PyAny>>,
) -> PyResult<Bound<'py, PyAny>> {
// Non-string keys can never match, so treat them as a miss and return
// the caller-supplied default rather than raising.
let Ok(key_str) = key.extract::<&str>() else {
return Ok(default.into_pyobject(py)?);
};
match self.object.get(key_str) {
Some(value) => Ok(pythonize(py, value)?),
None => Ok(default.into_pyobject(py)?),
}
}
fn __eq__(&self, other: Bound<'_, PyAny>) -> bool {
// We support equality against any Python mapping (e.g. plain dicts),
// so callers can swap a JsonObject in without rewriting comparisons.
let Ok(mapping) = other.cast::<PyMapping>() else {
return false;
};
let Ok(other_len) = mapping.len() else {
return false;
};
if other_len != self.object.len() {
return false;
}
// We know the "other" is a mapping with the same number of fields as
// us. So we can convert it into a JsonObject and compare the underlying
// maps.
let Ok(other_dict) = depythonize(&other) else {
return false;
};
*self.object == other_dict
}
// Since we implement comparisons with other types, we need to disable
// hashing to avoid violating the invariant that equal objects must have the
// same hash.
//
// Alternatively, we could only allow comparisons with other JsonObjects and
// allow hashing, but a) its nicer to be able to compare with arbitrary
// mappings and b) we don't really need hashing for these objects.
#[classattr]
const __hash__: Option<Py<PyAny>> = None;
fn __str__(&self) -> String {
serde_json::to_string(&self.object).expect("Value should be serializable")
}
fn __repr__(&self) -> String {
format!("JsonObject({})", self.__str__())
}
}
/// Helper class returned by `JsonObject.keys()` to act as a view into the keys
/// of the object.
///
/// This needs to both be iterable *and* operate like a set.
#[pyclass(frozen, skip_from_py_object)]
#[derive(Clone)]
pub struct JsonObjectKeysView {
object: JsonObject,
}
#[pymethods]
impl JsonObjectKeysView {
fn __iter__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyIterator>> {
// Create the iterator by making a temporary python list of the keys and
// calling `iter()` on it.
let list = PyList::new(py, self.object.object.keys().map(Box::as_ref))?;
PyIterator::from_object(&list)
}
fn __len__(&self) -> usize {
self.object.__len__()
}
fn __contains__(&self, key: &Bound<'_, PyAny>) -> bool {
self.object.__contains__(key)
}
fn __eq__(&self, other: Bound<'_, PyAny>) -> bool {
let other_len = match other.len() {
Ok(len) => len,
Err(_) => return false,
};
if self.object.__len__() != other_len {
return false;
}
for key in self.object.object.keys() {
if !matches!(other.contains(key.as_ref()), Ok(true)) {
return false;
}
}
true
}
// The set operators below match the behaviour of `dict.keys()` in Python:
// they accept any object that supports `__contains__` (for `&`) or is
// iterable (for `|`, `-`, `^`), not just sets. Each returns a fresh
// `PySet` so the caller gets a normal mutable Python set back.
//
// The `__r*__` variants are reflected operators, called by Python when
// the left-hand operand doesn't know how to combine with us. Since these
// operations are commutative for sets (or symmetric in the case of `^`),
// they just delegate. The asymmetric ops (`-`) need a separate impl.
fn __and__<'py>(
&self,
py: Python<'py>,
other: Bound<'_, PyAny>,
) -> PyResult<Bound<'py, PySet>> {
// Iterate our (typically small) key set and probe `other`, which may
// be any container supporting `__contains__`.
let mut result = Vec::new();
for key in self.object.object.keys() {
if matches!(other.contains(key.as_ref()), Ok(true)) {
result.push(key.as_ref());
}
}
PySet::new(py, &result)
}
fn __rand__<'py>(
&self,
py: Python<'py>,
other: Bound<'_, PyAny>,
) -> PyResult<Bound<'py, PySet>> {
self.__and__(py, other)
}
fn __or__<'py>(&self, py: Python<'py>, other: Bound<'_, PyAny>) -> PyResult<Bound<'py, PySet>> {
// Union needs to enumerate both sides, so the right operand must be
// iterable (a bare `__contains__` is not enough).
let Ok(other_iter) = other.try_iter() else {
return Err(PyTypeError::new_err("Right operand must be iterable"));
};
let result = PySet::new(py, self.object.object.keys().map(Box::as_ref))?;
// PySet handles dedup, so we can blindly add every element from the
// other iterable.
for item in other_iter {
let item = item?;
result.add(item)?;
}
Ok(result)
}
fn __ror__<'py>(
&self,
py: Python<'py>,
other: Bound<'_, PyAny>,
) -> PyResult<Bound<'py, PySet>> {
self.__or__(py, other)
}
fn __sub__<'py>(
&self,
py: Python<'py>,
other: Bound<'_, PyAny>,
) -> PyResult<Bound<'py, PySet>> {
// `self - other`: keep our keys that are not in `other`. Only `other`
// needs to support `__contains__` here.
let mut result = Vec::new();
for key in self.object.object.keys() {
if matches!(other.contains(key.as_ref()), Ok(true)) {
continue;
}
result.push(key.as_ref());
}
PySet::new(py, &result)
}
fn __rsub__<'py>(
&self,
py: Python<'py>,
other: Bound<'_, PyAny>,
) -> PyResult<Bound<'py, PySet>> {
// `other - self`: we need to enumerate `other`, so it must be
// iterable. Not symmetric with `__sub__`, hence a separate impl.
let Ok(other_iter) = other.try_iter() else {
return Err(PyTypeError::new_err("Left operand must be iterable"));
};
let result = PySet::empty(py)?;
for item in other_iter {
let item = item?;
if self.object.__contains__(&item) {
continue;
}
result.add(item)?;
}
Ok(result)
}
fn __xor__<'py>(
&self,
py: Python<'py>,
other: Bound<'_, PyAny>,
) -> PyResult<Bound<'py, PySet>> {
// Symmetric difference: elements in exactly one side. Implemented as
// two filtered passes — one over our keys, one over `other`.
let Ok(other_iter) = other.try_iter() else {
return Err(PyTypeError::new_err("Right operand must be iterable"));
};
let result = PySet::empty(py)?;
for key in self.object.object.keys() {
if matches!(other.contains(key.as_ref()), Ok(true)) {
continue;
}
result.add(key.as_ref())?;
}
for item in other_iter {
let item = item?;
if self.object.__contains__(&item) {
continue;
}
result.add(item)?;
}
Ok(result)
}
fn __rxor__<'py>(
&self,
py: Python<'py>,
other: Bound<'_, PyAny>,
) -> PyResult<Bound<'py, PySet>> {
self.__xor__(py, other)
}
fn isdisjoint(&self, other: Bound<'_, PyAny>) -> bool {
for key in self.object.object.keys() {
if matches!(other.contains(key.as_ref()), Ok(true)) {
return false;
}
}
true
}
}
/// Helper class returned by `JsonObject.values()` to act as a view into the
/// values of the object.
#[pyclass(frozen, skip_from_py_object)]
#[derive(Clone)]
pub struct JsonObjectValuesView {
object: JsonObject,
}
#[pymethods]
impl JsonObjectValuesView {
fn __iter__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyIterator>> {
// Create the iterator by making a temporary python list of the keys and
// calling `iter()` on it.
let list = PyList::empty(py);
for v in self.object.object.values() {
let py_value = pythonize(py, v)?.into_bound_py_any(py)?;
list.append(py_value)?;
}
PyIterator::from_object(&list)
}
fn __len__(&self) -> usize {
self.object.__len__()
}
fn __contains__(&self, other: Bound<'_, PyAny>) -> bool {
// We compare by JSON equality rather than Python identity: convert
// the candidate into a `serde_json::Value` once and scan our values.
// Anything that fails to depythonize cannot match by definition.
let other_value: serde_json::Value = match depythonize(&other) {
Ok(v) => v,
Err(_) => return false,
};
self.object.object.values().any(|v| *v == other_value)
}
}
/// Helper class returned by `JsonObject.items()` to act as a view into the
/// items of the object.
///
/// Technically this should be a set-like view according to Python semantics,
/// unless the values are unhashable. Since the values are immutable we could
/// support it, but it's more work and nobody seems to actually use the set
/// operations on `dict_items` in practice.
#[pyclass(frozen, skip_from_py_object)]
#[derive(Clone)]
pub struct JsonObjectItemsView {
object: JsonObject,
}
#[pymethods]
impl JsonObjectItemsView {
fn __iter__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyIterator>> {
// Create the iterator by making a temporary python list of the keys and
// calling `iter()` on it.
let list = PyList::empty(py);
for (k, v) in self.object.object.iter() {
let py_key = k.as_ref().into_bound_py_any(py)?;
let py_value = pythonize(py, v)?.into_bound_py_any(py)?;
let item = PyTuple::new(py, [py_key, py_value])?;
list.append(item)?;
}
PyIterator::from_object(&list)
}
fn __len__(&self) -> usize {
self.object.__len__()
}
fn __contains__(&self, other: Bound<'_, PyAny>) -> bool {
// `(key, value) in items` — only a 2-tuple can possibly match. We
// look the key up directly (avoiding a full scan) and then compare
// the stored value against `value` using JSON equality.
let Ok((key, value)) = other.extract::<(Bound<'_, PyAny>, Bound<'_, PyAny>)>() else {
return false;
};
let Ok(key_str) = key.extract::<&str>() else {
return false;
};
let Some(stored) = self.object.object.get(key_str) else {
return false;
};
let other_value: serde_json::Value = match depythonize(&value) {
Ok(v) => v,
Err(_) => return false,
};
*stored == other_value
}
}
+15 -1
View File
@@ -21,17 +21,31 @@
//! Classes for representing Events.
use pyo3::{
types::{PyAnyMethods, PyModule, PyModuleMethods},
types::{PyAnyMethods, PyMapping, PyModule, PyModuleMethods},
wrap_pyfunction, Bound, PyResult, Python,
};
pub mod filter;
mod internal_metadata;
mod json_object;
pub mod signatures;
pub mod unsigned;
use json_object::JsonObject;
/// Called when registering modules with python.
pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
// Register the `JsonObject` class as a `Mapping` so that `isinstance` works.
PyMapping::register::<JsonObject>(py)?;
let child_module = PyModule::new(py, "events")?;
child_module.add_class::<internal_metadata::EventInternalMetadata>()?;
child_module.add_class::<signatures::Signatures>()?;
child_module.add_class::<unsigned::Unsigned>()?;
child_module.add_class::<JsonObject>()?;
child_module.add_class::<json_object::JsonObjectKeysView>()?;
child_module.add_class::<json_object::JsonObjectValuesView>()?;
child_module.add_class::<json_object::JsonObjectItemsView>()?;
child_module.add_function(wrap_pyfunction!(filter::event_visible_to_server_py, m)?)?;
m.add_submodule(&child_module)?;
+348
View File
@@ -0,0 +1,348 @@
/*
* This file is licensed under the Affero General Public License (AGPL) version 3.
*
* Copyright (C) 2026 Element Creations Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* See the GNU Affero General Public License for more details:
* <https://www.gnu.org/licenses/agpl-3.0.html>.
*
*/
//! Class for representing event signatures
use std::{
collections::HashMap,
sync::{Arc, RwLock},
};
use pyo3::{
exceptions::{PyKeyError, PyRuntimeError},
pyclass, pymethods,
types::{PyAnyMethods, PyDict, PyMapping, PyMappingMethods},
Bound, IntoPyObject, PyAny, PyResult, Python,
};
use serde::{Deserialize, Serialize};
/// A class representing the signatures on an event.
#[pyclass(frozen, skip_from_py_object)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Signatures {
inner: Arc<RwLock<HashMap<String, HashMap<String, String>>>>,
}
#[pymethods]
impl Signatures {
#[new]
#[pyo3(signature = (signatures = None))]
fn py_new(signatures: Option<HashMap<String, HashMap<String, String>>>) -> Self {
let mut signatures = signatures.unwrap_or_default();
// Prune any entries that have no signatures.
signatures.retain(|_, server_sigs| !server_sigs.is_empty());
Self {
inner: Arc::new(RwLock::new(signatures)),
}
}
/// Check if the signatures contain a signature for the given server name.
fn __contains__(&self, key: Bound<'_, PyAny>) -> PyResult<bool> {
let Ok(key) = key.extract::<&str>() else {
return Ok(false);
};
let signatures = self
.inner
.read()
.map_err(|_| PyRuntimeError::new_err("Failed to acquire lock"))?;
Ok(signatures.contains_key(key))
}
/// Get the number of servers that have signatures.
fn __len__(&self) -> PyResult<usize> {
let signatures = self
.inner
.read()
.map_err(|_| PyRuntimeError::new_err("Failed to acquire lock"))?;
Ok(signatures.len())
}
/// Get the signature for the given server name and key ID, if it exists.
fn get_signature(&self, server_name: &str, key_id: &str) -> PyResult<Option<String>> {
let signatures = self
.inner
.read()
.map_err(|_| PyRuntimeError::new_err("Failed to acquire lock"))?;
Ok(signatures
.get(server_name)
.and_then(|server_sigs| server_sigs.get(key_id).cloned()))
}
/// Get the signatures for the given server name.
fn __getitem__(&self, key: Bound<'_, PyAny>) -> PyResult<HashMap<String, String>> {
let Some(server_name) = key.extract::<&str>().ok() else {
return Err(PyKeyError::new_err(key.to_string()));
};
let signatures = self
.inner
.read()
.map_err(|_| PyRuntimeError::new_err("Failed to acquire lock"))?;
if let Some(server_sigs) = signatures.get(server_name) {
Ok(server_sigs.clone())
} else {
Err(PyKeyError::new_err(server_name.to_string()))
}
}
/// Add a signature for the given server name and key ID.
fn add_signature(
&self,
server_name: String,
key_id: String,
signature: String,
) -> PyResult<()> {
let mut signatures = self
.inner
.write()
.map_err(|_| PyRuntimeError::new_err("Failed to acquire lock"))?;
signatures
.entry(server_name)
.or_default()
.insert(key_id, signature);
Ok(())
}
/// Update the signatures with the given signatures.
///
/// Will overwrite all existing signatures for the server names provided.
fn update(&self, other: &Bound<'_, PyMapping>) -> PyResult<()> {
let mut signatures = self
.inner
.write()
.map_err(|_| PyRuntimeError::new_err("Failed to acquire lock"))?;
for list_entry in other.items()? {
let (server_name, server_sigs) = list_entry.extract::<(String, Bound<PyMapping>)>()?;
let mut entry = HashMap::new();
for list_entry in server_sigs.items()? {
let (key, value) = list_entry.extract::<(String, String)>()?;
entry.insert(key, value);
}
// Only insert the entry if it has at least one signature.
if !entry.is_empty() {
signatures.insert(server_name, entry);
} else {
signatures.remove(&server_name);
}
}
Ok(())
}
/// Return a copy of the signatures as a dictionary.
fn as_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
let signatures = self
.inner
.read()
.map_err(|_| PyRuntimeError::new_err("Failed to acquire lock"))?;
(&*signatures).into_pyobject(py)
}
fn __repr__(&self) -> PyResult<String> {
let signatures = self
.inner
.read()
.map_err(|_| PyRuntimeError::new_err("Failed to acquire lock"))?;
Ok(format!("Signatures({signatures:?})"))
}
}
#[cfg(test)]
mod tests {
use pythonize::pythonize;
use super::*;
/// Helper that reads the inner map directly.
fn read_inner(sigs: &Signatures) -> HashMap<String, HashMap<String, String>> {
sigs.inner.read().expect("lock poisoned").clone()
}
/// Helper to create a server signatures map from a list of (key_id, sig)
/// pairs.
fn make_server_sigs(data: &[(&str, &str)]) -> HashMap<String, String> {
let mut map = HashMap::new();
for (key_id, sig) in data {
map.insert((*key_id).to_owned(), (*sig).to_owned());
}
map
}
/// Helper to create a `Signatures` object from a list of (server_name,
/// key_id, sig) tuples.
fn create_signatures(data: &[(&str, &str, &str)]) -> Signatures {
let mut map: HashMap<String, HashMap<String, String>> = HashMap::new();
for (server_name, key_id, sig) in data {
map.entry((*server_name).to_owned())
.or_default()
.insert((*key_id).to_owned(), (*sig).to_owned());
}
Signatures::py_new(Some(map))
}
#[test]
fn test_new_empty() {
let sigs = Signatures::py_new(None);
assert!(read_inner(&sigs).is_empty());
assert_eq!(sigs.__len__().unwrap(), 0);
}
#[test]
fn test_new_with_data() {
let sigs = create_signatures(&[("example.com", "ed25519:key1", "sig1")]);
assert_eq!(sigs.__len__().unwrap(), 1);
assert_eq!(
sigs.get_signature("example.com", "ed25519:key1").unwrap(),
Some("sig1".to_string())
);
}
#[test]
fn test_new_prunes_servers_with_no_signatures() {
let mut data = HashMap::new();
data.insert("empty.example.com".to_string(), HashMap::new());
data.insert(
"example.com".to_string(),
make_server_sigs(&[("ed25519:key1", "sig1")]),
);
let sigs = Signatures::py_new(Some(data));
let inner = read_inner(&sigs);
assert_eq!(inner.len(), 1);
assert!(inner.contains_key("example.com"));
assert!(!inner.contains_key("empty.example.com"));
}
#[test]
fn test_add_signature() {
let sigs = Signatures::py_new(None);
sigs.add_signature(
"example.com".to_string(),
"ed25519:key1".to_string(),
"sig1".to_string(),
)
.unwrap();
let inner = read_inner(&sigs);
assert_eq!(inner.len(), 1);
assert_eq!(
inner.get("example.com").and_then(|m| m.get("ed25519:key1")),
Some(&"sig1".to_string())
);
}
#[test]
fn test_add_signature_to_existing_server() {
let sigs = create_signatures(&[("example.com", "ed25519:key1", "sig1")]);
sigs.add_signature(
"example.com".to_string(),
"ed25519:key2".to_string(),
"sig2".to_string(),
)
.unwrap();
let inner = read_inner(&sigs);
assert_eq!(inner.len(), 1);
assert_eq!(
inner.get("example.com").and_then(|m| m.get("ed25519:key1")),
Some(&"sig1".to_string())
);
assert_eq!(
inner.get("example.com").and_then(|m| m.get("ed25519:key2")),
Some(&"sig2".to_string())
);
}
#[test]
fn test_update_signatures_clobbers_existing() {
let sigs = create_signatures(&[("example.com", "ed25519:key1", "sig1")]);
// Create a new signatures map with a different signature for the same
// server.
let mut other = HashMap::new();
other.insert(
"example.com".to_string(),
make_server_sigs(&[("ed25519:key2", "sig2")]),
);
// Update the signatures with the new map.
Python::initialize();
Python::attach(|py| {
let value = pythonize(py, &other).unwrap();
let value = value.cast::<PyMapping>().unwrap();
sigs.update(value).unwrap();
});
// Check that the old signature has been replaced with the new one.
let inner = read_inner(&sigs);
assert_eq!(inner.len(), 1);
assert_eq!(inner["example.com"].len(), 1);
assert_eq!(inner["example.com"]["ed25519:key2"], "sig2");
}
#[test]
fn test_serialize() {
let mut data = HashMap::new();
data.insert(
"example.com".to_string(),
make_server_sigs(&[("ed25519:key1", "sig1")]),
);
let sigs = Signatures::py_new(Some(data));
let json = serde_json::to_string(&sigs).unwrap();
assert_eq!(json, r#"{"example.com":{"ed25519:key1":"sig1"}}"#);
}
#[test]
fn test_serialize_empty() {
let sigs = Signatures::py_new(None);
let json = serde_json::to_string(&sigs).unwrap();
assert_eq!(json, "{}");
}
#[test]
fn test_deserialize() {
let json = r#"{"example.com":{"ed25519:key1":"sig1"}}"#;
let sigs: Signatures = serde_json::from_str(json).unwrap();
let inner = read_inner(&sigs);
assert_eq!(inner.len(), 1);
assert_eq!(
inner.get("example.com").and_then(|m| m.get("ed25519:key1")),
Some(&"sig1".to_string())
);
}
#[test]
fn test_deserialize_empty() {
let sigs: Signatures = serde_json::from_str("{}").unwrap();
assert!(read_inner(&sigs).is_empty());
}
}
+433
View File
@@ -0,0 +1,433 @@
/*
* This file is licensed under the Affero General Public License (AGPL) version 3.
*
* Copyright (C) 2026 Element Creations Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* See the GNU Affero General Public License for more details:
* <https://www.gnu.org/licenses/agpl-3.0.html>.
*
*/
use std::sync::{Arc, RwLock, RwLockReadGuard};
use pyo3::{
exceptions::{PyKeyError, PyRuntimeError, PyTypeError},
pyclass, pymethods,
types::{PyAnyMethods, PyList, PyListMethods, PyMapping},
Bound, IntoPyObjectExt, PyAny, PyResult, Python,
};
use pythonize::{depythonize, pythonize};
use serde::{Deserialize, Serialize};
use serde_json::Number;
#[pyclass(frozen, skip_from_py_object)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(transparent)]
pub struct Unsigned {
inner: Arc<RwLock<UnsignedInner>>,
}
/// The fields in the unsigned data of an event that are persisted in the
/// database.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct PersistedUnsignedFields {
#[serde(skip_serializing_if = "Option::is_none")]
age_ts: Option<Number>,
#[serde(skip_serializing_if = "Option::is_none")]
replaces_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
invite_room_state: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
knock_room_state: Option<Vec<serde_json::Value>>,
}
/// The inner representation of the unsigned data of an event, which includes
/// both the fields that are persisted in the database and the fields that are
/// only used in memory.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UnsignedInner {
#[serde(flatten)]
persisted_fields: PersistedUnsignedFields,
#[serde(skip_serializing_if = "Option::is_none")]
prev_content: Option<Box<serde_json::Value>>, // We use Box to minimise stack space
#[serde(skip_serializing_if = "Option::is_none")]
prev_sender: Option<String>,
}
/// The fields that exist on the unsigned data of an event.
///
/// This is used when converting from python to rust, to ensure that if we add a
/// new field we don't forget to add it to all the necessary places.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UnsignedField {
AgeTs,
ReplacesState,
InviteRoomState,
KnockRoomState,
PrevContent,
PrevSender,
}
impl std::str::FromStr for UnsignedField {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"age_ts" => Ok(Self::AgeTs),
"replaces_state" => Ok(Self::ReplacesState),
"invite_room_state" => Ok(Self::InviteRoomState),
"knock_room_state" => Ok(Self::KnockRoomState),
"prev_content" => Ok(Self::PrevContent),
"prev_sender" => Ok(Self::PrevSender),
_ => Err(()),
}
}
}
impl Unsigned {
fn py_read(&self) -> PyResult<RwLockReadGuard<'_, UnsignedInner>> {
self.inner
.read()
.map_err(|_| PyRuntimeError::new_err("Unsigned lock poisoned"))
}
fn py_write(&self) -> PyResult<std::sync::RwLockWriteGuard<'_, UnsignedInner>> {
self.inner
.write()
.map_err(|_| PyRuntimeError::new_err("Unsigned lock poisoned"))
}
}
#[pymethods]
impl Unsigned {
#[new]
fn py_new(unsigned: Bound<'_, PyMapping>) -> PyResult<Self> {
let inner = depythonize(&unsigned)?;
Ok(Self {
inner: Arc::new(RwLock::new(inner)),
})
}
fn __getitem__<'py>(
&self,
py: Python<'py>,
key: Bound<'_, PyAny>,
) -> PyResult<Bound<'py, PyAny>> {
let key = key
.extract::<&str>()
.map_err(|_| PyTypeError::new_err("Unsigned keys must be strings"))?;
let field: UnsignedField = key
.parse()
.map_err(|_| PyKeyError::new_err(format!("Unsigned has no key '{key}'")))?;
let unsigned = self.py_read()?;
match field {
UnsignedField::AgeTs => {
let age_ts = &unsigned
.persisted_fields
.age_ts
.as_ref()
.ok_or_else(|| PyKeyError::new_err("age_ts"))?;
Ok(pythonize(py, age_ts)?)
}
UnsignedField::ReplacesState => Ok((unsigned.persisted_fields.replaces_state)
.as_ref()
.ok_or_else(|| PyKeyError::new_err("replaces_state"))?
.into_bound_py_any(py)?),
UnsignedField::InviteRoomState => Ok(room_state_to_py(
py,
unsigned
.persisted_fields
.invite_room_state
.as_ref()
.ok_or_else(|| PyKeyError::new_err("invite_room_state"))?,
)?),
UnsignedField::KnockRoomState => Ok(room_state_to_py(
py,
unsigned
.persisted_fields
.knock_room_state
.as_ref()
.ok_or_else(|| PyKeyError::new_err("knock_room_state"))?,
)?),
UnsignedField::PrevContent => Ok(pythonize(
py,
unsigned
.prev_content
.as_ref()
.ok_or_else(|| PyKeyError::new_err("prev_content"))?,
)?),
UnsignedField::PrevSender => Ok((unsigned.prev_sender)
.as_ref()
.ok_or_else(|| PyKeyError::new_err("prev_sender"))?
.into_bound_py_any(py)?),
}
}
fn __contains__(&self, key: Bound<'_, PyAny>) -> PyResult<bool> {
let Ok(key) = key.extract::<&str>() else {
return Ok(false);
};
let Ok(field) = key.parse::<UnsignedField>() else {
return Ok(false);
};
let unsigned = self.py_read()?;
let exists = match field {
UnsignedField::AgeTs => unsigned.persisted_fields.age_ts.is_some(),
UnsignedField::ReplacesState => unsigned.persisted_fields.replaces_state.is_some(),
UnsignedField::InviteRoomState => unsigned.persisted_fields.invite_room_state.is_some(),
UnsignedField::KnockRoomState => unsigned.persisted_fields.knock_room_state.is_some(),
UnsignedField::PrevContent => unsigned.prev_content.is_some(),
UnsignedField::PrevSender => unsigned.prev_sender.is_some(),
};
Ok(exists)
}
fn __setitem__(&self, key: Bound<'_, PyAny>, value: Bound<'_, PyAny>) -> PyResult<()> {
let key = key
.extract::<&str>()
.map_err(|_| PyTypeError::new_err("Unsigned keys must be strings"))?;
let field: UnsignedField = key
.parse()
.map_err(|_| PyKeyError::new_err(format!("Unsigned has no key '{key}'")))?;
let mut unsigned = self.py_write()?;
match field {
UnsignedField::AgeTs => unsigned.persisted_fields.age_ts = Some(depythonize(&value)?),
UnsignedField::ReplacesState => {
unsigned.persisted_fields.replaces_state = Some(value.extract()?)
}
UnsignedField::InviteRoomState => {
unsigned.persisted_fields.invite_room_state = Some(room_state_from_py(value)?)
}
UnsignedField::KnockRoomState => {
unsigned.persisted_fields.knock_room_state = Some(room_state_from_py(value)?)
}
UnsignedField::PrevContent => {
unsigned.prev_content = Some(Box::new(depythonize(&value)?))
}
UnsignedField::PrevSender => unsigned.prev_sender = Some(value.extract()?),
}
Ok(())
}
fn __delitem__(&self, key: Bound<'_, PyAny>) -> PyResult<()> {
let key = key
.extract::<&str>()
.map_err(|_| PyTypeError::new_err("Unsigned keys must be strings"))?;
let field: UnsignedField = key
.parse()
.map_err(|_| PyKeyError::new_err(format!("Unsigned has no key '{key}'")))?;
let mut unsigned = self.py_write()?;
match field {
UnsignedField::AgeTs => unsigned.persisted_fields.age_ts = None,
UnsignedField::ReplacesState => unsigned.persisted_fields.replaces_state = None,
UnsignedField::InviteRoomState => unsigned.persisted_fields.invite_room_state = None,
UnsignedField::KnockRoomState => unsigned.persisted_fields.knock_room_state = None,
UnsignedField::PrevContent => unsigned.prev_content = None,
UnsignedField::PrevSender => unsigned.prev_sender = None,
}
Ok(())
}
#[pyo3(signature = (key, default=None))]
fn get<'py>(
&self,
py: Python<'py>,
key: Bound<'py, PyAny>,
default: Option<Bound<'py, PyAny>>,
) -> PyResult<Option<Bound<'py, PyAny>>> {
match self.__getitem__(py, key) {
Ok(value) => Ok(Some(value)),
Err(err) => {
if err.is_instance_of::<PyKeyError>(py) {
Ok(default)
} else {
Err(err)
}
}
}
}
fn for_persistence<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
Ok(pythonize(py, &self.py_read()?.persisted_fields)?)
}
fn for_event<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
Ok(pythonize(py, &*self.py_read()?)?)
}
}
fn room_state_to_py<'py>(
py: Python<'py>,
state: &[serde_json::Value],
) -> PyResult<Bound<'py, PyAny>> {
let py_list = PyList::empty(py);
for item in state {
py_list.append(pythonize(py, item)?)?;
}
py_list.into_bound_py_any(py)
}
fn room_state_from_py(value: Bound<'_, PyAny>) -> PyResult<Vec<serde_json::Value>> {
let py_list = value.cast::<PyList>()?;
let mut state = Vec::with_capacity(py_list.len());
for item in py_list.iter() {
state.push(pythonize::depythonize(&item)?);
}
Ok(state)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn test_unsigned_field_from_str_valid() {
assert_eq!("age_ts".parse(), Ok(UnsignedField::AgeTs));
assert_eq!("replaces_state".parse(), Ok(UnsignedField::ReplacesState));
assert_eq!(
"invite_room_state".parse(),
Ok(UnsignedField::InviteRoomState)
);
assert_eq!(
"knock_room_state".parse(),
Ok(UnsignedField::KnockRoomState)
);
assert_eq!("prev_content".parse(), Ok(UnsignedField::PrevContent));
assert_eq!("prev_sender".parse(), Ok(UnsignedField::PrevSender));
}
#[test]
fn test_unsigned_field_from_str_invalid() {
assert_eq!("".parse::<UnsignedField>(), Err(()));
assert_eq!("unknown".parse::<UnsignedField>(), Err(()));
// Case-sensitive: upper-case should not match.
assert_eq!("AGE_TS".parse::<UnsignedField>(), Err(()));
// Must be an exact match, no whitespace.
assert_eq!(" age_ts".parse::<UnsignedField>(), Err(()));
}
#[test]
fn test_persisted_fields_serialize_empty_is_empty_object() {
let fields = PersistedUnsignedFields::default();
let json = serde_json::to_value(&fields).unwrap();
assert_eq!(json, json!({}));
}
#[test]
fn test_persisted_fields_serialize_populated() {
let fields = PersistedUnsignedFields {
age_ts: Some(1234.into()),
replaces_state: Some("$prev:example.com".to_string()),
invite_room_state: Some(vec![json!({"type": "m.room.name"})]),
knock_room_state: Some(vec![json!({"type": "m.room.topic"})]),
};
let json = serde_json::to_value(&fields).unwrap();
assert_eq!(
json,
json!({
"age_ts": 1234,
"replaces_state": "$prev:example.com",
"invite_room_state": [{"type": "m.room.name"}],
"knock_room_state": [{"type": "m.room.topic"}],
})
);
}
#[test]
fn test_unsigned_inner_flattens_persisted_fields() {
let inner = UnsignedInner {
persisted_fields: PersistedUnsignedFields {
age_ts: Some(99.into()),
..Default::default()
},
prev_content: Some(Box::new(json!({"body": "hi"}))),
prev_sender: Some("@alice:example.com".to_string()),
};
let json = serde_json::to_value(&inner).unwrap();
assert_eq!(
json,
json!({
"age_ts": 99,
"prev_content": {"body": "hi"},
"prev_sender": "@alice:example.com",
})
);
}
#[test]
fn test_unsigned_inner_roundtrip() {
let original = UnsignedInner {
persisted_fields: PersistedUnsignedFields {
age_ts: Some(10.into()),
replaces_state: Some("$state:example.com".to_string()),
invite_room_state: None,
knock_room_state: None,
},
prev_content: Some(Box::new(json!({"membership": "join"}))),
prev_sender: None,
};
let json = serde_json::to_string(&original).unwrap();
let roundtripped: UnsignedInner = serde_json::from_str(&json).unwrap();
assert_eq!(roundtripped.persisted_fields.age_ts, Some(10.into()));
assert_eq!(
roundtripped.persisted_fields.replaces_state.as_deref(),
Some("$state:example.com")
);
assert_eq!(
roundtripped.prev_content.as_deref(),
Some(&json!({"membership": "join"}))
);
assert_eq!(roundtripped.prev_sender, None);
}
#[test]
fn test_unsigned_serializes_transparently() {
// `Unsigned` is `#[serde(transparent)]` over its inner, so serializing
// an empty default should yield an empty object rather than a wrapper.
let unsigned = Unsigned::default();
let json = serde_json::to_value(&unsigned).unwrap();
assert_eq!(json, json!({}));
}
#[test]
fn test_unsigned_deserialize_from_flat_object() {
let json = json!({
"age_ts": 5,
"prev_sender": "@bob:example.com",
});
let unsigned: Unsigned = serde_json::from_value(json).unwrap();
let inner = unsigned.inner.read().unwrap();
assert_eq!(inner.persisted_fields.age_ts, Some(5.into()));
assert_eq!(inner.prev_sender.as_deref(), Some("@bob:example.com"));
}
}
+3 -3
View File
@@ -104,7 +104,7 @@ fn get_base_rule_ids() -> HashSet<&'static str> {
/// A single push rule for a user.
#[derive(Debug, Clone)]
#[pyclass(frozen)]
#[pyclass(frozen, from_py_object)]
pub struct PushRule {
/// A unique ID for this rule
pub rule_id: Cow<'static, str>,
@@ -462,7 +462,7 @@ pub struct RelatedEventMatchTypeCondition {
/// The collection of push rules for a user.
#[derive(Debug, Clone, Default)]
#[pyclass(frozen)]
#[pyclass(frozen, from_py_object)]
pub struct PushRules {
/// Custom push rules that override a base rule.
overridden_base_rules: HashMap<Cow<'static, str>, PushRule>,
@@ -549,7 +549,7 @@ impl PushRules {
/// A wrapper around `PushRules` that checks the enabled state of rules and
/// filters out disabled experimental rules.
#[derive(Debug, Clone, Default)]
#[pyclass(frozen)]
#[pyclass(frozen, skip_from_py_object)]
pub struct FilteredPushRules {
push_rules: PushRules,
enabled_map: BTreeMap<String, bool>,
+248 -496
View File
@@ -14,7 +14,11 @@
//! Rust implementation of room version definitions.
use std::sync::{Arc, LazyLock, RwLock};
use std::{
fmt::Display,
str::FromStr,
sync::{Arc, LazyLock, RwLock},
};
use pyo3::{
exceptions::{PyKeyError, PyRuntimeError},
@@ -30,47 +34,47 @@ use pyo3::{
/// versions that they were used or introduced in.
/// The concept of an 'event format version' is specific to Synapse (the
/// specification does not mention this term.)
#[pyclass(frozen)]
#[pyclass(frozen, skip_from_py_object)]
pub struct EventFormatVersions {}
#[pymethods]
impl EventFormatVersions {
/// $id:server event id format: used for room v1 and v2
#[classattr]
const ROOM_V1_V2: i32 = 1;
pub const ROOM_V1_V2: i32 = 1;
/// MSC1659-style $hash event id format: used for room v3
#[classattr]
const ROOM_V3: i32 = 2;
pub const ROOM_V3: i32 = 2;
/// MSC1884-style $hash format: introduced for room v4
#[classattr]
const ROOM_V4_PLUS: i32 = 3;
pub const ROOM_V4_PLUS: i32 = 3;
/// MSC4291 room IDs as hashes: introduced for room HydraV11
#[classattr]
const ROOM_V11_HYDRA_PLUS: i32 = 4;
pub const ROOM_V11_HYDRA_PLUS: i32 = 4;
/// MSC4242 state DAGs: adds prev_state_events, removes auth_events
#[classattr]
const ROOM_VMSC4242: i32 = 5;
pub const ROOM_VMSC4242: i32 = 5;
}
/// Enum to identify the state resolution algorithms.
#[pyclass(frozen)]
#[pyclass(frozen, skip_from_py_object)]
pub struct StateResolutionVersions {}
#[pymethods]
impl StateResolutionVersions {
/// Room v1 state res
#[classattr]
const V1: i32 = 1;
pub const V1: i32 = 1;
/// MSC1442 state res: room v2 and later
#[classattr]
const V2: i32 = 2;
pub const V2: i32 = 2;
/// MSC4297 state res
#[classattr]
const V2_1: i32 = 3;
pub const V2_1: i32 = 3;
}
/// Room disposition constants.
#[pyclass(frozen)]
#[pyclass(frozen, skip_from_py_object)]
pub struct RoomDisposition {}
#[pymethods]
@@ -93,19 +97,19 @@ impl PushRuleRoomFlag {
}
/// An object which describes the unique attributes of a room version.
#[pyclass(frozen, eq, hash, get_all)]
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[pyclass(frozen, eq, hash, get_all, skip_from_py_object)]
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct RoomVersion {
/// The identifier for this version.
pub identifier: &'static str,
/// One of the RoomDisposition constants.
/// One of the [`RoomDisposition`] constants.
pub disposition: &'static str,
/// One of the EventFormatVersions constants.
/// One of the [`EventFormatVersions`] constants.
pub event_format: i32,
/// One of the StateResolutionVersions constants.
/// One of the [`StateResolutionVersions`] constants.
pub state_res: i32,
pub enforce_key_validity: bool,
/// Before MSC2432, m.room.aliases had special auth rules and redaction rules.
/// Before MSC2432, `m.room.aliases` had special auth rules and redaction rules.
pub special_case_aliases_auth: bool,
/// Strictly enforce canonicaljson, do not allow:
/// * Integers outside the range of [-2^53 + 1, 2^53 - 1]
@@ -159,459 +163,204 @@ pub struct RoomVersion {
pub msc4242_state_dags: bool,
}
const ROOM_VERSION_V1: RoomVersion = RoomVersion {
identifier: "1",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V1_V2,
state_res: StateResolutionVersions::V1,
enforce_key_validity: false,
special_case_aliases_auth: true,
strict_canonicaljson: false,
limit_notifications_power_levels: false,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: false,
restricted_join_rule_fix: false,
knock_join_rule: false,
msc3389_relation_redactions: false,
knock_restricted_join_rule: false,
enforce_int_power_levels: false,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
impl RoomVersion {
pub const V1: RoomVersion = RoomVersion {
identifier: "1",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V1_V2,
state_res: StateResolutionVersions::V1,
enforce_key_validity: false,
special_case_aliases_auth: true,
strict_canonicaljson: false,
limit_notifications_power_levels: false,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: false,
restricted_join_rule_fix: false,
knock_join_rule: false,
msc3389_relation_redactions: false,
knock_restricted_join_rule: false,
enforce_int_power_levels: false,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
const ROOM_VERSION_V2: RoomVersion = RoomVersion {
identifier: "2",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V1_V2,
state_res: StateResolutionVersions::V2,
enforce_key_validity: false,
special_case_aliases_auth: true,
strict_canonicaljson: false,
limit_notifications_power_levels: false,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: false,
restricted_join_rule_fix: false,
knock_join_rule: false,
msc3389_relation_redactions: false,
knock_restricted_join_rule: false,
enforce_int_power_levels: false,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
pub const V2: RoomVersion = RoomVersion {
identifier: "2",
state_res: StateResolutionVersions::V2,
..Self::V1
};
const ROOM_VERSION_V3: RoomVersion = RoomVersion {
identifier: "3",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V3,
state_res: StateResolutionVersions::V2,
enforce_key_validity: false,
special_case_aliases_auth: true,
strict_canonicaljson: false,
limit_notifications_power_levels: false,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: false,
restricted_join_rule_fix: false,
knock_join_rule: false,
msc3389_relation_redactions: false,
knock_restricted_join_rule: false,
enforce_int_power_levels: false,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
pub const V3: RoomVersion = RoomVersion {
identifier: "3",
event_format: EventFormatVersions::ROOM_V3,
..Self::V2
};
const ROOM_VERSION_V4: RoomVersion = RoomVersion {
identifier: "4",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V4_PLUS,
state_res: StateResolutionVersions::V2,
enforce_key_validity: false,
special_case_aliases_auth: true,
strict_canonicaljson: false,
limit_notifications_power_levels: false,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: false,
restricted_join_rule_fix: false,
knock_join_rule: false,
msc3389_relation_redactions: false,
knock_restricted_join_rule: false,
enforce_int_power_levels: false,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
pub const V4: RoomVersion = RoomVersion {
identifier: "4",
event_format: EventFormatVersions::ROOM_V4_PLUS,
..Self::V3
};
const ROOM_VERSION_V5: RoomVersion = RoomVersion {
identifier: "5",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V4_PLUS,
state_res: StateResolutionVersions::V2,
enforce_key_validity: true,
special_case_aliases_auth: true,
strict_canonicaljson: false,
limit_notifications_power_levels: false,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: false,
restricted_join_rule_fix: false,
knock_join_rule: false,
msc3389_relation_redactions: false,
knock_restricted_join_rule: false,
enforce_int_power_levels: false,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
pub const V5: RoomVersion = RoomVersion {
identifier: "5",
enforce_key_validity: true,
..Self::V4
};
const ROOM_VERSION_V6: RoomVersion = RoomVersion {
identifier: "6",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V4_PLUS,
state_res: StateResolutionVersions::V2,
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: false,
restricted_join_rule_fix: false,
knock_join_rule: false,
msc3389_relation_redactions: false,
knock_restricted_join_rule: false,
enforce_int_power_levels: false,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
pub const V6: RoomVersion = RoomVersion {
identifier: "6",
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
..Self::V5
};
const ROOM_VERSION_V7: RoomVersion = RoomVersion {
identifier: "7",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V4_PLUS,
state_res: StateResolutionVersions::V2,
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: false,
restricted_join_rule_fix: false,
knock_join_rule: true,
msc3389_relation_redactions: false,
knock_restricted_join_rule: false,
enforce_int_power_levels: false,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
pub const V7: RoomVersion = RoomVersion {
identifier: "7",
knock_join_rule: true,
..Self::V6
};
const ROOM_VERSION_V8: RoomVersion = RoomVersion {
identifier: "8",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V4_PLUS,
state_res: StateResolutionVersions::V2,
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: true,
restricted_join_rule_fix: false,
knock_join_rule: true,
msc3389_relation_redactions: false,
knock_restricted_join_rule: false,
enforce_int_power_levels: false,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
pub const V8: RoomVersion = RoomVersion {
identifier: "8",
restricted_join_rule: true,
..Self::V7
};
const ROOM_VERSION_V9: RoomVersion = RoomVersion {
identifier: "9",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V4_PLUS,
state_res: StateResolutionVersions::V2,
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: true,
restricted_join_rule_fix: true,
knock_join_rule: true,
msc3389_relation_redactions: false,
knock_restricted_join_rule: false,
enforce_int_power_levels: false,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
pub const V9: RoomVersion = RoomVersion {
identifier: "9",
restricted_join_rule_fix: true,
..Self::V8
};
const ROOM_VERSION_V10: RoomVersion = RoomVersion {
identifier: "10",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V4_PLUS,
state_res: StateResolutionVersions::V2,
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: true,
restricted_join_rule_fix: true,
knock_join_rule: true,
msc3389_relation_redactions: false,
knock_restricted_join_rule: true,
enforce_int_power_levels: true,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
pub const V10: RoomVersion = RoomVersion {
identifier: "10",
knock_restricted_join_rule: true,
enforce_int_power_levels: true,
..Self::V9
};
/// MSC3389 (Redaction changes for events with a relation) based on room version "10".
const ROOM_VERSION_MSC3389V10: RoomVersion = RoomVersion {
identifier: "org.matrix.msc3389.10",
disposition: RoomDisposition::UNSTABLE,
event_format: EventFormatVersions::ROOM_V4_PLUS,
state_res: StateResolutionVersions::V2,
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: true,
restricted_join_rule_fix: true,
knock_join_rule: true,
msc3389_relation_redactions: true, // Changed from v10
knock_restricted_join_rule: true,
enforce_int_power_levels: true,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: true,
msc4242_state_dags: false,
};
/// MSC3389 (Redaction changes for events with a relation) based on room version "10".
pub const MSC3389V10: RoomVersion = RoomVersion {
identifier: "org.matrix.msc3389.10",
disposition: RoomDisposition::UNSTABLE,
msc3389_relation_redactions: true,
strict_event_byte_limits_room_versions: true,
..Self::V10
};
/// MSC1767 (Extensible Events) based on room version "10".
const ROOM_VERSION_MSC1767V10: RoomVersion = RoomVersion {
identifier: "org.matrix.msc1767.10",
disposition: RoomDisposition::UNSTABLE,
event_format: EventFormatVersions::ROOM_V4_PLUS,
state_res: StateResolutionVersions::V2,
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: true,
restricted_join_rule_fix: true,
knock_join_rule: true,
msc3389_relation_redactions: false,
knock_restricted_join_rule: true,
enforce_int_power_levels: true,
msc3931_push_features: &[PushRuleRoomFlag::EXTENSIBLE_EVENTS],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
/// MSC1767 (Extensible Events) based on room version "10".
pub const MSC1767V10: RoomVersion = RoomVersion {
identifier: "org.matrix.msc1767.10",
disposition: RoomDisposition::UNSTABLE,
msc3931_push_features: &[PushRuleRoomFlag::EXTENSIBLE_EVENTS],
..Self::V10
};
/// MSC3757 (Restricting who can overwrite a state event) based on room version "10".
const ROOM_VERSION_MSC3757V10: RoomVersion = RoomVersion {
identifier: "org.matrix.msc3757.10",
disposition: RoomDisposition::UNSTABLE,
event_format: EventFormatVersions::ROOM_V4_PLUS,
state_res: StateResolutionVersions::V2,
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: false,
updated_redaction_rules: false,
restricted_join_rule: true,
restricted_join_rule_fix: true,
knock_join_rule: true,
msc3389_relation_redactions: false,
knock_restricted_join_rule: true,
enforce_int_power_levels: true,
msc3931_push_features: &[],
msc3757_enabled: true,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
};
/// MSC3757 (Restricting who can overwrite a state event) based on room version "10".
pub const MSC3757V10: RoomVersion = RoomVersion {
identifier: "org.matrix.msc3757.10",
disposition: RoomDisposition::UNSTABLE,
msc3757_enabled: true,
..Self::V10
};
const ROOM_VERSION_V11: RoomVersion = RoomVersion {
identifier: "11",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V4_PLUS,
state_res: StateResolutionVersions::V2,
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: true, // Used by MSC3820
updated_redaction_rules: true, // Used by MSC3820
restricted_join_rule: true,
restricted_join_rule_fix: true,
knock_join_rule: true,
msc3389_relation_redactions: false,
knock_restricted_join_rule: true,
enforce_int_power_levels: true,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: true, // Changed from v10
msc4242_state_dags: false,
};
pub const V11: RoomVersion = RoomVersion {
identifier: "11",
implicit_room_creator: true, // Used by MSC3820
updated_redaction_rules: true, // Used by MSC3820
strict_event_byte_limits_room_versions: true,
..Self::V10
};
/// MSC3757 (Restricting who can overwrite a state event) based on room version "11".
const ROOM_VERSION_MSC3757V11: RoomVersion = RoomVersion {
identifier: "org.matrix.msc3757.11",
disposition: RoomDisposition::UNSTABLE,
event_format: EventFormatVersions::ROOM_V4_PLUS,
state_res: StateResolutionVersions::V2,
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: true, // Used by MSC3820
updated_redaction_rules: true, // Used by MSC3820
restricted_join_rule: true,
restricted_join_rule_fix: true,
knock_join_rule: true,
msc3389_relation_redactions: false,
knock_restricted_join_rule: true,
enforce_int_power_levels: true,
msc3931_push_features: &[],
msc3757_enabled: true,
msc4289_creator_power_enabled: false,
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: true,
msc4242_state_dags: false,
};
/// MSC3757 (Restricting who can overwrite a state event) based on room version "11".
pub const MSC3757V11: RoomVersion = RoomVersion {
identifier: "org.matrix.msc3757.11",
disposition: RoomDisposition::UNSTABLE,
msc3757_enabled: true,
..Self::V11
};
const ROOM_VERSION_HYDRA_V11: RoomVersion = RoomVersion {
identifier: "org.matrix.hydra.11",
disposition: RoomDisposition::UNSTABLE,
event_format: EventFormatVersions::ROOM_V11_HYDRA_PLUS,
state_res: StateResolutionVersions::V2_1, // Changed from v11
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: true, // Used by MSC3820
updated_redaction_rules: true, // Used by MSC3820
restricted_join_rule: true,
restricted_join_rule_fix: true,
knock_join_rule: true,
msc3389_relation_redactions: false,
knock_restricted_join_rule: true,
enforce_int_power_levels: true,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: true, // Changed from v11
msc4291_room_ids_as_hashes: true, // Changed from v11
strict_event_byte_limits_room_versions: true,
msc4242_state_dags: false,
};
pub const HYDRA_V11: RoomVersion = RoomVersion {
identifier: "org.matrix.hydra.11",
disposition: RoomDisposition::UNSTABLE,
event_format: EventFormatVersions::ROOM_V11_HYDRA_PLUS,
state_res: StateResolutionVersions::V2_1,
msc4289_creator_power_enabled: true,
msc4291_room_ids_as_hashes: true,
..Self::V11
};
const ROOM_VERSION_V12: RoomVersion = RoomVersion {
identifier: "12",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V11_HYDRA_PLUS,
state_res: StateResolutionVersions::V2_1, // Changed from v11
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: true, // Used by MSC3820
updated_redaction_rules: true, // Used by MSC3820
restricted_join_rule: true,
restricted_join_rule_fix: true,
knock_join_rule: true,
msc3389_relation_redactions: false,
knock_restricted_join_rule: true,
enforce_int_power_levels: true,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: true, // Changed from v11
msc4291_room_ids_as_hashes: true, // Changed from v11
strict_event_byte_limits_room_versions: true,
msc4242_state_dags: false,
};
pub const V12: RoomVersion = RoomVersion {
identifier: "12",
disposition: RoomDisposition::STABLE,
event_format: EventFormatVersions::ROOM_V11_HYDRA_PLUS,
state_res: StateResolutionVersions::V2_1,
msc4289_creator_power_enabled: true,
msc4291_room_ids_as_hashes: true,
..Self::V11
};
const ROOM_VERSION_MSC4242V12: RoomVersion = RoomVersion {
identifier: "org.matrix.msc4242.12",
disposition: RoomDisposition::UNSTABLE,
event_format: EventFormatVersions::ROOM_VMSC4242,
state_res: StateResolutionVersions::V2_1,
enforce_key_validity: true,
special_case_aliases_auth: false,
strict_canonicaljson: true,
limit_notifications_power_levels: true,
implicit_room_creator: true,
updated_redaction_rules: true,
restricted_join_rule: true,
restricted_join_rule_fix: true,
knock_join_rule: true,
msc3389_relation_redactions: false,
knock_restricted_join_rule: true,
enforce_int_power_levels: true,
msc3931_push_features: &[],
msc3757_enabled: false,
msc4289_creator_power_enabled: true,
msc4291_room_ids_as_hashes: true,
strict_event_byte_limits_room_versions: true,
msc4242_state_dags: true,
};
pub const MSC4242V12: RoomVersion = RoomVersion {
identifier: "org.matrix.msc4242.12",
disposition: RoomDisposition::UNSTABLE,
event_format: EventFormatVersions::ROOM_VMSC4242,
msc4242_state_dags: true,
..Self::V12
};
}
impl Display for RoomVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.identifier.fmt(f)
}
}
impl FromStr for &'static RoomVersion {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"1" => Ok(&RoomVersion::V1),
"2" => Ok(&RoomVersion::V2),
"3" => Ok(&RoomVersion::V3),
"4" => Ok(&RoomVersion::V4),
"5" => Ok(&RoomVersion::V5),
"6" => Ok(&RoomVersion::V6),
"7" => Ok(&RoomVersion::V7),
"8" => Ok(&RoomVersion::V8),
"9" => Ok(&RoomVersion::V9),
"10" => Ok(&RoomVersion::V10),
"11" => Ok(&RoomVersion::V11),
"12" => Ok(&RoomVersion::V12),
"org.matrix.msc1767.10" => Ok(&RoomVersion::MSC1767V10),
"org.matrix.msc3389.10" => Ok(&RoomVersion::MSC3389V10),
"org.matrix.msc3757.10" => Ok(&RoomVersion::MSC3757V10),
"org.matrix.msc3757.11" => Ok(&RoomVersion::MSC3757V11),
"org.matrix.hydra.11" => Ok(&RoomVersion::HYDRA_V11),
"org.matrix.msc4242.12" => Ok(&RoomVersion::MSC4242V12),
_ => Err(anyhow::anyhow!("Unknown room version: {}", s)),
}
}
}
impl<'py> IntoPyObject<'py> for &RoomVersion {
type Target = RoomVersion;
type Output = Bound<'py, RoomVersion>;
type Error = PyErr;
fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
self.clone().into_pyobject(py)
}
}
/// Helper class for managing the known room versions, and providing dict-like
/// access to them for Python.
@@ -622,7 +371,7 @@ const ROOM_VERSION_MSC4242V12: RoomVersion = RoomVersion {
/// Note: room versions can be added to this mapping at startup (allowing
/// support for experimental room versions to be behind experimental feature
/// flags).
#[pyclass(frozen, mapping)]
#[pyclass(frozen, mapping, skip_from_py_object)]
#[derive(Clone)]
pub struct KnownRoomVersionsMapping {
// Note we use a Vec here to ensure that the order of keys is
@@ -637,19 +386,22 @@ pub struct KnownRoomVersionsMapping {
impl KnownRoomVersionsMapping {
/// Add a new room version to the mapping, indicating that this instance
/// supports it.
fn add_room_version(&self, version: RoomVersion) -> PyResult<()> {
fn add_room_version(&self, version: Bound<'_, RoomVersion>) -> PyResult<()> {
let mut versions = self
.versions
.write()
.map_err(|_| PyRuntimeError::new_err("KnownRoomVersionsMapping lock poisoned"))?;
if versions.iter().any(|v| v.identifier == version.identifier) {
if versions
.iter()
.any(|v| v.identifier == version.get().identifier)
{
// We already have this room version, so we don't add it again (as
// otherwise we'd end up with duplicates).
return Ok(());
}
versions.push(version);
versions.push(version.get().clone());
Ok(())
}
@@ -663,7 +415,7 @@ impl KnownRoomVersionsMapping {
versions
.iter()
.find(|v| v.identifier == key)
.copied()
.cloned()
.ok_or_else(|| PyKeyError::new_err(key.to_string()))
}
@@ -695,7 +447,7 @@ impl KnownRoomVersionsMapping {
// like a Set *and* has a stable ordering). We don't depend on this, so
// for simplicity we just return a list of the items.
let versions = self.versions.read().unwrap();
Ok(versions.iter().map(|v| (v.identifier, *v)).collect())
Ok(versions.iter().map(|v| (v.identifier, v.clone())).collect())
}
#[pyo3(signature = (key, default=None))]
@@ -712,8 +464,8 @@ impl KnownRoomVersionsMapping {
};
let versions = self.versions.read().unwrap();
if let Some(version) = versions.iter().find(|v| v.identifier == key).copied() {
return Ok(Some(version.into_bound_py_any(py)?));
if let Some(version) = versions.iter().find(|v| v.identifier == key) {
return Ok(Some(version.clone().into_bound_py_any(py)?));
}
Ok(default)
@@ -754,21 +506,21 @@ impl<'py> IntoPyObject<'py> for &KnownRoomVersionsMapping {
/// support all experimental room versions.
static KNOWN_ROOM_VERSIONS: LazyLock<KnownRoomVersionsMapping> = LazyLock::new(|| {
let vec = vec![
ROOM_VERSION_V1,
ROOM_VERSION_V2,
ROOM_VERSION_V3,
ROOM_VERSION_V4,
ROOM_VERSION_V5,
ROOM_VERSION_V6,
ROOM_VERSION_V7,
ROOM_VERSION_V8,
ROOM_VERSION_V9,
ROOM_VERSION_V10,
ROOM_VERSION_V11,
ROOM_VERSION_V12,
ROOM_VERSION_MSC3757V10,
ROOM_VERSION_MSC3757V11,
ROOM_VERSION_HYDRA_V11,
RoomVersion::V1,
RoomVersion::V2,
RoomVersion::V3,
RoomVersion::V4,
RoomVersion::V5,
RoomVersion::V6,
RoomVersion::V7,
RoomVersion::V8,
RoomVersion::V9,
RoomVersion::V10,
RoomVersion::V11,
RoomVersion::V12,
RoomVersion::MSC3757V10,
RoomVersion::MSC3757V11,
RoomVersion::HYDRA_V11,
];
KnownRoomVersionsMapping {
@@ -780,82 +532,82 @@ static KNOWN_ROOM_VERSIONS: LazyLock<KnownRoomVersionsMapping> = LazyLock::new(|
///
/// This should contain all room versions that we know about.
#[pyclass(frozen)]
pub struct RoomVersions {}
struct RoomVersions {}
#[pymethods]
#[allow(non_snake_case)]
impl RoomVersions {
#[classattr]
fn V1(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_V1.into_py_any(py)
RoomVersion::V1.into_py_any(py)
}
#[classattr]
fn V2(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_V2.into_py_any(py)
RoomVersion::V2.into_py_any(py)
}
#[classattr]
fn V3(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_V3.into_py_any(py)
RoomVersion::V3.into_py_any(py)
}
#[classattr]
fn V4(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_V4.into_py_any(py)
RoomVersion::V4.into_py_any(py)
}
#[classattr]
fn V5(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_V5.into_py_any(py)
RoomVersion::V5.into_py_any(py)
}
#[classattr]
fn V6(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_V6.into_py_any(py)
RoomVersion::V6.into_py_any(py)
}
#[classattr]
fn V7(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_V7.into_py_any(py)
RoomVersion::V7.into_py_any(py)
}
#[classattr]
fn V8(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_V8.into_py_any(py)
RoomVersion::V8.into_py_any(py)
}
#[classattr]
fn V9(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_V9.into_py_any(py)
RoomVersion::V9.into_py_any(py)
}
#[classattr]
fn V10(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_V10.into_py_any(py)
RoomVersion::V10.into_py_any(py)
}
#[classattr]
fn MSC1767v10(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_MSC1767V10.into_py_any(py)
RoomVersion::MSC1767V10.into_py_any(py)
}
#[classattr]
fn MSC3389v10(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_MSC3389V10.into_py_any(py)
RoomVersion::MSC3389V10.into_py_any(py)
}
#[classattr]
fn MSC3757v10(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_MSC3757V10.into_py_any(py)
RoomVersion::MSC3757V10.into_py_any(py)
}
#[classattr]
fn V11(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_V11.into_py_any(py)
RoomVersion::V11.into_py_any(py)
}
#[classattr]
fn MSC3757v11(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_MSC3757V11.into_py_any(py)
RoomVersion::MSC3757V11.into_py_any(py)
}
#[classattr]
fn HydraV11(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_HYDRA_V11.into_py_any(py)
RoomVersion::HYDRA_V11.into_py_any(py)
}
#[classattr]
fn V12(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_V12.into_py_any(py)
RoomVersion::V12.into_py_any(py)
}
#[classattr]
fn MSC4242v12(py: Python<'_>) -> PyResult<Py<PyAny>> {
ROOM_VERSION_MSC4242V12.into_py_any(py)
RoomVersion::MSC4242V12.into_py_any(py)
}
}
+10 -6
View File
@@ -1,5 +1,5 @@
$schema: https://element-hq.github.io/synapse/latest/schema/v1/meta.schema.json
$id: https://element-hq.github.io/synapse/schema/synapse/v1.152/synapse-config.schema.json
$id: https://element-hq.github.io/synapse/schema/synapse/v1.153/synapse-config.schema.json
type: object
properties:
modules:
@@ -4620,11 +4620,15 @@ properties:
type: boolean
description: >-
Use this setting to keep a user's profile fields in sync with
information from the identity provider. Currently only syncing the
displayname is supported. Fields are checked on every SSO login, and
are updated if necessary. Note that enabling this option will override
user profile information, regardless of whether users have opted-out
of syncing that information when first signing in.
information from the identity provider. Fields are checked on every
SSO login, and are updated if necessary. Note that enabling this
option will override user profile information, regardless of whether
users have opted-out of syncing that information when first signing
in.
Fields that will be synced:
* displayname
* picture - only if Synapse media repository is running in the main
process (i.e. not workerized) and media is stored locally
default: false
examples:
- client_whitelist:
+8 -1
View File
@@ -65,7 +65,8 @@ try:
except ImportError:
pass
# Teach canonicaljson how to serialise immutabledicts.
# Teach canonicaljson how to serialise immutabledicts and JsonObjects.
try:
from canonicaljson import register_preserialisation_callback
from immutabledict import immutabledict
@@ -79,6 +80,12 @@ try:
return dict(d)
register_preserialisation_callback(immutabledict, _immutabledict_cb)
# Teach canonicaljson how to serialise JsonObjects, which is just to
# convert them to dicts.
from synapse.synapse_rust.events import JsonObject # noqa: E402
register_preserialisation_callback(JsonObject, lambda obj: dict(obj))
except ImportError:
pass
+4
View File
@@ -616,3 +616,7 @@ class ExperimentalConfig(Config):
# Tracked in: https://github.com/element-hq/synapse/issues/19691
# Note that this is only applicable to legacy auth, not MAS integration (OAuth 2.0).
self.msc4450_enabled: bool = experimental.get("msc4450_enabled", False)
# MSC4455: Preview URL capability
# Tracked in: https://github.com/element-hq/synapse/issues/19719
self.msc4452_enabled: bool = experimental.get("msc4452_enabled", False)
+2 -1
View File
@@ -242,7 +242,8 @@ class ContentRepositoryConfig(Config):
self.thumbnail_requirements = parse_thumbnail_requirements(
config.get("thumbnail_sizes", DEFAULT_THUMBNAIL_SIZES)
)
self.url_preview_enabled = config.get("url_preview_enabled", False)
self.url_preview_enabled = bool(config.get("url_preview_enabled", False))
if self.url_preview_enabled:
check_requirements("url-preview")
+1 -3
View File
@@ -236,9 +236,7 @@ def event_needs_resigning(
if sender.domain != server_name:
return False
want_key_id = verify_key.alg + ":" + verify_key.version
signed_with_current_key_id = ev.signatures.get(server_name, {}).get(
want_key_id, None
)
signed_with_current_key_id = ev.signatures.get_signature(server_name, want_key_id)
if signed_with_current_key_id:
return False
+11 -1
View File
@@ -120,8 +120,18 @@ class VerifyJsonRequest:
) -> "VerifyJsonRequest":
"""Create a VerifyJsonRequest to verify all signatures on an event
object for the given server.
Raises immediately if the event doesn't have any signatures from the
given server.
"""
key_ids = list(event.signatures.get(server_name, []))
if server_name not in event.signatures:
raise SynapseError(
400,
f"Not signed by {server_name}",
Codes.UNAUTHORIZED,
)
key_ids = list(event.signatures[server_name])
return VerifyJsonRequest(
server_name,
# We defer creating the redacted json object, as it uses a lot more
+8 -8
View File
@@ -61,7 +61,8 @@ from synapse.api.room_versions import (
EventFormatVersions,
RoomVersion,
)
from synapse.events import FrozenEventVMSC4242, is_creator
from synapse.events import is_creator
from synapse.events.py_protocol import supports_msc4242_state_dag
from synapse.state import CREATE_KEY
from synapse.storage.databases.main.events_worker import EventRedactBehaviour
from synapse.types import (
@@ -128,7 +129,7 @@ def validate_event_for_room_version(event: "EventBase") -> None:
)
# Check the sender's domain has signed the event
if not event.signatures.get(sender_domain):
if sender_domain not in event.signatures:
# We allow invites via 3pid to have a sender from a different
# HS, as the sender must match the sender of the original
# 3pid invite. This is checked further down with the
@@ -141,7 +142,7 @@ def validate_event_for_room_version(event: "EventBase") -> None:
event_id_domain = get_domain_from_id(event.event_id)
# Check the origin domain has signed the event
if not event.signatures.get(event_id_domain):
if event_id_domain not in event.signatures:
raise AuthError(403, "Event not signed by sending server")
is_invite_via_allow_rule = (
@@ -154,7 +155,7 @@ def validate_event_for_room_version(event: "EventBase") -> None:
authoriser_domain = get_domain_from_id(
event.content[EventContentFields.AUTHORISING_USER]
)
if not event.signatures.get(authoriser_domain):
if authoriser_domain not in event.signatures:
raise AuthError(403, "Event not signed by authorising server")
@@ -187,8 +188,8 @@ async def check_state_independent_auth_rules(
return
# State DAGs 2. Considering the event's prev_state_events:
if event.room_version.msc4242_state_dags:
prev_state_events_ids = set(cast(FrozenEventVMSC4242, event).prev_state_events)
if supports_msc4242_state_dag(event):
prev_state_events_ids = set(event.prev_state_events)
# Fetch all of the `prev_state_events`
prev_state_events = {}
# Try to load the `prev_state_events` from `batched_auth_events` initially as
@@ -515,8 +516,7 @@ def _check_create(event: "EventBase") -> None:
raise AuthError(403, "Create event has prev events")
# State DAGs 1.2 If it has any prev_state_events, reject.
if event.room_version.msc4242_state_dags:
assert isinstance(event, FrozenEventVMSC4242)
if supports_msc4242_state_dag(event):
if len(event.prev_state_events) > 0:
raise AuthError(403, "Create event has prev state events")
+59 -30
View File
@@ -44,8 +44,18 @@ from synapse.api.constants import (
StickyEvent,
)
from synapse.api.room_versions import EventFormatVersions, RoomVersion, RoomVersions
from synapse.synapse_rust.events import EventInternalMetadata
from synapse.types import JsonDict, StateKey, StrCollection
from synapse.synapse_rust.events import (
EventInternalMetadata,
JsonObject,
Signatures,
Unsigned,
)
from synapse.types import (
JsonDict,
JsonMapping,
StateKey,
StrCollection,
)
from synapse.util.caches import intern_dict
from synapse.util.duration import Duration
from synapse.util.frozenutils import freeze
@@ -202,17 +212,29 @@ class EventBase(metaclass=abc.ABCMeta):
):
assert room_version.event_format == self.format_version
if "content" in event_dict:
event_dict["content"] = JsonObject(event_dict["content"])
# We intern these strings because they turn up a lot (especially when
# caching).
event_dict = intern_dict(event_dict)
if USE_FROZEN_DICTS:
frozen_dict = freeze(event_dict)
else:
frozen_dict = event_dict
self.room_version = room_version
self.signatures = signatures
self.unsigned = unsigned
self.signatures = Signatures(signatures)
self.unsigned = Unsigned(unsigned)
self.rejected_reason = rejected_reason
self._dict = event_dict
self._dict = frozen_dict
self.internal_metadata = EventInternalMetadata(internal_metadata_dict)
depth: DictProperty[int] = DictProperty("depth")
content: DictProperty[JsonDict] = DictProperty("content")
content: DictProperty[JsonMapping] = DictProperty("content")
hashes: DictProperty[dict[str, str]] = DictProperty("hashes")
origin_server_ts: DictProperty[int] = DictProperty("origin_server_ts")
sender: DictProperty[str] = DictProperty("sender")
@@ -254,8 +276,33 @@ class EventBase(metaclass=abc.ABCMeta):
return self._dict.get("state_key")
def get_dict(self) -> JsonDict:
"""Convert the event to a dictionary suitable for serialisation."""
d = dict(self._dict)
d.update({"signatures": self.signatures, "unsigned": dict(self.unsigned)})
if "content" in d:
# Convert the content (which is a JsonObject) back to a dict. Json
# serialization should handle JsonObjects fine, but for sanities
# sake we want `get_dict()` and `get_pdu_json()` to return plain
# dicts.
d["content"] = dict(self.content)
d.update(
{
"signatures": self.signatures.as_dict(),
"unsigned": self.unsigned.for_event(),
}
)
return d
def get_dict_for_persistence(self) -> JsonDict:
"""Convert the event to a dictionary suitable for persistence."""
d = dict(self._dict)
d.update(
{
"signatures": self.signatures.as_dict(),
"unsigned": self.unsigned.for_persistence(),
}
)
return d
@@ -395,21 +442,12 @@ class FrozenEvent(EventBase):
for name, sigs in event_dict.pop("signatures", {}).items()
}
unsigned = dict(event_dict.pop("unsigned", {}))
# We intern these strings because they turn up a lot (especially when
# caching).
event_dict = intern_dict(event_dict)
if USE_FROZEN_DICTS:
frozen_dict = freeze(event_dict)
else:
frozen_dict = event_dict
unsigned = event_dict.pop("unsigned", {})
self._event_id = event_dict["event_id"]
super().__init__(
frozen_dict,
event_dict,
room_version=room_version,
signatures=signatures,
unsigned=unsigned,
@@ -449,21 +487,12 @@ class FrozenEventV2(EventBase):
assert "event_id" not in event_dict
unsigned = dict(event_dict.pop("unsigned", {}))
# We intern these strings because they turn up a lot (especially when
# caching).
event_dict = intern_dict(event_dict)
if USE_FROZEN_DICTS:
frozen_dict = freeze(event_dict)
else:
frozen_dict = event_dict
unsigned = event_dict.pop("unsigned", {})
self._event_id: str | None = None
super().__init__(
frozen_dict,
event_dict,
room_version=room_version,
signatures=signatures,
unsigned=unsigned,
@@ -589,7 +618,7 @@ class FrozenEventVMSC4242(FrozenEventV4):
"""FrozenEventVMSC4242, which differs from FrozenEventV4 only in the addition of prev_state_events"""
format_version = EventFormatVersions.ROOM_VMSC4242
prev_state_events: DictProperty[list[str]] = DictProperty("prev_state_events")
prev_state_events: DictProperty[StrCollection] = DictProperty("prev_state_events")
def __init__(
self,
+1 -1
View File
@@ -132,7 +132,7 @@ class EventBuilder:
prev_event_ids: list[str],
auth_event_ids: list[str] | None,
depth: int | None = None,
prev_state_events: list[str] | None = None,
prev_state_events: StrCollection | None = None,
) -> EventBase:
"""Transform into a fully signed and hashed event
+88
View File
@@ -0,0 +1,88 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
#
"""Type-narrowing helpers for `EventBase`.
`EventBase` subclasses are split by room version (e.g. `FrozenEventV4`,
`FrozenEventVMSC4242`), and certain attributes — such as `prev_state_events`
on MSC4242 events — only exist on a subset of those subclasses. Branching on
`room_version.<flag>` at runtime tells us *which* subclass we have, but the
type checker can't see that link without an `isinstance` cast at every call
site.
This module provides "marker" subclasses of `EventBase` (`MSC4242Event`,
etc.) paired with `TypeIs`-returning predicates (`supports_msc4242_state_dag`,
etc.). A single call to the predicate both performs the room-version check
and narrows the type — replacing the `if room_version.foo: assert
isinstance(event, FrozenEventV...)` idiom.
The marker classes are *type-only*: their metaclass raises on `isinstance`
so they cannot be misused as real runtime classes. Add new markers and
predicates here when a new room-version feature gates access to additional
attributes.
"""
import abc
from typing import (
TYPE_CHECKING,
Sequence,
)
from typing_extensions import TypeIs
from synapse.events import EventBase
if TYPE_CHECKING:
from synapse.events.snapshot import EventContext, EventPersistencePair
class _DisableIsInstance(abc.ABCMeta):
"""Metaclass which disables isinstance checks on classes which use it, by
making isinstance() raise NotImplementedError.
This is used to prevent isinstance checks on EventProtocol, which is a
helper class used for type narrowing of EventBase objects, but which should
not be used for isinstance checks itself (as its purely type annotation
rather than a real class).
"""
def __instancecheck__(cls, instance: object) -> bool:
raise NotImplementedError("Instance cannot be used.")
class EventProtocol(EventBase, metaclass=_DisableIsInstance):
"""Helper subclass that allows type narrowing for `EventBase` objects."""
class MSC4242Event(EventProtocol):
"""Marker protocol for events in MSC4242 rooms. This allows us to narrow the
type of events."""
prev_state_events: list[str]
def supports_msc4242_state_dag(event: EventBase) -> TypeIs[MSC4242Event]:
"""Returns true if the given event is in a room that supports state DAGs
(MSC4242)"""
return event.room_version.msc4242_state_dags
def all_supports_msc4242_state_dag(
obj: Sequence["EventPersistencePair"],
) -> TypeIs[Sequence[tuple[MSC4242Event, "EventContext"]]]:
"""Returns true if the given sequence of events are all in a room that
supports state DAGs (MSC4242)"""
return all(event.room_version.msc4242_state_dags for event, _ in obj)
+3 -2
View File
@@ -47,6 +47,7 @@ from synapse.api.constants import (
from synapse.api.errors import Codes, SynapseError
from synapse.api.room_versions import RoomVersion
from synapse.logging.opentracing import SynapseTags, set_tag, trace
from synapse.synapse_rust.events import Unsigned
from synapse.types import JsonDict, Requester
from . import EventBase, FrozenEventV2, StrippedStateEvent, make_event_from_dict
@@ -987,7 +988,7 @@ def validate_canonicaljson(value: Any) -> None:
def maybe_upsert_event_field(
event: EventBase, container: JsonDict, key: str, value: object
event: EventBase, container: Unsigned, key: str, value: object
) -> bool:
"""Upsert an event field, but only if this doesn't make the event too large.
@@ -1031,7 +1032,7 @@ def strip_event(event: EventBase) -> JsonDict:
return {
"type": event.type,
"state_key": event.state_key,
"content": event.content,
"content": dict(event.content),
"sender": event.sender,
}
+54 -31
View File
@@ -22,7 +22,6 @@ import collections.abc
from typing import cast
import jsonschema
from pydantic import Field, StrictBool, StrictStr
from synapse.api.constants import (
MAX_ALIAS_LENGTH,
@@ -40,10 +39,8 @@ from synapse.events.utils import (
CANONICALJSON_MIN_INT,
validate_canonicaljson,
)
from synapse.http.servlet import validate_json_object
from synapse.storage.controllers.state import server_acl_evaluator_from_event
from synapse.types import EventID, JsonDict, RoomID, StrCollection, UserID
from synapse.types.rest import RequestBodyModel
from synapse.types import EventID, JsonDict, JsonMapping, RoomID, StrCollection, UserID
class EventValidator:
@@ -116,29 +113,18 @@ class EventValidator:
cls=POWER_LEVELS_VALIDATOR,
)
except jsonschema.ValidationError as e:
if e.path:
# example: "users_default": '0' is not of type 'integer'
# cast safety: path entries can be integers, if we fail to validate
# items in an array. However, the POWER_LEVELS_SCHEMA doesn't expect
# to see any arrays.
message = (
'"' + cast(str, e.path[-1]) + '": ' + e.message # noqa: B306
)
# jsonschema.ValidationError.message is a valid attribute
else:
# example: '0' is not of type 'integer'
message = e.message # noqa: B306
# jsonschema.ValidationError.message is a valid attribute
raise SynapseError(
code=400,
msg=message,
errcode=Codes.BAD_JSON,
)
raise _validation_error_to_api_error(e)
# If the event contains a mentions key, validate it.
if EventContentFields.MENTIONS in event.content:
validate_json_object(event.content[EventContentFields.MENTIONS], Mentions)
try:
jsonschema.validate(
instance=event.content[EventContentFields.MENTIONS],
schema=MENTIONS_SCHEMA,
cls=MENTIONS_VALIDATOR,
)
except jsonschema.ValidationError as e:
raise _validation_error_to_api_error(e)
def _validate_retention(self, event: EventBase) -> None:
"""Checks that an event that defines the retention policy for a room respects the
@@ -245,7 +231,7 @@ class EventValidator:
self._ensure_state_event(event)
def _ensure_strings(self, d: JsonDict, keys: StrCollection) -> None:
def _ensure_strings(self, d: JsonMapping, keys: StrCollection) -> None:
for s in keys:
if s not in d:
raise SynapseError(400, "'%s' not in content" % (s,))
@@ -284,10 +270,16 @@ POWER_LEVELS_SCHEMA = {
},
}
class Mentions(RequestBodyModel):
user_ids: list[StrictStr] = Field(default_factory=list)
room: StrictBool = False
MENTIONS_SCHEMA = {
"type": "object",
"properties": {
"user_ids": {
"type": "array",
"items": {"type": "string"},
},
"room": {"type": "boolean"},
},
}
# This could return something newer than Draft 7, but that's the current "latest"
@@ -295,14 +287,45 @@ class Mentions(RequestBodyModel):
def _create_validator(schema: JsonDict) -> type[jsonschema.Draft7Validator]:
validator = jsonschema.validators.validator_for(schema)
# by default jsonschema does not consider a immutabledict to be an object so
# we need to use a custom type checker
# by default jsonschema does not consider a immutabledict to be an object, or
# a tuple to be an array (frozenutils freezes lists to tuples), so we need a
# custom type checker for both.
# https://python-jsonschema.readthedocs.io/en/stable/validate/?highlight=object#validating-with-additional-types
type_checker = validator.TYPE_CHECKER.redefine(
"object", lambda checker, thing: isinstance(thing, collections.abc.Mapping)
).redefine(
"array",
lambda checker, thing: isinstance(thing, collections.abc.Sequence),
)
return jsonschema.validators.extend(validator, type_checker=type_checker)
def _validation_error_to_api_error(err: jsonschema.ValidationError) -> SynapseError:
"""
Converts a JSONSchema `ValidationError` to a `SynapseError` that can be thrown
to give a Matrix API-compatible 400 Bad Request response with `M_BAD_JSON` code
and a descriptive error message.
"""
if err.path:
# example: "users_default": '0' is not of type 'integer'
# cast safety: path entries can be integers, if we fail to validate
# items in an array. However, the POWER_LEVELS_SCHEMA doesn't expect
# to see any arrays.
message = '"' + cast(str, err.path[-1]) + '": ' + err.message
# jsonschema.ValidationError.message is a valid attribute
else:
# example: '0' is not of type 'integer'
message = err.message
# jsonschema.ValidationError.message is a valid attribute
return SynapseError(
code=400,
msg=message,
errcode=Codes.BAD_JSON,
)
POWER_LEVELS_VALIDATOR = _create_validator(POWER_LEVELS_SCHEMA)
MENTIONS_VALIDATOR = _create_validator(MENTIONS_SCHEMA)
+29 -3
View File
@@ -307,20 +307,27 @@ def _is_invite_via_3pid(event: EventBase) -> bool:
def parse_events_from_pdu_json(
pdus_json: Sequence[JsonDict], room_version: RoomVersion
pdus_json: Sequence[JsonDict],
room_version: RoomVersion,
received_time: int | None = None,
) -> list[EventBase]:
return [
event_from_pdu_json(pdu_json, room_version)
event_from_pdu_json(pdu_json, room_version, received_time=received_time)
for pdu_json in filter_pdus_for_valid_depth(pdus_json)
]
def event_from_pdu_json(pdu_json: JsonDict, room_version: RoomVersion) -> EventBase:
def event_from_pdu_json(
pdu_json: JsonDict, room_version: RoomVersion, received_time: int | None = None
) -> EventBase:
"""Construct an EventBase from an event json received over federation
Args:
pdu_json: pdu as received over federation
room_version: The version of the room this event belongs to
received_time: timestamp in ms that the event was received at. If
`None` then any `age` field in the `unsigned` block will be
dropped.
Raises:
SynapseError: if the pdu is missing required fields or is otherwise
@@ -333,6 +340,25 @@ def event_from_pdu_json(pdu_json: JsonDict, room_version: RoomVersion) -> EventB
if "unsigned" in pdu_json:
_strip_unsigned_values(pdu_json)
# Handle the `age` field, which is sent by some servers as part of the
# `unsigned` block. We convert this into an `age_ts` field, which is
# what Synapse uses internally. We also remove the `age` field to avoid
# confusion.
#
# c.f. https://github.com/matrix-org/synapse/issues/8429
unsigned = pdu_json["unsigned"]
age = unsigned.pop("age", None)
# We check that the `age` is actually an int before using it below. We
# don't error here as the `age` a) doesn't affect the validity of the
# event, and b) is best effort anyway.
if not isinstance(age, int):
age = None
unsigned.pop("age_ts", None)
if received_time is not None and age is not None:
unsigned["age_ts"] = received_time - int(age)
depth = pdu_json["depth"]
if type(depth) is not int: # noqa: E721
raise SynapseError(400, "Depth %r not an intger" % (depth,), Codes.BAD_JSON)
+6 -1
View File
@@ -1574,10 +1574,15 @@ class FederationClient(FederationBase):
min_depth=min_depth,
timeout=timeout,
)
received_time = self._clock.time_msec()
room_version = await self.store.get_room_version(room_id)
events = parse_events_from_pdu_json(content.get("events", []), room_version)
events = parse_events_from_pdu_json(
content.get("events", []),
room_version,
received_time=received_time,
)
signed_events = await self._check_sigs_and_hash_for_pulled_events_and_fetch(
destination, events, room_version=room_version
+6 -11
View File
@@ -451,16 +451,6 @@ class FederationServer(FederationBase):
newest_pdu_ts = 0
for p in transaction.pdus:
# FIXME (richardv): I don't think this works:
# https://github.com/matrix-org/synapse/issues/8429
if "unsigned" in p:
unsigned = p["unsigned"]
if "age" in unsigned:
p["age"] = unsigned["age"]
if "age" in p:
p["age_ts"] = request_time - int(p["age"])
del p["age"]
# We try and pull out an event ID so that if later checks fail we
# can log something sensible. We don't mandate an event ID here in
# case future event formats get rid of the key.
@@ -488,10 +478,15 @@ class FederationServer(FederationBase):
continue
try:
event = event_from_pdu_json(p, room_version)
event = event_from_pdu_json(p, room_version, received_time=request_time)
except SynapseError as e:
logger.info("Ignoring PDU for failing to deserialize: %s", e)
continue
except Exception as e:
# We catch all exceptions here as we don't want a single bad
# event to cause us to fail the whole transaction.
logger.exception("Error deserializing PDU: %s", e)
continue
pdus_by_room.setdefault(room_id, []).append(event)
+3 -3
View File
@@ -32,7 +32,8 @@ import attr
from synapse.api.constants import Direction, EventTypes, Membership
from synapse.api.errors import SynapseError
from synapse.events import EventBase, FrozenEventVMSC4242
from synapse.events import EventBase
from synapse.events.py_protocol import supports_msc4242_state_dag
from synapse.events.utils import FilteredEvent
from synapse.types import (
JsonMapping,
@@ -495,8 +496,7 @@ class AdminHandler:
try:
prev_state_events = None
if room_version.msc4242_state_dags:
assert isinstance(event, FrozenEventVMSC4242)
if supports_msc4242_state_dag(event):
prev_state_events = event.prev_state_events
assert prev_state_events is not None, (
"Parent event of redaction has no `prev_state_events` which should be impossible as `prev_state_events` is a required field in MSC4242 rooms"
+31 -8
View File
@@ -105,6 +105,12 @@ backfill_processing_before_timer = Histogram(
)
NUMBER_OF_EVENTS_TO_BACKFILL = 100
"""
The number of events we try to backfill from other servers in a single request.
"""
# TODO: We can refactor this away now that there is only one backfill point again
class _BackfillPointType(Enum):
# a regular backwards extremity (ie, an event which we don't yet have, but which
@@ -255,7 +261,9 @@ class FederationHandler:
_BackfillPoint(event_id, depth, _BackfillPointType.BACKWARDS_EXTREMITY)
for event_id, depth in await self.store.get_backfill_points_in_room(
room_id=room_id,
current_depth=current_depth,
# Per the docstring, it's best to pad the `current_depth` by the
# number of messages you plan to backfill from these points.
nearby_depth=current_depth + NUMBER_OF_EVENTS_TO_BACKFILL,
# We only need to end up with 5 extremities combined with the
# insertion event extremities to make the `/backfill` request
# but fetch an order of magnitude more to make sure there is
@@ -267,11 +275,22 @@ class FederationHandler:
)
]
# we now have a list of potential places to backpaginate from. We prefer to
# start with the most recent (ie, max depth), so let's sort the list.
# we now have a list of potential places to backpaginate from. Figure out which
# ones we should prefer, so let's sort the list.
sorted_backfill_points: list[_BackfillPoint] = sorted(
backwards_extremities,
key=lambda e: -int(e.depth),
key=lambda e: (
# Prefer backfill points that are closer to the `current_depth`
# (absolute distance)
abs(current_depth - e.depth),
# For the tie-break, we care about events that are actually in the past
# as they're more likely to reveal history that we can return (something
# absolutely in the past is better than something can potentially extend
# into the past).
#
# This sorts ascending so 0 sorts before 1
0 if current_depth >= e.depth else 1,
),
)
logger.debug(
@@ -292,19 +311,20 @@ class FederationHandler:
str(len(sorted_backfill_points)),
)
# If we have no backfill points lower than the `current_depth` then either we
# If we have no backfill points lower than the `nearby_depth` then either we
# can a) bail or b) still attempt to backfill. We opt to try backfilling anyway
# just in case we do get relevant events. This is good for eventual consistency
# sake but we don't need to block the client for something that is just as
# likely not to return anything relevant so we backfill in the background. The
# only way, this could return something relevant is if we discover a new branch
# of history that extends all the way back to where we are currently paginating
# and it's within the 100 events that are returned from `/backfill`.
# and it's within the `NUMBER_OF_EVENTS_TO_BACKFILL` events that are returned
# from `/backfill`.
if not sorted_backfill_points and current_depth != MAX_DEPTH:
# Check that we actually have later backfill points, if not just return.
have_later_backfill_points = await self.store.get_backfill_points_in_room(
room_id=room_id,
current_depth=MAX_DEPTH,
nearby_depth=MAX_DEPTH,
limit=1,
)
if not have_later_backfill_points:
@@ -464,7 +484,10 @@ class FederationHandler:
try:
await self._federation_event_handler.backfill(
dom, room_id, limit=100, extremities=extremities_to_request
dom,
room_id,
limit=NUMBER_OF_EVENTS_TO_BACKFILL,
extremities=extremities_to_request,
)
# If this succeeded then we probably already have the
# appropriate stuff.
+17 -7
View File
@@ -1166,9 +1166,10 @@ class FederationEventHandler:
return await self._state_handler.compute_event_context(event)
logger.info(
"Event %s is missing prev_events %s: calculating state for a "
"backwards extremity",
"_compute_event_context_with_maybe_missing_prevs(event_id=%s): Event in room %s is missing prev_events %s: "
"calculating state for a backwards extremity",
event_id,
room_id,
shortstr(missing_prevs),
)
# Calculate the state after each of the previous events, and
@@ -1186,16 +1187,21 @@ class FederationEventHandler:
# Ask the remote server for the states we don't
# know about
for p in missing_prevs:
logger.info("Requesting state after missing prev_event %s", p)
for missing_prev in missing_prevs:
logger.info(
"_compute_event_context_with_maybe_missing_prevs(event_id=%s): Requesting state from %s for missing prev_event %s",
event_id,
dest,
missing_prev,
)
with nested_logging_context(p):
with nested_logging_context(missing_prev):
# note that if any of the missing prevs share missing state or
# auth events, the requests to fetch those events are deduped
# by the get_pdu_cache in federation_client.
remote_state_map = (
await self._get_state_ids_after_missing_prev_event(
dest, room_id, p
dest, room_id, missing_prev
)
)
@@ -1225,7 +1231,11 @@ class FederationEventHandler:
except Exception as e:
logger.warning(
"Error attempting to resolve state at missing prev_events: %s", e
"_compute_event_context_with_maybe_missing_prevs(event_id=%s): Error attempting to resolve state from "
"%s for missing prev_events: %s",
event_id,
dest,
e,
)
raise FederationError(
"ERROR",
+10 -10
View File
@@ -53,8 +53,9 @@ from synapse.api.errors import (
from synapse.api.room_versions import KNOWN_ROOM_VERSIONS
from synapse.api.urls import ConsentURIBuilder
from synapse.event_auth import validate_event_for_room_version
from synapse.events import EventBase, FrozenEventVMSC4242, relation_from_event
from synapse.events import EventBase, relation_from_event
from synapse.events.builder import EventBuilder
from synapse.events.py_protocol import supports_msc4242_state_dag
from synapse.events.snapshot import (
EventContext,
EventPersistencePair,
@@ -79,6 +80,7 @@ from synapse.types import (
Requester,
RoomAlias,
StateMap,
StrCollection,
StreamToken,
UserID,
create_requester,
@@ -589,7 +591,7 @@ class EventCreationHandler:
state_map: StateMap[str] | None = None,
for_batch: bool = False,
current_state_group: int | None = None,
prev_state_events: list[str] | None = None,
prev_state_events: StrCollection | None = None,
delay_id: str | None = None,
) -> tuple[EventBase, UnpersistedEventContextBase]:
"""
@@ -982,7 +984,7 @@ class EventCreationHandler:
ignore_shadow_ban: bool = False,
outlier: bool = False,
depth: int | None = None,
prev_state_events: list[str] | None = None,
prev_state_events: StrCollection | None = None,
delay_id: str | None = None,
) -> tuple[EventBase, int]:
"""
@@ -1127,7 +1129,7 @@ class EventCreationHandler:
ignore_shadow_ban: bool = False,
outlier: bool = False,
depth: int | None = None,
prev_state_events: list[str] | None = None,
prev_state_events: StrCollection | None = None,
delay_id: str | None = None,
) -> tuple[EventBase, int]:
room_id = event_dict["room_id"]
@@ -1253,7 +1255,7 @@ class EventCreationHandler:
state_map: StateMap[str] | None = None,
for_batch: bool = False,
current_state_group: int | None = None,
prev_state_events: list[str] | None = None,
prev_state_events: StrCollection | None = None,
) -> tuple[EventBase, UnpersistedEventContextBase]:
"""Create a new event for a local client. If bool for_batch is true, will
create an event using the prev_event_ids, and will create an event context for
@@ -1602,8 +1604,7 @@ class EventCreationHandler:
auth_event = event_id_to_event.get(event_id)
if auth_event:
batched_auth_events[event_id] = auth_event
if event.room_version.msc4242_state_dags:
assert isinstance(event, FrozenEventVMSC4242)
if supports_msc4242_state_dag(event):
# State DAG rooms will check that the prev_state_events are not rejected.
# To do that, we need to make sure we pass in the prev_state_events as
# batched_auth_events, else we will fail the event due to the
@@ -1872,7 +1873,7 @@ class EventCreationHandler:
state_entry = await self.state.resolve_state_groups_for_events(
event.room_id,
event_ids=event.prev_state_events
if isinstance(event, FrozenEventVMSC4242)
if supports_msc4242_state_dag(event)
else event.prev_event_ids(),
)
@@ -2089,10 +2090,9 @@ class EventCreationHandler:
returned_invite = await federation_handler.send_invite(
invitee.domain, event
)
event.unsigned.pop("room_state", None)
# TODO: Make sure the signatures actually are correct.
event.signatures.update(returned_invite.signatures)
event.signatures.update(returned_invite.signatures.as_dict())
if event.content["membership"] == Membership.KNOCK:
maybe_upsert_event_field(
+6 -5
View File
@@ -566,7 +566,7 @@ class PaginationHandler:
(
events,
next_key,
_,
limited,
) = await self.store.paginate_room_events_by_topological_ordering(
room_id=room_id,
from_key=from_token.room_key,
@@ -645,7 +645,7 @@ class PaginationHandler:
(
events,
next_key,
_,
limited,
) = await self.store.paginate_room_events_by_topological_ordering(
room_id=room_id,
from_key=from_token.room_key,
@@ -668,11 +668,12 @@ class PaginationHandler:
next_token = from_token.copy_and_replace(StreamKeyType.ROOM, next_key)
# if no events are returned from pagination, that implies
# we have reached the end of the available events.
# if no events are returned from pagination (this page is empty)
# and there aren't any more pages (not limited),
# that implies we have reached the end of the available events.
# In that case we do not return end, to tell the client
# there is no need for further queries.
if not events:
if not limited and not events:
return GetMessagesResult(
messages_chunk=[],
bundled_aggregations={},
+5 -4
View File
@@ -706,12 +706,12 @@ class RoomCreationHandler:
spam_check = await self._spam_checker_module_callbacks.user_may_create_room(
user_id,
{
"creation_content": creation_content,
"creation_content": dict(creation_content),
"initial_state": [
{
"type": state_key[0],
"state_key": state_key[1],
"content": event_content,
"content": dict(event_content),
}
for state_key, event_content in initial_state.items()
],
@@ -1437,7 +1437,7 @@ class RoomCreationHandler:
room_config: JsonDict,
invite_list: list[str],
initial_state: MutableStateMap,
creation_content: JsonDict,
creation_content: JsonMapping,
room_alias: RoomAlias | None = None,
power_level_content_override: JsonDict | None = None,
creator_join_profile: JsonDict | None = None,
@@ -1508,7 +1508,7 @@ class RoomCreationHandler:
async def create_event(
etype: str,
content: JsonDict,
content: JsonMapping,
for_batch: bool,
**kwargs: Any,
) -> tuple[EventBase, synapse.events.snapshot.UnpersistedEventContextBase]:
@@ -1561,6 +1561,7 @@ class RoomCreationHandler:
if creation_event_with_context is None:
# MSC2175 removes the creator field from the create event.
if not room_version.implicit_room_creator:
creation_content = dict(creation_content)
creation_content["creator"] = creator_id
creation_event, unpersisted_creation_context = await create_event(
EventTypes.Create, creation_content, False
+4 -5
View File
@@ -181,9 +181,10 @@ class RoomPolicyHandler:
async def _verify_policy_server_signature(
self, event: EventBase, policy_server: str, public_key: str
) -> bool:
# check the event is signed with this (via, public_key).
verify_json_req = VerifyJsonRequest.from_event(policy_server, event, 0)
try:
# check the event is signed with this (via, public_key).
verify_json_req = VerifyJsonRequest.from_event(policy_server, event, 0)
key_bytes = decode_base64(public_key)
verify_key = decode_verify_key_bytes(POLICY_SERVER_KEY_ID, key_bytes)
# We would normally use KeyRing.verify_event_for_server but we can't here as we don't
@@ -260,9 +261,7 @@ class RoomPolicyHandler:
# servers need to manually fetch signatures for. This is the code that allows
# those events to continue working (because they're legally sent, even if missing
# the policy server signature).
event.signatures.setdefault(policy_server.server_name, {}).update(
signature.get(policy_server.server_name, {})
)
event.signatures.update(signature)
except HttpResponseException as ex:
# re-wrap HTTP errors as `SynapseError` so they can be proxied to clients directly
raise ex.to_synapse_error() from ex
+68 -54
View File
@@ -150,6 +150,23 @@ class SlidingSyncHandler:
# events or future events if the user is nefariously, manually modifying the
# token.
if from_token is not None:
# Work around a bug where older Synapse versions gave out tokens "from the
# future", i.e. that are ahead of the tokens persisted in the DB. This could
# also happen if a user is intentionally messing with the token so this also
# acts as sanitization/validation.
#
# If the token has positions ahead of our persisted positions in the
# database (invalid), then we simply use our max persisted position (recover
# gracefully); instead of waiting for a position that may never come around.
#
# FIXME: For Sliding Sync, instead of bounding the token, we should detect
# the invalid future position and raise a `M_UNKNOWN_POS` error.
from_token = SlidingSyncStreamToken(
stream_token=await self.event_sources.bound_future_token(
from_token.stream_token
),
connection_position=from_token.connection_position,
)
# We need to make sure this worker has caught up with the token. If
# this returns false, it means we timed out waiting, and we should
# just return an empty response.
@@ -167,38 +184,34 @@ class SlidingSyncHandler:
timeout_ms -= after_wait_ts - before_wait_ts
timeout_ms = max(timeout_ms, 0)
# Compute a response immediately. We always need to do this before
# waiting for new data (unlike in /v3/sync), as the request config might
# have changed (e.g. new room subscriptions, etc).
now_token = self.event_sources.get_current_token()
result = await self.current_sync_for_user(
sync_config,
from_token=from_token,
to_token=now_token,
)
# Return immediately if we have a result, the timeout is 0, or this is
# an initial sync.
if result or timeout_ms == 0 or from_token is None:
return result, did_wait
# Otherwise, we wait for something to happen and report it to the user.
async def current_sync_callback(
before_token: StreamToken, after_token: StreamToken
) -> SlidingSyncResult:
return await self.current_sync_for_user(
# We're going to respond immediately if the timeout is 0 or if this is an
# initial sync (without a `from_token`) so we can avoid calling
# `notifier.wait_for_events()`.
if timeout_ms == 0 or from_token is None:
now_token = self.event_sources.get_current_token()
result = await self.current_sync_for_user(
sync_config,
from_token=from_token,
to_token=after_token,
to_token=now_token,
)
else:
# Otherwise, we wait for something to happen and report it to the user.
async def current_sync_callback(
before_token: StreamToken, after_token: StreamToken
) -> SlidingSyncResult:
return await self.current_sync_for_user(
sync_config,
from_token=from_token,
to_token=after_token,
)
result = await self.notifier.wait_for_events(
sync_config.user.to_string(),
timeout_ms,
current_sync_callback,
from_token=now_token,
)
did_wait = True
result = await self.notifier.wait_for_events(
sync_config.user.to_string(),
timeout_ms,
current_sync_callback,
from_token=from_token.stream_token,
)
did_wait = True
return result, did_wait
@@ -859,20 +872,10 @@ class SlidingSyncHandler:
# For incremental syncs, we can do this first to determine if something relevant
# has changed and strategically avoid fetching other costly things.
room_state_delta_id_map: MutableStateMap[str] = {}
name_event_id: str | None = None
membership_changed = False
name_changed = False
avatar_changed = False
if initial:
# Check whether the room has a name set
name_state_ids = await self.get_current_state_ids_at(
room_id=room_id,
room_membership_for_user_at_to_token=room_membership_for_user_at_to_token,
state_filter=StateFilter.from_types([(EventTypes.Name, "")]),
to_token=to_token,
)
name_event_id = name_state_ids.get((EventTypes.Name, ""))
else:
if not initial:
assert from_bound is not None
# TODO: Limit the number of state events we're about to send down
@@ -920,6 +923,27 @@ class SlidingSyncHandler:
):
avatar_changed = True
# If a room has an m.room.name event with an absent, null, or empty
# name field, it should be treated the same as a room with no
# m.room.name event.
# https://spec.matrix.org/v1.17/client-server-api/#mroomname
#
# TODO: Should we also check for `EventTypes.CanonicalAlias`
# (`m.room.canonical_alias`) as a fallback for the room name? see
# https://github.com/matrix-org/matrix-spec-proposals/pull/4186/changes#r2860107511
room_name: str | None = None
if initial or name_changed:
# Check whether the room has a name set
name_states = await self.get_current_state_at(
room_id=room_id,
room_membership_for_user_at_to_token=room_membership_for_user_at_to_token,
state_filter=StateFilter.from_types([(EventTypes.Name, "")]),
to_token=to_token,
)
name_event = name_states.get((EventTypes.Name, ""))
if name_event is not None:
room_name = name_event.content.get("name")
# We only need the room summary for calculating heroes, however if we do
# fetch it then we can use it to calculate `joined_count` and
# `invited_count`.
@@ -936,12 +960,13 @@ class SlidingSyncHandler:
hero_user_ids: list[str] = []
# TODO: Should we also check for `EventTypes.CanonicalAlias`
# (`m.room.canonical_alias`) as a fallback for the room name? see
# https://github.com/matrix-org/matrix-spec-proposals/pull/3575#discussion_r1671260153
# https://github.com/matrix-org/matrix-spec-proposals/pull/4186/changes#r2860107511
#
# We need to fetch the `heroes` if the room name is not set. But we only need to
# get them on initial syncs (or the first time we send down the room) or if the
# We need to fetch the `heroes` if the room name is not set (taking
# care to treat an empty string as unset). But we only need to get them
# on initial syncs (or the first time we send down the room) or if the
# membership has changed which may change the heroes.
if name_event_id is None and (initial or (not initial and membership_changed)):
if not room_name and (initial or membership_changed):
# We need the room summary to extract the heroes from
if room_membership_for_user_at_to_token.membership != Membership.JOIN:
# TODO: Figure out how to get the membership summary for left/banned rooms
@@ -1160,8 +1185,6 @@ class SlidingSyncHandler:
(EventTypes.Member, hero_user_id) for hero_user_id in hero_user_ids
]
meta_room_state = list(hero_room_state)
if initial or name_changed:
meta_room_state.append((EventTypes.Name, ""))
if initial or avatar_changed:
meta_room_state.append((EventTypes.RoomAvatar, ""))
@@ -1319,15 +1342,6 @@ class SlidingSyncHandler:
if required_state_filter != StateFilter.none():
required_room_state = required_state_filter.filter_state(room_state)
# Find the room name and avatar from the state
room_name: str | None = None
# TODO: Should we also check for `EventTypes.CanonicalAlias`
# (`m.room.canonical_alias`) as a fallback for the room name? see
# https://github.com/matrix-org/matrix-spec-proposals/pull/3575#discussion_r1671260153
name_event = room_state.get((EventTypes.Name, ""))
if name_event is not None:
room_name = name_event.content.get("name")
room_avatar: str | None = None
avatar_event = room_state.get((EventTypes.RoomAvatar, ""))
if avatar_event is not None:
+4 -8
View File
@@ -852,15 +852,11 @@ class SlidingSyncRoomLists:
previous_connection_state.room_configs.get(room_id)
)
if prev_room_sync_config is not None:
# Always include rooms whose effective config has
# expanded. This covers timeline-limit increases and
# required-state additions introduced by room
# subscriptions overriding list-derived params.
# Always include rooms whose timeline limit has increased.
# (see the "XXX: Odd behavior" described below)
if (
prev_room_sync_config.combine_room_sync_config(
room_config
)
!= prev_room_sync_config
prev_room_sync_config.timeline_limit
< room_config.timeline_limit
):
rooms_should_send.add(room_id)
continue
+2 -2
View File
@@ -31,7 +31,7 @@ from typing import (
from synapse.api.constants import EventContentFields, EventTypes, Membership
from synapse.metrics import SERVER_NAME_LABEL, event_processing_positions
from synapse.storage.databases.main.state_deltas import StateDelta
from synapse.types import JsonDict
from synapse.types import JsonMapping
from synapse.util.duration import Duration
from synapse.util.events import get_plain_text_topic_from_event_content
@@ -195,7 +195,7 @@ class StatsHandler:
)
continue
event_content: JsonDict = {}
event_content: JsonMapping = {}
if delta.event_id is not None:
event = await self.store.get_event(delta.event_id, allow_none=True)
+18 -5
View File
@@ -421,6 +421,15 @@ class SyncHandler:
context.tag = sync_label
if since_token is not None:
# Work around a bug where older Synapse versions gave out tokens "from the
# future", i.e. that are ahead of the tokens persisted in the DB. This could
# also happen if a user is intentionally messing with the token so this also
# acts as sanitization/validation.
#
# If the token has positions ahead of our persisted positions in the
# database (invalid), then we simply use our max persisted position (recover
# gracefully); instead of waiting for a position that may never come around.
since_token = await self.event_sources.bound_future_token(since_token)
# We need to make sure this worker has caught up with the token. If
# this returns false it means we timed out waiting, and we should
# just return an empty response.
@@ -2375,19 +2384,23 @@ class SyncHandler:
if block_all_room_ephemeral:
ephemeral_by_room: dict[str, list[JsonDict]] = {}
else:
now_token, ephemeral_by_room = await self.ephemeral_by_room(
(
sync_result_builder.now_token,
ephemeral_by_room,
) = await self.ephemeral_by_room(
sync_result_builder,
now_token=sync_result_builder.now_token,
since_token=sync_result_builder.since_token,
)
sync_result_builder.now_token = now_token
sticky_by_room: dict[str, list[str]] = {}
if self.hs_config.experimental.msc4354_enabled:
now_token, sticky_by_room = await self.sticky_events_by_room(
sync_result_builder, now_token, since_token
(
sync_result_builder.now_token,
sticky_by_room,
) = await self.sticky_events_by_room(
sync_result_builder, sync_result_builder.now_token, since_token
)
sync_result_builder.now_token = now_token
# 2. We check up front if anything has changed, if it hasn't then there is
# no point in going further.
+96 -32
View File
@@ -54,6 +54,20 @@ logger = logging.getLogger(__name__)
# will not disappear under our feet as long as we don't delete the room.
NEW_EVENT_DURING_PURGE_LOCK_NAME = "new_event_during_purge_lock"
WORKER_LOCK_MAX_RETRY_INTERVAL = Duration(seconds=5)
"""
The maximum wait time before retrying to acquire the lock.
Better to retry more quickly than have workers wait around. 5 seconds is still a
reasonable gap in time to not overwhelm the CPU/Database.
This matters most when locks go stale as normally, when the lock holder releases, we
signal to other locks (with the same name/key) that they should try reacquiring the lock
immediately. But stale locks are never released and instead forcefully reaped behind the
scenes.
"""
WORKER_LOCK_EXCESSIVE_WAITING_WARN_DURATION = Duration(minutes=10)
class WorkerLocksHandler:
"""A class for waiting on taking out locks, rather than using the storage
@@ -206,9 +220,10 @@ class WaitingLock:
lock_name: str
lock_key: str
write: bool | None
start_ts_ms: int = 0
deferred: "defer.Deferred[None]" = attr.Factory(defer.Deferred)
_inner_lock: Lock | None = None
_retry_interval: float = 0.1
_timeout_interval: float = 0.1
_lock_span: "opentracing.Scope" = attr.Factory(
lambda: start_active_span("WaitingLock.lock")
)
@@ -220,6 +235,7 @@ class WaitingLock:
self.deferred.callback(None)
async def __aenter__(self) -> None:
self.start_ts_ms = self.clock.time_msec()
self._lock_span.__enter__()
with start_active_span("WaitingLock.waiting_for_lock"):
@@ -240,19 +256,44 @@ class WaitingLock:
break
try:
# Wait until the we get notified the lock might have been
# Wait until the notification that the lock might have been
# released (by the deferred being resolved). We also
# periodically wake up in case the lock was released but we
# periodically wake up in case the lock was released, but we
# weren't notified.
with PreserveLoggingContext():
timeout = self._get_next_retry_interval()
await timeout_deferred(
deferred=self.deferred,
timeout=timeout,
timeout=self._timeout_interval,
clock=self.clock,
)
except Exception:
pass
except defer.TimeoutError:
# Only increment the timeout value if this was an actual timeout
# (defer.TimeoutError)
self._increment_timeout_interval()
now_ms = self.clock.time_msec()
time_spent_trying_to_lock = Duration(
milliseconds=now_ms - self.start_ts_ms
)
if (
time_spent_trying_to_lock.as_millis()
> WORKER_LOCK_EXCESSIVE_WAITING_WARN_DURATION.as_millis()
):
logger.warning(
"(WaitingLock (%s, %s)) Time spent waiting to acquire lock "
"is getting excessive: %ss. There may be a deadlock.",
self.lock_name,
self.lock_key,
time_spent_trying_to_lock.as_secs(),
)
except Exception as e:
logger.warning(
"Caught an exception while waiting on WaitingLock(lock_name=%s, lock_key=%s): %r",
self.lock_name,
self.lock_key,
e,
)
return await self._inner_lock.__aenter__()
@@ -273,15 +314,14 @@ class WaitingLock:
return r
def _get_next_retry_interval(self) -> float:
next = self._retry_interval
self._retry_interval = max(5, next * 2)
if self._retry_interval > Duration(minutes=10).as_secs(): # >7 iterations
logger.warning(
"Lock timeout is getting excessive: %ss. There may be a deadlock.",
self._retry_interval,
)
return next * random.uniform(0.9, 1.1)
def _increment_timeout_interval(self) -> float:
next_interval = self._timeout_interval
next_interval = min(WORKER_LOCK_MAX_RETRY_INTERVAL.as_secs(), next_interval * 2)
# The jitter value is maintained for the timeout, to help avoid a "thundering
# herd" situation when all locks may time out at the same time.
self._timeout_interval = next_interval * random.uniform(0.9, 1.1)
return self._timeout_interval
@attr.s(auto_attribs=True, eq=False)
@@ -294,10 +334,11 @@ class WaitingMultiLock:
store: LockStore
handler: WorkerLocksHandler
start_ts_ms: int = 0
deferred: "defer.Deferred[None]" = attr.Factory(defer.Deferred)
_inner_lock_cm: AsyncContextManager | None = None
_retry_interval: float = 0.1
_timeout_interval: float = 0.1
_lock_span: "opentracing.Scope" = attr.Factory(
lambda: start_active_span("WaitingLock.lock")
)
@@ -309,6 +350,7 @@ class WaitingMultiLock:
self.deferred.callback(None)
async def __aenter__(self) -> None:
self.start_ts_ms = self.clock.time_msec()
self._lock_span.__enter__()
with start_active_span("WaitingLock.waiting_for_lock"):
@@ -324,19 +366,42 @@ class WaitingMultiLock:
break
try:
# Wait until the we get notified the lock might have been
# Wait until the notification that the lock might have been
# released (by the deferred being resolved). We also
# periodically wake up in case the lock was released but we
# periodically wake up in case the lock was released, but we
# weren't notified.
with PreserveLoggingContext():
timeout = self._get_next_retry_interval()
await timeout_deferred(
deferred=self.deferred,
timeout=timeout,
timeout=self._timeout_interval,
clock=self.clock,
)
except Exception:
pass
except defer.TimeoutError:
# Only increment the timeout value if this was an actual timeout
# (defer.TimeoutError)
self._increment_timeout_interval()
now_ms = self.clock.time_msec()
time_spent_trying_to_lock = Duration(
milliseconds=now_ms - self.start_ts_ms
)
if (
time_spent_trying_to_lock.as_millis()
> WORKER_LOCK_EXCESSIVE_WAITING_WARN_DURATION.as_millis()
):
logger.warning(
"(WaitingMultiLock (%r)) Time spent waiting to acquire lock "
"is getting excessive: %ss. There may be a deadlock.",
self.lock_names,
time_spent_trying_to_lock.as_secs(),
)
except Exception as e:
logger.warning(
"Caught an exception while waiting on WaitingMultiLock(lock_names=%r): %r",
self.lock_names,
e,
)
assert self._inner_lock_cm
await self._inner_lock_cm.__aenter__()
@@ -360,12 +425,11 @@ class WaitingMultiLock:
return r
def _get_next_retry_interval(self) -> float:
next = self._retry_interval
self._retry_interval = max(5, next * 2)
if self._retry_interval > Duration(minutes=10).as_secs(): # >7 iterations
logger.warning(
"Lock timeout is getting excessive: %ss. There may be a deadlock.",
self._retry_interval,
)
return next * random.uniform(0.9, 1.1)
def _increment_timeout_interval(self) -> float:
next_interval = self._timeout_interval
next_interval = min(WORKER_LOCK_MAX_RETRY_INTERVAL.as_secs(), next_interval * 2)
# The jitter value is maintained for the timeout, to help avoid a "thundering
# herd" situation when all locks may time out at the same time.
self._timeout_interval = next_interval * random.uniform(0.9, 1.1)
return self._timeout_interval
+40 -5
View File
@@ -833,15 +833,49 @@ class Notifier:
return result
async def wait_for_stream_token(self, stream_token: StreamToken) -> bool:
"""Wait for this worker to catch up with the given stream token."""
"""
Wait for this worker to catch up with the given stream token.
This is important to ensure that the worker has a proper view of the world
before trying to serve a request. For example, one worker can return a response
with some `next_batch` token, but then the next request goes to another worker
which is behind; if the worker assembles a response up to the token, it could be
missing data in the gap between where it's behind and the requested token.
### Inavlid future tokens
We assume the token has already been validated/sanitized before being passed to
this function to ensure it's not some invalid future token. We consider a token
invalid, if the token has positions ahead of our persisted positions in the
database. This is important as we we don't want to wait for the stream to
advance in those cases (as it may never do so) (it's a waste of time for the
user and server).
Previously, we would sanitize and `bound_future_token(...)` within this function
but that leads to bad patterns upstream where people can continue to use the
unbounded token.
While it was possible for older Synapse versions to erroneously give out invalid
future tokens, this is no longer the case and its considered a Synapse
programming error if this ever happens. Validation/sanitization is still
necessary as a user can intentionally mess with numbers in the tokens being
provided.
Args:
stream_token: The token to wait for. We assume the token has already been
validated/sanitized to ensure it's not some invalid future token (has a
stream position ahead of what is in the DB). (see details above)
Returns:
True when this worker has caught up
False when we timed out waiting
"""
current_token = self.event_sources.get_current_token()
# Return early if we are already caught up
if stream_token.is_before_or_eq(current_token):
return True
# Work around a bug where older Synapse versions gave out tokens "from
# the future", i.e. that are ahead of the tokens persisted in the DB.
stream_token = await self.event_sources.bound_future_token(stream_token)
# Start waiting until we've caught up to the `stream_token`
start = self.clock.time_msec()
logged = False
while True:
@@ -851,6 +885,7 @@ class Notifier:
now = self.clock.time_msec()
# Timed out
if now - start > 10_000:
return False
+2 -2
View File
@@ -51,7 +51,7 @@ from synapse.storage.databases.main.roommember import EventIdMembership
from synapse.storage.invite_rule import InviteRule
from synapse.storage.roommember import ProfileInfo
from synapse.synapse_rust.push import FilteredPushRules, PushRuleEvaluator
from synapse.types import JsonValue
from synapse.types import JsonMapping, JsonValue
from synapse.types.state import StateFilter
from synapse.util import unwrapFirstError
from synapse.util.async_helpers import gather_results
@@ -231,7 +231,7 @@ class BulkPushRuleEvaluator:
event: EventBase,
context: EventContext,
event_id_to_event: Mapping[str, EventBase],
) -> tuple[dict, int | None]:
) -> tuple[JsonMapping, int | None]:
"""
Given an event and an event context, get the power level event relevant to the event
and the power level of the sender of the event.
+18 -5
View File
@@ -43,7 +43,13 @@ from synapse.rest.admin._base import (
from synapse.storage.databases.main.media_repository import (
MediaSortOrder,
)
from synapse.types import JsonDict, UserID
from synapse.types import (
JsonDict,
MultiWriterStreamToken,
StreamKeyType,
StreamToken,
UserID,
)
if TYPE_CHECKING:
from synapse.server import HomeServer
@@ -243,6 +249,7 @@ class ListQuarantineChanges(RestServlet):
self.auth = hs.get_auth()
self.server_name = hs.hostname
self.replication = hs.get_replication_data_handler()
self.notifier = hs.get_notifier()
async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
await assert_requester_is_admin(self.auth, request)
@@ -256,8 +263,7 @@ class ListQuarantineChanges(RestServlet):
# The caller is trying to get future data, which we don't allow because
# we know it's an invalid state that should never happen. We could
# wait until we reach the token but we might as well not waste our
# resources on that which is why `wait_for_quarantined_media_stream_id(...)`
# has assertions around this.
# resources on that.
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"The `from` token is considered invalid because it includes stream positions "
@@ -268,9 +274,16 @@ class ListQuarantineChanges(RestServlet):
errcode=Codes.INVALID_PARAM,
)
# Create a `StreamToken` that's compatible with `wait_for_stream_token`.
#
# FIXME: Ideally, this endpoint would use a `StreamToken` to begin with
from_token = StreamToken.START.copy_and_replace(
StreamKeyType.QUARANTINED_MEDIA, MultiWriterStreamToken(stream=from_id)
)
# We need to wait to ensure that our current worker is actually caught up with
# the stream position, otherwise we might not return what we think we're returning.
if not await self.store.wait_for_quarantined_media_stream_id(from_id):
if not await self.notifier.wait_for_stream_token(from_token):
raise SynapseError(
HTTPStatus.INTERNAL_SERVER_ERROR,
"Timed out while waiting for the worker serving this request to catch up to the given "
@@ -280,7 +293,7 @@ class ListQuarantineChanges(RestServlet):
errcode=Codes.UNKNOWN,
)
to_id = await self.store.get_current_quarantined_media_stream_id()
to_id = self.store.get_current_quarantined_media_stream_id()
changes = await self.store.get_quarantined_media_changes(
from_id=from_id,
to_id=to_id,
+5
View File
@@ -77,6 +77,11 @@ class CapabilitiesRestServlet(RestServlet):
}
}
if self.config.experimental.msc4452_enabled:
response["capabilities"]["io.element.msc4452.preview_url"] = {
"enabled": self.config.media.url_preview_enabled,
}
if self.config.experimental.msc3720_enabled:
response["capabilities"]["org.matrix.msc3720.account_status"] = {
"enabled": True,
+14 -6
View File
@@ -23,7 +23,12 @@
import logging
import re
from synapse.api.errors import Codes, cs_error
from synapse.api.errors import (
Codes,
SynapseError,
UnrecognizedRequestError,
cs_error,
)
from synapse.http.server import (
HttpServer,
respond_with_json,
@@ -79,11 +84,17 @@ class PreviewURLServlet(RestServlet):
self.clock = hs.get_clock()
self.media_repo = media_repo
self.media_storage = media_storage
assert self.media_repo.url_previewer is not None
self.url_previewer = self.media_repo.url_previewer
self.can_respond_403 = hs.config.experimental.msc4452_enabled
async def on_GET(self, request: SynapseRequest) -> None:
requester = await self.auth.get_user_by_req(request)
if self.url_previewer is None:
# If we have no url_previewer then it has been disabled by the server.
if self.can_respond_403:
raise SynapseError(403, "URL Previews are disabled", Codes.FORBIDDEN)
else:
raise UnrecognizedRequestError(code=404)
url = parse_string(request, "url", required=True)
ts = parse_integer(request, "ts")
if ts is None:
@@ -299,10 +310,7 @@ class DownloadResource(RestServlet):
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
media_repo = hs.get_media_repository()
if hs.config.media.url_preview_enabled:
PreviewURLServlet(hs, media_repo, media_repo.media_storage).register(
http_server
)
PreviewURLServlet(hs, media_repo, media_repo.media_storage).register(http_server)
MediaConfigResource(hs).register(http_server)
ThumbnailResource(hs, media_repo, media_repo.media_storage).register(http_server)
DownloadResource(hs, media_repo).register(http_server)
@@ -106,8 +106,7 @@ class MediaRepositoryResource(JsonResource):
ThumbnailResource(hs, media_repo, media_repo.media_storage).register(
http_server
)
if hs.config.media.url_preview_enabled:
PreviewUrlResource(hs, media_repo, media_repo.media_storage).register(
http_server
)
PreviewUrlResource(hs, media_repo, media_repo.media_storage).register(
http_server
)
MediaConfigResource(hs).register(http_server)
+8 -2
View File
@@ -23,6 +23,7 @@
import re
from typing import TYPE_CHECKING
from synapse.api.errors import Codes, SynapseError, UnrecognizedRequestError
from synapse.http.server import respond_with_json_bytes
from synapse.http.servlet import RestServlet, parse_integer, parse_string
from synapse.http.site import SynapseRequest
@@ -65,12 +66,17 @@ class PreviewUrlResource(RestServlet):
self.clock = hs.get_clock()
self.media_repo = media_repo
self.media_storage = media_storage
assert self.media_repo.url_previewer is not None
self.url_previewer = self.media_repo.url_previewer
self.can_respond_403 = hs.config.experimental.msc4452_enabled
async def on_GET(self, request: SynapseRequest) -> None:
# XXX: if get_user_by_req fails, what should we do in an async render?
requester = await self.auth.get_user_by_req(request)
if self.url_previewer is None:
# If we have no url_previewer then it has been disabled by the server.
if self.can_respond_403:
raise SynapseError(403, "URL Previews are disabled", Codes.FORBIDDEN)
else:
raise UnrecognizedRequestError(code=404)
url = parse_string(request, "url", required=True)
ts = parse_integer(request, "ts", default=self.clock.time_msec())
og = await self.url_previewer.preview(url, requester.user, ts)
+3 -2
View File
@@ -37,7 +37,8 @@ from prometheus_client import Counter, Histogram
from synapse.api.constants import EventTypes
from synapse.api.room_versions import KNOWN_ROOM_VERSIONS, StateResolutionVersions
from synapse.events import EventBase, FrozenEventVMSC4242
from synapse.events import EventBase
from synapse.events.py_protocol import supports_msc4242_state_dag
from synapse.events.snapshot import (
EventContext,
UnpersistedEventContext,
@@ -315,7 +316,7 @@ class StateHandler:
# might redundantly recalculate the state for this event later.)
prev_event_ids = frozenset(
event.prev_state_events
if isinstance(event, FrozenEventVMSC4242)
if supports_msc4242_state_dag(event)
else event.prev_event_ids()
)
incomplete_prev_events = await self.store.get_partial_state_events(
+13 -10
View File
@@ -34,6 +34,7 @@ from typing import (
Generator,
Generic,
Iterable,
Sequence,
TypeVar,
cast,
)
@@ -46,7 +47,14 @@ from twisted.internet import defer
from synapse.api.constants import EventTypes, Membership
from synapse.api.errors import SynapseError
from synapse.api.room_versions import KNOWN_ROOM_VERSIONS
from synapse.events import EventBase, FrozenEventVMSC4242, event_exists_in_state_dag
from synapse.events import (
EventBase,
event_exists_in_state_dag,
)
from synapse.events.py_protocol import (
MSC4242Event,
all_supports_msc4242_state_dag,
)
from synapse.events.snapshot import EventContext, EventPersistencePair
from synapse.handlers.worker_lock import NEW_EVENT_DURING_PURGE_LOCK_NAME
from synapse.logging.context import PreserveLoggingContext, make_deferred_yieldable
@@ -637,7 +645,6 @@ class EventsPersistenceStorageController:
# Get the room version for the first event. This room version is the same for all events
# as events_and_contexts is all for one room.
assert len(events_and_contexts) > 0
room_version = events_and_contexts[0][0].room_version
for chunk in chunks:
# We can't easily parallelize these since different chunks
@@ -648,22 +655,18 @@ class EventsPersistenceStorageController:
new_state_dag_extrems = None
if not backfilled:
if room_version.msc4242_state_dags:
if all_supports_msc4242_state_dag(chunk):
with Measure(
self._clock,
name="_process_state_dag_forward_extremities_and_state_delta",
server_name=self.server_name,
):
assert all(
isinstance(ev, FrozenEventVMSC4242) for ev, _ in chunk
)
(
new_forward_extremities, # for prev_events
state_delta_for_room, # for state groups
new_state_dag_extrems, # for prev_state_events
) = await self._process_state_dag_forward_extremities_and_state_delta(
room_id,
cast(list[tuple[FrozenEventVMSC4242, EventContext]], chunk),
room_id, chunk
)
else:
with Measure(
@@ -840,7 +843,7 @@ class EventsPersistenceStorageController:
async def _process_state_dag_forward_extremities_and_state_delta(
self,
room_id: str,
event_contexts: list[tuple[FrozenEventVMSC4242, EventContext]],
event_contexts: Sequence[tuple[MSC4242Event, EventContext]],
) -> tuple[set[str] | None, DeltaState | None, set[str] | None]:
"""Process the forwards extremities for state DAG rooms.
Returns:
@@ -933,7 +936,7 @@ class EventsPersistenceStorageController:
self,
room_id: str,
existing_fwd_extrems: frozenset[str],
event_contexts: list[tuple[FrozenEventVMSC4242, EventContext]],
event_contexts: Sequence[tuple[MSC4242Event, EventContext]],
) -> set[str]:
"""Calculate the new state dag forward extremities. Modifies existing_fwd_extrems.
@@ -1199,34 +1199,72 @@ class EventFederationWorkerStore(
async def get_backfill_points_in_room(
self,
room_id: str,
current_depth: int,
nearby_depth: int,
limit: int,
) -> list[tuple[str, int]]:
"""
Get the backward extremities to backfill from in the room along with the
approximate depth.
Only returns events that are at a depth lower than or
equal to the `current_depth`. Sorted by depth, highest to lowest (descending)
so the closest events to the `current_depth` are first in the list.
Only returns events that are at a depth lower than or equal to the `nearby_depth`.
Sorted by depth, highest to lowest (descending) so the closest events to the
`nearby_depth` are first in the list.
We ignore extremities that are newer than the user's current scroll position
(ie, those with depth greater than `current_depth`) as:
1. we don't really care about getting events that have happened
after our current position; and
2. by the nature of paginating and scrolling back, we have likely
previously tried and failed to backfill from that extremity, so
to avoid getting "stuck" requesting the same backfill repeatedly
we drop those extremities.
### Why `nearby_depth`?
We find backfill points from the backward extremities in the DAG. Backward
extremities are the oldest events we know of in the room but we only know of
them because some other event referenced them by prev_event and aren't persisted
in our database yet (meaning we don't know their depth specifically). So we can
only do approximate depth comparisons (use the depth of the known events they're
connected to). And we don't know if those backward extremities point to a long
chain/fork of history that could stretch back far enough to be visible.
This means a naive homeserver implementation that looks for backward extremities <=
depth of the `/messages?dir=b&from=xxx` token may overlook a backfill point that could
reveal more history in the window the user is currently paginating in.
We consider "nearby" as anything within range of the number of events you plan
to backfill from the given backfill point. This is a good heuristic as since we
plan to backfill N events, the chain of events from a backfill point could
extend back into the visible window.
Example:
- Your pagination token represents a scroll position at a depth of `100`.
- We have a backfill point at an approximate depth of `125`
- You plan to backfill `50` events from that backfill point.
When we pad the token `depth` with the number of messages we plan to backfill,
`100` + `50` = `150`, we find the backfill point at `125` (because <= `150`, our
`nearby_depth`), backfill `50` events to a depth of `75` in the timeline
(exposing new events that we can return `100` -> `75`).
When we don't pad our token `depth`, `100` is lower than any of the backfill
points so we don't pick any and miss out on backfilling any events. Without
something like MSC3871 to indicate gaps in the timeline, clients will most
likely never know they are missing any events and never try to paginate again.
Generally though, we ignore extremities that are newer than the user's current
scroll position (ie, those with depth greater than `nearby_depth`) as:
1. we don't really care about getting events that have happened after our
current position; and
2. by the nature of paginating and scrolling back, we have likely previously
tried and failed to backfill from that extremity, so to avoid getting
"stuck" requesting the same backfill repeatedly we drop those
extremities. Although we also have `event_failed_pull_attempts` nowadays
to backoff as well.
Args:
room_id: Room where we want to find the oldest events
current_depth: The depth at the user's current scrollback position
nearby_depth: Typically, this is depth at the user's current scrollback
position + the number of events you plan to backfill from these backfill
points.
limit: The max number of backfill points to return
Returns:
List of (event_id, depth) tuples. Sorted by depth, highest to lowest
(descending) so the closest events to the `current_depth` are first
(descending) so the closest events to the `nearby_depth` are first
in the list.
"""
@@ -1234,12 +1272,12 @@ class EventFederationWorkerStore(
txn: LoggingTransaction, room_id: str
) -> list[tuple[str, int]]:
# Assemble a tuple lookup of event_id -> depth for the oldest events
# we know of in the room. Backwards extremeties are the oldest
# we know of in the room. Backwards extremities are the oldest
# events we know of in the room but we only know of them because
# some other event referenced them by prev_event and aren't
# persisted in our database yet (meaning we don't know their depth
# specifically). So we need to look for the approximate depth from
# the events connected to the current backwards extremeties.
# the events connected to the current backwards extremities.
if isinstance(self.database_engine, PostgresEngine):
least_function = "LEAST"
@@ -1259,7 +1297,7 @@ class EventFederationWorkerStore(
ON edge.event_id = event.event_id
/**
* We find the "oldest" events in the room by looking for
* events connected to backwards extremeties (oldest events
* events connected to backwards extremities (oldest events
* in the room that we know of so far).
*/
INNER JOIN event_backward_extremities AS backward_extrem
@@ -1285,16 +1323,19 @@ class EventFederationWorkerStore(
AND edge.is_state is FALSE
/**
* We only want backwards extremities that are older than or at
* the same position of the given `current_depth` (where older
* the same position of the given `nearby_depth` (where older
* means less than the given depth) because we're looking backwards
* from the `current_depth` when backfilling.
* from the `nearby_depth` when backfilling.
*
* current_depth (ignore events that come after this, ignore 2-4)
* Keep in mind that `event.depth` is an approximate depth of the
* backward extremity itself.
*
* nearby_depth (ignore events that come after this, ignore 2-4)
* |
* ▼
* <oldest-in-time> [0]<--[1]<--[2]<--[3]<--[4] <newest-in-time>
*/
AND event.depth <= ? /* current_depth */
AND event.depth <= ? /* nearby_depth */
/**
* Exponential back-off (up to the upper bound) so we don't retry the
* same backfill point over and over. ex. 2hr, 4hr, 8hr, 16hr, etc.
@@ -1312,7 +1353,7 @@ class EventFederationWorkerStore(
)
)
/**
* Sort from highest (closest to the `current_depth`) to the lowest depth
* Sort from highest (closest to the `nearby_depth`) to the lowest depth
* because the closest are most relevant to backfill from first.
* Then tie-break on alphabetical order of the event_ids so we get a
* consistent ordering which is nice when asserting things in tests.
@@ -1325,7 +1366,7 @@ class EventFederationWorkerStore(
sql,
(
room_id,
current_depth,
nearby_depth,
self.clock.time_msec(),
BACKFILL_EVENT_EXPONENTIAL_BACKOFF_MAXIMUM_DOUBLING_STEPS,
BACKFILL_EVENT_EXPONENTIAL_BACKOFF_STEP_MILLISECONDS,
+4 -7
View File
@@ -48,12 +48,12 @@ from synapse.api.errors import PartialStateConflictError
from synapse.api.room_versions import RoomVersions
from synapse.events import (
EventBase,
FrozenEventVMSC4242,
StrippedStateEvent,
event_exists_in_state_dag,
is_creator,
relation_from_event,
)
from synapse.events.py_protocol import MSC4242Event, supports_msc4242_state_dag
from synapse.events.snapshot import EventPersistencePair
from synapse.events.utils import parse_stripped_state_event
from synapse.logging.opentracing import trace
@@ -2711,7 +2711,7 @@ class PersistEventsStore:
return
def event_dict(event: EventBase) -> JsonDict:
d = event.get_dict()
d = event.get_dict_for_persistence()
d.pop("redacted", None)
d.pop("redacted_because", None)
return d
@@ -2897,10 +2897,7 @@ class PersistEventsStore:
self._handle_event_relations(txn, event)
if event.room_version.msc4242_state_dags and event_exists_in_state_dag(
event
):
assert isinstance(event, FrozenEventVMSC4242)
if supports_msc4242_state_dag(event) and event_exists_in_state_dag(event):
self._store_state_dag_edges(txn, event)
# Store the labels for this event.
@@ -2980,7 +2977,7 @@ class PersistEventsStore:
txn.call_after(local_prefill)
def _store_state_dag_edges(
self, txn: LoggingTransaction, event: FrozenEventVMSC4242
self, txn: LoggingTransaction, event: MSC4242Event
) -> None:
# the create event has no edge but we still need to persist it as get_state_dag just
# yanks all rows in this table. It's a bit gross to store NULL as the prev_state_event_id
@@ -2824,8 +2824,8 @@ class EventsBackgroundUpdatesStore(
# with the provided old key.
if old_verify_key is not None:
old_key_id = f"{old_verify_key.alg}:{old_verify_key.version}"
server_sigs = event.signatures.get(self.hs.hostname, {})
if old_key_id not in server_sigs:
old_sig = event.signatures.get_signature(self.hs.hostname, old_key_id)
if old_sig is None:
# Event wasn't signed with this key ID at all, skip.
continue
@@ -779,14 +779,26 @@ class EventsWorkerStore(SQLBaseStore):
events.append(event)
if get_prev_content:
if "replaces_state" in event.unsigned:
# The `event` here might be in the cache, and so might have
# already had the `prev_content` and `prev_sender` fields added
# to its unsigned.
#
# We check if a) we should add the previous content, and b) if
# we have already added it.
replaces_state = "replaces_state" in event.unsigned
has_prev = (
"prev_content" in event.unsigned and "prev_sender" in event.unsigned
)
if replaces_state and not has_prev:
prev = await self.get_event(
event.unsigned["replaces_state"],
get_prev_content=False,
allow_none=True,
)
if prev:
event.unsigned = dict(event.unsigned)
# This mutates the cached event, but that's fine as the
# previous content/sender will be the same for all
# requests for this event.
event.unsigned["prev_content"] = prev.content
event.unsigned["prev_sender"] = prev.sender
+8 -6
View File
@@ -53,9 +53,9 @@ logger = logging.getLogger(__name__)
_RENEWAL_INTERVAL = Duration(seconds=30)
# How long before an acquired lock times out.
_LOCK_TIMEOUT_MS = 2 * 60 * 1000
_LOCK_TIMEOUT = Duration(minutes=2)
_LOCK_REAP_INTERVAL = Duration(milliseconds=_LOCK_TIMEOUT_MS / 10.0)
_LOCK_REAP_INTERVAL = Duration(milliseconds=_LOCK_TIMEOUT.as_millis() / 10.0)
class LockStore(SQLBaseStore):
@@ -63,7 +63,7 @@ class LockStore(SQLBaseStore):
Locks are identified by a name and key. A lock is acquired by inserting into
the `worker_locks` table if a) there is no existing row for the name/key or
b) the existing row has a `last_renewed_ts` older than `_LOCK_TIMEOUT_MS`.
b) the existing row has a `last_renewed_ts` older than `_LOCK_TIMEOUT`.
When a lock is taken out the instance inserts a random `token`, the instance
that holds that token holds the lock until it drops (or times out).
@@ -182,7 +182,7 @@ class LockStore(SQLBaseStore):
self._instance_name,
token,
now,
now - _LOCK_TIMEOUT_MS,
now - _LOCK_TIMEOUT.as_millis(),
),
)
@@ -340,7 +340,9 @@ class LockStore(SQLBaseStore):
"""
def reap_stale_read_write_locks_txn(txn: LoggingTransaction) -> None:
txn.execute(delete_sql, (self.clock.time_msec() - _LOCK_TIMEOUT_MS,))
txn.execute(
delete_sql, (self.clock.time_msec() - _LOCK_TIMEOUT.as_millis(),)
)
if txn.rowcount:
logger.info("Reaped %d stale locks", txn.rowcount)
@@ -489,7 +491,7 @@ class Lock:
)
return (
last_renewed_ts is not None
and self._clock.time_msec() - _LOCK_TIMEOUT_MS < last_renewed_ts
and self._clock.time_msec() - _LOCK_TIMEOUT.as_millis() < last_renewed_ts
)
async def __aenter__(self) -> None:
+11 -8
View File
@@ -2474,14 +2474,6 @@ class RegistrationWorkerStore(StatsStore, CacheInvalidationWorkerStore):
def user_delete_access_tokens_for_devices_txn(
txn: LoggingTransaction, batch_device_ids: StrCollection
) -> list[tuple[str, int, str | None]]:
self.db_pool.simple_delete_many_txn(
txn,
table="refresh_tokens",
keyvalues={"user_id": user_id},
column="device_id",
values=batch_device_ids,
)
clause, args = make_in_list_sql_clause(
txn.database_engine, "device_id", batch_device_ids
)
@@ -2500,6 +2492,17 @@ class RegistrationWorkerStore(StatsStore, CacheInvalidationWorkerStore):
self.get_user_by_access_token,
[(t[0],) for t in tokens_and_devices],
)
# Delete access tokens first, before refresh tokens.
# This ensures we can capture the deleted access tokens for cache invalidation
# before any CASCADE deletes occur.
self.db_pool.simple_delete_many_txn(
txn,
table="refresh_tokens",
keyvalues={"user_id": user_id},
column="device_id",
values=batch_device_ids,
)
return tokens_and_devices
results = []
+10 -70
View File
@@ -62,12 +62,12 @@ from synapse.storage.types import Cursor
from synapse.storage.util.id_generators import IdGenerator, MultiWriterIdGenerator
from synapse.types import (
JsonDict,
MultiWriterStreamToken,
RetentionPolicy,
StrCollection,
ThirdPartyInstanceID,
)
from synapse.util.caches.descriptors import cached, cachedList
from synapse.util.duration import Duration
from synapse.util.json import json_encoder
from synapse.util.stringutils import MXC_REGEX
@@ -1302,7 +1302,15 @@ class RoomWorkerStore(CacheInvalidationWorkerStore):
return local_media_ids
async def get_current_quarantined_media_stream_id(self) -> int:
def get_quarantined_media_stream_token(self) -> MultiWriterStreamToken:
return MultiWriterStreamToken.from_generator(
self._quarantined_media_changes_id_gen
)
def get_quarantined_media_stream_id_generator(self) -> MultiWriterIdGenerator:
return self._quarantined_media_changes_id_gen
def get_current_quarantined_media_stream_id(self) -> int:
"""Gets the position of the quarantined media changes stream.
Returns:
@@ -1318,74 +1326,6 @@ class RoomWorkerStore(CacheInvalidationWorkerStore):
"""
return await self._quarantined_media_changes_id_gen.get_max_allocated_token()
async def wait_for_quarantined_media_stream_id(self, target_id: int) -> bool:
"""Waits until the quarantined media changes stream reaches the given stream ID.
See https://github.com/element-hq/synapse/pull/19644 for more details.
TODO: Replace function and call sites with https://github.com/element-hq/synapse/pull/19644
Args:
target_id: The stream ID to wait for.
Returns:
True when caught up to the target stream ID.
False when timing out while waiting.
"""
# We ideally would use something like `wait_for_stream_position` in the meantime,
# but that short circuits if the instance name matches the current instance name.
# Doing so means that if *another* writer is actually leading the to_id, then we'll
# assume that we're caught up when we aren't.
#
# NOTE: Because this is implemented to wait for stream positions by integer ID,
# we're technically waiting for *all* workers to catch up rather than just waiting
# for *our* worker to catch up. This is okay for now because the quarantined media
# stream should be pretty fast to update, and if it's not then the only thing we're
# affecting is an admin API that probably has a tool automatically retrying requests
# anyway. https://github.com/element-hq/synapse/pull/19644 does the waiting properly
# so this should be replaced by that (or similar).
# Get the minimum shared position/ID across all workers
current_id = self._quarantined_media_changes_id_gen.get_current_token()
if current_id >= target_id:
return True # nothing to wait for: we're already caught up.
# "This should never happen". Tokens we hand out via the API should exist. If they
# don't, then we're in a bad state and need to explode.
max_persisted_position = (
await self._quarantined_media_changes_id_gen.get_max_allocated_token()
)
assert max_persisted_position >= target_id, (
f"Unable to wait for invalid future token (token={target_id} has positions "
f"ahead of our max persisted position={max_persisted_position})"
)
# Start waiting until we've caught up to the `stream_token`
start = self.clock.time_msec()
logged = False
while True:
# Like above, get the minimum shared ID across all workers
current_id = self._quarantined_media_changes_id_gen.get_current_token()
if current_id >= target_id:
return True
now = self.clock.time_msec()
# Timed out
if now - start > 10_000:
return False
if not logged:
logger.info(
"Waiting for current token to reach %s; currently at %s",
target_id,
current_id,
)
logged = True
# TODO: be better
await self.clock.sleep(Duration(milliseconds=500))
async def get_quarantined_media_changes(
self, *, from_id: int, to_id: int, limit: int
) -> list[QuarantinedMediaUpdate]:
+10 -3
View File
@@ -2425,12 +2425,19 @@ class StreamWorkerStore(EventsWorkerStore, SQLBaseStore):
event_filter: If provided filters the events to those that match the filter.
Returns:
The results as a list of events, a token that points to the end of
the result set, and a boolean to indicate if there were more events
but we hit the limit. If no events are returned then the end of the
- The results as a list of events;
- a token that points to the end of the result set; and
- a boolean to indicate if there were more events
but we hit the limit (`limited`)
If no events are returned and `limited` is false, then the end of the
stream has been reached (i.e. there are no events between `from_key`
and `to_key`).
When `limited` is true, that means that more pagination can be attempted.
Note that `limited` can be true even if no events are returned,
because rejected events are filtered out after the limit check.
When Direction.FORWARDS: from_key < x <= to_key, (ascending order)
When Direction.BACKWARDS: from_key >= x > to_key, (descending order)
"""
+13
View File
@@ -85,6 +85,7 @@ class EventSources:
)
thread_subscriptions_key = self.store.get_max_thread_subscriptions_stream_id()
sticky_events_key = self.store.get_max_sticky_events_stream_id()
quarantined_media_key = self.store.get_quarantined_media_stream_token()
profile_updates_key = self.store.get_max_profile_updates_stream_id()
token = StreamToken(
@@ -101,6 +102,7 @@ class EventSources:
un_partial_stated_rooms_key=un_partial_stated_rooms_key,
thread_subscriptions_key=thread_subscriptions_key,
sticky_events_key=sticky_events_key,
quarantined_media_key=quarantined_media_key,
profile_updates_key=profile_updates_key,
)
return token
@@ -130,6 +132,7 @@ class EventSources:
StreamKeyType.UN_PARTIAL_STATED_ROOMS: self.store.get_un_partial_stated_rooms_id_generator(),
StreamKeyType.THREAD_SUBSCRIPTIONS: self.store.get_thread_subscriptions_stream_id_generator(),
StreamKeyType.STICKY_EVENTS: self.store.get_sticky_events_stream_id_generator(),
StreamKeyType.QUARANTINED_MEDIA: self.store.get_quarantined_media_stream_id_generator(),
StreamKeyType.PROFILE_UPDATES: self.store.get_profile_updates_stream_id_generator(),
}
@@ -152,6 +155,16 @@ class EventSources:
].get_max_allocated_token()
if max_token < token_value.get_max_stream_pos():
# Log *something* as we consider this as a Synapse programming error
# (assuming no malicious user manipulation of the token) (we shouldn't be
# handing out future tokens).
#
# We don't assert as the whole point of bounding is so that we can recover
# gracefully.
#
# Old versions of Synapse could advance streams without persisting anything in
# the DB (fixed in https://github.com/element-hq/synapse/pull/17229) and on
# restart, those updates would be lost.
logger.error(
"Bounding token from the future '%s': token: %s, bound: %s",
key,
+70 -2
View File
@@ -10,9 +10,9 @@
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
from typing import Mapping
from typing import Any, Iterator, Mapping
from synapse.types import JsonDict
from synapse.types import JsonDict, JsonMapping
class EventInternalMetadata:
def __init__(self, internal_metadata_dict: JsonDict): ...
@@ -154,3 +154,71 @@ def event_visible_to_server(
Returns:
Whether the server is allowed to see the unredacted event.
"""
class Signatures:
"""A class representing the signatures on an event."""
def __init__(self, signatures: Mapping[str, Mapping[str, str]] | None = None): ...
def get_signature(self, server_name: str, key_id: str) -> str | None: ...
"""Get the signature for the given server name and key ID, if it exists."""
def __getitem__(self, server_name: str) -> Mapping[str, str]: ...
"""Get the signatures for the given server name. Raises KeyError if there
are no signatures for that server."""
def __contains__(self, server_name: Any) -> bool: ...
"""Check if there are signatures for the given server name."""
def __len__(self) -> int: ...
"""Return the number of servers that have signatures."""
def add_signature(self, server_name: str, key_id: str, signature: str) -> None: ...
"""Add a signature for the given server name and key ID."""
def update(self, signatures: Mapping[str, Mapping[str, str]]) -> None: ...
"""Update the signatures with the given signatures.
Will overwrite all existing signatures for the server names provided.
"""
def as_dict(self) -> dict[str, dict[str, str]]: ...
"""Return a copy of the signatures as a dictionary."""
class Unsigned:
"""A class representing the unsigned data of an event."""
def __init__(self, unsigned_dict: JsonMapping): ...
def __getitem__(self, key: str) -> Any: ...
"""Get the value for the given key.
Raises KeyError if the key is unset or not recognised."""
def __setitem__(self, key: str, value: Any) -> None: ...
"""Set the value for the given key.
Raises KeyError if the key is not recognised."""
def __delitem__(self, key: str) -> None: ...
"""Delete the value for the given key.
Raises KeyError if the key is unset or not recognised."""
def __contains__(self, key: Any) -> bool: ...
def get(self, key: str, default: Any = None) -> Any: ...
"""Get the value for the given key, or ``default`` if the key is unset."""
def for_persistence(self) -> JsonDict: ...
"""Return a dict of the fields that should be persisted to the database."""
def for_event(self) -> JsonDict: ...
"""Return a dict of all unsigned fields, including those only kept in
memory, suitable for inclusion in an event."""
class JsonObject(Mapping[str, Any]):
"""Immutable JSON object mapping."""
def __init__(self, content_dict: JsonMapping | None = None): ...
def __len__(self) -> int: ...
def __getitem__(self, key: str) -> Any: ...
def __iter__(self) -> Iterator[str]: ...
def __eq__(self, other: object) -> bool: ...
+26 -4
View File
@@ -685,6 +685,9 @@ class AbstractMultiWriterStreamToken(metaclass=abc.ABCMeta):
def bound_stream_token(self, max_stream: int) -> "Self":
"""Bound the stream positions to a maximum value"""
# Shortcut if we're already under the bound
if self.get_max_stream_pos() <= max_stream:
return self
min_pos = min(self.stream, max_stream)
return type(self)(
@@ -1057,6 +1060,7 @@ class StreamKeyType(Enum):
UN_PARTIAL_STATED_ROOMS = "un_partial_stated_rooms_key"
THREAD_SUBSCRIPTIONS = "thread_subscriptions_key"
STICKY_EVENTS = "sticky_events_key"
QUARANTINED_MEDIA = "quarantined_media_key"
PROFILE_UPDATES = "profile_updates_key"
@@ -1065,7 +1069,7 @@ class StreamToken:
"""A collection of keys joined together by underscores in the following
order and which represent the position in their respective streams.
ex. `s2633508_17_338_6732159_1082514_541479_274711_265584_1_379_4242_4141_101`
ex. `s2633508_17_338_6732159_1082514_541479_274711_265584_1_379_4242_4141_4343_4444`
1. `room_key`: `s2633508` which is a `RoomStreamToken`
- `RoomStreamToken`'s can also look like `t426-2633508` or `m56~2.58~3.59`
- See the docstring for `RoomStreamToken` for more details.
@@ -1080,13 +1084,14 @@ class StreamToken:
10. `un_partial_stated_rooms_key`: `379`
11. `thread_subscriptions_key`: 4242
12. `sticky_events_key`: 4141
13. `profile_updates_key`: 101
13. `quarantined_media_key`: 4343
14. `profile_updates_key`: 4444
You can see how many of these keys correspond to the various
fields in a "/sync" response:
```json
{
"next_batch": "s12_4_0_1_1_1_1_4_1_1",
"next_batch": "s12_4_0_1_1_1_1_4_1_1_1_1_1",
"presence": {
"events": []
},
@@ -1098,7 +1103,7 @@ class StreamToken:
"!QrZlfIDQLNLdZHqTnt:hs1": {
"timeline": {
"events": [],
"prev_batch": "s10_4_0_1_1_1_1_4_1_1",
"prev_batch": "s10_4_0_1_1_1_1_4_1_1_1_1_1",
"limited": false
},
"state": {
@@ -1141,6 +1146,9 @@ class StreamToken:
un_partial_stated_rooms_key: int
thread_subscriptions_key: int
sticky_events_key: int
quarantined_media_key: MultiWriterStreamToken = attr.ib(
validator=attr.validators.instance_of(MultiWriterStreamToken)
)
profile_updates_key: int
_SEPARATOR = "_"
@@ -1171,6 +1179,7 @@ class StreamToken:
un_partial_stated_rooms_key,
thread_subscriptions_key,
sticky_events_key,
quarantined_media_key,
profile_updates_key,
) = keys
@@ -1189,6 +1198,9 @@ class StreamToken:
un_partial_stated_rooms_key=int(un_partial_stated_rooms_key),
thread_subscriptions_key=int(thread_subscriptions_key),
sticky_events_key=int(sticky_events_key),
quarantined_media_key=await MultiWriterStreamToken.parse(
store, quarantined_media_key
),
profile_updates_key=int(profile_updates_key),
)
except CancelledError:
@@ -1214,6 +1226,7 @@ class StreamToken:
str(self.un_partial_stated_rooms_key),
str(self.thread_subscriptions_key),
str(self.sticky_events_key),
await self.quarantined_media_key.to_string(store),
str(self.profile_updates_key),
]
)
@@ -1244,6 +1257,12 @@ class StreamToken:
self.device_list_key.copy_and_advance(new_value),
)
return new_token
elif key == StreamKeyType.QUARANTINED_MEDIA:
new_token = self.copy_and_replace(
StreamKeyType.QUARANTINED_MEDIA,
self.quarantined_media_key.copy_and_advance(new_value),
)
return new_token
new_token = self.copy_and_replace(key, new_value)
new_id = new_token.get_field(key)
@@ -1266,6 +1285,7 @@ class StreamToken:
key: Literal[
StreamKeyType.RECEIPT,
StreamKeyType.DEVICE_LIST,
StreamKeyType.QUARANTINED_MEDIA,
],
) -> MultiWriterStreamToken: ...
@@ -1339,6 +1359,7 @@ class StreamToken:
f"to_device: {self.to_device_key}, device_list: {self.device_list_key}, "
f"groups: {self.groups_key}, un_partial_stated_rooms: {self.un_partial_stated_rooms_key},"
f"thread_subscriptions: {self.thread_subscriptions_key}, sticky_events: {self.sticky_events_key}, "
f"quarantined_media: {self.quarantined_media_key})"
f"profile_updates: {self.profile_updates_key})"
)
@@ -1356,6 +1377,7 @@ StreamToken.START = StreamToken(
un_partial_stated_rooms_key=0,
thread_subscriptions_key=0,
sticky_events_key=0,
quarantined_media_key=MultiWriterStreamToken(stream=0),
profile_updates_key=0,
)
+27
View File
@@ -32,6 +32,33 @@ class Duration(timedelta):
```
"""
# Using `__new__` (instead of `__init__`) because that's what `timedelta` uses
def __new__(
cls,
# The whole goal of overriding `__new__` is to require keyword-only arguments.
# Without this, `Duration(5)` would create a duration represnting 5 *days*
# (timedelta's default), but callers almost certainly want to specify which unit
# like seconds or hours.
*,
days: float = 0,
seconds: float = 0,
microseconds: float = 0,
milliseconds: float = 0,
minutes: float = 0,
hours: float = 0,
weeks: float = 0,
) -> "Duration":
return super().__new__(
cls,
days=days,
seconds=seconds,
microseconds=microseconds,
milliseconds=milliseconds,
minutes=minutes,
hours=hours,
weeks=weeks,
)
def as_millis(self) -> int:
"""Returns the duration in milliseconds."""
return int(self / _ONE_MILLISECOND)
+2 -2
View File
@@ -17,7 +17,7 @@ from typing import Any
from pydantic import Field, StrictStr, ValidationError, field_validator
from synapse.types import JsonDict
from synapse.types import JsonMapping
from synapse.util.pydantic_models import ParseModel
from synapse.util.stringutils import random_string
@@ -103,7 +103,7 @@ class TopicContent(ParseModel):
return None
def get_plain_text_topic_from_event_content(content: JsonDict) -> str | None:
def get_plain_text_topic_from_event_content(content: JsonMapping) -> str | None:
"""
Given the `content` of an `m.room.topic` event, returns the plain-text topic
representation. Prefers pulling plain-text from the newer `m.topic` field if
+5
View File
@@ -23,6 +23,8 @@ from typing import Any
from immutabledict import immutabledict
from synapse.synapse_rust.events import JsonObject
def freeze(o: Any) -> Any:
if isinstance(o, dict):
@@ -31,6 +33,9 @@ def freeze(o: Any) -> Any:
if isinstance(o, immutabledict):
return o
if isinstance(o, JsonObject):
return o
if isinstance(o, (bytes, str)):
return o
+18 -12
View File
@@ -20,24 +20,30 @@ from typing import (
from immutabledict import immutabledict
from synapse.synapse_rust.events import JsonObject
def _reject_invalid_json(val: Any) -> None:
"""Do not allow Infinity, -Infinity, or NaN values in JSON."""
raise ValueError("Invalid JSON value: '%s'" % val)
def _handle_immutabledict(obj: Any) -> dict[Any, Any]:
"""Helper for json_encoder. Makes immutabledicts serializable by returning
the underlying dict
def _handle_extra_mappings(obj: Any) -> dict[Any, Any]:
"""Helper for json_encoder. Makes immutabledicts and JsonObjects
serializable
"""
if type(obj) is immutabledict:
# fishing the protected dict out of the object is a bit nasty,
# but we don't really want the overhead of copying the dict.
try:
# Safety: we catch the AttributeError immediately below.
return obj._dict
except AttributeError:
# If all else fails, resort to making a copy of the immutabledict
match obj:
case immutabledict():
# fishing the protected dict out of the object is a bit nasty,
# but we don't really want the overhead of copying the dict.
try:
# Safety: we catch the AttributeError immediately below.
return obj._dict
except AttributeError:
# If all else fails, resort to making a copy of the immutabledict
return dict(obj)
case JsonObject():
# Just convert to a dict.
return dict(obj)
raise TypeError(
"Object of type %s is not JSON serializable" % obj.__class__.__name__
@@ -49,7 +55,7 @@ def _handle_immutabledict(obj: Any) -> dict[Any, Any]:
# * produces valid JSON (no NaNs etc)
# * reduces redundant whitespace
json_encoder = json.JSONEncoder(
allow_nan=False, separators=(",", ":"), default=_handle_immutabledict
allow_nan=False, separators=(",", ":"), default=_handle_extra_mappings
)
# Create a custom decoder to reject Python extensions to JSON.
+49 -37
View File
@@ -36,7 +36,7 @@ from synapse.util.clock import Clock
from synapse.util.frozenutils import freeze
from tests import unittest
from tests.events.test_utils import MockEvent
from tests.test_utils.event_builders import make_test_event
user_id = UserID.from_string("@test_user:test")
user2_id = UserID.from_string("@test_user2:test")
@@ -134,18 +134,22 @@ class FilteringTestCase(unittest.HomeserverTestCase):
def test_definition_types_works_with_literals(self) -> None:
definition = {"types": ["m.room.message", "org.matrix.foo.bar"]}
event = MockEvent(sender="@foo:bar", type="m.room.message", room_id="!foo:bar")
event = make_test_event(
sender="@foo:bar", type="m.room.message", room_id="!foo:bar"
)
self.assertTrue(Filter(self.hs, definition)._check(event))
def test_definition_types_works_with_wildcards(self) -> None:
definition = {"types": ["m.*", "org.matrix.foo.bar"]}
event = MockEvent(sender="@foo:bar", type="m.room.message", room_id="!foo:bar")
event = make_test_event(
sender="@foo:bar", type="m.room.message", room_id="!foo:bar"
)
self.assertTrue(Filter(self.hs, definition)._check(event))
def test_definition_types_works_with_unknowns(self) -> None:
definition = {"types": ["m.room.message", "org.matrix.foo.bar"]}
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="now.for.something.completely.different",
room_id="!foo:bar",
@@ -154,19 +158,23 @@ class FilteringTestCase(unittest.HomeserverTestCase):
def test_definition_not_types_works_with_literals(self) -> None:
definition = {"not_types": ["m.room.message", "org.matrix.foo.bar"]}
event = MockEvent(sender="@foo:bar", type="m.room.message", room_id="!foo:bar")
event = make_test_event(
sender="@foo:bar", type="m.room.message", room_id="!foo:bar"
)
self.assertFalse(Filter(self.hs, definition)._check(event))
def test_definition_not_types_works_with_wildcards(self) -> None:
definition = {"not_types": ["m.room.message", "org.matrix.*"]}
event = MockEvent(
event = make_test_event(
sender="@foo:bar", type="org.matrix.custom.event", room_id="!foo:bar"
)
self.assertFalse(Filter(self.hs, definition)._check(event))
def test_definition_not_types_works_with_unknowns(self) -> None:
definition = {"not_types": ["m.*", "org.*"]}
event = MockEvent(sender="@foo:bar", type="com.nom.nom.nom", room_id="!foo:bar")
event = make_test_event(
sender="@foo:bar", type="com.nom.nom.nom", room_id="!foo:bar"
)
self.assertTrue(Filter(self.hs, definition)._check(event))
def test_definition_not_types_takes_priority_over_types(self) -> None:
@@ -174,33 +182,35 @@ class FilteringTestCase(unittest.HomeserverTestCase):
"not_types": ["m.*", "org.*"],
"types": ["m.room.message", "m.room.topic"],
}
event = MockEvent(sender="@foo:bar", type="m.room.topic", room_id="!foo:bar")
event = make_test_event(
sender="@foo:bar", type="m.room.topic", room_id="!foo:bar"
)
self.assertFalse(Filter(self.hs, definition)._check(event))
def test_definition_senders_works_with_literals(self) -> None:
definition = {"senders": ["@flibble:wibble"]}
event = MockEvent(
event = make_test_event(
sender="@flibble:wibble", type="com.nom.nom.nom", room_id="!foo:bar"
)
self.assertTrue(Filter(self.hs, definition)._check(event))
def test_definition_senders_works_with_unknowns(self) -> None:
definition = {"senders": ["@flibble:wibble"]}
event = MockEvent(
event = make_test_event(
sender="@challenger:appears", type="com.nom.nom.nom", room_id="!foo:bar"
)
self.assertFalse(Filter(self.hs, definition)._check(event))
def test_definition_not_senders_works_with_literals(self) -> None:
definition = {"not_senders": ["@flibble:wibble"]}
event = MockEvent(
event = make_test_event(
sender="@flibble:wibble", type="com.nom.nom.nom", room_id="!foo:bar"
)
self.assertFalse(Filter(self.hs, definition)._check(event))
def test_definition_not_senders_works_with_unknowns(self) -> None:
definition = {"not_senders": ["@flibble:wibble"]}
event = MockEvent(
event = make_test_event(
sender="@challenger:appears", type="com.nom.nom.nom", room_id="!foo:bar"
)
self.assertTrue(Filter(self.hs, definition)._check(event))
@@ -210,21 +220,21 @@ class FilteringTestCase(unittest.HomeserverTestCase):
"not_senders": ["@misspiggy:muppets"],
"senders": ["@kermit:muppets", "@misspiggy:muppets"],
}
event = MockEvent(
event = make_test_event(
sender="@misspiggy:muppets", type="m.room.topic", room_id="!foo:bar"
)
self.assertFalse(Filter(self.hs, definition)._check(event))
def test_definition_rooms_works_with_literals(self) -> None:
definition = {"rooms": ["!secretbase:unknown"]}
event = MockEvent(
event = make_test_event(
sender="@foo:bar", type="m.room.message", room_id="!secretbase:unknown"
)
self.assertTrue(Filter(self.hs, definition)._check(event))
def test_definition_rooms_works_with_unknowns(self) -> None:
definition = {"rooms": ["!secretbase:unknown"]}
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!anothersecretbase:unknown",
@@ -233,7 +243,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
def test_definition_not_rooms_works_with_literals(self) -> None:
definition = {"not_rooms": ["!anothersecretbase:unknown"]}
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!anothersecretbase:unknown",
@@ -242,7 +252,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
def test_definition_not_rooms_works_with_unknowns(self) -> None:
definition = {"not_rooms": ["!secretbase:unknown"]}
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!anothersecretbase:unknown",
@@ -254,7 +264,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
"not_rooms": ["!secretbase:unknown"],
"rooms": ["!secretbase:unknown"],
}
event = MockEvent(
event = make_test_event(
sender="@foo:bar", type="m.room.message", room_id="!secretbase:unknown"
)
self.assertFalse(Filter(self.hs, definition)._check(event))
@@ -268,7 +278,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
"types": ["m.room.message", "muppets.kermit.*"],
"not_types": ["muppets.misspiggy.*"],
}
event = MockEvent(
event = make_test_event(
sender="@kermit:muppets", # yup
type="m.room.message", # yup
room_id="!stage:unknown", # yup
@@ -284,7 +294,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
"types": ["m.room.message", "muppets.kermit.*"],
"not_types": ["muppets.misspiggy.*"],
}
event = MockEvent(
event = make_test_event(
sender="@misspiggy:muppets", # nope
type="m.room.message", # yup
room_id="!stage:unknown", # yup
@@ -300,7 +310,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
"types": ["m.room.message", "muppets.kermit.*"],
"not_types": ["muppets.misspiggy.*"],
}
event = MockEvent(
event = make_test_event(
sender="@kermit:muppets", # yup
type="m.room.message", # yup
room_id="!piggyshouse:muppets", # nope
@@ -316,7 +326,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
"types": ["m.room.message", "muppets.kermit.*"],
"not_types": ["muppets.misspiggy.*"],
}
event = MockEvent(
event = make_test_event(
sender="@kermit:muppets", # yup
type="muppets.misspiggy.kisses", # nope
room_id="!stage:unknown", # yup
@@ -325,7 +335,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
def test_filter_labels(self) -> None:
definition = {"org.matrix.labels": ["#fun"]}
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!secretbase:unknown",
@@ -334,7 +344,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
self.assertTrue(Filter(self.hs, definition)._check(event))
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!secretbase:unknown",
@@ -344,7 +354,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
self.assertFalse(Filter(self.hs, definition)._check(event))
# check it works with frozen dictionaries too
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!secretbase:unknown",
@@ -354,7 +364,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
def test_filter_not_labels(self) -> None:
definition = {"org.matrix.not_labels": ["#fun"]}
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!secretbase:unknown",
@@ -363,7 +373,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
self.assertFalse(Filter(self.hs, definition)._check(event))
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!secretbase:unknown",
@@ -375,7 +385,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
@unittest.override_config({"experimental_features": {"msc3874_enabled": True}})
def test_filter_rel_type(self) -> None:
definition = {"org.matrix.msc3874.rel_types": ["m.thread"]}
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!secretbase:unknown",
@@ -384,7 +394,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
self.assertFalse(Filter(self.hs, definition)._check(event))
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!secretbase:unknown",
@@ -393,7 +403,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
self.assertFalse(Filter(self.hs, definition)._check(event))
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!secretbase:unknown",
@@ -405,7 +415,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
@unittest.override_config({"experimental_features": {"msc3874_enabled": True}})
def test_filter_not_rel_type(self) -> None:
definition = {"org.matrix.msc3874.not_rel_types": ["m.thread"]}
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!secretbase:unknown",
@@ -414,7 +424,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
self.assertFalse(Filter(self.hs, definition)._check(event))
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!secretbase:unknown",
@@ -423,7 +433,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
self.assertTrue(Filter(self.hs, definition)._check(event))
event = MockEvent(
event = make_test_event(
sender="@foo:bar",
type="m.room.message",
room_id="!secretbase:unknown",
@@ -494,7 +504,9 @@ class FilteringTestCase(unittest.HomeserverTestCase):
user_id=user_id, user_filter=user_filter_json
)
)
event = MockEvent(sender="@foo:bar", type="m.room.topic", room_id="!foo:bar")
event = make_test_event(
sender="@foo:bar", type="m.room.topic", room_id="!foo:bar"
)
events = [event]
user_filter = self.get_success(
@@ -511,7 +523,7 @@ class FilteringTestCase(unittest.HomeserverTestCase):
user_id=user_id, user_filter=user_filter_json
)
)
event = MockEvent(
event = make_test_event(
sender="@foo:bar", type="org.matrix.custom.event", room_id="!foo:bar"
)
events = [event]
@@ -542,14 +554,14 @@ class FilteringTestCase(unittest.HomeserverTestCase):
def test_filter_relations(self) -> None:
events = [
# An event without a relation.
MockEvent(
make_test_event(
event_id="$no_relation",
sender="@foo:bar",
type="org.matrix.custom.event",
room_id="!foo:bar",
),
# An event with a relation.
MockEvent(
make_test_event(
event_id="$with_relation",
sender="@foo:bar",
type="org.matrix.custom.event",
+13 -11
View File
@@ -34,10 +34,11 @@ from synapse.crypto.event_signing import (
event_needs_resigning,
resign_event,
)
from synapse.events import EventBase, make_event_from_dict
from synapse.events import EventBase
from synapse.types import JsonDict
from tests import unittest
from tests.test_utils.event_builders import make_test_event
# Perform these tests using given secret key so we get entirely deterministic
# signatures output that we can test against.
@@ -62,6 +63,7 @@ class EventSigningTestCase(unittest.TestCase):
"origin_server_ts": 1000000,
"signatures": {},
"type": "X",
"content": {},
"unsigned": {"age_ts": 1000000},
}
@@ -69,12 +71,12 @@ class EventSigningTestCase(unittest.TestCase):
RoomVersions.V1, event_dict, HOSTNAME, self.signing_key
)
event = make_event_from_dict(event_dict)
event = make_test_event(event_dict)
self.assertTrue(hasattr(event, "hashes"))
self.assertIn("sha256", event.hashes)
self.assertEqual(
event.hashes["sha256"], "A6Nco6sqoy18PPfPDVdYvoowfc0PVBk9g9OiyT3ncRM"
event.hashes["sha256"], "mq4QfPPpC+QsBd6eqfVsmJIEz8uvMSVK0+AU67PLESk"
)
self.assertTrue(hasattr(event, "signatures"))
@@ -82,8 +84,8 @@ class EventSigningTestCase(unittest.TestCase):
self.assertIn(KEY_NAME, event.signatures["domain"])
self.assertEqual(
event.signatures[HOSTNAME][KEY_NAME],
"PBc48yDVszWB9TRaB/+CZC1B+pDAC10F8zll006j+NN"
"fe4PEMWcVuLaG63LFTK9e4rwJE8iLZMPtCKhDTXhpAQ",
"18rGIkd4JJXxw9m+1j3BtN+TmqmLip4VHvFbyXLngpB"
"LXOqbxlQViQABRzep2cODQ2aa5FnFgz+Llt2P03WiAw",
)
def test_sign_message(self) -> None:
@@ -102,7 +104,7 @@ class EventSigningTestCase(unittest.TestCase):
RoomVersions.V1, event_dict, HOSTNAME, self.signing_key
)
event = make_event_from_dict(event_dict)
event = make_test_event(event_dict)
self.assertTrue(hasattr(event, "hashes"))
self.assertIn("sha256", event.hashes)
@@ -140,7 +142,7 @@ class EventResigningTestCase(unittest.TestCase):
add_hashes_and_signatures(
RoomVersions.V1, event_dict, HOSTNAME, self.signing_key
)
event = make_event_from_dict(event_dict)
event = make_test_event(event_dict)
self.assertIn(HOSTNAME, event.signatures)
self.assertIn(KEY_NAME, event.signatures[HOSTNAME])
signature = event.signatures[HOSTNAME][KEY_NAME]
@@ -170,7 +172,7 @@ class EventResigningTestCase(unittest.TestCase):
"signatures": {},
"unsigned": {"age_ts": 1000000},
}
event = make_event_from_dict(event_dict)
event = make_test_event(event_dict)
resigned_event = resign_event(event, HOSTNAME, signing_key_2)
self.assertIn(HOSTNAME, resigned_event["signatures"])
self.assertIn(key_name_2, resigned_event["signatures"][HOSTNAME])
@@ -187,7 +189,7 @@ class EventResigningTestCase(unittest.TestCase):
"unsigned": {"age_ts": 1000000},
}
internal_metadata: JsonDict = {}
event_that_needs_resigning = make_event_from_dict(
event_that_needs_resigning = make_test_event(
event_that_needs_resigning_dict,
RoomVersions.V1,
internal_metadata,
@@ -206,7 +208,7 @@ class EventResigningTestCase(unittest.TestCase):
events_that_dont_need_resigning: list[TestCase] = [
{
"name": "sender domain isn't ours",
"event": make_event_from_dict(
"event": make_test_event(
{**event_that_needs_resigning_dict, "sender": "@u:somewhereelse"},
RoomVersions.V1,
internal_metadata,
@@ -214,7 +216,7 @@ class EventResigningTestCase(unittest.TestCase):
},
{
"name": "already signed with this key",
"event": make_event_from_dict(
"event": make_test_event(
{
**event_that_needs_resigning_dict,
"signatures": {
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more