feat: implement Ghidra adapter and Nix integration

This commit is contained in:
hermes 2026-07-28 19:51:32 +00:00
commit c0e540ee76
14 changed files with 1482 additions and 14 deletions

14
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,14 @@
name: checks
on:
push:
pull_request:
jobs:
flake:
runs-on: x86_64-linux
steps:
- uses: actions/checkout@v4
- run: nix flake check --print-build-logs
- run: nix build .#packages.x86_64-linux.default --print-build-logs
- run: nix run . -- doctor

8
.gitignore vendored
View file

@ -1,7 +1,5 @@
/target/ /result
*.log /result-*
/target
*.gpr *.gpr
*.rep/ *.rep/
.direnv/
.envrc

View file

@ -3,10 +3,10 @@
A small, dependable command-line interface for read-only Ghidra analysis. A small, dependable command-line interface for read-only Ghidra analysis.
The repository is named `ghidra-cli`; the installed executable is `ghidr`. The repository is named `ghidra-cli`; the installed executable is `ghidr`.
The project now has its version 0.1 Rust contract foundation. The typed CLI, The project has a version 0.1 Rust contract foundation plus a strict Java
public envelopes and schemas, bounded Rust/Java protocol, and synchronous headless adapter, reproducible native fixtures, and pinned x86-64 Linux Nix
execution seam are present; store, process/sandbox, and Ghidra adapter execution packaging. Store and process/sandbox integration is developed independently
remain deliberately unconnected. behind the existing synchronous executor boundary.
Proposed usage: Proposed usage:
@ -32,12 +32,63 @@ available explicitly through `--format human`.
including the Ghidra MCP comparison that informed protocol safeguards and including the Ghidra MCP comparison that informed protocol safeguards and
the post-version-0.1 roadmap. the post-version-0.1 roadmap.
## Status ## Pinned package
Foundation only. Every accepted command currently returns a typed The flake fixes Ghidra at exactly 12.1.2 and uses JDK 21. It also closes over
`internal_not_implemented` runtime error instead of pretending Ghidra analysis Rust, Bubblewrap, the Java adapter, deterministic Java/Nix formatters,
succeeded. Subsequent layers implement the library's synchronous executor `cargo-deny`, and `statix`:
contract while preserving the committed schemas and reviewed fixtures.
```console
nix run . -- doctor
nix develop
```
The native fixtures are reproducible Nix outputs rather than committed opaque
binaries:
```console
nix build .#fixtures
file result/elf-x86_64/known.elf result/pe32plus-x86_64/known.exe
```
## Adapter boundary
The Ghidra worker dispatches `doctor`, `inspect`, `functions`, and `decompile`.
`inspect` is the import/analyze vertical slice: the trusted harness stages the
Sample and invokes official `analyzeHeadless`; the post-script reads only the
current imported Program and private request/response paths. `clean` is in the
five-operation Rust registry but is intentionally rejected by Java because it
is a trusted, atomic Analysis Store transaction. This boundary is enforced by
`tests/adapter_contract.rs` and documented in `java/dispatch.txt`.
The adapter accepts at most 1 MiB of request JSON before parsing, rejects
missing/unknown fields, echoes protocol version/invocation/operation, and
publishes at most 256 MiB through a temporary file plus same-filesystem atomic
rename. It has no socket, network client, listener, script execution, mutation,
or user-selected destination surface.
## Checks
The complete pinned suite is:
```console
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets
cargo deny check
nix flake check --print-build-logs
```
`nix flake check` compiles Java with JDK 21 `-Xlint:all -Werror`, checks
Google-style `clang-format`, `nixfmt`, and `statix`, builds both fixtures, runs the
adapter against real Ghidra 12.1.2, and exercises `inspect`, `functions`, and
`decompile` through the packaged `ghidr` path. Individual real-engine checks
remain directly runnable as:
```console
nix build .#checks.x86_64-linux.java-adapter-e2e --print-build-logs
nix build .#checks.x86_64-linux.real-ghidra-e2e --print-build-logs
```
## License ## License

26
deny.toml Normal file
View file

@ -0,0 +1,26 @@
[advisories]
version = 2
yanked = "deny"
[bans]
multiple-versions = "warn"
wildcards = "deny"
highlight = "all"
[licenses]
version = 2
confidence-threshold = 0.93
allow = [
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"MIT",
"Unicode-3.0",
"Zlib",
]
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]

14
fixtures/README.md Normal file
View file

@ -0,0 +1,14 @@
# Reproducible native fixtures
`fixtures/src/known.c` is compiled by Nix into two tiny, dependency-free test
programs from the same reviewed source:
- `elf-x86_64/known.elf`: static ELF, x86-64 little-endian;
- `pe32plus-x86_64/known.exe`: PE32+, x86-64.
Both builds disable timestamps and build IDs, set `SOURCE_DATE_EPOCH=1`, avoid
host libraries, and are stripped only of non-semantic comments/notes. Build
them with `nix build .#fixtures`. Version 0.1 intentionally does not claim an
unverified third target until that target is exercised by the real-Ghidra
suite; recognizing a file format without a maintained cross-toolchain test is
not sufficient evidence.

18
fixtures/src/known.c Normal file
View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: CC0-1.0
volatile unsigned long fixture_marker = 7;
__attribute__((noinline)) unsigned long fixture_add(unsigned long value) {
return value + fixture_marker;
}
void _start(void) {
(void)fixture_add(35);
for (;;) {
__asm__ volatile("" ::: "memory");
}
}
void mainCRTStartup(void) {
_start();
}

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1752480373,
"narHash": "sha256-JHQbm+OcGp32wAsXTE/FLYGNpb+4GLi5oTvCxwSoBOA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "62e0f05ede1da0d54515d4ea8ce9c733f12d9f08",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "62e0f05ede1da0d54515d4ea8ce9c733f12d9f08",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

271
flake.nix Normal file
View file

@ -0,0 +1,271 @@
{
description = "ghidr 0.1: pinned read-only Ghidra analysis CLI";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/62e0f05ede1da0d54515d4ea8ce9c733f12d9f08";
outputs =
{ self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
inherit (pkgs) lib;
jdk = pkgs.jdk21_headless;
ghidra = pkgs.stdenvNoCC.mkDerivation {
pname = "ghidra";
version = "12.1.2";
src = pkgs.fetchurl {
url = "https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.1.2_build/ghidra_12.1.2_PUBLIC_20260605.zip";
hash = "sha256-ti6BoDkGGEZsAZxg2ML3ls7SUJxMGupKN2RKdycs+Z0=";
};
nativeBuildInputs = [
pkgs.makeWrapper
pkgs.unzip
];
dontConfigure = true;
dontBuild = true;
unpackPhase = ''
runHook preUnpack
unzip -q "$src"
sourceRoot=ghidra_12.1.2_PUBLIC
runHook postUnpack
'';
installPhase = ''
runHook preInstall
mkdir -p "$out/lib/ghidra" "$out/bin"
cp -a . "$out/lib/ghidra/"
makeWrapper "$out/lib/ghidra/support/analyzeHeadless" "$out/bin/ghidra-analyzeHeadless" \
--set JAVA_HOME "${jdk}" \
--prefix PATH : "${lib.makeBinPath [ jdk ]}"
runHook postInstall
'';
passthru = { inherit jdk; };
meta = {
description = "Ghidra 12.1.2 headless analysis distribution";
homepage = "https://github.com/NationalSecurityAgency/ghidra";
license = lib.licenses.asl20;
platforms = [ system ];
sourceProvenance = [ lib.sourceTypes.binaryBytecode ];
};
};
adapter = pkgs.stdenvNoCC.mkDerivation {
pname = "ghidr-java-adapter";
version = "0.1.0";
src = ./java;
nativeBuildInputs = [
jdk
pkgs.clang-tools
];
dontConfigure = true;
buildPhase = ''
runHook preBuild
clang-format --dry-run --Werror --style=Google GhidrAdapter.java
classpath="$(find ${ghidra}/lib/ghidra -type f -name '*.jar' -print | LC_ALL=C sort | paste -sd: -)"
javac -encoding UTF-8 -source 21 -target 21 -Xlint:all -Werror -cp "$classpath" GhidrAdapter.java
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out/share/ghidr/java"
install -m 0444 GhidrAdapter.java dispatch.txt "$out/share/ghidr/java/"
install -m 0444 ./*.class "$out/share/ghidr/java/"
runHook postInstall
'';
};
elfFixture = pkgs.stdenv.mkDerivation {
pname = "ghidr-fixture-elf-x86-64";
version = "0.1.0";
src = ./fixtures/src/known.c;
dontUnpack = true;
SOURCE_DATE_EPOCH = "1";
buildPhase = ''
runHook preBuild
$CC -x c "$src" -Os -ffreestanding -fno-ident -fno-stack-protector \
-fno-asynchronous-unwind-tables -fno-unwind-tables -nostdlib -static \
-no-pie -Wl,--build-id=none,-e,_start -o known.elf
runHook postBuild
'';
installPhase = ''
mkdir -p "$out/elf-x86_64"
install -m 0555 known.elf "$out/elf-x86_64/known.elf"
'';
};
peFixture = pkgs.pkgsCross.mingwW64.stdenv.mkDerivation {
pname = "ghidr-fixture-pe32plus-x86-64";
version = "0.1.0";
src = ./fixtures/src/known.c;
dontUnpack = true;
SOURCE_DATE_EPOCH = "1";
buildPhase = ''
runHook preBuild
$CC -x c "$src" -Os -ffreestanding -fno-ident -fno-stack-protector \
-nostdlib -Wl,--no-insert-timestamp,--entry,mainCRTStartup,--subsystem,console \
-o known.exe
runHook postBuild
'';
installPhase = ''
mkdir -p "$out/pe32plus-x86_64"
install -m 0555 known.exe "$out/pe32plus-x86_64/known.exe"
'';
};
fixtures = pkgs.symlinkJoin {
name = "ghidr-fixtures-0.1.0";
paths = [
elfFixture
peFixture
];
};
rustPackage = pkgs.rustPlatform.buildRustPackage {
pname = "ghidra-cli";
version = "0.1.0";
src = lib.cleanSource self;
cargoLock.lockFile = ./Cargo.lock;
doCheck = true;
nativeBuildInputs = [ pkgs.installShellFiles ];
};
ghidr =
pkgs.runCommand "ghidr-0.1.0"
{
nativeBuildInputs = [ pkgs.makeWrapper ];
meta.mainProgram = "ghidr";
}
''
mkdir -p "$out/bin" "$out/share/ghidr"
ln -s ${adapter}/share/ghidr/java "$out/share/ghidr/java"
makeWrapper ${rustPackage}/bin/ghidr "$out/bin/ghidr" \
--set GHIDR_GHIDRA_VERSION 12.1.2 \
--set GHIDR_JAVA_HOME ${jdk} \
--set GHIDR_ANALYZE_HEADLESS ${ghidra}/bin/ghidra-analyzeHeadless \
--set GHIDR_ADAPTER_PATH "$out/share/ghidr/java" \
--prefix PATH : ${
lib.makeBinPath [
ghidra
jdk
pkgs.bubblewrap
]
}
'';
realGhidraE2e =
pkgs.runCommand "ghidr-real-ghidra-e2e"
{
nativeBuildInputs = [
ghidr
pkgs.jq
];
}
''
export HOME="$TMPDIR/home"
mkdir -p "$HOME" "$TMPDIR/store"
${ghidr}/bin/ghidr --sandbox off --store "$TMPDIR/store" inspect \
${fixtures}/elf-x86_64/known.elf > elf.json
jq -e '.kind == "inspection" and .provenance.ghidra_version == "12.1.2"' elf.json
${ghidr}/bin/ghidr --sandbox off --store "$TMPDIR/store" functions \
${fixtures}/pe32plus-x86_64/known.exe --all > pe.json
jq -e '.kind == "functions" and (.data.items | length) > 0' pe.json
${ghidr}/bin/ghidr --sandbox off --store "$TMPDIR/store" decompile \
${fixtures}/elf-x86_64/known.elf --name fixture_add > decompile.json
jq -e '.data.function.name == "fixture_add"' decompile.json
touch "$out"
'';
adapterE2e =
pkgs.runCommand "ghidr-java-adapter-e2e"
{
nativeBuildInputs = [
ghidra
jdk
pkgs.jq
];
}
''
export HOME="$TMPDIR/home"
export GHIDR_ANALYZE_HEADLESS=${ghidra}/bin/ghidra-analyzeHeadless
export GHIDR_ADAPTER_PATH=${adapter}/share/ghidr/java
export GHIDR_FIXTURES=${fixtures}
mkdir -p "$HOME"
cp -R ${./tests} tests
chmod +x tests/real_ghidra_adapter.sh
./tests/real_ghidra_adapter.sh
touch "$out"
'';
in
{
packages.${system} = {
default = ghidr;
inherit
ghidr
ghidra
adapter
fixtures
;
real-ghidra-e2e = realGhidraE2e;
};
apps.${system}.default = {
type = "app";
program = "${ghidr}/bin/ghidr";
};
checks.${system} = {
rust = rustPackage;
java-adapter = adapter;
java-adapter-e2e = adapterE2e;
inherit fixtures;
real-ghidra-e2e = realGhidraE2e;
cargo-deny =
pkgs.runCommand "ghidr-cargo-deny"
{
nativeBuildInputs = [ pkgs.cargo-deny ];
}
''
cp -R ${lib.cleanSource self} source
chmod -R u+w source
cd source
cargo deny check bans licenses sources
touch "$out"
'';
nix-style =
pkgs.runCommand "ghidr-nix-style"
{
nativeBuildInputs = [
pkgs.nixfmt-rfc-style
pkgs.statix
];
}
''
cp ${./flake.nix} flake.nix
nixfmt --check flake.nix
statix check flake.nix
touch "$out"
'';
};
devShells.${system}.default = pkgs.mkShell {
packages = [
pkgs.rustc
pkgs.cargo
pkgs.clippy
pkgs.rustfmt
pkgs.cargo-deny
pkgs.bubblewrap
pkgs.clang-tools
pkgs.nixfmt-rfc-style
pkgs.statix
pkgs.jq
jdk
ghidra
];
GHIDR_GHIDRA_VERSION = "12.1.2";
GHIDR_JAVA_HOME = "${jdk}";
GHIDR_ANALYZE_HEADLESS = "${ghidra}/bin/ghidra-analyzeHeadless";
GHIDR_ADAPTER_PATH = "${adapter}/share/ghidr/java";
};
};
}

905
java/GhidrAdapter.java Normal file
View file

@ -0,0 +1,905 @@
// SPDX-License-Identifier: Apache-2.0
// Ghidra headless post-script for ghidra-cli. This file intentionally has no package.
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.script.GhidraScript;
import ghidra.framework.Application;
import ghidra.program.model.address.Address;
import ghidra.program.model.address.AddressFactory;
import ghidra.program.model.address.AddressSpace;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Strict, bounded, read-only adapter used only by the ghidr harness. */
public final class GhidrAdapter extends GhidraScript {
static final long PROTOCOL_VERSION = 1;
static final int MAX_REQUEST_BYTES = 1_048_576;
static final long MAX_RESPONSE_BYTES = 268_435_456L;
static final Set<String> DISPATCH = Set.of("doctor", "inspect", "functions", "decompile");
@Override
protected void run() throws Exception {
String[] arguments = getScriptArgs();
if (arguments.length != 2) {
throw new AdapterException(
"protocol_violation", "adapter requires request and response paths");
}
Path requestPath = Path.of(arguments[0]);
Path responsePath = Path.of(arguments[1]);
if (!requestPath.isAbsolute() || !responsePath.isAbsolute()) {
throw new AdapterException("protocol_violation", "adapter paths must be absolute");
}
Path temporaryPath = responsePath.resolveSibling(responsePath.getFileName() + ".tmp");
Request request = null;
try {
request = parseRequest(readBounded(requestPath));
Object data = dispatch(request);
writeResponse(temporaryPath, responsePath, response(request, "success", data, null));
} catch (ResponseTooLargeException error) {
Files.deleteIfExists(temporaryPath);
if (request == null) {
throw error;
}
Map<String, Object> details = object();
details.put("limit_bytes", MAX_RESPONSE_BYTES);
details.put("observed_at_least_bytes", error.observedAtLeast);
details.put("operation", request.operation);
writeResponse(temporaryPath, responsePath,
response(request, "error", null,
new AdapterError("result_too_large", "adapter result exceeds 256 MiB", details)));
} catch (AdapterException error) {
Files.deleteIfExists(temporaryPath);
if (request == null) {
throw error;
}
writeResponse(temporaryPath, responsePath,
response(request, "error", null,
new AdapterError(error.code, error.getMessage(), error.details)));
}
}
private Object dispatch(Request request) throws Exception {
return switch (request.operation) {
case "doctor" -> doctor(request.arguments);
case "inspect" -> inspect(request.arguments);
case "functions" -> functions(request.arguments);
case "decompile" -> decompile(request.arguments, request.decompileTimeoutSeconds);
default ->
throw new AdapterException("protocol_violation", "operation is not dispatched by Java");
};
}
private Object doctor(Map<String, Object> arguments) throws AdapterException {
expectKeys(arguments, Set.of("kind"), "doctor arguments");
requireKind(arguments, "doctor");
Map<String, Object> result = object();
result.put("ready", true);
result.put("ghidra_version", Application.getApplicationVersion());
result.put("java_version", Runtime.version().feature());
result.put("adapter_protocol_version", PROTOCOL_VERSION);
return result;
}
private Object inspect(Map<String, Object> arguments) throws AdapterException {
expectProgram();
expectKeys(arguments, Set.of("kind"), "inspect arguments");
requireKind(arguments, "inspect");
Map<String, Object> program = object();
program.put("image_base", address(currentProgram.getImageBase()));
program.put("minimum_address", address(currentProgram.getMinAddress()));
program.put("maximum_address", address(currentProgram.getMaxAddress()));
program.put("function_count", allFunctions().size());
Map<String, Object> result = object();
result.put("program", program);
result.put("target", target());
result.put("ghidra_version", Application.getApplicationVersion());
result.put("java_version", Runtime.version().feature());
return result;
}
private Object functions(Map<String, Object> arguments) throws AdapterException {
expectProgram();
expectKeys(arguments, Set.of("kind", "page"), "functions arguments");
requireKind(arguments, "functions");
Map<String, Object> pageRequest = asObject(arguments.get("page"), "page");
expectKeys(pageRequest, Set.of("offset", "limit"), "page");
long offset = nonNegativeLong(pageRequest.get("offset"), "offset");
Object limitValue = pageRequest.get("limit");
Long limit = parseLimit(limitValue);
List<Function> all = allFunctions();
long total = all.size();
int start = (int) Math.min(offset, total);
long requestedEnd = limit == null ? total : Math.min(total, saturatingAdd(offset, limit));
int end = (int) Math.max(start, requestedEnd);
List<Object> items = new ArrayList<>();
for (Function function : all.subList(start, end)) {
items.add(functionItem(function));
}
Map<String, Object> page = object();
page.put("order", "location_then_entry_ascending");
page.put("offset", offset);
page.put("limit", limit);
page.put("returned", items.size());
page.put("total", total);
page.put("has_more", end < total);
Map<String, Object> result = object();
result.put("page", page);
result.put("items", items);
return result;
}
private Object decompile(Map<String, Object> arguments, long timeoutSeconds) throws Exception {
expectProgram();
expectKeys(arguments, Set.of("kind", "selector"), "decompile arguments");
requireKind(arguments, "decompile");
Map<String, Object> selector = asObject(arguments.get("selector"), "selector");
expectKeys(selector, Set.of("kind", "value"), "selector");
String kind = string(selector.get("kind"), "selector kind");
Function function;
if (kind.equals("name")) {
String value = string(selector.get("value"), "selector value");
function = resolveName(value);
} else if (kind.equals("address")) {
function = resolveAddress(asObject(selector.get("value"), "selector address"));
} else {
throw new AdapterException("protocol_violation", "unknown Function Selector kind");
}
if (function.isExternal()) {
throw new AdapterException(
"function_not_decompilable", "external Function cannot be decompiled");
}
String text;
DecompInterface decompiler = new DecompInterface();
try {
if (!decompiler.openProgram(currentProgram)) {
throw new AdapterException(
"decompilation_failed", "Ghidra decompiler did not open Program");
}
int seconds = (int) Math.min(timeoutSeconds, Integer.MAX_VALUE);
DecompileResults results = decompiler.decompileFunction(function, seconds, monitor);
if (!results.decompileCompleted()) {
String message = results.getErrorMessage();
if (message != null && message.toLowerCase(java.util.Locale.ROOT).contains("timeout")) {
throw new AdapterException("decompilation_timeout", "Ghidra decompilation timed out");
}
throw new AdapterException("decompilation_failed",
message == null || message.isBlank() ? "Ghidra decompilation failed" : message);
}
text = normalizeLineEndings(results.getDecompiledFunction().getC());
} finally {
decompiler.dispose();
}
Map<String, Object> selected = object();
selected.put("name", function.getName(false));
selected.put("qualified_name", function.getName(true));
selected.put("entry", address(function.getEntryPoint()));
Map<String, Object> decompilation = object();
decompilation.put("syntax", "ghidra_c");
decompilation.put("text", text);
Map<String, Object> result = object();
result.put("requested_selector", selector);
result.put("function", selected);
result.put("decompilation", decompilation);
return result;
}
private Function resolveName(String requested) throws AdapterException {
boolean qualified = requested.contains("::");
List<Function> matches = new ArrayList<>();
for (Function function : allFunctions()) {
String candidate = qualified ? function.getName(true) : function.getName(false);
if (candidate.equals(requested)) {
matches.add(function);
}
}
if (matches.isEmpty()) {
throw new AdapterException("function_not_found", "Function Selector did not resolve");
}
if (matches.size() != 1) {
Map<String, Object> details = object();
List<Object> candidates = new ArrayList<>();
for (Function function : matches.subList(0, Math.min(100, matches.size()))) {
candidates.add(address(function.getEntryPoint()));
}
details.put("candidates", candidates);
details.put("candidate_count", matches.size());
throw new AdapterException("function_selector_ambiguous",
"Function Selector resolves to more than one Function", details);
}
return matches.get(0);
}
private Function resolveAddress(Map<String, Object> selector) throws AdapterException {
expectKeys(selector, Set.of("space", "offset"), "selector address");
String spaceName = string(selector.get("space"), "address space");
String offsetText = string(selector.get("offset"), "address offset");
AddressFactory factory = currentProgram.getAddressFactory();
AddressSpace selectedSpace = null;
List<String> spaces = new ArrayList<>();
for (AddressSpace space : factory.getAllAddressSpaces()) {
spaces.add(space.getName());
if (space.getName().equals(spaceName)) {
selectedSpace = space;
}
}
if (selectedSpace == null) {
spaces.sort(GhidrAdapter::compareUtf8);
Map<String, Object> details = object();
details.put("valid_address_spaces", spaces.subList(0, Math.min(100, spaces.size())));
throw new AdapterException(
"address_space_not_found", "address space does not exist", details);
}
int width = Math.max(2, (selectedSpace.getSize() + 3) / 4);
if (!offsetText.matches("0x[0-9a-f]+") || offsetText.length() != width + 2) {
throw new AdapterException(
"protocol_violation", "address offset is not canonical for its space");
}
BigInteger offset = new BigInteger(offsetText.substring(2), 16);
if (offset.bitLength() > 64) {
throw new AdapterException(
"protocol_violation", "address offset exceeds adapter representation");
}
Address requested;
try {
requested = selectedSpace.getAddress(offset.longValue());
} catch (RuntimeException error) {
throw new AdapterException("function_not_found", "address is outside its address space");
}
Function exact = currentProgram.getFunctionManager().getFunctionAt(requested);
if (exact != null) {
return exact;
}
Function containing = currentProgram.getFunctionManager().getFunctionContaining(requested);
if (containing != null) {
Map<String, Object> details = object();
details.put("containing_function_entry", address(containing.getEntryPoint()));
throw new AdapterException(
"function_entry_required", "address is inside a Function but is not its entry", details);
}
throw new AdapterException(
"function_not_found", "no Function exists at the requested entry Address");
}
private List<Function> allFunctions() {
LinkedHashSet<Function> unique = new LinkedHashSet<>();
FunctionIterator memory = currentProgram.getFunctionManager().getFunctions(true);
while (memory.hasNext()) {
unique.add(memory.next());
}
FunctionIterator external = currentProgram.getFunctionManager().getExternalFunctions();
while (external.hasNext()) {
unique.add(external.next());
}
List<Function> functions = new ArrayList<>(unique);
functions.sort(Comparator.comparingInt((Function function) -> function.isExternal() ? 1 : 0)
.thenComparing(function
-> function.getEntryPoint().getAddressSpace().getName(),
GhidrAdapter::compareUtf8)
.thenComparing(
function -> unsigned(function.getEntryPoint().getOffset()), BigInteger::compareTo));
return functions;
}
private Map<String, Object> functionItem(Function function) {
Map<String, Object> item = object();
item.put("name", function.getName(false));
item.put("qualified_name", function.getName(true));
item.put("entry", address(function.getEntryPoint()));
item.put("body_address_count", function.getBody().getNumAddresses());
item.put("location", function.isExternal() ? "external" : "memory");
item.put("is_external", function.isExternal());
item.put("is_thunk", function.isThunk());
Function thunk = function.getThunkedFunction(false);
item.put("thunk_target_entry", thunk == null ? null : address(thunk.getEntryPoint()));
item.put("decompilable", !function.isExternal());
return item;
}
private Map<String, Object> target() {
Map<String, Object> target = object();
target.put("loader", currentProgram.getExecutableFormat());
target.put("format", currentProgram.getExecutableFormat());
target.put("processor_language", currentProgram.getLanguageID().getIdAsString());
target.put("compiler_specification",
currentProgram.getCompilerSpec().getCompilerSpecID().getIdAsString());
return target;
}
private static Map<String, Object> address(Address value) {
if (value == null || value == Address.NO_ADDRESS) {
return null;
}
AddressSpace space = value.getAddressSpace();
int width = Math.max(2, (space.getSize() + 3) / 4);
String digits = Long.toUnsignedString(value.getOffset(), 16);
Map<String, Object> result = object();
result.put("space", space.getName());
result.put("offset",
"0x"
+ "0".repeat(Math.max(0, width - digits.length())) + digits);
return result;
}
private static Request parseRequest(byte[] bytes) throws AdapterException {
Object parsed = new JsonParser(decodeUtf8(bytes)).parse();
Map<String, Object> root = asObject(parsed, "request");
expectKeys(root,
Set.of("protocol_version", "invocation_id", "operation", "staged_sample", "analysis_path",
"limits", "arguments"),
"request");
long version = nonNegativeLong(root.get("protocol_version"), "protocol_version");
if (version != PROTOCOL_VERSION) {
throw new AdapterException("protocol_violation", "unsupported adapter protocol version");
}
String invocation = string(root.get("invocation_id"), "invocation_id");
if (!invocation.matches("[0-9a-f]{32}")) {
throw new AdapterException("protocol_violation", "invalid invocation ID");
}
String operation = string(root.get("operation"), "operation");
if (!DISPATCH.contains(operation)) {
throw new AdapterException("protocol_violation", "operation is not in Java dispatch set");
}
nullableAbsolutePath(root.get("staged_sample"), "staged_sample");
nullableAbsolutePath(root.get("analysis_path"), "analysis_path");
Map<String, Object> limits = asObject(root.get("limits"), "limits");
expectKeys(limits,
Set.of("max_heap_mib", "max_cpu", "analysis_timeout_seconds", "decompile_timeout_seconds",
"child_watchdog_seconds", "max_sample_bytes", "max_inline_bytes"),
"limits");
positiveLong(limits.get("max_heap_mib"), "max_heap_mib");
positiveLong(limits.get("max_cpu"), "max_cpu");
positiveLong(limits.get("analysis_timeout_seconds"), "analysis_timeout_seconds");
Object decompileTimeout = limits.get("decompile_timeout_seconds");
long timeout =
decompileTimeout == null ? 60 : positiveLong(decompileTimeout, "decompile_timeout_seconds");
positiveLong(limits.get("child_watchdog_seconds"), "child_watchdog_seconds");
positiveLong(limits.get("max_sample_bytes"), "max_sample_bytes");
positiveLong(limits.get("max_inline_bytes"), "max_inline_bytes");
Map<String, Object> arguments = asObject(root.get("arguments"), "arguments");
if (!string(arguments.get("kind"), "argument kind").equals(operation)) {
throw new AdapterException("protocol_violation", "operation and argument kind differ");
}
return new Request(invocation, operation, arguments, timeout);
}
private static Map<String, Object> response(
Request request, String status, Object data, AdapterError error) {
Map<String, Object> root = object();
root.put("protocol_version", PROTOCOL_VERSION);
root.put("invocation_id", request.invocationId);
root.put("operation", request.operation);
Map<String, Object> result = object();
result.put("status", status);
if (error == null) {
result.put("data", data);
} else {
result.put("code", error.code);
result.put("message", error.message);
result.put("details", error.details);
}
root.put("result", result);
return root;
}
private static byte[] readBounded(Path path) throws IOException, AdapterException {
try (InputStream input = Files.newInputStream(path);
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
byte[] buffer = new byte[8192];
int remaining = MAX_REQUEST_BYTES + 1;
while (remaining > 0) {
int count = input.read(buffer, 0, Math.min(buffer.length, remaining));
if (count < 0) {
break;
}
output.write(buffer, 0, count);
remaining -= count;
}
if (output.size() > MAX_REQUEST_BYTES) {
throw new AdapterException("protocol_violation", "request exceeds 1 MiB");
}
return output.toByteArray();
}
}
private static String decodeUtf8(byte[] bytes) throws AdapterException {
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException error) {
throw new AdapterException("protocol_violation", "request is not valid UTF-8");
}
}
private static void writeResponse(Path temporary, Path response, Object value)
throws IOException, ResponseTooLargeException {
Files.deleteIfExists(temporary);
try (OutputStream raw = Files.newOutputStream(
temporary, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
BoundedOutputStream bounded = new BoundedOutputStream(raw, MAX_RESPONSE_BYTES)) {
JsonWriter.write(value, bounded);
bounded.flush();
} catch (ResponseTooLargeException error) {
Files.deleteIfExists(temporary);
throw error;
}
try {
Files.move(temporary, response, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException error) {
Files.deleteIfExists(temporary);
throw new IOException("response filesystem does not support atomic rename", error);
}
}
private void expectProgram() throws AdapterException {
if (currentProgram == null) {
throw new AdapterException("analysis_failed", "operation requires an imported Program");
}
}
private static void requireKind(Map<String, Object> arguments, String expected)
throws AdapterException {
if (!string(arguments.get("kind"), "argument kind").equals(expected)) {
throw new AdapterException("protocol_violation", "unexpected argument kind");
}
}
private static Long parseLimit(Object value) throws AdapterException {
if (value instanceof String string && string.equals("all")) {
return null;
}
Map<String, Object> tagged = asObject(value, "limit");
expectKeys(tagged, Set.of("bounded"), "bounded limit");
return positiveLong(tagged.get("bounded"), "limit");
}
private static void nullableAbsolutePath(Object value, String name) throws AdapterException {
if (value == null) {
return;
}
String path = string(value, name);
try {
if (!Path.of(path).isAbsolute()) {
throw new AdapterException("protocol_violation", name + " must be absolute");
}
} catch (java.nio.file.InvalidPathException error) {
throw new AdapterException("protocol_violation", name + " is not a valid worker path");
}
}
private static long positiveLong(Object value, String name) throws AdapterException {
long result = nonNegativeLong(value, name);
if (result == 0) {
throw new AdapterException("protocol_violation", name + " must be positive");
}
return result;
}
private static long nonNegativeLong(Object value, String name) throws AdapterException {
if (!(value instanceof Long number) || number < 0) {
throw new AdapterException("protocol_violation", name + " must be a non-negative integer");
}
return number;
}
@SuppressWarnings("unchecked")
private static Map<String, Object> asObject(Object value, String name) throws AdapterException {
if (!(value instanceof Map<?, ?>)) {
throw new AdapterException("protocol_violation", name + " must be an object");
}
return (Map<String, Object>) value;
}
private static String string(Object value, String name) throws AdapterException {
if (!(value instanceof String text)) {
throw new AdapterException("protocol_violation", name + " must be a string");
}
return text;
}
private static void expectKeys(Map<String, Object> object, Set<String> expected, String name)
throws AdapterException {
if (!object.keySet().equals(expected)) {
throw new AdapterException("protocol_violation", name + " has missing or unknown fields");
}
}
private static long saturatingAdd(long left, long right) {
return Long.MAX_VALUE - left < right ? Long.MAX_VALUE : left + right;
}
private static BigInteger unsigned(long value) {
return new BigInteger(Long.toUnsignedString(value));
}
private static int compareUtf8(String left, String right) {
byte[] a = left.getBytes(StandardCharsets.UTF_8);
byte[] b = right.getBytes(StandardCharsets.UTF_8);
int length = Math.min(a.length, b.length);
for (int index = 0; index < length; index++) {
int compared = Integer.compare(Byte.toUnsignedInt(a[index]), Byte.toUnsignedInt(b[index]));
if (compared != 0) {
return compared;
}
}
return Integer.compare(a.length, b.length);
}
private static String normalizeLineEndings(String input) {
return input.replace("\r\n", "\n").replace('\r', '\n');
}
private static Map<String, Object> object() {
return new LinkedHashMap<>();
}
private record Request(String invocationId, String operation, Map<String, Object> arguments,
long decompileTimeoutSeconds) {}
private record AdapterError(String code, String message, Map<String, Object> details) {}
private static final class AdapterException extends Exception {
private static final long serialVersionUID = 1L;
final String code;
final Map<String, Object> details;
AdapterException(String code, String message) {
this(code, message, object());
}
AdapterException(String code, String message, Map<String, Object> details) {
super(message);
this.code = code;
this.details = details;
}
}
private static final class ResponseTooLargeException extends IOException {
private static final long serialVersionUID = 1L;
final long observedAtLeast;
ResponseTooLargeException(long observedAtLeast) {
super("response exceeds fixed bound");
this.observedAtLeast = observedAtLeast;
}
}
private static final class BoundedOutputStream extends OutputStream {
private final OutputStream delegate;
private final long limit;
private long written;
BoundedOutputStream(OutputStream delegate, long limit) {
this.delegate = delegate;
this.limit = limit;
}
@Override
public void write(int value) throws IOException {
ensure(1);
delegate.write(value);
written++;
}
@Override
public void write(byte[] bytes, int offset, int length) throws IOException {
ensure(length);
delegate.write(bytes, offset, length);
written += length;
}
private void ensure(int additional) throws ResponseTooLargeException {
if (additional > limit - written) {
throw new ResponseTooLargeException(written + 1);
}
}
@Override
public void flush() throws IOException {
delegate.flush();
}
@Override
public void close() throws IOException {
delegate.close();
}
}
private static final class JsonWriter {
private JsonWriter() {}
static void write(Object value, OutputStream output) throws IOException {
if (value == null) {
bytes(output, "null");
} else if (value instanceof String string) {
string(output, string);
} else if (value instanceof Boolean || value instanceof Number) {
bytes(output, value.toString());
} else if (value instanceof Map<?, ?> map) {
output.write('{');
boolean first = true;
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (!first) {
output.write(',');
}
first = false;
string(output, entry.getKey().toString());
output.write(':');
write(entry.getValue(), output);
}
output.write('}');
} else if (value instanceof Iterable<?> iterable) {
output.write('[');
boolean first = true;
for (Object item : iterable) {
if (!first) {
output.write(',');
}
first = false;
write(item, output);
}
output.write(']');
} else {
throw new IOException("unsupported JSON value");
}
}
private static void string(OutputStream output, String value) throws IOException {
output.write('"');
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
switch (character) {
case '"' -> bytes(output, "\\\"");
case '\\' -> bytes(output, "\\\\");
case '\b' -> bytes(output, "\\b");
case '\f' -> bytes(output, "\\f");
case '\n' -> bytes(output, "\\n");
case '\r' -> bytes(output, "\\r");
case '\t' -> bytes(output, "\\t");
default -> {
if (character < 0x20) {
bytes(output, String.format("\\u%04x", (int) character));
} else {
int codePoint = value.codePointAt(index);
byte[] encoded =
new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8);
output.write(encoded);
if (Character.isSupplementaryCodePoint(codePoint)) {
index++;
}
}
}
}
}
output.write('"');
}
private static void bytes(OutputStream output, String value) throws IOException {
output.write(value.getBytes(StandardCharsets.UTF_8));
}
}
private static final class JsonParser {
private final String input;
private int offset;
JsonParser(String input) {
this.input = input;
}
Object parse() throws AdapterException {
Object value = value();
whitespace();
if (offset != input.length()) {
fail();
}
return value;
}
private Object value() throws AdapterException {
whitespace();
if (offset >= input.length()) {
return fail();
}
return switch (input.charAt(offset)) {
case '{' -> objectValue();
case '[' -> arrayValue();
case '"' -> stringValue();
case 't' -> literal("true", Boolean.TRUE);
case 'f' -> literal("false", Boolean.FALSE);
case 'n' -> literal("null", null);
default -> numberValue();
};
}
private Map<String, Object> objectValue() throws AdapterException {
offset++;
Map<String, Object> result = object();
whitespace();
if (consume('}')) {
return result;
}
while (true) {
whitespace();
if (offset >= input.length() || input.charAt(offset) != '"') {
return fail();
}
String key = stringValue();
if (result.containsKey(key)) {
throw new AdapterException("protocol_violation", "duplicate JSON object key");
}
whitespace();
if (!consume(':')) {
return fail();
}
result.put(key, value());
whitespace();
if (consume('}')) {
return result;
}
if (!consume(',')) {
return fail();
}
}
}
private List<Object> arrayValue() throws AdapterException {
offset++;
List<Object> result = new ArrayList<>();
whitespace();
if (consume(']')) {
return result;
}
while (true) {
result.add(value());
whitespace();
if (consume(']')) {
return result;
}
if (!consume(',')) {
return fail();
}
}
}
private String stringValue() throws AdapterException {
offset++;
StringBuilder result = new StringBuilder();
while (offset < input.length()) {
char character = input.charAt(offset++);
if (character == '"') {
return validString(result.toString());
}
if (character == '\\') {
if (offset >= input.length()) {
return fail();
}
char escaped = input.charAt(offset++);
switch (escaped) {
case '"', '\\', '/' -> result.append(escaped);
case 'b' -> result.append('\b');
case 'f' -> result.append('\f');
case 'n' -> result.append('\n');
case 'r' -> result.append('\r');
case 't' -> result.append('\t');
case 'u' -> result.append(unicodeEscape());
default -> throw new AdapterException("protocol_violation", "invalid JSON escape");
}
} else if (character < 0x20) {
return fail();
} else {
result.append(character);
}
}
return fail();
}
private String validString(String value) throws AdapterException {
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (Character.isHighSurrogate(character)) {
if (index + 1 >= value.length() || !Character.isLowSurrogate(value.charAt(index + 1))) {
throw new AdapterException(
"protocol_violation", "JSON string has an unpaired surrogate");
}
index++;
} else if (Character.isLowSurrogate(character)) {
throw new AdapterException("protocol_violation", "JSON string has an unpaired surrogate");
}
}
return value;
}
private char unicodeEscape() throws AdapterException {
if (offset + 4 > input.length()) {
return fail();
}
try {
char value = (char) Integer.parseInt(input.substring(offset, offset + 4), 16);
offset += 4;
return value;
} catch (NumberFormatException error) {
return fail();
}
}
private Object numberValue() throws AdapterException {
int start = offset;
if (offset < input.length() && input.charAt(offset) == '-') {
offset++;
}
if (offset >= input.length() || !Character.isDigit(input.charAt(offset))) {
return fail();
}
if (input.charAt(offset) == '0') {
offset++;
} else {
while (offset < input.length() && Character.isDigit(input.charAt(offset))) {
offset++;
}
}
if (offset < input.length() && ".eE+".indexOf(input.charAt(offset)) >= 0) {
throw new AdapterException("protocol_violation", "floating-point JSON is not accepted");
}
try {
return Long.valueOf(input.substring(start, offset));
} catch (NumberFormatException error) {
throw new AdapterException("protocol_violation", "JSON integer is out of range");
}
}
private Object literal(String text, Object value) throws AdapterException {
if (!input.startsWith(text, offset)) {
return fail();
}
offset += text.length();
return value;
}
private boolean consume(char expected) {
if (offset < input.length() && input.charAt(offset) == expected) {
offset++;
return true;
}
return false;
}
private void whitespace() {
while (offset < input.length() && " \n\r\t".indexOf(input.charAt(offset)) >= 0) {
offset++;
}
}
private <T> T fail() throws AdapterException {
throw new AdapterException("protocol_violation", "malformed JSON request");
}
}
}

