diff --git a/.changeset/shared-linear-trim.md b/.changeset/shared-linear-trim.md new file mode 100644 index 00000000..076c6b2b --- /dev/null +++ b/.changeset/shared-linear-trim.md @@ -0,0 +1,5 @@ +--- +'@quill/shared': patch +--- + +Trim trailing slashes and anchor dashes with a linear loop instead of a backtracking regex, so a long run of the same character can never stall the API reference or the form links. diff --git a/packages/shared/src/api-reference.ts b/packages/shared/src/api-reference.ts index 207ee9cc..05b2cc15 100644 --- a/packages/shared/src/api-reference.ts +++ b/packages/shared/src/api-reference.ts @@ -21,6 +21,8 @@ * The page shows `:param` as `{param}`, like the skill does. */ +import { trimChar, trimTrailing } from './trim'; + export type ApiArea = | 'forms' | 'folders' @@ -902,10 +904,7 @@ export function apiDisplayPath(path: string): string { /** The in-page anchor of an endpoint: `get-v1-forms-id`. */ export function apiAnchor(route: string): string { - return route - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); + return trimChar(route.toLowerCase().replace(/[^a-z0-9]+/g, '-'), '-'); } /** The example body as JSON text, the way the docs print it and curl sends it. */ @@ -921,7 +920,7 @@ export function apiExampleJson(value: unknown): string { * the quoting holds). */ export function apiCurl(endpoint: Pick, apiUrl: string): string { - const base = apiUrl.replace(/\/+$/, ''); + const base = trimTrailing(apiUrl, '/'); const params = endpoint.params ?? []; let path = endpoint.path; for (const p of params) { diff --git a/packages/shared/src/form-links.ts b/packages/shared/src/form-links.ts index 11fa67ab..a6e80a38 100644 --- a/packages/shared/src/form-links.ts +++ b/packages/shared/src/form-links.ts @@ -18,6 +18,8 @@ * the API hands the same link to agents and must not keep a second copy. */ +import { trimTrailing } from './trim'; + /** * The fixed middle segment. It can never collide with a member's public page * at `/{accountCode}/{handle}`: a handle is at least three characters, both by @@ -64,7 +66,7 @@ export function formLinks( accountCode: string | null | undefined, form: { id: string; slug: string }, ): FormLinks { - const base = (appUrl ?? '').trim().replace(/\/+$/, ''); + const base = trimTrailing((appUrl ?? '').trim(), '/'); if (!base) return { publicUrl: null, editorUrl: null }; return { publicUrl: accountCode ? `${base}${publicFormPath(accountCode, form.slug)}` : null, diff --git a/packages/shared/src/trim.spec.ts b/packages/shared/src/trim.spec.ts new file mode 100644 index 00000000..0d63a243 --- /dev/null +++ b/packages/shared/src/trim.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { trimChar, trimTrailing } from './trim'; + +describe('trimTrailing', () => { + it('drops every trailing occurrence and nothing else', () => { + expect(trimTrailing('https://api.example.com///', '/')).toBe('https://api.example.com'); + expect(trimTrailing('https://api.example.com', '/')).toBe('https://api.example.com'); + expect(trimTrailing('///', '/')).toBe(''); + expect(trimTrailing('', '/')).toBe(''); + }); + + it('stays linear on a long run that is not at the end', () => { + const input = `${'/'.repeat(200_000)}x`; + const started = performance.now(); + expect(trimTrailing(input, '/')).toBe(input); + expect(performance.now() - started).toBeLessThan(100); + }); +}); + +describe('trimChar', () => { + it('drops the character at both ends', () => { + expect(trimChar('--get-v1-forms--', '-')).toBe('get-v1-forms'); + expect(trimChar('get-v1', '-')).toBe('get-v1'); + expect(trimChar('---', '-')).toBe(''); + }); + + it('stays linear on a long run in the middle', () => { + const input = `a${'-'.repeat(200_000)}b`; + const started = performance.now(); + expect(trimChar(input, '-')).toBe(input); + expect(performance.now() - started).toBeLessThan(100); + }); +}); diff --git a/packages/shared/src/trim.ts b/packages/shared/src/trim.ts new file mode 100644 index 00000000..3badaf8a --- /dev/null +++ b/packages/shared/src/trim.ts @@ -0,0 +1,21 @@ +/** + * Strip a repeated character from the ends of a string, in linear time. + * + * The regex equivalents (`/\/+$/`, `/^-+|-+$/g`) backtrack on a long run of + * the character that is not at the end, which static analysis flags as a + * polynomial ReDoS. These loops never backtrack. + */ + +/** `value` without any trailing `char` (`'a//'` → `'a'`). */ +export function trimTrailing(value: string, char: string): string { + let end = value.length; + while (end > 0 && value[end - 1] === char) end--; + return value.slice(0, end); +} + +/** `value` without any leading or trailing `char` (`'--a--'` → `'a'`). */ +export function trimChar(value: string, char: string): string { + let start = 0; + while (start < value.length && value[start] === char) start++; + return trimTrailing(value.slice(start), char); +}