test(llm-security): add JetBrains fixture tree + build helper

This commit is contained in:
Kjell Tore Guttormsen 2026-04-18 10:49:49 +02:00
commit 3de29931fe
21 changed files with 324 additions and 0 deletions

View 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

View file

@ -0,0 +1,3 @@
Manifest-Version: 1.0
Implementation-Title: com.google.example
Implementation-Version: 1.0.0

View file

@ -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>

View file

@ -0,0 +1,3 @@
Manifest-Version: 1.0
Implementation-Title: com.example.fleet
Implementation-Version: 1.0.0

View file

@ -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>

View file

@ -0,0 +1,3 @@
Manifest-Version: 1.0
Implementation-Title: com.example.benign
Implementation-Version: 1.0.0

View file

@ -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>

View file

@ -0,0 +1,3 @@
Manifest-Version: 1.0
Implementation-Title: com.example.broad-activation
Implementation-Version: 1.0.0

View file

@ -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>

View file

@ -0,0 +1,3 @@
Manifest-Version: 1.0
Implementation-Title: com.example.depends-chain
Implementation-Version: 1.0.0

View file

@ -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>

View file

@ -0,0 +1,3 @@
Manifest-Version: 1.0
Implementation-Title: com.example.native-binary
Implementation-Version: 1.0.0

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -0,0 +1,3 @@
Manifest-Version: 1.0
Implementation-Title: com.example.theme-with-code
Implementation-Version: 1.0.0

View file

@ -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>

View file

@ -0,0 +1,3 @@
Manifest-Version: 1.0
Implementation-Title: com.intellij.jaba
Implementation-Version: 1.0.0

View file

@ -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>

View 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);
});
}