feat: integrate ghidra-cli v0.1

This commit is contained in:
hermes 2026-07-28 20:20:05 +00:00
52 changed files with 6689 additions and 126 deletions

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

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

8
.gitignore vendored
View file

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

21
Cargo.lock generated
View file

@ -244,6 +244,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"signal-hook",
"tempfile", "tempfile",
"thiserror", "thiserror",
] ]
@ -482,6 +483,26 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"

View file

@ -7,6 +7,10 @@ description = "Reproducible, read-only Ghidra analysis from the command line"
license = "Apache-2.0" license = "Apache-2.0"
publish = false publish = false
[features]
# Enables timing controls used only by release-profile integration tests.
test-support = []
[lib] [lib]
name = "ghidra_cli" name = "ghidra_cli"
path = "src/lib.rs" path = "src/lib.rs"
@ -15,21 +19,26 @@ path = "src/lib.rs"
name = "ghidr" name = "ghidr"
path = "src/main.rs" path = "src/main.rs"
[[bin]]
name = "ghidr-fake-child"
path = "tests/support/fake_child.rs"
[dependencies] [dependencies]
base64 = "0.22.1" base64 = "0.22.1"
clap = { version = "4.5.60", features = ["derive"] } clap = { version = "4.5.60", features = ["derive"] }
getrandom = "0.3.3" getrandom = "=0.3.3"
hex = "0.4.3" hex = "0.4.3"
rustix = { version = "1.0.8", features = ["fs"] } rustix = { version = "=1.0.8", features = ["fs", "process"] }
schemars = { version = "=1.0.4", features = ["derive"] } schemars = { version = "=1.0.4", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
sha2 = "0.10.9" sha2 = "0.10.9"
signal-hook = "0.3.18"
thiserror = "2.0.18" thiserror = "2.0.18"
[dev-dependencies] [dev-dependencies]
assert_cmd = "2.1.2" assert_cmd = "2.1.2"
tempfile = "3.21.0" tempfile = "=3.21.0"
[lints.rust] [lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"

View file

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

26
deny.toml Normal file
View file

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

14
fixtures/README.md Normal file
View file

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

View file

@ -13,8 +13,33 @@
"data": { "data": {
"ready": true, "ready": true,
"components": { "components": {
"ghidra": { "status": "ready", "details": { "version": "12.1.2" } }, "ghidra": {
"java": { "status": "ready", "details": { "version": "21" } } "status": "ready",
"version": "12.1.2",
"launcher": {
"encoding": "utf8",
"value": "/nix/store/ghidra/bin/ghidra-analyzeHeadless"
}
},
"java": {
"status": "ready",
"version": "21",
"executable": {
"encoding": "utf8",
"value": "/nix/store/jdk/bin/java"
}
},
"analysis_store": {
"status": "ready",
"path": { "encoding": "utf8", "value": "/tmp/ghidr-store" },
"source": "cli",
"usage": { "logical_bytes": 1048576, "allocated_bytes": 1114112 }
},
"sandbox": {
"status": "ready",
"backend": "bubblewrap",
"verification": "verified"
}
} }
}, },
"warnings": [] "warnings": []

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

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

27
flake.lock generated Normal file
View file

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

306
flake.nix Normal file
View file

@ -0,0 +1,306 @@
{
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.autoPatchelfHook
pkgs.makeWrapper
pkgs.unzip
];
buildInputs = [ pkgs.stdenv.cc.cc.lib ];
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/"
patchShebangs "$out/lib/ghidra/support"
makeWrapper "$out/lib/ghidra/support/analyzeHeadless" "$out/bin/ghidra-analyzeHeadless" \
--set JAVA_HOME "${jdk}" \
--prefix PATH : "${
lib.makeBinPath [
jdk
pkgs.bash
pkgs.coreutils
pkgs.findutils
pkgs.gnugrep
pkgs.gnused
]
}"
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: -)"
# Ghidra's release JAR manifests reference optional JARs that are not
# shipped in the distribution. Keep adapter source warnings fatal,
# while excluding those upstream class-path warnings and preventing
# processors discovered in Ghidra's dependency closure from running.
javac -encoding UTF-8 -source 21 -target 21 -proc:none \
-Xlint:all,-path -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;
cargoTestFlags = [ "--features=test-support" ];
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" \
--set GHIDR_BUBBLEWRAP ${pkgs.bubblewrap}/bin/bwrap \
--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.ghidr_version == "0.1.0"
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.bash
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
patchShebangs 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";
meta.description = "Run ghidr with its pinned Ghidra runtime";
};
checks.${system} = {
rust = rustPackage;
java-adapter = adapter;
java-adapter-e2e = adapterE2e;
inherit fixtures;
real-ghidra-e2e = realGhidraE2e;
cargo-deny = pkgs.stdenvNoCC.mkDerivation {
pname = "ghidr-cargo-deny";
version = "0.1.0";
src = lib.cleanSource self;
inherit (rustPackage) cargoDeps;
nativeBuildInputs = [
pkgs.cargo
pkgs.cargo-deny
pkgs.rustPlatform.cargoSetupHook
];
dontConfigure = true;
CARGO_NET_OFFLINE = "true";
buildPhase = ''
runHook preBuild
cargo deny check bans licenses sources
runHook postBuild
'';
installPhase = ''
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";
};
};
}

954
java/GhidrAdapter.java Normal file
View file

@ -0,0 +1,954 @@
// 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.util.headless.HeadlessScript;
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 HeadlessScript {
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));
if (!request.operation.equals("doctor") && analysisTimeoutOccurred()) {
throw new AdapterException("analysis_timeout", "Ghidra auto-analysis timed out");
}
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());
return queryResult(program);
}
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 queryResult(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 queryResult(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();
String executableFormat = currentProgram.getExecutableFormat();
if (executableFormat.contains("ELF")) {
target.put("loader", "ElfLoader");
target.put("format", "ELF");
} else if (executableFormat.contains("Portable Executable")) {
target.put("loader", "PeLoader");
target.put("format",
currentProgram.getLanguage().getLanguageDescription().getSize() == 64 ? "PE32+" : "PE");
} else {
target.put("loader", executableFormat);
target.put("format", executableFormat);
}
target.put("processor_language", currentProgram.getLanguageID().getIdAsString());
target.put("compiler_specification",
currentProgram.getCompilerSpec().getCompilerSpecID().getIdAsString());
return target;
}
private Map<String, Object> queryResult(Object query) {
Map<String, Object> context = object();
context.put("ghidra_version", Application.getApplicationVersion());
context.put("java_version", Runtime.version().feature());
context.put("target", target());
context.put("loader_options", List.of());
context.put("analyzer_options", analysisOptions());
Map<String, Object> result = object();
result.put("context", context);
result.put("query", query);
return result;
}
private List<Object> analysisOptions() {
Map<String, String> current = getCurrentAnalysisOptionsAndValues(currentProgram);
List<String> names = new ArrayList<>(current.keySet());
names.sort(GhidrAdapter::compareUtf8);
List<Object> result = new ArrayList<>();
for (String name : names) {
Map<String, Object> value = object();
value.put("type", "string");
value.put("value", current.get(name));
Map<String, Object> option = object();
option.put("name", name);
option.put("value", value);
result.add(option);
}
return result;
}
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");
}
Object stagedSample = root.get("staged_sample");
Object analysisPath = root.get("analysis_path");
nullableAbsolutePath(stagedSample, "staged_sample");
nullableAbsolutePath(analysisPath, "analysis_path");
if (operation.equals("doctor") && (stagedSample != null || analysisPath != null)) {
throw new AdapterException(
"protocol_violation", "doctor must not receive Sample or Analysis paths");
}
if (!operation.equals("doctor") && (stagedSample == null || analysisPath == null)) {
throw new AdapterException(
"protocol_violation", "Sample-backed operation requires Sample and Analysis paths");
}
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 transient Map<String, Object> details;
AdapterException(String code, String message) {
this(code, message, object());
}
AdapterException(String code, String message, Map<String, Object> details) {
super(message);
this.code = code;
this.details = details;
}
}
private static final class ResponseTooLargeException extends IOException {
private static final long serialVersionUID = 1L;
final long observedAtLeast;
ResponseTooLargeException(long observedAtLeast) {
super("response exceeds fixed bound");
this.observedAtLeast = observedAtLeast;
}
}
private static final class BoundedOutputStream extends OutputStream {
private final OutputStream delegate;
private final long limit;
private long written;
BoundedOutputStream(OutputStream delegate, long limit) {
this.delegate = delegate;
this.limit = limit;
}
@Override
public void write(int value) throws IOException {
ensure(1);
delegate.write(value);
written++;
}
@Override
public void write(byte[] bytes, int offset, int length) throws IOException {
ensure(length);
delegate.write(bytes, offset, length);
written += length;
}
private void ensure(int additional) throws ResponseTooLargeException {
if (additional > limit - written) {
throw new ResponseTooLargeException(written + 1);
}
}
@Override
public void flush() throws IOException {
delegate.flush();
}
@Override
public void close() throws IOException {
delegate.close();
}
}
private static final class JsonWriter {
private JsonWriter() {}
static void write(Object value, OutputStream output) throws IOException {
if (value == null) {
bytes(output, "null");
} else if (value instanceof String string) {
string(output, string);
} else if (value instanceof Boolean || value instanceof Number) {
bytes(output, value.toString());
} else if (value instanceof Map<?, ?> map) {
output.write('{');
boolean first = true;
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (!first) {
output.write(',');
}
first = false;
string(output, entry.getKey().toString());
output.write(':');
write(entry.getValue(), output);
}
output.write('}');
} else if (value instanceof Iterable<?> iterable) {
output.write('[');
boolean first = true;
for (Object item : iterable) {
if (!first) {
output.write(',');
}
first = false;
write(item, output);
}
output.write(']');
} else {
throw new IOException("unsupported JSON value");
}
}
private static void string(OutputStream output, String value) throws IOException {
output.write('"');
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
switch (character) {
case '"' -> bytes(output, "\\\"");
case '\\' -> bytes(output, "\\\\");
case '\b' -> bytes(output, "\\b");
case '\f' -> bytes(output, "\\f");
case '\n' -> bytes(output, "\\n");
case '\r' -> bytes(output, "\\r");
case '\t' -> bytes(output, "\\t");
default -> {
if (character < 0x20) {
bytes(output, String.format("\\u%04x", (int) character));
} else {
int codePoint = value.codePointAt(index);
byte[] encoded =
new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8);
output.write(encoded);
if (Character.isSupplementaryCodePoint(codePoint)) {
index++;
}
}
}
}
}
output.write('"');
}
private static void bytes(OutputStream output, String value) throws IOException {
output.write(value.getBytes(StandardCharsets.UTF_8));
}
}
private static final class JsonParser {
private final String input;
private int offset;
JsonParser(String input) {
this.input = input;
}
Object parse() throws AdapterException {
Object value = value();
whitespace();
if (offset != input.length()) {
fail();
}
return value;
}
private Object value() throws AdapterException {
whitespace();
if (offset >= input.length()) {
return fail();
}
return switch (input.charAt(offset)) {
case '{' -> objectValue();
case '[' -> arrayValue();
case '"' -> stringValue();
case 't' -> literal("true", Boolean.TRUE);
case 'f' -> literal("false", Boolean.FALSE);
case 'n' -> literal("null", null);
default -> numberValue();
};
}
private Map<String, Object> objectValue() throws AdapterException {
offset++;
Map<String, Object> result = object();
whitespace();
if (consume('}')) {
return result;
}
while (true) {
whitespace();
if (offset >= input.length() || input.charAt(offset) != '"') {
return fail();
}
String key = stringValue();
if (result.containsKey(key)) {
throw new AdapterException("protocol_violation", "duplicate JSON object key");
}
whitespace();
if (!consume(':')) {
return fail();
}
result.put(key, value());
whitespace();
if (consume('}')) {
return result;
}
if (!consume(',')) {
return fail();
}
}
}
private List<Object> arrayValue() throws AdapterException {
offset++;
List<Object> result = new ArrayList<>();
whitespace();
if (consume(']')) {
return result;
}
while (true) {
result.add(value());
whitespace();
if (consume(']')) {
return result;
}
if (!consume(',')) {
return fail();
}
}
}
private String stringValue() throws AdapterException {
offset++;
StringBuilder result = new StringBuilder();
while (offset < input.length()) {
char character = input.charAt(offset++);
if (character == '"') {
return validString(result.toString());
}
if (character == '\\') {
if (offset >= input.length()) {
return fail();
}
char escaped = input.charAt(offset++);
switch (escaped) {
case '"', '\\', '/' -> result.append(escaped);
case 'b' -> result.append('\b');
case 'f' -> result.append('\f');
case 'n' -> result.append('\n');
case 'r' -> result.append('\r');
case 't' -> result.append('\t');
case 'u' -> result.append(unicodeEscape());
default -> throw new AdapterException("protocol_violation", "invalid JSON escape");
}
} else if (character < 0x20) {
return fail();
} else {
result.append(character);
}
}
return fail();
}
private String validString(String value) throws AdapterException {
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (Character.isHighSurrogate(character)) {
if (index + 1 >= value.length() || !Character.isLowSurrogate(value.charAt(index + 1))) {
throw new AdapterException(
"protocol_violation", "JSON string has an unpaired surrogate");
}
index++;
} else if (Character.isLowSurrogate(character)) {
throw new AdapterException("protocol_violation", "JSON string has an unpaired surrogate");
}
}
return value;
}
private char unicodeEscape() throws AdapterException {
if (offset + 4 > input.length()) {
return fail();
}
try {
char value = (char) Integer.parseInt(input.substring(offset, offset + 4), 16);
offset += 4;
return value;
} catch (NumberFormatException error) {
return fail();
}
}
private Object numberValue() throws AdapterException {
int start = offset;
if (offset < input.length() && input.charAt(offset) == '-') {
offset++;
}
if (offset >= input.length() || !Character.isDigit(input.charAt(offset))) {
return fail();
}
if (input.charAt(offset) == '0') {
offset++;
} else {
while (offset < input.length() && Character.isDigit(input.charAt(offset))) {
offset++;
}
}
if (offset < input.length() && ".eE+".indexOf(input.charAt(offset)) >= 0) {
throw new AdapterException("protocol_violation", "floating-point JSON is not accepted");
}
try {
return Long.valueOf(input.substring(start, offset));
} catch (NumberFormatException error) {
throw new AdapterException("protocol_violation", "JSON integer is out of range");
}
}
private Object literal(String text, Object value) throws AdapterException {
if (!input.startsWith(text, offset)) {
return fail();
}
offset += text.length();
return value;
}
private boolean consume(char expected) {
if (offset < input.length() && input.charAt(offset) == expected) {
offset++;
return true;
}
return false;
}
private void whitespace() {
while (offset < input.length() && " \n\r\t".indexOf(input.charAt(offset)) >= 0) {
offset++;
}
}
private <T> T fail() throws AdapterException {
throw new AdapterException("protocol_violation", "malformed JSON request");
}
}
}

8
java/dispatch.txt Normal file
View file

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

View file

