Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement MSC3873 to handle escaped dots in push rule keys #3134

Merged
merged 26 commits into from
Mar 1, 2023
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1facb89
Add comments.
clokep Feb 7, 2023
0600523
Implment MSC3873 to handle escaped dots in keys.
clokep Feb 7, 2023
0ac33c8
Add some comments about tests.
clokep Feb 7, 2023
9da576c
Merge remote-tracking branch 'upstream/develop' into dotted-keys
clokep Feb 14, 2023
f680ad8
Clarify spec behavior.
clokep Feb 14, 2023
a23ed41
Fix typo.
clokep Feb 14, 2023
704ed20
Don't manually iterate string.
clokep Feb 14, 2023
0b9d896
Clean-up tests.
clokep Feb 14, 2023
08144fa
Simplify tests.
clokep Feb 14, 2023
b724cf1
Add more tests & fix bug with empty parts.
clokep Feb 15, 2023
7e11af8
Add more edge cases.
clokep Feb 15, 2023
97fe8e9
Add a regular expression solution.
clokep Feb 15, 2023
322913e
Split on a simpler regular expression.
clokep Feb 15, 2023
7016e10
Merge remote-tracking branch 'upstream/develop' into dotted-keys
clokep Feb 15, 2023
d7fcd36
Remove redundant case in regex.
clokep Feb 15, 2023
c8a8f09
Enable sticky regex.
clokep Feb 15, 2023
256cc92
Rollback use of regex.
clokep Feb 16, 2023
bdde4ef
Cache values in the PushProcessor.
clokep Feb 16, 2023
d04d2a6
Use more each in tests.
clokep Feb 16, 2023
e673f36
Pre-calculate the key parts instead of caching them.
clokep Feb 22, 2023
d85806e
Merge remote-tracking branch 'upstream/develop' into dotted-keys
clokep Feb 22, 2023
3c445f6
Fix typo.
clokep Feb 23, 2023
ab9f212
Switch back to external cache, but clean out obsolete cached values.
clokep Feb 27, 2023
fc66cec
Remove obsolete property.
clokep Feb 27, 2023
cdb8760
Merge remote-tracking branch 'upstream/develop' into dotted-keys
clokep Feb 27, 2023
9e7c3c9
Remove more obsolete properties.
clokep Feb 27, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 91 additions & 1 deletion spec/unit/pushprocessor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as utils from "../test-utils/test-utils";
import { IActionsObject, PushProcessor } from "../../src/pushprocessor";
import { EventType, IContent, MatrixClient, MatrixEvent } from "../../src";
import { ConditionKind, EventType, IContent, MatrixClient, MatrixEvent } from "../../src";

