Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shared-linear-trim.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 4 additions & 5 deletions packages/shared/src/api-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
* The page shows `:param` as `{param}`, like the skill does.
*/

import { trimChar, trimTrailing } from './trim';

export type ApiArea =
| 'forms'
| 'folders'
Expand Down Expand Up @@ -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. */
Expand All @@ -921,7 +920,7 @@ export function apiExampleJson(value: unknown): string {
* the quoting holds).
*/
export function apiCurl(endpoint: Pick<ApiEndpoint, 'method' | 'path' | 'params' | 'body' | 'responseType'>, apiUrl: string): string {
const base = apiUrl.replace(/\/+$/, '');
const base = trimTrailing(apiUrl, '/');
const params = endpoint.params ?? [];
let path = endpoint.path;
for (const p of params) {
Expand Down
4 changes: 3 additions & 1 deletion packages/shared/src/form-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions packages/shared/src/trim.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
21 changes: 21 additions & 0 deletions packages/shared/src/trim.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading