diff --git a/src/backend/services/events/matcher.test.ts b/src/backend/services/events/matcher.test.ts index 6004a3b27..c1e7252da 100644 --- a/src/backend/services/events/matcher.test.ts +++ b/src/backend/services/events/matcher.test.ts @@ -3,18 +3,19 @@ * * This file is part of Puter. * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. + * Puter is free software: you can redistribute it and/or modify it under the + * terms of the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any + * later version. * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. * * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . + * along with this program. If not, see + * [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). */ import { describe, expect, it } from 'vitest'; @@ -69,6 +70,11 @@ const CASES: Array<{ pattern: string; matches: string[]; misses: string[] }> = [ matches: ['a+b(c).txt'], misses: ['axbxcx.txt'], }, + { + pattern: 'in*/**/out*.log', + matches: ['inbox/out.log', 'in/a/b/out-1.log'], + misses: ['inbox/out.txt', 'box/out.log'], + }, ]; describe('compileMatch', () => { @@ -108,6 +114,9 @@ describe('compileMatch', () => { .fill('a') .join('/'), }, + { name: 'with two stars in one segment', pattern: '*report*.pdf' }, + { name: 'with a doubled star inside a segment', pattern: 'a**b' }, + { name: 'with two globstars', pattern: '**/build/**' }, ]; it.each(rejections)('rejects a pattern $name', ({ pattern }) => { @@ -118,7 +127,9 @@ describe('compileMatch', () => { thrown = err; } expect(thrown).toBeInstanceOf(HttpError); - expect((thrown as HttpError).legacyCode).toBe('invalid_subject_pattern'); + expect((thrown as HttpError).legacyCode).toBe( + 'invalid_subject_pattern', + ); }); it('accepts a pattern exactly on the bounds', () => { @@ -128,6 +139,31 @@ describe('compileMatch', () => { expect(() => compileMatch(Array(MATCH_PATTERN_MAX_SEGMENTS).fill('a').join('/')), ).not.toThrow(); + expect(() => + compileMatch('?'.repeat(MATCH_PATTERN_MAX_LENGTH)), + ).not.toThrow(); + }); + + it('treats a doubled star with no delimiter as two stars', () => { + expect(() => compileMatch('**', { separator: null })).toThrow( + HttpError, + ); + expect(() => compileMatch('a**', { separator: null })).toThrow( + HttpError, + ); + }); + + it('stays cheap on the worst shape the bounds allow', () => { + // One globstar, then a star in every remaining segment, against a + // deep path of long segments that misses only at the very end. + const pattern = `**/${Array(MATCH_PATTERN_MAX_SEGMENTS - 2) + .fill('*a') + .join('/')}/z`; + const path = Array(60).fill('a'.repeat(200)).join('/'); + const compiled = compileMatch(pattern); + const started = performance.now(); + expect(compiled.test(path)).toBe(false); + expect(performance.now() - started).toBeLessThan(50); }); }); diff --git a/src/backend/services/events/matcher.ts b/src/backend/services/events/matcher.ts index 3bb959c86..425c6a4ff 100644 --- a/src/backend/services/events/matcher.ts +++ b/src/backend/services/events/matcher.ts @@ -22,8 +22,10 @@ import { HttpError } from '../../core/http/HttpError.js'; /** * Match filters are globs compiled once at subscribe time and evaluated * in-process against a value the event already carries — never a store scan. - * `**` crosses delimiters and is not bounded; the only bounds are compile - * cost. + * `**` crosses delimiters and is not bounded. A pattern gets one `**` and one + * `*` per segment: every further unbounded wildcard multiplies the ways the + * engine can split a non-matching path among them, and the subscriber names the + * files a filter is tested against. */ // -- Limits ----------------------------------------------------------- @@ -105,6 +107,17 @@ export function compileMatch( (segment, i) => segment !== '**' || raw[i - 1] !== '**', ); + // A lone `*` is pinned by the delimiters either side of it, so a wrong + // split dies in one step; the one `**` is the only choice point left. + // Ten stars in one segment is a second of CPU per event. + const isGlobstar = (segment: string): boolean => + separator !== null && segment === '**'; + if (segments.filter(isGlobstar).length > 1) + throw invalidPattern('may use `**` only once', pattern); + for (const segment of segments) + if (!isGlobstar(segment) && segment.split('*').length > 2) + throw invalidPattern('may use `*` only once per segment', pattern); + const escapedSeparator = separator ? escapeRegExp(separator) : ''; let source = '^'; for (let i = 0; i < segments.length; i++) { diff --git a/src/docs/src/Events.md b/src/docs/src/Events.md index fb80a7bcc..0d6837b61 100644 --- a/src/docs/src/Events.md +++ b/src/docs/src/Events.md @@ -38,6 +38,8 @@ await puter.events.onLocal('fs:~/Pictures/*.png', handler); // one segme await puter.events.onLocal('fs:~/Projects/**/build.log', handler); // across directories ``` +`*` matches within one path segment, `**` crosses directories, and `?` matches one character. A subject may use `*` once per segment and `**` once in total; anything more is rejected with `invalid_subject_pattern`. + `fs:` and `kv:` subjects can be subscribed to; `notif:` is reserved and still rejected. ### Key-value subjects diff --git a/src/docs/src/Events/onLocal.md b/src/docs/src/Events/onLocal.md index 1d6563cf7..0e7a5cf4c 100644 --- a/src/docs/src/Events/onLocal.md +++ b/src/docs/src/Events/onLocal.md @@ -50,7 +50,7 @@ The promise rejects with `{ message, code }`: | `invalid_subject` | The subject is not a non-empty string, or the server could not parse it. | | `invalid_handler` | `handler` is not a function. | | `invalid_subject_op` | The `:op` suffix is not one of the five operations. | -| `invalid_subject_pattern` | The match pattern is past the compile-cost bounds (256 characters, 16 segments). | +| `invalid_subject_pattern` | The match pattern is past its bounds: 256 characters, 16 segments, one `*` per segment, one `**` in total. | | `invalid_kv_pattern` | A `kv:` subject has a `*` somewhere other than the end, or a `?`. | | `events_cross_app_disabled` | The subject names another app's key-value data and that is not enabled here. | | `forbidden` | The target app does not share its data, or this app has not been granted `app-data::kv:read` on it. | diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index b978fd80a..60afd1f40 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -213,7 +213,7 @@ A suspended subscription stops delivering and stops being metered, so it cannot Deleting the node a subscription is anchored on ends it too, unless the subject named a path or a pattern, in which case it follows that path up to the nearest folder that still exists and keeps watching, so recreating the path resumes delivery. -Match patterns are compiled once when you subscribe and are capped at **256 characters** and **16 segments**; anything larger is rejected with `invalid_subject_pattern`. `**` crosses directories and costs no more than `*`. +Match patterns are compiled once when you subscribe and are capped at **256 characters** and **16 segments**, with **one `*` per segment** and **one `**` per pattern**; anything past that is rejected with `invalid_subject_pattern`. `**` crosses directories and costs no more than `*`. A `kv:` subject is indexed on the first **6** `:`-segments, or **160 bytes**, of its key — whichever comes first; past that the remainder becomes a match pattern, which is subject to the caps above. A key-value subject matches its key exactly unless it ends in `*`, and a `*` anywhere else — or a `?` — is rejected with `invalid_kv_pattern`. Watching another app's key-value data is refused with `events_cross_app_disabled` where that is not enabled, and otherwise takes the same consent as reading it.