test(llm-security): add JetBrains fixture tree + build helper
This commit is contained in:
parent
378e177000
commit
3de29931fe
21 changed files with 324 additions and 0 deletions
4
plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/.gitignore
vendored
Normal file
4
plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Generated by tests/helpers/build-jetbrains-fixtures.mjs — do not commit.
|
||||
# JARs are built from source/ at test time for determinism and to keep the
|
||||
# repository free of binary blobs.
|
||||
**/lib/*.jar
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Manifest-Version: 1.0
|
||||
Implementation-Title: com.google.example
|
||||
Implementation-Version: 1.0.0
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idea-plugin>
|
||||
<id>com.google.example</id>
|
||||
<name>Android Studio Example</name>
|
||||
<version>1.0.0</version>
|
||||
<vendor url="https://android.com">Google</vendor>
|
||||
<description>A plugin installed under the Android Studio base directory — discovery must find it.</description>
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<depends>org.jetbrains.android</depends>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Manifest-Version: 1.0
|
||||
Implementation-Title: com.example.fleet
|
||||
Implementation-Version: 1.0.0
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idea-plugin>
|
||||
<id>com.example.fleet</id>
|
||||
<name>Fleet Plugin</name>
|
||||
<version>1.0.0</version>
|
||||
<vendor>Example Inc</vendor>
|
||||
<description>MUST be excluded by JetBrains discovery — Fleet uses a different plugin model.</description>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Manifest-Version: 1.0
|
||||
Implementation-Title: com.example.benign
|
||||
Implementation-Version: 1.0.0
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idea-plugin>
|
||||
<id>com.example.benign</id>
|
||||
<name>Benign Example</name>
|
||||
<version>1.0.0</version>
|
||||
<vendor url="https://example.com">Example Inc</vendor>
|
||||
<description>A well-behaved plugin with no flagged signals.</description>
|
||||
<idea-version since-build="232.0" until-build="242.*"/>
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Manifest-Version: 1.0
|
||||
Implementation-Title: com.example.broad-activation
|
||||
Implementation-Version: 1.0.0
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idea-plugin>
|
||||
<id>com.example.broad-activation</id>
|
||||
<name>Broad Activation</name>
|
||||
<version>1.0.0</version>
|
||||
<vendor>Example Inc</vendor>
|
||||
<description>Declares legacy application-components — loads at IDE startup for every project.</description>
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<application-components>
|
||||
<component>
|
||||
<implementation-class>com.example.EarlyBoot</implementation-class>
|
||||
</component>
|
||||
</application-components>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Manifest-Version: 1.0
|
||||
Implementation-Title: com.example.depends-chain
|
||||
Implementation-Version: 1.0.0
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idea-plugin>
|
||||
<id>com.example.depends-chain</id>
|
||||
<name>Depends Chain</name>
|
||||
<version>1.0.0</version>
|
||||
<vendor>Example Inc</vendor>
|
||||
<description>Long mandatory dependency chain — amplifies blast radius if any dep is compromised.</description>
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<depends>com.intellij.modules.lang</depends>
|
||||
<depends>com.intellij.modules.java</depends>
|
||||
<depends>com.jetbrains.php</depends>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Manifest-Version: 1.0
|
||||
Implementation-Title: com.example.native-binary
|
||||
Implementation-Version: 1.0.0
|
||||
Binary file not shown.
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idea-plugin>
|
||||
<id>com.example.native-binary</id>
|
||||
<name>Native Binary Plugin</name>
|
||||
<version>1.0.0</version>
|
||||
<vendor>Example Inc</vendor>
|
||||
<description>Ships a shared library under native/ — expands attack surface via JNI.</description>
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
Manifest-Version: 1.0
|
||||
Implementation-Title: com.example.premain
|
||||
Implementation-Version: 1.0.0
|
||||
Premain-Class: com.example.Agent
|
||||
Can-Redefine-Classes: true
|
||||
Can-Retransform-Classes: true
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idea-plugin>
|
||||
<id>com.example.premain</id>
|
||||
<name>Premain Agent Plugin</name>
|
||||
<version>1.0.0</version>
|
||||
<vendor>Example Inc</vendor>
|
||||
<description>Ships a java-agent via Premain-Class — can rewrite bytecode at startup.</description>
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Manifest-Version: 1.0
|
||||
Implementation-Title: com.example.theme-with-code
|
||||
Implementation-Version: 1.0.0
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idea-plugin>
|
||||
<id>com.example.theme-with-code</id>
|
||||
<name>Theme With Code</name>
|
||||
<version>1.0.0</version>
|
||||
<vendor>Evil Inc</vendor>
|
||||
<description>A theme plugin that smuggles executable services (Material Theme malware pattern).</description>
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<themeProvider id="evil-dark" path="/themes/evil-dark.theme.json"/>
|
||||
<applicationService serviceImplementation="com.example.evil.BackgroundExec"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Manifest-Version: 1.0
|
||||
Implementation-Title: com.intellij.jaba
|
||||
Implementation-Version: 1.0.0
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idea-plugin>
|
||||
<id>com.intellij.jaba</id>
|
||||
<name>Java (jaba)</name>
|
||||
<version>1.0.0</version>
|
||||
<vendor>Impostor Inc</vendor>
|
||||
<description>Typosquat of com.intellij.java (Levenshtein distance 1).</description>
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
</idea-plugin>
|
||||
196
plugins/llm-security/tests/helpers/build-jetbrains-fixtures.mjs
Normal file
196
plugins/llm-security/tests/helpers/build-jetbrains-fixtures.mjs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
#!/usr/bin/env node
|
||||
// build-jetbrains-fixtures.mjs — Deterministic fixture-jar builder.
|
||||
//
|
||||
// Walks `tests/fixtures/ide-extensions/root-jetbrains/` and for every plugin
|
||||
// directory containing a `source/` subtree, produces `lib/<pluginId>.jar` by
|
||||
// packing the source files into a real ZIP with JetBrains-plugin layout:
|
||||
//
|
||||
// source/plugin.xml → META-INF/plugin.xml (inside jar)
|
||||
// source/MANIFEST.MF → META-INF/MANIFEST.MF (inside jar)
|
||||
// source/native/foo.so → native/foo.so (inside jar)
|
||||
// source/<other path> → <other path> (inside jar)
|
||||
//
|
||||
// Invariants:
|
||||
// - Deterministic: stored-method, fixed DOS timestamp, sorted entries.
|
||||
// Same input → byte-identical output across Node versions / platforms.
|
||||
// - Idempotent + race-safe: each jar write is atomic (temp-then-rename).
|
||||
// If the target file already exists with the expected SHA-256 (computed
|
||||
// from the intended bytes), the write is skipped. Concurrent callers are
|
||||
// safe because rename is atomic on the same filesystem and every writer
|
||||
// produces identical bytes.
|
||||
// - Zero dependencies. Uses `createZip` from `../helpers/zip-writer.mjs`.
|
||||
//
|
||||
// Public API:
|
||||
// export function buildJetBrainsFixtures({ fixtureRoot? }) → { fixtureRoot, builtJars }
|
||||
// export function writeJar(finalPath, entries) → { path, sha256, skipped }
|
||||
//
|
||||
// CLI: `node tests/helpers/build-jetbrains-fixtures.mjs` — walks the default
|
||||
// fixture root, builds all jars, prints a one-line summary per jar.
|
||||
//
|
||||
// Called by:
|
||||
// - tests/scanners/ide-extension-scanner.test.mjs (Step 14 integration)
|
||||
// - any future test that needs the synthetic JetBrains plugin tree
|
||||
//
|
||||
// See: plan step 13 (`ultraplan-2026-04-17-jetbrains-ide-scan.md`).
|
||||
|
||||
import { readdir, mkdir, rename, writeFile, readFile, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createZip } from './zip-writer.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_FIXTURE_ROOT = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'fixtures',
|
||||
'ide-extensions',
|
||||
'root-jetbrains',
|
||||
);
|
||||
|
||||
function sha256(buf) {
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk a directory, returning relative paths of regular files.
|
||||
* Relative paths use forward slashes (ZIP spec).
|
||||
*/
|
||||
async function walkFiles(root, rel = '') {
|
||||
const out = [];
|
||||
const full = rel ? join(root, rel) : root;
|
||||
const entries = await readdir(full, { withFileTypes: true });
|
||||
for (const ent of entries) {
|
||||
const sub = rel ? `${rel}/${ent.name}` : ent.name;
|
||||
if (ent.isDirectory()) {
|
||||
out.push(...(await walkFiles(root, sub)));
|
||||
} else if (ent.isFile()) {
|
||||
out.push(sub);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a `source/`-relative path to the in-jar path.
|
||||
*
|
||||
* source/plugin.xml → META-INF/plugin.xml
|
||||
* source/MANIFEST.MF → META-INF/MANIFEST.MF
|
||||
* source/native/foo.so → native/foo.so
|
||||
* source/<other> → <other>
|
||||
*/
|
||||
function sourceToJarPath(srcRelPath) {
|
||||
const p = srcRelPath.replace(/\\/g, '/');
|
||||
if (p === 'plugin.xml') return 'META-INF/plugin.xml';
|
||||
if (p === 'MANIFEST.MF') return 'META-INF/MANIFEST.MF';
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write a file by staging into `<path>.tmp.<pid>` and renaming.
|
||||
* Rename is atomic on the same filesystem; if two writers race, the last
|
||||
* rename wins — and since callers here pass identical bytes for a given
|
||||
* fixture input, the final file is byte-identical regardless of winner.
|
||||
*/
|
||||
async function atomicWrite(finalPath, bytes) {
|
||||
await mkdir(dirname(finalPath), { recursive: true });
|
||||
const tmp = `${finalPath}.tmp.${process.pid}`;
|
||||
await writeFile(tmp, bytes);
|
||||
await rename(tmp, finalPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single jar from a list of `{ name, data }` entries and write it to
|
||||
* `finalPath`. Skips the write if `finalPath` already exists with the
|
||||
* expected SHA-256 — makes the function idempotent and safe to call
|
||||
* repeatedly (including concurrently from multiple test.before hooks).
|
||||
*
|
||||
* @returns {{ path: string, sha256: string, skipped: boolean }}
|
||||
*/
|
||||
export async function writeJar(finalPath, entries) {
|
||||
const bytes = createZip(entries);
|
||||
const expected = sha256(bytes);
|
||||
|
||||
if (existsSync(finalPath)) {
|
||||
try {
|
||||
const existing = await readFile(finalPath);
|
||||
if (sha256(existing) === expected) {
|
||||
return { path: finalPath, sha256: expected, skipped: true };
|
||||
}
|
||||
} catch {
|
||||
// Fall through and rewrite.
|
||||
}
|
||||
}
|
||||
|
||||
await atomicWrite(finalPath, bytes);
|
||||
return { path: finalPath, sha256: expected, skipped: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build fixture jars for every plugin directory under the fixture root.
|
||||
* A plugin directory is detected by the presence of a `source/plugin.xml`.
|
||||
* The produced jar lands at `<pluginDir>/lib/<pluginDirName>.jar`.
|
||||
*
|
||||
* @param {{ fixtureRoot?: string }} [opts]
|
||||
* @returns {Promise<{ fixtureRoot: string, builtJars: Array<{ path: string, sha256: string, skipped: boolean, pluginId: string }> }>}
|
||||
*/
|
||||
export async function buildJetBrainsFixtures(opts = {}) {
|
||||
const fixtureRoot = opts.fixtureRoot || DEFAULT_FIXTURE_ROOT;
|
||||
if (!existsSync(fixtureRoot)) {
|
||||
throw new Error(`fixture root does not exist: ${fixtureRoot}`);
|
||||
}
|
||||
|
||||
const builtJars = [];
|
||||
|
||||
// Walk two levels deep: <fixtureRoot>/<IDE>/plugins/<pluginId>/source/plugin.xml
|
||||
async function walkIDEs() {
|
||||
const ideDirs = await readdir(fixtureRoot, { withFileTypes: true });
|
||||
for (const ide of ideDirs) {
|
||||
if (!ide.isDirectory()) continue;
|
||||
const pluginsDir = join(fixtureRoot, ide.name, 'plugins');
|
||||
if (!existsSync(pluginsDir)) continue;
|
||||
const pluginEntries = await readdir(pluginsDir, { withFileTypes: true });
|
||||
for (const p of pluginEntries) {
|
||||
if (!p.isDirectory()) continue;
|
||||
const pluginDir = join(pluginsDir, p.name);
|
||||
const sourceDir = join(pluginDir, 'source');
|
||||
if (!existsSync(join(sourceDir, 'plugin.xml'))) continue;
|
||||
|
||||
// Build jar entries from source tree.
|
||||
const files = await walkFiles(sourceDir);
|
||||
const entries = [];
|
||||
for (const rel of files) {
|
||||
const abs = join(sourceDir, rel);
|
||||
const data = await readFile(abs);
|
||||
entries.push({ name: sourceToJarPath(rel), data });
|
||||
}
|
||||
|
||||
const jarPath = join(pluginDir, 'lib', `${p.name}.jar`);
|
||||
const result = await writeJar(jarPath, entries);
|
||||
builtJars.push({ ...result, pluginId: p.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walkIDEs();
|
||||
return { fixtureRoot, builtJars };
|
||||
}
|
||||
|
||||
// CLI mode — invoked via `node tests/helpers/build-jetbrains-fixtures.mjs`.
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
buildJetBrainsFixtures()
|
||||
.then(({ fixtureRoot, builtJars }) => {
|
||||
const root = relative(process.cwd(), fixtureRoot);
|
||||
for (const j of builtJars) {
|
||||
const rel = relative(process.cwd(), j.path);
|
||||
const tag = j.skipped ? 'skip' : ' new';
|
||||
process.stdout.write(`[${tag}] ${rel}\n`);
|
||||
}
|
||||
process.stdout.write(`built ${builtJars.length} jar(s) under ${root}\n`);
|
||||
})
|
||||
.catch((err) => {
|
||||
process.stderr.write(`build-jetbrains-fixtures: ${err.stack || err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue