From 61b0553ae9883bf8d7ce8f099b7da10e7ecac402 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 27 Sep 2022 12:08:39 +0300 Subject: [PATCH] Implement MSC3873 and match Synapse behavior in push rules Synapse's current behavior isn't specced, but MSC3873 adds the current behavior as a backwards compat method and defines some sensible escaping. https://github.com/matrix-org/matrix-spec-proposals/pull/3873 --- pushrules/condition.go | 72 +++++++++++++++++++++++++++++++----- pushrules/rule_test.go | 83 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 11 deletions(-) diff --git a/pushrules/condition.go b/pushrules/condition.go index 49f88f48..0b8aa22b 100644 --- a/pushrules/condition.go +++ b/pushrules/condition.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020 Tulir Asokan +// Copyright (c) 2022 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -7,6 +7,7 @@ package pushrules import ( + "fmt" "regexp" "strconv" "strings" @@ -62,14 +63,59 @@ func (cond *PushCondition) Match(room Room, evt *event.Event) bool { } } -func (cond *PushCondition) matchValue(room Room, evt *event.Event) bool { - index := strings.IndexRune(cond.Key, '.') - key := cond.Key - subkey := "" - if index > 0 { - subkey = key[index+1:] - key = key[0:index] +func splitWithEscaping(s string, separator, escape byte) []string { + var token []byte + var tokens []string + for i := 0; i < len(s); i++ { + if s[i] == separator { + tokens = append(tokens, string(token)) + token = token[:0] + } else if s[i] == escape && i+1 < len(s) { + i++ + token = append(token, s[i]) + } else { + token = append(token, s[i]) + } + } + tokens = append(tokens, string(token)) + return tokens +} + +func hackyNestedGet(data map[string]interface{}, path []string) (interface{}, bool) { + val, ok := data[path[0]] + if len(path) == 1 { + // We don't have any more path parts, return the value regardless of whether it exists or not. + return val, ok + } else if ok { + if mapVal, ok := val.(map[string]interface{}); ok { + val, ok = hackyNestedGet(mapVal, path[1:]) + if ok { + return val, true + } + } } + // If we don't find the key, try to combine the first two parts. + // e.g. if the key is content.m.relates_to.rel_type, we'll first try data["m"], which will fail, + // then combine m and relates_to to get data["m.relates_to"], which should succeed. + path[1] = path[0] + "." + path[1] + return hackyNestedGet(data, path[1:]) +} + +func stringifyForPushCondition(val interface{}) string { + switch typedVal := val.(type) { + case string: + return typedVal + case float64: + // Floats aren't allowed in Matrix events, but the JSON parser always stores numbers as floats, + // so just handle that and convert to int + return strconv.FormatInt(int64(typedVal), 10) + default: + return fmt.Sprint(val) + } +} + +func (cond *PushCondition) matchValue(room Room, evt *event.Event) bool { + key, subkey, _ := strings.Cut(cond.Key, ".") pattern, err := glob.Compile(cond.Pattern) if err != nil { @@ -89,8 +135,14 @@ func (cond *PushCondition) matchValue(room Room, evt *event.Event) bool { } return pattern.MatchString(*evt.StateKey) case "content": - val, _ := evt.Content.Raw[subkey].(string) - return pattern.MatchString(val) + // Split the match key with escaping to implement https://github.com/matrix-org/matrix-spec-proposals/pull/3873 + splitKey := splitWithEscaping(subkey, '.', '\\') + // Then do a hacky nested get that supports combining parts for the backwards-compat part of MSC3873 + val, ok := hackyNestedGet(evt.Content.Raw, splitKey) + if !ok { + return cond.Pattern == "" + } + return pattern.MatchString(stringifyForPushCondition(val)) default: return false } diff --git a/pushrules/rule_test.go b/pushrules/rule_test.go index 0a382f5c..305f8ad8 100644 --- a/pushrules/rule_test.go +++ b/pushrules/rule_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020 Tulir Asokan +// Copyright (c) 2022 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -31,6 +31,87 @@ func TestPushRule_Match_Conditions(t *testing.T) { assert.True(t, rule.Match(blankTestRoom, evt)) } +func TestPushRule_Match_Conditions_NestedKey(t *testing.T) { + cond1 := newMatchPushCondition("content.m.relates_to.rel_type", "m.replace") + rule := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1}, + } + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + RelatesTo: &event.RelatesTo{ + Type: event.RelReplace, + EventID: "$meow", + }, + }) + assert.True(t, rule.Match(blankTestRoom, evt)) + + evt = newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "is testing pushrules", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Conditions_NestedKey_Boolean(t *testing.T) { + cond1 := newMatchPushCondition("content.fi.mau.will_auto_accept", "true") + rule := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1}, + } + + evt := newFakeEvent(event.EventMessage, &event.MemberEventContent{ + Membership: "invite", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) + evt.Content.Raw["fi.mau.will_auto_accept"] = true + assert.True(t, rule.Match(blankTestRoom, evt)) + delete(evt.Content.Raw, "fi.mau.will_auto_accept") + assert.False(t, rule.Match(blankTestRoom, evt)) + evt.Content.Raw["fi.mau"] = map[string]interface{}{ + "will_auto_accept": true, + } + assert.True(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Conditions_EscapedKey(t *testing.T) { + cond1 := newMatchPushCondition("content.fi\\.mau\\.will_auto_accept", "true") + rule := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1}, + } + + evt := newFakeEvent(event.EventMessage, &event.MemberEventContent{ + Membership: "invite", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) + evt.Content.Raw["fi.mau.will_auto_accept"] = true + assert.True(t, rule.Match(blankTestRoom, evt)) +} + +func TestPushRule_Match_Conditions_EscapedKey_NoNesting(t *testing.T) { + cond1 := newMatchPushCondition("content.fi\\.mau\\.will_auto_accept", "true") + rule := &pushrules.PushRule{ + Type: pushrules.OverrideRule, + Enabled: true, + Conditions: []*pushrules.PushCondition{cond1}, + } + + evt := newFakeEvent(event.EventMessage, &event.MemberEventContent{ + Membership: "invite", + }) + assert.False(t, rule.Match(blankTestRoom, evt)) + evt.Content.Raw["fi.mau"] = map[string]interface{}{ + "will_auto_accept": true, + } + assert.False(t, rule.Match(blankTestRoom, evt)) +} + func TestPushRule_Match_Conditions_Disabled(t *testing.T) { cond1 := newMatchPushCondition("content.msgtype", "m.emote") cond2 := newMatchPushCondition("content.body", "*pushrules")