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.