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 all 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
91 changes: 90 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 more characters;
// 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,82 @@ 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,
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);
});
});
14 changes: 13 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8499,10 +8499,22 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public getPushRules(): Promise<IPushRules> {
return this.http.authedRequest<IPushRules>(Method.Get, "/pushrules/").then((rules: IPushRules) => {
return PushProcessor.rewriteDefaultRules(rules);
this.setPushRules(rules);
return this.pushRules!;
});
}

/**
* Update the push rules for the account. This should be called whenever
* updated push rules are available.
*/
public setPushRules(rules: IPushRules): void {
// Fix-up defaults, if applicable.
this.pushRules = PushProcessor.rewriteDefaultRules(rules);
// Pre-calculate any necessary caches.
this.pushProcessor.updateCachedPushRuleKeys(this.pushRules);
}

/**
* @returns Promise which resolves: an empty object `{}`
* @returns Rejects: with an error response.
Expand Down
139 changes: 133 additions & 6 deletions src/pushprocessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ export class PushProcessor {
*/
public constructor(private readonly client: MatrixClient) {}

/**
* Maps the original key from the push rules to a list of property names
* after unescaping.
*/
private readonly parsedKeys = new Map<string, string[]>();
clokep marked this conversation as resolved.
Show resolved Hide resolved

/**
* Convert a list of actions into a object with the actions as keys and their values
* @example
Expand Down Expand Up @@ -162,7 +168,7 @@ 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.override) newRules.global.underride = [];
if (!newRules.global.underride) newRules.global.underride = [];
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to have been a separate, long-standing bug?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, how exciting. What would the symptoms have been? Maybe we can find an issue to close....

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was added in #2873, but that also added (on line 191) a newRules.global.underride ?? [], likely to solve the same problem? 🤷 (I don't think this would have a user-visible bug.)


// Merge the client-level defaults with the ones from the server
const globalOverrides = newRules.global.override;
Expand Down Expand Up @@ -202,6 +208,53 @@ export class PushProcessor {
return newRules;
}

/**
* Pre-caches the parsed keys for push rules and cleans out any obsolete cache
* entries. Should be called after push rules are updated.
* @param newRules - The new push rules.
*/
public updateCachedPushRuleKeys(newRules: IPushRules): void {
// These lines are mostly to make the tests happy. We shouldn't run into these
// properties missing in practice.
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.underride) newRules.global.underride = [];
Comment on lines +219 to +224
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was copied from rewriteDefaultRules, but it might make more sense to just skip any that are undefined instead of mutating the input value?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems fine like this, if it's more consistent.


// Process the 'key' property on event_match conditions pre-cache the
// values and clean-out any unused values.
const toRemoveKeys = new Set(this.parsedKeys.keys());
for (const ruleset of [
newRules.global.override,
newRules.global.room,
newRules.global.sender,
newRules.global.underride,
]) {
for (const rule of ruleset) {
if (!rule.conditions) {
continue;
}

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

// Ensure we keep this key.
toRemoveKeys.delete(condition.key);

// Pre-process the key.
this.parsedKeys.set(condition.key, PushProcessor.partsForDottedKey(condition.key));
}
}
}
// Any keys that were previously cached, but are no longer needed should
// be removed.
toRemoveKeys.forEach((k) => this.parsedKeys.delete(k));
}

private static cachedGlobToRegex: Record<string, RegExp> = {}; // $glob: RegExp

private matchingRuleFromKindSet(ev: MatrixEvent, kindset: PushRuleSet): IAnnotatedPushRule | null {
Expand Down Expand Up @@ -433,25 +486,99 @@ export class PushProcessor {
return PushProcessor.cachedGlobToRegex[glob];
}

/**
* 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 key - The key of the push rule condition: a dotted field to fetch.
* @param ev - The matrix event to fetch the field from.
* @returns The value at the dotted path given by key.
*/
private valueForDottedKey(key: string, ev: MatrixEvent): any {
const parts = key.split(".");
// The key should already have been parsed via updateCachedPushRuleKeys,
// but if it hasn't (maybe via an old consumer of the SDK which hasn't
// been updated?) then lazily calculate it here.
let parts = this.parsedKeys.get(key);
if (parts === undefined) {
parts = PushProcessor.partsForDottedKey(key);
this.parsedKeys.set(key, parts);
}
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
3 changes: 1 addition & 2 deletions src/sliding-sync-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ import {
} from "./sliding-sync";
import { EventType } from "./@types/event";
import { IPushRules } from "./@types/PushRules";
import { PushProcessor } from "./pushprocessor";
import { RoomStateEvent } from "./models/room-state";
import { RoomMemberEvent } from "./models/room-member";

Expand Down Expand Up @@ -262,7 +261,7 @@ class ExtensionAccountData implements Extension<ExtensionAccountDataRequest, Ext
// (see sync) before syncing over the network.
if (accountDataEvent.getType() === EventType.PushRules) {
const rules = accountDataEvent.getContent<IPushRules>();
this.client.pushRules = PushProcessor.rewriteDefaultRules(rules);
this.client.setPushRules(rules);
}
const prevEvent = prevEventsMap[accountDataEvent.getType()];
this.client.emit(ClientEvent.AccountData, accountDataEvent, prevEvent);
Expand Down
3 changes: 1 addition & 2 deletions src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import * as utils from "./utils";
import { IDeferred } from "./utils";
import { Filter } from "./filter";
import { EventTimeline } from "./models/event-timeline";
import { PushProcessor } from "./pushprocessor";
import { logger } from "./logger";
import { InvalidStoreError, InvalidStoreState } from "./errors";
import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering, ResetTimelineCallback } from "./client";
Expand Down Expand Up @@ -1162,7 +1161,7 @@ export class SyncApi {
// (see sync) before syncing over the network.
if (accountDataEvent.getType() === EventType.PushRules) {
const rules = accountDataEvent.getContent<IPushRules>();
client.pushRules = PushProcessor.rewriteDefaultRules(rules);
client.setPushRules(rules);
}
const prevEvent = prevEventsMap[accountDataEvent.getType()!];
client.emit(ClientEvent.AccountData, accountDataEvent, prevEvent);
Expand Down