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