describe("NotificationService", function () {
const testUserId = "@ali:matrix.org";
Expand Down Expand Up @@ -287,6 +287,13 @@ describe("NotificationService", function () {
expect(actions.tweaks.highlight).toEqual(true);
});

// TODO: This is not spec compliant behaviour.
clokep marked this conversation as resolved.
Show resolved Hide resolved
//
// See https://spec.matrix.org/v1.5/client-server-api/#conditions-1 which
// describes pattern should glob:
//
// 1. * matches 0 or ore characters;
clokep marked this conversation as resolved.
Show resolved Hide resolved
// 2. ? matches exactly one character
it("should bing on character group ([abc]) bing words.", function () {
testEvent.event.content!.body = "Ping!";
let actions = pushProcessor.actionsForEvent(testEvent);
Expand All @@ -296,12 +303,14 @@ describe("NotificationService", function () {
expect(actions.tweaks.highlight).toEqual(true);
});

// TODO: This is not spec compliant behaviour. (See above.)
it("should bing on character range ([a-z]) bing words.", function () {
testEvent.event.content!.body = "I ate 6 pies";
const actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toEqual(true);
});

// TODO: This is not spec compliant behaviour. (See above.)
it("should bing on character negation ([!a]) bing words.", function () {
testEvent.event.content!.body = "boke";
let actions = pushProcessor.actionsForEvent(testEvent);
Expand Down Expand Up @@ -330,6 +339,8 @@ describe("NotificationService", function () {
// invalid

it("should gracefully handle bad input.", function () {
// The following body is an object (not a string) and thus is invalid
// for matching against.
testEvent.event.content!.body = { foo: "bar" };
const actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toEqual(false);
Expand Down Expand Up @@ -493,4 +504,83 @@ describe("NotificationService", function () {
});
});
});

it.each([
// The properly escaped key works.
{ key: "content.m\\.test.foo", pattern: "bar", expected: true },
// An unescaped version does not match.
{ key: "content.m.test.foo", pattern: "bar", expected: false },
// Over escaping does not match.
{ key: "content.m\\.test\\.foo", pattern: "bar", expected: false },
// Escaping backslashes should match.
{ key: "content.m\\\\example", pattern: "baz", expected: true },
// An unnecessary escape sequence leaves the backslash and still matches.
{ key: "content.m\\example", pattern: "baz", expected: true },
])("test against escaped dotted paths '$key'", ({ key, pattern, expected }) => {
testEvent = utils.mkEvent({
type: "m.room.message",
room: testRoomId,
user: "@alfred:localhost",
event: true,
content: {
// A dot in the field name.
"m.test": { foo: "bar" },
// A backslash in a field name.
"m\\example": "baz",
},
});

expect(
pushProcessor.ruleMatchesEvent(
{
rule_id: "rule1",
actions: [],
conditions: [
{
kind: ConditionKind.EventMatch,
key: key,
keyParts: PushProcessor.partsForDottedKey(key),
pattern: pattern,
},
],
default: false,
enabled: true,
},
testEvent,
),
).toBe(expected);
});
});

describe("Test PushProcessor.partsForDottedKey", function () {
it.each([
// A field with no dots.
["m", ["m"]],
// Simple dotted fields.
["m.foo", ["m", "foo"]],
["m.foo.bar", ["m", "foo", "bar"]],
// Backslash is used as an escape character.
["m\\.foo", ["m.foo"]],
["m\\\\.foo", ["m\\", "foo"]],
["m\\\\\\.foo", ["m\\.foo"]],
["m\\\\\\\\.foo", ["m\\\\", "foo"]],
["m\\foo", ["m\\foo"]],
["m\\\\foo", ["m\\foo"]],
["m\\\\\\foo", ["m\\\\foo"]],
["m\\\\\\\\foo", ["m\\\\foo"]],
// Ensure that escapes at the end don't cause issues.
["m.foo\\", ["m", "foo\\"]],
["m.foo\\\\", ["m", "foo\\"]],
["m.foo\\.", ["m", "foo."]],
["m.foo\\\\.", ["m", "foo\\", ""]],
["m.foo\\\\\\.", ["m", "foo\\."]],
// Empty parts (corresponding to properties which are an empty string) are allowed.
[".m", ["", "m"]],
["..m", ["", "", "m"]],
["m.", ["m", ""]],
["m..", ["m", "", ""]],
["m..foo", ["m", "", "foo"]],
])("partsFotDottedKey for %s", (path: string, expected: string[]) => {
expect(PushProcessor.partsForDottedKey(path)).toStrictEqual(expected);
});
});
5 changes: 5 additions & 0 deletions src/@types/PushRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export interface IPushRuleCondition<N extends ConditionKind | string> {

export interface IEventMatchCondition extends IPushRuleCondition<ConditionKind.EventMatch> {
key: string;
/**
* keyParts is calculated by rewriteDefaultRules by unescaping and spitting
* key.
*/
keyParts: string[];
pattern?: string;
value?: string;
}
Expand Down
111 changes: 103 additions & 8 deletions src/pushprocessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
{
kind: ConditionKind.EventMatch,
key: "type",
keyParts: ["type"],
pattern: "m.reaction",
},
],
Expand All @@ -77,11 +78,13 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
{
kind: ConditionKind.EventMatch,
key: "type",
keyParts: ["type"],
pattern: EventType.RoomServerAcl,
},
{
kind: ConditionKind.EventMatch,
key: "state_key",
keyParts: ["state_key"],
pattern: "",
},
],
Expand All @@ -99,6 +102,7 @@ const DEFAULT_UNDERRIDE_RULES: IPushRule[] = [
{
kind: ConditionKind.EventMatch,
key: "type",
keyParts: ["type"],
pattern: "org.matrix.msc3401.call",
},
{
Expand Down Expand Up @@ -162,6 +166,8 @@ export class PushProcessor {
if (!newRules) newRules = {} as IPushRules;
if (!newRules.global) newRules.global = {} as PushRuleSet;
if (!newRules.global.override) newRules.global.override = [];
if (!newRules.global.room) newRules.global.room = [];
if (!newRules.global.sender) newRules.global.sender = [];
if (!newRules.global.override) newRules.global.underride = [];

// Merge the client-level defaults with the ones from the server
Expand Down Expand Up @@ -199,6 +205,26 @@ export class PushProcessor {
}
}

// Process the 'key' property on event_match conditions to unescape and
// split it.
for (const ruleset of [globalOverrides, newRules.global.room, newRules.global.sender, globalUnderrides]) {
for (const rule of ruleset) {
if (!rule.conditions) {
continue;
}

for (const condition of rule.conditions) {
if (condition.kind !== ConditionKind.EventMatch) {
continue;
}

// The homeserver might have returned an object with this key
// already, but we want to always stomp over it.
condition.keyParts = PushProcessor.partsForDottedKey(condition.key);
}
}
}

return newRules;
}

Expand Down Expand Up @@ -253,6 +279,7 @@ export class PushProcessor {
rawrule.conditions!.push({
kind: ConditionKind.EventMatch,
key: "room_id",
keyParts: ["room_id"],
value: tprule.rule_id,
});
break;
Expand All @@ -263,6 +290,7 @@ export class PushProcessor {
rawrule.conditions!.push({
kind: ConditionKind.EventMatch,
key: "user_id",
keyParts: ["user_id"],
value: tprule.rule_id,
});
break;
Expand All @@ -273,6 +301,7 @@ export class PushProcessor {
rawrule.conditions!.push({
kind: ConditionKind.EventMatch,
key: "content.body",
keyParts: ["content", "body"],
pattern: tprule.pattern,
});
break;
Expand Down Expand Up @@ -383,11 +412,11 @@ export class PushProcessor {
}

private eventFulfillsEventMatchCondition(cond: IEventMatchCondition, ev: MatrixEvent): boolean {
if (!cond.key) {
if (!cond.key || !cond.keyParts) {
return false;
}

const val = this.valueForDottedKey(cond.key, ev);
const val = this.valueForDottedKey(cond.keyParts, ev);
if (typeof val !== "string") {
return false;
}
Expand Down Expand Up @@ -433,25 +462,91 @@ export class PushProcessor {
return PushProcessor.cachedGlobToRegex[glob];
}

private valueForDottedKey(key: string, ev: MatrixEvent): any {
const parts = key.split(".");
/**
* Parse the key into the separate fields to search by splitting on
* unescaped ".", and then removing any escape characters.
*
* @param str - The key of the push rule condition: a dotted field.
* @returns The unescaped parts to fetch.
* @internal
*/
public static partsForDottedKey(str: string): string[] {
const result = [];

// The current field and whether the previous character was the escape
// character (a backslash).
let part = "";
let escaped = false;

// Iterate over each character, and decide whether to append to the current
// part (following the escape rules) or to start a new part (based on the
// field separator).
clokep marked this conversation as resolved.
Show resolved Hide resolved
for (const c of str) {
// If the previous character was the escape character (a backslash)
// then decide what to append to the current part.
if (escaped) {
if (c === "\\" || c === ".") {
// An escaped backslash or dot just gets added.
part += c;
} else {
// A character that shouldn't be escaped gets the backslash prepended.
part += "\\" + c;
}
// This always resets being escaped.
escaped = false;
continue;
}

if (c == ".") {
// The field separator creates a new part.
result.push(part);
part = "";
} else if (c == "\\") {
// A backslash adds no characters, but starts an escape sequence.
escaped = true;
} else {
// Otherwise, just add the current character.
part += c;
}
}

// Ensure the final part is included. If there's an open escape sequence
// it should be included.
if (escaped) {
part += "\\";
}
result.push(part);

return result;
}

/**
* For a dotted field and event, fetch the value at that position, if one
* exists.
*
* @param parts - The pre-split key of the push rule condition to fetch.
* @param ev - The matrix event to fetch the field from.
* @returns The value at the dotted path given by key.
*/
private valueForDottedKey(parts: string[], ev: MatrixEvent): any {
let val: any;

// special-case the first component to deal with encrypted messages
const firstPart = parts[0];
let currentIndex = 0;
if (firstPart === "content") {
val = ev.getContent();
parts.shift();
++currentIndex;
} else if (firstPart === "type") {
val = ev.getType();
parts.shift();
++currentIndex;
} else {
// use the raw event for any other fields
val = ev.event;
}

while (parts.length > 0) {
const thisPart = parts.shift()!;
for (; currentIndex < parts.length; ++currentIndex) {
const thisPart = parts[currentIndex];
if (isNullOrUndefined(val[thisPart])) {
return null;
}
Expand Down