@ -6,7 +6,7 @@
"properties": { "properties": {
"data": { "data": {
"description": "Operation-specific result.", "description": "Operation-specific result.",
"$ref": "#/$defs/CleanupData" "$ref": "#/$defs/InlineOrArtifact"
}, },
"kind": { "kind": {
"type": "string", "type": "string",
@ -54,6 +54,41 @@
"source" "source"
] ]
}, },
"ArtifactDescriptor": {
"description": "Artifact descriptor returned instead of oversized inline data.",
"type": "object",
"properties": {
"bytes": {
"description": "Exact file bytes including trailing LF.",
"type": "integer",
"format": "uint64",
"minimum": 0
},
"contains": {
"description": "Always `complete_success_response`.",
"type": "string"
},
"media_type": {
"description": "Always `application/json`.",
"type": "string"
},
"path": {
"description": "Tagged absolute Artifact path.",
"$ref": "#/$defs/TaggedPath"
},
"sha256": {
"description": "SHA-256 of exact file bytes.",
"$ref": "#/$defs/Digest"
}
},
"required": [
"path",
"bytes",
"sha256",
"media_type",
"contains"
]
},
"CleanupData": { "CleanupData": {
"description": "Cleanup result data; detailed count contracts are carried as typed snapshots.", "description": "Cleanup result data; detailed count contracts are carried as typed snapshots.",
"type": "object", "type": "object",
@ -215,6 +250,19 @@
"description": "A lowercase SHA-256 digest.", "description": "A lowercase SHA-256 digest.",
"type": "string" "type": "string"
}, },
"InlineOrArtifact": {
"description": "Successful command data emitted inline or represented by a complete Artifact.",
"anyOf": [
{
"description": "Complete command-specific data.",
"$ref": "#/$defs/CleanupData"
},
{
"description": "Bounded descriptor for the complete stored success response.",
"$ref": "#/$defs/SpilledData"
}
]
},
"SandboxBackend": { "SandboxBackend": {
"description": "Sandbox backend.", "description": "Sandbox backend.",
"oneOf": [ "oneOf": [
@ -273,6 +321,24 @@
} }
] ]
}, },
"SpilledData": {
"description": "Public data shape returned when a complete success exceeds the inline byte budget.",
"type": "object",
"properties": {
"artifact": {
"description": "Immutable complete-success Artifact.",
"$ref": "#/$defs/ArtifactDescriptor"
},
"spilled": {
"description": "Always true for this variant.",
"type": "boolean"
}
},
"required": [
"spilled",
"artifact"
]
},
"StorageUsage": { "StorageUsage": {
"description": "Filesystem usage represented without implying freed space.", "description": "Filesystem usage represented without implying freed space.",
"type": "object", "type": "object",

View file

@ -6,7 +6,7 @@
"properties": { "properties": {
"data": { "data": {
"description": "Operation-specific result.", "description": "Operation-specific result.",
"$ref": "#/$defs/DecompilationData" "$ref": "#/$defs/InlineOrArtifact"
}, },
"kind": { "kind": {
"type": "string", "type": "string",
@ -72,6 +72,41 @@
"source" "source"
] ]
}, },
"ArtifactDescriptor": {
"description": "Artifact descriptor returned instead of oversized inline data.",
"type": "object",
"properties": {
"bytes": {
"description": "Exact file bytes including trailing LF.",
"type": "integer",
"format": "uint64",
"minimum": 0
},
"contains": {
"description": "Always `complete_success_response`.",
"type": "string"
},
"media_type": {
"description": "Always `application/json`.",
"type": "string"
},
"path": {
"description": "Tagged absolute Artifact path.",
"$ref": "#/$defs/TaggedPath"
},
"sha256": {
"description": "SHA-256 of exact file bytes.",
"$ref": "#/$defs/Digest"
}
},
"required": [
"path",
"bytes",
"sha256",
"media_type",
"contains"
]
},
"DecompilationData": { "DecompilationData": {
"description": "Successful targeted decompilation data.", "description": "Successful targeted decompilation data.",
"type": "object", "type": "object",
@ -166,6 +201,19 @@
} }
] ]
}, },
"InlineOrArtifact": {
"description": "Successful command data emitted inline or represented by a complete Artifact.",
"anyOf": [
{
"description": "Complete command-specific data.",
"$ref": "#/$defs/DecompilationData"
},
{
"description": "Bounded descriptor for the complete stored success response.",
"$ref": "#/$defs/SpilledData"
}
]
},
"Limits": { "Limits": {
"description": "Resource policy selected for one invocation.", "description": "Resource policy selected for one invocation.",
"type": "object", "type": "object",
@ -367,6 +415,24 @@
"entry" "entry"
] ]
}, },
"SpilledData": {
"description": "Public data shape returned when a complete success exceeds the inline byte budget.",
"type": "object",
"properties": {
"artifact": {
"description": "Immutable complete-success Artifact.",
"$ref": "#/$defs/ArtifactDescriptor"
},
"spilled": {
"description": "Always true for this variant.",
"type": "boolean"
}
},
"required": [
"spilled",
"artifact"
]
},
"StoreSource": { "StoreSource": {
"description": "Analysis Store resolution source.", "description": "Analysis Store resolution source.",
"oneOf": [ "oneOf": [

View file

@ -6,7 +6,7 @@
"properties": { "properties": {
"data": { "data": {
"description": "Operation-specific result.", "description": "Operation-specific result.",
"$ref": "#/$defs/DoctorData" "$ref": "#/$defs/InlineOrArtifact"
}, },
"kind": { "kind": {
"type": "string", "type": "string",
@ -36,6 +36,34 @@
"warnings" "warnings"
], ],
"$defs": { "$defs": {
"AnalysisStoreComponent": {
"description": "Diagnosed Analysis Store.",
"type": "object",
"properties": {
"path": {
"description": "Resolved absolute store path.",
"$ref": "#/$defs/TaggedPath"
},
"source": {
"description": "Resolution precedence source.",
"$ref": "#/$defs/StoreSource"
},
"status": {
"description": "Stable readiness classification.",
"$ref": "#/$defs/ComponentStatus"
},
"usage": {
"description": "Complete non-following store usage scan.",
"$ref": "#/$defs/StorageUsage"
}
},
"required": [
"status",
"path",
"source",
"usage"
]
},
"AnalysisStoreProvenance": { "AnalysisStoreProvenance": {
"description": "Analysis Store provenance.", "description": "Analysis Store provenance.",
"type": "object", "type": "object",
@ -54,23 +82,39 @@
"source" "source"
] ]
}, },
"ComponentReport": { "ArtifactDescriptor": {
"description": "One diagnosed component.", "description": "Artifact descriptor returned instead of oversized inline data.",
"type": "object", "type": "object",
"properties": { "properties": {
"details": { "bytes": {
"description": "Component-specific facts.", "description": "Exact file bytes including trailing LF.",
"type": "object", "type": "integer",
"additionalProperties": true "format": "uint64",
"minimum": 0
}, },
"status": { "contains": {
"description": "Stable readiness classification.", "description": "Always `complete_success_response`.",
"$ref": "#/$defs/ComponentStatus" "type": "string"
},
"media_type": {
"description": "Always `application/json`.",
"type": "string"
},
"path": {
"description": "Tagged absolute Artifact path.",
"$ref": "#/$defs/TaggedPath"
},
"sha256": {
"description": "SHA-256 of exact file bytes.",
"$ref": "#/$defs/Digest"
} }
}, },
"required": [ "required": [
"status", "path",
"details" "bytes",
"sha256",
"media_type",
"contains"
] ]
}, },
"ComponentStatus": { "ComponentStatus": {
@ -103,16 +147,45 @@
} }
] ]
}, },
"Digest": {
"description": "A lowercase SHA-256 digest.",
"type": "string"
},
"DoctorComponents": {
"description": "Fixed installation components checked safely by `doctor`.",
"type": "object",
"properties": {
"analysis_store": {
"description": "Analysis Store.",
"$ref": "#/$defs/AnalysisStoreComponent"
},
"ghidra": {
"description": "Ghidra launcher and adapter.",
"$ref": "#/$defs/GhidraComponent"
},
"java": {
"description": "Java runtime.",
"$ref": "#/$defs/JavaComponent"
},
"sandbox": {
"description": "Worker isolation backend.",
"$ref": "#/$defs/SandboxComponent"
}
},
"required": [
"ghidra",
"java",
"analysis_store",
"sandbox"
]
},
"DoctorData": { "DoctorData": {
"description": "Installation diagnosis data.", "description": "Installation diagnosis data.",
"type": "object", "type": "object",
"properties": { "properties": {
"components": { "components": {
"description": "Typed component reports keyed by stable component name.", "description": "Complete fixed component report.",
"type": "object", "$ref": "#/$defs/DoctorComponents"
"additionalProperties": {
"$ref": "#/$defs/ComponentReport"
}
}, },
"ready": { "ready": {
"description": "True only when every required component is ready.", "description": "True only when every required component is ready.",
@ -124,6 +197,81 @@
"components" "components"
] ]
}, },
"GhidraComponent": {
"description": "Diagnosed Ghidra installation.",
"type": "object",
"properties": {
"launcher": {
"description": "Configured official launcher.",
"anyOf": [
{
"$ref": "#/$defs/TaggedPath"
},
{
"type": "null"
}
]
},
"status": {
"description": "Stable readiness classification.",
"$ref": "#/$defs/ComponentStatus"
},
"version": {
"description": "Observed version when the probe ran.",
"type": [
"string",
"null"
]
}
},
"required": [
"status"
]
},
"InlineOrArtifact": {
"description": "Successful command data emitted inline or represented by a complete Artifact.",
"anyOf": [
{
"description": "Complete command-specific data.",
"$ref": "#/$defs/DoctorData"
},
{
"description": "Bounded descriptor for the complete stored success response.",
"$ref": "#/$defs/SpilledData"
}
]
},
"JavaComponent": {
"description": "Diagnosed Java installation.",
"type": "object",
"properties": {
"executable": {
"description": "Configured Java executable.",
"anyOf": [
{
"$ref": "#/$defs/TaggedPath"
},
{
"type": "null"
}
]
},
"status": {
"description": "Stable readiness classification.",
"$ref": "#/$defs/ComponentStatus"
},
"version": {
"description": "Observed feature version.",
"type": [
"string",
"null"
]
}
},
"required": [
"status"
]
},
"SandboxBackend": { "SandboxBackend": {
"description": "Sandbox backend.", "description": "Sandbox backend.",
"oneOf": [ "oneOf": [
@ -144,6 +292,29 @@
} }
] ]
}, },
"SandboxComponent": {
"description": "Diagnosed sandbox backend.",
"type": "object",
"properties": {
"backend": {
"description": "Explicit selected backend.",
"$ref": "#/$defs/SandboxBackend"
},
"status": {
"description": "Stable readiness classification.",
"$ref": "#/$defs/ComponentStatus"
},
"verification": {
"description": "Verification state.",
"$ref": "#/$defs/SandboxVerification"
}
},
"required": [
"status",
"backend",
"verification"
]
},
"SandboxProvenance": { "SandboxProvenance": {
"description": "Worker sandbox provenance.", "description": "Worker sandbox provenance.",
"type": "object", "type": "object",
@ -182,6 +353,46 @@
} }
] ]
}, },
"SpilledData": {
"description": "Public data shape returned when a complete success exceeds the inline byte budget.",
"type": "object",
"properties": {
"artifact": {
"description": "Immutable complete-success Artifact.",
"$ref": "#/$defs/ArtifactDescriptor"
},
"spilled": {
"description": "Always true for this variant.",
"type": "boolean"
}
},
"required": [
"spilled",
"artifact"
]
},
"StorageUsage": {
"description": "Filesystem usage represented without implying freed space.",
"type": "object",
"properties": {
"allocated_bytes": {
"description": "Sum of Linux allocated blocks including directories.",
"type": "integer",
"format": "uint64",
"minimum": 0
},
"logical_bytes": {
"description": "Sum of file lengths.",
"type": "integer",
"format": "uint64",
"minimum": 0
}
},
"required": [
"logical_bytes",
"allocated_bytes"
]
},
"StoreSource": { "StoreSource": {
"description": "Analysis Store resolution source.", "description": "Analysis Store resolution source.",
"oneOf": [ "oneOf": [

View file

@ -120,11 +120,6 @@
"type": "string", "type": "string",
"const": "java_incompatible" "const": "java_incompatible"
}, },
{
"description": "A later integration layer has not connected execution yet.",
"type": "string",
"const": "internal_not_implemented"
},
{ {
"description": "Rust/Java request or response validation failed.", "description": "Rust/Java request or response validation failed.",
"type": "string", "type": "string",

View file

@ -6,7 +6,7 @@
"properties": { "properties": {
"data": { "data": {
"description": "Operation-specific result.", "description": "Operation-specific result.",
"$ref": "#/$defs/FunctionsData" "$ref": "#/$defs/InlineOrArtifact"
}, },
"kind": { "kind": {
"type": "string", "type": "string",
@ -72,6 +72,41 @@
"source" "source"
] ]
}, },
"ArtifactDescriptor": {
"description": "Artifact descriptor returned instead of oversized inline data.",
"type": "object",
"properties": {
"bytes": {
"description": "Exact file bytes including trailing LF.",
"type": "integer",
"format": "uint64",
"minimum": 0
},
"contains": {
"description": "Always `complete_success_response`.",
"type": "string"
},
"media_type": {
"description": "Always `application/json`.",
"type": "string"
},
"path": {
"description": "Tagged absolute Artifact path.",
"$ref": "#/$defs/TaggedPath"
},
"sha256": {
"description": "SHA-256 of exact file bytes.",
"$ref": "#/$defs/Digest"
}
},
"required": [
"path",
"bytes",
"sha256",
"media_type",
"contains"
]
},
"Digest": { "Digest": {
"description": "A lowercase SHA-256 digest.", "description": "A lowercase SHA-256 digest.",
"type": "string" "type": "string"
@ -173,6 +208,19 @@
"items" "items"
] ]
}, },
"InlineOrArtifact": {
"description": "Successful command data emitted inline or represented by a complete Artifact.",
"anyOf": [
{
"description": "Complete command-specific data.",
"$ref": "#/$defs/FunctionsData"
},
{
"description": "Bounded descriptor for the complete stored success response.",
"$ref": "#/$defs/SpilledData"
}
]
},
"Limits": { "Limits": {
"description": "Resource policy selected for one invocation.", "description": "Resource policy selected for one invocation.",
"type": "object", "type": "object",
@ -399,6 +447,24 @@
} }
] ]
}, },
"SpilledData": {
"description": "Public data shape returned when a complete success exceeds the inline byte budget.",
"type": "object",
"properties": {
"artifact": {
"description": "Immutable complete-success Artifact.",
"$ref": "#/$defs/ArtifactDescriptor"
},
"spilled": {
"description": "Always true for this variant.",
"type": "boolean"
}
},
"required": [
"spilled",
"artifact"
]
},
"StoreSource": { "StoreSource": {
"description": "Analysis Store resolution source.", "description": "Analysis Store resolution source.",
"oneOf": [ "oneOf": [

View file

@ -6,7 +6,7 @@
"properties": { "properties": {
"data": { "data": {
"description": "Operation-specific result.", "description": "Operation-specific result.",
"$ref": "#/$defs/InspectionData" "$ref": "#/$defs/InlineOrArtifact"
}, },
"kind": { "kind": {
"type": "string", "type": "string",
@ -105,10 +105,58 @@
"disposition" "disposition"
] ]
}, },
"ArtifactDescriptor": {
"description": "Artifact descriptor returned instead of oversized inline data.",
"type": "object",
"properties": {
"bytes": {
"description": "Exact file bytes including trailing LF.",
"type": "integer",
"format": "uint64",
"minimum": 0
},
"contains": {
"description": "Always `complete_success_response`.",
"type": "string"
},
"media_type": {
"description": "Always `application/json`.",
"type": "string"
},
"path": {
"description": "Tagged absolute Artifact path.",
"$ref": "#/$defs/TaggedPath"
},
"sha256": {
"description": "SHA-256 of exact file bytes.",
"$ref": "#/$defs/Digest"
}
},
"required": [
"path",
"bytes",
"sha256",
"media_type",
"contains"
]
},
"Digest": { "Digest": {
"description": "A lowercase SHA-256 digest.", "description": "A lowercase SHA-256 digest.",
"type": "string" "type": "string"
}, },
"InlineOrArtifact": {
"description": "Successful command data emitted inline or represented by a complete Artifact.",
"anyOf": [
{
"description": "Complete command-specific data.",
"$ref": "#/$defs/InspectionData"
},
{
"description": "Bounded descriptor for the complete stored success response.",
"$ref": "#/$defs/SpilledData"
}
]
},
"InspectionData": { "InspectionData": {
"description": "Sample inspection data.", "description": "Sample inspection data.",
"type": "object", "type": "object",
@ -378,6 +426,24 @@
} }
] ]
}, },
"SpilledData": {
"description": "Public data shape returned when a complete success exceeds the inline byte budget.",
"type": "object",
"properties": {
"artifact": {
"description": "Immutable complete-success Artifact.",
"$ref": "#/$defs/ArtifactDescriptor"
},
"spilled": {
"description": "Always true for this variant.",
"type": "boolean"
}
},
"required": [
"spilled",
"artifact"
]
},
"StoreSource": { "StoreSource": {
"description": "Analysis Store resolution source.", "description": "Analysis Store resolution source.",
"oneOf": [ "oneOf": [

321
src/commands/doctor.rs Normal file
View file

@ -0,0 +1,321 @@
//! Complete safe installation diagnosis.
use std::{
ffi::OsString,
fs,
path::{Path, PathBuf},
process::Command as ProcessCommand,
};
use serde_json::json;
use crate::{
AppError, ErrorCode,
cli::{Cli, SandboxMode},
domain::{InvocationId, Limits, PositiveU64},
operation::{
ADAPTER_PROTOCOL_VERSION, AnalysisStoreComponent, ComponentStatus, DoctorComponents,
DoctorData, GhidraComponent, JavaComponent, SandboxComponent, SuccessEnvelope,
ToolProvenance,
},
output::RenderedSuccess,
process::{Cancellation, Invocation, NativePhaseTimeouts, run},
protocol::{
AdapterDoctorData, AdapterRequest, AdapterResult, InvocationFiles, RequestArguments,
ValidatedAdapterData, validate_operation_data,
},
runtime::{RuntimeConfig, private_jvm_options},
sandbox::{BubblewrapConfig, WorkerCommand},
store::{AnalysisStore, ResolvedStore, create_private_directory},
};
use super::{query, zero_digest};
pub(super) fn execute(
cli: &Cli,
store: &AnalysisStore,
resolved: &ResolvedStore,
cancellation: &(impl Cancellation + ?Sized),
) -> Result<RenderedSuccess, AppError> {
let runtime = RuntimeConfig::from_process();
let expected_ghidra = runtime.ghidra_version.clone();
let launcher = runtime.analyze_headless.as_ref();
let adapter_ready = runtime.adapter_source().is_some_and(|path| path.is_file());
let engine_probe = probe_engine(cli, store, &runtime, cancellation);
let ghidra_ready = engine_probe.as_ref().is_ok_and(|probe| {
probe.ready
&& probe.ghidra_version == expected_ghidra
&& probe.adapter_protocol_version == ADAPTER_PROTOCOL_VERSION
});
let ghidra_status = if launcher.is_none_or(|path| !path.is_file()) || !adapter_ready {
ComponentStatus::Missing
} else if ghidra_ready {
ComponentStatus::Ready
} else if engine_probe.is_ok() {
ComponentStatus::Incompatible
} else {
ComponentStatus::Invalid
};
let ghidra = GhidraComponent {
status: ghidra_status,
version: engine_probe
.as_ref()
.ok()
.map(|probe| probe.ghidra_version.clone()),
launcher: launcher.map(|path| crate::domain::TaggedPath::from_path(path)),
};
let java = runtime.java_executable();
let java_ready = java.as_ref().is_some_and(|path| probe_java(path));
let java = JavaComponent {
status: if java.as_ref().is_none_or(|path| !path.is_file()) {
ComponentStatus::Missing
} else if java_ready {
ComponentStatus::Ready
} else {
ComponentStatus::Incompatible
},
version: java_ready.then(|| "21".to_owned()),
executable: java
.as_ref()
.map(|path| crate::domain::TaggedPath::from_path(path)),
};
let usage = store.usage().map_err(super::store_error)?;
let probe = store.root().join("staging").join("doctor-write-probe");
let store_status = match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe)
{
Ok(file) => {
drop(file);
let _ = fs::remove_file(&probe);
ComponentStatus::Ready
}
Err(_) => ComponentStatus::Unwritable,
};
let analysis_store = AnalysisStoreComponent {
status: store_status,
path: resolved.tagged_path(),
source: resolved.source(),
usage,
};
let sandbox = query::sandbox_provenance(cli.sandbox);
let sandbox_ready = cli.sandbox != crate::cli::SandboxMode::Bubblewrap
|| runtime.bubblewrap.is_some_and(|path| path.is_file());
let sandbox_component = SandboxComponent {
status: if sandbox_ready {
ComponentStatus::Ready
} else {
ComponentStatus::Missing
},
backend: sandbox.backend,
verification: sandbox.verification,
};
let components = DoctorComponents {
ghidra,
java,
analysis_store,
sandbox: sandbox_component,
};
let ready = components.ghidra.status == ComponentStatus::Ready
&& components.java.status == ComponentStatus::Ready
&& components.analysis_store.status == ComponentStatus::Ready
&& components.sandbox.status == ComponentStatus::Ready;
let data = DoctorData { ready, components };
if !ready {
let mut error = AppError::new(
ErrorCode::DoctorFailed,
"one or more required components are not ready",
false,
)
.with_detail(
"report",
serde_json::to_value(&data).unwrap_or_else(|_| json!({"ready": false})),
);
if let Err(failure) = &engine_probe {
error = error.with_detail(
"ghidra_probe_error",
serde_json::Value::String(failure.message.clone()),
);
if let Some(diagnostic) = &failure.diagnostic {
error = error.with_detail("diagnostic_log", diagnostic.clone());
}
}
return Err(error);
}
let provenance = ToolProvenance {
ghidr_version: env!("CARGO_PKG_VERSION").to_owned(),
adapter_protocol_version: ADAPTER_PROTOCOL_VERSION,
analysis_store: resolved.provenance(),
sandbox,
};
let anchor = zero_digest()?;
query::render_or_spill(
cli,
store,
&anchor,
&anchor,
SuccessEnvelope::new(
"doctor",
provenance,
data,
query::policy_warnings(cli.sandbox),
),
format!("Ghidra {expected_ghidra}: ready\nJDK 21: ready\nAnalysis store: writable"),
)
}
fn probe_java(java: &Path) -> bool {
ProcessCommand::new(java)
.arg("-version")
.env_clear()
.output()
.is_ok_and(|output| {
output.status.success() && String::from_utf8_lossy(&output.stderr).contains("\"21")
})
}
fn probe_engine(
cli: &Cli,
store: &AnalysisStore,
runtime: &RuntimeConfig,
cancellation: &(impl Cancellation + ?Sized),
) -> Result<AdapterDoctorData, ProbeFailure> {
let launcher = RuntimeConfig::require(&runtime.analyze_headless, "GHIDR_ANALYZE_HEADLESS")
.map_err(probe_failure)?;
let adapter = RuntimeConfig::require(&runtime.adapter_path, "GHIDR_ADAPTER_PATH")
.map_err(probe_failure)?;
let invocation_id = InvocationId::generate()
.map_err(|error| probe_failure(format!("invocation ID failed: {error}")))?;
let directory = store
.root()
.join("staging")
.join(format!("doctor-{invocation_id}"));
let invocation_directory = directory.join("invocation");
let home = directory.join("home");
let temporary = directory.join("tmp");
for path in [&directory, &invocation_directory, &home, &temporary] {
create_private_directory(path).map_err(probe_failure)?;
}
let jvm_options = private_jvm_options(&home, &temporary).map_err(probe_failure)?;
let result = (|| {
let files = InvocationFiles::new(invocation_directory.clone()).map_err(probe_failure)?;
let watchdog = PositiveU64::try_from(120).map_err(probe_failure)?;
let request = AdapterRequest {
protocol_version: ADAPTER_PROTOCOL_VERSION,
invocation_id,
operation: crate::operation::Operation::Doctor,
staged_sample: None,
analysis_path: None,
limits: Limits {
max_heap_mib: cli.max_heap_mib,
max_cpu: cli.max_cpu,
analysis_timeout_seconds: cli.analysis_timeout_seconds,
decompile_timeout_seconds: None,
child_watchdog_seconds: watchdog,
max_sample_bytes: cli.max_sample_bytes,
max_inline_bytes: cli.max_inline_bytes,
},
arguments: RequestArguments::Doctor,
};
let worker_arguments = vec![
directory.as_os_str().to_owned(),
OsString::from("doctor-probe"),
OsString::from("-noanalysis"),
OsString::from("-scriptPath"),
adapter.as_os_str().to_owned(),
OsString::from("-postScript"),
OsString::from("GhidrAdapter.java"),
files.request_path().into_os_string(),
files.response_path().into_os_string(),
OsString::from("-deleteProject"),
];
// Adapter arguments must follow the post-script and therefore precede no
// headless options. Keep deleteProject before postScript.
let mut worker_arguments = worker_arguments;
let delete = worker_arguments
.pop()
.ok_or_else(|| probe_failure("missing delete option"))?;
worker_arguments.insert(3, delete);
let command = WorkerCommand::for_policy(
cli.sandbox,
launcher.clone(),
worker_arguments.clone(),
if cli.sandbox == SandboxMode::Bubblewrap {
Some(BubblewrapConfig {
bubblewrap: RuntimeConfig::require(&runtime.bubblewrap, "GHIDR_BUBBLEWRAP")
.map_err(probe_failure)?,
worker_program: launcher,
worker_arguments,
readonly_paths: vec![PathBuf::from("/nix/store")],
staged_sample: None,
writable_paths: vec![directory.clone()],
private_home: home.clone(),
private_tmp: temporary.clone(),
})
} else {
None
},
)
.map_err(probe_failure)?
.with_environment(
"GHIDRA_HEADLESS_MAXMEM",
format!("{}M", cli.max_heap_mib.get()),
)
.with_environment("HOME", home.into_os_string())
.with_environment("TMPDIR", temporary.clone().into_os_string())
.with_environment("JDK_JAVA_OPTIONS", jvm_options)
.with_environment("LC_ALL", "C.UTF-8");
let invocation = Invocation::new(
&request,
&files,
&command,
NativePhaseTimeouts {
analysis_seconds: None,
decompile_seconds: None,
},
)
.map_err(probe_failure)?;
let completed = match run(invocation, cancellation) {
Ok(completed) => completed,
Err(failure) => {
let message = failure.to_string();
let diagnostic =
query::publish_diagnostic(store.root(), None, *failure.diagnostics).ok();
return Err(ProbeFailure {
message,
diagnostic,
});
}
};
let AdapterResult::Success { data } = completed.response.result else {
let diagnostic =
query::publish_diagnostic(store.root(), None, completed.diagnostics).ok();
return Err(ProbeFailure {
message: "doctor adapter returned an error".to_owned(),
diagnostic,
});
};
match validate_operation_data(crate::operation::Operation::Doctor, data)
.map_err(probe_failure)?
{
ValidatedAdapterData::Doctor(data) => Ok(data),
_ => Err(probe_failure("doctor adapter returned the wrong payload")),
}
})();
let _ = fs::remove_dir_all(&directory);
result
}
#[derive(Clone, Debug)]
struct ProbeFailure {
message: String,
diagnostic: Option<serde_json::Value>,
}
fn probe_failure(error: impl std::fmt::Display) -> ProbeFailure {
ProbeFailure {
message: error.to_string(),
diagnostic: None,
}
}

155
src/commands/mod.rs Normal file
View file

@ -0,0 +1,155 @@
//! Synchronous public command execution and trusted orchestration.
mod doctor;
mod query;
use std::{
io::{self, IsTerminal as _, Write as _},
path::Path,
str::FromStr as _,
};
use serde_json::{Value, json};
use crate::{
AppError, ErrorCode,
cli::{Cli, Command, OutputFormat},
operation::{ADAPTER_PROTOCOL_VERSION, CleanupTarget, SuccessEnvelope, ToolProvenance},
output::{Executor, RenderedSuccess},
process::{Cancellation, NeverCancel},
store::{AnalysisStore, StoreEnvironment, execute_cleanup, resolve_store},
};
/// Production synchronous executor used by the installed binary.
#[derive(Clone, Debug)]
pub struct ApplicationExecutor<C = NeverCancel> {
cancellation: C,
}
impl Default for ApplicationExecutor<NeverCancel> {
fn default() -> Self {
Self {
cancellation: NeverCancel,
}
}
}
impl<C> ApplicationExecutor<C> {
/// Creates an executor wired to one frontend cancellation source.
pub const fn new(cancellation: C) -> Self {
Self { cancellation }
}
}
impl<C: Cancellation> Executor for ApplicationExecutor<C> {
fn execute(&self, cli: &Cli) -> Result<RenderedSuccess, AppError> {
let resolved = resolve_store(cli.store.as_deref(), &StoreEnvironment::from_process())
.map_err(store_error)?;
let store = AnalysisStore::initialize(resolved.clone()).map_err(store_error)?;
match &cli.command {
Command::Doctor => doctor::execute(cli, &store, &resolved, &self.cancellation),
Command::Inspect(arguments) => {
query::execute(cli, &store, &arguments.sample, &self.cancellation)
}
Command::Functions(arguments) => {
query::execute(cli, &store, &arguments.sample, &self.cancellation)
}
Command::Decompile(arguments) => {
query::execute(cli, &store, &arguments.sample, &self.cancellation)
}
Command::Clean(arguments) => {
let mut arguments = arguments.clone();
if arguments.all
&& !arguments.dry_run
&& !arguments.yes
&& cli.format == OutputFormat::Human
&& io::stdin().is_terminal()
{
let mut stderr = io::stderr().lock();
stderr
.write_all(b"Remove all ghidr stored data? [y/N] ")
.map_err(io_error)?;
stderr.flush().map_err(io_error)?;
let mut answer = String::new();
io::stdin().read_line(&mut answer).map_err(io_error)?;
arguments.yes = matches!(answer.trim(), "y" | "Y" | "yes" | "YES");
}
let data = execute_cleanup(&store, &arguments, cli.max_sample_bytes.get())?;
let provenance = ToolProvenance {
ghidr_version: env!("CARGO_PKG_VERSION").to_owned(),
adapter_protocol_version: ADAPTER_PROTOCOL_VERSION,
analysis_store: resolved.provenance(),
sandbox: query::sandbox_provenance(cli.sandbox),
};
let sample = match &data.target {
CleanupTarget::Sample { sha256, .. } | CleanupTarget::Digest { sha256 } => {
sha256.clone()
}
CleanupTarget::All => zero_digest()?,
};
let profile = data
.matched
.analysis_profile_sha256
.first()
.cloned()
.map_or_else(zero_digest, Ok)?;
query::render_or_spill(
cli,
&store,
&sample,
&profile,
SuccessEnvelope::new(
"cleanup",
provenance,
data,
query::policy_warnings(cli.sandbox),
),
"Cleanup completed".to_owned(),
)
}
}
}
}
fn io_error(error: io::Error) -> AppError {
AppError::new(
ErrorCode::Internal,
format!("interactive confirmation failed: {error}"),
false,
)
}
pub(super) fn zero_digest() -> Result<crate::domain::Digest, AppError> {
crate::domain::Digest::from_str(&"0".repeat(64))
.map_err(|error| AppError::new(ErrorCode::Internal, error.to_string(), false))
}
pub(super) fn store_error(error: crate::store::StoreError) -> AppError {
use crate::store::StoreErrorKind;
let (code, retryable) = match error.kind() {
StoreErrorKind::InvalidStorePath | StoreErrorKind::UnsafeStorePath => {
(ErrorCode::InvalidStorePath, false)
}
StoreErrorKind::CorruptStore | StoreErrorKind::ImmutableConflict => {
(ErrorCode::CorruptAnalysis, false)
}
StoreErrorKind::SampleNotFound => (ErrorCode::SampleNotFound, false),
StoreErrorKind::SampleUnreadable => (ErrorCode::SampleUnreadable, false),
StoreErrorKind::InvalidSampleType => (ErrorCode::InvalidSampleType, false),
StoreErrorKind::SampleTooLarge => (ErrorCode::SampleTooLarge, false),
StoreErrorKind::SampleChanged => (ErrorCode::SampleChanged, true),
StoreErrorKind::InsufficientStoreSpace => (ErrorCode::InsufficientStoreSpace, true),
StoreErrorKind::AnalysisBusy => (ErrorCode::AnalysisBusy, true),
StoreErrorKind::CleanupIncomplete => (ErrorCode::CleanupIncomplete, true),
StoreErrorKind::Io => (ErrorCode::Internal, false),
};
let mut app = AppError::new(code, error.to_string(), retryable);
if let Some(path) = error.path() {
app = app.with_detail("path", tagged_path_value(path));
}
app
}
pub(super) fn tagged_path_value(path: &Path) -> Value {
serde_json::to_value(crate::domain::TaggedPath::from_path(path)).unwrap_or_else(|_| json!({}))
}

908
src/commands/query.rs Normal file
View file

@ -0,0 +1,908 @@
//! Sample snapshot, immutable Analysis reuse/build, worker invocation, and spill.
use std::{
collections::BTreeMap,
ffi::OsString,
fs,
path::{Path, PathBuf},
str::FromStr as _,
time::{SystemTime, UNIX_EPOCH},
};
use serde::Serialize;
use serde_json::{Value, json};
use sha2::{Digest as _, Sha256};
use crate::{
AppError, ErrorCode,
cli::{Cli, Command, SandboxMode},
domain::{Digest, InvocationId, Limits, PositiveU64},
operation::{
ADAPTER_PROTOCOL_VERSION, AnalysisDisposition, AnalysisProfile, AnalysisSummary,
InspectionData, ProfileTarget, QueryProvenance, SampleSummary, SandboxBackend,
SandboxProvenance, SandboxVerification, SuccessEnvelope, TargetVerification, Warning,
},
output::RenderedSuccess,
process::{
Cancellation, Invocation, InvocationFailureKind, NativePhaseTimeouts,
diagnostics::DiagnosticBundle, run,
},
protocol::{
AdapterContext, AdapterQueryData, AdapterRequest, AdapterResult, InvocationFiles,
RequestArguments, ValidatedAdapterData, WorkerPath, validate_operation_data,
},
runtime::{RuntimeConfig, private_jvm_options},
sandbox::{BubblewrapConfig, WorkerCommand},
store::{
AnalysisLock, AnalysisManifest, AnalysisProfileManifest, AnalysisProject, AnalysisStore,
CreatedBy, ManifestSample, RebuildDecision, SnapshotOptions, analysis_profile_digest,
copy_private_tree, create_private_directory, inventory_project, publish_artifact,
snapshot_sample, validate_analysis, write_analysis_manifest,
},
};
use super::store_error;
pub(super) fn execute(
cli: &Cli,
store: &AnalysisStore,
sample: &Path,
cancellation: &(impl Cancellation + ?Sized),
) -> Result<RenderedSuccess, AppError> {
let snapshot = snapshot_sample(
store,
sample,
SnapshotOptions::new(cli.max_sample_bytes.get()).map_err(store_error)?,
)
.map_err(store_error)?;
let mut warnings = policy_warnings(cli.sandbox);
let cached = find_cached(store, snapshot.digest(), cli, &mut warnings)?;
let building = cached.is_none();
let (context, query, profile_digest, disposition, sandbox) = if let Some(cached) = cached {
let lock = AnalysisLock::acquire_shared(store, snapshot.digest(), &cached.profile)
.map_err(store_error)?;
let manifest = validate_analysis(&cached.directory, snapshot.digest(), &cached.profile)
.map_err(store_error)?;
let worker_result = invoke(
cli,
&snapshot,
&cached.directory,
false,
Some(&manifest.project.files),
cancellation,
);
validate_analysis(&cached.directory, snapshot.digest(), &cached.profile).map_err(
|error| {
AppError::new(
ErrorCode::CorruptAnalysis,
format!("cached Analysis changed during read-only Query: {error}"),
false,
)
},
)?;
let result = worker_result?;
drop(lock);
let profile = profile_from_context(cli, &result.context);
let recomputed = analysis_profile_digest(&profile).map_err(store_error)?;
if recomputed != cached.profile {
return Err(AppError::new(
ErrorCode::CorruptAnalysis,
"cached Analysis Profile no longer matches engine context",
false,
));
}
(
result.context,
result.query,
cached.profile,
AnalysisDisposition::Reused,
result.sandbox,
)
} else {
let provisional = provisional_profile(cli)?;
let build_lock =
AnalysisLock::acquire(store, snapshot.digest(), &provisional).map_err(store_error)?;
let staged_analysis = snapshot.staging_directory().join("complete-analysis");
let project = staged_analysis.join("project");
create_private(&project)?;
let result = invoke(cli, &snapshot, &staged_analysis, true, None, cancellation)?;
let profile = profile_from_context(cli, &result.context);
let profile_digest = analysis_profile_digest(&profile).map_err(store_error)?;
let actual_lock = AnalysisLock::acquire(store, snapshot.digest(), &profile_digest)
.map_err(store_error)?;
let manifest = manifest(
&snapshot,
&result.context,
&profile,
&profile_digest,
&project,
&result.invocation_id,
)?;
write_analysis_manifest(&staged_analysis, &manifest).map_err(store_error)?;
let active = store
.promote_analysis(
&staged_analysis,
snapshot.digest(),
&profile_digest,
&actual_lock,
)
.map_err(store_error)?;
validate_analysis(&active, snapshot.digest(), &profile_digest).map_err(store_error)?;
drop(actual_lock);
drop(build_lock);
let disposition = if warnings
.iter()
.any(|warning| warning.code == "corrupt_analysis_rebuilt")
{
AnalysisDisposition::Rebuilt
} else {
AnalysisDisposition::Created
};
(
result.context,
result.query,
profile_digest,
disposition,
result.sandbox,
)
};
let verification = target_verification(&context);
if verification == TargetVerification::Unverified {
warnings.push(Warning {
code: "unverified_target".to_owned(),
message: "target is outside the verified integration-test matrix".to_owned(),
details: BTreeMap::from([(
"verified_targets".to_owned(),
json!(["elf-x86_64-little-endian", "pe32plus-x86_64"]),
)]),
});
}
let provenance = QueryProvenance {
ghidr_version: env!("CARGO_PKG_VERSION").to_owned(),
adapter_protocol_version: ADAPTER_PROTOCOL_VERSION,
ghidra_version: context.ghidra_version.clone(),
java_version: context.java_version.to_string(),
sample_sha256: snapshot.digest().clone(),
source_path: snapshot.source_path().clone(),
analysis_profile_sha256: profile_digest.clone(),
target: context.target.provenance(verification),
analysis_store: crate::operation::AnalysisStoreProvenance {
path: crate::domain::TaggedPath::from_path(store.root()),
source: store.source(),
},
sandbox,
limits: selected_limits(cli, building)?,
};
let (kind, data, human) = public_result(
cli,
snapshot.digest(),
snapshot.size_bytes(),
disposition,
query,
)?;
let envelope = SuccessEnvelope::new(kind, provenance, data, warnings);
render_or_spill(
cli,
store,
snapshot.digest(),
&profile_digest,
envelope,
human,
)
}
struct CachedAnalysis {
profile: Digest,
directory: PathBuf,
}
fn find_cached(
store: &AnalysisStore,
sample: &Digest,
cli: &Cli,
warnings: &mut Vec<Warning>,
) -> Result<Option<CachedAnalysis>, AppError> {
let root = store.root().join("analyses").join(sample.as_str());
let mut entries = match fs::read_dir(&root) {
Ok(entries) => entries
.collect::<Result<Vec<_>, _>>()
.map_err(|error| AppError::new(ErrorCode::Internal, error.to_string(), false))?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(AppError::new(ErrorCode::Internal, error.to_string(), false)),
};
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
let Ok(profile) = Digest::from_str(&name) else {
continue;
};
match validate_analysis(&entry.path(), sample, &profile) {
Ok(manifest)
if manifest.analysis_profile.ghidra_version == expected_ghidra()
&& manifest.analysis_profile.adapter_protocol_version
== ADAPTER_PROTOCOL_VERSION
&& manifest.analysis_profile.target.get("max_cpu")
== Some(&Value::from(cli.max_cpu.get())) =>
{
return Ok(Some(CachedAnalysis {
profile,
directory: entry.path(),
}));
}
Ok(_) => {}
Err(_) => {
let lock = AnalysisLock::acquire(store, sample, &profile).map_err(store_error)?;
let outcome = store
.quarantine_analysis(sample, &profile, &lock, false)
.map_err(store_error)?;
if outcome.decision == RebuildDecision::RebuildOnce {
warnings.push(Warning {
code: "corrupt_analysis_rebuilt".to_owned(),
message: "corrupt cached Analysis was quarantined and rebuilt".to_owned(),
details: BTreeMap::new(),
});
}
}
}
}
Ok(None)
}
struct WorkerResult {
context: AdapterContext,
query: Value,
sandbox: SandboxProvenance,
invocation_id: InvocationId,
}
fn invoke(
cli: &Cli,
snapshot: &crate::store::SampleSnapshot,
analysis: &Path,
new_analysis: bool,
expected_project: Option<&[crate::store::InventoryFile]>,
cancellation: &(impl Cancellation + ?Sized),
) -> Result<WorkerResult, AppError> {
let runtime = RuntimeConfig::from_process();
let invocation_id = InvocationId::generate().map_err(|error| {
AppError::new(
ErrorCode::Internal,
format!("invocation ID generation failed: {error}"),
false,
)
})?;
let result_invocation_id = invocation_id.clone();
let invocation_directory = snapshot
.staging_directory()
.join(format!("invocation-{invocation_id}"));
create_private(&invocation_directory)?;
let home = invocation_directory.join("home");
let temporary = invocation_directory.join("tmp");
create_private(&home)?;
create_private(&temporary)?;
let jvm_options = private_jvm_options(&home, &temporary)?;
let worker_analysis = if let Some(expected) = expected_project {
let disposable = invocation_directory.join("query-analysis");
let disposable_project = disposable.join("project");
create_private(&disposable)?;
copy_private_tree(&analysis.join("project"), &disposable_project).map_err(store_error)?;
let copied = inventory_project(&disposable_project).map_err(store_error)?;
if copied != expected {
return Err(AppError::new(
ErrorCode::CorruptAnalysis,
"cached Analysis changed while creating the read-only Query snapshot",
false,
));
}
disposable
} else {
analysis.to_path_buf()
};
let files = InvocationFiles::new(invocation_directory.clone()).map_err(protocol_error)?;
let operation = command_operation(&cli.command);
let arguments = request_arguments(&cli.command)?;
let limits = selected_limits(cli, new_analysis)?;
let request = AdapterRequest {
protocol_version: ADAPTER_PROTOCOL_VERSION,
invocation_id,
operation,
staged_sample: Some(
WorkerPath::new(snapshot.path().to_path_buf()).map_err(protocol_error)?,
),
analysis_path: Some(WorkerPath::new(worker_analysis.clone()).map_err(protocol_error)?),
limits,
arguments,
};
let launcher = RuntimeConfig::require(&runtime.analyze_headless, "GHIDR_ANALYZE_HEADLESS")?;
let adapter = RuntimeConfig::require(&runtime.adapter_path, "GHIDR_ADAPTER_PATH")?;
let project = worker_analysis.join("project");
let mut worker_arguments = vec![project.as_os_str().to_owned(), OsString::from("analysis")];
if new_analysis {
worker_arguments.extend([
OsString::from("-import"),
snapshot.path().as_os_str().to_owned(),
OsString::from("-analysisTimeoutPerFile"),
OsString::from(cli.analysis_timeout_seconds.get().to_string()),
OsString::from("-max-cpu"),
OsString::from(cli.max_cpu.get().to_string()),
]);
} else {
worker_arguments.extend([
OsString::from("-process"),
OsString::from("sample"),
OsString::from("-readOnly"),
OsString::from("-noanalysis"),
]);
}
worker_arguments.extend([
OsString::from("-scriptPath"),
adapter.as_os_str().to_owned(),
OsString::from("-postScript"),
OsString::from("GhidrAdapter.java"),
files.request_path().into_os_string(),
files.response_path().into_os_string(),
]);
let direct = WorkerCommand::for_policy(
cli.sandbox,
launcher.clone(),
worker_arguments.clone(),
if cli.sandbox == SandboxMode::Bubblewrap {
Some(BubblewrapConfig {
bubblewrap: RuntimeConfig::require(&runtime.bubblewrap, "GHIDR_BUBBLEWRAP")?,
worker_program: launcher.clone(),
worker_arguments,
readonly_paths: vec![PathBuf::from("/nix/store")],
staged_sample: Some(snapshot.path().to_path_buf()),
writable_paths: vec![invocation_directory, project],
private_home: home.clone(),
private_tmp: temporary.clone(),
})
} else {
None
},
)
.map_err(|error| AppError::new(ErrorCode::Internal, error.to_string(), false))?
.with_environment(
"GHIDRA_HEADLESS_MAXMEM",
format!("{}M", cli.max_heap_mib.get()),
)
.with_environment("HOME", home.into_os_string())
.with_environment("TMPDIR", temporary.clone().into_os_string())
.with_environment("JDK_JAVA_OPTIONS", jvm_options)
.with_environment("LC_ALL", "C.UTF-8");
let phases = NativePhaseTimeouts {
analysis_seconds: new_analysis.then_some(cli.analysis_timeout_seconds),
decompile_seconds: matches!(cli.command, Command::Decompile(_))
.then_some(cli.decompile_timeout_seconds),
};
let invocation = Invocation::new(&request, &files, &direct, phases)
.map_err(|error| AppError::new(ErrorCode::InvalidArguments, error.to_string(), false))?;
let completed = match run(invocation, cancellation) {
Ok(completed) => completed,
Err(failure) => {
let error = match failure.kind {
InvocationFailureKind::Spawn => {
AppError::new(ErrorCode::GhidraMissing, failure.to_string(), false)
}
InvocationFailureKind::ChildExit
if matches!(cli.command, Command::Decompile(_)) =>
{
AppError::new(ErrorCode::DecompilationFailed, failure.to_string(), false)
}
InvocationFailureKind::ChildExit => {
AppError::new(ErrorCode::AnalysisFailed, failure.to_string(), false)
}
_ => failure.app_error(),
};
return Err(with_diagnostic(
error,
publish_diagnostic(
store_for_snapshot(snapshot),
Some(snapshot.digest()),
*failure.diagnostics,
),
));
}
};
match completed.response.result {
AdapterResult::Error {
code,
message,
details,
} => {
let error = adapter_error(&code, message, details);
Err(with_diagnostic(
error,
publish_diagnostic(
store_for_snapshot(snapshot),
Some(snapshot.digest()),
completed.diagnostics,
),
))
}
AdapterResult::Success { data } => {
let validated = validate_operation_data(operation, data).map_err(protocol_error)?;
let (context, query) = match validated {
ValidatedAdapterData::Inspect(value) => query_value(value)?,
ValidatedAdapterData::Functions(value) => query_value(value)?,
ValidatedAdapterData::Decompile(value) => query_value(value)?,
_ => {
return Err(AppError::new(
ErrorCode::ProtocolViolation,
"worker returned the wrong operation payload",
false,
));
}
};
Ok(WorkerResult {
context,
query,
sandbox: direct.provenance,
invocation_id: result_invocation_id,
})
}
}
}
fn store_for_snapshot(snapshot: &crate::store::SampleSnapshot) -> &Path {
snapshot
.staging_directory()
.parent()
.and_then(Path::parent)
.unwrap_or_else(|| snapshot.staging_directory())
}
pub(super) fn publish_diagnostic(
store_root: &Path,
sample: Option<&Digest>,
bundle: DiagnosticBundle,
) -> Result<Value, String> {
let identifier = bundle.invocation_id().to_string();
let temporary = store_root
.join("staging")
.join(format!("diagnostic-{identifier}"));
let scope = sample.map_or("global", Digest::as_str);
let destination_parent = store_root.join("diagnostics").join(scope);
let destination = destination_parent.join(&identifier);
create_private(&destination_parent).map_err(|error| error.to_string())?;
let private_manifest = bundle
.write_private_directory(&temporary)
.map_err(|error| error.to_string())?;
let result = (|| {
fs::rename(&temporary, &destination).map_err(|error| error.to_string())?;
fs::File::open(&destination_parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| error.to_string())?;
let manifest = destination.join(
private_manifest
.file_name()
.ok_or_else(|| "diagnostic manifest has no filename".to_owned())?,
);
let bytes = fs::read(&manifest).map_err(|error| error.to_string())?;
Ok(json!({
"path": crate::domain::TaggedPath::from_path(&manifest),
"bytes": bytes.len(),
"sha256": hex::encode(Sha256::digest(&bytes)),
"sensitive": true,
"media_type": "application/json"
}))
})();
if result.is_err() {
let _ = fs::remove_dir_all(&temporary);
}
result
}
fn with_diagnostic(error: AppError, diagnostic: Result<Value, String>) -> AppError {
match diagnostic {
Ok(descriptor) => error.with_detail("diagnostic_log", descriptor),
Err(publication_error) => error.with_detail(
"diagnostic_publication_error",
Value::String(publication_error),
),
}
}
fn query_value<T: Serialize>(
value: AdapterQueryData<T>,
) -> Result<(AdapterContext, Value), AppError> {
let query = serde_json::to_value(value.query)
.map_err(|error| AppError::new(ErrorCode::ProtocolViolation, error.to_string(), false))?;
Ok((value.context, query))
}
fn public_result(
cli: &Cli,
sample: &Digest,
size: u64,
disposition: AnalysisDisposition,
query: Value,
) -> Result<(&'static str, Value, String), AppError> {
match &cli.command {
Command::Inspect(_) => {
let program = serde_json::from_value(query).map_err(|error| {
AppError::new(ErrorCode::ProtocolViolation, error.to_string(), false)
})?;
let data = InspectionData {
sample: SampleSummary {
sha256: sample.clone(),
size_bytes: size,
},
program,
analysis: AnalysisSummary { disposition },
};
let functions = data.program.function_count;
Ok((
"inspection",
serde_json::to_value(data).unwrap_or(Value::Null),
format!("Sample: {}\nFunctions: {functions}", sample.as_str()),
))
}
Command::Functions(_) => {
let data: crate::operation::FunctionsData =
serde_json::from_value(query).map_err(|error| {
AppError::new(ErrorCode::ProtocolViolation, error.to_string(), false)
})?;
let mut human = String::from("ADDRESS SIZE NAME\n");
for item in &data.items {
human.push_str(&format!(
"{}:{} {} {}\n",
item.entry.space,
item.entry.offset,
item.body_address_count,
item.qualified_name
));
}
if data.page.has_more {
human.push_str("more results available\n");
}
Ok((
"functions",
serde_json::to_value(data).unwrap_or(Value::Null),
human,
))
}
Command::Decompile(_) => {
let data: crate::operation::DecompilationData =
serde_json::from_value(query).map_err(|error| {
AppError::new(ErrorCode::ProtocolViolation, error.to_string(), false)
})?;
let human = data.decompilation.text.clone();
Ok((
"decompilation",
serde_json::to_value(data).unwrap_or(Value::Null),
human,
))
}
_ => Err(AppError::new(
ErrorCode::Internal,
"non-Query command entered Query executor",
false,
)),
}
}
pub(super) fn render_or_spill<P: Serialize, D: Serialize>(
cli: &Cli,
store: &AnalysisStore,
sample: &Digest,
profile: &Digest,
envelope: SuccessEnvelope<P, D>,
human: String,
) -> Result<RenderedSuccess, AppError> {
let complete = serde_json::to_value(&envelope)
.map_err(|error| AppError::new(ErrorCode::Internal, error.to_string(), false))?;
let mut exact = serde_json::to_vec(&complete)
.map_err(|error| AppError::new(ErrorCode::Internal, error.to_string(), false))?;
exact.push(b'\n');
if exact.len() <= usize::try_from(cli.max_inline_bytes.get()).unwrap_or(usize::MAX) {
return Ok(RenderedSuccess {
json: complete,
human,
});
}
let artifact = publish_artifact(store, sample, profile, &exact).map_err(store_error)?;
let descriptor = json!({
"schema_version": complete["schema_version"], "kind": complete["kind"],
"provenance": complete["provenance"],
"data": {"spilled": true, "artifact": artifact.descriptor()},
"warnings": complete["warnings"]
});
let required = serde_json::to_vec(&descriptor)
.map(|mut bytes| {
bytes.push(b'\n');
bytes.len()
})
.unwrap_or(usize::MAX);
if required > usize::try_from(cli.max_inline_bytes.get()).unwrap_or(usize::MAX) {
let _ = fs::remove_file(artifact.path());
return Err(AppError::new(
ErrorCode::InlineBudgetTooSmall,
"Artifact descriptor exceeds the selected inline budget",
false,
)
.with_detail("configured_bytes", Value::from(cli.max_inline_bytes.get()))
.with_detail("required_bytes", Value::from(required)));
}
Ok(RenderedSuccess {
json: descriptor,
human: format!(
"Result stored as Artifact: {} ({} bytes, {})",
artifact.path().display(),
artifact.bytes(),
artifact.digest()
),
})
}
fn profile_from_context(cli: &Cli, context: &AdapterContext) -> AnalysisProfile {
// Ghidra 12.1.2 does not report this analyzer immediately after a new
// analysis and does not run it, but registers it as enabled when the same
// project is reopened with -noanalysis. It is lifecycle bookkeeping rather
// than an option that defined the completed Analysis. Keep every option
// Ghidra reported for the actual analysis byte-for-byte.
let analyzer_options = context
.analyzer_options
.iter()
.filter(|option| option.name != "External Symbol Resolver")
.cloned()
.collect();
AnalysisProfile {
profile_version: 1,
ghidra_version: context.ghidra_version.clone(),
java_version: context.java_version.to_string(),
analysis_adapter_version: ADAPTER_PROTOCOL_VERSION,
target: ProfileTarget {
loader: context.target.loader.clone(),
processor_language: context.target.processor_language.clone(),
compiler_specification: context.target.compiler_specification.clone(),
},
loader_options: context.loader_options.clone(),
analyzer_options,
max_cpu: cli.max_cpu,
}
}
fn provisional_profile(cli: &Cli) -> Result<Digest, AppError> {
let profile = AnalysisProfile {
profile_version: 1,
ghidra_version: expected_ghidra(),
java_version: "21".to_owned(),
analysis_adapter_version: ADAPTER_PROTOCOL_VERSION,
target: ProfileTarget {
loader: "pending".to_owned(),
processor_language: "pending".to_owned(),
compiler_specification: "pending".to_owned(),
},
loader_options: Vec::new(),
analyzer_options: Vec::new(),
max_cpu: cli.max_cpu,
};
analysis_profile_digest(&profile).map_err(store_error)
}
fn manifest(
snapshot: &crate::store::SampleSnapshot,
context: &AdapterContext,
profile: &AnalysisProfile,
digest: &Digest,
project: &Path,
invocation_id: &InvocationId,
) -> Result<AnalysisManifest, AppError> {
let analyzer =
digest_bytes(&serde_json::to_vec(&profile.analyzer_options).unwrap_or_default())?;
let target = BTreeMap::from([
(
"loader".to_owned(),
Value::String(context.target.loader.clone()),
),
(
"format".to_owned(),
Value::String(context.target.format.clone()),
),
(
"processor_language".to_owned(),
Value::String(context.target.processor_language.clone()),
),
(
"compiler_specification".to_owned(),
Value::String(context.target.compiler_specification.clone()),
),
("java_version".to_owned(), Value::from(context.java_version)),
("max_cpu".to_owned(), Value::from(profile.max_cpu.get())),
]);
Ok(AnalysisManifest {
manifest_version: 1,
state: "complete".to_owned(),
sample: ManifestSample {
sha256: snapshot.digest().clone(),
size_bytes: snapshot.size_bytes(),
},
analysis_profile: AnalysisProfileManifest {
sha256: digest.clone(),
ghidra_version: context.ghidra_version.clone(),
adapter_protocol_version: ADAPTER_PROTOCOL_VERSION,
target,
analyzer_options_sha256: analyzer,
},
created_by: CreatedBy {
ghidr_version: env!("CARGO_PKG_VERSION").to_owned(),
invocation_id: invocation_id.to_string(),
completed_at: rfc3339_now(),
},
project: AnalysisProject {
name: "analysis".to_owned(),
path: "project".to_owned(),
files: inventory_project(project).map_err(store_error)?,
},
})
}
fn selected_limits(cli: &Cli, analysis: bool) -> Result<Limits, AppError> {
let decompile = matches!(cli.command, Command::Decompile(_));
let watchdog = 120_u64
.checked_add(if analysis {
cli.analysis_timeout_seconds.get()
} else {
0
})
.and_then(|value| {
value.checked_add(if decompile {
cli.decompile_timeout_seconds.get()
} else {
0
})
})
.ok_or_else(|| AppError::invalid_arguments("derived child watchdog overflows"))?;
Ok(Limits {
max_heap_mib: cli.max_heap_mib,
max_cpu: cli.max_cpu,
analysis_timeout_seconds: cli.analysis_timeout_seconds,
decompile_timeout_seconds: decompile.then_some(cli.decompile_timeout_seconds),
child_watchdog_seconds: PositiveU64::try_from(watchdog)
.map_err(AppError::invalid_arguments)?,
max_sample_bytes: cli.max_sample_bytes,
max_inline_bytes: cli.max_inline_bytes,
})
}
fn request_arguments(command: &Command) -> Result<RequestArguments, AppError> {
match command {
Command::Inspect(_) => Ok(RequestArguments::Inspect),
Command::Functions(arguments) => Ok(RequestArguments::Functions {
page: arguments.page(),
}),
Command::Decompile(arguments) => Ok(RequestArguments::Decompile {
selector: arguments.selector().map_err(AppError::invalid_arguments)?,
}),
_ => Err(AppError::new(
ErrorCode::Internal,
"operation does not use Sample worker",
false,
)),
}
}
fn command_operation(command: &Command) -> crate::operation::Operation {
match command {
Command::Inspect(_) => crate::operation::Operation::Inspect,
Command::Functions(_) => crate::operation::Operation::Functions,
Command::Decompile(_) => crate::operation::Operation::Decompile,
Command::Doctor => crate::operation::Operation::Doctor,
Command::Clean(_) => crate::operation::Operation::Clean,
}
}
fn adapter_error(code: &str, message: String, details: serde_json::Map<String, Value>) -> AppError {
let (public, retryable) = match code {
"unsupported_target" => (ErrorCode::UnsupportedTarget, false),
"ambiguous_target" => (ErrorCode::AmbiguousTarget, false),
"analysis_timeout" => (ErrorCode::AnalysisTimeout, true),
"analysis_failed" => (ErrorCode::AnalysisFailed, false),
"decompilation_timeout" => (ErrorCode::DecompileTimeout, true),
"decompilation_failed" | "function_not_decompilable" => {
(ErrorCode::DecompilationFailed, false)
}
"function_not_found" => (ErrorCode::FunctionNotFound, false),
"function_selector_ambiguous" => (ErrorCode::FunctionSelectorAmbiguous, false),
"function_entry_required" => (ErrorCode::FunctionEntryRequired, false),
"address_space_not_found" => (ErrorCode::AddressSpaceNotFound, false),
"result_too_large" => (ErrorCode::ResultTooLarge, false),
_ => (ErrorCode::ProtocolViolation, false),
};
details.into_iter().fold(
AppError::new(public, message, retryable),
|error, (key, value)| error.with_detail(key, value),
)
}
fn protocol_error(error: impl std::fmt::Display) -> AppError {
AppError::new(ErrorCode::ProtocolViolation, error.to_string(), false)
}
fn create_private(path: &Path) -> Result<(), AppError> {
create_private_directory(path).map_err(store_error)
}
fn digest_bytes(bytes: &[u8]) -> Result<Digest, AppError> {
Digest::from_str(&hex::encode(Sha256::digest(bytes)))
.map_err(|error| AppError::new(ErrorCode::Internal, error.to_string(), false))
}
fn expected_ghidra() -> String {
std::env::var("GHIDR_GHIDRA_VERSION").unwrap_or_else(|_| "12.1.2".to_owned())
}
fn target_verification(context: &AdapterContext) -> TargetVerification {
let target = &context.target;
let x86_64 = target.processor_language.starts_with("x86:LE:64");
if x86_64
&& (target.format.contains("ELF")
|| target.format.contains("Portable Executable")
|| target.format.contains("PE32"))
{
TargetVerification::Verified
} else {
TargetVerification::Unverified
}
}
pub(super) const fn sandbox_provenance(mode: SandboxMode) -> SandboxProvenance {
match mode {
SandboxMode::Bubblewrap => SandboxProvenance {
backend: SandboxBackend::Bubblewrap,
verification: SandboxVerification::Verified,
},
SandboxMode::External => SandboxProvenance {
backend: SandboxBackend::External,
verification: SandboxVerification::Unverified,
},
SandboxMode::Off => SandboxProvenance {
backend: SandboxBackend::Off,
verification: SandboxVerification::Disabled,
},
}
}
pub(super) fn policy_warnings(mode: SandboxMode) -> Vec<Warning> {
match mode {
SandboxMode::Bubblewrap => Vec::new(),
SandboxMode::External => vec![Warning {
code: "external_sandbox_unverified".to_owned(),
message: "external sandbox isolation cannot be verified by ghidr".to_owned(),
details: BTreeMap::new(),
}],
SandboxMode::Off => vec![Warning {
code: "sandbox_disabled".to_owned(),
message: "worker sandbox isolation is explicitly disabled".to_owned(),
details: BTreeMap::new(),
}],
}
}
fn rfc3339_now() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs());
let days = i64::try_from(seconds / 86_400).unwrap_or(i64::MAX);
let seconds_of_day = seconds % 86_400;
let shifted = days + 719_468;
let era = shifted.div_euclid(146_097);
let day_of_era = shifted - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
if month <= 2 {
year += 1;
}
let hour = seconds_of_day / 3_600;
let minute = (seconds_of_day % 3_600) / 60;
let second = seconds_of_day % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}

