feat: integrate ghidra-cli v0.1
This commit is contained in:
commit
ffab80a1f2
52 changed files with 6689 additions and 126 deletions
14
.forgejo/workflows/ci.yml
Normal file
14
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
name: checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
flake:
|
||||||
|
runs-on: x86_64-linux
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: nix flake check --print-build-logs
|
||||||
|
- run: nix build .#packages.x86_64-linux.default --print-build-logs
|
||||||
|
- run: nix run . -- doctor
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -1,7 +1,5 @@
|
||||||
/target/
|
/result
|
||||||
*.log
|
/result-*
|
||||||
|
/target
|
||||||
*.gpr
|
*.gpr
|
||||||
*.rep/
|
*.rep/
|
||||||
.direnv/
|
|
||||||
.envrc
|
|
||||||
|
|
||||||
|
|
|
||||||
21
Cargo.lock
generated
21
Cargo.lock
generated
|
|
@ -244,6 +244,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"signal-hook",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
@ -482,6 +483,26 @@ dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
|
|
|
||||||
15
Cargo.toml
15
Cargo.toml
|
|
@ -7,6 +7,10 @@ description = "Reproducible, read-only Ghidra analysis from the command line"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Enables timing controls used only by release-profile integration tests.
|
||||||
|
test-support = []
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "ghidra_cli"
|
name = "ghidra_cli"
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
@ -15,21 +19,26 @@ path = "src/lib.rs"
|
||||||
name = "ghidr"
|
name = "ghidr"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ghidr-fake-child"
|
||||||
|
path = "tests/support/fake_child.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
clap = { version = "4.5.60", features = ["derive"] }
|
clap = { version = "4.5.60", features = ["derive"] }
|
||||||
getrandom = "0.3.3"
|
getrandom = "=0.3.3"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
rustix = { version = "1.0.8", features = ["fs"] }
|
rustix = { version = "=1.0.8", features = ["fs", "process"] }
|
||||||
schemars = { version = "=1.0.4", features = ["derive"] }
|
schemars = { version = "=1.0.4", features = ["derive"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
|
signal-hook = "0.3.18"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2.1.2"
|
assert_cmd = "2.1.2"
|
||||||
tempfile = "3.21.0"
|
tempfile = "=3.21.0"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
|
||||||
73
README.md
73
README.md
|
|
@ -3,18 +3,18 @@
|
||||||
A small, dependable command-line interface for read-only Ghidra analysis.
|
A small, dependable command-line interface for read-only Ghidra analysis.
|
||||||
The repository is named `ghidra-cli`; the installed executable is `ghidr`.
|
The repository is named `ghidra-cli`; the installed executable is `ghidr`.
|
||||||
|
|
||||||
The project now has its version 0.1 Rust contract foundation. The typed CLI,
|
Version 0.1 provides a synchronous Rust harness, strict Java headless adapter,
|
||||||
public envelopes and schemas, bounded Rust/Java protocol, and synchronous
|
content-addressed Analysis Store, Bubblewrap worker sandbox, reproducible
|
||||||
execution seam are present; store, process/sandbox, and Ghidra adapter execution
|
native fixtures, and pinned x86-64 Linux Nix packaging.
|
||||||
remain deliberately unconnected.
|
|
||||||
|
|
||||||
Proposed usage:
|
Usage:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
ghidr doctor
|
ghidr doctor
|
||||||
ghidr inspect ./sample
|
ghidr inspect ./sample
|
||||||
ghidr functions ./sample
|
ghidr functions ./sample --limit 100
|
||||||
ghidr decompile ./sample --name main
|
ghidr decompile ./sample --name main
|
||||||
|
ghidr clean ./sample --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
Each command will identify the sample by its content, reuse compatible cached
|
Each command will identify the sample by its content, reuse compatible cached
|
||||||
|
|
@ -32,12 +32,63 @@ available explicitly through `--format human`.
|
||||||
including the Ghidra MCP comparison that informed protocol safeguards and
|
including the Ghidra MCP comparison that informed protocol safeguards and
|
||||||
the post-version-0.1 roadmap.
|
the post-version-0.1 roadmap.
|
||||||
|
|
||||||
## Status
|
## Pinned package
|
||||||
|
|
||||||
Foundation only. Every accepted command currently returns a typed
|
The flake fixes Ghidra at exactly 12.1.2 and uses JDK 21. It also closes over
|
||||||
`internal_not_implemented` runtime error instead of pretending Ghidra analysis
|
Rust, Bubblewrap, the Java adapter, deterministic Java/Nix formatters,
|
||||||
succeeded. Subsequent layers implement the library's synchronous executor
|
`cargo-deny`, and `statix`:
|
||||||
contract while preserving the committed schemas and reviewed fixtures.
|
|
||||||
|
```console
|
||||||
|
nix run . -- doctor
|
||||||
|
nix develop
|
||||||
|
```
|
||||||
|
|
||||||
|
The native fixtures are reproducible Nix outputs rather than committed opaque
|
||||||
|
binaries:
|
||||||
|
|
||||||
|
```console
|
||||||
|
nix build .#fixtures
|
||||||
|
file result/elf-x86_64/known.elf result/pe32plus-x86_64/known.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adapter boundary
|
||||||
|
|
||||||
|
The Ghidra worker dispatches `doctor`, `inspect`, `functions`, and `decompile`.
|
||||||
|
`inspect` is the import/analyze vertical slice: the trusted harness stages the
|
||||||
|
Sample and invokes official `analyzeHeadless`; the post-script reads only the
|
||||||
|
current imported Program and private request/response paths. `clean` is in the
|
||||||
|
five-operation Rust registry but is intentionally rejected by Java because it
|
||||||
|
is a trusted, atomic Analysis Store transaction. This boundary is enforced by
|
||||||
|
`tests/adapter_contract.rs` and documented in `java/dispatch.txt`.
|
||||||
|
|
||||||
|
The adapter accepts at most 1 MiB of request JSON before parsing, rejects
|
||||||
|
missing/unknown fields, echoes protocol version/invocation/operation, and
|
||||||
|
publishes at most 256 MiB through a temporary file plus same-filesystem atomic
|
||||||
|
rename. It has no socket, network client, listener, script execution, mutation,
|
||||||
|
or user-selected destination surface.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
The complete pinned suite is:
|
||||||
|
|
||||||
|
```console
|
||||||
|
cargo fmt --all --check
|
||||||
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
cargo test --all-targets --all-features
|
||||||
|
cargo deny check
|
||||||
|
nix flake check --print-build-logs
|
||||||
|
```
|
||||||
|
|
||||||
|
`nix flake check` compiles Java with JDK 21 `-Xlint:all -Werror`, checks
|
||||||
|
Google-style `clang-format`, `nixfmt`, and `statix`, builds both fixtures, runs the
|
||||||
|
adapter against real Ghidra 12.1.2, and exercises `inspect`, `functions`, and
|
||||||
|
`decompile` through the packaged `ghidr` path. Individual real-engine checks
|
||||||
|
remain directly runnable as:
|
||||||
|
|
||||||
|
```console
|
||||||
|
nix build .#checks.x86_64-linux.java-adapter-e2e --print-build-logs
|
||||||
|
nix build .#checks.x86_64-linux.real-ghidra-e2e --print-build-logs
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
26
deny.toml
Normal file
26
deny.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[advisories]
|
||||||
|
version = 2
|
||||||
|
yanked = "deny"
|
||||||
|
|
||||||
|
[bans]
|
||||||
|
multiple-versions = "warn"
|
||||||
|
wildcards = "deny"
|
||||||
|
highlight = "all"
|
||||||
|
|
||||||
|
[licenses]
|
||||||
|
version = 2
|
||||||
|
confidence-threshold = 0.93
|
||||||
|
allow = [
|
||||||
|
"Apache-2.0",
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"ISC",
|
||||||
|
"MIT",
|
||||||
|
"Unicode-3.0",
|
||||||
|
"Zlib",
|
||||||
|
]
|
||||||
|
|
||||||
|
[sources]
|
||||||
|
unknown-registry = "deny"
|
||||||
|
unknown-git = "deny"
|
||||||
|
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||||
14
fixtures/README.md
Normal file
14
fixtures/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Reproducible native fixtures
|
||||||
|
|
||||||
|
`fixtures/src/known.c` is compiled by Nix into two tiny, dependency-free test
|
||||||
|
programs from the same reviewed source:
|
||||||
|
|
||||||
|
- `elf-x86_64/known.elf`: static ELF, x86-64 little-endian;
|
||||||
|
- `pe32plus-x86_64/known.exe`: PE32+, x86-64.
|
||||||
|
|
||||||
|
Both builds disable timestamps and build IDs, set `SOURCE_DATE_EPOCH=1`, avoid
|
||||||
|
host libraries, and are stripped only of non-semantic comments/notes. Build
|
||||||
|
them with `nix build .#fixtures`. Version 0.1 intentionally does not claim an
|
||||||
|
unverified third target until that target is exercised by the real-Ghidra
|
||||||
|
suite; recognizing a file format without a maintained cross-toolchain test is
|
||||||
|
not sufficient evidence.
|
||||||
|
|
@ -13,8 +13,33 @@
|
||||||
"data": {
|
"data": {
|
||||||
"ready": true,
|
"ready": true,
|
||||||
"components": {
|
"components": {
|
||||||
"ghidra": { "status": "ready", "details": { "version": "12.1.2" } },
|
"ghidra": {
|
||||||
"java": { "status": "ready", "details": { "version": "21" } }
|
"status": "ready",
|
||||||
|
"version": "12.1.2",
|
||||||
|
"launcher": {
|
||||||
|
"encoding": "utf8",
|
||||||
|
"value": "/nix/store/ghidra/bin/ghidra-analyzeHeadless"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"java": {
|
||||||
|
"status": "ready",
|
||||||
|
"version": "21",
|
||||||
|
"executable": {
|
||||||
|
"encoding": "utf8",
|
||||||
|
"value": "/nix/store/jdk/bin/java"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"analysis_store": {
|
||||||
|
"status": "ready",
|
||||||
|
"path": { "encoding": "utf8", "value": "/tmp/ghidr-store" },
|
||||||
|
"source": "cli",
|
||||||
|
"usage": { "logical_bytes": 1048576, "allocated_bytes": 1114112 }
|
||||||
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"status": "ready",
|
||||||
|
"backend": "bubblewrap",
|
||||||
|
"verification": "verified"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"warnings": []
|
"warnings": []
|
||||||
|
|
|
||||||
18
fixtures/src/known.c
Normal file
18
fixtures/src/known.c
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// SPDX-License-Identifier: CC0-1.0
|
||||||
|
|
||||||
|
volatile unsigned long fixture_marker = 7;
|
||||||
|
|
||||||
|
__attribute__((noinline)) unsigned long fixture_add(unsigned long value) {
|
||||||
|
return value + fixture_marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _start(void) {
|
||||||
|
(void)fixture_add(35);
|
||||||
|
for (;;) {
|
||||||
|
__asm__ volatile("" ::: "memory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void mainCRTStartup(void) {
|
||||||
|
_start();
|
||||||
|
}
|
||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1752480373,
|
||||||
|
"narHash": "sha256-JHQbm+OcGp32wAsXTE/FLYGNpb+4GLi5oTvCxwSoBOA=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "62e0f05ede1da0d54515d4ea8ce9c733f12d9f08",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "62e0f05ede1da0d54515d4ea8ce9c733f12d9f08",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
306
flake.nix
Normal file
306
flake.nix
Normal 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
954
java/GhidrAdapter.java
Normal 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
8
java/dispatch.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Java accepts only operations which require a Ghidra/JVM worker.
|
||||||
|
doctor
|
||||||
|
inspect
|
||||||
|
functions
|
||||||
|
decompile
|
||||||
|
|
||||||
|
# clean is deliberately absent: cleanup is a trusted Rust Analysis Store
|
||||||
|
# transaction and is never delegated into the capability-sandboxed worker.
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
"description": "Operation-specific result.",
|
"description": "Operation-specific result.",
|
||||||
"$ref": "#/$defs/CleanupData"
|
"$ref": "#/$defs/InlineOrArtifact"
|
||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -54,6 +54,41 @@
|
||||||
"source"
|
"source"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ArtifactDescriptor": {
|
||||||
|
"description": "Artifact descriptor returned instead of oversized inline data.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bytes": {
|
||||||
|
"description": "Exact file bytes including trailing LF.",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"description": "Always `complete_success_response`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"media_type": {
|
||||||
|
"description": "Always `application/json`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"description": "Tagged absolute Artifact path.",
|
||||||
|
"$ref": "#/$defs/TaggedPath"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"description": "SHA-256 of exact file bytes.",
|
||||||
|
"$ref": "#/$defs/Digest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"path",
|
||||||
|
"bytes",
|
||||||
|
"sha256",
|
||||||
|
"media_type",
|
||||||
|
"contains"
|
||||||
|
]
|
||||||
|
},
|
||||||
"CleanupData": {
|
"CleanupData": {
|
||||||
"description": "Cleanup result data; detailed count contracts are carried as typed snapshots.",
|
"description": "Cleanup result data; detailed count contracts are carried as typed snapshots.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -215,6 +250,19 @@
|
||||||
"description": "A lowercase SHA-256 digest.",
|
"description": "A lowercase SHA-256 digest.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"InlineOrArtifact": {
|
||||||
|
"description": "Successful command data emitted inline or represented by a complete Artifact.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Complete command-specific data.",
|
||||||
|
"$ref": "#/$defs/CleanupData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Bounded descriptor for the complete stored success response.",
|
||||||
|
"$ref": "#/$defs/SpilledData"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"SandboxBackend": {
|
"SandboxBackend": {
|
||||||
"description": "Sandbox backend.",
|
"description": "Sandbox backend.",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
|
@ -273,6 +321,24 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SpilledData": {
|
||||||
|
"description": "Public data shape returned when a complete success exceeds the inline byte budget.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"artifact": {
|
||||||
|
"description": "Immutable complete-success Artifact.",
|
||||||
|
"$ref": "#/$defs/ArtifactDescriptor"
|
||||||
|
},
|
||||||
|
"spilled": {
|
||||||
|
"description": "Always true for this variant.",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"spilled",
|
||||||
|
"artifact"
|
||||||
|
]
|
||||||
|
},
|
||||||
"StorageUsage": {
|
"StorageUsage": {
|
||||||
"description": "Filesystem usage represented without implying freed space.",
|
"description": "Filesystem usage represented without implying freed space.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
"description": "Operation-specific result.",
|
"description": "Operation-specific result.",
|
||||||
"$ref": "#/$defs/DecompilationData"
|
"$ref": "#/$defs/InlineOrArtifact"
|
||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -72,6 +72,41 @@
|
||||||
"source"
|
"source"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ArtifactDescriptor": {
|
||||||
|
"description": "Artifact descriptor returned instead of oversized inline data.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bytes": {
|
||||||
|
"description": "Exact file bytes including trailing LF.",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"description": "Always `complete_success_response`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"media_type": {
|
||||||
|
"description": "Always `application/json`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"description": "Tagged absolute Artifact path.",
|
||||||
|
"$ref": "#/$defs/TaggedPath"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"description": "SHA-256 of exact file bytes.",
|
||||||
|
"$ref": "#/$defs/Digest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"path",
|
||||||
|
"bytes",
|
||||||
|
"sha256",
|
||||||
|
"media_type",
|
||||||
|
"contains"
|
||||||
|
]
|
||||||
|
},
|
||||||
"DecompilationData": {
|
"DecompilationData": {
|
||||||
"description": "Successful targeted decompilation data.",
|
"description": "Successful targeted decompilation data.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -166,6 +201,19 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"InlineOrArtifact": {
|
||||||
|
"description": "Successful command data emitted inline or represented by a complete Artifact.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Complete command-specific data.",
|
||||||
|
"$ref": "#/$defs/DecompilationData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Bounded descriptor for the complete stored success response.",
|
||||||
|
"$ref": "#/$defs/SpilledData"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"Limits": {
|
"Limits": {
|
||||||
"description": "Resource policy selected for one invocation.",
|
"description": "Resource policy selected for one invocation.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -367,6 +415,24 @@
|
||||||
"entry"
|
"entry"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SpilledData": {
|
||||||
|
"description": "Public data shape returned when a complete success exceeds the inline byte budget.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"artifact": {
|
||||||
|
"description": "Immutable complete-success Artifact.",
|
||||||
|
"$ref": "#/$defs/ArtifactDescriptor"
|
||||||
|
},
|
||||||
|
"spilled": {
|
||||||
|
"description": "Always true for this variant.",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"spilled",
|
||||||
|
"artifact"
|
||||||
|
]
|
||||||
|
},
|
||||||
"StoreSource": {
|
"StoreSource": {
|
||||||
"description": "Analysis Store resolution source.",
|
"description": "Analysis Store resolution source.",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
"description": "Operation-specific result.",
|
"description": "Operation-specific result.",
|
||||||
"$ref": "#/$defs/DoctorData"
|
"$ref": "#/$defs/InlineOrArtifact"
|
||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -36,6 +36,34 @@
|
||||||
"warnings"
|
"warnings"
|
||||||
],
|
],
|
||||||
"$defs": {
|
"$defs": {
|
||||||
|
"AnalysisStoreComponent": {
|
||||||
|
"description": "Diagnosed Analysis Store.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"description": "Resolved absolute store path.",
|
||||||
|
"$ref": "#/$defs/TaggedPath"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"description": "Resolution precedence source.",
|
||||||
|
"$ref": "#/$defs/StoreSource"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"description": "Stable readiness classification.",
|
||||||
|
"$ref": "#/$defs/ComponentStatus"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"description": "Complete non-following store usage scan.",
|
||||||
|
"$ref": "#/$defs/StorageUsage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"path",
|
||||||
|
"source",
|
||||||
|
"usage"
|
||||||
|
]
|
||||||
|
},
|
||||||
"AnalysisStoreProvenance": {
|
"AnalysisStoreProvenance": {
|
||||||
"description": "Analysis Store provenance.",
|
"description": "Analysis Store provenance.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -54,23 +82,39 @@
|
||||||
"source"
|
"source"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ComponentReport": {
|
"ArtifactDescriptor": {
|
||||||
"description": "One diagnosed component.",
|
"description": "Artifact descriptor returned instead of oversized inline data.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"details": {
|
"bytes": {
|
||||||
"description": "Component-specific facts.",
|
"description": "Exact file bytes including trailing LF.",
|
||||||
"type": "object",
|
"type": "integer",
|
||||||
"additionalProperties": true
|
"format": "uint64",
|
||||||
|
"minimum": 0
|
||||||
},
|
},
|
||||||
"status": {
|
"contains": {
|
||||||
"description": "Stable readiness classification.",
|
"description": "Always `complete_success_response`.",
|
||||||
"$ref": "#/$defs/ComponentStatus"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"media_type": {
|
||||||
|
"description": "Always `application/json`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"description": "Tagged absolute Artifact path.",
|
||||||
|
"$ref": "#/$defs/TaggedPath"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"description": "SHA-256 of exact file bytes.",
|
||||||
|
"$ref": "#/$defs/Digest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"status",
|
"path",
|
||||||
"details"
|
"bytes",
|
||||||
|
"sha256",
|
||||||
|
"media_type",
|
||||||
|
"contains"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ComponentStatus": {
|
"ComponentStatus": {
|
||||||
|
|
@ -103,16 +147,45 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"Digest": {
|
||||||
|
"description": "A lowercase SHA-256 digest.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"DoctorComponents": {
|
||||||
|
"description": "Fixed installation components checked safely by `doctor`.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"analysis_store": {
|
||||||
|
"description": "Analysis Store.",
|
||||||
|
"$ref": "#/$defs/AnalysisStoreComponent"
|
||||||
|
},
|
||||||
|
"ghidra": {
|
||||||
|
"description": "Ghidra launcher and adapter.",
|
||||||
|
"$ref": "#/$defs/GhidraComponent"
|
||||||
|
},
|
||||||
|
"java": {
|
||||||
|
"description": "Java runtime.",
|
||||||
|
"$ref": "#/$defs/JavaComponent"
|
||||||
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"description": "Worker isolation backend.",
|
||||||
|
"$ref": "#/$defs/SandboxComponent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ghidra",
|
||||||
|
"java",
|
||||||
|
"analysis_store",
|
||||||
|
"sandbox"
|
||||||
|
]
|
||||||
|
},
|
||||||
"DoctorData": {
|
"DoctorData": {
|
||||||
"description": "Installation diagnosis data.",
|
"description": "Installation diagnosis data.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"components": {
|
"components": {
|
||||||
"description": "Typed component reports keyed by stable component name.",
|
"description": "Complete fixed component report.",
|
||||||
"type": "object",
|
"$ref": "#/$defs/DoctorComponents"
|
||||||
"additionalProperties": {
|
|
||||||
"$ref": "#/$defs/ComponentReport"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"ready": {
|
"ready": {
|
||||||
"description": "True only when every required component is ready.",
|
"description": "True only when every required component is ready.",
|
||||||
|
|
@ -124,6 +197,81 @@
|
||||||
"components"
|
"components"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"GhidraComponent": {
|
||||||
|
"description": "Diagnosed Ghidra installation.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"launcher": {
|
||||||
|
"description": "Configured official launcher.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/TaggedPath"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"description": "Stable readiness classification.",
|
||||||
|
"$ref": "#/$defs/ComponentStatus"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"description": "Observed version when the probe ran.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"InlineOrArtifact": {
|
||||||
|
"description": "Successful command data emitted inline or represented by a complete Artifact.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Complete command-specific data.",
|
||||||
|
"$ref": "#/$defs/DoctorData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Bounded descriptor for the complete stored success response.",
|
||||||
|
"$ref": "#/$defs/SpilledData"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"JavaComponent": {
|
||||||
|
"description": "Diagnosed Java installation.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"executable": {
|
||||||
|
"description": "Configured Java executable.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/TaggedPath"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"description": "Stable readiness classification.",
|
||||||
|
"$ref": "#/$defs/ComponentStatus"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"description": "Observed feature version.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
},
|
||||||
"SandboxBackend": {
|
"SandboxBackend": {
|
||||||
"description": "Sandbox backend.",
|
"description": "Sandbox backend.",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
|
@ -144,6 +292,29 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SandboxComponent": {
|
||||||
|
"description": "Diagnosed sandbox backend.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"backend": {
|
||||||
|
"description": "Explicit selected backend.",
|
||||||
|
"$ref": "#/$defs/SandboxBackend"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"description": "Stable readiness classification.",
|
||||||
|
"$ref": "#/$defs/ComponentStatus"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"description": "Verification state.",
|
||||||
|
"$ref": "#/$defs/SandboxVerification"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"backend",
|
||||||
|
"verification"
|
||||||
|
]
|
||||||
|
},
|
||||||
"SandboxProvenance": {
|
"SandboxProvenance": {
|
||||||
"description": "Worker sandbox provenance.",
|
"description": "Worker sandbox provenance.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -182,6 +353,46 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SpilledData": {
|
||||||
|
"description": "Public data shape returned when a complete success exceeds the inline byte budget.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"artifact": {
|
||||||
|
"description": "Immutable complete-success Artifact.",
|
||||||
|
"$ref": "#/$defs/ArtifactDescriptor"
|
||||||
|
},
|
||||||
|
"spilled": {
|
||||||
|
"description": "Always true for this variant.",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"spilled",
|
||||||
|
"artifact"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"StorageUsage": {
|
||||||
|
"description": "Filesystem usage represented without implying freed space.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allocated_bytes": {
|
||||||
|
"description": "Sum of Linux allocated blocks including directories.",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"logical_bytes": {
|
||||||
|
"description": "Sum of file lengths.",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"logical_bytes",
|
||||||
|
"allocated_bytes"
|
||||||
|
]
|
||||||
|
},
|
||||||
"StoreSource": {
|
"StoreSource": {
|
||||||
"description": "Analysis Store resolution source.",
|
"description": "Analysis Store resolution source.",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
|
|
||||||
|
|
@ -120,11 +120,6 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "java_incompatible"
|
"const": "java_incompatible"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"description": "A later integration layer has not connected execution yet.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "internal_not_implemented"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "Rust/Java request or response validation failed.",
|
"description": "Rust/Java request or response validation failed.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
"description": "Operation-specific result.",
|
"description": "Operation-specific result.",
|
||||||
"$ref": "#/$defs/FunctionsData"
|
"$ref": "#/$defs/InlineOrArtifact"
|
||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -72,6 +72,41 @@
|
||||||
"source"
|
"source"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ArtifactDescriptor": {
|
||||||
|
"description": "Artifact descriptor returned instead of oversized inline data.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bytes": {
|
||||||
|
"description": "Exact file bytes including trailing LF.",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"description": "Always `complete_success_response`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"media_type": {
|
||||||
|
"description": "Always `application/json`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"description": "Tagged absolute Artifact path.",
|
||||||
|
"$ref": "#/$defs/TaggedPath"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"description": "SHA-256 of exact file bytes.",
|
||||||
|
"$ref": "#/$defs/Digest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"path",
|
||||||
|
"bytes",
|
||||||
|
"sha256",
|
||||||
|
"media_type",
|
||||||
|
"contains"
|
||||||
|
]
|
||||||
|
},
|
||||||
"Digest": {
|
"Digest": {
|
||||||
"description": "A lowercase SHA-256 digest.",
|
"description": "A lowercase SHA-256 digest.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
@ -173,6 +208,19 @@
|
||||||
"items"
|
"items"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"InlineOrArtifact": {
|
||||||
|
"description": "Successful command data emitted inline or represented by a complete Artifact.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Complete command-specific data.",
|
||||||
|
"$ref": "#/$defs/FunctionsData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Bounded descriptor for the complete stored success response.",
|
||||||
|
"$ref": "#/$defs/SpilledData"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"Limits": {
|
"Limits": {
|
||||||
"description": "Resource policy selected for one invocation.",
|
"description": "Resource policy selected for one invocation.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -399,6 +447,24 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SpilledData": {
|
||||||
|
"description": "Public data shape returned when a complete success exceeds the inline byte budget.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"artifact": {
|
||||||
|
"description": "Immutable complete-success Artifact.",
|
||||||
|
"$ref": "#/$defs/ArtifactDescriptor"
|
||||||
|
},
|
||||||
|
"spilled": {
|
||||||
|
"description": "Always true for this variant.",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"spilled",
|
||||||
|
"artifact"
|
||||||
|
]
|
||||||
|
},
|
||||||
"StoreSource": {
|
"StoreSource": {
|
||||||
"description": "Analysis Store resolution source.",
|
"description": "Analysis Store resolution source.",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
"description": "Operation-specific result.",
|
"description": "Operation-specific result.",
|
||||||
"$ref": "#/$defs/InspectionData"
|
"$ref": "#/$defs/InlineOrArtifact"
|
||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -105,10 +105,58 @@
|
||||||
"disposition"
|
"disposition"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ArtifactDescriptor": {
|
||||||
|
"description": "Artifact descriptor returned instead of oversized inline data.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bytes": {
|
||||||
|
"description": "Exact file bytes including trailing LF.",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"description": "Always `complete_success_response`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"media_type": {
|
||||||
|
"description": "Always `application/json`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"description": "Tagged absolute Artifact path.",
|
||||||
|
"$ref": "#/$defs/TaggedPath"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"description": "SHA-256 of exact file bytes.",
|
||||||
|
"$ref": "#/$defs/Digest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"path",
|
||||||
|
"bytes",
|
||||||
|
"sha256",
|
||||||
|
"media_type",
|
||||||
|
"contains"
|
||||||
|
]
|
||||||
|
},
|
||||||
"Digest": {
|
"Digest": {
|
||||||
"description": "A lowercase SHA-256 digest.",
|
"description": "A lowercase SHA-256 digest.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"InlineOrArtifact": {
|
||||||
|
"description": "Successful command data emitted inline or represented by a complete Artifact.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Complete command-specific data.",
|
||||||
|
"$ref": "#/$defs/InspectionData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Bounded descriptor for the complete stored success response.",
|
||||||
|
"$ref": "#/$defs/SpilledData"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"InspectionData": {
|
"InspectionData": {
|
||||||
"description": "Sample inspection data.",
|
"description": "Sample inspection data.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -378,6 +426,24 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SpilledData": {
|
||||||
|
"description": "Public data shape returned when a complete success exceeds the inline byte budget.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"artifact": {
|
||||||
|
"description": "Immutable complete-success Artifact.",
|
||||||
|
"$ref": "#/$defs/ArtifactDescriptor"
|
||||||
|
},
|
||||||
|
"spilled": {
|
||||||
|
"description": "Always true for this variant.",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"spilled",
|
||||||
|
"artifact"
|
||||||
|
]
|
||||||
|
},
|
||||||
"StoreSource": {
|
"StoreSource": {
|
||||||
"description": "Analysis Store resolution source.",
|
"description": "Analysis Store resolution source.",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
|
|
||||||
321
src/commands/doctor.rs
Normal file
321
src/commands/doctor.rs
Normal 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
155
src/commands/mod.rs
Normal 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
908
src/commands/query.rs
Normal 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")
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,13 @@ impl fmt::Display for Digest {
|
||||||
pub struct InvocationId(String);
|
pub struct InvocationId(String);
|
||||||
|
|
||||||
impl InvocationId {
|
impl InvocationId {
|
||||||
|
/// Generates a cryptographically random 128-bit invocation identifier.
|
||||||
|
pub fn generate() -> Result<Self, getrandom::Error> {
|
||||||
|
let mut bytes = [0_u8; 16];
|
||||||
|
getrandom::fill(&mut bytes)?;
|
||||||
|
Ok(Self(hex::encode(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Parses a canonical invocation identifier.
|
/// Parses a canonical invocation identifier.
|
||||||
pub fn parse(value: &str) -> Result<Self, IdentifierError> {
|
pub fn parse(value: &str) -> Result<Self, IdentifierError> {
|
||||||
if value.len() != 32
|
if value.len() != 32
|
||||||
|
|
@ -51,6 +58,18 @@ impl InvocationId {
|
||||||
}
|
}
|
||||||
Ok(Self(value.to_owned()))
|
Ok(Self(value.to_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical lowercase hexadecimal spelling.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for InvocationId {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identifier validation failure.
|
/// Identifier validation failure.
|
||||||
|
|
|
||||||
18
src/error.rs
18
src/error.rs
|
|
@ -37,8 +37,6 @@ pub enum ErrorCode {
|
||||||
JavaMissing,
|
JavaMissing,
|
||||||
/// The installed Java version is incompatible.
|
/// The installed Java version is incompatible.
|
||||||
JavaIncompatible,
|
JavaIncompatible,
|
||||||
/// A later integration layer has not connected execution yet.
|
|
||||||
InternalNotImplemented,
|
|
||||||
/// Rust/Java request or response validation failed.
|
/// Rust/Java request or response validation failed.
|
||||||
ProtocolViolation,
|
ProtocolViolation,
|
||||||
/// The child exceeded a native or watchdog timeout.
|
/// The child exceeded a native or watchdog timeout.
|
||||||
|
|
@ -157,22 +155,6 @@ impl AppError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear failure used until the store/process/Java execution layer is attached.
|
|
||||||
#[must_use]
|
|
||||||
pub fn execution_not_implemented(operation: &str) -> Self {
|
|
||||||
let mut error = Self::new(
|
|
||||||
ErrorCode::InternalNotImplemented,
|
|
||||||
format!("operation '{operation}' is not connected to the Ghidra execution layer"),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
error
|
|
||||||
.envelope
|
|
||||||
.error
|
|
||||||
.details
|
|
||||||
.insert("operation".to_owned(), Value::String(operation.to_owned()));
|
|
||||||
error
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Constructs an invalid-invocation failure from clap context.
|
/// Constructs an invalid-invocation failure from clap context.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn invalid_arguments(message: impl Into<String>) -> Self {
|
pub fn invalid_arguments(message: impl Into<String>) -> Self {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@
|
||||||
#![doc = "Public contracts and synchronous orchestration seams for `ghidr`."]
|
#![doc = "Public contracts and synchronous orchestration seams for `ghidr`."]
|
||||||
|
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
pub mod commands;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod operation;
|
pub mod operation;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
|
pub mod process;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
pub mod runtime;
|
||||||
|
pub mod sandbox;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
|
|
|
||||||
42
src/main.rs
42
src/main.rs
|
|
@ -3,17 +3,45 @@
|
||||||
|
|
||||||
use std::{io, process::ExitCode};
|
use std::{io, process::ExitCode};
|
||||||
|
|
||||||
use ghidra_cli::output::{UnimplementedExecutor, run_from};
|
use ghidra_cli::{
|
||||||
|
AppError, ErrorCode,
|
||||||
|
cli::Cli,
|
||||||
|
commands::ApplicationExecutor,
|
||||||
|
output::{Executor, RenderedSuccess, run_from},
|
||||||
|
process::InterruptCounter,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct StartupFailure(AppError);
|
||||||
|
|
||||||
|
impl Executor for StartupFailure {
|
||||||
|
fn execute(&self, _cli: &Cli) -> Result<RenderedSuccess, AppError> {
|
||||||
|
Err(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
let mut stdout = io::stdout().lock();
|
let mut stdout = io::stdout().lock();
|
||||||
let mut stderr = io::stderr().lock();
|
let mut stderr = io::stderr().lock();
|
||||||
match run_from(
|
let arguments: Vec<_> = std::env::args_os().collect();
|
||||||
std::env::args_os(),
|
let result = match InterruptCounter::install() {
|
||||||
&UnimplementedExecutor,
|
Ok(cancellation) => run_from(
|
||||||
&mut stdout,
|
arguments,
|
||||||
&mut stderr,
|
&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)),
|
Ok(status) => ExitCode::from(u8::try_from(status.code()).unwrap_or(1)),
|
||||||
Err(_) => ExitCode::FAILURE,
|
Err(_) => ExitCode::FAILURE,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -293,17 +293,67 @@ pub enum SandboxVerification {
|
||||||
pub struct DoctorData {
|
pub struct DoctorData {
|
||||||
/// True only when every required component is ready.
|
/// True only when every required component is ready.
|
||||||
pub ready: bool,
|
pub ready: bool,
|
||||||
/// Typed component reports keyed by stable component name.
|
/// Complete fixed component report.
|
||||||
pub components: BTreeMap<String, ComponentReport>,
|
pub components: DoctorComponents,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One diagnosed component.
|
/// Fixed installation components checked safely by `doctor`.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct ComponentReport {
|
pub struct DoctorComponents {
|
||||||
|
/// Ghidra launcher and adapter.
|
||||||
|
pub ghidra: GhidraComponent,
|
||||||
|
/// Java runtime.
|
||||||
|
pub java: JavaComponent,
|
||||||
|
/// Analysis Store.
|
||||||
|
pub analysis_store: AnalysisStoreComponent,
|
||||||
|
/// Worker isolation backend.
|
||||||
|
pub sandbox: SandboxComponent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnosed Ghidra installation.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct GhidraComponent {
|
||||||
/// Stable readiness classification.
|
/// Stable readiness classification.
|
||||||
pub status: ComponentStatus,
|
pub status: ComponentStatus,
|
||||||
/// Component-specific facts.
|
/// Observed version when the probe ran.
|
||||||
pub details: BTreeMap<String, Value>,
|
pub version: Option<String>,
|
||||||
|
/// Configured official launcher.
|
||||||
|
pub launcher: Option<TaggedPath>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnosed Java installation.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct JavaComponent {
|
||||||
|
/// Stable readiness classification.
|
||||||
|
pub status: ComponentStatus,
|
||||||
|
/// Observed feature version.
|
||||||
|
pub version: Option<String>,
|
||||||
|
/// Configured Java executable.
|
||||||
|
pub executable: Option<TaggedPath>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnosed Analysis Store.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct AnalysisStoreComponent {
|
||||||
|
/// Stable readiness classification.
|
||||||
|
pub status: ComponentStatus,
|
||||||
|
/// Resolved absolute store path.
|
||||||
|
pub path: TaggedPath,
|
||||||
|
/// Resolution precedence source.
|
||||||
|
pub source: StoreSource,
|
||||||
|
/// Complete non-following store usage scan.
|
||||||
|
pub usage: StorageUsage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnosed sandbox backend.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct SandboxComponent {
|
||||||
|
/// Stable readiness classification.
|
||||||
|
pub status: ComponentStatus,
|
||||||
|
/// Explicit selected backend.
|
||||||
|
pub backend: SandboxBackend,
|
||||||
|
/// Verification state.
|
||||||
|
pub verification: SandboxVerification,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Doctor component readiness classification.
|
/// Doctor component readiness classification.
|
||||||
|
|
@ -556,6 +606,25 @@ pub struct ArtifactDescriptor {
|
||||||
pub contains: String,
|
pub contains: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Successful command data emitted inline or represented by a complete Artifact.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum InlineOrArtifact<D> {
|
||||||
|
/// Complete command-specific data.
|
||||||
|
Inline(D),
|
||||||
|
/// Bounded descriptor for the complete stored success response.
|
||||||
|
Spilled(SpilledData),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public data shape returned when a complete success exceeds the inline byte budget.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct SpilledData {
|
||||||
|
/// Always true for this variant.
|
||||||
|
pub spilled: bool,
|
||||||
|
/// Immutable complete-success Artifact.
|
||||||
|
pub artifact: ArtifactDescriptor,
|
||||||
|
}
|
||||||
|
|
||||||
/// Canonical Analysis Profile document whose exact JSON bytes are hashed.
|
/// Canonical Analysis Profile document whose exact JSON bytes are hashed.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppError, ErrorCode, ExitStatus,
|
AppError, ErrorCode, ExitStatus,
|
||||||
cli::{Cli, Command, OutputFormat},
|
cli::{Cli, OutputFormat},
|
||||||
protocol::requests_human_format,
|
protocol::requests_human_format,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -32,18 +32,6 @@ pub trait Executor {
|
||||||
fn execute(&self, cli: &Cli) -> Result<RenderedSuccess, AppError>;
|
fn execute(&self, cli: &Cli) -> Result<RenderedSuccess, AppError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Foundation executor which truthfully rejects every unconnected path.
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
|
||||||
pub struct UnimplementedExecutor;
|
|
||||||
|
|
||||||
impl Executor for UnimplementedExecutor {
|
|
||||||
fn execute(&self, cli: &Cli) -> Result<RenderedSuccess, AppError> {
|
|
||||||
Err(AppError::execution_not_implemented(operation_name(
|
|
||||||
&cli.command,
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses, executes, and writes exactly the stream framing selected by the caller.
|
/// Parses, executes, and writes exactly the stream framing selected by the caller.
|
||||||
pub fn run_from<I, T>(
|
pub fn run_from<I, T>(
|
||||||
arguments: I,
|
arguments: I,
|
||||||
|
|
@ -133,16 +121,6 @@ fn write_error_json(error: &AppError, writer: &mut impl Write) -> io::Result<()>
|
||||||
writer.write_all(&bytes)
|
writer.write_all(&bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn operation_name(command: &Command) -> &'static str {
|
|
||||||
match command {
|
|
||||||
Command::Doctor => "doctor",
|
|
||||||
Command::Inspect(_) => "inspect",
|
|
||||||
Command::Functions(_) => "functions",
|
|
||||||
Command::Decompile(_) => "decompile",
|
|
||||||
Command::Clean(_) => "clean",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
|
||||||
219
src/process/capture.rs
Normal file
219
src/process/capture.rs
Normal 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
369
src/process/diagnostics.rs
Normal 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
618
src/process/lifecycle.rs
Normal 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
12
src/process/mod.rs
Normal 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
46
src/process/signals.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/protocol.rs
298
src/protocol.rs
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
ffi::OsStr,
|
ffi::OsStr,
|
||||||
io::{self, Read},
|
fs::{self, File, OpenOptions},
|
||||||
|
io::{self, Read, Write},
|
||||||
|
os::unix::fs::OpenOptionsExt as _,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -12,14 +14,95 @@ use thiserror::Error;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::{FunctionSelector, InvocationId, Limits, PageRequest},
|
domain::{FunctionSelector, InvocationId, Limits, PageRequest},
|
||||||
operation::{ADAPTER_PROTOCOL_VERSION, Operation},
|
operation::{
|
||||||
|
ADAPTER_PROTOCOL_VERSION, DecompilationData, FunctionsData, Operation, ProfileOption,
|
||||||
|
ProgramSummary, TargetProvenance,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Engine facts returned by the Java installation probe.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AdapterDoctorData {
|
||||||
|
/// Probe result.
|
||||||
|
pub ready: bool,
|
||||||
|
/// Exact Ghidra version.
|
||||||
|
pub ghidra_version: String,
|
||||||
|
/// Java feature version.
|
||||||
|
pub java_version: u64,
|
||||||
|
/// Adapter protocol implementation version.
|
||||||
|
pub adapter_protocol_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Engine metadata common to every Sample-backed worker result.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AdapterContext {
|
||||||
|
/// Exact Ghidra version.
|
||||||
|
pub ghidra_version: String,
|
||||||
|
/// Java feature version.
|
||||||
|
pub java_version: u64,
|
||||||
|
/// Resolved Target Specification without harness verification policy.
|
||||||
|
pub target: AdapterTarget,
|
||||||
|
/// Fully resolved loader options; empty when the selected loader exposes none.
|
||||||
|
pub loader_options: Vec<ProfileOption>,
|
||||||
|
/// Fully resolved current analysis option values.
|
||||||
|
pub analyzer_options: Vec<ProfileOption>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Target fields extracted from the current Ghidra Program.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AdapterTarget {
|
||||||
|
/// Loader identifier.
|
||||||
|
pub loader: String,
|
||||||
|
/// Executable format.
|
||||||
|
pub format: String,
|
||||||
|
/// Processor language identifier.
|
||||||
|
pub processor_language: String,
|
||||||
|
/// Compiler specification identifier.
|
||||||
|
pub compiler_specification: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdapterTarget {
|
||||||
|
/// Adds the harness-owned integration verification classification.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provenance(
|
||||||
|
&self,
|
||||||
|
verification: crate::operation::TargetVerification,
|
||||||
|
) -> TargetProvenance {
|
||||||
|
TargetProvenance {
|
||||||
|
loader: self.loader.clone(),
|
||||||
|
format: self.format.clone(),
|
||||||
|
processor_language: self.processor_language.clone(),
|
||||||
|
compiler_specification: self.compiler_specification.clone(),
|
||||||
|
verification,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict Sample-backed worker payload; public provenance is added by Rust.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AdapterQueryData<T> {
|
||||||
|
/// Current Program/engine context.
|
||||||
|
pub context: AdapterContext,
|
||||||
|
/// Operation-specific facts.
|
||||||
|
pub query: T,
|
||||||
|
}
|
||||||
|
|
||||||
/// Maximum exact serialized `request.json` size (1 MiB).
|
/// Maximum exact serialized `request.json` size (1 MiB).
|
||||||
pub const MAX_REQUEST_BYTES: usize = 1_048_576;
|
pub const MAX_REQUEST_BYTES: usize = 1_048_576;
|
||||||
/// Maximum exact serialized `response.json` size (256 MiB).
|
/// Maximum exact serialized `response.json` size (256 MiB).
|
||||||
pub const MAX_RESPONSE_BYTES: usize = 268_435_456;
|
pub const MAX_RESPONSE_BYTES: usize = 268_435_456;
|
||||||
|
|
||||||
|
/// Fixed names inside one private invocation directory.
|
||||||
|
pub const REQUEST_FILE_NAME: &str = "request.json";
|
||||||
|
/// Java's incomplete response, never accepted by Rust.
|
||||||
|
pub const TEMP_RESPONSE_FILE_NAME: &str = "response.json.tmp";
|
||||||
|
/// Java's only accepted final response name.
|
||||||
|
pub const RESPONSE_FILE_NAME: &str = "response.json";
|
||||||
|
|
||||||
/// A validated UTF-8 absolute path owned by the trusted harness.
|
/// A validated UTF-8 absolute path owned by the trusted harness.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
|
|
@ -164,6 +247,127 @@ pub fn validate_response_echo(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strictly decoded operation data after echo and schema validation.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ValidatedAdapterData {
|
||||||
|
/// Installation probe result.
|
||||||
|
Doctor(AdapterDoctorData),
|
||||||
|
/// Program inspection result.
|
||||||
|
Inspect(AdapterQueryData<ProgramSummary>),
|
||||||
|
/// Stable Function page.
|
||||||
|
Functions(AdapterQueryData<FunctionsData>),
|
||||||
|
/// Complete decompilation.
|
||||||
|
Decompile(AdapterQueryData<DecompilationData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a success payload against the exact operation-specific Rust type.
|
||||||
|
///
|
||||||
|
/// Comparing the value with its typed round trip also rejects unknown fields,
|
||||||
|
/// rather than silently discarding them during deserialization.
|
||||||
|
pub fn validate_operation_data(
|
||||||
|
operation: Operation,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Result<ValidatedAdapterData, ProtocolCodecError> {
|
||||||
|
macro_rules! exact {
|
||||||
|
($type:ty, $variant:ident) => {{
|
||||||
|
let decoded: $type = serde_json::from_value(data.clone())?;
|
||||||
|
if serde_json::to_value(&decoded)? != data {
|
||||||
|
return Err(ProtocolCodecError::OperationSchemaMismatch);
|
||||||
|
}
|
||||||
|
Ok(ValidatedAdapterData::$variant(decoded))
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
match operation {
|
||||||
|
Operation::Doctor => exact!(AdapterDoctorData, Doctor),
|
||||||
|
Operation::Inspect => exact!(AdapterQueryData<ProgramSummary>, Inspect),
|
||||||
|
Operation::Functions => exact!(AdapterQueryData<FunctionsData>, Functions),
|
||||||
|
Operation::Decompile => exact!(AdapterQueryData<DecompilationData>, Decompile),
|
||||||
|
Operation::Clean => Err(ProtocolCodecError::OperationSchemaMismatch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File paths and atomic request/strict response handling for one Invocation.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct InvocationFiles {
|
||||||
|
directory: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InvocationFiles {
|
||||||
|
/// Uses an existing private, tool-owned invocation directory.
|
||||||
|
pub fn new(directory: PathBuf) -> Result<Self, ProtocolCodecError> {
|
||||||
|
if !directory.is_absolute() || directory.to_str().is_none() {
|
||||||
|
return Err(ProtocolCodecError::InvalidWorkerPath);
|
||||||
|
}
|
||||||
|
let metadata = fs::symlink_metadata(&directory)?;
|
||||||
|
if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
|
||||||
|
return Err(ProtocolCodecError::InvalidInvocationDirectory);
|
||||||
|
}
|
||||||
|
Ok(Self { directory })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute request path passed to the adapter.
|
||||||
|
#[must_use]
|
||||||
|
pub fn request_path(&self) -> PathBuf {
|
||||||
|
self.directory.join(REQUEST_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute final response path passed to the adapter.
|
||||||
|
#[must_use]
|
||||||
|
pub fn response_path(&self) -> PathBuf {
|
||||||
|
self.directory.join(RESPONSE_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically creates a mode-0600 request before worker launch.
|
||||||
|
pub fn write_request(&self, request: &AdapterRequest) -> Result<(), ProtocolCodecError> {
|
||||||
|
let bytes = encode_request(request)?;
|
||||||
|
let temporary = self.directory.join("request.json.tmp");
|
||||||
|
let final_path = self.request_path();
|
||||||
|
if fs::symlink_metadata(&final_path).is_ok() || fs::symlink_metadata(&temporary).is_ok() {
|
||||||
|
return Err(ProtocolCodecError::ProtocolFileAlreadyExists);
|
||||||
|
}
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&temporary)?;
|
||||||
|
file.write_all(&bytes)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
fs::rename(&temporary, &final_path)?;
|
||||||
|
File::open(&self.directory)?.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens only a final regular response, then checks bounds and echoed fields.
|
||||||
|
pub fn read_response(
|
||||||
|
&self,
|
||||||
|
request: &AdapterRequest,
|
||||||
|
) -> Result<AdapterResponse, ProtocolCodecError> {
|
||||||
|
let path = self.response_path();
|
||||||
|
let metadata = fs::symlink_metadata(&path).map_err(|error| {
|
||||||
|
if error.kind() == io::ErrorKind::NotFound {
|
||||||
|
ProtocolCodecError::MissingFinalResponse
|
||||||
|
} else {
|
||||||
|
ProtocolCodecError::Io(error)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
|
||||||
|
return Err(ProtocolCodecError::InvalidFinalResponseType);
|
||||||
|
}
|
||||||
|
if metadata.len() > MAX_RESPONSE_BYTES as u64 {
|
||||||
|
return Err(ProtocolCodecError::ResponseTooLarge {
|
||||||
|
limit: MAX_RESPONSE_BYTES,
|
||||||
|
observed: MAX_RESPONSE_BYTES.saturating_add(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let response = decode_response(File::open(path)?)?;
|
||||||
|
validate_response_echo(request, &response)?;
|
||||||
|
if let AdapterResult::Success { data } = &response.result {
|
||||||
|
let _validated = validate_operation_data(response.operation, data.clone())?;
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn encode_bounded<T: Serialize>(
|
fn encode_bounded<T: Serialize>(
|
||||||
value: &T,
|
value: &T,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
|
|
@ -238,6 +442,21 @@ pub enum ProtocolCodecError {
|
||||||
/// Operation echo differs.
|
/// Operation echo differs.
|
||||||
#[error("adapter operation mismatch")]
|
#[error("adapter operation mismatch")]
|
||||||
OperationMismatch,
|
OperationMismatch,
|
||||||
|
/// Operation data did not round-trip through its exact typed schema.
|
||||||
|
#[error("adapter operation data failed strict schema validation")]
|
||||||
|
OperationSchemaMismatch,
|
||||||
|
/// The private invocation directory is not a real directory.
|
||||||
|
#[error("invocation path must name a real directory")]
|
||||||
|
InvalidInvocationDirectory,
|
||||||
|
/// A protocol path unexpectedly existed before launch.
|
||||||
|
#[error("protocol file already exists before worker launch")]
|
||||||
|
ProtocolFileAlreadyExists,
|
||||||
|
/// No atomically published final response exists.
|
||||||
|
#[error("adapter did not publish response.json")]
|
||||||
|
MissingFinalResponse,
|
||||||
|
/// The final response is a symlink, directory, or special file.
|
||||||
|
#[error("response.json must be a regular non-symlink file")]
|
||||||
|
InvalidFinalResponseType,
|
||||||
/// Platform cannot represent the protocol bound.
|
/// Platform cannot represent the protocol bound.
|
||||||
#[error("platform cannot represent protocol size bound")]
|
#[error("platform cannot represent protocol size bound")]
|
||||||
PlatformLimit,
|
PlatformLimit,
|
||||||
|
|
@ -260,10 +479,45 @@ pub fn requests_human_format(arguments: &[impl AsRef<OsStr>]) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::io::Cursor;
|
use std::{io::Cursor, path::PathBuf};
|
||||||
|
|
||||||
use super::{ProtocolCodecError, decode_bounded, requests_human_format};
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
AdapterRequest, InvocationFiles, ProtocolCodecError, RequestArguments, decode_bounded,
|
||||||
|
requests_human_format, validate_operation_data,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
domain::{FunctionSelector, InvocationId, Limits, PositiveU64},
|
||||||
|
operation::{ADAPTER_PROTOCOL_VERSION, Operation},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn positive(value: u64) -> PositiveU64 {
|
||||||
|
PositiveU64::try_from(value).expect("positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(arguments: RequestArguments) -> AdapterRequest {
|
||||||
|
AdapterRequest {
|
||||||
|
protocol_version: ADAPTER_PROTOCOL_VERSION,
|
||||||
|
invocation_id: InvocationId::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||||
|
.expect("identifier"),
|
||||||
|
operation: arguments.operation(),
|
||||||
|
staged_sample: None,
|
||||||
|
analysis_path: None,
|
||||||
|
limits: Limits {
|
||||||
|
max_heap_mib: positive(2048),
|
||||||
|
max_cpu: positive(2),
|
||||||
|
analysis_timeout_seconds: positive(600),
|
||||||
|
decompile_timeout_seconds: None,
|
||||||
|
child_watchdog_seconds: positive(120),
|
||||||
|
max_sample_bytes: positive(1_073_741_824),
|
||||||
|
max_inline_bytes: positive(65_536),
|
||||||
|
},
|
||||||
|
arguments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn response_reader_stops_at_bound_plus_one() {
|
fn response_reader_stops_at_bound_plus_one() {
|
||||||
|
|
@ -302,4 +556,40 @@ mod tests {
|
||||||
Err(ProtocolCodecError::RequestTooLarge { .. })
|
Err(ProtocolCodecError::RequestTooLarge { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversized_typed_request_is_rejected_before_any_file_is_published() {
|
||||||
|
let root = tempdir().expect("temporary root");
|
||||||
|
let invocation = root.path().join("invocation");
|
||||||
|
std::fs::create_dir(&invocation).expect("invocation directory");
|
||||||
|
let files = InvocationFiles::new(invocation).expect("protocol paths");
|
||||||
|
let request = request(RequestArguments::Decompile {
|
||||||
|
selector: FunctionSelector::Name("x".repeat(super::MAX_REQUEST_BYTES)),
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
files.write_request(&request),
|
||||||
|
Err(ProtocolCodecError::RequestTooLarge { .. })
|
||||||
|
));
|
||||||
|
assert!(!files.request_path().exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_schema_rejects_unknown_fields() {
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"ready": true,
|
||||||
|
"ghidra_version": "12.1.2",
|
||||||
|
"java_version": 21,
|
||||||
|
"adapter_protocol_version": 1,
|
||||||
|
"unexpected": true
|
||||||
|
});
|
||||||
|
assert!(validate_operation_data(Operation::Doctor, data).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invocation_directory_must_be_absolute() {
|
||||||
|
assert!(matches!(
|
||||||
|
InvocationFiles::new(PathBuf::from("relative")),
|
||||||
|
Err(ProtocolCodecError::InvalidWorkerPath)
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
98
src/runtime.rs
Normal file
98
src/runtime.rs
Normal 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
360
src/sandbox/mod.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,8 @@ use serde_json::{Value, json};
|
||||||
use crate::{
|
use crate::{
|
||||||
error::ErrorEnvelope,
|
error::ErrorEnvelope,
|
||||||
operation::{
|
operation::{
|
||||||
AnalysisProfile, CleanupData, DecompilationData, DoctorData, FunctionsData, InspectionData,
|
AnalysisProfile, CleanupData, DecompilationData, DoctorData, FunctionsData,
|
||||||
QueryProvenance, SuccessEnvelope, ToolProvenance,
|
InlineOrArtifact, InspectionData, QueryProvenance, SuccessEnvelope, ToolProvenance,
|
||||||
},
|
},
|
||||||
protocol::{AdapterRequest, AdapterResponse},
|
protocol::{AdapterRequest, AdapterResponse},
|
||||||
};
|
};
|
||||||
|
|
@ -25,21 +25,24 @@ pub fn v1_schemas() -> BTreeMap<&'static str, Schema> {
|
||||||
(
|
(
|
||||||
"cleanup.schema.json",
|
"cleanup.schema.json",
|
||||||
with_kind(
|
with_kind(
|
||||||
schema_for!(SuccessEnvelope<ToolProvenance, CleanupData>),
|
schema_for!(SuccessEnvelope<ToolProvenance, InlineOrArtifact<CleanupData>>),
|
||||||
"cleanup",
|
"cleanup",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"decompilation.schema.json",
|
"decompilation.schema.json",
|
||||||
with_kind(
|
with_kind(
|
||||||
schema_for!(SuccessEnvelope<QueryProvenance, DecompilationData>),
|
schema_for!(SuccessEnvelope<
|
||||||
|
QueryProvenance,
|
||||||
|
InlineOrArtifact<DecompilationData>,
|
||||||
|
>),
|
||||||
"decompilation",
|
"decompilation",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"doctor.schema.json",
|
"doctor.schema.json",
|
||||||
with_kind(
|
with_kind(
|
||||||
schema_for!(SuccessEnvelope<ToolProvenance, DoctorData>),
|
schema_for!(SuccessEnvelope<ToolProvenance, InlineOrArtifact<DoctorData>>),
|
||||||
"doctor",
|
"doctor",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -54,14 +57,14 @@ pub fn v1_schemas() -> BTreeMap<&'static str, Schema> {
|
||||||
(
|
(
|
||||||
"functions.schema.json",
|
"functions.schema.json",
|
||||||
with_kind(
|
with_kind(
|
||||||
schema_for!(SuccessEnvelope<QueryProvenance, FunctionsData>),
|
schema_for!(SuccessEnvelope<QueryProvenance, InlineOrArtifact<FunctionsData>>),
|
||||||
"functions",
|
"functions",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"inspection.schema.json",
|
"inspection.schema.json",
|
||||||
with_kind(
|
with_kind(
|
||||||
schema_for!(SuccessEnvelope<QueryProvenance, InspectionData>),
|
schema_for!(SuccessEnvelope<QueryProvenance, InlineOrArtifact<InspectionData>>),
|
||||||
"inspection",
|
"inspection",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,7 @@ impl AnalysisStore {
|
||||||
profile: &Digest,
|
profile: &Digest,
|
||||||
lock: &AnalysisLock,
|
lock: &AnalysisLock,
|
||||||
) -> Result<PathBuf, StoreError> {
|
) -> Result<PathBuf, StoreError> {
|
||||||
if !lock.guards(sample, profile) {
|
if !lock.guards_exclusive(sample, profile) {
|
||||||
return Err(StoreError::new(
|
return Err(StoreError::new(
|
||||||
StoreErrorKind::AnalysisBusy,
|
StoreErrorKind::AnalysisBusy,
|
||||||
"lock token does not guard the promoted Analysis identity",
|
"lock token does not guard the promoted Analysis identity",
|
||||||
|
|
@ -267,7 +267,7 @@ impl AnalysisStore {
|
||||||
lock: &AnalysisLock,
|
lock: &AnalysisLock,
|
||||||
rebuild_already_attempted: bool,
|
rebuild_already_attempted: bool,
|
||||||
) -> Result<QuarantineOutcome, StoreError> {
|
) -> Result<QuarantineOutcome, StoreError> {
|
||||||
if !lock.guards(sample, profile) {
|
if !lock.guards_exclusive(sample, profile) {
|
||||||
return Err(StoreError::new(
|
return Err(StoreError::new(
|
||||||
StoreErrorKind::AnalysisBusy,
|
StoreErrorKind::AnalysisBusy,
|
||||||
"lock token does not guard the quarantined Analysis identity",
|
"lock token does not guard the quarantined Analysis identity",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ pub fn execute_cleanup(
|
||||||
"store-wide cleanup requires explicit confirmation",
|
"store-wide cleanup requires explicit confirmation",
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
.with_detail(
|
||||||
|
"matched",
|
||||||
|
serde_json::to_value(plan.snapshot()).unwrap_or(Value::Null),
|
||||||
|
)
|
||||||
.with_detail("analyses", Value::from(plan.snapshot().analyses))
|
.with_detail("analyses", Value::from(plan.snapshot().analyses))
|
||||||
.with_detail("artifacts", Value::from(plan.snapshot().artifacts))
|
.with_detail("artifacts", Value::from(plan.snapshot().artifacts))
|
||||||
.with_detail(
|
.with_detail(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
use std::{
|
use std::{
|
||||||
fs::{self, File, OpenOptions},
|
fs::{self, File, OpenOptions},
|
||||||
io::Write as _,
|
io::{self, Write as _},
|
||||||
os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _},
|
os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use rustix::fs::{Mode, OFlags};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::operation::{StorageUsage, StoreSource};
|
use crate::operation::{StorageUsage, StoreSource};
|
||||||
|
|
@ -123,6 +124,77 @@ pub(crate) fn create_private_directory(path: &Path) -> Result<(), StoreError> {
|
||||||
.map_err(|error| StoreError::io(path, error))
|
.map_err(|error| StoreError::io(path, error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copies a validated project into private disposable storage without following
|
||||||
|
/// symlinks or accepting special files.
|
||||||
|
pub(crate) fn copy_private_tree(source: &Path, destination: &Path) -> Result<(), StoreError> {
|
||||||
|
let metadata = fs::symlink_metadata(source).map_err(|error| StoreError::io(source, error))?;
|
||||||
|
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||||
|
return Err(StoreError::at(
|
||||||
|
StoreErrorKind::CorruptStore,
|
||||||
|
source,
|
||||||
|
"project snapshot source is not an ordinary directory",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
create_private_directory(destination)?;
|
||||||
|
let mut entries = fs::read_dir(source)
|
||||||
|
.map_err(|error| StoreError::io(source, error))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|error| StoreError::io(source, error))?;
|
||||||
|
entries.sort_by_key(fs::DirEntry::file_name);
|
||||||
|
for entry in entries {
|
||||||
|
let source_path = entry.path();
|
||||||
|
let destination_path = destination.join(entry.file_name());
|
||||||
|
let metadata = fs::symlink_metadata(&source_path)
|
||||||
|
.map_err(|error| StoreError::io(&source_path, error))?;
|
||||||
|
if metadata.file_type().is_symlink() {
|
||||||
|
return Err(StoreError::at(
|
||||||
|
StoreErrorKind::CorruptStore,
|
||||||
|
&source_path,
|
||||||
|
"symlink found while copying cached Analysis",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if metadata.is_dir() {
|
||||||
|
copy_private_tree(&source_path, &destination_path)?;
|
||||||
|
} else if metadata.is_file() {
|
||||||
|
copy_private_file(&source_path, &destination_path)?;
|
||||||
|
} else {
|
||||||
|
return Err(StoreError::at(
|
||||||
|
StoreErrorKind::CorruptStore,
|
||||||
|
&source_path,
|
||||||
|
"special file found while copying cached Analysis",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sync_directory(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_private_file(source: &Path, destination: &Path) -> Result<(), StoreError> {
|
||||||
|
let source_fd = rustix::fs::open(
|
||||||
|
source,
|
||||||
|
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
)
|
||||||
|
.map_err(|error| StoreError::io(source, io::Error::from(error)))?;
|
||||||
|
let mut source_file = File::from(source_fd);
|
||||||
|
if !source_file
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| StoreError::io(source, error))?
|
||||||
|
.is_file()
|
||||||
|
{
|
||||||
|
return Err(StoreError::at(
|
||||||
|
StoreErrorKind::CorruptStore,
|
||||||
|
source,
|
||||||
|
"cached Analysis entry changed type while copying",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut destination_file = open_new_private_file(destination)?;
|
||||||
|
io::copy(&mut source_file, &mut destination_file)
|
||||||
|
.map_err(|error| StoreError::io(destination, error))?;
|
||||||
|
destination_file
|
||||||
|
.sync_all()
|
||||||
|
.map_err(|error| StoreError::io(destination, error))
|
||||||
|
}
|
||||||
|
|
||||||
fn reject_symlink_ancestors(path: &Path) -> Result<(), StoreError> {
|
fn reject_symlink_ancestors(path: &Path) -> Result<(), StoreError> {
|
||||||
for ancestor in path.ancestors().collect::<Vec<_>>().into_iter().rev() {
|
for ancestor in path.ancestors().collect::<Vec<_>>().into_iter().rev() {
|
||||||
match fs::symlink_metadata(ancestor) {
|
match fs::symlink_metadata(ancestor) {
|
||||||
|
|
@ -280,7 +352,7 @@ mod tests {
|
||||||
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
use super::AnalysisStore;
|
use super::{AnalysisStore, copy_private_tree};
|
||||||
use crate::store::{StoreEnvironment, resolve_store};
|
use crate::store::{StoreEnvironment, resolve_store};
|
||||||
|
|
||||||
fn store(temp: &TempDir) -> AnalysisStore {
|
fn store(temp: &TempDir) -> AnalysisStore {
|
||||||
|
|
@ -329,4 +401,23 @@ mod tests {
|
||||||
symlink(&outside, store.root().join("artifacts/escape")).expect("symlink");
|
symlink(&outside, store.root().join("artifacts/escape")).expect("symlink");
|
||||||
assert!(store.usage().is_err());
|
assert!(store.usage().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_tree_copy_is_exact_and_rejects_symlinks() {
|
||||||
|
let temp = TempDir::new().expect("temp");
|
||||||
|
let source = temp.path().join("source");
|
||||||
|
let destination = temp.path().join("destination");
|
||||||
|
fs::create_dir_all(source.join("nested")).expect("source");
|
||||||
|
fs::write(source.join("nested/data"), b"exact bytes").expect("data");
|
||||||
|
copy_private_tree(&source, &destination).expect("copy");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(destination.join("nested/data")).expect("copied data"),
|
||||||
|
b"exact bytes"
|
||||||
|
);
|
||||||
|
|
||||||
|
let outside = temp.path().join("outside");
|
||||||
|
fs::write(&outside, b"outside").expect("outside");
|
||||||
|
symlink(&outside, source.join("escape")).expect("symlink");
|
||||||
|
assert!(copy_private_tree(&source, &temp.path().join("rejected")).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,14 @@ use crate::domain::Digest;
|
||||||
|
|
||||||
use super::{AnalysisStore, StoreError, StoreErrorKind, filesystem::create_private_directory};
|
use super::{AnalysisStore, StoreError, StoreErrorKind, filesystem::create_private_directory};
|
||||||
|
|
||||||
/// Held nonblocking exclusive per-Analysis OS lock.
|
/// Held nonblocking shared or exclusive per-Analysis OS lock.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AnalysisLock {
|
pub struct AnalysisLock {
|
||||||
_file: File,
|
_file: File,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
sample: Digest,
|
sample: Digest,
|
||||||
profile: Digest,
|
profile: Digest,
|
||||||
|
exclusive: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnalysisLock {
|
impl AnalysisLock {
|
||||||
|
|
@ -24,6 +25,37 @@ impl AnalysisLock {
|
||||||
store: &AnalysisStore,
|
store: &AnalysisStore,
|
||||||
sample: &Digest,
|
sample: &Digest,
|
||||||
profile: &Digest,
|
profile: &Digest,
|
||||||
|
) -> Result<Self, StoreError> {
|
||||||
|
Self::acquire_with_operation(
|
||||||
|
store,
|
||||||
|
sample,
|
||||||
|
profile,
|
||||||
|
FlockOperation::NonBlockingLockExclusive,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immediately acquires a shared immutable-Query lock.
|
||||||
|
pub fn acquire_shared(
|
||||||
|
store: &AnalysisStore,
|
||||||
|
sample: &Digest,
|
||||||
|
profile: &Digest,
|
||||||
|
) -> Result<Self, StoreError> {
|
||||||
|
Self::acquire_with_operation(
|
||||||
|
store,
|
||||||
|
sample,
|
||||||
|
profile,
|
||||||
|
FlockOperation::NonBlockingLockShared,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acquire_with_operation(
|
||||||
|
store: &AnalysisStore,
|
||||||
|
sample: &Digest,
|
||||||
|
profile: &Digest,
|
||||||
|
operation: FlockOperation,
|
||||||
|
exclusive: bool,
|
||||||
) -> Result<Self, StoreError> {
|
) -> Result<Self, StoreError> {
|
||||||
let directory = store.path("locks").join(sample.as_str());
|
let directory = store.path("locks").join(sample.as_str());
|
||||||
create_private_directory(&directory)?;
|
create_private_directory(&directory)?;
|
||||||
|
|
@ -41,12 +73,13 @@ impl AnalysisLock {
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let file = File::from(descriptor);
|
let file = File::from(descriptor);
|
||||||
match rustix::fs::flock(&file, FlockOperation::NonBlockingLockExclusive) {
|
match rustix::fs::flock(&file, operation) {
|
||||||
Ok(()) => Ok(Self {
|
Ok(()) => Ok(Self {
|
||||||
_file: file,
|
_file: file,
|
||||||
path,
|
path,
|
||||||
sample: sample.clone(),
|
sample: sample.clone(),
|
||||||
profile: profile.clone(),
|
profile: profile.clone(),
|
||||||
|
exclusive,
|
||||||
}),
|
}),
|
||||||
Err(error) if error == rustix::io::Errno::WOULDBLOCK => Err(StoreError::at(
|
Err(error) if error == rustix::io::Errno::WOULDBLOCK => Err(StoreError::at(
|
||||||
StoreErrorKind::AnalysisBusy,
|
StoreErrorKind::AnalysisBusy,
|
||||||
|
|
@ -78,6 +111,12 @@ impl AnalysisLock {
|
||||||
pub fn guards(&self, sample: &Digest, profile: &Digest) -> bool {
|
pub fn guards(&self, sample: &Digest, profile: &Digest) -> bool {
|
||||||
&self.sample == sample && &self.profile == profile
|
&self.sample == sample && &self.profile == profile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns whether the token permits promotion, quarantine, or cleanup.
|
||||||
|
#[must_use]
|
||||||
|
pub fn guards_exclusive(&self, sample: &Digest, profile: &Digest) -> bool {
|
||||||
|
self.exclusive && self.guards(sample, profile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -109,4 +148,25 @@ mod tests {
|
||||||
drop(first);
|
drop(first);
|
||||||
assert!(AnalysisLock::acquire(&store, &sample, &profile).is_ok());
|
assert!(AnalysisLock::acquire(&store, &sample, &profile).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn immutable_queries_share_locks_but_exclude_cleanup() {
|
||||||
|
let temp = TempDir::new().expect("temp");
|
||||||
|
let environment = StoreEnvironment {
|
||||||
|
ghidr_store: Some(OsString::from(temp.path().join("store"))),
|
||||||
|
..StoreEnvironment::default()
|
||||||
|
};
|
||||||
|
let store = AnalysisStore::initialize(resolve_store(None, &environment).expect("resolve"))
|
||||||
|
.expect("store");
|
||||||
|
let sample = Digest::from_str(&"a".repeat(64)).expect("digest");
|
||||||
|
let profile = Digest::from_str(&"b".repeat(64)).expect("digest");
|
||||||
|
let first =
|
||||||
|
AnalysisLock::acquire_shared(&store, &sample, &profile).expect("first shared lock");
|
||||||
|
let second =
|
||||||
|
AnalysisLock::acquire_shared(&store, &sample, &profile).expect("second shared lock");
|
||||||
|
assert!(AnalysisLock::acquire(&store, &sample, &profile).is_err());
|
||||||
|
drop(first);
|
||||||
|
drop(second);
|
||||||
|
assert!(AnalysisLock::acquire(&store, &sample, &profile).is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ pub use cleanup::{CleanupPlan, CleanupScope, CleanupTransaction, plan_cleanup};
|
||||||
pub use cleanup_command::execute_cleanup;
|
pub use cleanup_command::execute_cleanup;
|
||||||
pub use error::{StoreError, StoreErrorKind};
|
pub use error::{StoreError, StoreErrorKind};
|
||||||
pub use filesystem::{AnalysisStore, STORE_LAYOUT_VERSION, StoreUsage};
|
pub use filesystem::{AnalysisStore, STORE_LAYOUT_VERSION, StoreUsage};
|
||||||
|
pub(crate) use filesystem::{copy_private_tree, create_private_directory};
|
||||||
pub use lock::AnalysisLock;
|
pub use lock::AnalysisLock;
|
||||||
pub use resolution::{ResolvedStore, StoreEnvironment, resolve_store};
|
pub use resolution::{ResolvedStore, StoreEnvironment, resolve_store};
|
||||||
pub use sample::{DEFAULT_STORE_RESERVE_BYTES, SampleSnapshot, SnapshotOptions, snapshot_sample};
|
pub use sample::{DEFAULT_STORE_RESERVE_BYTES, SampleSnapshot, SnapshotOptions, snapshot_sample};
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,12 @@ impl SampleSnapshot {
|
||||||
&self.path
|
&self.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Private staging root used to assemble a complete Analysis.
|
||||||
|
#[must_use]
|
||||||
|
pub fn staging_directory(&self) -> &Path {
|
||||||
|
&self.staging_directory
|
||||||
|
}
|
||||||
|
|
||||||
/// Exact caller-supplied path retained only as provenance.
|
/// Exact caller-supplied path retained only as provenance.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn source_path(&self) -> &TaggedPath {
|
pub const fn source_path(&self) -> &TaggedPath {
|
||||||
|
|
|
||||||
48
tests/adapter_contract.rs
Normal file
48
tests/adapter_contract.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
#![allow(clippy::expect_used)]
|
||||||
|
#![doc = "Java dispatch, protocol-bound, and capability-surface regressions."]
|
||||||
|
|
||||||
|
use std::{collections::BTreeSet, fs, path::PathBuf};
|
||||||
|
|
||||||
|
use ghidra_cli::operation::OPERATIONS;
|
||||||
|
|
||||||
|
fn root(relative: &str) -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn java_dispatch_matches_only_worker_owned_registry_operations() {
|
||||||
|
let dispatch = fs::read_to_string(root("java/dispatch.txt")).expect("read Java dispatch");
|
||||||
|
let java: BTreeSet<&str> = dispatch
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||||
|
.collect();
|
||||||
|
let rust_worker: BTreeSet<&str> = OPERATIONS
|
||||||
|
.iter()
|
||||||
|
.map(|descriptor| descriptor.operation.as_str())
|
||||||
|
.filter(|operation| *operation != "clean")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(java, rust_worker);
|
||||||
|
assert!(!java.contains("clean"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn java_source_has_protocol_bounds_and_no_egress_or_mutation_surface() {
|
||||||
|
let source = fs::read_to_string(root("java/GhidrAdapter.java")).expect("read adapter");
|
||||||
|
assert!(source.contains("MAX_REQUEST_BYTES = 1_048_576"));
|
||||||
|
assert!(source.contains("MAX_RESPONSE_BYTES = 268_435_456L"));
|
||||||
|
for forbidden in [
|
||||||
|
"ServerSocket",
|
||||||
|
"HttpServer",
|
||||||
|
"java.net.",
|
||||||
|
"setName(",
|
||||||
|
"startTransaction(",
|
||||||
|
"runScript(",
|
||||||
|
"executeScript(",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!source.contains(forbidden),
|
||||||
|
"forbidden Java surface: {forbidden}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
tests/bubblewrap_probe.rs
Normal file
120
tests/bubblewrap_probe.rs
Normal 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())
|
||||||
|
})
|
||||||
|
}
|
||||||
281
tests/cli.rs
281
tests/cli.rs
|
|
@ -1,20 +1,237 @@
|
||||||
#![allow(clippy::expect_used)]
|
#![allow(clippy::expect_used)]
|
||||||
#![doc = "Black-box CLI framing and exit-status tests."]
|
#![doc = "Black-box CLI framing and exit-status tests."]
|
||||||
|
|
||||||
use std::process::Command;
|
use std::{
|
||||||
|
fs,
|
||||||
|
os::unix::fs::PermissionsExt as _,
|
||||||
|
process::{Command, Stdio},
|
||||||
|
thread,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use rustix::process::{Pid, Signal, kill_process};
|
||||||
#[test]
|
#[test]
|
||||||
fn unimplemented_operation_is_a_typed_json_runtime_failure() {
|
fn doctor_reports_ready_with_closed_runtime_paths() {
|
||||||
|
let temp = tempfile::tempdir().expect("temp");
|
||||||
|
let runtime = temp.path().join("runtime");
|
||||||
|
let java_home = runtime.join("jdk");
|
||||||
|
let adapter = runtime.join("adapter");
|
||||||
|
fs::create_dir_all(java_home.join("bin")).expect("java directory");
|
||||||
|
fs::create_dir_all(&adapter).expect("adapter directory");
|
||||||
|
for path in [runtime.join("bwrap"), adapter.join("GhidrAdapter.java")] {
|
||||||
|
fs::write(path, b"fixture").expect("runtime fixture");
|
||||||
|
}
|
||||||
|
let java = java_home.join("bin/java");
|
||||||
|
fs::write(&java, b"#!/bin/sh\necho 'openjdk version \"21\"' >&2\n").expect("java fixture");
|
||||||
|
fs::set_permissions(&java, fs::Permissions::from_mode(0o755)).expect("java mode");
|
||||||
let output = Command::new(env!("CARGO_BIN_EXE_ghidr"))
|
let output = Command::new(env!("CARGO_BIN_EXE_ghidr"))
|
||||||
.arg("doctor")
|
.args([
|
||||||
|
"--store",
|
||||||
|
temp.path().join("store").to_str().expect("UTF-8"),
|
||||||
|
"--sandbox",
|
||||||
|
"off",
|
||||||
|
"doctor",
|
||||||
|
])
|
||||||
|
.env("GHIDR_GHIDRA_VERSION", "12.1.2")
|
||||||
|
.env(
|
||||||
|
"GHIDR_ANALYZE_HEADLESS",
|
||||||
|
env!("CARGO_BIN_EXE_ghidr-fake-child"),
|
||||||
|
)
|
||||||
|
.env("GHIDR_JAVA_HOME", java_home)
|
||||||
|
.env("GHIDR_ADAPTER_PATH", adapter)
|
||||||
|
.env("GHIDR_BUBBLEWRAP", runtime.join("bwrap"))
|
||||||
.output()
|
.output()
|
||||||
.expect("run ghidr");
|
.expect("run ghidr");
|
||||||
assert_eq!(output.status.code(), Some(1));
|
assert_eq!(output.status.code(), Some(0), "{:?}", output.stderr);
|
||||||
assert!(output.stdout.is_empty());
|
assert!(output.stderr.is_empty());
|
||||||
|
let success: serde_json::Value =
|
||||||
|
serde_json::from_slice(&output.stdout).expect("valid JSON success");
|
||||||
|
assert_eq!(success["kind"], "doctor");
|
||||||
|
assert_eq!(success["data"]["ready"], true);
|
||||||
|
assert!(
|
||||||
|
!temp
|
||||||
|
.path()
|
||||||
|
.join("store/staging/doctor-write-probe")
|
||||||
|
.exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_dry_run_and_execution_are_real_store_transactions() {
|
||||||
|
let temp = tempfile::tempdir().expect("temp");
|
||||||
|
let store = temp.path().join("store");
|
||||||
|
let sample = "a".repeat(64);
|
||||||
|
let profile = "b".repeat(64);
|
||||||
|
let artifact = store
|
||||||
|
.join("artifacts")
|
||||||
|
.join(&sample)
|
||||||
|
.join(&profile)
|
||||||
|
.join(format!("{}.json", "c".repeat(64)));
|
||||||
|
fs::create_dir_all(artifact.parent().expect("parent")).expect("artifact directory");
|
||||||
|
fs::write(&artifact, b"{}\n").expect("artifact");
|
||||||
|
|
||||||
|
let invoke = |dry_run: bool| {
|
||||||
|
let mut command = Command::new(env!("CARGO_BIN_EXE_ghidr"));
|
||||||
|
command.args([
|
||||||
|
"--store",
|
||||||
|
store.to_str().expect("UTF-8"),
|
||||||
|
"clean",
|
||||||
|
"--digest",
|
||||||
|
&sample,
|
||||||
|
]);
|
||||||
|
if dry_run {
|
||||||
|
command.arg("--dry-run");
|
||||||
|
}
|
||||||
|
command.output().expect("run clean")
|
||||||
|
};
|
||||||
|
let dry_run = invoke(true);
|
||||||
|
assert_eq!(dry_run.status.code(), Some(0), "{:?}", dry_run.stderr);
|
||||||
|
let report: serde_json::Value = serde_json::from_slice(&dry_run.stdout).expect("dry-run JSON");
|
||||||
|
assert_eq!(report["data"]["mode"], "dry_run");
|
||||||
|
assert_eq!(report["data"]["matched"]["artifacts"], 1);
|
||||||
|
assert!(artifact.exists());
|
||||||
|
|
||||||
|
let executed = invoke(false);
|
||||||
|
assert_eq!(executed.status.code(), Some(0), "{:?}", executed.stderr);
|
||||||
|
let report: serde_json::Value =
|
||||||
|
serde_json::from_slice(&executed.stdout).expect("execution JSON");
|
||||||
|
assert_eq!(report["data"]["mode"], "executed");
|
||||||
|
assert_eq!(report["data"]["removed"]["artifacts"], 1);
|
||||||
|
assert!(!artifact.exists());
|
||||||
|
|
||||||
|
let confirmation = Command::new(env!("CARGO_BIN_EXE_ghidr"))
|
||||||
|
.args(["--store", store.to_str().expect("UTF-8"), "clean", "--all"])
|
||||||
|
.output()
|
||||||
|
.expect("run clean all");
|
||||||
|
assert_eq!(confirmation.status.code(), Some(1));
|
||||||
|
assert!(confirmation.stdout.is_empty());
|
||||||
let error: serde_json::Value =
|
let error: serde_json::Value =
|
||||||
serde_json::from_slice(&output.stderr).expect("valid JSON error");
|
serde_json::from_slice(&confirmation.stderr).expect("confirmation JSON");
|
||||||
assert_eq!(error["error"]["code"], "internal_not_implemented");
|
assert_eq!(error["error"]["code"], "confirmation_required");
|
||||||
assert_eq!(error["error"]["details"]["operation"], "doctor");
|
assert!(error["error"]["details"]["matched"]["usage"].is_object());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fake_worker_drives_inspect_reuse_functions_decompile_and_typed_error() {
|
||||||
|
let temp = tempfile::tempdir().expect("temp");
|
||||||
|
let store = temp.path().join("store");
|
||||||
|
let adapter = temp.path().join("adapter");
|
||||||
|
fs::create_dir(&adapter).expect("adapter");
|
||||||
|
fs::write(adapter.join("GhidrAdapter.java"), b"fixture").expect("adapter source");
|
||||||
|
let sample = temp.path().join("sample");
|
||||||
|
fs::write(&sample, b"controllable sample").expect("sample");
|
||||||
|
|
||||||
|
let invoke = |arguments: &[&str], error: Option<&str>| {
|
||||||
|
let mut command = Command::new(env!("CARGO_BIN_EXE_ghidr"));
|
||||||
|
command
|
||||||
|
.args([
|
||||||
|
"--sandbox",
|
||||||
|
"off",
|
||||||
|
"--store",
|
||||||
|
store.to_str().expect("UTF-8"),
|
||||||
|
])
|
||||||
|
.args(arguments)
|
||||||
|
.env("GHIDR_GHIDRA_VERSION", "12.1.2")
|
||||||
|
.env(
|
||||||
|
"GHIDR_ANALYZE_HEADLESS",
|
||||||
|
env!("CARGO_BIN_EXE_ghidr-fake-child"),
|
||||||
|
)
|
||||||
|
.env("GHIDR_ADAPTER_PATH", &adapter);
|
||||||
|
if let Some(code) = error {
|
||||||
|
command.env("GHIDR_FAKE_WORKER_ERROR", code);
|
||||||
|
}
|
||||||
|
command.output().expect("run Query")
|
||||||
|
};
|
||||||
|
|
||||||
|
let inspected = invoke(&["inspect", sample.to_str().expect("UTF-8")], None);
|
||||||
|
assert_eq!(inspected.status.code(), Some(0), "{:?}", inspected.stderr);
|
||||||
|
let inspection: serde_json::Value =
|
||||||
|
serde_json::from_slice(&inspected.stdout).expect("inspection");
|
||||||
|
assert_eq!(inspection["data"]["analysis"]["disposition"], "created");
|
||||||
|
assert_eq!(inspection["provenance"]["sandbox"]["backend"], "off");
|
||||||
|
let profile = inspection["provenance"]["analysis_profile_sha256"].clone();
|
||||||
|
|
||||||
|
let functions = invoke(
|
||||||
|
&["functions", sample.to_str().expect("UTF-8"), "--limit", "1"],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(functions.status.code(), Some(0), "{:?}", functions.stderr);
|
||||||
|
let functions: serde_json::Value =
|
||||||
|
serde_json::from_slice(&functions.stdout).expect("functions");
|
||||||
|
assert_eq!(functions["data"]["items"][0]["name"], "main");
|
||||||
|
assert_eq!(functions["provenance"]["analysis_profile_sha256"], profile);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_dir(store.join("analyses"))
|
||||||
|
.expect("analyses")
|
||||||
|
.flat_map(|entry| fs::read_dir(entry.expect("sample entry").path()).expect("profiles"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
let decompiled = invoke(
|
||||||
|
&[
|
||||||
|
"decompile",
|
||||||
|
sample.to_str().expect("UTF-8"),
|
||||||
|
"--name",
|
||||||
|
"main",
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(decompiled.status.code(), Some(0), "{:?}", decompiled.stderr);
|
||||||
|
let decompiled: serde_json::Value =
|
||||||
|
serde_json::from_slice(&decompiled.stdout).expect("decompilation");
|
||||||
|
assert_eq!(decompiled["data"]["requested_selector"]["value"], "main");
|
||||||
|
assert!(
|
||||||
|
decompiled["data"]["decompilation"]["text"]
|
||||||
|
.as_str()
|
||||||
|
.expect("text")
|
||||||
|
.contains("return 0")
|
||||||
|
);
|
||||||
|
|
||||||
|
let spilled = invoke(
|
||||||
|
&[
|
||||||
|
"decompile",
|
||||||
|
sample.to_str().expect("UTF-8"),
|
||||||
|
"--name",
|
||||||
|
"large",
|
||||||
|
"--max-inline-bytes",
|
||||||
|
"4096",
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(spilled.status.code(), Some(0), "{:?}", spilled.stderr);
|
||||||
|
let spilled: serde_json::Value =
|
||||||
|
serde_json::from_slice(&spilled.stdout).expect("spill descriptor");
|
||||||
|
assert_eq!(spilled["data"]["spilled"], true);
|
||||||
|
let artifact = spilled["data"]["artifact"]["path"]["value"]
|
||||||
|
.as_str()
|
||||||
|
.expect("Artifact path");
|
||||||
|
let complete = fs::read(artifact).expect("complete Artifact");
|
||||||
|
assert_eq!(complete.last(), Some(&b'\n'));
|
||||||
|
let complete: serde_json::Value =
|
||||||
|
serde_json::from_slice(&complete).expect("complete success response");
|
||||||
|
assert_eq!(
|
||||||
|
complete["data"]["decompilation"]["text"]
|
||||||
|
.as_str()
|
||||||
|
.expect("large text")
|
||||||
|
.len(),
|
||||||
|
100_000
|
||||||
|
);
|
||||||
|
|
||||||
|
let other = temp.path().join("other");
|
||||||
|
fs::copy("tests/fixtures/fake-worker-error.sample", &other).expect("other");
|
||||||
|
let failed = invoke(
|
||||||
|
&["inspect", other.to_str().expect("UTF-8")],
|
||||||
|
Some("analysis_failed"),
|
||||||
|
);
|
||||||
|
assert_eq!(failed.status.code(), Some(1));
|
||||||
|
assert!(failed.stdout.is_empty());
|
||||||
|
let error: serde_json::Value = serde_json::from_slice(&failed.stderr).expect("error JSON");
|
||||||
|
assert_eq!(error["error"]["code"], "analysis_failed");
|
||||||
|
let diagnostic = error["error"]["details"]["diagnostic_log"]["path"]["value"]
|
||||||
|
.as_str()
|
||||||
|
.expect("diagnostic path");
|
||||||
|
assert!(std::path::Path::new(diagnostic).is_file());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -41,3 +258,51 @@ fn explicit_human_parse_failure_is_not_json() {
|
||||||
assert!(output.stdout.is_empty());
|
assert!(output.stdout.is_empty());
|
||||||
assert!(serde_json::from_slice::<serde_json::Value>(&output.stderr).is_err());
|
assert!(serde_json::from_slice::<serde_json::Value>(&output.stderr).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sigint_interrupts_the_actual_cli_and_never_promotes_analysis() {
|
||||||
|
let temp = tempfile::tempdir().expect("temp");
|
||||||
|
let store = temp.path().join("store");
|
||||||
|
let adapter = temp.path().join("adapter");
|
||||||
|
fs::create_dir(&adapter).expect("adapter");
|
||||||
|
fs::write(adapter.join("GhidrAdapter.java"), b"fixture").expect("adapter source");
|
||||||
|
let sample = temp.path().join("sample");
|
||||||
|
fs::write(&sample, b"interruptible sample").expect("sample");
|
||||||
|
|
||||||
|
let child = Command::new(env!("CARGO_BIN_EXE_ghidr"))
|
||||||
|
.args([
|
||||||
|
"--sandbox",
|
||||||
|
"off",
|
||||||
|
"--store",
|
||||||
|
store.to_str().expect("UTF-8"),
|
||||||
|
"inspect",
|
||||||
|
sample.to_str().expect("UTF-8"),
|
||||||
|
])
|
||||||
|
.env("GHIDR_GHIDRA_VERSION", "12.1.2")
|
||||||
|
.env(
|
||||||
|
"GHIDR_ANALYZE_HEADLESS",
|
||||||
|
env!("CARGO_BIN_EXE_ghidr-fake-child"),
|
||||||
|
)
|
||||||
|
.env("GHIDR_ADAPTER_PATH", &adapter)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn ghidr");
|
||||||
|
thread::sleep(Duration::from_millis(200));
|
||||||
|
let raw_pid = i32::try_from(child.id()).expect("PID fits i32");
|
||||||
|
let pid = Pid::from_raw(raw_pid).expect("positive child PID");
|
||||||
|
kill_process(pid, Signal::INT).expect("send SIGINT");
|
||||||
|
let output = child.wait_with_output().expect("wait for interrupted CLI");
|
||||||
|
|
||||||
|
assert_eq!(output.status.code(), Some(130), "{:?}", output.stderr);
|
||||||
|
assert!(output.stdout.is_empty());
|
||||||
|
let error: serde_json::Value =
|
||||||
|
serde_json::from_slice(&output.stderr).expect("interrupted JSON");
|
||||||
|
assert_eq!(error["error"]["code"], "interrupted");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_dir(store.join("analyses"))
|
||||||
|
.expect("analyses")
|
||||||
|
.count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
19
tests/data/adapter-doctor-request.json
Normal file
19
tests/data/adapter-doctor-request.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"protocol_version": 1,
|
||||||
|
"invocation_id": "fedcba9876543210fedcba9876543210",
|
||||||
|
"operation": "doctor",
|
||||||
|
"staged_sample": null,
|
||||||
|
"analysis_path": null,
|
||||||
|
"limits": {
|
||||||
|
"max_heap_mib": 2048,
|
||||||
|
"max_cpu": 2,
|
||||||
|
"analysis_timeout_seconds": 60,
|
||||||
|
"decompile_timeout_seconds": null,
|
||||||
|
"child_watchdog_seconds": 120,
|
||||||
|
"max_sample_bytes": 1073741824,
|
||||||
|
"max_inline_bytes": 65536
|
||||||
|
},
|
||||||
|
"arguments": {
|
||||||
|
"kind": "doctor"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tests/data/adapter-inspect-request.json
Normal file
19
tests/data/adapter-inspect-request.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"protocol_version": 1,
|
||||||
|
"invocation_id": "0123456789abcdef0123456789abcdef",
|
||||||
|
"operation": "inspect",
|
||||||
|
"staged_sample": "@SAMPLE@",
|
||||||
|
"analysis_path": "@ANALYSIS@",
|
||||||
|
"limits": {
|
||||||
|
"max_heap_mib": 2048,
|
||||||
|
"max_cpu": 2,
|
||||||
|
"analysis_timeout_seconds": 60,
|
||||||
|
"decompile_timeout_seconds": null,
|
||||||
|
"child_watchdog_seconds": 180,
|
||||||
|
"max_sample_bytes": 1073741824,
|
||||||
|
"max_inline_bytes": 65536
|
||||||
|
},
|
||||||
|
"arguments": {
|
||||||
|
"kind": "inspect"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
tests/fixtures/fake-worker-error.sample
vendored
Normal file
1
tests/fixtures/fake-worker-error.sample
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
other sample
|
||||||
51
tests/real_ghidra_adapter.sh
Normal file
51
tests/real_ghidra_adapter.sh
Normal 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
271
tests/support/fake_child.rs
Normal 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
187
tests/worker_lifecycle.rs
Normal 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue