// jetbrains-parser.test.mjs — Zero-dep plugin.xml + MANIFEST.MF parsers. // // All inputs are inline strings — no filesystem fixtures required. import { describe, it, after } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, mkdir, writeFile, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createHash } from 'node:crypto'; import { parsePluginXml, parseManifestMf, parseIntelliJPlugin, } from '../../scanners/lib/ide-extension-parser.mjs'; import { createZip } from '../helpers/zip-writer.mjs'; describe('parsePluginXml — happy path', () => { const xml = ` org.example.myplugin My Plugin 1.2.3 Example Inc com.intellij.modules.platform com.intellij.modules.python `; it('extracts pluginId, name, version, vendor', () => { const { manifest, warnings } = parsePluginXml(xml); assert.ok(manifest, `expected manifest, got null; warnings: ${warnings.join('; ')}`); assert.equal(manifest.pluginId, 'org.example.myplugin'); assert.equal(manifest.name, 'My Plugin'); assert.equal(manifest.version, '1.2.3'); assert.equal(manifest.vendor, 'Example Inc'); assert.equal(manifest.vendorUrl, 'https://example.com'); }); it('extracts idea-version build range', () => { const { manifest } = parsePluginXml(xml); assert.equal(manifest.sinceBuild, '232.0'); assert.equal(manifest.untilBuild, '242.*'); }); it('extracts depends[] with optional + config-file', () => { const { manifest } = parsePluginXml(xml); assert.equal(manifest.depends.length, 2); assert.deepEqual(manifest.depends[0], { id: 'com.intellij.modules.platform', optional: false, configFile: null, }); assert.deepEqual(manifest.depends[1], { id: 'com.intellij.modules.python', optional: true, configFile: 'python.xml', }); }); it('captures extension children with namespace', () => { const { manifest } = parsePluginXml(xml); const names = manifest.extensionDeclarations.map(e => e.name).sort(); assert.deepEqual(names, ['applicationService', 'postStartupActivity', 'themeProvider']); assert.ok(manifest.extensionDeclarations.every(e => e.namespace === 'com.intellij')); }); it('collects themeProviders[] with id + path', () => { const { manifest } = parsePluginXml(xml); assert.equal(manifest.themeProviders.length, 1); assert.equal(manifest.themeProviders[0].id, 'my-theme'); assert.equal(manifest.themeProviders[0].path, '/themes/my.theme.json'); }); }); describe('parsePluginXml — CDATA + entity handling', () => { it('preserves CDATA content verbatim', () => { const xml = ` x.y hello & world]]> `; const { manifest } = parsePluginXml(xml); assert.equal(manifest.name, 'hello & world'); }); it('decodes named entity refs in non-CDATA text', () => { const xml = ` com.intellij.java&extras n `; const { manifest } = parsePluginXml(xml); assert.equal(manifest.pluginId, 'com.intellij.java&extras'); }); it('decodes numeric entity refs (decimal + hex)', () => { const xml = `ABCDn`; const { manifest } = parsePluginXml(xml); assert.equal(manifest.pluginId, 'ABCD'); }); }); describe('parsePluginXml — robustness', () => { it('parses BOM-prefixed input identically', () => { const xmlA = `an`; const xmlB = '\uFEFF' + xmlA; assert.deepEqual(parsePluginXml(xmlA).manifest, parsePluginXml(xmlB).manifest); }); it('parses CRLF identically to LF', () => { const xmlLF = `\na\nn\n`; const xmlCRLF = xmlLF.replace(/\n/g, '\r\n'); assert.deepEqual(parsePluginXml(xmlLF).manifest, parsePluginXml(xmlCRLF).manifest); }); it('strips XML comments before regex match', () => { const xml = ` real.id n `; const { manifest } = parsePluginXml(xml); assert.equal(manifest.pluginId, 'real.id'); }); it('non-string input returns null + warning (never throws)', () => { const { manifest, warnings } = parsePluginXml(null); assert.equal(manifest, null); assert.ok(warnings.length > 0); }); it('truncated input returns null + warning (never throws)', () => { const xml = `an 0); }); it('unknown namespace on is preserved', () => { const xml = ` an `; const { manifest } = parsePluginXml(xml); assert.equal(manifest.extensionDeclarations.length, 1); assert.equal(manifest.extensionDeclarations[0].namespace, 'org.custom'); assert.equal(manifest.extensionDeclarations[0].name, 'myService'); }); it('captures legacy application-components', () => { const xml = ` an com.bad.Comp `; const { manifest } = parsePluginXml(xml); assert.deepEqual(manifest.applicationComponents, ['com.bad.Comp']); }); it('captures applicationListener topic + class', () => { const xml = ` an `; const { manifest } = parsePluginXml(xml); assert.equal(manifest.listeners.length, 1); assert.equal(manifest.listeners[0].topic, 'com.intellij.ide.AppLifecycleListener'); assert.equal(manifest.listeners[0].class, 'org.x.Listener'); }); }); describe('parseManifestMf', () => { it('extracts Main-Class, Premain-Class, Implementation-Title/Version', () => { const mf = [ 'Manifest-Version: 1.0', 'Main-Class: org.example.Main', 'Premain-Class: org.bad.Agent', 'Implementation-Title: my-plugin', 'Implementation-Version: 1.0.0', '', ].join('\n'); const out = parseManifestMf(mf); assert.equal(out.mainClass, 'org.example.Main'); assert.equal(out.premainClass, 'org.bad.Agent'); assert.equal(out.implTitle, 'my-plugin'); assert.equal(out.implVersion, '1.0.0'); }); it('collects Premain-/Agent-/Can- attrs into premainAttrs', () => { const mf = [ 'Premain-Class: org.bad.Agent', 'Can-Redefine-Classes: true', 'Can-Retransform-Classes: true', 'Agent-Class: org.bad.Agent', 'Boot-Class-Path: boot.jar', '', ].join('\n'); const out = parseManifestMf(mf); assert.equal(out.premainAttrs['Can-Redefine-Classes'], 'true'); assert.equal(out.premainAttrs['Can-Retransform-Classes'], 'true'); assert.equal(out.premainAttrs['Agent-Class'], 'org.bad.Agent'); assert.equal(out.premainAttrs['Boot-Class-Path'], 'boot.jar'); }); it('handles 72-char continuation lines (space-prefixed)', () => { const mf = [ 'Premain-Class: org.example.VeryLongPackage', ' Name.ContinuedAgent', '', ].join('\n'); const out = parseManifestMf(mf); assert.equal(out.premainClass, 'org.example.VeryLongPackageName.ContinuedAgent'); }); it('handles tab continuation (rare but legal)', () => { const mf = 'Main-Class: org.a\n\tTail\n'; const out = parseManifestMf(mf); assert.equal(out.mainClass, 'org.aTail'); }); it('empty input returns all-null without throwing', () => { const out = parseManifestMf(''); assert.equal(out.mainClass, null); assert.equal(out.premainClass, null); assert.deepEqual(out.premainAttrs, {}); }); it('non-string input returns all-null without throwing', () => { const out = parseManifestMf(null); assert.equal(out.mainClass, null); }); it('garbage input returns all-null without throwing', () => { const out = parseManifestMf('lkajsdf qwertyui 12345\n!!!\n'); assert.equal(out.mainClass, null); assert.equal(out.premainClass, null); }); it('CRLF input parses identically to LF', () => { const lf = 'Main-Class: org.a\nPremain-Class: org.b\n'; const crlf = lf.replace(/\n/g, '\r\n'); assert.deepEqual(parseManifestMf(lf), parseManifestMf(crlf)); }); }); // --------------------------------------------------------------------------- // parseIntelliJPlugin — synthetic plugin dirs built in-test via zip-writer // --------------------------------------------------------------------------- const TEST_TMP_PREFIX = 'llmsec-jbparse-test-'; const createdRoots = []; async function makePluginDir(jars) { const root = await mkdtemp(join(tmpdir(), TEST_TMP_PREFIX)); createdRoots.push(root); await mkdir(join(root, 'lib'), { recursive: true }); for (const { name, entries } of jars) { const buf = createZip(entries); await writeFile(join(root, 'lib', name), buf); } return root; } const BENIGN_PLUGIN_XML = ` com.example.benign Benign 1.0 Example `; describe('parseIntelliJPlugin — benign synthetic plugin', () => { it('extracts pluginId, depends, no native/premain/signature', async () => { const root = await makePluginDir([ { name: 'main.jar', entries: [ { name: 'META-INF/plugin.xml', data: BENIGN_PLUGIN_XML }, { name: 'META-INF/MANIFEST.MF', data: 'Manifest-Version: 1.0\n' }, ], }, ]); const res = await parseIntelliJPlugin(root); assert.ok(res, 'expected non-null result'); assert.ok(res.manifest, 'expected manifest'); assert.equal(res.manifest.type, 'jetbrains'); assert.equal(res.manifest.pluginId, 'com.example.benign'); assert.equal(res.manifest.nativeBinaries.length, 0); assert.equal(res.manifest.hasPremainClass, false); assert.equal(res.manifest.hasSignature, false); assert.ok(Array.isArray(res.manifest.bundledJars)); assert.equal(res.manifest.bundledJars.length, 1); }); }); describe('parseIntelliJPlugin — Premain-Class detection', () => { it('hasPremainClass === true when MANIFEST.MF sets it', async () => { const root = await makePluginDir([ { name: 'main.jar', entries: [ { name: 'META-INF/plugin.xml', data: BENIGN_PLUGIN_XML }, { name: 'META-INF/MANIFEST.MF', data: 'Manifest-Version: 1.0\nPremain-Class: com.example.Agent\n', }, ], }, ]); const res = await parseIntelliJPlugin(root); assert.equal(res.manifest.hasPremainClass, true); assert.equal(res.manifest.premainClass, 'com.example.Agent'); }); }); describe('parseIntelliJPlugin — native binary detection', () => { it('collects .so files with SHA-256 and size', async () => { const nativeBytes = Buffer.alloc(16, 0xAB); const expectedSha = createHash('sha256').update(nativeBytes).digest('hex'); const root = await makePluginDir([ { name: 'main.jar', entries: [ { name: 'META-INF/plugin.xml', data: BENIGN_PLUGIN_XML }, { name: 'native/dummy.so', data: nativeBytes }, ], }, ]); const res = await parseIntelliJPlugin(root); assert.equal(res.manifest.nativeBinaries.length, 1); assert.equal(res.manifest.nativeBinaries[0].size, 16); assert.equal(res.manifest.nativeBinaries[0].sha256, expectedSha); }); }); describe('parseIntelliJPlugin — failure modes', () => { it('missing lib/ returns null with IDE-JB-NO-LIB-DIR warning', async () => { const root = await mkdtemp(join(tmpdir(), TEST_TMP_PREFIX)); createdRoots.push(root); const res = await parseIntelliJPlugin(root); assert.equal(res.manifest, null); assert.ok(res.warnings.some(w => w.startsWith('IDE-JB-NO-LIB-DIR'))); }); it('no plugin.xml in any jar returns null with IDE-JB-NO-PLUGIN-XML', async () => { const root = await makePluginDir([ { name: 'empty.jar', entries: [ { name: 'META-INF/MANIFEST.MF', data: 'Manifest-Version: 1.0\n' }, ], }, ]); const res = await parseIntelliJPlugin(root); assert.equal(res.manifest, null); assert.ok(res.warnings.some(w => w.includes('NO-PLUGIN-XML'))); }); }); describe('parseIntelliJPlugin — temp dir cleanup', () => { after(async () => { // Cleanup test plugin roots for (const r of createdRoots) { await rm(r, { recursive: true, force: true }).catch(() => {}); } // Assert no llmsec-jb-* temp dirs remain const entries = await readdir(tmpdir()).catch(() => []); const leaked = entries.filter(n => n.startsWith('llmsec-jb-')); assert.equal(leaked.length, 0, `leaked temp dirs: ${leaked.join(', ')}`); }); it('cleanup runs (checked via after hook)', () => { assert.ok(true); }); });