View file

@ -40,6 +40,13 @@ impl fmt::Display for Digest {
pub struct InvocationId(String); pub struct InvocationId(String);
impl InvocationId { impl InvocationId {
/// Generates a cryptographically random 128-bit invocation identifier.
pub fn generate() -> Result<Self, getrandom::Error> {
let mut bytes = [0_u8; 16];
getrandom::fill(&mut bytes)?;
Ok(Self(hex::encode(bytes)))
}
/// Parses a canonical invocation identifier. /// Parses a canonical invocation identifier.
pub fn parse(value: &str) -> Result<Self, IdentifierError> { pub fn parse(value: &str) -> Result<Self, IdentifierError> {
if value.len() != 32 if value.len() != 32
@ -51,6 +58,18 @@ impl InvocationId {
} }
Ok(Self(value.to_owned())) Ok(Self(value.to_owned()))
} }
/// Returns the canonical lowercase hexadecimal spelling.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for InvocationId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
} }
/// Identifier validation failure. /// Identifier validation failure.

View file

@ -37,8 +37,6 @@ pub enum ErrorCode {
JavaMissing, JavaMissing,
/// The installed Java version is incompatible. /// The installed Java version is incompatible.
JavaIncompatible, JavaIncompatible,
/// A later integration layer has not connected execution yet.
InternalNotImplemented,
/// Rust/Java request or response validation failed. /// Rust/Java request or response validation failed.
ProtocolViolation, ProtocolViolation,
/// The child exceeded a native or watchdog timeout. /// The child exceeded a native or watchdog timeout.
@ -157,22 +155,6 @@ impl AppError {
} }
} }
/// Clear failure used until the store/process/Java execution layer is attached.
#[must_use]
pub fn execution_not_implemented(operation: &str) -> Self {
let mut error = Self::new(
ErrorCode::InternalNotImplemented,
format!("operation '{operation}' is not connected to the Ghidra execution layer"),
false,
);
error
.envelope
.error
.details
.insert("operation".to_owned(), Value::String(operation.to_owned()));
error
}
/// Constructs an invalid-invocation failure from clap context. /// Constructs an invalid-invocation failure from clap context.
#[must_use] #[must_use]
pub fn invalid_arguments(message: impl Into<String>) -> Self { pub fn invalid_arguments(message: impl Into<String>) -> Self {

View file

@ -2,11 +2,15 @@
#![doc = "Public contracts and synchronous orchestration seams for `ghidr`."] #![doc = "Public contracts and synchronous orchestration seams for `ghidr`."]
pub mod cli; pub mod cli;
pub mod commands;
pub mod domain; pub mod domain;
pub mod error; pub mod error;
pub mod operation; pub mod operation;
pub mod output; pub mod output;
pub mod process;
pub mod protocol; pub mod protocol;
pub mod runtime;
pub mod sandbox;
pub mod schema; pub mod schema;
pub mod store; pub mod store;

View file

@ -3,17 +3,45 @@
use std::{io, process::ExitCode}; use std::{io, process::ExitCode};
use ghidra_cli::output::{UnimplementedExecutor, run_from}; use ghidra_cli::{
AppError, ErrorCode,
cli::Cli,
commands::ApplicationExecutor,
output::{Executor, RenderedSuccess, run_from},
process::InterruptCounter,
};
struct StartupFailure(AppError);
impl Executor for StartupFailure {
fn execute(&self, _cli: &Cli) -> Result<RenderedSuccess, AppError> {
Err(self.0.clone())
}
}
fn main() -> ExitCode { fn main() -> ExitCode {
let mut stdout = io::stdout().lock(); let mut stdout = io::stdout().lock();
let mut stderr = io::stderr().lock(); let mut stderr = io::stderr().lock();
match run_from( let arguments: Vec<_> = std::env::args_os().collect();
std::env::args_os(), let result = match InterruptCounter::install() {
&UnimplementedExecutor, Ok(cancellation) => run_from(
arguments,
&ApplicationExecutor::new(cancellation),
&mut stdout, &mut stdout,
&mut stderr, &mut stderr,
) { ),
Err(error) => run_from(
arguments,
&StartupFailure(AppError::new(
ErrorCode::Internal,
format!("SIGINT handler installation failed: {error}"),
false,
)),
&mut stdout,
&mut stderr,
),
};
match result {
Ok(status) => ExitCode::from(u8::try_from(status.code()).unwrap_or(1)), Ok(status) => ExitCode::from(u8::try_from(status.code()).unwrap_or(1)),
Err(_) => ExitCode::FAILURE, Err(_) => ExitCode::FAILURE,
} }

View file

@ -293,17 +293,67 @@ pub enum SandboxVerification {
pub struct DoctorData { pub struct DoctorData {
/// True only when every required component is ready. /// True only when every required component is ready.
pub ready: bool, pub ready: bool,
/// Typed component reports keyed by stable component name. /// Complete fixed component report.
pub components: BTreeMap<String, ComponentReport>, pub components: DoctorComponents,
} }
/// One diagnosed component. /// Fixed installation components checked safely by `doctor`.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ComponentReport { pub struct DoctorComponents {
/// Ghidra launcher and adapter.
pub ghidra: GhidraComponent,
/// Java runtime.
pub java: JavaComponent,
/// Analysis Store.
pub analysis_store: AnalysisStoreComponent,
/// Worker isolation backend.
pub sandbox: SandboxComponent,
}
/// Diagnosed Ghidra installation.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GhidraComponent {
/// Stable readiness classification. /// Stable readiness classification.
pub status: ComponentStatus, pub status: ComponentStatus,
/// Component-specific facts. /// Observed version when the probe ran.
pub details: BTreeMap<String, Value>, pub version: Option<String>,
/// Configured official launcher.
pub launcher: Option<TaggedPath>,
}
/// Diagnosed Java installation.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct JavaComponent {
/// Stable readiness classification.
pub status: ComponentStatus,
/// Observed feature version.
pub version: Option<String>,
/// Configured Java executable.
pub executable: Option<TaggedPath>,
}
/// Diagnosed Analysis Store.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct AnalysisStoreComponent {
/// Stable readiness classification.
pub status: ComponentStatus,
/// Resolved absolute store path.
pub path: TaggedPath,
/// Resolution precedence source.
pub source: StoreSource,
/// Complete non-following store usage scan.
pub usage: StorageUsage,
}
/// Diagnosed sandbox backend.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SandboxComponent {
/// Stable readiness classification.
pub status: ComponentStatus,
/// Explicit selected backend.
pub backend: SandboxBackend,
/// Verification state.
pub verification: SandboxVerification,
} }
/// Doctor component readiness classification. /// Doctor component readiness classification.
@ -556,6 +606,25 @@ pub struct ArtifactDescriptor {
pub contains: String, pub contains: String,
} }
/// Successful command data emitted inline or represented by a complete Artifact.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum InlineOrArtifact<D> {
/// Complete command-specific data.
Inline(D),
/// Bounded descriptor for the complete stored success response.
Spilled(SpilledData),
}
/// Public data shape returned when a complete success exceeds the inline byte budget.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SpilledData {
/// Always true for this variant.
pub spilled: bool,
/// Immutable complete-success Artifact.
pub artifact: ArtifactDescriptor,
}
/// Canonical Analysis Profile document whose exact JSON bytes are hashed. /// Canonical Analysis Profile document whose exact JSON bytes are hashed.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]

View file

@ -10,7 +10,7 @@ use serde_json::Value;
use crate::{ use crate::{
AppError, ErrorCode, ExitStatus, AppError, ErrorCode, ExitStatus,
cli::{Cli, Command, OutputFormat}, cli::{Cli, OutputFormat},
protocol::requests_human_format, protocol::requests_human_format,
}; };
@ -32,18 +32,6 @@ pub trait Executor {
fn execute(&self, cli: &Cli) -> Result<RenderedSuccess, AppError>; fn execute(&self, cli: &Cli) -> Result<RenderedSuccess, AppError>;
} }
/// Foundation executor which truthfully rejects every unconnected path.
#[derive(Clone, Copy, Debug, Default)]
pub struct UnimplementedExecutor;
impl Executor for UnimplementedExecutor {
fn execute(&self, cli: &Cli) -> Result<RenderedSuccess, AppError> {
Err(AppError::execution_not_implemented(operation_name(
&cli.command,
)))
}
}
/// Parses, executes, and writes exactly the stream framing selected by the caller. /// Parses, executes, and writes exactly the stream framing selected by the caller.
pub fn run_from<I, T>( pub fn run_from<I, T>(
arguments: I, arguments: I,
@ -133,16 +121,6 @@ fn write_error_json(error: &AppError, writer: &mut impl Write) -> io::Result<()>
writer.write_all(&bytes) writer.write_all(&bytes)
} }
fn operation_name(command: &Command) -> &'static str {
match command {
Command::Doctor => "doctor",
Command::Inspect(_) => "inspect",
Command::Functions(_) => "functions",
Command::Decompile(_) => "decompile",
Command::Clean(_) => "clean",
}
}
#[cfg(test)] #[cfg(test)]
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
mod tests { mod tests {

219
src/process/capture.rs Normal file
View file

@ -0,0 +1,219 @@
//! Bounded, byte-exact child stream capture.
use std::{
collections::VecDeque,
io::{self, Read},
};
/// Maximum bytes retained independently for each child stream (8 MiB).
pub const STREAM_CAPTURE_BYTES: usize = 8 * 1024 * 1024;
/// Bytes retained at each end of a truncated stream (4 MiB).
pub const STREAM_SEGMENT_BYTES: usize = STREAM_CAPTURE_BYTES / 2;
/// A completely drained child stream with fixed-memory capture.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CapturedStream {
total_bytes: u64,
content: CapturedContent,
utf8: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum CapturedContent {
Complete(Vec<u8>),
Segments { first: Vec<u8>, last: Vec<u8> },
}
impl CapturedStream {
/// Empty capture used when no child pipe was created.
#[must_use]
pub const fn empty() -> Self {
Self {
total_bytes: 0,
content: CapturedContent::Complete(Vec::new()),
utf8: true,
}
}
/// Drains a stream to EOF while retaining no more than 8 MiB.
pub fn drain(mut reader: impl Read) -> io::Result<Self> {
let mut complete = Vec::new();
let mut first = Vec::new();
let mut last = VecDeque::with_capacity(STREAM_SEGMENT_BYTES);
let mut truncated = false;
let mut total_bytes = 0_u64;
let mut utf8 = Utf8Validator::default();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = reader.read(&mut buffer)?;
if read == 0 {
break;
}
total_bytes = total_bytes.saturating_add(u64::try_from(read).unwrap_or(u64::MAX));
let bytes = &buffer[..read];
utf8.push(bytes);
if !truncated && complete.len().saturating_add(read) <= STREAM_CAPTURE_BYTES {
complete.extend_from_slice(bytes);
continue;
}
if !truncated {
first.extend_from_slice(&complete[..STREAM_SEGMENT_BYTES]);
last.extend(&complete[STREAM_SEGMENT_BYTES..]);
complete.clear();
truncated = true;
}
push_tail(&mut last, bytes);
}
let content = if truncated {
CapturedContent::Segments {
first,
last: last.into_iter().collect(),
}
} else {
CapturedContent::Complete(complete)
};
Ok(Self {
total_bytes,
content,
utf8: utf8.finish(),
})
}
/// Total bytes observed while draining, including discarded middle bytes.
#[must_use]
pub const fn total_bytes(&self) -> u64 {
self.total_bytes
}
/// Exact number of retained bytes.
#[must_use]
pub fn captured_bytes(&self) -> u64 {
match &self.content {
CapturedContent::Complete(bytes) => u64::try_from(bytes.len()).unwrap_or(u64::MAX),
CapturedContent::Segments { first, last } => {
u64::try_from(first.len().saturating_add(last.len())).unwrap_or(u64::MAX)
}
}
}
/// Whether middle bytes were omitted.
#[must_use]
pub const fn truncated(&self) -> bool {
matches!(self.content, CapturedContent::Segments { .. })
}
/// Whether all retained bytes form valid UTF-8.
#[must_use]
pub fn is_utf8(&self) -> bool {
self.utf8
}
pub(crate) fn segments(&self) -> CapturedSegments<'_> {
match &self.content {
CapturedContent::Complete(bytes) => CapturedSegments::Complete(bytes),
CapturedContent::Segments { first, last } => CapturedSegments::Truncated {
first,
last,
last_offset: self.total_bytes.saturating_sub(last.len() as u64),
},
}
}
}
#[derive(Default)]
struct Utf8Validator {
incomplete: Vec<u8>,
invalid: bool,
}
impl Utf8Validator {
fn push(&mut self, bytes: &[u8]) {
if self.invalid {
return;
}
self.incomplete.extend_from_slice(bytes);
match std::str::from_utf8(&self.incomplete) {
Ok(_) => self.incomplete.clear(),
Err(error) if error.error_len().is_none() => {
self.incomplete.drain(..error.valid_up_to());
}
Err(_) => {
self.invalid = true;
self.incomplete.clear();
}
}
}
fn finish(self) -> bool {
!self.invalid && self.incomplete.is_empty()
}
}
pub(crate) enum CapturedSegments<'a> {
Complete(&'a [u8]),
Truncated {
first: &'a [u8],
last: &'a [u8],
last_offset: u64,
},
}
fn push_tail(tail: &mut VecDeque<u8>, bytes: &[u8]) {
if bytes.len() >= STREAM_SEGMENT_BYTES {
tail.clear();
tail.extend(&bytes[bytes.len() - STREAM_SEGMENT_BYTES..]);
return;
}
let overflow = tail
.len()
.saturating_add(bytes.len())
.saturating_sub(STREAM_SEGMENT_BYTES);
tail.drain(..overflow);
tail.extend(bytes);
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic)]
mod tests {
use super::{CapturedSegments, CapturedStream, STREAM_CAPTURE_BYTES, STREAM_SEGMENT_BYTES};
#[test]
fn retains_complete_stream_at_exact_cap() {
let bytes = vec![b'a'; STREAM_CAPTURE_BYTES];
let capture = CapturedStream::drain(bytes.as_slice()).expect("capture");
assert!(!capture.truncated());
assert_eq!(capture.captured_bytes(), STREAM_CAPTURE_BYTES as u64);
}
#[test]
fn retains_exact_first_and_last_segments_over_cap() {
let mut bytes = vec![b'a'; STREAM_SEGMENT_BYTES];
bytes.extend(vec![b'm'; 17]);
bytes.extend(vec![b'z'; STREAM_SEGMENT_BYTES]);
let capture = CapturedStream::drain(bytes.as_slice()).expect("capture");
assert!(capture.truncated());
let CapturedSegments::Truncated {
first,
last,
last_offset,
} = capture.segments()
else {
panic!("expected split capture");
};
assert!(first.iter().all(|byte| *byte == b'a'));
assert!(last.iter().all(|byte| *byte == b'z'));
assert_eq!(last_offset, (STREAM_SEGMENT_BYTES + 17) as u64);
}
#[test]
fn utf8_status_covers_discarded_middle_bytes() {
let mut bytes = vec![b'a'; STREAM_SEGMENT_BYTES];
bytes.extend([0xff]);
bytes.extend(vec![b'z'; STREAM_SEGMENT_BYTES]);
let capture = CapturedStream::drain(bytes.as_slice()).expect("capture");
assert!(capture.truncated());
assert!(!capture.is_utf8());
}
}

369
src/process/diagnostics.rs Normal file
View file

