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

21
Cargo.lock generated
View file

@ -244,6 +244,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"signal-hook",
"tempfile",
"thiserror",
]
@ -482,6 +483,26 @@ dependencies = [
"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]]
name = "strsim"
version = "0.11.1"

View file

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

View file

@ -3,18 +3,18 @@
A small, dependable command-line interface for read-only Ghidra analysis.
The repository is named `ghidra-cli`; the installed executable is `ghidr`.
The project now has its version 0.1 Rust contract foundation. The typed CLI,
public envelopes and schemas, bounded Rust/Java protocol, and synchronous
execution seam are present; store, process/sandbox, and Ghidra adapter execution
remain deliberately unconnected.
Version 0.1 provides a synchronous Rust harness, strict Java headless adapter,
content-addressed Analysis Store, Bubblewrap worker sandbox, reproducible
native fixtures, and pinned x86-64 Linux Nix packaging.
Proposed usage:
Usage:
```console
ghidr doctor
ghidr inspect ./sample
ghidr functions ./sample
ghidr functions ./sample --limit 100
ghidr decompile ./sample --name main
ghidr clean ./sample --dry-run
```
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
the post-version-0.1 roadmap.
## Status
## Pinned package
Foundation only. Every accepted command currently returns a typed
`internal_not_implemented` runtime error instead of pretending Ghidra analysis
succeeded. Subsequent layers implement the library's synchronous executor
contract while preserving the committed schemas and reviewed fixtures.
The flake fixes Ghidra at exactly 12.1.2 and uses JDK 21. It also closes over
Rust, Bubblewrap, the Java adapter, deterministic Java/Nix formatters,
`cargo-deny`, and `statix`:
```console
nix run . -- doctor
nix develop
```
The native fixtures are reproducible Nix outputs rather than committed opaque
binaries:
```console
nix build .#fixtures
file result/elf-x86_64/known.elf result/pe32plus-x86_64/known.exe
```
## Adapter boundary
The Ghidra worker dispatches `doctor`, `inspect`, `functions`, and `decompile`.
`inspect` is the import/analyze vertical slice: the trusted harness stages the
Sample and invokes official `analyzeHeadless`; the post-script reads only the
current imported Program and private request/response paths. `clean` is in the
five-operation Rust registry but is intentionally rejected by Java because it
is a trusted, atomic Analysis Store transaction. This boundary is enforced by
`tests/adapter_contract.rs` and documented in `java/dispatch.txt`.
The adapter accepts at most 1 MiB of request JSON before parsing, rejects
missing/unknown fields, echoes protocol version/invocation/operation, and
publishes at most 256 MiB through a temporary file plus same-filesystem atomic
rename. It has no socket, network client, listener, script execution, mutation,
or user-selected destination surface.
## Checks
The complete pinned suite is:
```console
cargo fmt --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

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": {
"ready": true,
"components": {
"ghidra": { "status": "ready", "details": { "version": "12.1.2" } },
"java": { "status": "ready", "details": { "version": "21" } }
"ghidra": {
"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": []

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": {
"data": {
"description": "Operation-specific result.",
"$ref": "#/$defs/CleanupData"
"$ref": "#/$defs/InlineOrArtifact"
},
"kind": {
"type": "string",
@ -54,6 +54,41 @@
"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": {
"description": "Cleanup result data; detailed count contracts are carried as typed snapshots.",
"type": "object",
@ -215,6 +250,19 @@
"description": "A lowercase SHA-256 digest.",
"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": {
"description": "Sandbox backend.",
"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": {
"description": "Filesystem usage represented without implying freed space.",
"type": "object",

View file

@ -6,7 +6,7 @@
"properties": {
"data": {
"description": "Operation-specific result.",
"$ref": "#/$defs/DecompilationData"
"$ref": "#/$defs/InlineOrArtifact"
},
"kind": {
"type": "string",
@ -72,6 +72,41 @@
"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": {
"description": "Successful targeted decompilation data.",
"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": {
"description": "Resource policy selected for one invocation.",
"type": "object",
@ -367,6 +415,24 @@
"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": {
"description": "Analysis Store resolution source.",
"oneOf": [

View file

@ -6,7 +6,7 @@
"properties": {
"data": {
"description": "Operation-specific result.",
"$ref": "#/$defs/DoctorData"
"$ref": "#/$defs/InlineOrArtifact"
},
"kind": {
"type": "string",
@ -36,6 +36,34 @@
"warnings"
],
"$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": {
"description": "Analysis Store provenance.",
"type": "object",
@ -54,23 +82,39 @@
"source"
]
},
"ComponentReport": {
"description": "One diagnosed component.",
"ArtifactDescriptor": {
"description": "Artifact descriptor returned instead of oversized inline data.",
"type": "object",
"properties": {
"details": {
"description": "Component-specific facts.",
"type": "object",
"additionalProperties": true
"bytes": {
"description": "Exact file bytes including trailing LF.",
"type": "integer",
"format": "uint64",
"minimum": 0
},
"status": {
"description": "Stable readiness classification.",
"$ref": "#/$defs/ComponentStatus"
"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": [
"status",
"details"
"path",
"bytes",
"sha256",
"media_type",
"contains"
]
},
"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": {
"description": "Installation diagnosis data.",
"type": "object",
"properties": {
"components": {
"description": "Typed component reports keyed by stable component name.",
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/ComponentReport"
}
"description": "Complete fixed component report.",
"$ref": "#/$defs/DoctorComponents"
},
"ready": {
"description": "True only when every required component is ready.",
@ -124,6 +197,81 @@
"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": {
"description": "Sandbox backend.",
"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": {
"description": "Worker sandbox provenance.",
"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": {
"description": "Analysis Store resolution source.",
"oneOf": [

View file

@ -120,11 +120,6 @@
"type": "string",
"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.",
"type": "string",

View file

@ -6,7 +6,7 @@
"properties": {
"data": {
"description": "Operation-specific result.",
"$ref": "#/$defs/FunctionsData"
"$ref": "#/$defs/InlineOrArtifact"
},
"kind": {
"type": "string",
@ -72,6 +72,41 @@
"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": {
"description": "A lowercase SHA-256 digest.",
"type": "string"
@ -173,6 +208,19 @@
"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": {
"description": "Resource policy selected for one invocation.",
"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": {
"description": "Analysis Store resolution source.",
"oneOf": [

View file

@ -6,7 +6,7 @@
"properties": {
"data": {
"description": "Operation-specific result.",
"$ref": "#/$defs/InspectionData"
"$ref": "#/$defs/InlineOrArtifact"
},
"kind": {
"type": "string",
@ -105,10 +105,58 @@
"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": {
"description": "A lowercase SHA-256 digest.",
"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": {
"description": "Sample inspection data.",
"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": {
"description": "Analysis Store resolution source.",
"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);
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.
pub fn parse(value: &str) -> Result<Self, IdentifierError> {
if value.len() != 32
@ -51,6 +58,18 @@ impl InvocationId {
}
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.

View file

@ -37,8 +37,6 @@ pub enum ErrorCode {
JavaMissing,
/// The installed Java version is incompatible.
JavaIncompatible,
/// A later integration layer has not connected execution yet.
InternalNotImplemented,
/// Rust/Java request or response validation failed.
ProtocolViolation,
/// 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.
#[must_use]
pub fn invalid_arguments(message: impl Into<String>) -> Self {

View file

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

View file

@ -3,17 +3,45 @@
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 {
let mut stdout = io::stdout().lock();
let mut stderr = io::stderr().lock();
match run_from(
std::env::args_os(),
&UnimplementedExecutor,
&mut stdout,
&mut stderr,
) {
let arguments: Vec<_> = std::env::args_os().collect();
let result = match InterruptCounter::install() {
Ok(cancellation) => run_from(
arguments,
&ApplicationExecutor::new(cancellation),
&mut stdout,
&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)),
Err(_) => ExitCode::FAILURE,
}

View file

@ -293,17 +293,67 @@ pub enum SandboxVerification {
pub struct DoctorData {
/// True only when every required component is ready.
pub ready: bool,
/// Typed component reports keyed by stable component name.
pub components: BTreeMap<String, ComponentReport>,
/// Complete fixed component report.
pub components: DoctorComponents,
}
/// One diagnosed component.
/// Fixed installation components checked safely by `doctor`.
#[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.
pub status: ComponentStatus,
/// Component-specific facts.
pub details: BTreeMap<String, Value>,
/// Observed version when the probe ran.
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.
@ -556,6 +606,25 @@ pub struct ArtifactDescriptor {
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.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]

View file

@ -10,7 +10,7 @@ use serde_json::Value;
use crate::{
AppError, ErrorCode, ExitStatus,
cli::{Cli, Command, OutputFormat},
cli::{Cli, OutputFormat},
protocol::requests_human_format,
};
@ -32,18 +32,6 @@ pub trait Executor {
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.
pub fn run_from<I, T>(
arguments: I,
@ -133,16 +121,6 @@ fn write_error_json(error: &AppError, writer: &mut impl Write) -> io::Result<()>
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)]
#[allow(clippy::expect_used)]
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::{
ffi::OsStr,
io::{self, Read},
fs::{self, File, OpenOptions},
io::{self, Read, Write},
os::unix::fs::OpenOptionsExt as _,
path::{Path, PathBuf},
};
@ -12,14 +14,95 @@ use thiserror::Error;
use crate::{
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).
pub const MAX_REQUEST_BYTES: usize = 1_048_576;
/// Maximum exact serialized `response.json` size (256 MiB).
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.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(transparent)]
@ -164,6 +247,127 @@ pub fn validate_response_echo(
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>(
value: &T,
limit: usize,
@ -238,6 +442,21 @@ pub enum ProtocolCodecError {
/// Operation echo differs.
#[error("adapter operation mismatch")]
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.
#[error("platform cannot represent protocol size bound")]
PlatformLimit,
@ -260,10 +479,45 @@ pub fn requests_human_format(arguments: &[impl AsRef<OsStr>]) -> bool {
}
#[cfg(test)]
#[allow(clippy::expect_used)]
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]
fn response_reader_stops_at_bound_plus_one() {
@ -302,4 +556,40 @@ mod tests {
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::{
error::ErrorEnvelope,
operation::{
AnalysisProfile, CleanupData, DecompilationData, DoctorData, FunctionsData, InspectionData,
QueryProvenance, SuccessEnvelope, ToolProvenance,
AnalysisProfile, CleanupData, DecompilationData, DoctorData, FunctionsData,
InlineOrArtifact, InspectionData, QueryProvenance, SuccessEnvelope, ToolProvenance,
},
protocol::{AdapterRequest, AdapterResponse},
};
@ -25,21 +25,24 @@ pub fn v1_schemas() -> BTreeMap<&'static str, Schema> {
(
"cleanup.schema.json",
with_kind(
schema_for!(SuccessEnvelope<ToolProvenance, CleanupData>),
schema_for!(SuccessEnvelope<ToolProvenance, InlineOrArtifact<CleanupData>>),
"cleanup",
),
),
(
"decompilation.schema.json",
with_kind(
schema_for!(SuccessEnvelope<QueryProvenance, DecompilationData>),
schema_for!(SuccessEnvelope<
QueryProvenance,
InlineOrArtifact<DecompilationData>,
>),
"decompilation",
),
),
(
"doctor.schema.json",
with_kind(
schema_for!(SuccessEnvelope<ToolProvenance, DoctorData>),
schema_for!(SuccessEnvelope<ToolProvenance, InlineOrArtifact<DoctorData>>),
"doctor",
),
),
@ -54,14 +57,14 @@ pub fn v1_schemas() -> BTreeMap<&'static str, Schema> {
(
"functions.schema.json",
with_kind(
schema_for!(SuccessEnvelope<QueryProvenance, FunctionsData>),
schema_for!(SuccessEnvelope<QueryProvenance, InlineOrArtifact<FunctionsData>>),
"functions",
),
),
(
"inspection.schema.json",
with_kind(
schema_for!(SuccessEnvelope<QueryProvenance, InspectionData>),
schema_for!(SuccessEnvelope<QueryProvenance, InlineOrArtifact<InspectionData>>),
"inspection",
),
),

View file

@ -236,7 +236,7 @@ impl AnalysisStore {
profile: &Digest,
lock: &AnalysisLock,
) -> Result<PathBuf, StoreError> {
if !lock.guards(sample, profile) {
if !lock.guards_exclusive(sample, profile) {
return Err(StoreError::new(
StoreErrorKind::AnalysisBusy,
"lock token does not guard the promoted Analysis identity",
@ -267,7 +267,7 @@ impl AnalysisStore {
lock: &AnalysisLock,
rebuild_already_attempted: bool,
) -> Result<QuarantineOutcome, StoreError> {
if !lock.guards(sample, profile) {
if !lock.guards_exclusive(sample, profile) {
return Err(StoreError::new(
StoreErrorKind::AnalysisBusy,
"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",
false,
)
.with_detail(
"matched",
serde_json::to_value(plan.snapshot()).unwrap_or(Value::Null),
)
.with_detail("analyses", Value::from(plan.snapshot().analyses))
.with_detail("artifacts", Value::from(plan.snapshot().artifacts))
.with_detail(

View file

@ -1,10 +1,11 @@
use std::{
fs::{self, File, OpenOptions},
io::Write as _,
io::{self, Write as _},
os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _},
path::{Path, PathBuf},
};
use rustix::fs::{Mode, OFlags};
use serde::{Deserialize, Serialize};
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))
}
/// 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> {
for ancestor in path.ancestors().collect::<Vec<_>>().into_iter().rev() {
match fs::symlink_metadata(ancestor) {
@ -280,7 +352,7 @@ mod tests {
use tempfile::TempDir;
use super::AnalysisStore;
use super::{AnalysisStore, copy_private_tree};
use crate::store::{StoreEnvironment, resolve_store};
fn store(temp: &TempDir) -> AnalysisStore {
@ -329,4 +401,23 @@ mod tests {
symlink(&outside, store.root().join("artifacts/escape")).expect("symlink");
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};
/// Held nonblocking exclusive per-Analysis OS lock.
/// Held nonblocking shared or exclusive per-Analysis OS lock.
#[derive(Debug)]
pub struct AnalysisLock {
_file: File,
path: PathBuf,
sample: Digest,
profile: Digest,
exclusive: bool,
}
impl AnalysisLock {
@ -24,6 +25,37 @@ impl AnalysisLock {
store: &AnalysisStore,
sample: &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> {
let directory = store.path("locks").join(sample.as_str());
create_private_directory(&directory)?;
@ -41,12 +73,13 @@ impl AnalysisLock {
)
})?;
let file = File::from(descriptor);
match rustix::fs::flock(&file, FlockOperation::NonBlockingLockExclusive) {
match rustix::fs::flock(&file, operation) {
Ok(()) => Ok(Self {
_file: file,
path,
sample: sample.clone(),
profile: profile.clone(),
exclusive,
}),
Err(error) if error == rustix::io::Errno::WOULDBLOCK => Err(StoreError::at(
StoreErrorKind::AnalysisBusy,
@ -78,6 +111,12 @@ impl AnalysisLock {
pub fn guards(&self, sample: &Digest, profile: &Digest) -> bool {
&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)]
@ -109,4 +148,25 @@ mod tests {
drop(first);
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 error::{StoreError, StoreErrorKind};
pub use filesystem::{AnalysisStore, STORE_LAYOUT_VERSION, StoreUsage};
pub(crate) use filesystem::{copy_private_tree, create_private_directory};
pub use lock::AnalysisLock;
pub use resolution::{ResolvedStore, StoreEnvironment, resolve_store};
pub use sample::{DEFAULT_STORE_RESERVE_BYTES, SampleSnapshot, SnapshotOptions, snapshot_sample};

View file

@ -72,6 +72,12 @@ impl SampleSnapshot {
&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.
#[must_use]
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)]
#![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]
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"))
.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()
.expect("run ghidr");
assert_eq!(output.status.code(), Some(1));
assert!(output.stdout.is_empty());
assert_eq!(output.status.code(), Some(0), "{:?}", output.stderr);
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 =
serde_json::from_slice(&output.stderr).expect("valid JSON error");
assert_eq!(error["error"]["code"], "internal_not_implemented");
assert_eq!(error["error"]["details"]["operation"], "doctor");
serde_json::from_slice(&confirmation.stderr).expect("confirmation JSON");
assert_eq!(error["error"]["code"], "confirmation_required");
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]
@ -41,3 +258,51 @@ fn explicit_human_parse_failure_is_not_json() {
assert!(output.stdout.is_empty());
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()
);
}