mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 22:17:48 +02:00
* 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>
125 lines
4.0 KiB
TypeScript
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);
|
|
});
|
|
});
|