feat(llm-security): add zero-dep plugin.xml + MANIFEST.MF parsers
This commit is contained in:
parent
50c0cd3065
commit
b86239448d
2 changed files with 546 additions and 4 deletions
243
plugins/llm-security/tests/scanners/jetbrains-parser.test.mjs
Normal file
243
plugins/llm-security/tests/scanners/jetbrains-parser.test.mjs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// jetbrains-parser.test.mjs — Zero-dep plugin.xml + MANIFEST.MF parsers.
|
||||
//
|
||||
// All inputs are inline strings — no filesystem fixtures required.
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
parsePluginXml,
|
||||
parseManifestMf,
|
||||
} from '../../scanners/lib/ide-extension-parser.mjs';
|
||||
|
||||
describe('parsePluginXml — happy path', () => {
|
||||
const xml = `<?xml version="1.0"?>
|
||||
<idea-plugin>
|
||||
<id>org.example.myplugin</id>
|
||||
<name>My Plugin</name>
|
||||
<version>1.2.3</version>
|
||||
<vendor url="https://example.com">Example Inc</vendor>
|
||||
<idea-version since-build="232.0" until-build="242.*"/>
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<depends optional="true" config-file="python.xml">com.intellij.modules.python</depends>
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<applicationService serviceImplementation="org.example.Foo"/>
|
||||
<postStartupActivity implementation="org.example.Startup"/>
|
||||
<themeProvider id="my-theme" path="/themes/my.theme.json"/>
|
||||
</extensions>
|
||||
</idea-plugin>`;
|
||||
|
||||
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 = `<idea-plugin>
|
||||
<id>x.y</id>
|
||||
<name><![CDATA[<b>hello & world</b>]]></name>
|
||||
</idea-plugin>`;
|
||||
const { manifest } = parsePluginXml(xml);
|
||||
assert.equal(manifest.name, '<b>hello & world</b>');
|
||||
});
|
||||
|
||||
it('decodes named entity refs in non-CDATA text', () => {
|
||||
const xml = `<idea-plugin>
|
||||
<id>com.intellij.java&extras</id>
|
||||
<name>n</name>
|
||||
</idea-plugin>`;
|
||||
const { manifest } = parsePluginXml(xml);
|
||||
assert.equal(manifest.pluginId, 'com.intellij.java&extras');
|
||||
});
|
||||
|
||||
it('decodes numeric entity refs (decimal + hex)', () => {
|
||||
const xml = `<idea-plugin><id>ABCD</id><name>n</name></idea-plugin>`;
|
||||
const { manifest } = parsePluginXml(xml);
|
||||
assert.equal(manifest.pluginId, 'ABCD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePluginXml — robustness', () => {
|
||||
it('parses BOM-prefixed input identically', () => {
|
||||
const xmlA = `<idea-plugin><id>a</id><name>n</name></idea-plugin>`;
|
||||
const xmlB = '\uFEFF' + xmlA;
|
||||
assert.deepEqual(parsePluginXml(xmlA).manifest, parsePluginXml(xmlB).manifest);
|
||||
});
|
||||
|
||||
it('parses CRLF identically to LF', () => {
|
||||
const xmlLF = `<idea-plugin>\n<id>a</id>\n<name>n</name>\n</idea-plugin>`;
|
||||
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 = `<idea-plugin>
|
||||
<!-- <id>fake.id</id> -->
|
||||
<id>real.id</id>
|
||||
<name>n</name>
|
||||
</idea-plugin>`;
|
||||
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 = `<idea-plugin><id>a</id><name>n</name`; // truncated
|
||||
const { manifest, warnings } = parsePluginXml(xml);
|
||||
assert.equal(manifest, null);
|
||||
assert.ok(warnings.length > 0);
|
||||
});
|
||||
|
||||
it('unknown namespace on <extensions> is preserved', () => {
|
||||
const xml = `<idea-plugin>
|
||||
<id>a</id><name>n</name>
|
||||
<extensions defaultExtensionNs="org.custom">
|
||||
<myService key="x"/>
|
||||
</extensions>
|
||||
</idea-plugin>`;
|
||||
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 = `<idea-plugin>
|
||||
<id>a</id><name>n</name>
|
||||
<application-components>
|
||||
<component>
|
||||
<implementation-class>com.bad.Comp</implementation-class>
|
||||
</component>
|
||||
</application-components>
|
||||
</idea-plugin>`;
|
||||
const { manifest } = parsePluginXml(xml);
|
||||
assert.deepEqual(manifest.applicationComponents, ['com.bad.Comp']);
|
||||
});
|
||||
|
||||
it('captures applicationListener topic + class', () => {
|
||||
const xml = `<idea-plugin>
|
||||
<id>a</id><name>n</name>
|
||||
<applicationListener topic="com.intellij.ide.AppLifecycleListener" class="org.x.Listener"/>
|
||||
</idea-plugin>`;
|
||||
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));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue