fix: one ** per match pattern and one * per segment (#3736)

`compileMatch` turned every `*` into an unbounded `[^/]*`, so a subject
like `fs:~/x/*a*a*a*a*a*a*a*a*a*a*z` tested against a 24-char filename
with no `z` — both named by the same subscriber — cost the regex engine
C(34,10) splits before failing: 1.0 s per event at ten stars, 3.7 s at
eleven, on the fs-write dispatch path, inside the 256-character cap.

With one `*` per segment the delimiters pin each star and a wrong split
dies in one step; the single `**` is the only choice point left, so the
worst allowed shape is O(depth × length): 0.4 ms at depth 60 of 200-char
segments. Every documented pattern (`*.png`, `**/build.log`, `**/*.png`,
`dir/**`, `report-?.csv`) stays valid; `*a*`, `a**b` and `**/x/**` are
refused with `invalid_subject_pattern`. The rule is stated where the
syntax is introduced, on the limits page, and in onLocal's error table.
This commit is contained in:
Daniel Salazar
2026-09-03 15:08:49 -07:00
committed by GitHub
parent 1b777cd760
commit 32a838d2e2
5 changed files with 65 additions and 14 deletions
+46 -10
View File
@@ -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 <https://www.gnu.org/licenses/>.
* 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);
});
});
+15 -2
View File
@@ -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++) {
+2
View File
@@ -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
+1 -1
View File
@@ -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:<appId>:kv:read` on it. |
+1 -1
View File
@@ -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.