feat: implement Ghidra adapter and Nix integration
This commit is contained in:
parent
170ca32f58
commit
c0e540ee76
14 changed files with 1482 additions and 14 deletions
14
.forgejo/workflows/ci.yml
Normal file
14
.forgejo/workflows/ci.yml
Normal 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
8
.gitignore
vendored
|
|
@ -1,7 +1,5 @@
|
|||
/target/
|
||||
*.log
|
||||
/result
|
||||
/result-*
|
||||
/target
|
||||
*.gpr
|
||||
*.rep/
|
||||
.direnv/
|
||||
.envrc
|
||||
|
||||
|
|
|
|||
69
README.md
69
README.md
|
|
@ -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
26
deny.toml
Normal 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
14
fixtures/README.md
Normal 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
18
fixtures/src/known.c
Normal 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
27
flake.lock
generated
Normal 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
271
flake.nix
Normal 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
905
java/GhidrAdapter.java
Normal 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
8
java/dispatch.txt
Normal 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
48
tests/adapter_contract.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
19
tests/data/adapter-doctor-request.json
Normal file
19
tests/data/adapter-doctor-request.json
Normal 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"
|
||||
}
|
||||
}
|
||||
19
tests/data/adapter-inspect-request.json
Normal file
19
tests/data/adapter-inspect-request.json
Normal 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"
|
||||
}
|
||||
}
|
||||
50
tests/real_ghidra_adapter.sh
Normal file
50
tests/real_ghidra_adapter.sh
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue