Files
multica/packages/views/editor/extensions/slash-command-extension.test.ts
Naiyuan Qing ebd0248be2 MUL-4016: fix mention tokenizer stacktrace backtracking (#4889)
* MUL-4016: fix mention tokenizer stacktrace backtracking

Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): de-ambiguate escaped-label regexes to kill ReDoS (MUL-4016)

The mention/slash/file-card label regexes used `(?:\\.|[^\]])` where both
alternatives can consume a backslash. On an unterminated match, each `\x`
run is enumerated 2^n ways — pasting a Java stacktrace (`\~\[...\]`) or a
crafted ~50-char string freezes the main thread for seconds (GitHub #4881).

Exclude backslash from the char class (`[^\]\\]`) so a backslash can only be
consumed by `\\.`. The alternatives become disjoint and matching is linear;
legal escaped-bracket labels like `David\[TF\]` still parse unchanged.

Fixed in all four sites that shared the pattern:
- mention-extension.ts tokenize()
- slash-command-extension.ts start() + tokenize()
- file-card.tsx FILE_CARD_MARKDOWN_RE
- packages/ui/markdown/file-cards.ts NEW_FILE_CARD_RE (runs on every
  read-only comment/description render, not just the editor)

Adds adversarial regression tests (repeated `\a` + missing closing bracket)
that fail in ~10-40s against the old regexes and pass in <1ms after the fix.
Builds on #4889's marker-first mention start().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): escape backslash in mention/slash labels for round-trip (MUL-4016)

Follow-up to the de-ambiguation fix, addressing Howard's PR review.

The linear tokenizer now treats "\" as an escape lead (\\.), so a label whose
serialized form contains a bare "\" adjacent to the closing "]" no longer parses
back — the "\]" is consumed as an escaped bracket and swallows the boundary. The
old ambiguous regex tolerated this by chance; the de-ambiguation exposes it.

mention/slash renderMarkdown escaped only [ and ], not \. Switch both to the
shared escapeMarkdownLabel() (escapes [ ] \ ( )) and mirror it on parse with
replace(/\\([[\]\\()])/g, "$1"), matching what file-card already does. This also
converges the three tokenizers on one escape contract. file-card was already
correct and is unchanged.

Adds parameterized round-trip tests for labels containing "\" / "\]" / parens
(e.g. "A\\", "ends\\", "a\\]b"); these fail on the old serializer and pass now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:43:51 +08:00

125 lines
4.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { SlashCommandExtension } from "./slash-command-extension";
const tokenizer = SlashCommandExtension.config.markdownTokenizer!;
const startFn = tokenizer.start as (src: string) => number;
const tokenizeFn = tokenizer.tokenize as (
src: string,
) => { type: string; raw: string; attributes: Record<string, string> } | undefined;
const renderMarkdown = SlashCommandExtension.config.renderMarkdown as (
node: { attrs: Record<string, string> },
) => string;
const renderHTML = SlashCommandExtension.config.renderHTML as (
this: { options: { HTMLAttributes: Record<string, string> } },
props: {
node: { attrs: Record<string, string | undefined> };
HTMLAttributes: Record<string, string>;
},
) => [string, Record<string, string>, string];
function tokenize(src: string) {
const start = startFn(src);
if (start === -1) return undefined;
return tokenizeFn(src.slice(start));
}
describe("slash command tokenizer", () => {
it("parses a slash skill link", () => {
const token = tokenize("[/git-commit](slash://skill/aaa-bbb)");
expect(token).toBeDefined();
expect(token!.attributes.label).toBe("git-commit");
expect(token!.attributes.id).toBe("aaa-bbb");
});
it("round-trips through renderMarkdown", () => {
const md = renderMarkdown({
attrs: { id: "skill-1", label: "deploy" },
});
expect(md).toBe("[/deploy](slash://skill/skill-1)");
expect(tokenize(md)?.attributes).toEqual({
id: "skill-1",
label: "deploy",
});
});
it("uses a generic fallback when rendering markdown without a label", () => {
const md = renderMarkdown({
attrs: { id: "skill-1" },
});
expect(md).toBe("[/?](slash://skill/skill-1)");
});
it("does not write an unused slash-specific id attribute", () => {
const [, attrs, text] = renderHTML.call(
{ options: { HTMLAttributes: { class: "slash-command" } } },
{
node: { attrs: { id: "skill-1", label: "deploy" } },
HTMLAttributes: {},
},
);
expect(attrs).toMatchObject({
"data-type": "slash-command",
class: "slash-command",
});
expect(attrs).not.toHaveProperty("data-slash-id");
expect(text).toBe("/deploy");
});
it("handles labels with escaped brackets", () => {
const md = renderMarkdown({
attrs: { id: "skill-1", label: "deploy[prod]" },
});
expect(md).toBe("[/deploy\\[prod\\]](slash://skill/skill-1)");
expect(tokenize(md)?.attributes.label).toBe("deploy[prod]");
});
it.each(["A\\", "ends\\", "a\\]b", "f(x)", "back\\slash"])(
"round-trips a label containing backslash/parens: %j",
(label) => {
// renderMarkdown must escape "\" so a trailing "\" does not swallow the
// closing "]" under the linear tokenizer (regression guard).
const md = renderMarkdown({ attrs: { id: "skill-1", label } });
expect(tokenize(md)?.attributes.label).toBe(label);
},
);
it("does not match ordinary markdown links", () => {
expect(tokenize("[docs](https://example.com)")).toBeUndefined();
});
it("does not match slash action links", () => {
expect(tokenize("[/deploy](slash://action/deploy)")).toBeUndefined();
});
it("rejects an unterminated slash link with escape-pair runs in linear time", () => {
// Each "\a" pair is ambiguous under (?:\\.|[^\]]) — the pre-fix regex
// enumerates 2^28 backtrack paths (~10s) before failing. The disjoint
// char class must fail fast instead.
const src = `[/${"\\a".repeat(28)}](slash://skill/abc`;
const t0 = performance.now();
const token = tokenizeFn(src);
const elapsed = performance.now() - t0;
expect(token).toBeUndefined();
expect(elapsed).toBeLessThan(100);
});
it("returns -1 fast from start() when escape-pair runs precede no slash link", () => {
const src = `[/${"\\a".repeat(28)}] plain text, no slash link`;
const t0 = performance.now();
const start = startFn(src);
const elapsed = performance.now() - t0;
expect(start).toBe(-1);
expect(elapsed).toBeLessThan(100);
});
});