8
java/dispatch.txt Normal file
View file

@ -0,0 +1,8 @@
# Java accepts only operations which require a Ghidra/JVM worker.
doctor
inspect
functions
decompile
# clean is deliberately absent: cleanup is a trusted Rust Analysis Store
# transaction and is never delegated into the capability-sandboxed worker.

48
tests/adapter_contract.rs Normal file
View file

@ -0,0 +1,48 @@
#![allow(clippy::expect_used)]
#![doc = "Java dispatch, protocol-bound, and capability-surface regressions."]
use std::{collections::BTreeSet, fs, path::PathBuf};
use ghidra_cli::operation::OPERATIONS;
fn root(relative: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative)
}
#[test]
fn java_dispatch_matches_only_worker_owned_registry_operations() {
let dispatch = fs::read_to_string(root("java/dispatch.txt")).expect("read Java dispatch");
let java: BTreeSet<&str> = dispatch
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect();
let rust_worker: BTreeSet<&str> = OPERATIONS
.iter()
.map(|descriptor| descriptor.operation.as_str())
.filter(|operation| *operation != "clean")
.collect();
assert_eq!(java, rust_worker);
assert!(!java.contains("clean"));
}
#[test]
fn java_source_has_protocol_bounds_and_no_egress_or_mutation_surface() {
let source = fs::read_to_string(root("java/GhidrAdapter.java")).expect("read adapter");
assert!(source.contains("MAX_REQUEST_BYTES = 1_048_576"));
assert!(source.contains("MAX_RESPONSE_BYTES = 268_435_456L"));
for forbidden in [
"ServerSocket",
"HttpServer",
"java.net.",
"setName(",
"startTransaction(",
"runScript(",
"executeScript(",
] {
assert!(
!source.contains(forbidden),
"forbidden Java surface: {forbidden}"
);
}
}

