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/
*.log
/result
/result-*
/target
*.gpr
*.rep/
.direnv/
.envrc

View file

@ -3,10 +3,10 @@
A small, dependable command-line interface for read-only Ghidra analysis.
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,
public envelopes and schemas, bounded Rust/Java protocol, and synchronous
execution seam are present; store, process/sandbox, and Ghidra adapter execution
remain deliberately unconnected.
The project has a version 0.1 Rust contract foundation plus a strict Java
headless adapter, reproducible native fixtures, and pinned x86-64 Linux Nix
packaging. Store and process/sandbox integration is developed independently
behind the existing synchronous executor boundary.
Proposed usage:
@ -32,12 +32,63 @@ available explicitly through `--format human`.
including the Ghidra MCP comparison that informed protocol safeguards and
the post-version-0.1 roadmap.
## Status
## Pinned package
Foundation only. Every accepted command currently returns a typed
`internal_not_implemented` runtime error instead of pretending Ghidra analysis
succeeded. Subsequent layers implement the library's synchronous executor
contract while preserving the committed schemas and reviewed fixtures.
The flake fixes Ghidra at exactly 12.1.2 and uses JDK 21. It also closes over
Rust, Bubblewrap, the Java adapter, deterministic Java/Nix formatters,
`cargo-deny`, and `statix`:
```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

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"