mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-10 15:25:49 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user