@ -0,0 +1,369 @@
//! Diagnostic bundle metadata and store-neutral publication interface.
use std::{
collections::BTreeMap,
fs::{self, File, OpenOptions},
io::{self, Write},
os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _},
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use crate::{
domain::{InvocationId, TaggedPath},
operation::Operation,
};
use super::capture::{CapturedSegments, CapturedStream};
/// Immutable publication result supplied by the Analysis Store layer.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PublishedDiagnostic {
/// Tagged absolute path to the published `manifest.json`.
pub manifest_path: TaggedPath,
/// Exact manifest file length.
pub bytes: u64,
/// SHA-256 of the exact manifest bytes.
pub sha256: String,
/// Diagnostic bundles may contain Sample-derived secrets.
pub sensitive: bool,
/// Always `application/json` for the descriptor target.
pub media_type: &'static str,
}
/// Store-owned publication seam; process code never chooses store layout.
pub trait DiagnosticPublisher {
/// Atomically publishes a fully captured private bundle.
fn publish(&self, bundle: DiagnosticBundle) -> io::Result<PublishedDiagnostic>;
}
/// Child termination class recorded in a diagnostic manifest.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TerminationReason {
/// Child exited and its response was accepted.
Success,
/// Child exited unsuccessfully.
ChildExit,
/// Child returned a complete typed adapter error.
AdapterError,
/// Derived watchdog expired.
WatchdogTimeout,
/// Caller cancellation won the result race.
Interrupted,
/// The child could not be launched.
SpawnFailure,
/// File protocol validation rejected the result.
ProtocolViolation,
}
/// Whether cancellation required SIGKILL after the grace period.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TerminationOutcome {
/// No signal escalation was necessary.
None,
/// The process group exited after SIGTERM.
Graceful,
/// SIGKILL was sent to the process group.
Forced,
}
/// Metadata supplied by the lifecycle controller.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InvocationDiagnosticMetadata {
/// Invocation identity.
pub invocation_id: InvocationId,
/// Worker operation.
pub operation: Operation,
/// Milliseconds since the Unix epoch when launch began.
pub started_unix_millis: u128,
/// Milliseconds since the Unix epoch after reap and drain.
pub finished_unix_millis: u128,
/// Why capture was retained.
pub termination_reason: TerminationReason,
/// Signal escalation outcome.
pub termination_outcome: TerminationOutcome,
/// Normal child exit code, when available.
pub child_exit_code: Option<i32>,
}
/// A complete, bounded pair of drained child streams.
#[derive(Debug)]
pub struct DiagnosticBundle {
metadata: InvocationDiagnosticMetadata,
stdout: CapturedStream,
stderr: CapturedStream,
}
impl DiagnosticBundle {
/// Combines lifecycle metadata with concurrently drained child output.
#[must_use]
pub const fn new(
metadata: InvocationDiagnosticMetadata,
stdout: CapturedStream,
stderr: CapturedStream,
) -> Self {
Self {
metadata,
stdout,
stderr,
}
}
/// Returns whether either stream omitted middle bytes.
#[must_use]
pub const fn truncated(&self) -> bool {
self.stdout.truncated() || self.stderr.truncated()
}
/// Invocation identity used by a publisher to select its final path.
#[must_use]
pub const fn invocation_id(&self) -> &InvocationId {
&self.metadata.invocation_id
}
/// Lifecycle reason recorded if the bundle is retained.
#[must_use]
pub const fn termination_reason(&self) -> TerminationReason {
self.metadata.termination_reason
}
/// Writes a complete bundle into a new private directory.
///
/// The publisher remains responsible for atomically renaming this directory
/// into its durable store location.
pub fn write_private_directory(&self, directory: &Path) -> io::Result<PathBuf> {
let mut builder = fs::DirBuilder::new();
builder.mode(0o700);
builder.create(directory)?;
let (stdout_manifest, stdout_files) = stream_files("stdout", &self.stdout);
let (stderr_manifest, stderr_files) = stream_files("stderr", &self.stderr);
for file in stdout_files.into_iter().chain(stderr_files) {
write_new_file(&directory.join(&file.name), file.bytes)?;
}
let manifest = DiagnosticManifest {
manifest_version: 1,
invocation_id: self.metadata.invocation_id.to_string(),
operation: self.metadata.operation,
started_unix_millis: self.metadata.started_unix_millis,
finished_unix_millis: self.metadata.finished_unix_millis,
termination_reason: self.metadata.termination_reason,
termination_outcome: self.metadata.termination_outcome,
child_exit_code: self.metadata.child_exit_code,
streams: BTreeMap::from([
("stderr".to_owned(), stderr_manifest),
("stdout".to_owned(), stdout_manifest),
]),
};
let mut bytes = serde_json::to_vec(&manifest).map_err(io::Error::other)?;
bytes.push(b'\n');
let path = directory.join("manifest.json");
write_new_file(&path, &bytes)?;
sync_directory(directory)?;
Ok(path)
}
}
/// SHA-256 hex digest for publication descriptors.
#[must_use]
pub fn sha256_hex(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
#[derive(Serialize)]
struct DiagnosticManifest {
manifest_version: u32,
invocation_id: String,
operation: Operation,
started_unix_millis: u128,
finished_unix_millis: u128,
termination_reason: TerminationReason,
termination_outcome: TerminationOutcome,
child_exit_code: Option<i32>,
streams: BTreeMap<String, StreamManifest>,
}
#[derive(Serialize)]
struct StreamManifest {
total_bytes: u64,
captured_bytes: u64,
truncated: bool,
encoding: StreamEncoding,
segments: Vec<SegmentManifest>,
}
#[derive(Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
enum StreamEncoding {
Utf8,
ArbitraryBytes,
}
#[derive(Serialize)]
struct SegmentManifest {
offset: u64,
length: u64,
path: TaggedPath,
sha256: String,
}
struct BundleFile<'a> {
name: String,
bytes: &'a [u8],
}
fn stream_files<'a>(
stream_name: &str,
capture: &'a CapturedStream,
) -> (StreamManifest, Vec<BundleFile<'a>>) {
let encoding = if capture.is_utf8() {
StreamEncoding::Utf8
} else {
StreamEncoding::ArbitraryBytes
};
let (segments, files) = match capture.segments() {
CapturedSegments::Complete(bytes) => {
let name = format!("{stream_name}.log");
(
vec![segment(&name, 0, bytes)],
vec![BundleFile { name, bytes }],
)
}
CapturedSegments::Truncated {
first,
last,
last_offset,
} => {
let first_name = format!("{stream_name}.first.log");
let last_name = format!("{stream_name}.last.log");
(
vec![
segment(&first_name, 0, first),
segment(&last_name, last_offset, last),
],
vec![
BundleFile {
name: first_name,
bytes: first,
},
BundleFile {
name: last_name,
bytes: last,
},
],
)
}
};
(
StreamManifest {
total_bytes: capture.total_bytes(),
captured_bytes: capture.captured_bytes(),
truncated: capture.truncated(),
encoding,
segments,
},
files,
)
}
fn segment(name: &str, offset: u64, bytes: &[u8]) -> SegmentManifest {
SegmentManifest {
offset,
length: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
path: TaggedPath::Utf8(name.to_owned()),
sha256: sha256_hex(bytes),
}
}
fn write_new_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(bytes)?;
file.sync_all()
}
fn sync_directory(directory: &Path) -> io::Result<()> {
File::open(directory)?.sync_all()
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::{
DiagnosticBundle, InvocationDiagnosticMetadata, TerminationOutcome, TerminationReason,
};
use crate::{
domain::InvocationId,
operation::Operation,
process::capture::{CapturedStream, STREAM_CAPTURE_BYTES},
};
#[test]
fn private_bundle_uses_exact_stream_bytes_and_private_modes() {
let root = tempdir().expect("temporary root");
let directory = root.path().join("bundle");
let bundle = DiagnosticBundle::new(
InvocationDiagnosticMetadata {
invocation_id: InvocationId::parse(&"a".repeat(32)).expect("identifier"),
operation: Operation::Doctor,
started_unix_millis: 1,
finished_unix_millis: 2,
termination_reason: TerminationReason::ChildExit,
termination_outcome: TerminationOutcome::None,
child_exit_code: Some(7),
},
CapturedStream::drain(&b"out\0"[..]).expect("stdout"),
CapturedStream::drain(&b"err\xff"[..]).expect("stderr"),
);
let manifest = bundle.write_private_directory(&directory).expect("bundle");
assert_eq!(
fs::read(directory.join("stdout.log")).expect("stdout"),
b"out\0"
);
assert_eq!(
fs::read(directory.join("stderr.log")).expect("stderr"),
b"err\xff"
);
let value: serde_json::Value =
serde_json::from_slice(&fs::read(manifest).expect("manifest")).expect("json");
assert_eq!(value["streams"]["stderr"]["encoding"], "arbitrary_bytes");
}
#[test]
fn truncated_bundle_uses_separate_segment_files() {
let root = tempdir().expect("temporary root");
let bytes = vec![b'x'; STREAM_CAPTURE_BYTES + 1];
let capture = CapturedStream::drain(bytes.as_slice()).expect("capture");
let bundle = DiagnosticBundle::new(
InvocationDiagnosticMetadata {
invocation_id: InvocationId::parse(&"b".repeat(32)).expect("identifier"),
operation: Operation::Inspect,
started_unix_millis: 1,
finished_unix_millis: 2,
termination_reason: TerminationReason::ProtocolViolation,
termination_outcome: TerminationOutcome::None,
child_exit_code: Some(0),
},
capture,
CapturedStream::drain(&b""[..]).expect("stderr"),
);
bundle
.write_private_directory(&root.path().join("bundle"))
.expect("bundle");
assert!(root.path().join("bundle/stdout.first.log").is_file());
assert!(root.path().join("bundle/stdout.last.log").is_file());
assert!(!root.path().join("bundle/stdout.log").exists());
}
}

618
src/process/lifecycle.rs Normal file
View file

@ -0,0 +1,618 @@
//! One-attempt synchronous worker lifecycle and process-group cancellation.
use std::{
io,
os::unix::process::CommandExt as _,
process::{Command, ExitStatus as ProcessExitStatus, Stdio},
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use rustix::process::{Pid, Signal, kill_process_group, test_kill_process_group};
use thiserror::Error;
use crate::{
AppError, ErrorCode,
domain::PositiveU64,
protocol::{AdapterRequest, AdapterResponse, AdapterResult, InvocationFiles},
sandbox::WorkerCommand,
};
use super::{
capture::CapturedStream,
diagnostics::{
DiagnosticBundle, InvocationDiagnosticMetadata, TerminationOutcome, TerminationReason,
},
};
/// Fixed time after SIGTERM before forced process-group termination.
pub const TERMINATION_GRACE: Duration = Duration::from_secs(10);
const POLL_INTERVAL: Duration = Duration::from_millis(10);
const WATCHDOG_OVERHEAD_SECONDS: u64 = 120;
/// Pollable caller cancellation state.
///
/// A value of one requests graceful interruption; two or more immediately
/// escalates an active grace period to SIGKILL.
pub trait Cancellation {
/// Number of interrupt requests observed so far.
fn interrupt_count(&self) -> u32;
}
/// Production default when no frontend cancellation adapter is installed.
#[derive(Clone, Copy, Debug, Default)]
pub struct NeverCancel;
impl Cancellation for NeverCancel {
fn interrupt_count(&self) -> u32 {
0
}
}
/// Enabled native phases used to derive, never independently configure, a watchdog.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct NativePhaseTimeouts {
/// Auto-analysis phase, absent for a compatible cached Analysis.
pub analysis_seconds: Option<PositiveU64>,
/// Targeted decompilation phase, absent for non-decompilation Queries.
pub decompile_seconds: Option<PositiveU64>,
}
impl NativePhaseTimeouts {
/// Sum of enabled phase bounds plus the fixed 120-second harness overhead.
pub fn watchdog(self) -> Result<Duration, WatchdogError> {
let seconds = self
.analysis_seconds
.map_or(0, PositiveU64::get)
.checked_add(self.decompile_seconds.map_or(0, PositiveU64::get))
.and_then(|value| value.checked_add(WATCHDOG_OVERHEAD_SECONDS))
.ok_or(WatchdogError::Overflow)?;
Ok(Duration::from_secs(seconds))
}
}
/// Derived watchdog construction failure.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum WatchdogError {
/// Selected phase bounds cannot be represented after addition.
#[error("derived child watchdog overflows the supported duration")]
Overflow,
/// Protocol provenance did not echo the automatically derived value.
#[error("request child watchdog does not match enabled native phases")]
RequestMismatch,
}
/// Inputs for exactly one worker attempt.
pub struct Invocation<'a> {
/// Strict request written before launch.
request: &'a AdapterRequest,
/// Existing private file-protocol directory.
files: &'a InvocationFiles,
/// Fully resolved direct or sandboxed command.
command: &'a WorkerCommand,
/// Automatically derived hard watchdog.
watchdog: Duration,
}
impl<'a> Invocation<'a> {
/// Creates an Invocation only when limit provenance matches the derived watchdog.
pub fn new(
request: &'a AdapterRequest,
files: &'a InvocationFiles,
command: &'a WorkerCommand,
phases: NativePhaseTimeouts,
) -> Result<Self, WatchdogError> {
let watchdog = phases.watchdog()?;
if request.limits.child_watchdog_seconds.get() != watchdog.as_secs() {
return Err(WatchdogError::RequestMismatch);
}
Ok(Self {
request,
files,
command,
watchdog,
})
}
/// Replaces the production watchdog only in debug builds for fast lifecycle tests.
#[cfg(any(debug_assertions, feature = "test-support"))]
#[must_use]
pub fn with_test_watchdog(mut self, watchdog: Duration) -> Self {
self.watchdog = watchdog;
self
}
}
/// A complete successful transport attempt.
#[derive(Debug)]
pub struct CompletedInvocation {
/// Strict validated adapter response.
pub response: AdapterResponse,
/// Captured streams, normally dropped unless a success warning references them.
pub diagnostics: DiagnosticBundle,
}
impl CompletedInvocation {
/// Typed adapter errors always require durable diagnostic publication.
#[must_use]
pub const fn requires_diagnostic_publication(&self) -> bool {
matches!(&self.response.result, AdapterResult::Error { .. })
}
}
/// Failure category with stable public error mapping.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InvocationFailureKind {
/// Request file construction failed before launch.
RequestProtocol,
/// OS process creation failed.
Spawn,
/// Derived watchdog expired.
WatchdogTimeout,
/// Caller interruption won the result race.
Interrupted,
/// Child exited unsuccessfully.
ChildExit,
/// Response file or typed response validation failed.
ResponseProtocol,
/// Concurrent stream drain failed.
DiagnosticDrain,
}
impl InvocationFailureKind {
/// Stable public code used by command orchestration.
#[must_use]
pub const fn error_code(self) -> ErrorCode {
match self {
Self::WatchdogTimeout => ErrorCode::Timeout,
Self::Interrupted => ErrorCode::Interrupted,
Self::RequestProtocol | Self::ResponseProtocol => ErrorCode::ProtocolViolation,
Self::Spawn | Self::ChildExit | Self::DiagnosticDrain => ErrorCode::Internal,
}
}
}
/// Failed transport attempt with retained diagnostic material.
#[derive(Debug, Error)]
#[error("{message}")]
pub struct InvocationFailure {
/// Stable lifecycle classification.
pub kind: InvocationFailureKind,
/// Concise internal context.
pub message: String,
/// Captured material to publish atomically.
pub diagnostics: Box<DiagnosticBundle>,
}
impl InvocationFailure {
/// Converts lifecycle classification to the public error taxonomy.
#[must_use]
pub fn app_error(&self) -> AppError {
AppError::new(self.kind.error_code(), self.message.clone(), false)
}
}
/// Runs one and only one worker process attempt.
pub fn run(
invocation: Invocation<'_>,
cancellation: &(impl Cancellation + ?Sized),
) -> Result<CompletedInvocation, InvocationFailure> {
let started_unix_millis = unix_millis();
if let Err(error) = invocation.files.write_request(invocation.request) {
return Err(failure_without_child(
invocation.request,
started_unix_millis,
InvocationFailureKind::RequestProtocol,
TerminationReason::ProtocolViolation,
error.to_string(),
));
}
let mut command = Command::new(&invocation.command.program);
command
.args(&invocation.command.arguments)
.env_clear()
.envs(invocation.command.environment.iter().cloned())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0);
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
return Err(failure_without_child(
invocation.request,
started_unix_millis,
InvocationFailureKind::Spawn,
TerminationReason::SpawnFailure,
format!("worker launch failed: {error}"),
));
}
};
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stdout_thread = stdout.map(|pipe| thread::spawn(move || CapturedStream::drain(pipe)));
let stderr_thread = stderr.map(|pipe| thread::spawn(move || CapturedStream::drain(pipe)));
let process_group = Pid::from_raw(i32::try_from(child.id()).unwrap_or(i32::MAX));
let deadline = Instant::now().checked_add(invocation.watchdog);
let mut cancellation_reason = None;
let status = loop {
if cancellation.interrupt_count() > 0 {
cancellation_reason = Some(TerminationReason::Interrupted);
break None;
}
if deadline.is_none_or(|value| Instant::now() >= value) {
cancellation_reason = Some(TerminationReason::WatchdogTimeout);
break None;
}
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) => thread::sleep(POLL_INTERVAL),
Err(_error) => {
cancellation_reason = Some(TerminationReason::ChildExit);
break None;
}
}
};
while cancellation_reason.is_none()
&& !drains_finished(stdout_thread.as_ref(), stderr_thread.as_ref())
{
if cancellation.interrupt_count() > 0 {
cancellation_reason = Some(TerminationReason::Interrupted);
} else if deadline.is_none_or(|value| Instant::now() >= value) {
cancellation_reason = Some(TerminationReason::WatchdogTimeout);
} else {
thread::sleep(POLL_INTERVAL);
}
}
let (status, termination_outcome) = if let Some(reason) = cancellation_reason {
let (status, outcome) = terminate_group(
&mut child,
process_group,
cancellation,
stdout_thread.as_ref(),
stderr_thread.as_ref(),
);
let failure_kind = if reason == TerminationReason::Interrupted {
InvocationFailureKind::Interrupted
} else if reason == TerminationReason::WatchdogTimeout {
InvocationFailureKind::WatchdogTimeout
} else {
InvocationFailureKind::ChildExit
};
let (stdout, stderr, drain_error) = join_captures(stdout_thread, stderr_thread);
let diagnostics = diagnostic_bundle(
invocation.request,
started_unix_millis,
reason,
outcome,
status.as_ref(),
stdout,
stderr,
);
return Err(InvocationFailure {
kind: failure_kind,
message: drain_error.unwrap_or_else(|| match reason {
TerminationReason::Interrupted => "worker invocation was interrupted".to_owned(),
TerminationReason::WatchdogTimeout => {
"worker exceeded the derived watchdog".to_owned()
}
_ => "worker status could not be observed".to_owned(),
}),
diagnostics,
});
} else {
(status, TerminationOutcome::None)
};
let (stdout, stderr, drain_error) = join_captures(stdout_thread, stderr_thread);
if let Some(message) = drain_error {
return Err(InvocationFailure {
kind: InvocationFailureKind::DiagnosticDrain,
message,
diagnostics: diagnostic_bundle(
invocation.request,
started_unix_millis,
TerminationReason::ProtocolViolation,
termination_outcome,
status.as_ref(),
stdout,
stderr,
),
});
}
let Some(status) = status else {
return Err(InvocationFailure {
kind: InvocationFailureKind::ChildExit,
message: "worker exited without an observable status".to_owned(),
diagnostics: diagnostic_bundle(
invocation.request,
started_unix_millis,
TerminationReason::ChildExit,
termination_outcome,
None,
stdout,
stderr,
),
});
};
if cancellation.interrupt_count() > 0 {
return Err(InvocationFailure {
kind: InvocationFailureKind::Interrupted,
message: "worker invocation was interrupted".to_owned(),
diagnostics: diagnostic_bundle(
invocation.request,
started_unix_millis,
TerminationReason::Interrupted,
TerminationOutcome::Graceful,
Some(&status),
stdout,
stderr,
),
});
}
if !status.success() {
return Err(InvocationFailure {
kind: InvocationFailureKind::ChildExit,
message: format!("worker exited unsuccessfully: {status}"),
diagnostics: diagnostic_bundle(
invocation.request,
started_unix_millis,
TerminationReason::ChildExit,
termination_outcome,
Some(&status),
stdout,
stderr,
),
});
}
if process_group.is_some_and(group_is_alive) {
let outcome = terminate_lingering_group(process_group, cancellation);
return Err(InvocationFailure {
kind: InvocationFailureKind::ChildExit,
message: "worker leader exited while its process group remained alive".to_owned(),
diagnostics: diagnostic_bundle(
invocation.request,
started_unix_millis,
TerminationReason::ChildExit,
outcome,
Some(&status),
stdout,
stderr,
),
});
}
match invocation.files.read_response(invocation.request) {
Ok(response) => {
let interrupted = cancellation.interrupt_count() > 0;
let adapter_error = matches!(&response.result, AdapterResult::Error { .. });
let diagnostics = diagnostic_bundle(
invocation.request,
started_unix_millis,
if interrupted {
TerminationReason::Interrupted
} else if adapter_error {
TerminationReason::AdapterError
} else {
TerminationReason::Success
},
termination_outcome,
Some(&status),
stdout,
stderr,
);
if interrupted {
Err(InvocationFailure {
kind: InvocationFailureKind::Interrupted,
message: "worker invocation was interrupted".to_owned(),
diagnostics,
})
} else {
Ok(CompletedInvocation {
response,
diagnostics: *diagnostics,
})
}
}
Err(error) => Err(InvocationFailure {
kind: InvocationFailureKind::ResponseProtocol,
message: format!("worker response violated the file protocol: {error}"),
diagnostics: diagnostic_bundle(
invocation.request,
started_unix_millis,
TerminationReason::ProtocolViolation,
termination_outcome,
Some(&status),
stdout,
stderr,
),
}),
}
}
fn terminate_group(
child: &mut std::process::Child,
process_group: Option<Pid>,
cancellation: &(impl Cancellation + ?Sized),
stdout: Option<&DrainThread>,
stderr: Option<&DrainThread>,
) -> (Option<ProcessExitStatus>, TerminationOutcome) {
signal_group(process_group, Signal::TERM);
let deadline = Instant::now() + TERMINATION_GRACE;
let mut status = None;
loop {
if cancellation.interrupt_count() > 1 || Instant::now() >= deadline {
signal_group(process_group, Signal::KILL);
return (
status.or_else(|| child.wait().ok()),
TerminationOutcome::Forced,
);
}
if status.is_none() {
status = match child.try_wait() {
Ok(value) => value,
Err(_) => child.wait().ok(),
};
}
if status.is_some()
&& drains_finished(stdout, stderr)
&& process_group.is_none_or(|group| !group_is_alive(group))
{
return (status, TerminationOutcome::Graceful);
}
thread::sleep(POLL_INTERVAL);
}
}
fn terminate_lingering_group(
process_group: Option<Pid>,
cancellation: &(impl Cancellation + ?Sized),
) -> TerminationOutcome {
signal_group(process_group, Signal::TERM);
let deadline = Instant::now() + TERMINATION_GRACE;
while process_group.is_some_and(group_is_alive) {
if cancellation.interrupt_count() > 1 || Instant::now() >= deadline {
signal_group(process_group, Signal::KILL);
return TerminationOutcome::Forced;
}
thread::sleep(POLL_INTERVAL);
}
TerminationOutcome::Graceful
}
fn group_is_alive(process_group: Pid) -> bool {
test_kill_process_group(process_group).is_ok()
}
fn signal_group(process_group: Option<Pid>, signal: Signal) {
if let Some(process_group) = process_group {
let _ignored = kill_process_group(process_group, signal);
}
}
type DrainThread = thread::JoinHandle<io::Result<CapturedStream>>;
fn drains_finished(stdout: Option<&DrainThread>, stderr: Option<&DrainThread>) -> bool {
stdout.is_none_or(thread::JoinHandle::is_finished)
&& stderr.is_none_or(thread::JoinHandle::is_finished)
}
fn join_captures(
stdout: Option<DrainThread>,
stderr: Option<DrainThread>,
) -> (CapturedStream, CapturedStream, Option<String>) {
let (stdout, stdout_error) = join_capture(stdout, "stdout");
let (stderr, stderr_error) = join_capture(stderr, "stderr");
(stdout, stderr, stdout_error.or(stderr_error))
}
fn join_capture(thread: Option<DrainThread>, name: &str) -> (CapturedStream, Option<String>) {
let Some(thread) = thread else {
return (
CapturedStream::empty(),
Some(format!("worker {name} pipe was unavailable")),
);
};
match thread.join() {
Ok(Ok(capture)) => (capture, None),
Ok(Err(error)) => (
CapturedStream::empty(),
Some(format!("worker {name} drain failed: {error}")),
),
Err(_) => (
CapturedStream::empty(),
Some(format!("worker {name} drain thread failed")),
),
}
}
fn failure_without_child(
request: &AdapterRequest,
started_unix_millis: u128,
kind: InvocationFailureKind,
reason: TerminationReason,
message: String,
) -> InvocationFailure {
InvocationFailure {
kind,
message,
diagnostics: diagnostic_bundle(
request,
started_unix_millis,
reason,
TerminationOutcome::None,
None,
CapturedStream::empty(),
CapturedStream::empty(),
),
}
}
fn diagnostic_bundle(
request: &AdapterRequest,
started_unix_millis: u128,
termination_reason: TerminationReason,
termination_outcome: TerminationOutcome,
status: Option<&ProcessExitStatus>,
stdout: CapturedStream,
stderr: CapturedStream,
) -> Box<DiagnosticBundle> {
Box::new(DiagnosticBundle::new(
InvocationDiagnosticMetadata {
invocation_id: request.invocation_id.clone(),
operation: request.operation,
started_unix_millis,
finished_unix_millis: unix_millis(),
termination_reason,
termination_outcome,
child_exit_code: status.and_then(ProcessExitStatus::code),
},
stdout,
stderr,
))
}
fn unix_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_millis()
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use std::time::Duration;
use super::NativePhaseTimeouts;
use crate::domain::PositiveU64;
#[test]
fn derives_watchdog_from_enabled_native_phases() {
let analysis = PositiveU64::try_from(600).expect("positive");
let decompile = PositiveU64::try_from(60).expect("positive");
assert_eq!(
NativePhaseTimeouts {
analysis_seconds: Some(analysis),
decompile_seconds: Some(decompile)
}
.watchdog()
.expect("duration"),
Duration::from_secs(780),
);
assert_eq!(
NativePhaseTimeouts {
analysis_seconds: None,
decompile_seconds: None
}
.watchdog()
.expect("duration"),
Duration::from_secs(120),
);
}
}

