// codepoints.mjs — Code-point carrier tables, built from vendored commons. // // v8 Phase 5 step 4, first consumer swap. These four tables were hardcoded // constants in two places (unicode-scanner.mjs's charset block and // string-utils.mjs's HOMOGLYPH_MAP); they are now built once, here, from // `codepoints/carriers.json` in the vendored llm-security-commons subtree. // Verified byte-equal to the pre-swap constants before the swap: 5 zero-width, // 9 BIDI, 28 homoglyph entries, U+E0001–U+E007F, same values, same order. // // commons carries two further tables this module does NOT build: // - `cyrillic_confusables` (13): unicode-scanner.mjs declares a set by that // name but never reads it — its homoglyph-mixing detector tests // `isCyrillic(cp)`, the whole U+0400–U+04FF block. Loading a table no // runtime consumes would move dead data into the load path. // - `private_use`: no constant behind it here at all; commons transcribed it // from a source comment and marks it `verified: false`. // // Graceful-empty, deliberately, matching commons-loader.mjs's contract: this // module is on the import path of string-utils.mjs, which hooks import, and // hooks run per-tool-call in fresh processes. A module-load throw there would // break the tool call rather than just degrade the scan. The cost of that // choice is that a lost commons is silent at runtime, so the loud half lives // in `tests/lib/codepoints.test.mjs`, which asserts exact per-table counts // through the real default root. // // Zero external dependencies — Node.js builtins only. import { loadArtifact } from './commons-loader.mjs'; /** `"U+200B"` -> `0x200B`. Returns NaN for anything malformed, filtered by the callers. */ function parseCodepoint(value) { if (typeof value !== 'string') return NaN; const m = /^U\+([0-9A-Fa-f]{4,6})$/.exec(value.trim()); return m ? parseInt(m[1], 16) : NaN; } function codepointSet(entries) { const out = new Set(); if (!Array.isArray(entries)) return out; for (const entry of entries) { const cp = parseCodepoint(entry?.codepoint); if (!Number.isNaN(cp)) out.add(cp); } return out; } /** * Build the carrier tables from a commons root. * * @param {object} [opts] * @param {string} [opts.commonsRoot] - explicit commons root (tests, dev checkout). * @returns {{ * ZERO_WIDTH_CHARS: Set, * BIDI_CHARS: Set, * UNICODE_TAG_START: number, * UNICODE_TAG_END: number, * HOMOGLYPH_MAP: Readonly>, * }} */ export function buildCarrierTables(opts = {}) { const artifact = loadArtifact('codepoints/carriers', { fallback: {}, commonsRoot: opts.commonsRoot }); const tables = artifact?.tables ?? {}; const tagStart = parseCodepoint(tables.unicode_tags?.range?.start); const tagEnd = parseCodepoint(tables.unicode_tags?.range?.end); // An absent range must match nothing. It is a comparison, not a set, so // emptiness is expressed as an inverted range rather than a zero-size table. const rangeUsable = !Number.isNaN(tagStart) && !Number.isNaN(tagEnd); const homoglyphs = {}; for (const entry of tables.homoglyph_map?.entries ?? []) { if (typeof entry?.from_char === 'string' && typeof entry?.to === 'string') { homoglyphs[entry.from_char] = entry.to; } } return { ZERO_WIDTH_CHARS: codepointSet(tables.zero_width?.codepoints), BIDI_CHARS: codepointSet(tables.bidi?.codepoints), UNICODE_TAG_START: rangeUsable ? tagStart : Infinity, UNICODE_TAG_END: rangeUsable ? tagEnd : -Infinity, HOMOGLYPH_MAP: Object.freeze(homoglyphs), }; } const _tables = buildCarrierTables(); /** U+200B–U+200D, U+FEFF, U+00AD: visually invisible, used to hide content */ export const ZERO_WIDTH_CHARS = _tables.ZERO_WIDTH_CHARS; /** BIDI control characters — Trojan Source attack (CVE-2021-42574 class) */ export const BIDI_CHARS = _tables.BIDI_CHARS; /** Unicode Tags block U+E0001–U+E007F: encodes hidden ASCII via codepoint - 0xE0000 */ export const UNICODE_TAG_START = _tables.UNICODE_TAG_START; export const UNICODE_TAG_END = _tables.UNICODE_TAG_END; /** * Confusable → Latin fold table (Cyrillic + Greek), applied by * `foldHomoglyphs` after NFKC. Deliberately small: Latin Extended letters * (æ, ø, å, é, ñ, ü, ...) are legitimate in non-English source and are * excluded by design, as are non-overlapping Cyrillic/Greek letters and the * U+1D400 mathematical block (NFKC already handles it). */ export const HOMOGLYPH_MAP = _tables.HOMOGLYPH_MAP;