Skip to content

Commit e635873

Browse files
committed
Merge remote-tracking branch 'upstream/release-v1.143' into famedly-release/v1.143
2 parents e222712 + c176f4b commit e635873

21 files changed

+542
-329
lines changed

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ update-ca-certificates
252252

253253
## Sytest parity
254254

255-
As of 10 February 2023:
255+
As of 29 October 2025:
256256
```
257257
$ go build ./cmd/sytest-coverage
258258
$ ./sytest-coverage -v
@@ -507,7 +507,13 @@ $ ./sytest-coverage -v
507507
✓ Can get rooms/{roomId}/members
508508
509509
30rooms/60version_upgrade 0/19 tests
510-
30rooms/70publicroomslist 0/5 tests
510+
30rooms/70publicroomslist 2/5 tests
511+
× Asking for a remote rooms list, but supplying the local server's name, returns the local rooms list
512+
× Can get remote public room list
513+
× Can paginate public room list
514+
✓ Can search public room list
515+
✓ Name/topic keys are correct
516+
511517
31sync/01filter 2/2 tests
512518
✓ Can create filter
513519
✓ Can download filter
@@ -707,5 +713,5 @@ $ ./sytest-coverage -v
707713
90jira/SYN-516 0/1 tests
708714
90jira/SYN-627 0/1 tests
709715
710-
TOTAL: 220/610 tests converted
716+
TOTAL: 222/610 tests converted
711717
```

client/client.go

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626

2727
"github.com/matrix-org/complement/b"
2828
"github.com/matrix-org/complement/ct"
29+
"github.com/matrix-org/complement/internal"
2930
)
3031

