diff --git a/scanners/lib/ide-extension-parser.mjs b/scanners/lib/ide-extension-parser.mjs index a6c0873..8bc5f5b 100644 --- a/scanners/lib/ide-extension-parser.mjs +++ b/scanners/lib/ide-extension-parser.mjs @@ -140,15 +140,23 @@ const NAMED_ENTITIES = { * @param {string} s * @returns {string} */ +/** Unicode's maximum code point — String.fromCodePoint throws RangeError above it. */ +const MAX_CODE_POINT = 0x10FFFF; + +/** Number.isFinite does not bound the value; fromCodePoint needs an actual range check. */ +function isDecodableCodePoint(cp) { + return Number.isInteger(cp) && cp >= 0 && cp <= MAX_CODE_POINT; +} + function decodeEntities(s) { return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (full, inner) => { if (inner.startsWith('#x') || inner.startsWith('#X')) { const cp = parseInt(inner.slice(2), 16); - return Number.isFinite(cp) ? String.fromCodePoint(cp) : full; + return isDecodableCodePoint(cp) ? String.fromCodePoint(cp) : full; } if (inner.startsWith('#')) { const cp = parseInt(inner.slice(1), 10); - return Number.isFinite(cp) ? String.fromCodePoint(cp) : full; + return isDecodableCodePoint(cp) ? String.fromCodePoint(cp) : full; } return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, inner) ? NAMED_ENTITIES[inner] diff --git a/tests/scanners/ide-extension-parser-entities.test.mjs b/tests/scanners/ide-extension-parser-entities.test.mjs new file mode 100644 index 0000000..3ae479c --- /dev/null +++ b/tests/scanners/ide-extension-parser-entities.test.mjs @@ -0,0 +1,59 @@ +// ide-extension-parser-entities.test.mjs — Regression tests for XML numeric +// character references in plugin.xml. +// +// parsePluginXml documents a no-throw contract: "Malformed input returns +// { manifest: null, warnings: [...] } rather than throwing." decodeEntities +// guarded parseInt with Number.isFinite, which does not bound the value, so +// String.fromCodePoint raised RangeError for any code point above 0x10FFFF — +// a one-token hostile plugin.xml that aborts the parse instead of being +// reported. + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parsePluginXml } from '../../scanners/lib/ide-extension-parser.mjs'; + +function xmlWithName(name) { + return `com.example.p${name}v`; +} + +describe('ide-extension-parser — numeric character references', () => { + const outOfRange = [ + ['hex, just past the Unicode maximum', '�'], + ['decimal, just past the Unicode maximum', '�'], + ['hex, far out of range', '�'], + ['decimal, far out of range', '�'], + ]; + + for (const [label, ref] of outOfRange) { + it(`does not throw on an out-of-range reference (${label})`, () => { + assert.doesNotThrow(() => parsePluginXml(xmlWithName(`Bad${ref}Name`))); + }); + + it(`leaves an out-of-range reference literal (${label})`, () => { + const { manifest } = parsePluginXml(xmlWithName(`Bad${ref}Name`)); + assert.ok(manifest, 'manifest should still parse'); + assert.ok( + manifest.name.includes(ref), + `undecodable reference should be left as-is, got: ${manifest.name}` + ); + }); + } + + it('still decodes valid numeric references', () => { + const { manifest } = parsePluginXml(xmlWithName('AB')); + assert.ok(manifest); + assert.equal(manifest.name, 'AB'); + }); + + it('still decodes the maximum valid code point', () => { + const { manifest } = parsePluginXml(xmlWithName('x􏿿')); + assert.ok(manifest); + assert.equal(manifest.name, 'x\u{10FFFF}'); + }); + + it('still decodes named entities', () => { + const { manifest } = parsePluginXml(xmlWithName('A&B<C')); + assert.ok(manifest); + assert.equal(manifest.name, 'A&B