12
src/process/mod.rs Normal file
View file

@ -0,0 +1,12 @@
//! Synchronous worker lifecycle, bounded diagnostics, and publication seams.
pub mod capture;
pub mod diagnostics;
mod lifecycle;
mod signals;
pub use lifecycle::{
Cancellation, CompletedInvocation, Invocation, InvocationFailure, InvocationFailureKind,
NativePhaseTimeouts, NeverCancel, TERMINATION_GRACE, WatchdogError, run,
};
pub use signals::InterruptCounter;

46
src/process/signals.rs Normal file
View file

@ -0,0 +1,46 @@
//! Safe frontend SIGINT subscription for the synchronous worker lifecycle.
use std::{
io,
sync::{
Arc,
atomic::{AtomicU32, Ordering},
},
thread,
};
use signal_hook::{consts::signal::SIGINT, iterator::Signals};
use super::Cancellation;
/// Process-wide interrupt count observed by the production CLI frontend.
#[derive(Clone, Debug)]
pub struct InterruptCounter {
count: Arc<AtomicU32>,
}
impl InterruptCounter {
/// Installs a safe SIGINT iterator and detached counter thread.
pub fn install() -> io::Result<Self> {
let mut signals = Signals::new([SIGINT])?;
let count = Arc::new(AtomicU32::new(0));
let thread_count = Arc::clone(&count);
thread::Builder::new()
.name("ghidr-sigint".to_owned())
.spawn(move || {
for _signal in signals.forever() {
let _previous =
thread_count.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |value| {
Some(value.saturating_add(1))
});
}
})?;
Ok(Self { count })
}
}
impl Cancellation for InterruptCounter {
fn interrupt_count(&self) -> u32 {
self.count.load(Ordering::SeqCst)
}
}

View file

@ -2,7 +2,9 @@
use std::{ use std::{
ffi::OsStr, ffi::OsStr,
io::{self, Read}, fs::{self, File, OpenOptions},
io::{self, Read, Write},
os::unix::fs::OpenOptionsExt as _,
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
@ -12,14 +14,95 @@ use thiserror::Error;
use crate::{ use crate::{
domain::{FunctionSelector, InvocationId, Limits, PageRequest}, domain::{FunctionSelector, InvocationId, Limits, PageRequest},
operation::{ADAPTER_PROTOCOL_VERSION, Operation}, operation::{
ADAPTER_PROTOCOL_VERSION, DecompilationData, FunctionsData, Operation, ProfileOption,
ProgramSummary, TargetProvenance,
},
}; };
/// Engine facts returned by the Java installation probe.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AdapterDoctorData {
/// Probe result.
pub ready: bool,
/// Exact Ghidra version.
pub ghidra_version: String,
/// Java feature version.
pub java_version: u64,
/// Adapter protocol implementation version.
pub adapter_protocol_version: u32,
}
/// Engine metadata common to every Sample-backed worker result.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AdapterContext {
/// Exact Ghidra version.
pub ghidra_version: String,
/// Java feature version.
pub java_version: u64,
/// Resolved Target Specification without harness verification policy.
pub target: AdapterTarget,
/// Fully resolved loader options; empty when the selected loader exposes none.
pub loader_options: Vec<ProfileOption>,
/// Fully resolved current analysis option values.
pub analyzer_options: Vec<ProfileOption>,
}
/// Target fields extracted from the current Ghidra Program.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AdapterTarget {
/// Loader identifier.
pub loader: String,
/// Executable format.
pub format: String,
/// Processor language identifier.
pub processor_language: String,
/// Compiler specification identifier.
pub compiler_specification: String,
}
impl AdapterTarget {
/// Adds the harness-owned integration verification classification.
#[must_use]
pub fn provenance(
&self,
verification: crate::operation::TargetVerification,
) -> TargetProvenance {
TargetProvenance {
loader: self.loader.clone(),
format: self.format.clone(),
processor_language: self.processor_language.clone(),
compiler_specification: self.compiler_specification.clone(),
verification,
}
}
}
/// Strict Sample-backed worker payload; public provenance is added by Rust.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AdapterQueryData<T> {
/// Current Program/engine context.
pub context: AdapterContext,
/// Operation-specific facts.
pub query: T,
}
/// Maximum exact serialized `request.json` size (1 MiB). /// Maximum exact serialized `request.json` size (1 MiB).
pub const MAX_REQUEST_BYTES: usize = 1_048_576; pub const MAX_REQUEST_BYTES: usize = 1_048_576;
/// Maximum exact serialized `response.json` size (256 MiB). /// Maximum exact serialized `response.json` size (256 MiB).
pub const MAX_RESPONSE_BYTES: usize = 268_435_456; pub const MAX_RESPONSE_BYTES: usize = 268_435_456;
/// Fixed names inside one private invocation directory.
pub const REQUEST_FILE_NAME: &str = "request.json";
/// Java's incomplete response, never accepted by Rust.
pub const TEMP_RESPONSE_FILE_NAME: &str = "response.json.tmp";
/// Java's only accepted final response name.
pub const RESPONSE_FILE_NAME: &str = "response.json";
/// A validated UTF-8 absolute path owned by the trusted harness. /// A validated UTF-8 absolute path owned by the trusted harness.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(transparent)] #[serde(transparent)]
@ -164,6 +247,127 @@ pub fn validate_response_echo(
Ok(()) Ok(())
} }
/// Strictly decoded operation data after echo and schema validation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ValidatedAdapterData {
/// Installation probe result.
Doctor(AdapterDoctorData),
/// Program inspection result.
Inspect(AdapterQueryData<ProgramSummary>),
/// Stable Function page.
Functions(AdapterQueryData<FunctionsData>),
/// Complete decompilation.
Decompile(AdapterQueryData<DecompilationData>),
}
/// Validates a success payload against the exact operation-specific Rust type.
///
/// Comparing the value with its typed round trip also rejects unknown fields,
/// rather than silently discarding them during deserialization.
pub fn validate_operation_data(
operation: Operation,
data: serde_json::Value,
) -> Result<ValidatedAdapterData, ProtocolCodecError> {
macro_rules! exact {
($type:ty, $variant:ident) => {{
let decoded: $type = serde_json::from_value(data.clone())?;
if serde_json::to_value(&decoded)? != data {
return Err(ProtocolCodecError::OperationSchemaMismatch);
}
Ok(ValidatedAdapterData::$variant(decoded))
}};
}
match operation {
Operation::Doctor => exact!(AdapterDoctorData, Doctor),
Operation::Inspect => exact!(AdapterQueryData<ProgramSummary>, Inspect),
Operation::Functions => exact!(AdapterQueryData<FunctionsData>, Functions),
Operation::Decompile => exact!(AdapterQueryData<DecompilationData>, Decompile),
Operation::Clean => Err(ProtocolCodecError::OperationSchemaMismatch),
}
}
/// File paths and atomic request/strict response handling for one Invocation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InvocationFiles {
directory: PathBuf,
}
impl InvocationFiles {
/// Uses an existing private, tool-owned invocation directory.
pub fn new(directory: PathBuf) -> Result<Self, ProtocolCodecError> {
if !directory.is_absolute() || directory.to_str().is_none() {
return Err(ProtocolCodecError::InvalidWorkerPath);
}
let metadata = fs::symlink_metadata(&directory)?;
if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
return Err(ProtocolCodecError::InvalidInvocationDirectory);
}
Ok(Self { directory })
}
/// Absolute request path passed to the adapter.
#[must_use]
pub fn request_path(&self) -> PathBuf {
self.directory.join(REQUEST_FILE_NAME)
}
/// Absolute final response path passed to the adapter.
#[must_use]
pub fn response_path(&self) -> PathBuf {
self.directory.join(RESPONSE_FILE_NAME)
}
/// Atomically creates a mode-0600 request before worker launch.
pub fn write_request(&self, request: &AdapterRequest) -> Result<(), ProtocolCodecError> {
let bytes = encode_request(request)?;
let temporary = self.directory.join("request.json.tmp");
let final_path = self.request_path();
if fs::symlink_metadata(&final_path).is_ok() || fs::symlink_metadata(&temporary).is_ok() {
return Err(ProtocolCodecError::ProtocolFileAlreadyExists);
}
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temporary)?;
file.write_all(&bytes)?;
file.sync_all()?;
fs::rename(&temporary, &final_path)?;
File::open(&self.directory)?.sync_all()?;
Ok(())
}
/// Opens only a final regular response, then checks bounds and echoed fields.
pub fn read_response(
&self,
request: &AdapterRequest,
) -> Result<AdapterResponse, ProtocolCodecError> {
let path = self.response_path();
let metadata = fs::symlink_metadata(&path).map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
ProtocolCodecError::MissingFinalResponse
} else {
ProtocolCodecError::Io(error)
}
})?;
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
return Err(ProtocolCodecError::InvalidFinalResponseType);
}
if metadata.len() > MAX_RESPONSE_BYTES as u64 {
return Err(ProtocolCodecError::ResponseTooLarge {
limit: MAX_RESPONSE_BYTES,
observed: MAX_RESPONSE_BYTES.saturating_add(1),
});
}
let response = decode_response(File::open(path)?)?;
validate_response_echo(request, &response)?;
if let AdapterResult::Success { data } = &response.result {
let _validated = validate_operation_data(response.operation, data.clone())?;
}
Ok(response)
}
}
fn encode_bounded<T: Serialize>( fn encode_bounded<T: Serialize>(
value: &T, value: &T,
limit: usize, limit: usize,
@ -238,6 +442,21 @@ pub enum ProtocolCodecError {
/// Operation echo differs. /// Operation echo differs.
#[error("adapter operation mismatch")] #[error("adapter operation mismatch")]
OperationMismatch, OperationMismatch,
/// Operation data did not round-trip through its exact typed schema.
#[error("adapter operation data failed strict schema validation")]
OperationSchemaMismatch,
/// The private invocation directory is not a real directory.
#[error("invocation path must name a real directory")]
InvalidInvocationDirectory,
/// A protocol path unexpectedly existed before launch.
#[error("protocol file already exists before worker launch")]
ProtocolFileAlreadyExists,
/// No atomically published final response exists.
#[error("adapter did not publish response.json")]
MissingFinalResponse,
/// The final response is a symlink, directory, or special file.
#[error("response.json must be a regular non-symlink file")]
InvalidFinalResponseType,
/// Platform cannot represent the protocol bound. /// Platform cannot represent the protocol bound.
#[error("platform cannot represent protocol size bound")] #[error("platform cannot represent protocol size bound")]
PlatformLimit, PlatformLimit,
@ -260,10 +479,45 @@ pub fn requests_human_format(arguments: &[impl AsRef<OsStr>]) -> bool {
} }
#[cfg(test)] #[cfg(test)]
#[allow(clippy::expect_used)]
mod tests { mod tests {
use std::io::Cursor; use std::{io::Cursor, path::PathBuf};
use super::{ProtocolCodecError, decode_bounded, requests_human_format}; use tempfile::tempdir;
use super::{
AdapterRequest, InvocationFiles, ProtocolCodecError, RequestArguments, decode_bounded,
requests_human_format, validate_operation_data,
};
use crate::{
domain::{FunctionSelector, InvocationId, Limits, PositiveU64},
operation::{ADAPTER_PROTOCOL_VERSION, Operation},
};
fn positive(value: u64) -> PositiveU64 {
PositiveU64::try_from(value).expect("positive")
}
fn request(arguments: RequestArguments) -> AdapterRequest {
AdapterRequest {
protocol_version: ADAPTER_PROTOCOL_VERSION,
invocation_id: InvocationId::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
.expect("identifier"),
operation: arguments.operation(),
staged_sample: None,
analysis_path: None,
limits: Limits {
max_heap_mib: positive(2048),
max_cpu: positive(2),
analysis_timeout_seconds: positive(600),
decompile_timeout_seconds: None,
child_watchdog_seconds: positive(120),
max_sample_bytes: positive(1_073_741_824),
max_inline_bytes: positive(65_536),
},
arguments,
}
}
#[test] #[test]
fn response_reader_stops_at_bound_plus_one() { fn response_reader_stops_at_bound_plus_one() {
@ -302,4 +556,40 @@ mod tests {
Err(ProtocolCodecError::RequestTooLarge { .. }) Err(ProtocolCodecError::RequestTooLarge { .. })
)); ));
} }
#[test]
fn oversized_typed_request_is_rejected_before_any_file_is_published() {
let root = tempdir().expect("temporary root");
let invocation = root.path().join("invocation");
std::fs::create_dir(&invocation).expect("invocation directory");
let files = InvocationFiles::new(invocation).expect("protocol paths");
let request = request(RequestArguments::Decompile {
selector: FunctionSelector::Name("x".repeat(super::MAX_REQUEST_BYTES)),
});
assert!(matches!(
files.write_request(&request),
Err(ProtocolCodecError::RequestTooLarge { .. })
));
assert!(!files.request_path().exists());
}
#[test]
fn operation_schema_rejects_unknown_fields() {
let data = serde_json::json!({
"ready": true,
"ghidra_version": "12.1.2",
"java_version": 21,
"adapter_protocol_version": 1,
"unexpected": true
});
assert!(validate_operation_data(Operation::Doctor, data).is_err());
}
#[test]
fn invocation_directory_must_be_absolute() {
assert!(matches!(
InvocationFiles::new(PathBuf::from("relative")),
Err(ProtocolCodecError::InvalidWorkerPath)
));
}
} }

98
src/runtime.rs Normal file
View file

@ -0,0 +1,98 @@
//! Runtime paths closed over by the Nix package and controllable in tests.
use std::{
ffi::OsString,
path::{Path, PathBuf},
};
use crate::{AppError, ErrorCode};
/// Complete external runtime configuration used by doctor and worker commands.
#[derive(Clone, Debug)]
pub struct RuntimeConfig {
/// Expected Ghidra version shipped with this adapter.
pub ghidra_version: String,
/// Official headless launcher.
pub analyze_headless: Option<PathBuf>,
/// JDK root containing `bin/java`.
pub java_home: Option<PathBuf>,
/// Directory containing the Java adapter source/classes.
pub adapter_path: Option<PathBuf>,
/// Bubblewrap executable used only in explicit Bubblewrap mode.
pub bubblewrap: Option<PathBuf>,
}
impl RuntimeConfig {
/// Reads only the documented, narrowly scoped runtime environment.
#[must_use]
pub fn from_process() -> Self {
Self {
ghidra_version: std::env::var("GHIDR_GHIDRA_VERSION")
.unwrap_or_else(|_| "12.1.2".to_owned()),
analyze_headless: absolute_env("GHIDR_ANALYZE_HEADLESS"),
java_home: absolute_env("GHIDR_JAVA_HOME"),
adapter_path: absolute_env("GHIDR_ADAPTER_PATH"),
bubblewrap: absolute_env("GHIDR_BUBBLEWRAP"),
}
}
/// Returns the configured Java executable.
#[must_use]
pub fn java_executable(&self) -> Option<PathBuf> {
self.java_home.as_ref().map(|path| path.join("bin/java"))
}
/// Requires one configured absolute runtime path before worker launch.
pub fn require(path: &Option<PathBuf>, name: &str) -> Result<PathBuf, AppError> {
path.clone().ok_or_else(|| {
AppError::new(
ErrorCode::GhidraMissing,
format!("required runtime path {name} is not configured as an absolute path"),
false,
)
})
}
/// Adapter source expected by the headless script loader.
#[must_use]
pub fn adapter_source(&self) -> Option<PathBuf> {
self.adapter_path
.as_ref()
.map(|path| path.join("GhidrAdapter.java"))
}
}
/// Builds JVM options that isolate Ghidra's Java-level profile and temp paths.
///
/// `HOME` and `TMPDIR` alone are insufficient because Ghidra resolves these
/// locations from Java system properties. `JDK_JAVA_OPTIONS` is consumed by
/// every JVM launched by Ghidra, including its initial JDK discovery helper.
pub fn private_jvm_options(home: &Path, temporary: &Path) -> Result<OsString, AppError> {
let home = quoted_jvm_path(home)?;
let temporary = quoted_jvm_path(temporary)?;
Ok(OsString::from(format!(
"-Duser.home=\"{home}\" -Djava.io.tmpdir=\"{temporary}\""
)))
}
fn quoted_jvm_path(path: &Path) -> Result<String, AppError> {
let path = path.to_str().ok_or_else(|| {
AppError::new(
ErrorCode::InvalidArguments,
"Ghidra private runtime paths must be valid UTF-8",
false,
)
})?;
Ok(path.replace('\\', "\\\\").replace('"', "\\\""))
}
fn absolute_env(name: &str) -> Option<PathBuf> {
std::env::var_os(name)
.map(PathBuf::from)
.filter(|path| path.is_absolute() && !has_parent(path))
}
fn has_parent(path: &Path) -> bool {
path.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
}

360
src/sandbox/mod.rs Normal file
View file

@ -0,0 +1,360 @@
//! Explicit worker sandbox policy and production Bubblewrap command construction.
use std::{
collections::{BTreeMap, BTreeSet},
ffi::{OsStr, OsString},
path::{Component, Path, PathBuf},
};
use thiserror::Error;
use crate::{
cli::SandboxMode,
operation::{SandboxBackend, SandboxProvenance, SandboxVerification, Warning},
};
/// A fully resolved worker command without a shell or caller-supplied runner.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorkerCommand {
/// Executable launched by trusted Rust.
pub program: PathBuf,
/// Exact argument vector.
pub arguments: Vec<OsString>,
/// Minimal explicit environment for the launched process.
pub environment: Vec<(OsString, OsString)>,
/// Public backend provenance.
pub provenance: SandboxProvenance,
/// Mandatory warning for external and disabled policies.
pub warnings: Vec<Warning>,
}
/// Inputs whose capabilities are exposed to one Bubblewrap worker.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BubblewrapConfig {
/// Pinned Bubblewrap executable.
pub bubblewrap: PathBuf,
/// Official Ghidra headless launcher or purpose-built probe executable.
pub worker_program: PathBuf,
/// Worker arguments, already validated and never interpreted by a shell.
pub worker_arguments: Vec<OsString>,
/// Exact pinned Nix closure roots visible read-only at their host paths.
pub readonly_paths: Vec<PathBuf>,
/// Staged Sample exposed read-only, when applicable.
pub staged_sample: Option<PathBuf>,
/// Invocation and project/staging paths exposed read-write.
pub writable_paths: Vec<PathBuf>,
/// Empty private home created by trusted Rust.
pub private_home: PathBuf,
/// Empty private temporary directory created by trusted Rust.
pub private_tmp: PathBuf,
}
impl WorkerCommand {
/// Constructs the selected policy without auto-detection or fallback.
pub fn for_policy(
mode: SandboxMode,
direct_program: PathBuf,
direct_arguments: Vec<OsString>,
bubblewrap: Option<BubblewrapConfig>,
) -> Result<Self, SandboxError> {
validate_absolute_utf8(&direct_program)?;
match mode {
SandboxMode::Bubblewrap => {
let config = bubblewrap.ok_or(SandboxError::MissingBubblewrapConfiguration)?;
build_bubblewrap(config)
}
SandboxMode::External => Ok(Self {
program: direct_program,
arguments: direct_arguments,
environment: Vec::new(),
provenance: SandboxProvenance {
backend: SandboxBackend::External,
verification: SandboxVerification::Unverified,
},
warnings: vec![policy_warning(
"external_sandbox_unverified",
"external sandbox isolation cannot be verified by ghidr",
)],
}),
SandboxMode::Off => Ok(Self {
program: direct_program,
arguments: direct_arguments,
environment: Vec::new(),
provenance: SandboxProvenance {
backend: SandboxBackend::Off,
verification: SandboxVerification::Disabled,
},
warnings: vec![policy_warning(
"sandbox_disabled",
"worker sandbox isolation is explicitly disabled",
)],
}),
}
}
/// Adds one explicit worker environment value after the default environment is cleared.
#[must_use]
pub fn with_environment(mut self, name: &str, value: impl Into<OsString>) -> Self {
let value = value.into();
if self.provenance.backend == SandboxBackend::Bubblewrap {
if let Some(position) = self.arguments.iter().position(|argument| argument == "--") {
self.arguments.splice(
position..position,
[OsString::from("--setenv"), OsString::from(name), value],
);
}
} else {
self.environment.push((OsString::from(name), value));
}
self
}
}
fn build_bubblewrap(config: BubblewrapConfig) -> Result<WorkerCommand, SandboxError> {
validate_absolute_utf8(&config.bubblewrap)?;
validate_absolute_utf8(&config.worker_program)?;
validate_absolute_utf8(&config.private_home)?;
validate_absolute_utf8(&config.private_tmp)?;
for path in config
.readonly_paths
.iter()
.chain(config.staged_sample.iter())
.chain(config.writable_paths.iter())
{
validate_absolute_utf8(path)?;
}
let mut arguments = os_args([
"--die-with-parent",
"--new-session",
"--unshare-all",
"--cap-drop",
"ALL",
"--clearenv",
"--tmpfs",
"/",
"--proc",
"/proc",
"--dev",
"/dev",
]);
let mut directories = BTreeSet::new();
for path in config
.readonly_paths
.iter()
.chain(config.writable_paths.iter())
.chain([&config.private_home, &config.private_tmp])
{
add_destination_directories(&mut directories, path, true);
}
if let Some(sample) = &config.staged_sample {
add_destination_directories(&mut directories, sample, false);
}
for directory in directories {
arguments.push(OsString::from("--dir"));
arguments.push(directory.into_os_string());
}
// Apply broad writable staging capabilities before narrower read-only
// mounts so a staged Sample can never be made writable by a later parent
// bind.
for path in &config.writable_paths {
bind(&mut arguments, "--bind", path, path);
}
bind(
&mut arguments,
"--bind",
&config.private_home,
&config.private_home,
);
bind(
&mut arguments,
"--bind",
&config.private_tmp,
&config.private_tmp,
);
for path in &config.readonly_paths {
bind(&mut arguments, "--ro-bind", path, path);
}
if let Some(sample) = &config.staged_sample {
bind(&mut arguments, "--ro-bind", sample, sample);
}
arguments.extend(os_args(["--setenv", "HOME"]));
arguments.push(config.private_home.as_os_str().to_owned());
arguments.extend(os_args(["--setenv", "TMPDIR"]));
arguments.push(config.private_tmp.as_os_str().to_owned());
arguments.push(OsString::from("--chdir"));
arguments.push(config.private_tmp.as_os_str().to_owned());
arguments.push(OsString::from("--"));
arguments.push(config.worker_program.as_os_str().to_owned());
arguments.extend(config.worker_arguments);
Ok(WorkerCommand {
program: config.bubblewrap,
arguments,
environment: Vec::new(),
provenance: SandboxProvenance {
backend: SandboxBackend::Bubblewrap,
verification: SandboxVerification::Verified,
},
warnings: Vec::new(),
})
}
fn add_destination_directories(
directories: &mut BTreeSet<PathBuf>,
path: &Path,
include_path: bool,
) {
let start = if include_path {
Some(path)
} else {
path.parent()
};
for ancestor in start.into_iter().flat_map(Path::ancestors) {
if ancestor != Path::new("/") {
directories.insert(ancestor.to_path_buf());
}
}
}
fn bind(arguments: &mut Vec<OsString>, flag: &str, source: &Path, destination: &Path) {
arguments.push(OsString::from(flag));
arguments.push(source.as_os_str().to_owned());
arguments.push(destination.as_os_str().to_owned());
}
fn os_args<const N: usize>(values: [&str; N]) -> Vec<OsString> {
values.into_iter().map(OsString::from).collect()
}
fn validate_absolute_utf8(path: &Path) -> Result<(), SandboxError> {
if !path.is_absolute()
|| path.to_str().is_none()
|| path
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err(SandboxError::InvalidCapabilityPath(path.to_path_buf()));
}
Ok(())
}
fn policy_warning(code: &str, message: &str) -> Warning {
Warning {
code: code.to_owned(),
message: message.to_owned(),
details: BTreeMap::new(),
}
}
/// Sandbox construction failure before worker launch.
#[derive(Debug, Error)]
pub enum SandboxError {
/// Bubblewrap mode requires its pinned executable and capability list.
#[error("bubblewrap mode requires an explicit production configuration")]
MissingBubblewrapConfiguration,
/// Every path crossing the sandbox boundary must be absolute UTF-8.
#[error("sandbox capability path must be absolute UTF-8 without parent traversal: {0:?}")]
InvalidCapabilityPath(PathBuf),
}
/// Appends the two fixed adapter protocol paths to a worker argument vector.
#[must_use]
pub fn adapter_file_arguments(request: &Path, response: &Path) -> Vec<OsString> {
vec![
OsString::from("--request"),
request.as_os_str().to_owned(),
OsString::from("--response"),
response.as_os_str().to_owned(),
]
}
/// Returns whether an argument vector contains an exact OS string.
#[must_use]
pub fn contains_argument(arguments: &[OsString], expected: impl AsRef<OsStr>) -> bool {
arguments
.iter()
.any(|argument| argument == expected.as_ref())
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use std::{ffi::OsString, path::PathBuf};
use super::{BubblewrapConfig, WorkerCommand, contains_argument};
use crate::{
cli::SandboxMode,
operation::{SandboxBackend, SandboxVerification},
};
#[test]
fn bubblewrap_has_minimal_namespaces_capabilities_and_parent_death() {
let command = WorkerCommand::for_policy(
SandboxMode::Bubblewrap,
PathBuf::from("/nix/store/worker/bin/worker"),
vec![],
Some(BubblewrapConfig {
bubblewrap: PathBuf::from("/nix/store/bwrap/bin/bwrap"),
worker_program: PathBuf::from("/nix/store/worker/bin/worker"),
worker_arguments: vec![OsString::from("--request")],
readonly_paths: vec![PathBuf::from("/nix/store/worker")],
staged_sample: Some(PathBuf::from("/store/staging/sample")),
writable_paths: vec![PathBuf::from("/store/staging")],
private_home: PathBuf::from("/store/staging/home"),
private_tmp: PathBuf::from("/store/staging/tmp"),
}),
)
.expect("bubblewrap command");
for flag in [
"--die-with-parent",
"--new-session",
"--unshare-all",
"--cap-drop",
"--clearenv",
] {
assert!(contains_argument(&command.arguments, flag));
}
assert_eq!(command.provenance.backend, SandboxBackend::Bubblewrap);
assert_eq!(
command.provenance.verification,
SandboxVerification::Verified
);
let writable_position = command
.arguments
.iter()
.position(|argument| argument == "/store/staging")
.expect("writable bind");
let sample_position = command
.arguments
.iter()
.position(|argument| argument == "/store/staging/sample")
.expect("Sample bind");
assert!(sample_position > writable_position);
assert!(command.warnings.is_empty());
}
#[test]
fn external_and_off_are_explicit_in_provenance_and_warnings() {
for (mode, backend, verification, code) in [
(
SandboxMode::External,
SandboxBackend::External,
SandboxVerification::Unverified,
"external_sandbox_unverified",
),
(
SandboxMode::Off,
SandboxBackend::Off,
SandboxVerification::Disabled,
"sandbox_disabled",
),
] {
let command = WorkerCommand::for_policy(mode, PathBuf::from("/worker"), vec![], None)
.expect("direct command");
assert_eq!(command.provenance.backend, backend);
assert_eq!(command.provenance.verification, verification);
assert_eq!(command.warnings[0].code, code);
}
}
}

View file

@ -8,8 +8,8 @@ use serde_json::{Value, json};
use crate::{ use crate::{
error::ErrorEnvelope, error::ErrorEnvelope,
operation::{ operation::{
AnalysisProfile, CleanupData, DecompilationData, DoctorData, FunctionsData, InspectionData, AnalysisProfile, CleanupData, DecompilationData, DoctorData, FunctionsData,
QueryProvenance, SuccessEnvelope, ToolProvenance, InlineOrArtifact, InspectionData, QueryProvenance, SuccessEnvelope, ToolProvenance,
}, },
protocol::{AdapterRequest, AdapterResponse}, protocol::{AdapterRequest, AdapterResponse},
}; };
@ -25,21 +25,24 @@ pub fn v1_schemas() -> BTreeMap<&'static str, Schema> {
( (
"cleanup.schema.json", "cleanup.schema.json",
with_kind( with_kind(
schema_for!(SuccessEnvelope<ToolProvenance, CleanupData>), schema_for!(SuccessEnvelope<ToolProvenance, InlineOrArtifact<CleanupData>>),
"cleanup", "cleanup",
), ),
), ),
( (
"decompilation.schema.json", "decompilation.schema.json",
with_kind( with_kind(
schema_for!(SuccessEnvelope<QueryProvenance, DecompilationData>), schema_for!(SuccessEnvelope<
QueryProvenance,
InlineOrArtifact<DecompilationData>,
>),
"decompilation", "decompilation",
), ),
), ),
( (
"doctor.schema.json", "doctor.schema.json",
with_kind( with_kind(
schema_for!(SuccessEnvelope<ToolProvenance, DoctorData>), schema_for!(SuccessEnvelope<ToolProvenance, InlineOrArtifact<DoctorData>>),
"doctor", "doctor",
), ),
), ),
@ -54,14 +57,14 @@ pub fn v1_schemas() -> BTreeMap<&'static str, Schema> {
( (
"functions.schema.json", "functions.schema.json",
with_kind( with_kind(
schema_for!(SuccessEnvelope<QueryProvenance, FunctionsData>), schema_for!(SuccessEnvelope<QueryProvenance, InlineOrArtifact<FunctionsData>>),
"functions", "functions",
), ),
), ),
( (
"inspection.schema.json", "inspection.schema.json",
with_kind( with_kind(
schema_for!(SuccessEnvelope<QueryProvenance, InspectionData>), schema_for!(SuccessEnvelope<QueryProvenance, InlineOrArtifact<InspectionData>>),
"inspection", "inspection",
), ),
), ),

View file

