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
7 changes: 7 additions & 0 deletions .changeset/calm-math-scanner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@bidilens/core': patch
---

Replace the raw-text math delimiter regular expression with a forward scanner
so repeated unmatched `\(` input stays linear instead of triggering
polynomial backtracking.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ is published under the public `@bidilens` npm scope.

## Unreleased

### Security and performance

- Replaced the raw-text math-delimiter regular expression with a single-pass
scanner, preventing quadratic work on repeated unmatched `\(` input while
preserving `$...$`, `$$...$$`, `$$$$`, and `\(...\)` recognition.
- Expressed UTF-16-to-code-point range construction with bounded typed-array
fills so input-derived offsets cannot be interpreted as object properties.
- Restricted downloaded Unicode data writes to the two repository-owned,
version-pinned destination paths after exact SHA-256 verification.

### Direction correctness

- Kept ordinary hyphenated English compounds and block-level ALL-CAPS prose as
Expand Down
28 changes: 24 additions & 4 deletions action/dist/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6148,6 +6148,27 @@ function addMatches(text, ranges, expression, kind, group = 0) {
addRange(ranges, text, start, start + value.length, kind);
}
}
function addMathRanges(text, ranges) {
let i = 0;
let scanned = -1;
while (i < text.length) {
const p = text[i] === "\\" && text[i + 1] === "(";
const d = text[i] === "$" ? text[i + 1] === "$" ? "$$" : "$" : p && i >= scanned ? "\\)" : "";
if (!d) {
i++;
continue;
}
let e = i + (p ? 2 : d.length);
while (e < text.length && text[e] !== "\r" && text[e] !== "\n" && !text.startsWith(d, e)) e++;
if (text.startsWith(d, e) && (d !== "$" || e > i + 1)) {
addRange(ranges, text, i, e + d.length, "math");
i = e + d.length;
} else {
if (p) scanned = e;
i++;
}
}
}
function trimTechnicalPunctuation(value) {
let end = value.length;
while (end > 0 && /[.,;:!?،؛؟。।۔]/u.test(value[end - 1])) end -= 1;
Expand Down Expand Up @@ -6333,7 +6354,7 @@ function findTechnicalTokenRanges(text, technicalIdentifiers = []) {
const ranges = [];
addCodeRanges(text, ranges);
addMatches(text, ranges, /<\/?[A-Za-z][^<>\r\n]*>/gu, "html");
addMatches(text, ranges, /(?:\$\$[^\r\n]*?\$\$|\$[^$\r\n]+\$|\\\([^\r\n]*?\\\))/gu, "math");
addMathRanges(text, ranges);
const urls = /\b(?:https?|ftp):\/\/[^\s<>{}"']+/giu;
let urlMatch;
while ((urlMatch = urls.exec(text)) !== null) {
Expand Down Expand Up @@ -6828,12 +6849,11 @@ function attachSourceRanges(text, isolations) {
let utf16Offset = 0;
let codePointOffset = 0;
for (const character of text) {
codePointAtUtf16[utf16Offset] = codePointOffset;
if (character.length === 2) codePointAtUtf16[utf16Offset + 1] = codePointOffset;
codePointAtUtf16.fill(codePointOffset, utf16Offset, utf16Offset + character.length);
utf16Offset += character.length;
codePointOffset += 1;
codePointAtUtf16[utf16Offset] = codePointOffset;
}
codePointAtUtf16.fill(codePointOffset, utf16Offset);
return isolations.map((isolation) => ({
...isolation,
sourceRange: {
Expand Down
4 changes: 2 additions & 2 deletions action/dist/index.cjs.map

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ API signal while a rich document is between checkpoints.
at most 14 live Markdown parses;
- a unit alarm permits 8,000 single-character pushes and dense isolation
planning to finish within three seconds on the CI machine;
- an adversarial unit alarm scans 128,000 UTF-16 units of repeated unmatched
`\(` delimiters within the batch budget, guarding the linear math scanner;
- release checks enforce aggregate emitted-JavaScript budgets, including
code-split chunks.

Expand Down
5 changes: 5 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ pops/openers, formatting that crosses an isolate boundary, and U+200B.
- markup adapters prefer `<bdi>`/`dir` to invisible control insertion;
- terminal isolate insertion is opt-in and carries a warning;
- recursive CLI scanning skips symbolic links.
- raw-text math delimiter discovery is a single forward scan; repeated
unmatched delimiters cannot trigger polynomial regular-expression work.
- the optional Unicode download command accepts only two source/destination
pairs pinned in repository code and verifies each exact SHA-256 before a
write; untrusted text never reaches that build-time path.

## False-positive policy

Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ describe('direction detection', () => {
.map((range) => range.text)).toContain('https://example.com');
});

it('recognizes inline math delimiters without crossing line boundaries', () => {
const source = 'Use $x + 1$, $$E = mc^2$$, \\(a + b\\), $$$$, then \\($z$.';
expect(findTechnicalTokenRanges(source)
.filter((range) => range.kind === 'math')
.map((range) => range.text))
.toEqual(['$x + 1$', '$$E = mc^2$$', '\\(a + b\\)', '$$$$', '$z$']);
expect(findTechnicalTokenRanges('\\(not closed\n\\)')
.filter((range) => range.kind === 'math')).toEqual([]);
});

it('treats closed multiline Markdown fences inside prose as technical ranges in raw text', () => {
const closed = 'شغل الكود:\n```bash\necho hello world\n```\nالناتج صحيح.';
const closedRanges = findTechnicalTokenRanges(closed);
Expand Down Expand Up @@ -906,6 +916,18 @@ describe('streaming', () => {
findTechnicalTokenRanges(backticks);
expect(performance.now() - backtickBatchStart).toBeLessThan(1_500);

// An unmatched `\(` used to restart the lazy regular-expression search at
// every later opener, making adversarial chat input quadratic.
const unmatchedParenthesizedMath = '\\('.repeat(64_000);
const parenthesizedMathStart = performance.now();
findTechnicalTokenRanges(unmatchedParenthesizedMath);
expect(performance.now() - parenthesizedMathStart).toBeLessThan(1_500);

const unmatchedMathLines = `${'\\(\n'.repeat(32_000)}\\)`;
const mathLinesStart = performance.now();
findTechnicalTokenRanges(unmatchedMathLines);
expect(performance.now() - mathLinesStart).toBeLessThan(1_500);

const manyFences = `متن\n${'```\nx\n```\n\n'.repeat(1_000)}ادامه`;
const manyFencesStart = performance.now();
findTechnicalTokenRanges(manyFences);
Expand Down
33 changes: 32 additions & 1 deletion packages/core/src/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,37 @@ function addMatches(
}
}

/**
* Adds `$...$`, `$$...$$`, and `\(...\)` math ranges in one forward pass.
* A combined lazy regular expression restarts its search after every unmatched
* `\(` opener, which makes adversarial input quadratic.
*/
function addMathRanges(text: string, ranges: TechnicalTokenRange[]): void {
let i = 0;
let scanned = -1;
while (i < text.length) {
const p = text[i] === '\\' && text[i + 1] === '(';
const d = text[i] === '$'
? (text[i + 1] === '$' ? '$$' : '$')
: (p && i >= scanned ? '\\)' : '');
if (!d) { i++; continue; }

let e = i + (p ? 2 : d.length);
while (e < text.length
&& text[e] !== '\r'
&& text[e] !== '\n'
&& !text.startsWith(d, e)) e++;

if (text.startsWith(d, e) && (d !== '$' || e > i + 1)) {
addRange(ranges, text, i, e + d.length, 'math');
i = e + d.length;
} else {
if (p) scanned = e;
i++;
}
}
}

function trimTechnicalPunctuation(value: string): string {
let end = value.length;
while (end > 0 && /[.,;:!?،؛؟。।۔]/u.test(value[end - 1]!)) end -= 1;
Expand Down Expand Up @@ -338,7 +369,7 @@ export function findTechnicalTokenRanges(
const ranges: TechnicalTokenRange[] = [];
addCodeRanges(text, ranges);
addMatches(text, ranges, /<\/?[A-Za-z][^<>\r\n]*>/gu, 'html');
addMatches(text, ranges, /(?:\$\$[^\r\n]*?\$\$|\$[^$\r\n]+\$|\\\([^\r\n]*?\\\))/gu, 'math');
addMathRanges(text, ranges);

const urls = /\b(?:https?|ftp):\/\/[^\s<>{}"']+/giu;
let urlMatch: RegExpExecArray | null;
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/segments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@ function attachSourceRanges(text: string, isolations: Omit<InlineIsolation, 'sou
let utf16Offset = 0;
let codePointOffset = 0;
for (const character of text) {
codePointAtUtf16[utf16Offset] = codePointOffset;
if (character.length === 2) codePointAtUtf16[utf16Offset + 1] = codePointOffset;
codePointAtUtf16.fill(codePointOffset, utf16Offset, utf16Offset + character.length);
utf16Offset += character.length;
codePointOffset += 1;
codePointAtUtf16[utf16Offset] = codePointOffset;
}
codePointAtUtf16.fill(codePointOffset, utf16Offset);
return isolations.map((isolation) => ({
...isolation,
sourceRange: {
Expand Down
30 changes: 21 additions & 9 deletions scripts/generate-bidi-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ interface UnicodeSource {
sha256: string;
}

const UNICODE_SOURCES = [
{
label: 'DerivedBidiClass',
url: BIDI_URL,
path: BIDI_PATH,
sha256: BIDI_SHA256
},
{
label: 'DerivedGeneralCategory',
url: GENERAL_CATEGORY_URL,
path: GENERAL_CATEGORY_PATH,
sha256: GENERAL_CATEGORY_SHA256
}
] as const satisfies readonly UnicodeSource[];

const PINNED_UNICODE_PATHS = new Set(UNICODE_SOURCES.map((source) => source.path));

const CLASS = {
L: 0,
R: 1,
Expand Down Expand Up @@ -267,6 +284,9 @@ async function sourceBytes(source: UnicodeSource, download: boolean): Promise<Ui
throw new Error(`${source.label} checksum mismatch: expected ${source.sha256}, received ${actualSha256}`);
}
if (download) {
if (!PINNED_UNICODE_PATHS.has(source.path)) {
throw new Error(`Refusing to write outside the pinned Unicode source paths: ${source.path}`);
}
await mkdir(dirname(source.path), { recursive: true });
await writeFile(source.path, bytes);
}
Expand All @@ -276,15 +296,7 @@ async function sourceBytes(source: UnicodeSource, download: boolean): Promise<Ui
async function main(): Promise<void> {
const download = process.argv.includes('--download');
const check = process.argv.includes('--check');
const bidiSource: UnicodeSource = {
label: 'DerivedBidiClass', url: BIDI_URL, path: BIDI_PATH, sha256: BIDI_SHA256
};
const generalCategorySource: UnicodeSource = {
label: 'DerivedGeneralCategory',
url: GENERAL_CATEGORY_URL,
path: GENERAL_CATEGORY_PATH,
sha256: GENERAL_CATEGORY_SHA256
};
const [bidiSource, generalCategorySource] = UNICODE_SOURCES;
const [bidiBytes, generalCategoryBytes] = await Promise.all([
sourceBytes(bidiSource, download),
sourceBytes(generalCategorySource, download)
Expand Down