3132
type ctxKey string
@@ -767,6 +768,9 @@ func (c *CSAPI) MustDo(t ct.TestLike, method string, paths []string, opts ...Req
767768
// match.JSONKeyEqual("errcode", "M_INVALID_USERNAME"),
768769
// },
769770
// })
771+
//
772+
// The caller does not need to worry about closing the returned `http.Response.Body` as
773+
// this is handled automatically.
770774
func (c *CSAPI) Do(t ct.TestLike, method string, paths []string, opts ...RequestOpt) *http.Response {
771775
t.Helper()
772776
escapedPaths := make([]string, len(paths))
@@ -815,6 +819,30 @@ func (c *CSAPI) Do(t ct.TestLike, method string, paths []string, opts ...Request
815819
if err != nil {
816820
ct.Fatalf(t, "CSAPI.Do response returned error: %s", err)
817821
}
822+
// `defer` is function scoped but it's okay that we only clean up all requests at
823+
// the end. To also be clear, `defer` arguments are evaluated at the time of the
824+
// `defer` statement so we are only closing the original response body here. Our new
825+
// response body will be untouched.
826+
defer internal.CloseIO(
827+
res.Body,
828+
fmt.Sprintf(
829+
"CSAPI.Do: response body from %s %s",
830+
res.Request.Method,
831+
res.Request.URL.String(),
832+
),
833+
)
834+
835+
// Make a copy of the response body so that downstream callers can read it multiple
836+
// times if needed and don't need to worry about closing it.
837+
var resBody []byte
838+
if res.Body != nil {
839+
resBody, err = io.ReadAll(res.Body)
840+
if err != nil {
841+
ct.Fatalf(t, "CSAPI.Do failed to read response body for RetryUntil check: %s", err)
842+
}
843+
res.Body = io.NopCloser(bytes.NewBuffer(resBody))
844+
}
845+
818846
// debug log the response
819847
if c.Debug && res != nil {
820848
var dump []byte
@@ -824,19 +852,12 @@ func (c *CSAPI) Do(t ct.TestLike, method string, paths []string, opts ...Request
824852
}
825853
t.Logf("%s", string(dump))
826854
}
855+
827856
if retryUntil == nil || retryUntil.timeout == 0 {
828857
return res // don't retry
829858
}
830859

831-
// check the condition, make a copy of the response body first in case the check consumes it
832-
var resBody []byte
833-
if res.Body != nil {
834-
resBody, err = io.ReadAll(res.Body)
835-
if err != nil {
836-
ct.Fatalf(t, "CSAPI.Do failed to read response body for RetryUntil check: %s", err)
837-
}
838-
res.Body = io.NopCloser(bytes.NewBuffer(resBody))
839-
}
860+
// check the condition
840861
if retryUntil.untilFn(res) {
841862
// remake the response and return
842863
res.Body = io.NopCloser(bytes.NewBuffer(resBody))

client/sync.go

Lines changed: 109 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net/http"
66
"net/url"
77
"reflect"
8+
"slices"
89
"sort"
910
"strings"
1011
"time"
@@ -269,95 +270,138 @@ func SyncPresenceHas(fromUser string, expectedPresence *string, checks ...func(g
269270
}
270271
}
271272

272-
// Checks that `userID` gets invited to `roomID`.
273+
// syncMembershipIn checks that `userID` has `membership` in `roomID`, with optional
274+
// extra checks on the found membership event.
273275
//
274-
// This checks different parts of the /sync response depending on the client making the request.
275-
// If the client is also the person being invited to the room then the 'invite' block will be inspected.
276-
// If the client is different to the person being invited then the 'join' block will be inspected.
277-
func SyncInvitedTo(userID, roomID string) SyncCheckOpt {
278-
return func(clientUserID string, topLevelSyncJSON gjson.Result) error {
279-
// two forms which depend on what the client user is:
280-
// - passively viewing an invite for a room you're joined to (timeline events)
281-
// - actively being invited to a room.
282-
if clientUserID == userID {
283-
// active
284-
err := checkArrayElements(
285-
topLevelSyncJSON, "rooms.invite."+GjsonEscape(roomID)+".invite_state.events",
286-
func(ev gjson.Result) bool {
287-
return ev.Get("type").Str == "m.room.member" && ev.Get("state_key").Str == userID && ev.Get("content.membership").Str == "invite"
288-
},
289-
)
290-
if err != nil {
291-
return fmt.Errorf("SyncInvitedTo(%s): %s", roomID, err)
292-
}
293-
return nil
294-
}
295-
// passive
296-
return SyncTimelineHas(roomID, func(ev gjson.Result) bool {
297-
return ev.Get("type").Str == "m.room.member" && ev.Get("state_key").Str == userID && ev.Get("content.membership").Str == "invite"
298-
})(clientUserID, topLevelSyncJSON)
299-
}
300-
}
301-
302-
// Check that `userID` gets joined to `roomID` by inspecting the join timeline for a membership event.
276+
// This can be also used to passively observe another user's membership changes in a
277+
// room although we assume that the observing client is joined to the room.
303278
//
304-
// Additional checks can be passed to narrow down the check, all must pass.
305-
func SyncJoinedTo(userID, roomID string, checks ...func(gjson.Result) bool) SyncCheckOpt {
306-
checkJoined := func(ev gjson.Result) bool {
307-
if ev.Get("type").Str == "m.room.member" && ev.Get("state_key").Str == userID && ev.Get("content.membership").Str == "join" {
279+
// Note: This will not work properly with leave/ban membership for initial syncs, see
280+
// https://github.com/matrix-org/matrix-doc/issues/3537
281+
func syncMembershipIn(userID, roomID, membership string, checks ...func(gjson.Result) bool) SyncCheckOpt {
282+
checkMembership := func(ev gjson.Result) bool {
283+
if ev.Get("type").Str == "m.room.member" && ev.Get("state_key").Str == userID && ev.Get("content.membership").Str == membership {
308284
for _, check := range checks {
309285
if !check(ev) {
310286
// short-circuit, bail early
311287
return false
312288
}
313289
}
314-
// passed both basic join check and all other checks
290+
// passed both basic membership check and all other checks
315291
return true
316292
}
317293
return false
318294
}
319295
return func(clientUserID string, topLevelSyncJSON gjson.Result) error {
320-
// Check both the timeline and the state events for the join event
321-
// since on initial sync, the state events may only be in
322-
// <room>.state.events.
296+
// Check both the timeline and the state events for the membership event since on
297+
// initial sync, the state events may only be in state. Additionally, state only
298+
// covers the "updates for the room up to the start of the timeline."
299+
300+
// We assume the passively observing client user is joined to the room
301+
roomTypeKey := "join"
302+
// Otherwise, if the client is the user whose membership we are checking, we need to
303+
// pick the correct room type JSON key based on the membership being checked.
304+
if clientUserID == userID {
305+
if membership == "join" {
306+
roomTypeKey = "join"
307+
} else if membership == "leave" || membership == "ban" {
308+
roomTypeKey = "leave"
309+
} else if membership == "invite" {
310+
roomTypeKey = "invite"
311+
} else if membership == "knock" {
312+
roomTypeKey = "knock"
313+
} else {
314+
return fmt.Errorf("syncMembershipIn(%s, %s): unknown membership: %s", roomID, membership, membership)
315+
}
316+
}
317+
318+
// We assume the passively observing client user is joined to the room (`rooms.join.<roomID>.state`)
319+
stateKey := "state"
320+
// Otherwise, if the client is the user whose membership we are checking,
321+
// we need to pick the correct JSON key based on the membership being checked.
322+
if clientUserID == userID {
323+
if membership == "join" || membership == "leave" || membership == "ban" {
324+
stateKey = "state"
325+
} else if membership == "invite" {
326+
stateKey = "invite_state"
327+
} else if membership == "knock" {
328+
stateKey = "knock_state"
329+
} else {
330+
return fmt.Errorf("syncMembershipIn(%s, %s): unknown membership: %s", roomID, membership, membership)
331+
}
332+
}
333+
334+
// Check the state first as it's a better source of truth than the `timeline`.
335+
//
336+
// FIXME: Ideally, we'd use something like `state_after` to get the actual current
337+
// state in the room instead of us assuming that no state resets/conflicts happen
338+
// when we apply state from the `timeline` on top of the `state`. But `state_after`
339+
// is gated behind a sync request parameter which we can't control here.
323340
firstErr := checkArrayElements(
324-
topLevelSyncJSON, "rooms.join."+GjsonEscape(roomID)+".timeline.events", checkJoined,
341+
topLevelSyncJSON, "rooms."+roomTypeKey+"."+GjsonEscape(roomID)+"."+stateKey+".events", checkMembership,
325342
)
326343
if firstErr == nil {
327344
return nil
328345
}
329346

330-
secondErr := checkArrayElements(
331-
topLevelSyncJSON, "rooms.join."+GjsonEscape(roomID)+".state.events", checkJoined,
332-
)
333-
if secondErr == nil {
334-
return nil
347+
// Check the timeline
348+
//
349+
// This is also important to differentiate between leave/ban because those both
350+
// appear in the `leave` `roomTypeKey` and we need to specifically check the
351+
// timeline for the membership event to differentiate them.
352+
var secondErr error
353+
// The `timeline` is only available for join/leave/ban memberships.
354+
if slices.Contains([]string{"join", "leave", "ban"}, membership) ||
355+
// We assume the passively observing client user is joined to the room (therefore
356+
// has `timeline`).
357+
clientUserID != userID {
358+
secondErr = checkArrayElements(
359+
topLevelSyncJSON, "rooms."+roomTypeKey+"."+GjsonEscape(roomID)+".timeline.events", checkMembership,
360+
)
361+
if secondErr == nil {
362+
return nil
363+
}
335364
}
336-
return fmt.Errorf("SyncJoinedTo(%s): %s & %s", roomID, firstErr, secondErr)
365+
366+
return fmt.Errorf("syncMembershipIn(%s, %s): %s & %s - %s", roomID, membership, firstErr, secondErr, topLevelSyncJSON)
337367
}
338368
}
339369

340-
// Check that `userID` is leaving `roomID` by inspecting the timeline for a membership event, or witnessing `roomID` in `rooms.leave`
370+
// Checks that `userID` gets invited to `roomID`
371+
//
372+
// Additional checks can be passed to narrow down the check, all must pass.
373+
func SyncInvitedTo(userID, roomID string, checks ...func(gjson.Result) bool) SyncCheckOpt {
374+
return syncMembershipIn(userID, roomID, "invite", checks...)
375+
}
376+
377+
// Checks that `userID` has knocked on `roomID`
378+
//
379+
// Additional checks can be passed to narrow down the check, all must pass.
380+
func SyncKnockedOn(userID, roomID string, checks ...func(gjson.Result) bool) SyncCheckOpt {
381+
return syncMembershipIn(userID, roomID, "knock", checks...)
382+
}
383+
384+
// Check that `userID` gets joined to `roomID`
385+
//
386+
// Additional checks can be passed to narrow down the check, all must pass.
387+
func SyncJoinedTo(userID, roomID string, checks ...func(gjson.Result) bool) SyncCheckOpt {
388+
return syncMembershipIn(userID, roomID, "join", checks...)
389+
}
390+
391+
// Check that `userID` has left the `roomID`
341392
// Note: This will not work properly with initial syncs, see https://github.com/matrix-org/matrix-doc/issues/3537
342-
func SyncLeftFrom(userID, roomID string) SyncCheckOpt {
343-
return func(clientUserID string, topLevelSyncJSON gjson.Result) error {
344-
// two forms which depend on what the client user is:
345-
// - passively viewing a membership for a room you're joined in
346-
// - actively leaving the room
347-
if clientUserID == userID {
348-
// active
349-
events := topLevelSyncJSON.Get("rooms.leave." + GjsonEscape(roomID))
350-
if !events.Exists() {
351-
return fmt.Errorf("no leave section for room %s", roomID)
352-
} else {
353-
return nil
354-
}
355-
}
356-
// passive
357-
return SyncTimelineHas(roomID, func(ev gjson.Result) bool {
358-
return ev.Get("type").Str == "m.room.member" && ev.Get("state_key").Str == userID && ev.Get("content.membership").Str == "leave"
359-
})(clientUserID, topLevelSyncJSON)
360-
}
393+
//
394+
// Additional checks can be passed to narrow down the check, all must pass.
395+
func SyncLeftFrom(userID, roomID string, checks ...func(gjson.Result) bool) SyncCheckOpt {
396+
return syncMembershipIn(userID, roomID, "leave", checks...)
397+
}
398+
399+
// Check that `userID` is banned from the `roomID`
400+
// Note: This will not work properly with initial syncs, see https://github.com/matrix-org/matrix-doc/issues/3537
401+
//
402+
// Additional checks can be passed to narrow down the check, all must pass.
403+
func SyncBannedFrom(userID, roomID string, checks ...func(gjson.Result) bool) SyncCheckOpt {
404+
return syncMembershipIn(userID, roomID, "ban", checks...)
361405
}
362406

363407
// Calls the `check` function for each global account data event, and returns with success if the

cmd/account-snapshot/internal/sync.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"os"
1313
"strconv"
1414
"strings"
15+
16+
"github.com/matrix-org/complement/internal"
1517
)
1618

1719
// LoadSyncData loads sync data from disk or by doing a /sync request
@@ -75,7 +77,14 @@ func doRequest(httpCli *http.Client, req *http.Request, token string) ([]byte, e
7577
if err != nil {
7678
return nil, fmt.Errorf("failed to perform request: %w", err)
7779
}
78-
defer res.Body.Close()
80+
defer internal.CloseIO(
81+
res.Body,
82+
fmt.Sprintf(
83+
"doRequest: response body from %s %s",
84+
res.Request.Method,
85+
res.Request.URL.String(),
86+
),
87+
)
7988
if res.StatusCode != 200 {
8089
return nil, fmt.Errorf("response returned %s", res.Status)
8190
}

federation/server.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package federation
44

55
import (
6+
"bytes"
67
"context"
78
"crypto/ed25519"
89
"crypto/rand"
@@ -12,6 +13,7 @@ import (
1213
"encoding/json"
1314
"encoding/pem"
1415
"fmt"
16+
"io"
1517
"io/ioutil"
1618
"math/big"
1719
"net"
@@ -32,6 +34,7 @@ import (
3234

3335
"github.com/matrix-org/complement/config"
3436
"github.com/matrix-org/complement/ct"
37+
"github.com/matrix-org/complement/internal"
3538
)
3639

3740
// Subset of Deployment used in federation
@@ -278,6 +281,9 @@ func (s *Server) SendFederationRequest(
278281
// DoFederationRequest signs and sends an arbitrary federation request from this server, and returns the response.
279282
//
280283
// The requests will be routed according to the deployment map in `deployment`.
284+
//
285+
// The caller does not need to worry about closing the returned `http.Response.Body` as
286+
// this is handled automatically.
281287
func (s *Server) DoFederationRequest(
282288
ctx context.Context,
283289
t ct.TestLike,
@@ -297,12 +303,25 @@ func (s *Server) DoFederationRequest(
297303

298304
var resp *http.Response
299305
resp, err = httpClient.DoHTTPRequest(ctx, httpReq)
306+
defer internal.CloseIO(resp.Body, "DoFederationRequest: federation response body")
300307

301308
if httpError, ok := err.(gomatrix.HTTPError); ok {
302309
t.Logf("[SSAPI] %s %s%s => error(%d): %s (%s)", req.Method(), req.Destination(), req.RequestURI(), httpError.Code, err, time.Since(start))
303310
} else if err == nil {
304311
t.Logf("[SSAPI] %s %s%s => %d (%s)", req.Method(), req.Destination(), req.RequestURI(), resp.StatusCode, time.Since(start))
305312
}
313+
314+
// Make a copy of the response body so that downstream callers can read it multiple
315+
// times if needed and don't need to worry about closing it.
316+
var respBody []byte
317+
if resp.Body != nil {
318+
respBody, err = io.ReadAll(resp.Body)
319+
if err != nil {
320+
ct.Fatalf(t, "CSAPI.Do failed to read response body for RetryUntil check: %s", err)
321+
}
322+
resp.Body = io.NopCloser(bytes.NewBuffer(respBody))
323+
}
324+
306325
return resp, err
307326
}
308327

0 commit comments

Comments
 (0)