diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..1e2a6fb --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index babfde6..5cedfba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ -/target/ -*.log +/result +/result-* +/target *.gpr *.rep/ -.direnv/ -.envrc - diff --git a/README.md b/README.md index a4092d6..4102142 100644 --- a/README.md +++ b/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 diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..cd89eb4 --- /dev/null +++ b/deny.toml @@ -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"] diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..a793af9 --- /dev/null +++ b/fixtures/README.md @@ -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. diff --git a/fixtures/src/known.c b/fixtures/src/known.c new file mode 100644 index 0000000..f8c3434 --- /dev/null +++ b/fixtures/src/known.c @@ -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(); +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..bbe337e --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..abab516 --- /dev/null +++ b/flake.nix @@ -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"; + }; + }; +} diff --git a/java/GhidrAdapter.java b/java/GhidrAdapter.java new file mode 100644 index 0000000..314f5c8 --- /dev/null +++ b/java/GhidrAdapter.java @@ -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 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 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 arguments) throws AdapterException { + expectKeys(arguments, Set.of("kind"), "doctor arguments"); + requireKind(arguments, "doctor"); + Map 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 arguments) throws AdapterException { + expectProgram(); + expectKeys(arguments, Set.of("kind"), "inspect arguments"); + requireKind(arguments, "inspect"); + Map 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 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 arguments) throws AdapterException { + expectProgram(); + expectKeys(arguments, Set.of("kind", "page"), "functions arguments"); + requireKind(arguments, "functions"); + Map 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 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 items = new ArrayList<>(); + for (Function function : all.subList(start, end)) { + items.add(functionItem(function)); + } + Map 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 result = object(); + result.put("page", page); + result.put("items", items); + return result; + } + + private Object decompile(Map arguments, long timeoutSeconds) throws Exception { + expectProgram(); + expectKeys(arguments, Set.of("kind", "selector"), "decompile arguments"); + requireKind(arguments, "decompile"); + Map 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 selected = object(); + selected.put("name", function.getName(false)); + selected.put("qualified_name", function.getName(true)); + selected.put("entry", address(function.getEntryPoint())); + Map decompilation = object(); + decompilation.put("syntax", "ghidra_c"); + decompilation.put("text", text); + Map 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 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 details = object(); + List 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 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 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 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 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 allFunctions() { + LinkedHashSet 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 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 functionItem(Function function) { + Map 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 target() { + Map 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 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 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 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 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 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 response( + Request request, String status, Object data, AdapterError error) { + Map root = object(); + root.put("protocol_version", PROTOCOL_VERSION); + root.put("invocation_id", request.invocationId); + root.put("operation", request.operation); + Map 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 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 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 asObject(Object value, String name) throws AdapterException { + if (!(value instanceof Map)) { + throw new AdapterException("protocol_violation", name + " must be an object"); + } + return (Map) 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 object, Set 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 object() { + return new LinkedHashMap<>(); + } + + private record Request(String invocationId, String operation, Map arguments, + long decompileTimeoutSeconds) {} + + private record AdapterError(String code, String message, Map details) {} + + private static final class AdapterException extends Exception { + private static final long serialVersionUID = 1L; + + final String code; + final Map details; + + AdapterException(String code, String message) { + this(code, message, object()); + } + + AdapterException(String code, String message, Map 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 objectValue() throws AdapterException { + offset++; + Map 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 arrayValue() throws AdapterException { + offset++; + List 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 fail() throws AdapterException { + throw new AdapterException("protocol_violation", "malformed JSON request"); + } + } +} diff --git a/java/dispatch.txt b/java/dispatch.txt new file mode 100644 index 0000000..784d0f8 --- /dev/null +++ b/java/dispatch.txt @@ -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. diff --git a/tests/adapter_contract.rs b/tests/adapter_contract.rs new file mode 100644 index 0000000..759f588 --- /dev/null +++ b/tests/adapter_contract.rs @@ -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}" + ); + } +} diff --git a/tests/data/adapter-doctor-request.json b/tests/data/adapter-doctor-request.json new file mode 100644 index 0000000..4b88c94 --- /dev/null +++ b/tests/data/adapter-doctor-request.json @@ -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" + } +} diff --git a/tests/data/adapter-inspect-request.json b/tests/data/adapter-inspect-request.json new file mode 100644 index 0000000..c4f6fcf --- /dev/null +++ b/tests/data/adapter-inspect-request.json @@ -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" + } +} diff --git a/tests/real_ghidra_adapter.sh b/tests/real_ghidra_adapter.sh new file mode 100644 index 0000000..e2752d3 --- /dev/null +++ b/tests/real_ghidra_adapter.sh @@ -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"