From 5cbf06c7ad0ace599721bffd7da566b8a790b622 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:24:36 +0800 Subject: [PATCH] fix(ghidra): admit the 12.1 line and the install's JDK range (#840) * fix(ghidra): admit the 12.1 line and the install's JDK range The provider treated the verified Ghidra 12.1.4 build and JDK 21 as the only legal installation, so a working 12.1.x package or a newer JDK was rejected before analysis started. Admission now follows the 12.1 release line and application.java.min/max. A compatible build other than 12.1.4 keeps its observed version and records that the bridge was verified against 12.1.4. Fixes #821 * fix(ghidra): stop pinning the NativeAOT build to one version string The build script still rejected every Ghidra release except the literal 12.1.4 and every javac except major 21. It now accepts the 12.1 line and javac 21 or newer, compiles with --release 21, and records the observed Ghidra version. The JADX engine version uses the audited release constant. * fix(ghidra): report unverified builds on analysis results Session open uses health only as a startup gate and discards that execution, so the unverified-build limitation never reached CLI or MCP results. Attach it to every Ghidra execution for that install. --------- Co-authored-by: Cursor Agent --- README.md | 2 +- README_ar.md | 4 +- README_ja.md | 2 +- README_ko.md | 2 +- README_zh.md | 2 +- docs/firmware-analysis.md | 2 +- docs/ghidra-dos.md | 6 +- docs/ghidra-nativeaot.md | 5 +- docs/installation.md | 11 +- docs/native-investigation.md | 2 +- docs/provider-evaluation.md | 14 +- docs/roadmap.md | 6 +- docs/testing.md | 5 + docs/windows-ghidra-p0.md | 4 +- scripts/build-ghidra-nativeaot.mjs | 52 ++++- src/android/JadxSession.ts | 2 +- src/ghidra/GhidraInstallation.test.ts | 117 ++++++++++- src/ghidra/GhidraInstallation.ts | 103 ++++++---- src/ghidra/GhidraInstallationPolicy.ts | 220 +++++++++++++++++++++ src/ghidra/GhidraProvider.behavior.test.ts | 67 +++++++ src/ghidra/GhidraProvider.ts | 24 +-- tests/conformance/ghidra/README.md | 5 +- 22 files changed, 572 insertions(+), 85 deletions(-) create mode 100644 src/ghidra/GhidraInstallationPolicy.ts diff --git a/README.md b/README.md index fb600a60f..67b6e756d 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Co ### Ghidra analysis provider -Already use Ghidra? REA can connect it to your agent on Linux x64 or macOS x64/arm64. It requires **Ghidra 12.1.4** and a **64-bit JDK 21**. On macOS, your Ghidra installation must also include the native decompiler for your architecture. +Already use Ghidra? REA can connect it to your agent on Linux x64 or macOS x64/arm64. It accepts **Ghidra 12.1.x** and the **64-bit full JDK** that installation declares (`application.java.min` through `application.java.max`). Current 12.1 releases require JDK 21 or newer and set no maximum. The bridge is verified with Ghidra 12.1.4 and JDK 21. On macOS, your Ghidra installation must also include the native decompiler for your architecture. Set the installation paths, then run setup: diff --git a/README_ar.md b/README_ar.md index e674ea57c..338706731 100644 --- a/README_ar.md +++ b/README_ar.md @@ -141,7 +141,7 @@ rea setup يتطلب تحليل الملفات التنفيذية الأصلية Hopper أو Ghidra. Hopper برنامج منفصل، وللوضع التجريبي قيود يحددها المورّد، لكن الترخيص المدفوع ليس إلزاميًا. -يدعم Ghidra نظامي Linux x64 وmacOS x64/arm64. ثبت Ghidra 12.1.4 وJDK 21 الكامل بنواة 64 بت بصورة منفصلة، ثم اضبط REA لاستخدامهما. يحتاج macOS أيضًا إلى أداة فك الترجمة الأصلية المطابقة لمعمارية الجهاز. +يدعم Ghidra نظامي Linux x64 وmacOS x64/arm64. ثبت Ghidra 12.1.x وJDK الكامل بنواة 64 بت الذي يعلنه ذلك التثبيت (`application.java.min` حتى `application.java.max`) بصورة منفصلة، ثم اضبط REA لاستخدامهما. تتطلب إصدارات 12.1 الحالية JDK 21 أو أحدث ولا تحدد حدًا أعلى. جرى التحقق من الجسر مع Ghidra 12.1.4 وJDK 21. يحتاج macOS أيضًا إلى أداة فك الترجمة الأصلية المطابقة لمعمارية الجهاز. يستطيع Setup التحقق من التثبيت وحفظ المسارات؛ ولا يثبت أو يحدّث Ghidra أو Java أو Node.js أو npm أو Homebrew. @@ -311,7 +311,7 @@ npx -y rea-agents@latest compare /absolute/path/to/left-evidence.json /absolute/
هل يعمل REA على Linux أو Windows؟ -يدعم REA نظام macOS 12+ وUbuntu 24.04+ وFedora 41+ وArch Linux بنواة 64 بت. يعمل تحليل Ghidra للقراءة فقط على Linux x64 وmacOS x64/arm64 مع Ghidra 12.1.4 وJDK 21 الكامل، إضافة إلى حد Windows x64 P0 التجريبي لتطبيقات PE الأصلية x86-64 على NTFS محلي؛ راجع [دليل Windows Ghidra P0](docs/windows-ghidra-p0.md) للنطاق الموثق. +يدعم REA نظام macOS 12+ وUbuntu 24.04+ وFedora 41+ وArch Linux بنواة 64 بت. يعمل تحليل Ghidra للقراءة فقط على Linux x64 وmacOS x64/arm64 مع Ghidra 12.1.x وJDK الكامل الذي يعلنه التثبيت (JDK 21 أو أحدث دون حد أعلى في إصدارات 12.1 الحالية)، إضافة إلى حد Windows x64 P0 التجريبي لتطبيقات PE الأصلية x86-64 على NTFS محلي؛ راجع [دليل Windows Ghidra P0](docs/windows-ghidra-p0.md) للنطاق الموثق.
diff --git a/README_ja.md b/README_ja.md index 145a9f4d8..d95d207c0 100644 --- a/README_ja.md +++ b/README_ja.md @@ -141,7 +141,7 @@ rea setup ネイティブバイナリ解析には Hopper または Ghidra が必要です。Hopper は別製品です。デモにはベンダー所定の制限がありますが、有料ライセンスは必須ではありません。 -Ghidra は Linux x64 と macOS x64/arm64 に対応します。Ghidra 12.1.4 と完全な 64 ビット JDK 21 を別途インストールし、REA で使うように設定してください。macOS ではホストのアーキテクチャに合うネイティブデコンパイラーも必要です。 +Ghidra は Linux x64 と macOS x64/arm64 に対応します。Ghidra 12.1.x と、そのインストールが宣言する完全な 64 ビット JDK(`application.java.min` から `application.java.max` まで)を別途インストールし、REA で使うように設定してください。現行の 12.1 リリースは JDK 21 以降を要求し、上限は設定しません。ブリッジは Ghidra 12.1.4 と JDK 21 で検証しています。macOS ではホストのアーキテクチャに合うネイティブデコンパイラーも必要です。 Setup はインストールを確認し、パスを保存できます。Ghidra、Java、Node.js、npm、Homebrew のインストールや更新は行いません。 diff --git a/README_ko.md b/README_ko.md index 5a6dd3e41..93f503057 100644 --- a/README_ko.md +++ b/README_ko.md @@ -142,7 +142,7 @@ rea setup 네이티브 바이너리 분석에는 Hopper 또는 Ghidra가 필요합니다. Hopper는 별도 소프트웨어입니다. 데모에는 공급업체의 제한이 있지만 유료 라이선스가 필수는 아닙니다. -Ghidra는 Linux x64와 macOS x64/arm64를 지원합니다. Ghidra 12.1.4와 완전한 64비트 JDK 21을 별도로 설치한 뒤 REA가 사용하도록 설정하세요. macOS에서는 호스트 아키텍처에 맞는 네이티브 디컴파일러도 필요합니다. +Ghidra는 Linux x64와 macOS x64/arm64를 지원합니다. Ghidra 12.1.x와 그 설치본이 선언한 완전한 64비트 JDK(`application.java.min`부터 `application.java.max`까지)를 별도로 설치한 뒤 REA가 사용하도록 설정하세요. 현재 12.1 릴리스는 JDK 21 이상을 요구하고 상한은 없습니다. 브리지는 Ghidra 12.1.4와 JDK 21에서 검증됩니다. macOS에서는 호스트 아키텍처에 맞는 네이티브 디컴파일러도 필요합니다. Setup은 설치를 확인하고 경로를 저장할 수 있습니다. Ghidra, Java, Node.js, npm, Homebrew를 설치하거나 업데이트하지 않습니다. diff --git a/README_zh.md b/README_zh.md index abcf139ee..8c7ff9823 100644 --- a/README_zh.md +++ b/README_zh.md @@ -141,7 +141,7 @@ rea setup 原生二进制分析需要 Hopper 或 Ghidra。Hopper 是独立软件;演示模式有厂商规定的限制,不要求购买许可证。 -Ghidra 支持 Linux x64 和 macOS x64/arm64。单独安装 Ghidra 12.1.4 和完整的 64 位 JDK 21,然后配置 REA 使用它们。macOS 还需要对应架构的原生反编译器。 +Ghidra 支持 Linux x64 和 macOS x64/arm64。单独安装 Ghidra 12.1.x,以及该安装在 `application.java.min` 到 `application.java.max` 中声明的完整 64 位 JDK,然后配置 REA 使用它们。当前 12.1 发行版要求 JDK 21 或更新版本,并且不设置上限。桥接在 Ghidra 12.1.4 和 JDK 21 上完成验证。macOS 还需要对应架构的原生反编译器。 Setup 可以验证安装并保存路径,不会安装或升级 Ghidra、Java、Node.js、npm 或 Homebrew。 diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 1dd1d5d78..b585affd8 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -165,7 +165,7 @@ Additional lanes are explicit, so the base lane stays small: REA_FIRMWARE_VERIFY_EXT4=1 npm run fixtures:firmware REA_FIRMWARE_VERIFY_EXT4=1 npm run verify:firmware -# Existing Ghidra 12.1.4 and Java required. +# Existing Ghidra 12.1.x and its declared JDK required. REA_FIRMWARE_VERIFY_GHIDRA=1 npm run verify:firmware ``` diff --git a/docs/ghidra-dos.md b/docs/ghidra-dos.md index a43e52408..893c3506d 100644 --- a/docs/ghidra-dos.md +++ b/docs/ghidra-dos.md @@ -2,9 +2,9 @@ REA can analyze DOS MZ executables and explicitly selected COM images through the bring-your-own Ghidra adapter. The Linux x64 verification lane exercises actual 16-bit disassembly and -decompilation through the CLI and stdio MCP. It requires the same Ghidra -12.1.4 and 64-bit JDK 21 installation as other Ghidra sessions; no DOS emulator -or cross-compiler is required. +decompilation through the CLI and stdio MCP. Provider admission accepts Ghidra +12.1.x and that installation's declared JDK range. The verification lane uses +Ghidra 12.1.4 and JDK 21; no DOS emulator or cross-compiler is required. ## Open and inspect diff --git a/docs/ghidra-nativeaot.md b/docs/ghidra-nativeaot.md index ccbf2e4e7..90dbf99b4 100644 --- a/docs/ghidra-nativeaot.md +++ b/docs/ghidra-nativeaot.md @@ -7,7 +7,10 @@ source notices are retained. No upstream source or algorithms are rewritten. The initial tested layout is .NET runtime **8.0.22**, NativeAOT RTR **9.1**, x86-64 ELF and native Windows PE **targets analyzed on Linux x64**, using -Ghidra **12.1.4** and JDK **21**. Other layouts/architectures/hosts are unsupported. +Ghidra **12.1.4** and JDK **21**. The build accepts any Ghidra 12.1.x release and +javac 21 or newer, and it compiles with `--release 21`. Real verification of +this layout remains the Ghidra 12.1.4 and JDK 21 pair. Other NativeAOT layouts, +architectures, and hosts are unsupported. Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun assembly should use `inspect_managed_artifact`; it is not NativeAOT. diff --git a/docs/installation.md b/docs/installation.md index 02b7d5c94..acb0036bd 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -266,7 +266,10 @@ copying license secrets into logs, or killing unrelated Hopper processes. REA connects to an existing Ghidra installation on Linux x64, macOS x64/arm64, or experimental Windows x64 P0. -It requires Ghidra 12.1.4 and a 64-bit full JDK 21. On macOS, the installation +It accepts Ghidra 12.1.x and the 64-bit full JDK declared by that installation's +`application.java.min` and `application.java.max`. Current 12.1 releases require +JDK 21 or newer and set no maximum. The bridge is verified with Ghidra 12.1.4 +and JDK 21. On macOS, the installation must include the native decompiler for the host architecture; REA does not build it or change Gatekeeper quarantine settings. @@ -312,9 +315,9 @@ installation remains unavailable on Windows. Setup never installs Ghidra, Java, or Python. It preserves valid detected Ghidra/JDK settings in agent registrations. See [Windows Ghidra P0](windows-ghidra-p0.md) for provider diagnostics. -Doctor validates the platform, architecture, application version, -`support/analyzeHeadless` or `support/analyzeHeadless.bat`, Java -version/bitness, and the presence of `javac`/`javac.exe`. +Doctor validates the platform, architecture, Ghidra 12.1.x application version, +`support/analyzeHeadless` or `support/analyzeHeadless.bat`, the installation's +Java major range, 64-bit JDK bitness, and the presence of `javac`/`javac.exe`. When Java is found through `PATH`, setup records its observed JDK home so GUI MCP clients do not depend on an incidental shell path. Setup shows every exact environment entry in its plan, writes only after approval, and never downloads, diff --git a/docs/native-investigation.md b/docs/native-investigation.md index bd80e84b2..9fc7e6043 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -150,7 +150,7 @@ actions may change application data or trigger network activity. ## Provider and verification boundaries -Install Ghidra 12.1.4 and a full 64-bit JDK 21 separately, then configure REA to +Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to use them. Ghidra analysis supports Linux x64 and macOS x64/arm64; macOS requires the matching native decompiler. Experimental Windows x64 P0 admits native x86-64 PE applications on local NTFS using bundled Job Object ownership, diff --git a/docs/provider-evaluation.md b/docs/provider-evaluation.md index 1d03738ba..69a621419 100644 --- a/docs/provider-evaluation.md +++ b/docs/provider-evaluation.md @@ -3,8 +3,10 @@ This guide describes repository main. Check the [released package boundary](installation.md#released-package-and-main) when using npm. Ghidra read-only analysis is available on Linux x64 and macOS x64/arm64. -Install Ghidra 12.1.4 and a full 64-bit JDK 21 separately, then configure REA to -use them. macOS installations need the matching native decompiler. +Install a Ghidra 12.1.x release and the 64-bit full JDK that release declares, +then configure REA to use them. Current 12.1 releases require JDK 21 or newer +and set no maximum. The bridge is verified with Ghidra 12.1.4 and JDK 21. +macOS installations need the matching native decompiler. REA imports one target into a temporary project and exposes 25 read-only operations after analysis completes. They cover inventories, search, @@ -53,9 +55,11 @@ follow. ## Shipped foundation boundary -`GHIDRA_INSTALL_DIR` must identify an extracted official 12.1.4 release; -optional `JAVA_HOME` must identify a 64-bit full JDK 21, otherwise doctor probes -`java`/`javac` or `java.exe`/`javac.exe` from `PATH`. Supported Linux and macOS +`GHIDRA_INSTALL_DIR` must identify an extracted Ghidra 12.1.x release. Optional +`JAVA_HOME` must identify a 64-bit full JDK inside that installation's +`application.java.min` and `application.java.max` (JDK 21 or newer, with no +maximum, when the release leaves those at the 12.1 defaults). Otherwise doctor +probes `java`/`javac` or `java.exe`/`javac.exe` from `PATH`. Supported Linux and macOS hosts accept compatible ELF, PE, and Mach-O executable targets. Host admission and target compatibility are separate checks. Windows x64 P0 admits only native, non-managed, non-DLL x86-64 PE applications on fixed local NTFS, with the packaged diff --git a/docs/roadmap.md b/docs/roadmap.md index 77ca34678..a5f86686c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -45,8 +45,10 @@ verified paths for an existing Ghidra installation. It configures Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity, GitHub Copilot CLI, Command Code, and VS Code using each client's configuration format. -Ghidra analysis supports Linux x64 and macOS x64/arm64 with Ghidra 12.1.4 and a -64-bit full JDK 21. macOS also requires the matching native decompiler. The +Ghidra analysis supports Linux x64 and macOS x64/arm64 with Ghidra 12.1.x and the +64-bit full JDK that installation declares. Current 12.1 releases require JDK 21 +or newer and set no maximum; the bridge is verified with Ghidra 12.1.4 and JDK 21. +macOS also requires the matching native decompiler. The adapter exposes thirteen inventory operations and twelve function-analysis operations, for 25 read-only operations total. Linux and macOS additionally support atomic function-name and entry-comment edits with refreshed analysis; metadata diff --git a/docs/testing.md b/docs/testing.md index e12334cec..206729722 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -118,6 +118,11 @@ commands. Check prerequisites before starting expensive work and name the missing command, target, and lane in any failure message. A lane must not imply that a host or target is covered when it was skipped. +Provider admission accepts Ghidra 12.1.x and the JDK range declared by that +installation (`application.java.min` through `application.java.max`). Current +12.1 releases require JDK 21 or newer and set no maximum. The lanes below still +prove behavior on the verified Ghidra 12.1.4 and JDK 21 build. + | Ghidra lane | Supported runner/target | Additional local tools | | ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | `npm run verify:ghidra` | Linux x64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 | diff --git a/docs/windows-ghidra-p0.md b/docs/windows-ghidra-p0.md index 72e5286b6..676107072 100644 --- a/docs/windows-ghidra-p0.md +++ b/docs/windows-ghidra-p0.md @@ -13,8 +13,8 @@ additional permission flag or degraded mode is required. - Windows 10+ x64 with fixed local NTFS target and temporary volumes; - Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+; -- an operator-installed official Ghidra 12.1.4 distribution; -- a 64-bit full JDK 21; +- an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4); +- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases); - an explicit native, non-managed, non-DLL x86-64 PE application; and - the 25 read-only Ghidra inventory, memory, and function-analysis operations. diff --git a/scripts/build-ghidra-nativeaot.mjs b/scripts/build-ghidra-nativeaot.mjs index 61920228c..f787c8c72 100644 --- a/scripts/build-ghidra-nativeaot.mjs +++ b/scripts/build-ghidra-nativeaot.mjs @@ -20,14 +20,17 @@ const source = resolve( const install = process.env.GHIDRA_INSTALL_DIR; if (!install) throw new Error( - "NativeAOT build lane requires existing GHIDRA_INSTALL_DIR (Ghidra 12.1.4).", + "NativeAOT build lane requires existing GHIDRA_INSTALL_DIR (Ghidra 12.1.x).", ); const properties = await readFile( join(install, "Ghidra/application.properties"), "utf8", ); -if (!/^application.version=12\.1\.4\s*$/m.test(properties)) - throw new Error("NativeAOT build lane requires Ghidra 12.1.4."); +const ghidraVersion = propertyValue(properties, "application.version"); +if (!ghidraReleaseLineAccepted(ghidraVersion)) + throw new Error( + `NativeAOT build lane accepts Ghidra 12.1.x (verified with 12.1.4). Observed application.version=${ghidraVersion ?? "missing"}.`, + ); const output = resolve( process.env.REA_NATIVEAOT_BUILD_ROOT ?? join(root, "_reference/nativeaot-integration/extension"), @@ -47,9 +50,11 @@ const run = (command, args) => { return result.stdout.trim(); }; const javac = java ? join(java, "javac") : "javac"; -if (!/^javac 21(?:\.|$)/u.test(run(javac, ["-version"]))) +const javacBanner = capture(javac, ["-version"]); +const javacMajor = Number(/^javac (\d+)/mu.exec(javacBanner)?.[1]); +if (!Number.isInteger(javacMajor) || javacMajor < 21) throw new Error( - "NativeAOT build lane requires an existing JDK 21 javac; REA does not install or upgrade Java.", + `NativeAOT build lane requires javac 21 or newer and compiles with --release 21. Observed: ${javacBanner || "no version"}. REA does not install or upgrade Java.`, ); const revision = run("git", ["-C", source, "rev-parse", "HEAD"]); if ( @@ -93,6 +98,8 @@ try { run(javac, [ "-J-Xmx512m", "-J-XX:ActiveProcessorCount=1", + "--release", + "21", `@${join(output, "javac.args")}`, ]); await writeFile( @@ -119,7 +126,7 @@ try { jar, sha256, upstream_revision: revision, - ghidra_version: "12.1.4", + ghidra_version: ghidraVersion, integration_api: 1, }, null, @@ -131,10 +138,41 @@ try { jar, sha256, upstream_revision: revision, - ghidra_version: "12.1.4", + ghidra_version: ghidraVersion, integration_api: 1, }), ); } finally { await rm(classes, { recursive: true, force: true }); } + +/** Read one application.properties value. Empty and missing values stay unknown. */ +function propertyValue(content, name) { + for (const line of content.split(/\r?\n/u)) { + const separator = line.indexOf("="); + if (separator < 0 || line.slice(0, separator).trim() !== name) continue; + const value = line.slice(separator + 1).trim(); + return value.length === 0 ? null : value; + } + return null; +} + +/** Same 12.1 line as GhidraInstallationPolicy, including a distro suffix. */ +function ghidraReleaseLineAccepted(raw) { + const match = /^(\d+)\.(\d+)(?:\.(\d+))?([-+].*)?$/u.exec(raw ?? ""); + return match !== null && Number(match[1]) === 12 && Number(match[2]) === 1; +} + +function capture(command, args) { + const result = spawnSync(command, args, { + cwd: root, + encoding: "utf8", + timeout: 120_000, + maxBuffer: 4 * 1024 * 1024, + }); + if (result.error || result.status !== 0) + throw new Error( + `NativeAOT build prerequisite/action ${command} failed: ${result.error?.message ?? ""}\n${result.stdout ?? ""}${result.stderr ?? ""}`, + ); + return `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); +} diff --git a/src/android/JadxSession.ts b/src/android/JadxSession.ts index e9cb20b59..d806af782 100644 --- a/src/android/JadxSession.ts +++ b/src/android/JadxSession.ts @@ -79,7 +79,7 @@ export class JadxSession { ); const engine = { name: "jadx-headless-mcp", - version: "0.7.1", + version: JADX_RELEASE.version, artifact_sha256: jarHash, source_revision: jarHash === JADX_RELEASE.sha256 ? JADX_RELEASE.revision : null, diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index f9a5681be..9f5e869c0 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -60,7 +60,7 @@ describe("Ghidra installation inspection", () => { }); }); - it("accepts only the exact Linux x64 Ghidra and JDK commitment", () => { + it("accepts the verified Linux x64 Ghidra release and JDK", () => { expect( inspectGhidraInstallation( { installDir: INSTALL, platform: "linux", architecture: "x64" }, @@ -74,6 +74,40 @@ describe("Ghidra installation inspection", () => { }); }); + it.each([ + ["12.1.2", "27", 27], + ["12.1.2-2.1", "27", 27], + ["12.1.0", "21.0.2", 21], + [SUPPORTED_GHIDRA_VERSION, "25.0.1", 25], + ])( + "accepts Ghidra %s with JDK %s on the verified release line", + (providerVersion, javaVersion, major) => { + const result = inspectGhidraInstallation( + { installDir: INSTALL, platform: "linux", architecture: "x64" }, + host({ + readText: () => + `application.version=${providerVersion}\napplication.java.min=21\napplication.java.max=\n`, + probeJava: () => ({ + ...JAVA, + version: javaVersion, + major, + }), + }), + ); + expect(result.status).toBe("available"); + expect(result).toMatchObject({ providerVersion, javaVersion }); + expect( + result.checks.find(({ name }) => name === "version"), + ).toMatchObject({ + status: "passed", + detail: + providerVersion === SUPPORTED_GHIDRA_VERSION + ? SUPPORTED_GHIDRA_VERSION + : expect.stringContaining("verified build"), + }); + }, + ); + it.each(["x64", "arm64"] as const)( "accepts macOS %s with matching native tools", (architecture) => { @@ -247,3 +281,84 @@ describe("Ghidra installation rejection diagnostics", () => { }); }); }); + +describe("Ghidra release line and JDK bounds", () => { + it.each([ + { + name: "JDK above the installation maximum", + readText: () => + "application.version=12.1.4\napplication.java.min=21\napplication.java.max=25\n", + probeJava: () => ({ ...JAVA, major: 27, version: "27" }), + failed: "java", + code: "unsupported_version", + }, + { + name: "unreadable Java minimum", + readText: () => + "application.version=12.1.4\napplication.java.min=latest\n", + probeJava: () => JAVA, + failed: "java", + code: "version_unresolved", + }, + { + name: "unparseable Ghidra version", + readText: () => "application.version=latest\n", + probeJava: () => JAVA, + failed: "version", + code: "version_unresolved", + }, + { + name: "different Ghidra line", + readText: () => "application.version=12.2.0\n", + probeJava: () => JAVA, + failed: "version", + code: "unsupported_version", + }, + ])("distinguishes $name", ({ readText, probeJava, failed, code }) => { + const result = inspectGhidraInstallation( + { installDir: INSTALL, platform: "linux", architecture: "x64" }, + host({ readText, probeJava }), + ); + expect(result.status).toBe("unavailable"); + expect( + result.checks.find(({ status }) => status === "failed"), + ).toMatchObject({ name: failed, code }); + }); + + it("names the accepted Ghidra line and JDK range in failure remediation", () => { + const result = inspectGhidraInstallation( + { + installDir: INSTALL, + javaHome: "/usr/lib/jvm/java-17-openjdk", + platform: "linux", + architecture: "x64", + }, + host({ + readText: () => + "application.version=12.0.4\napplication.java.min=21\napplication.java.max=\n", + probeJava: () => ({ ...JAVA, major: 17, version: "17.0.20.1" }), + }), + ); + expect(result.status).toBe("unavailable"); + if (result.status !== "unavailable") return; + expect(result.rejection.reason).toContain("12.1.x"); + expect(result.rejection.reason).toContain(SUPPORTED_GHIDRA_VERSION); + expect(result.checks.find(({ name }) => name === "java")).toMatchObject({ + status: "failed", + remediation: expect.stringContaining("JDK 21 or newer"), + }); + }); + + it("honors an installation that declares a lower Java minimum", () => { + expect( + inspectGhidraInstallation( + { installDir: INSTALL, platform: "linux", architecture: "x64" }, + host({ + readText: () => + `application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`, + probeJava: () => ({ ...JAVA, major: 17, version: "17.0.20.1" }), + }), + ), + ).toMatchObject({ status: "available", javaVersion: "17.0.20.1" }); + }); +}); diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 59e1fef5c..9a2eb858d 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -5,11 +5,22 @@ import { posix, win32 } from "node:path"; import type { ProviderRejectionCode } from "../contracts/providerSelection.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { + ghidraApplicationProperties, + ghidraJavaMajorAccepted, + ghidraJavaRangeLabel, + ghidraJavaRequirement, + ghidraReleaseAccepted, + ghidraReleaseCompatibility, + ghidraReleaseLine, + ghidraVersionDetail, + SUPPORTED_GHIDRA_JAVA_MAJOR, + SUPPORTED_GHIDRA_VERSION, + type GhidraApplicationProperties, + type GhidraJavaRequirement, +} from "./GhidraInstallationPolicy.js"; -/** Exact Ghidra build whose Java bridge contract this REA release supports. */ -export const SUPPORTED_GHIDRA_VERSION = "12.1.4"; -/** Exact JDK major documented by the supported Ghidra release. */ -export const SUPPORTED_GHIDRA_JAVA_MAJOR = 21; +export { SUPPORTED_GHIDRA_JAVA_MAJOR, SUPPORTED_GHIDRA_VERSION }; /** Caller-owned paths and host coordinates used for one installation probe. */ export interface GhidraInstallationOptions { @@ -110,7 +121,7 @@ interface GhidraInstallationCoordinates { readonly applicationPropertiesPath: string | null; readonly analyzeHeadlessPath: string | null; readonly nativeDecompilerPath: string | null; - readonly properties: Readonly<{ providerVersion: string | null }> | undefined; + readonly properties: GhidraApplicationProperties | undefined; readonly javaCommand: string; readonly java: GhidraJavaObservation | undefined; } @@ -269,7 +280,7 @@ const installationCoordinates = ( properties: properties === undefined ? undefined - : { providerVersion: propertyValue(properties, "application.version") }, + : ghidraApplicationProperties(properties), javaCommand, java: host.probeJava( javaCommand, @@ -290,8 +301,7 @@ const installationChecks = ({ passed: coordinates.installDir !== null, code: "not_configured", detail: coordinates.installDir ?? "GHIDRA_INSTALL_DIR is not set", - remediation: - "Set GHIDRA_INSTALL_DIR to an extracted Ghidra 12.1.4 release directory.", + remediation: `Set GHIDRA_INSTALL_DIR to an extracted Ghidra ${ghidraReleaseLine()} release directory.`, }), installationCheck({ name: "platform", @@ -323,18 +333,7 @@ const installationChecks = ({ remediation: "Point GHIDRA_INSTALL_DIR at the root of an extracted official Ghidra release.", }), - installationCheck({ - name: "version", - passed: - coordinates.properties?.providerVersion === SUPPORTED_GHIDRA_VERSION, - code: - coordinates.properties?.providerVersion === null || - coordinates.properties?.providerVersion === undefined - ? "version_unresolved" - : "unsupported_version", - detail: coordinates.properties?.providerVersion ?? "unknown", - remediation: `Install Ghidra ${SUPPORTED_GHIDRA_VERSION}, or update REA for a different Ghidra build.`, - }), + versionCheck(coordinates.properties), installationCheck({ name: "headless", passed: @@ -357,7 +356,12 @@ const installationChecks = ({ remediation: "REA does not build native tools. Build Ghidra's native components for this macOS architecture or provide an installation containing Ghidra/Features/Decompiler/os//decompile.", }), - javaCheck(coordinates.java, coordinates.javaCommand, javaHome), + javaCheck( + coordinates.java, + coordinates.javaCommand, + javaHome, + coordinates.properties, + ), ]; /** Project an installation probe into caller-visible, secret-free diagnostics. */ @@ -428,40 +432,65 @@ const installationCheck = (options: { remediation: options.remediation, }; +const versionCheck = ( + properties: GhidraApplicationProperties | undefined, +): GhidraInstallationCheck => { + const raw = properties?.providerVersion; + return installationCheck({ + name: "version", + passed: ghidraReleaseAccepted(raw), + code: + ghidraReleaseCompatibility(raw).status === "unsupported" + ? "unsupported_version" + : "version_unresolved", + detail: ghidraVersionDetail(raw), + remediation: `Install a Ghidra ${ghidraReleaseLine()} release (verified with ${SUPPORTED_GHIDRA_VERSION}), or update REA for another Ghidra line.`, + }); +}; + const javaCheck = ( observation: GhidraJavaObservation | undefined, command: string, configuredHome: string | undefined, + properties: GhidraApplicationProperties | undefined, ): GhidraInstallationCheck => { + const requirement = ghidraJavaRequirement(properties); + const range = ghidraJavaRangeLabel(requirement); const supported = observation !== undefined && - observation.major === SUPPORTED_GHIDRA_JAVA_MAJOR && + ghidraJavaMajorAccepted(observation.major, requirement) && observation.bits === 64 && observation.runtime === "jdk"; const detail = observation === undefined - ? command - : `${observation.version}; ${String(observation.bits)}-bit; ${observation.runtime === "jdk" ? "JDK" : "runtime only"}; ${observation.home}`; + ? requirement.unresolved === null + ? command + : `${command}; ${requirement.unresolved}` + : `${observation.version}; ${String(observation.bits)}-bit; ${observation.runtime === "jdk" ? "JDK" : "runtime only"}; ${observation.home}; accepted ${range}`; return installationCheck({ name: "java", passed: supported, - code: observation === undefined ? "runtime_missing" : "unsupported_version", + code: + requirement.unresolved !== null + ? "version_unresolved" + : observation === undefined + ? "runtime_missing" + : "unsupported_version", detail, - remediation: - configuredHome === undefined - ? "Install a 64-bit JDK 21 or set JAVA_HOME before starting REA." - : "Update JAVA_HOME to the root of a 64-bit JDK 21 installation.", + remediation: javaRemediation(requirement, configuredHome, range), }); }; -const propertyValue = (content: string, name: string): string | null => { - for (const line of content.split(/\r?\n/u)) { - const separator = line.indexOf("="); - if (separator < 0 || line.slice(0, separator).trim() !== name) continue; - const value = line.slice(separator + 1).trim(); - return value.length === 0 ? null : value; - } - return null; +const javaRemediation = ( + requirement: GhidraJavaRequirement, + configuredHome: string | undefined, + range: string, +): string => { + if (requirement.unresolved !== null) + return `The Ghidra installation's Java bounds could not be read (${requirement.unresolved}). Install a 64-bit full JDK inside the declared application.java.min and application.java.max.`; + return configuredHome === undefined + ? `Install ${range}, or set JAVA_HOME before starting REA.` + : `Update JAVA_HOME to the root of ${range} installation.`; }; const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ diff --git a/src/ghidra/GhidraInstallationPolicy.ts b/src/ghidra/GhidraInstallationPolicy.ts new file mode 100644 index 000000000..bd19dc1d1 --- /dev/null +++ b/src/ghidra/GhidraInstallationPolicy.ts @@ -0,0 +1,220 @@ +/** Ghidra build whose bridge behavior this REA release verifies. */ +export const SUPPORTED_GHIDRA_VERSION = "12.1.4"; +/** + * JDK major used when an installation omits `application.java.min`. + * Admission uses that installation's `application.java.min` and + * `application.java.max`. An empty maximum means the release sets no upper bound. + */ +export const SUPPORTED_GHIDRA_JAVA_MAJOR = 21; + +/** Numeric Ghidra release read from `application.version`. */ +interface GhidraReleaseVersion { + readonly major: number; + readonly minor: number; + readonly patch: number; +} + +/** How an observed Ghidra release relates to this adapter's bridge. */ +type GhidraReleaseCompatibility = + | { + readonly status: "verified"; + readonly version: GhidraReleaseVersion; + readonly raw: string; + } + | { + readonly status: "compatible"; + readonly version: GhidraReleaseVersion; + readonly raw: string; + } + | { + readonly status: "unsupported"; + readonly version: GhidraReleaseVersion; + readonly raw: string; + } + | { readonly status: "unresolved"; readonly raw: string | null }; + +/** One Java major bound declared by `application.properties`. */ +type GhidraJavaBound = + | { readonly status: "absent" } + | { readonly status: "major"; readonly major: number } + | { readonly status: "unresolved"; readonly raw: string }; + +/** JDK majors this installation admits, after reading its own properties. */ +export interface GhidraJavaRequirement { + readonly minimumMajor: number; + readonly maximumMajor: number | null; + readonly unresolved: string | null; +} + +/** Version and Java bounds read from one `application.properties` file. */ +export interface GhidraApplicationProperties { + readonly providerVersion: string | null; + readonly javaMinimum: GhidraJavaBound; + readonly javaMaximum: GhidraJavaBound; +} + +const GHIDRA_RELEASE_PATTERN = /^(\d+)\.(\d+)(?:\.(\d+))?([-+].*)?$/u; +const JAVA_MAJOR_PATTERN = /^(\d+)(?:\.\d+)*$/u; + +/** Read the version and Java bounds Ghidra publishes for one installation. */ +export const ghidraApplicationProperties = ( + content: string, +): GhidraApplicationProperties => ({ + providerVersion: propertyValue(content, "application.version"), + javaMinimum: parseJavaBound(propertyValue(content, "application.java.min")), + javaMaximum: parseJavaBound(propertyValue(content, "application.java.max")), +}); + +/** Classify `application.version` against the verified release line. */ +export const ghidraReleaseCompatibility = ( + raw: string | null | undefined, +): GhidraReleaseCompatibility => { + if (raw === null || raw === undefined) + return { status: "unresolved", raw: null }; + const version = parseGhidraRelease(raw); + if (version === undefined) return { status: "unresolved", raw }; + if (raw === SUPPORTED_GHIDRA_VERSION) + return { status: "verified", version, raw }; + const verified = verifiedGhidraRelease(); + return version.major === verified.major && version.minor === verified.minor + ? { status: "compatible", version, raw } + : { status: "unsupported", version, raw }; +}; + +/** + * Limitation recorded when a session runs a compatible build other than the + * verified release. The observed version stays on the provider identity. + */ +export const unverifiedGhidraBuildLimitation = ( + providerVersion: string, +): string | undefined => { + const compatibility = ghidraReleaseCompatibility(providerVersion); + if (compatibility.status !== "compatible") return undefined; + return `This session uses Ghidra ${providerVersion}. The bridge is verified against ${SUPPORTED_GHIDRA_VERSION}; other ${ghidraReleaseLine()} builds are accepted and the observed version is reported.`; +}; + +/** Release line accepted by this bridge, such as `12.1.x`. */ +export const ghidraReleaseLine = (): string => { + const verified = verifiedGhidraRelease(); + return `${String(verified.major)}.${String(verified.minor)}.x`; +}; + +/** Detail for the installation version check, including the accepted line. */ +export const ghidraVersionDetail = (raw: string | null | undefined): string => { + const compatibility = ghidraReleaseCompatibility(raw); + switch (compatibility.status) { + case "unresolved": + return compatibility.raw ?? "unknown"; + case "verified": + return compatibility.raw; + case "compatible": + return `${compatibility.raw}; compatible ${ghidraReleaseLine()} release (verified build ${SUPPORTED_GHIDRA_VERSION})`; + case "unsupported": + return `${compatibility.raw}; accepted releases are ${ghidraReleaseLine()} (verified build ${SUPPORTED_GHIDRA_VERSION})`; + default: { + const unreachable: never = compatibility; + return unreachable; + } + } +}; + +/** Whether this release line can run the packaged bridge. */ +export const ghidraReleaseAccepted = ( + raw: string | null | undefined, +): boolean => { + const status = ghidraReleaseCompatibility(raw).status; + return status === "verified" || status === "compatible"; +}; + +/** Resolve JDK majors from `application.java.min` and `application.java.max`. */ +export const ghidraJavaRequirement = ( + properties: GhidraApplicationProperties | undefined, +): GhidraJavaRequirement => { + const minimum = properties?.javaMinimum ?? { status: "absent" }; + const maximum = properties?.javaMaximum ?? { status: "absent" }; + if (minimum.status === "unresolved") + return unresolvedJavaRequirement(`application.java.min=${minimum.raw}`); + if (maximum.status === "unresolved") + return unresolvedJavaRequirement(`application.java.max=${maximum.raw}`); + const minimumMajor = + minimum.status === "major" ? minimum.major : SUPPORTED_GHIDRA_JAVA_MAJOR; + const maximumMajor = maximum.status === "major" ? maximum.major : null; + if (maximumMajor !== null && maximumMajor < minimumMajor) + return unresolvedJavaRequirement( + `application.java.max=${String(maximumMajor)} is below application.java.min=${String(minimumMajor)}`, + ); + return { minimumMajor, maximumMajor, unresolved: null }; +}; + +/** Caller-facing description of one resolved or unreadable JDK range. */ +export const ghidraJavaRangeLabel = ( + requirement: GhidraJavaRequirement, +): string => { + if (requirement.unresolved !== null) + return "a 64-bit full JDK matching the installation's declared Java bounds"; + if (requirement.maximumMajor === null) + return `a 64-bit full JDK ${String(requirement.minimumMajor)} or newer`; + if (requirement.maximumMajor === requirement.minimumMajor) + return `a 64-bit full JDK ${String(requirement.minimumMajor)}`; + return `a 64-bit full JDK ${String(requirement.minimumMajor)} through ${String(requirement.maximumMajor)}`; +}; + +/** Whether an observed JDK satisfies the installation's declared range. */ +export const ghidraJavaMajorAccepted = ( + major: number, + requirement: GhidraJavaRequirement, +): boolean => + requirement.unresolved === null && + major >= requirement.minimumMajor && + (requirement.maximumMajor === null || major <= requirement.maximumMajor); + +const verifiedGhidraRelease = (): GhidraReleaseVersion => { + const version = parseGhidraRelease(SUPPORTED_GHIDRA_VERSION); + if (version === undefined) + throw new TypeError( + `SUPPORTED_GHIDRA_VERSION ${SUPPORTED_GHIDRA_VERSION} is not a Ghidra release`, + ); + return version; +}; + +const parseGhidraRelease = (raw: string): GhidraReleaseVersion | undefined => { + const match = GHIDRA_RELEASE_PATTERN.exec(raw); + const major = integerGroup(match?.[1]); + const minor = integerGroup(match?.[2]); + if (match === null || major === undefined || minor === undefined) + return undefined; + const patch = match[3] === undefined ? 0 : integerGroup(match[3]); + return patch === undefined ? undefined : { major, minor, patch }; +}; + +const parseJavaBound = (raw: string | null): GhidraJavaBound => { + if (raw === null) return { status: "absent" }; + const major = integerGroup(JAVA_MAJOR_PATTERN.exec(raw)?.[1]); + return major === undefined + ? { status: "unresolved", raw } + : { status: "major", major }; +}; + +const integerGroup = (value: string | undefined): number | undefined => { + if (value === undefined) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; +}; + +const unresolvedJavaRequirement = ( + unresolved: string, +): GhidraJavaRequirement => ({ + minimumMajor: SUPPORTED_GHIDRA_JAVA_MAJOR, + maximumMajor: null, + unresolved, +}); + +const propertyValue = (content: string, name: string): string | null => { + for (const line of content.split(/\r?\n/u)) { + const separator = line.indexOf("="); + if (separator < 0 || line.slice(0, separator).trim() !== name) continue; + const value = line.slice(separator + 1).trim(); + return value.length === 0 ? null : value; + } + return null; +}; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index d4cf34d11..3c05e93fe 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -791,3 +791,70 @@ describe("Ghidra extension failures", () => { }, ); }); + +describe("compatible Ghidra builds", () => { + it("accepts the build and reports that it is unverified", async () => { + const ghidra = provider( + { + ...installationHost(), + readText: () => + "application.version=12.1.2\napplication.java.min=21\napplication.java.max=\n", + probeJava: () => ({ + version: "27", + major: 27, + home: "/usr/lib/jvm/java-27-openjdk", + bits: 64, + runtime: "jdk", + }), + }, + () => ({ + start: () => + Promise.resolve( + ok({ + ...sessionInfo(), + provider: { id: "ghidra", version: "12.1.2" }, + }), + ), + callTool: () => + Promise.resolve( + ok([ + { + address: "0x1000", + value: "fixture_main", + procedure: { + external: false, + thunk: false, + thunk_target: null, + }, + }, + ]), + ), + close: () => Promise.resolve(), + }), + ); + const resolved = await ghidra.resolveAnalysisProfile( + executableTarget("elf", "x86_64"), + ); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected a compatible Ghidra profile"); + const client = ghidra.createClient( + executableTarget("elf", "x86_64"), + resolved.value.profile, + ); + const health = await client.execute("health", {}); + const procedures = await client.execute("list_procedures", {}); + expect(ghidra.inspectAvailability()).toMatchObject({ + status: "available", + diagnostics: { provider_version: "12.1.2", java_version: "27" }, + }); + expect(health.ok && health.value.provider.version).toBe("12.1.2"); + const unverified = expect.stringContaining("12.1.2"); + expect(health.ok && health.value.limitations).toEqual( + expect.arrayContaining([unverified]), + ); + expect(procedures.ok && procedures.value.limitations).toEqual( + expect.arrayContaining([unverified]), + ); + expect(procedures.ok && procedures.value.provider.version).toBe("12.1.2"); + }); +}); diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 76e5cefcc..cfb1ddc64 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -59,6 +59,7 @@ import { type GhidraInstallationHost, type GhidraInstallationInspection, } from "./GhidraInstallation.js"; +import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js"; import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; import { resolveGhidraAnalysisProfile } from "./GhidraAnalysisProfile.js"; import type { GhidraSessionError } from "./GhidraSessionError.js"; @@ -332,6 +333,15 @@ export class GhidraProvider implements AnalysisProviderCandidate { await client.close(); return extensionFailure; }; + const releaseLimitation = unverifiedGhidraBuildLimitation( + prerequisites.value.providerVersion, + ); + const sessionLimitations = [ + ...providerLimitations, + ...targetLimitations, + ...ghidraExtensionLimitations(extensions), + ...(releaseLimitation === undefined ? [] : [releaseLimitation]), + ]; return { execute: async (operation, parameters, options) => { if (extensionFailure !== undefined) return err(extensionFailure); @@ -356,12 +366,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { return ok( createAnalysisExecution(started.value, committedProfile.provider, { analysisProfile: committedProfile, - limitations: [ - ...healthLimitations, - ...providerLimitations, - ...targetLimitations, - ...ghidraExtensionLimitations(extensions), - ], + limitations: [...healthLimitations, ...sessionLimitations], }), ); } @@ -435,12 +440,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { createAnalysisExecution(normalized, committedProfile.provider, { rawResult: called.value, analysisProfile: committedProfile, - limitations: [ - ...limitationsFor(operation), - ...providerLimitations, - ...targetLimitations, - ...ghidraExtensionLimitations(extensions), - ], + limitations: [...limitationsFor(operation), ...sessionLimitations], }), ); }, diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index ee9c648cb..b99acb644 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -14,9 +14,10 @@ binaries and Ghidra projects are never committed. `scripts/verify-real-ghidra.mjs` uses `cc`, `clang`, and `lld-link` by default; `REA_CC`, `REA_CLANG`, and `REA_LLD_LINK` can select alternate commands. The -verifier requires an exact bring-your-own Ghidra 12.1.4 installation through +verifier requires the verified bring-your-own Ghidra 12.1.4 installation through `GHIDRA_INSTALL_DIR` and validates header classification before starting the -provider. +provider. Provider admission accepts the rest of the 12.1.x line; this lane does +not. Conformance compares semantic facts: provider/profile identity, function classification, resolved call edges, typed references, strings/xrefs, and CFG