@ -236,7 +236,7 @@ impl AnalysisStore {
profile: &Digest, profile: &Digest,
lock: &AnalysisLock, lock: &AnalysisLock,
) -> Result<PathBuf, StoreError> { ) -> Result<PathBuf, StoreError> {
if !lock.guards(sample, profile) { if !lock.guards_exclusive(sample, profile) {
return Err(StoreError::new( return Err(StoreError::new(
StoreErrorKind::AnalysisBusy, StoreErrorKind::AnalysisBusy,
"lock token does not guard the promoted Analysis identity", "lock token does not guard the promoted Analysis identity",
@ -267,7 +267,7 @@ impl AnalysisStore {
lock: &AnalysisLock, lock: &AnalysisLock,
rebuild_already_attempted: bool, rebuild_already_attempted: bool,
) -> Result<QuarantineOutcome, StoreError> { ) -> Result<QuarantineOutcome, StoreError> {
if !lock.guards(sample, profile) { if !lock.guards_exclusive(sample, profile) {
return Err(StoreError::new( return Err(StoreError::new(
StoreErrorKind::AnalysisBusy, StoreErrorKind::AnalysisBusy,
"lock token does not guard the quarantined Analysis identity", "lock token does not guard the quarantined Analysis identity",

View file

@ -57,6 +57,10 @@ pub fn execute_cleanup(
"store-wide cleanup requires explicit confirmation", "store-wide cleanup requires explicit confirmation",
false, false,
) )
.with_detail(
"matched",
serde_json::to_value(plan.snapshot()).unwrap_or(Value::Null),
)
.with_detail("analyses", Value::from(plan.snapshot().analyses)) .with_detail("analyses", Value::from(plan.snapshot().analyses))
.with_detail("artifacts", Value::from(plan.snapshot().artifacts)) .with_detail("artifacts", Value::from(plan.snapshot().artifacts))
.with_detail( .with_detail(

View file

@ -1,10 +1,11 @@
use std::{ use std::{
fs::{self, File, OpenOptions}, fs::{self, File, OpenOptions},
io::Write as _, io::{self, Write as _},
os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}, os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _},
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use rustix::fs::{Mode, OFlags};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::operation::{StorageUsage, StoreSource}; use crate::operation::{StorageUsage, StoreSource};
@ -123,6 +124,77 @@ pub(crate) fn create_private_directory(path: &Path) -> Result<(), StoreError> {
.map_err(|error| StoreError::io(path, error)) .map_err(|error| StoreError::io(path, error))
} }
/// Copies a validated project into private disposable storage without following
/// symlinks or accepting special files.
pub(crate) fn copy_private_tree(source: &Path, destination: &Path) -> Result<(), StoreError> {
let metadata = fs::symlink_metadata(source).map_err(|error| StoreError::io(source, error))?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(StoreError::at(
StoreErrorKind::CorruptStore,
source,
"project snapshot source is not an ordinary directory",
));
}
create_private_directory(destination)?;
let mut entries = fs::read_dir(source)
.map_err(|error| StoreError::io(source, error))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| StoreError::io(source, error))?;
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
let metadata = fs::symlink_metadata(&source_path)
.map_err(|error| StoreError::io(&source_path, error))?;
if metadata.file_type().is_symlink() {
return Err(StoreError::at(
StoreErrorKind::CorruptStore,
&source_path,
"symlink found while copying cached Analysis",
));
}
if metadata.is_dir() {
copy_private_tree(&source_path, &destination_path)?;
} else if metadata.is_file() {
copy_private_file(&source_path, &destination_path)?;
} else {
return Err(StoreError::at(
StoreErrorKind::CorruptStore,
&source_path,
"special file found while copying cached Analysis",
));
}
}
sync_directory(destination)
}
fn copy_private_file(source: &Path, destination: &Path) -> Result<(), StoreError> {
let source_fd = rustix::fs::open(
source,
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(|error| StoreError::io(source, io::Error::from(error)))?;
let mut source_file = File::from(source_fd);
if !source_file
.metadata()
.map_err(|error| StoreError::io(source, error))?
.is_file()
{
return Err(StoreError::at(
StoreErrorKind::CorruptStore,
source,
"cached Analysis entry changed type while copying",
));
}
let mut destination_file = open_new_private_file(destination)?;
io::copy(&mut source_file, &mut destination_file)
.map_err(|error| StoreError::io(destination, error))?;
destination_file
.sync_all()
.map_err(|error| StoreError::io(destination, error))
}
fn reject_symlink_ancestors(path: &Path) -> Result<(), StoreError> { fn reject_symlink_ancestors(path: &Path) -> Result<(), StoreError> {
for ancestor in path.ancestors().collect::<Vec<_>>().into_iter().rev() { for ancestor in path.ancestors().collect::<Vec<_>>().into_iter().rev() {
match fs::symlink_metadata(ancestor) { match fs::symlink_metadata(ancestor) {
@ -280,7 +352,7 @@ mod tests {
use tempfile::TempDir; use tempfile::TempDir;
use super::AnalysisStore; use super::{AnalysisStore, copy_private_tree};
use crate::store::{StoreEnvironment, resolve_store}; use crate::store::{StoreEnvironment, resolve_store};
fn store(temp: &TempDir) -> AnalysisStore { fn store(temp: &TempDir) -> AnalysisStore {
@ -329,4 +401,23 @@ mod tests {
symlink(&outside, store.root().join("artifacts/escape")).expect("symlink"); symlink(&outside, store.root().join("artifacts/escape")).expect("symlink");
assert!(store.usage().is_err()); assert!(store.usage().is_err());
} }
#[test]
fn private_tree_copy_is_exact_and_rejects_symlinks() {
let temp = TempDir::new().expect("temp");
let source = temp.path().join("source");
let destination = temp.path().join("destination");
fs::create_dir_all(source.join("nested")).expect("source");
fs::write(source.join("nested/data"), b"exact bytes").expect("data");
copy_private_tree(&source, &destination).expect("copy");
assert_eq!(
fs::read(destination.join("nested/data")).expect("copied data"),
b"exact bytes"
);
let outside = temp.path().join("outside");
fs::write(&outside, b"outside").expect("outside");
symlink(&outside, source.join("escape")).expect("symlink");
assert!(copy_private_tree(&source, &temp.path().join("rejected")).is_err());
}
} }

View file

@ -9,13 +9,14 @@ use crate::domain::Digest;
use super::{AnalysisStore, StoreError, StoreErrorKind, filesystem::create_private_directory}; use super::{AnalysisStore, StoreError, StoreErrorKind, filesystem::create_private_directory};
/// Held nonblocking exclusive per-Analysis OS lock. /// Held nonblocking shared or exclusive per-Analysis OS lock.
#[derive(Debug)] #[derive(Debug)]
pub struct AnalysisLock { pub struct AnalysisLock {
_file: File, _file: File,
path: PathBuf, path: PathBuf,
sample: Digest, sample: Digest,
profile: Digest, profile: Digest,
exclusive: bool,
} }
impl AnalysisLock { impl AnalysisLock {
@ -24,6 +25,37 @@ impl AnalysisLock {
store: &AnalysisStore, store: &AnalysisStore,
sample: &Digest, sample: &Digest,
profile: &Digest, profile: &Digest,
) -> Result<Self, StoreError> {
Self::acquire_with_operation(
store,
sample,
profile,
FlockOperation::NonBlockingLockExclusive,
true,
)
}
/// Immediately acquires a shared immutable-Query lock.
pub fn acquire_shared(
store: &AnalysisStore,
sample: &Digest,
profile: &Digest,
) -> Result<Self, StoreError> {
Self::acquire_with_operation(
store,
sample,
profile,
FlockOperation::NonBlockingLockShared,
false,
)
}
fn acquire_with_operation(
store: &AnalysisStore,
sample: &Digest,
profile: &Digest,
operation: FlockOperation,
exclusive: bool,
) -> Result<Self, StoreError> { ) -> Result<Self, StoreError> {
let directory = store.path("locks").join(sample.as_str()); let directory = store.path("locks").join(sample.as_str());
create_private_directory(&directory)?; create_private_directory(&directory)?;
@ -41,12 +73,13 @@ impl AnalysisLock {
) )
})?; })?;
let file = File::from(descriptor); let file = File::from(descriptor);
match rustix::fs::flock(&file, FlockOperation::NonBlockingLockExclusive) { match rustix::fs::flock(&file, operation) {
Ok(()) => Ok(Self { Ok(()) => Ok(Self {
_file: file, _file: file,
path, path,
sample: sample.clone(), sample: sample.clone(),
profile: profile.clone(), profile: profile.clone(),
exclusive,
}), }),
Err(error) if error == rustix::io::Errno::WOULDBLOCK => Err(StoreError::at( Err(error) if error == rustix::io::Errno::WOULDBLOCK => Err(StoreError::at(
StoreErrorKind::AnalysisBusy, StoreErrorKind::AnalysisBusy,
@ -78,6 +111,12 @@ impl AnalysisLock {
pub fn guards(&self, sample: &Digest, profile: &Digest) -> bool { pub fn guards(&self, sample: &Digest, profile: &Digest) -> bool {
&self.sample == sample && &self.profile == profile &self.sample == sample && &self.profile == profile
} }
/// Returns whether the token permits promotion, quarantine, or cleanup.
#[must_use]
pub fn guards_exclusive(&self, sample: &Digest, profile: &Digest) -> bool {
self.exclusive && self.guards(sample, profile)
}
} }
#[cfg(test)] #[cfg(test)]
@ -109,4 +148,25 @@ mod tests {
drop(first); drop(first);
assert!(AnalysisLock::acquire(&store, &sample, &profile).is_ok()); assert!(AnalysisLock::acquire(&store, &sample, &profile).is_ok());
} }
#[test]
fn immutable_queries_share_locks_but_exclude_cleanup() {
let temp = TempDir::new().expect("temp");
let environment = StoreEnvironment {
ghidr_store: Some(OsString::from(temp.path().join("store"))),
..StoreEnvironment::default()
};
let store = AnalysisStore::initialize(resolve_store(None, &environment).expect("resolve"))
.expect("store");
let sample = Digest::from_str(&"a".repeat(64)).expect("digest");
let profile = Digest::from_str(&"b".repeat(64)).expect("digest");
let first =
AnalysisLock::acquire_shared(&store, &sample, &profile).expect("first shared lock");
let second =
AnalysisLock::acquire_shared(&store, &sample, &profile).expect("second shared lock");
assert!(AnalysisLock::acquire(&store, &sample, &profile).is_err());
drop(first);
drop(second);
assert!(AnalysisLock::acquire(&store, &sample, &profile).is_ok());
}
} }

View file

@ -20,6 +20,7 @@ pub use cleanup::{CleanupPlan, CleanupScope, CleanupTransaction, plan_cleanup};
pub use cleanup_command::execute_cleanup; pub use cleanup_command::execute_cleanup;
pub use error::{StoreError, StoreErrorKind}; pub use error::{StoreError, StoreErrorKind};
pub use filesystem::{AnalysisStore, STORE_LAYOUT_VERSION, StoreUsage}; pub use filesystem::{AnalysisStore, STORE_LAYOUT_VERSION, StoreUsage};
pub(crate) use filesystem::{copy_private_tree, create_private_directory};
pub use lock::AnalysisLock; pub use lock::AnalysisLock;
pub use resolution::{ResolvedStore, StoreEnvironment, resolve_store}; pub use resolution::{ResolvedStore, StoreEnvironment, resolve_store};
pub use sample::{DEFAULT_STORE_RESERVE_BYTES, SampleSnapshot, SnapshotOptions, snapshot_sample}; pub use sample::{DEFAULT_STORE_RESERVE_BYTES, SampleSnapshot, SnapshotOptions, snapshot_sample};

View file

@ -72,6 +72,12 @@ impl SampleSnapshot {
&self.path &self.path
} }
/// Private staging root used to assemble a complete Analysis.
#[must_use]
pub fn staging_directory(&self) -> &Path {
&self.staging_directory
}
/// Exact caller-supplied path retained only as provenance. /// Exact caller-supplied path retained only as provenance.
#[must_use] #[must_use]
pub const fn source_path(&self) -> &TaggedPath { pub const fn source_path(&self) -> &TaggedPath {

48
tests/adapter_contract.rs Normal file
View file

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

120
tests/bubblewrap_probe.rs Normal file
View file

@ -0,0 +1,120 @@
#![allow(clippy::expect_used)]
#![doc = "Host-permitted production Bubblewrap network-isolation probe."]
use std::{
env,
ffi::OsString,
net::TcpListener,
path::{Path, PathBuf},
process::Command,
};
use ghidra_cli::{
cli::SandboxMode,
sandbox::{BubblewrapConfig, WorkerCommand},
};
#[test]
fn production_bubblewrap_command_cannot_reach_host_listener_when_supported() {
let Some(bubblewrap) = bubblewrap_path() else {
return;
};
let fake_child = PathBuf::from(env!("CARGO_BIN_EXE_ghidr-fake-child"));
let Ok(listener) = TcpListener::bind("127.0.0.1:0") else {
// Some CI sandboxes prohibit even loopback listeners.
return;
};
let address = listener.local_addr().expect("listener address").to_string();
let direct = Command::new(&fake_child)
.args(["network-probe", &address])
.status()
.expect("direct probe process");
assert!(
direct.success(),
"control probe must reach the host listener"
);
let private = tempfile::tempdir().expect("sandbox private directory");
let home = private.path().join("home");
let temporary = private.path().join("tmp");
std::fs::create_dir(&home).expect("private home");
std::fs::create_dir(&temporary).expect("private tmp");
let benign = bubblewrap_command(
&bubblewrap,
&fake_child,
vec![OsString::from("exit-success")],
&home,
&temporary,
);
let benign_status = Command::new(&benign.program)
.args(&benign.arguments)
.status()
.expect("Bubblewrap capability probe");
if !benign_status.success() {
// User namespaces are legitimately unavailable on some test hosts.
return;
}
let isolated = bubblewrap_command(
&bubblewrap,
&fake_child,
vec![OsString::from("network-probe"), OsString::from(address)],
&home,
&temporary,
);
let isolated_status = Command::new(&isolated.program)
.args(&isolated.arguments)
.status()
.expect("isolated probe");
assert!(
!isolated_status.success(),
"worker unexpectedly reached a host listener across --unshare-all"
);
}
fn bubblewrap_command(
bubblewrap: &Path,
fake_child: &Path,
worker_arguments: Vec<OsString>,
home: &Path,
temporary: &Path,
) -> WorkerCommand {
WorkerCommand::for_policy(
SandboxMode::Bubblewrap,
fake_child.to_path_buf(),
worker_arguments.clone(),
Some(BubblewrapConfig {
bubblewrap: bubblewrap.to_path_buf(),
worker_program: fake_child.to_path_buf(),
worker_arguments,
readonly_paths: vec![
PathBuf::from("/nix/store"),
fake_child
.parent()
.expect("fake child parent")
.to_path_buf(),
],
staged_sample: None,
writable_paths: Vec::new(),
private_home: home.to_path_buf(),
private_tmp: temporary.to_path_buf(),
}),
)
.expect("production Bubblewrap command")
}
fn bubblewrap_path() -> Option<PathBuf> {
env::var_os("GHIDR_TEST_BWRAP")
.map(PathBuf::from)
.or_else(|| find_in_path("bwrap"))
}
fn find_in_path(executable: &str) -> Option<PathBuf> {
env::var_os("PATH").and_then(|path| {
env::split_paths(&path)
.map(|directory| directory.join(executable))
.find(|candidate| candidate.is_file())
})
}

View file

@ -1,20 +1,237 @@
#![allow(clippy::expect_used)] #![allow(clippy::expect_used)]
#![doc = "Black-box CLI framing and exit-status tests."] #![doc = "Black-box CLI framing and exit-status tests."]
use std::process::Command; use std::{
fs,
os::unix::fs::PermissionsExt as _,
process::{Command, Stdio},
thread,
time::Duration,
};
use rustix::process::{Pid, Signal, kill_process};
#[test] #[test]
fn unimplemented_operation_is_a_typed_json_runtime_failure() { fn doctor_reports_ready_with_closed_runtime_paths() {
let temp = tempfile::tempdir().expect("temp");
let runtime = temp.path().join("runtime");
let java_home = runtime.join("jdk");
let adapter = runtime.join("adapter");
fs::create_dir_all(java_home.join("bin")).expect("java directory");
fs::create_dir_all(&adapter).expect("adapter directory");
for path in [runtime.join("bwrap"), adapter.join("GhidrAdapter.java")] {
fs::write(path, b"fixture").expect("runtime fixture");
}
let java = java_home.join("bin/java");
fs::write(&java, b"#!/bin/sh\necho 'openjdk version \"21\"' >&2\n").expect("java fixture");
fs::set_permissions(&java, fs::Permissions::from_mode(0o755)).expect("java mode");
let output = Command::new(env!("CARGO_BIN_EXE_ghidr")) let output = Command::new(env!("CARGO_BIN_EXE_ghidr"))
.arg("doctor") .args([
"--store",
temp.path().join("store").to_str().expect("UTF-8"),
"--sandbox",
"off",
"doctor",
])
.env("GHIDR_GHIDRA_VERSION", "12.1.2")
.env(
"GHIDR_ANALYZE_HEADLESS",
env!("CARGO_BIN_EXE_ghidr-fake-child"),
)
.env("GHIDR_JAVA_HOME", java_home)
.env("GHIDR_ADAPTER_PATH", adapter)
.env("GHIDR_BUBBLEWRAP", runtime.join("bwrap"))
.output() .output()
.expect("run ghidr"); .expect("run ghidr");
assert_eq!(output.status.code(), Some(1)); assert_eq!(output.status.code(), Some(0), "{:?}", output.stderr);
assert!(output.stdout.is_empty()); assert!(output.stderr.is_empty());
let success: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("valid JSON success");
assert_eq!(success["kind"], "doctor");
assert_eq!(success["data"]["ready"], true);
assert!(
!temp
.path()
.join("store/staging/doctor-write-probe")
.exists()
);
}
#[test]
fn clean_dry_run_and_execution_are_real_store_transactions() {
let temp = tempfile::tempdir().expect("temp");
let store = temp.path().join("store");
let sample = "a".repeat(64);
let profile = "b".repeat(64);
let artifact = store
.join("artifacts")
.join(&sample)
.join(&profile)
.join(format!("{}.json", "c".repeat(64)));
fs::create_dir_all(artifact.parent().expect("parent")).expect("artifact directory");
fs::write(&artifact, b"{}\n").expect("artifact");
let invoke = |dry_run: bool| {
let mut command = Command::new(env!("CARGO_BIN_EXE_ghidr"));
command.args([
"--store",
store.to_str().expect("UTF-8"),
"clean",
"--digest",
&sample,
]);
if dry_run {
command.arg("--dry-run");
}
command.output().expect("run clean")
};
let dry_run = invoke(true);
assert_eq!(dry_run.status.code(), Some(0), "{:?}", dry_run.stderr);
let report: serde_json::Value = serde_json::from_slice(&dry_run.stdout).expect("dry-run JSON");
assert_eq!(report["data"]["mode"], "dry_run");
assert_eq!(report["data"]["matched"]["artifacts"], 1);
assert!(artifact.exists());
let executed = invoke(false);
assert_eq!(executed.status.code(), Some(0), "{:?}", executed.stderr);
let report: serde_json::Value =
serde_json::from_slice(&executed.stdout).expect("execution JSON");
assert_eq!(report["data"]["mode"], "executed");
assert_eq!(report["data"]["removed"]["artifacts"], 1);
assert!(!artifact.exists());
let confirmation = Command::new(env!("CARGO_BIN_EXE_ghidr"))
.args(["--store", store.to_str().expect("UTF-8"), "clean", "--all"])
.output()
.expect("run clean all");
assert_eq!(confirmation.status.code(), Some(1));
assert!(confirmation.stdout.is_empty());
let error: serde_json::Value = let error: serde_json::Value =
serde_json::from_slice(&output.stderr).expect("valid JSON error"); serde_json::from_slice(&confirmation.stderr).expect("confirmation JSON");
assert_eq!(error["error"]["code"], "internal_not_implemented"); assert_eq!(error["error"]["code"], "confirmation_required");
assert_eq!(error["error"]["details"]["operation"], "doctor"); assert!(error["error"]["details"]["matched"]["usage"].is_object());
}
#[test]
fn fake_worker_drives_inspect_reuse_functions_decompile_and_typed_error() {
let temp = tempfile::tempdir().expect("temp");
let store = temp.path().join("store");
let adapter = temp.path().join("adapter");
fs::create_dir(&adapter).expect("adapter");
fs::write(adapter.join("GhidrAdapter.java"), b"fixture").expect("adapter source");
let sample = temp.path().join("sample");
fs::write(&sample, b"controllable sample").expect("sample");
let invoke = |arguments: &[&str], error: Option<&str>| {
let mut command = Command::new(env!("CARGO_BIN_EXE_ghidr"));
command
.args([
"--sandbox",
"off",
"--store",
store.to_str().expect("UTF-8"),
])
.args(arguments)
.env("GHIDR_GHIDRA_VERSION", "12.1.2")
.env(
"GHIDR_ANALYZE_HEADLESS",
env!("CARGO_BIN_EXE_ghidr-fake-child"),
)
.env("GHIDR_ADAPTER_PATH", &adapter);
if let Some(code) = error {
command.env("GHIDR_FAKE_WORKER_ERROR", code);
}
command.output().expect("run Query")
};
let inspected = invoke(&["inspect", sample.to_str().expect("UTF-8")], None);
assert_eq!(inspected.status.code(), Some(0), "{:?}", inspected.stderr);
let inspection: serde_json::Value =
serde_json::from_slice(&inspected.stdout).expect("inspection");
assert_eq!(inspection["data"]["analysis"]["disposition"], "created");
assert_eq!(inspection["provenance"]["sandbox"]["backend"], "off");
let profile = inspection["provenance"]["analysis_profile_sha256"].clone();
let functions = invoke(
&["functions", sample.to_str().expect("UTF-8"), "--limit", "1"],
None,
);
assert_eq!(functions.status.code(), Some(0), "{:?}", functions.stderr);
let functions: serde_json::Value =
serde_json::from_slice(&functions.stdout).expect("functions");
assert_eq!(functions["data"]["items"][0]["name"], "main");
assert_eq!(functions["provenance"]["analysis_profile_sha256"], profile);
assert_eq!(
fs::read_dir(store.join("analyses"))
.expect("analyses")
.flat_map(|entry| fs::read_dir(entry.expect("sample entry").path()).expect("profiles"))
.count(),
1
);
let decompiled = invoke(
&[
"decompile",
sample.to_str().expect("UTF-8"),
"--name",
"main",
],
None,
);
assert_eq!(decompiled.status.code(), Some(0), "{:?}", decompiled.stderr);
let decompiled: serde_json::Value =
serde_json::from_slice(&decompiled.stdout).expect("decompilation");
assert_eq!(decompiled["data"]["requested_selector"]["value"], "main");
assert!(
decompiled["data"]["decompilation"]["text"]
.as_str()
.expect("text")
.contains("return 0")
);
let spilled = invoke(
&[
"decompile",
sample.to_str().expect("UTF-8"),
"--name",
"large",
"--max-inline-bytes",
"4096",
],
None,
);
assert_eq!(spilled.status.code(), Some(0), "{:?}", spilled.stderr);
let spilled: serde_json::Value =
serde_json::from_slice(&spilled.stdout).expect("spill descriptor");
assert_eq!(spilled["data"]["spilled"], true);
let artifact = spilled["data"]["artifact"]["path"]["value"]
.as_str()
.expect("Artifact path");
let complete = fs::read(artifact).expect("complete Artifact");
assert_eq!(complete.last(), Some(&b'\n'));
let complete: serde_json::Value =
serde_json::from_slice(&complete).expect("complete success response");
assert_eq!(
complete["data"]["decompilation"]["text"]
.as_str()
.expect("large text")
.len(),
100_000
);
let other = temp.path().join("other");
fs::copy("tests/fixtures/fake-worker-error.sample", &other).expect("other");
let failed = invoke(
&["inspect", other.to_str().expect("UTF-8")],
Some("analysis_failed"),
);
assert_eq!(failed.status.code(), Some(1));
assert!(failed.stdout.is_empty());
let error: serde_json::Value = serde_json::from_slice(&failed.stderr).expect("error JSON");
assert_eq!(error["error"]["code"], "analysis_failed");
let diagnostic = error["error"]["details"]["diagnostic_log"]["path"]["value"]
.as_str()
.expect("diagnostic path");
assert!(std::path::Path::new(diagnostic).is_file());
} }
#[test] #[test]
@ -41,3 +258,51 @@ fn explicit_human_parse_failure_is_not_json() {
assert!(output.stdout.is_empty()); assert!(output.stdout.is_empty());
assert!(serde_json::from_slice::<serde_json::Value>(&output.stderr).is_err()); assert!(serde_json::from_slice::<serde_json::Value>(&output.stderr).is_err());
} }
#[test]
fn sigint_interrupts_the_actual_cli_and_never_promotes_analysis() {
let temp = tempfile::tempdir().expect("temp");
let store = temp.path().join("store");
let adapter = temp.path().join("adapter");
fs::create_dir(&adapter).expect("adapter");
fs::write(adapter.join("GhidrAdapter.java"), b"fixture").expect("adapter source");
let sample = temp.path().join("sample");
fs::write(&sample, b"interruptible sample").expect("sample");
let child = Command::new(env!("CARGO_BIN_EXE_ghidr"))
.args([
"--sandbox",
"off",
"--store",
store.to_str().expect("UTF-8"),
"inspect",
sample.to_str().expect("UTF-8"),
])
.env("GHIDR_GHIDRA_VERSION", "12.1.2")
.env(
"GHIDR_ANALYZE_HEADLESS",
env!("CARGO_BIN_EXE_ghidr-fake-child"),
)
.env("GHIDR_ADAPTER_PATH", &adapter)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn ghidr");
thread::sleep(Duration::from_millis(200));
let raw_pid = i32::try_from(child.id()).expect("PID fits i32");
let pid = Pid::from_raw(raw_pid).expect("positive child PID");
kill_process(pid, Signal::INT).expect("send SIGINT");
let output = child.wait_with_output().expect("wait for interrupted CLI");
assert_eq!(output.status.code(), Some(130), "{:?}", output.stderr);
assert!(output.stdout.is_empty());
let error: serde_json::Value =
serde_json::from_slice(&output.stderr).expect("interrupted JSON");
assert_eq!(error["error"]["code"], "interrupted");
assert_eq!(
fs::read_dir(store.join("analyses"))
.expect("analyses")
.count(),
0
);
}

View file

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

View file

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

View file

@ -0,0 +1 @@
other sample

View file

@ -0,0 +1,51 @@
#!/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.context.target.processor_language == "x86:LE:64:default" and
(.result.data.context.analyzer_options | length) > 0
' "$response"
test ! -e "$response.tmp"

271
tests/support/fake_child.rs Normal file
View file

@ -0,0 +1,271 @@
#![forbid(unsafe_code)]
#![allow(missing_docs)]
use std::{
env,
fs::{self, OpenOptions},
io::{self, Read as _, Write as _},
net::TcpStream,
os::unix::fs::OpenOptionsExt as _,
path::{Path, PathBuf},
process::ExitCode,
thread,
time::Duration,
};
fn main() -> ExitCode {
match execute() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
let _ignored = writeln!(io::stderr().lock(), "fake child: {error}");
ExitCode::FAILURE
}
}
}
fn execute() -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = env::args_os().skip(1);
let behavior = arguments.next().ok_or("missing behavior")?;
if behavior == "-version" {
writeln!(io::stderr().lock(), "openjdk version \"21\"")?;
return Ok(());
}
if behavior == "exit-success" {
return Ok(());
}
if behavior == "network-probe" {
let address = arguments.next().ok_or("missing probe address")?;
TcpStream::connect(address.to_str().ok_or("probe address is not UTF-8")?)?;
return Ok(());
}
let behavior_text = behavior.to_str().ok_or("behavior is not UTF-8")?;
if !matches!(
behavior_text,
"success" | "mismatch" | "temporary-only" | "no-response" | "sleep" | "flood"
) {
return analyze_headless(PathBuf::from(behavior), arguments.collect());
}
let request = PathBuf::from(arguments.next().ok_or("missing request path")?);
let response = PathBuf::from(arguments.next().ok_or("missing response path")?);
match behavior_text {
"success" => write_response(&request, &response, false, true),
"mismatch" => write_response(&request, &response, true, true),
"temporary-only" => write_response(&request, &response, false, false),
"no-response" => Ok(()),
"sleep" => {
let milliseconds = arguments
.next()
.ok_or("missing sleep milliseconds")?
.to_str()
.ok_or("sleep value is not UTF-8")?
.parse::<u64>()?;
thread::sleep(Duration::from_millis(milliseconds));
write_response(&request, &response, false, true)
}
"flood" => {
let count = arguments
.next()
.ok_or("missing flood byte count")?
.to_str()
.ok_or("flood value is not UTF-8")?
.parse::<usize>()?;
write_repeated(io::stdout().lock(), b'o', count)?;
write_repeated(io::stderr().lock(), b'e', count)?;
write_response(&request, &response, false, true)
}
_ => Err("unknown behavior".into()),
}
}
fn analyze_headless(
project_directory: PathBuf,
arguments: Vec<std::ffi::OsString>,
) -> Result<(), Box<dyn std::error::Error>> {
if arguments.len() < 3 {
return Err("fake analyzeHeadless received too few arguments".into());
}
let project_name = arguments[0].to_str().ok_or("project name is not UTF-8")?;
if arguments.iter().any(|argument| argument == "-import") {
fs::create_dir_all(project_directory.join(format!("{project_name}.rep")))?;
fs::write(
project_directory.join(format!("{project_name}.gpr")),
b"fake immutable Ghidra project",
)?;
fs::write(
project_directory
.join(format!("{project_name}.rep"))
.join("program"),
b"fake analyzed program",
)?;
}
let request = PathBuf::from(&arguments[arguments.len() - 2]);
let response = PathBuf::from(&arguments[arguments.len() - 1]);
let request_value: serde_json::Value = serde_json::from_slice(&fs::read(&request)?)?;
if request_value["staged_sample"]
.as_str()
.and_then(|path| fs::read(path).ok())
.is_some_and(|bytes| bytes.starts_with(b"interruptible sample"))
{
thread::sleep(Duration::from_secs(5));
}
write_response(&request, &response, false, true)
}
fn write_repeated(mut writer: impl io::Write, byte: u8, mut remaining: usize) -> io::Result<()> {
let buffer = vec![byte; 64 * 1024];
while remaining > 0 {
let count = remaining.min(buffer.len());
writer.write_all(&buffer[..count])?;
remaining -= count;
}
writer.flush()
}
fn write_response(
request_path: &Path,
response_path: &Path,
mismatch: bool,
publish: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let mut request_bytes = Vec::new();
fs::File::open(request_path)?.read_to_end(&mut request_bytes)?;
let request: serde_json::Value = serde_json::from_slice(&request_bytes)?;
let invocation_id = if mismatch {
serde_json::Value::String("ffffffffffffffffffffffffffffffff".to_owned())
} else {
request["invocation_id"].clone()
};
let response = serde_json::json!({
"protocol_version": request["protocol_version"],
"invocation_id": invocation_id,
"operation": request["operation"],
"result": if fake_error(&request) {
serde_json::json!({
"status": "error",
"code": "analysis_failed",
"message": "controllable fake worker error",
"details": {}
})
} else {
serde_json::json!({
"status": "success",
"data": fake_data(&request)
})
}
});
let bytes = serde_json::to_vec(&response)?;
let temporary = response_path.with_file_name("response.json.tmp");
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temporary)?;
file.write_all(&bytes)?;
file.sync_all()?;
if publish {
fs::rename(temporary, response_path)?;
}
Ok(())
}
fn fake_error(request: &serde_json::Value) -> bool {
request["staged_sample"]
.as_str()
.and_then(|path| fs::read(path).ok())
.is_some_and(|bytes| bytes.starts_with(b"other sample"))
}
fn fake_data(request: &serde_json::Value) -> serde_json::Value {
match request["operation"].as_str() {
Some("doctor") => serde_json::json!({
"ready": true,
"ghidra_version": "12.1.2",
"java_version": 21,
"adapter_protocol_version": 1
}),
Some("inspect") => query_data(serde_json::json!({
"image_base": address("0x0000000000400000"),
"minimum_address": address("0x0000000000400000"),
"maximum_address": address("0x0000000000400fff"),
"function_count": 1
})),
Some("functions") => {
let offset = request["arguments"]["page"]["offset"].as_u64().unwrap_or(0);
let items = if offset == 0 {
vec![serde_json::json!({
"name": "main",
"qualified_name": "main",
"entry": address("0x0000000000401000"),
"body_address_count": 32,
"location": "memory",
"is_external": false,
"is_thunk": false,
"thunk_target_entry": null,
"decompilable": true
})]
} else {
Vec::new()
};
let limit = match &request["arguments"]["page"]["limit"] {
serde_json::Value::String(value) if value == "all" => serde_json::Value::Null,
value => value["bounded"].clone(),
};
query_data(serde_json::json!({
"page": {
"order": "location_then_entry_ascending",
"offset": offset,
"limit": limit,
"returned": items.len(),
"total": 1,
"has_more": false
},
"items": items
}))
}
Some("decompile") => {
let requested = request["arguments"]["selector"]["value"]
.as_str()
.unwrap_or("main");
let text = if requested == "large" {
"x".repeat(100_000)
} else {
"int main(void) {\n return 0;\n}\n".to_owned()
};
query_data(serde_json::json!({
"requested_selector": request["arguments"]["selector"],
"function": {
"name": requested,
"qualified_name": requested,
"entry": address("0x0000000000401000")
},
"decompilation": {
"syntax": "ghidra_c",
"text": text
}
}))
}
_ => serde_json::json!({}),
}
}
fn query_data(query: serde_json::Value) -> serde_json::Value {
serde_json::json!({
"context": {
"ghidra_version": "12.1.2",
"java_version": 21,
"target": {
"loader": "ElfLoader",
"format": "ELF",
"processor_language": "x86:LE:64:default",
"compiler_specification": "gcc"
},
"loader_options": [],
"analyzer_options": []
},
"query": query
})
}
fn address(offset: &str) -> serde_json::Value {
serde_json::json!({"space": "ram", "offset": offset})
}

187
tests/worker_lifecycle.rs Normal file
View file

@ -0,0 +1,187 @@
#![allow(clippy::expect_used)]
#![doc = "Controllable fake-child coverage for synchronous worker execution."]
use std::{
ffi::OsString,
fs,
os::unix::fs::PermissionsExt as _,
path::{Path, PathBuf},
sync::atomic::{AtomicU32, Ordering},
time::Duration,
};
use ghidra_cli::{
cli::SandboxMode,
domain::{InvocationId, Limits, PositiveU64},
operation::{ADAPTER_PROTOCOL_VERSION, Operation},
process::{
Cancellation, Invocation, InvocationFailureKind, NativePhaseTimeouts, NeverCancel, run,
},
protocol::{AdapterRequest, InvocationFiles, RequestArguments},
sandbox::WorkerCommand,
};
use tempfile::TempDir;
fn positive(value: u64) -> PositiveU64 {
PositiveU64::try_from(value).expect("positive")
}
fn request() -> AdapterRequest {
AdapterRequest {
protocol_version: ADAPTER_PROTOCOL_VERSION,
invocation_id: InvocationId::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("identifier"),
operation: Operation::Doctor,
staged_sample: None,
analysis_path: None,
limits: Limits {
max_heap_mib: positive(2048),
max_cpu: positive(2),
analysis_timeout_seconds: positive(600),
decompile_timeout_seconds: None,
child_watchdog_seconds: positive(120),
max_sample_bytes: positive(1_073_741_824),
max_inline_bytes: positive(65_536),
},
arguments: RequestArguments::Doctor,
}
}
fn fixture(behavior: &str, extra: &[&str]) -> (TempDir, InvocationFiles, WorkerCommand) {
let root = tempfile::tempdir().expect("temporary directory");
let invocation_directory = root.path().join("invocation");
fs::create_dir(&invocation_directory).expect("invocation directory");
let files = InvocationFiles::new(invocation_directory).expect("protocol paths");
let mut arguments = vec![
OsString::from(behavior),
files.request_path().into_os_string(),
files.response_path().into_os_string(),
];
arguments.extend(extra.iter().map(OsString::from));
let command = WorkerCommand::for_policy(
SandboxMode::Off,
PathBuf::from(env!("CARGO_BIN_EXE_ghidr-fake-child")),
arguments,
None,
)
.expect("worker command");
(root, files, command)
}
fn invocation<'a>(
request: &'a AdapterRequest,
files: &'a InvocationFiles,
command: &'a WorkerCommand,
) -> Invocation<'a> {
Invocation::new(
request,
files,
command,
NativePhaseTimeouts {
analysis_seconds: None,
decompile_seconds: None,
},
)
.expect("derived Invocation")
}
#[test]
fn one_attempt_writes_private_request_and_accepts_only_strict_final_response() {
let (_root, files, command) = fixture("success", &[]);
let request = request();
let completed =
run(invocation(&request, &files, &command), &NeverCancel).expect("completed invocation");
assert_eq!(completed.response.operation, Operation::Doctor);
assert_eq!(
fs::metadata(files.request_path())
.expect("request metadata")
.permissions()
.mode()
& 0o777,
0o600,
);
}
#[test]
fn temporary_response_is_never_accepted() {
let (_root, files, command) = fixture("temporary-only", &[]);
let request = request();
let failure = run(invocation(&request, &files, &command), &NeverCancel)
.expect_err("temporary response must fail");
assert_eq!(failure.kind, InvocationFailureKind::ResponseProtocol);
}
#[test]
fn mismatched_echo_is_a_protocol_violation() {
let (_root, files, command) = fixture("mismatch", &[]);
let request = request();
let failure =
run(invocation(&request, &files, &command), &NeverCancel).expect_err("mismatch must fail");
assert_eq!(failure.kind, InvocationFailureKind::ResponseProtocol);
}
#[test]
fn both_full_pipes_are_drained_without_deadlock_and_capture_is_bounded() {
let count = (9 * 1024 * 1024).to_string();
let (_root, files, command) = fixture("flood", &[&count]);
let request = request();
let completed =
run(invocation(&request, &files, &command), &NeverCancel).expect("flood invocation");
assert!(completed.diagnostics.truncated());
}
#[test]
fn watchdog_terminates_and_reaps_the_process_group() {
let (_root, files, command) = fixture("sleep", &["5000"]);
let request = request();
let failure = run(
invocation(&request, &files, &command).with_test_watchdog(Duration::from_millis(30)),
&NeverCancel,
)
.expect_err("watchdog must fire");
assert_eq!(failure.kind, InvocationFailureKind::WatchdogTimeout);
}
struct Interrupts(AtomicU32);
impl Cancellation for Interrupts {
fn interrupt_count(&self) -> u32 {
self.0.load(Ordering::SeqCst)
}
}
#[test]
fn second_interrupt_escalates_without_waiting_for_grace() {
let (_root, files, command) = fixture("sleep", &["5000"]);
let request = request();
let interrupts = Interrupts(AtomicU32::new(2));
let before = std::time::Instant::now();
let failure =
run(invocation(&request, &files, &command), &interrupts).expect_err("interrupt must win");
assert_eq!(failure.kind, InvocationFailureKind::Interrupted);
assert!(before.elapsed() < Duration::from_secs(2));
}
#[test]
fn protocol_paths_are_absolute_in_fixture() {
let (_root, files, _command) = fixture("success", &[]);
assert!(Path::new(&files.request_path()).is_absolute());
}
#[test]
fn invocation_rejects_a_competing_total_watchdog_value() {
let (_root, files, command) = fixture("success", &[]);
let mut request = request();
request.limits.child_watchdog_seconds = positive(121);
assert!(
Invocation::new(
&request,
&files,
&command,
NativePhaseTimeouts {
analysis_seconds: None,
decompile_seconds: None,
},
)
.is_err()
);
}