package pushrules import ( "bytes" "encoding/json" "fmt" "regexp" "strconv" "strings" "sync" ) // Event is an event as the push rules see it: a tree of JSON values that // dotted keys are looked up in. type Event struct { tree map[string]any // hasMentions is whether content carries m.mentions at all, which // switches the legacy mention rules off. hasMentions bool } // NewEvent parses an event. Any shape of event works — the federation // form or the client one — because the keys rules look at (type, sender, // state_key, room_id, content) are the same in both. func NewEvent(raw []byte) (*Event, error) { dec := json.NewDecoder(bytes.NewReader(raw)) // Numbers stay as written, so that an integer compares as one: a float // round trip would make 9007199254740993 equal to its neighbour. dec.UseNumber() var tree map[string]any if err := dec.Decode(&tree); err != nil { return nil, fmt.Errorf("pushrules: event is not a JSON object: %w", err) } ev := &Event{tree: tree} if content, ok := tree["content"].(map[string]any); ok { _, ev.hasMentions = content["m.mentions"] } return ev, nil } // lookup follows a dotted key into the event. func (e *Event) lookup(key string) (any, bool) { var cur any = e.tree for _, part := range SplitKey(key) { obj, ok := cur.(map[string]any) if !ok { return nil, false } if cur, ok = obj[part]; !ok { return nil, false } } return cur, true } // str is the string at a key, if there is one. func (e *Event) str(key string) (string, bool) { v, ok := e.lookup(key) if !ok { return "", false } s, ok := v.(string) return s, ok } // SplitKey splits a dotted key into its parts. A dot preceded by a // backslash is part of a name rather than a separator, which is how a key // reaches into `m.mentions`; a backslash preceded by one is a literal // backslash. Any other backslash is kept as it is. func SplitKey(key string) []string { var parts []string var cur strings.Builder for i := 0; i < len(key); i++ { c := key[i] switch { case c == '\\' && i+1 < len(key) && (key[i+1] == '.' || key[i+1] == '\\'): cur.WriteByte(key[i+1]) i++ case c == '.': parts = append(parts, cur.String()) cur.Reset() default: cur.WriteByte(c) } } return append(parts, cur.String()) } // Context is what an evaluation needs to know beyond the event itself. type Context struct { // UserID is the user the rules belong to. UserID string // DisplayName is the user's display name in the room, or empty. DisplayName string // MemberCount is how many users are joined to the room. MemberCount int // SenderMayNotify reports whether the event's sender has the power a // notification key — "room", for @room — requires. Nil means nobody // has. SenderMayNotify func(key string) bool } // Evaluate runs the ruleset against an event and returns the first // enabled rule that matches it, kind by kind in priority order. func (rs *Ruleset) Evaluate(ev *Event, ctx Context) (Rule, Kind, bool) { for _, kind := range Kinds { for _, rule := range rs.Rules(kind) { if !rule.Enabled { continue } if rule.Default && ev.hasMentions && legacyMentionRules[rule.RuleID] { continue } if ruleMatches(kind, rule, ev, ctx) { return rule, kind, true } } } return Rule{}, "", false } func ruleMatches(kind Kind, rule Rule, ev *Event, ctx Context) bool { switch kind { case KindOverride, KindUnderride: for _, cond := range rule.Conditions { if !conditionMatches(cond, ev, ctx) { return false } } return true case KindContent: if rule.Pattern == nil { return false } body, ok := ev.str("content.body") return ok && globMatches(*rule.Pattern, body, true) case KindRoom: roomID, ok := ev.str("room_id") return ok && roomID == rule.RuleID case KindSender: sender, ok := ev.str("sender") return ok && sender == rule.RuleID } return false } func conditionMatches(cond Condition, ev *Event, ctx Context) bool { switch cond.Kind { case CondEventMatch: if cond.Pattern == nil { return false } // event_match is about strings: a number or a boolean at the key // is not a match for any pattern, "*" included. value, ok := ev.str(cond.Key) return ok && globMatches(*cond.Pattern, value, cond.Key == "content.body") case CondEventPropertyIs: want, ok := scalar(cond.Value) if !ok { return false } got, ok := ev.lookup(cond.Key) return ok && scalarEqual(got, want) case CondEventPropertyContains: want, ok := scalar(cond.Value) if !ok { return false } got, ok := ev.lookup(cond.Key) if !ok { return false } list, ok := got.([]any) if !ok { return false } for _, item := range list { if scalarEqual(item, want) { return true } } return false case CondContainsDisplayName: if ctx.DisplayName == "" { return false } body, ok := ev.str("content.body") return ok && literalMatches(ctx.DisplayName, body) case CondRoomMemberCount: return memberCountMatches(cond.Is, ctx.MemberCount) case CondSenderNotificationPermission: return cond.Key != "" && ctx.SenderMayNotify != nil && ctx.SenderMayNotify(cond.Key) } return false } // scalar decodes a condition's value. Only the canonical JSON scalars may // be compared: a string, an integer, a boolean or null. func scalar(raw json.RawMessage) (any, bool) { if len(raw) == 0 { return nil, false } dec := json.NewDecoder(bytes.NewReader(raw)) dec.UseNumber() var v any if err := dec.Decode(&v); err != nil { return nil, false } switch v.(type) { case string, bool, nil, json.Number: return v, true } return nil, false } // scalarEqual compares an event value with a condition value. Numbers are // compared as integers, which is all canonical JSON allows. func scalarEqual(got, want any) bool { switch w := want.(type) { case nil: return got == nil case string: g, ok := got.(string) return ok && g == w case bool: g, ok := got.(bool) return ok && g == w case json.Number: g, ok := got.(json.Number) if !ok { return false } gi, err1 := g.Int64() wi, err2 := w.Int64() return err1 == nil && err2 == nil && gi == wi } return false } // memberCountMatches evaluates room_member_count's "is": an optional // comparison operator, then an integer. No operator means equality. func memberCountMatches(is string, count int) bool { op := "==" for _, candidate := range []string{"==", "<=", ">=", "<", ">"} { if rest, ok := strings.CutPrefix(is, candidate); ok { op, is = candidate, rest break } } n, err := strconv.Atoi(is) if err != nil { return false } switch op { case "==": return count == n case "<": return count < n case ">": return count > n case "<=": return count <= n case ">=": return count >= n } return false } // Glob matching. // // A pattern matches case-insensitively, with `*` for any run of characters // and `?` for one. It normally has to match the whole value. Against // content.body it instead has to match a run of whole words somewhere in // it: a body is prose, and the rule that looks for "alice" should fire on // "hi alice!" and not on "malice". // wordChar is the class a word boundary is the edge of: letters and digits // in any script, and the underscore, as Python's \w — and so Synapse — // has it. const wordChar = `\p{L}\p{N}_` var ( globCacheMu sync.Mutex globCache = map[string]*regexp.Regexp{} ) // maxGlobCache bounds the compiled pattern cache. Patterns come from // users, so an unbounded cache would be a way to fill the server's memory; // past the bound it starts again, which costs a compile per pattern. const maxGlobCache = 4096 func globMatches(pattern, value string, words bool) bool { re := compileGlob(pattern, words) return re != nil && re.MatchString(value) } func compileGlob(pattern string, words bool) *regexp.Regexp { key := "e\x00" + pattern if words { key = "w\x00" + pattern } globCacheMu.Lock() re, ok := globCache[key] globCacheMu.Unlock() if ok { return re } var b strings.Builder for _, r := range pattern { switch r { case '*': b.WriteString(".*?") case '?': b.WriteString(".") default: b.WriteString(regexp.QuoteMeta(string(r))) } } re = boundaryRegexp(b.String(), words) globCacheMu.Lock() if len(globCache) >= maxGlobCache { globCache = map[string]*regexp.Regexp{} } globCache[key] = re globCacheMu.Unlock() return re } // literalMatches is a word match of a literal string, which is what // contains_display_name asks: a display name is not a pattern, and one // called "*" must not match every message. func literalMatches(literal, value string) bool { re := boundaryRegexp(regexp.QuoteMeta(literal), true) return re != nil && re.MatchString(value) } func boundaryRegexp(expr string, words bool) *regexp.Regexp { if words { expr = `(?:^|[^` + wordChar + `])(?:` + expr + `)(?:[^` + wordChar + `]|$)` } else { expr = `^(?:` + expr + `)$` } re, err := regexp.Compile(`(?is)` + expr) if err != nil { return nil } return re }