fix(plugin-sdk): stop the README heading match backtracking

`missingSections()` matched headings with `(.+?)\s*(?:<!--.*)?$`, where the
lazy body and the trailing `\s*` compete over the same run of spaces. That
backtracks quadratically: a heading line padded with 80k spaces takes about
12 seconds, 200k about 75. The gate runs over READMEs anyone can submit to
the registry, so the padding is not the author own input.

The trailing anchor comment is now cut with indexOf, and the body is matched
with its edges pinned to non-space, so nothing competes with the trailing
`\s*`. Same shape the wiki sidebar parser was rewritten to for the same
reason. Behaviour is unchanged on every heading that is not the attack:
trailing comment, unterminated comment, tab separator, padded title.

scripts/check-readme.mjs in the TREK-Plugins registry carries the same
regex and needs the same change.
@
This commit is contained in:
Maurice
2026-08-27 12:34:55 +02:00
committed by GitHub
parent 1f66ef1d32
commit de9bd94279
2 changed files with 39 additions and 3 deletions
+18
View File
@@ -241,6 +241,24 @@ describe('README helpers mirror the registry exactly', () => {
expect(missingSections('## What it does')).toEqual(['Screenshots', 'Permissions', 'Setup']);
});
it('still ignores the trailing anchor comment authors hide on a heading', () => {
const md = '## What it does <!-- #what -->\n## Screenshots<!--x-->\n##\tPermissions \n## Setup <!-- unterminated';
expect(missingSections(md)).toEqual([]);
// A heading that is nothing but a comment names no section.
expect(missingSections('# <!-- only -->')).toEqual(['What it does', 'Screenshots', 'Permissions', 'Setup']);
});
it('does not stall on a heading padded with spaces', () => {
// The old `(.+?)\s*(?:<!--.*)?$` was quadratic: ~12s at 80k spaces, ~75s at 200k.
// READMEs reach this gate from the registry's submission CI, so the padding is
// attacker-supplied. The bound is 1000x the real cost, so it fails on the shape
// of the regression, not on a slow machine.
const md = `## What it does${' '.repeat(200_000)}x\n## Screenshots\n## Permissions\n## Setup`;
const started = performance.now();
expect(missingSections(md)).toEqual([]);
expect(performance.now() - started).toBeLessThan(1000);
});
it('ignores data: URIs when looking for a screenshot, like the registry does', () => {
expect(images('![a](data:image/png;base64,xxx)')).toEqual([]);
expect(images('<img src="shot.png">')).toEqual(['shot.png']);