View file

@ -0,0 +1,19 @@
{
"protocol_version": 1,
"invocation_id": "fedcba9876543210fedcba9876543210",
"operation": "doctor",
"staged_sample": null,
"analysis_path": null,
"limits": {
"max_heap_mib": 2048,
"max_cpu": 2,
"analysis_timeout_seconds": 60,
"decompile_timeout_seconds": null,
"child_watchdog_seconds": 120,
"max_sample_bytes": 1073741824,
"max_inline_bytes": 65536
},
"arguments": {
"kind": "doctor"
}
}

View file

@ -0,0 +1,19 @@
{
"protocol_version": 1,
"invocation_id": "0123456789abcdef0123456789abcdef",
"operation": "inspect",
"staged_sample": "@SAMPLE@",
"analysis_path": "@ANALYSIS@",
"limits": {
"max_heap_mib": 2048,
"max_cpu": 2,
"analysis_timeout_seconds": 60,
"decompile_timeout_seconds": null,
"child_watchdog_seconds": 180,
"max_sample_bytes": 1073741824,
"max_inline_bytes": 65536
},
"arguments": {
"kind": "inspect"
}
}

View file

@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
: "${GHIDR_ANALYZE_HEADLESS:?set by the Nix check or dev shell}"
: "${GHIDR_ADAPTER_PATH:?set by the Nix check or dev shell}"
: "${GHIDR_FIXTURES:?set by the Nix check}"
test_root="${TMPDIR:?}/ghidr-adapter-e2e"
mkdir -p "$test_root/project" "$test_root/invocation"
doctor_request="$test_root/invocation/doctor-request.json"
doctor_response="$test_root/invocation/doctor-response.json"
request="$test_root/invocation/request.json"
response="$test_root/invocation/response.json"
sample="$GHIDR_FIXTURES/elf-x86_64/known.elf"
cp tests/data/adapter-doctor-request.json "$doctor_request"
"$GHIDR_ANALYZE_HEADLESS" "$test_root" doctor-probe \
-noanalysis \
-scriptPath "$GHIDR_ADAPTER_PATH" \
-postScript GhidrAdapter.java "$doctor_request" "$doctor_response" \
-deleteProject
jq -e '
.operation == "doctor" and
.result.status == "success" and
.result.data.ready == true and
.result.data.ghidra_version == "12.1.2" and
.result.data.java_version == 21
' "$doctor_response"
sed \
-e "s|@SAMPLE@|$sample|g" \
-e "s|@ANALYSIS@|$test_root/project|g" \
tests/data/adapter-inspect-request.json > "$request"
"$GHIDR_ANALYZE_HEADLESS" "$test_root/project" analysis \
-import "$sample" \
-analysisTimeoutPerFile 60 \
-max-cpu 2 \
-scriptPath "$GHIDR_ADAPTER_PATH" \
-postScript GhidrAdapter.java "$request" "$response"
jq -e '
.protocol_version == 1 and
.invocation_id == "0123456789abcdef0123456789abcdef" and
.operation == "inspect" and
.result.status == "success" and
.result.data.target.processor_language == "x86:LE:64:default"
' "$response"
test ! -e "$response.tmp"