From 3de29931fef4a22bae432252d7e4c6c85f09b559 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 18 Apr 2026 10:49:49 +0200 Subject: [PATCH] test(llm-security): add JetBrains fixture tree + build helper --- .../ide-extensions/root-jetbrains/.gitignore | 4 + .../com.google.example/source/MANIFEST.MF | 3 + .../com.google.example/source/plugin.xml | 10 + .../com.example.fleet/source/MANIFEST.MF | 3 + .../com.example.fleet/source/plugin.xml | 8 + .../com.example.benign/source/MANIFEST.MF | 3 + .../com.example.benign/source/plugin.xml | 10 + .../source/MANIFEST.MF | 3 + .../source/plugin.xml | 14 ++ .../source/MANIFEST.MF | 3 + .../source/plugin.xml | 12 ++ .../source/MANIFEST.MF | 3 + .../source/native/foo.so | Bin 0 -> 48 bytes .../source/plugin.xml | 9 + .../com.example.premain/source/MANIFEST.MF | 6 + .../com.example.premain/source/plugin.xml | 9 + .../source/MANIFEST.MF | 3 + .../source/plugin.xml | 13 ++ .../com.intellij.jaba/source/MANIFEST.MF | 3 + .../com.intellij.jaba/source/plugin.xml | 9 + .../helpers/build-jetbrains-fixtures.mjs | 196 ++++++++++++++++++ 21 files changed, 324 insertions(+) create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/.gitignore create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/MANIFEST.MF create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/plugin.xml create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/MANIFEST.MF create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/plugin.xml create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/MANIFEST.MF create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/plugin.xml create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/MANIFEST.MF create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/plugin.xml create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/MANIFEST.MF create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/plugin.xml create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/MANIFEST.MF create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/native/foo.so create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/plugin.xml create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/MANIFEST.MF create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/plugin.xml create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/MANIFEST.MF create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/plugin.xml create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/MANIFEST.MF create mode 100644 plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/plugin.xml create mode 100644 plugins/llm-security/tests/helpers/build-jetbrains-fixtures.mjs diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/.gitignore b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/.gitignore new file mode 100644 index 0000000..006fc5f --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/.gitignore @@ -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 diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/MANIFEST.MF new file mode 100644 index 0000000..97d34b9 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Implementation-Title: com.google.example +Implementation-Version: 1.0.0 diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/plugin.xml new file mode 100644 index 0000000..8f93225 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/plugin.xml @@ -0,0 +1,10 @@ + + + com.google.example + Android Studio Example + 1.0.0 + Google + A plugin installed under the Android Studio base directory — discovery must find it. + com.intellij.modules.platform + org.jetbrains.android + diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/MANIFEST.MF new file mode 100644 index 0000000..51501d1 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Implementation-Title: com.example.fleet +Implementation-Version: 1.0.0 diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/plugin.xml new file mode 100644 index 0000000..1e2375b --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/plugin.xml @@ -0,0 +1,8 @@ + + + com.example.fleet + Fleet Plugin + 1.0.0 + Example Inc + MUST be excluded by JetBrains discovery — Fleet uses a different plugin model. + diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/MANIFEST.MF new file mode 100644 index 0000000..f3a6e65 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Implementation-Title: com.example.benign +Implementation-Version: 1.0.0 diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/plugin.xml new file mode 100644 index 0000000..823fb17 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/plugin.xml @@ -0,0 +1,10 @@ + + + com.example.benign + Benign Example + 1.0.0 + Example Inc + A well-behaved plugin with no flagged signals. + + com.intellij.modules.platform + diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/MANIFEST.MF new file mode 100644 index 0000000..480c9f4 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Implementation-Title: com.example.broad-activation +Implementation-Version: 1.0.0 diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/plugin.xml new file mode 100644 index 0000000..19ea46e --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/plugin.xml @@ -0,0 +1,14 @@ + + + com.example.broad-activation + Broad Activation + 1.0.0 + Example Inc + Declares legacy application-components — loads at IDE startup for every project. + com.intellij.modules.platform + + + com.example.EarlyBoot + + + diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/MANIFEST.MF new file mode 100644 index 0000000..0b7a6b6 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Implementation-Title: com.example.depends-chain +Implementation-Version: 1.0.0 diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/plugin.xml new file mode 100644 index 0000000..3f1c9ea --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/plugin.xml @@ -0,0 +1,12 @@ + + + com.example.depends-chain + Depends Chain + 1.0.0 + Example Inc + Long mandatory dependency chain — amplifies blast radius if any dep is compromised. + com.intellij.modules.platform + com.intellij.modules.lang + com.intellij.modules.java + com.jetbrains.php + diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/MANIFEST.MF new file mode 100644 index 0000000..9707837 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Implementation-Title: com.example.native-binary +Implementation-Version: 1.0.0 diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/native/foo.so b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/native/foo.so new file mode 100644 index 0000000000000000000000000000000000000000..4b2c3a8315d39b1df9bce52f4a879dabc7735267 GIT binary patch literal 48 Rcmb<-^>JfjWMm)~002Ku0b2k7 literal 0 HcmV?d00001 diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/plugin.xml new file mode 100644 index 0000000..b3debcf --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/plugin.xml @@ -0,0 +1,9 @@ + + + com.example.native-binary + Native Binary Plugin + 1.0.0 + Example Inc + Ships a shared library under native/ — expands attack surface via JNI. + com.intellij.modules.platform + diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/MANIFEST.MF new file mode 100644 index 0000000..9f1071e --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/MANIFEST.MF @@ -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 diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/plugin.xml new file mode 100644 index 0000000..496ac22 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/plugin.xml @@ -0,0 +1,9 @@ + + + com.example.premain + Premain Agent Plugin + 1.0.0 + Example Inc + Ships a java-agent via Premain-Class — can rewrite bytecode at startup. + com.intellij.modules.platform + diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/MANIFEST.MF new file mode 100644 index 0000000..140644a --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Implementation-Title: com.example.theme-with-code +Implementation-Version: 1.0.0 diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/plugin.xml new file mode 100644 index 0000000..254cbf5 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/plugin.xml @@ -0,0 +1,13 @@ + + + com.example.theme-with-code + Theme With Code + 1.0.0 + Evil Inc + A theme plugin that smuggles executable services (Material Theme malware pattern). + com.intellij.modules.platform + + + + + diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/MANIFEST.MF new file mode 100644 index 0000000..da26766 --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Implementation-Title: com.intellij.jaba +Implementation-Version: 1.0.0 diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/plugin.xml new file mode 100644 index 0000000..ad0f72c --- /dev/null +++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/plugin.xml @@ -0,0 +1,9 @@ + + + com.intellij.jaba + Java (jaba) + 1.0.0 + Impostor Inc + Typosquat of com.intellij.java (Levenshtein distance 1). + com.intellij.modules.platform + diff --git a/plugins/llm-security/tests/helpers/build-jetbrains-fixtures.mjs b/plugins/llm-security/tests/helpers/build-jetbrains-fixtures.mjs new file mode 100644 index 0000000..c382310 --- /dev/null +++ b/plugins/llm-security/tests/helpers/build-jetbrains-fixtures.mjs @@ -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/.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/ (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/ + */ +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 `.tmp.` 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 `/lib/.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: //plugins//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); + }); +}