diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..1e2a6fb --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,14 @@ +name: checks + +on: + push: + pull_request: + +jobs: + flake: + runs-on: x86_64-linux + steps: + - uses: actions/checkout@v4 + - run: nix flake check --print-build-logs + - run: nix build .#packages.x86_64-linux.default --print-build-logs + - run: nix run . -- doctor diff --git a/.gitignore b/.gitignore index babfde6..5cedfba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ -/target/ -*.log +/result +/result-* +/target *.gpr *.rep/ -.direnv/ -.envrc - diff --git a/Cargo.lock b/Cargo.lock index e5de3cf..53ccecb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,6 +244,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "signal-hook", "tempfile", "thiserror", ] @@ -482,6 +483,26 @@ dependencies = [ "digest", ] +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index a34eae3..6fc0a79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,10 @@ description = "Reproducible, read-only Ghidra analysis from the command line" license = "Apache-2.0" publish = false +[features] +# Enables timing controls used only by release-profile integration tests. +test-support = [] + [lib] name = "ghidra_cli" path = "src/lib.rs" @@ -15,21 +19,26 @@ path = "src/lib.rs" name = "ghidr" path = "src/main.rs" +[[bin]] +name = "ghidr-fake-child" +path = "tests/support/fake_child.rs" + [dependencies] base64 = "0.22.1" clap = { version = "4.5.60", features = ["derive"] } -getrandom = "0.3.3" +getrandom = "=0.3.3" hex = "0.4.3" -rustix = { version = "1.0.8", features = ["fs"] } +rustix = { version = "=1.0.8", features = ["fs", "process"] } schemars = { version = "=1.0.4", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sha2 = "0.10.9" +signal-hook = "0.3.18" thiserror = "2.0.18" [dev-dependencies] assert_cmd = "2.1.2" -tempfile = "3.21.0" +tempfile = "=3.21.0" [lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index a4092d6..78d22a4 100644 --- a/README.md +++ b/README.md @@ -3,18 +3,18 @@ A small, dependable command-line interface for read-only Ghidra analysis. The repository is named `ghidra-cli`; the installed executable is `ghidr`. -The project now has its version 0.1 Rust contract foundation. The typed CLI, -public envelopes and schemas, bounded Rust/Java protocol, and synchronous -execution seam are present; store, process/sandbox, and Ghidra adapter execution -remain deliberately unconnected. +Version 0.1 provides a synchronous Rust harness, strict Java headless adapter, +content-addressed Analysis Store, Bubblewrap worker sandbox, reproducible +native fixtures, and pinned x86-64 Linux Nix packaging. -Proposed usage: +Usage: ```console ghidr doctor ghidr inspect ./sample -ghidr functions ./sample +ghidr functions ./sample --limit 100 ghidr decompile ./sample --name main +ghidr clean ./sample --dry-run ``` Each command will identify the sample by its content, reuse compatible cached @@ -32,12 +32,63 @@ available explicitly through `--format human`. including the Ghidra MCP comparison that informed protocol safeguards and the post-version-0.1 roadmap. -## Status +## Pinned package -Foundation only. Every accepted command currently returns a typed -`internal_not_implemented` runtime error instead of pretending Ghidra analysis -succeeded. Subsequent layers implement the library's synchronous executor -contract while preserving the committed schemas and reviewed fixtures. +The flake fixes Ghidra at exactly 12.1.2 and uses JDK 21. It also closes over +Rust, Bubblewrap, the Java adapter, deterministic Java/Nix formatters, +`cargo-deny`, and `statix`: + +```console +nix run . -- doctor +nix develop +``` + +The native fixtures are reproducible Nix outputs rather than committed opaque +binaries: + +```console +nix build .#fixtures +file result/elf-x86_64/known.elf result/pe32plus-x86_64/known.exe +``` + +## Adapter boundary + +The Ghidra worker dispatches `doctor`, `inspect`, `functions`, and `decompile`. +`inspect` is the import/analyze vertical slice: the trusted harness stages the +Sample and invokes official `analyzeHeadless`; the post-script reads only the +current imported Program and private request/response paths. `clean` is in the +five-operation Rust registry but is intentionally rejected by Java because it +is a trusted, atomic Analysis Store transaction. This boundary is enforced by +`tests/adapter_contract.rs` and documented in `java/dispatch.txt`. + +The adapter accepts at most 1 MiB of request JSON before parsing, rejects +missing/unknown fields, echoes protocol version/invocation/operation, and +publishes at most 256 MiB through a temporary file plus same-filesystem atomic +rename. It has no socket, network client, listener, script execution, mutation, +or user-selected destination surface. + +## Checks + +The complete pinned suite is: + +```console +cargo fmt --all --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets --all-features +cargo deny check +nix flake check --print-build-logs +``` + +`nix flake check` compiles Java with JDK 21 `-Xlint:all -Werror`, checks +Google-style `clang-format`, `nixfmt`, and `statix`, builds both fixtures, runs the +adapter against real Ghidra 12.1.2, and exercises `inspect`, `functions`, and +`decompile` through the packaged `ghidr` path. Individual real-engine checks +remain directly runnable as: + +```console +nix build .#checks.x86_64-linux.java-adapter-e2e --print-build-logs +nix build .#checks.x86_64-linux.real-ghidra-e2e --print-build-logs +``` ## License diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..cd89eb4 --- /dev/null +++ b/deny.toml @@ -0,0 +1,26 @@ +[advisories] +version = 2 +yanked = "deny" + +[bans] +multiple-versions = "warn" +wildcards = "deny" +highlight = "all" + +[licenses] +version = 2 +confidence-threshold = 0.93 +allow = [ + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MIT", + "Unicode-3.0", + "Zlib", +] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..a793af9 --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,14 @@ +# Reproducible native fixtures + +`fixtures/src/known.c` is compiled by Nix into two tiny, dependency-free test +programs from the same reviewed source: + +- `elf-x86_64/known.elf`: static ELF, x86-64 little-endian; +- `pe32plus-x86_64/known.exe`: PE32+, x86-64. + +Both builds disable timestamps and build IDs, set `SOURCE_DATE_EPOCH=1`, avoid +host libraries, and are stripped only of non-semantic comments/notes. Build +them with `nix build .#fixtures`. Version 0.1 intentionally does not claim an +unverified third target until that target is exercised by the real-Ghidra +suite; recognizing a file format without a maintained cross-toolchain test is +not sufficient evidence. diff --git a/fixtures/golden/v1/doctor.json b/fixtures/golden/v1/doctor.json index 5e9fe80..c667958 100644 --- a/fixtures/golden/v1/doctor.json +++ b/fixtures/golden/v1/doctor.json @@ -13,8 +13,33 @@ "data": { "ready": true, "components": { - "ghidra": { "status": "ready", "details": { "version": "12.1.2" } }, - "java": { "status": "ready", "details": { "version": "21" } } + "ghidra": { + "status": "ready", + "version": "12.1.2", + "launcher": { + "encoding": "utf8", + "value": "/nix/store/ghidra/bin/ghidra-analyzeHeadless" + } + }, + "java": { + "status": "ready", + "version": "21", + "executable": { + "encoding": "utf8", + "value": "/nix/store/jdk/bin/java" + } + }, + "analysis_store": { + "status": "ready", + "path": { "encoding": "utf8", "value": "/tmp/ghidr-store" }, + "source": "cli", + "usage": { "logical_bytes": 1048576, "allocated_bytes": 1114112 } + }, + "sandbox": { + "status": "ready", + "backend": "bubblewrap", + "verification": "verified" + } } }, "warnings": [] diff --git a/fixtures/src/known.c b/fixtures/src/known.c new file mode 100644 index 0000000..f8c3434 --- /dev/null +++ b/fixtures/src/known.c @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: CC0-1.0 + +volatile unsigned long fixture_marker = 7; + +__attribute__((noinline)) unsigned long fixture_add(unsigned long value) { + return value + fixture_marker; +} + +void _start(void) { + (void)fixture_add(35); + for (;;) { + __asm__ volatile("" ::: "memory"); + } +} + +void mainCRTStartup(void) { + _start(); +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..bbe337e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1752480373, + "narHash": "sha256-JHQbm+OcGp32wAsXTE/FLYGNpb+4GLi5oTvCxwSoBOA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "62e0f05ede1da0d54515d4ea8ce9c733f12d9f08", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "62e0f05ede1da0d54515d4ea8ce9c733f12d9f08", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..88ae7c7 --- /dev/null +++ b/flake.nix @@ -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"; + }; + }; +} diff --git a/java/GhidrAdapter.java b/java/GhidrAdapter.java new file mode 100644 index 0000000..62395f2 --- /dev/null +++ b/java/GhidrAdapter.java @@ -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 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 details = object(); + details.put("limit_bytes", MAX_RESPONSE_BYTES); + details.put("observed_at_least_bytes", error.observedAtLeast); + details.put("operation", request.operation); + writeResponse(temporaryPath, responsePath, + response(request, "error", null, + new AdapterError("result_too_large", "adapter result exceeds 256 MiB", details))); + } catch (AdapterException error) { + Files.deleteIfExists(temporaryPath); + if (request == null) { + throw error; + } + writeResponse(temporaryPath, responsePath, + response(request, "error", null, + new AdapterError(error.code, error.getMessage(), error.details))); + } + } + + private Object dispatch(Request request) throws Exception { + return switch (request.operation) { + case "doctor" -> doctor(request.arguments); + case "inspect" -> inspect(request.arguments); + case "functions" -> functions(request.arguments); + case "decompile" -> decompile(request.arguments, request.decompileTimeoutSeconds); + default -> + throw new AdapterException("protocol_violation", "operation is not dispatched by Java"); + }; + } + + private Object doctor(Map arguments) throws AdapterException { + expectKeys(arguments, Set.of("kind"), "doctor arguments"); + requireKind(arguments, "doctor"); + Map result = object(); + result.put("ready", true); + result.put("ghidra_version", Application.getApplicationVersion()); + result.put("java_version", Runtime.version().feature()); + result.put("adapter_protocol_version", PROTOCOL_VERSION); + return result; + } + + private Object inspect(Map arguments) throws AdapterException { + expectProgram(); + expectKeys(arguments, Set.of("kind"), "inspect arguments"); + requireKind(arguments, "inspect"); + Map program = object(); + program.put("image_base", address(currentProgram.getImageBase())); + program.put("minimum_address", address(currentProgram.getMinAddress())); + program.put("maximum_address", address(currentProgram.getMaxAddress())); + program.put("function_count", allFunctions().size()); + return queryResult(program); + } + + private Object functions(Map arguments) throws AdapterException { + expectProgram(); + expectKeys(arguments, Set.of("kind", "page"), "functions arguments"); + requireKind(arguments, "functions"); + Map pageRequest = asObject(arguments.get("page"), "page"); + expectKeys(pageRequest, Set.of("offset", "limit"), "page"); + long offset = nonNegativeLong(pageRequest.get("offset"), "offset"); + Object limitValue = pageRequest.get("limit"); + Long limit = parseLimit(limitValue); + + List all = allFunctions(); + long total = all.size(); + int start = (int) Math.min(offset, total); + long requestedEnd = limit == null ? total : Math.min(total, saturatingAdd(offset, limit)); + int end = (int) Math.max(start, requestedEnd); + List items = new ArrayList<>(); + for (Function function : all.subList(start, end)) { + items.add(functionItem(function)); + } + Map page = object(); + page.put("order", "location_then_entry_ascending"); + page.put("offset", offset); + page.put("limit", limit); + page.put("returned", items.size()); + page.put("total", total); + page.put("has_more", end < total); + Map result = object(); + result.put("page", page); + result.put("items", items); + return queryResult(result); + } + + private Object decompile(Map arguments, long timeoutSeconds) throws Exception { + expectProgram(); + expectKeys(arguments, Set.of("kind", "selector"), "decompile arguments"); + requireKind(arguments, "decompile"); + Map selector = asObject(arguments.get("selector"), "selector"); + expectKeys(selector, Set.of("kind", "value"), "selector"); + String kind = string(selector.get("kind"), "selector kind"); + Function function; + if (kind.equals("name")) { + String value = string(selector.get("value"), "selector value"); + function = resolveName(value); + } else if (kind.equals("address")) { + function = resolveAddress(asObject(selector.get("value"), "selector address")); + } else { + throw new AdapterException("protocol_violation", "unknown Function Selector kind"); + } + if (function.isExternal()) { + throw new AdapterException( + "function_not_decompilable", "external Function cannot be decompiled"); + } + + String text; + DecompInterface decompiler = new DecompInterface(); + try { + if (!decompiler.openProgram(currentProgram)) { + throw new AdapterException( + "decompilation_failed", "Ghidra decompiler did not open Program"); + } + int seconds = (int) Math.min(timeoutSeconds, Integer.MAX_VALUE); + DecompileResults results = decompiler.decompileFunction(function, seconds, monitor); + if (!results.decompileCompleted()) { + String message = results.getErrorMessage(); + if (message != null && message.toLowerCase(java.util.Locale.ROOT).contains("timeout")) { + throw new AdapterException("decompilation_timeout", "Ghidra decompilation timed out"); + } + throw new AdapterException("decompilation_failed", + message == null || message.isBlank() ? "Ghidra decompilation failed" : message); + } + text = normalizeLineEndings(results.getDecompiledFunction().getC()); + } finally { + decompiler.dispose(); + } + Map selected = object(); + selected.put("name", function.getName(false)); + selected.put("qualified_name", function.getName(true)); + selected.put("entry", address(function.getEntryPoint())); + Map decompilation = object(); + decompilation.put("syntax", "ghidra_c"); + decompilation.put("text", text); + Map result = object(); + result.put("requested_selector", selector); + result.put("function", selected); + result.put("decompilation", decompilation); + return queryResult(result); + } + + private Function resolveName(String requested) throws AdapterException { + boolean qualified = requested.contains("::"); + List matches = new ArrayList<>(); + for (Function function : allFunctions()) { + String candidate = qualified ? function.getName(true) : function.getName(false); + if (candidate.equals(requested)) { + matches.add(function); + } + } + if (matches.isEmpty()) { + throw new AdapterException("function_not_found", "Function Selector did not resolve"); + } + if (matches.size() != 1) { + Map details = object(); + List candidates = new ArrayList<>(); + for (Function function : matches.subList(0, Math.min(100, matches.size()))) { + candidates.add(address(function.getEntryPoint())); + } + details.put("candidates", candidates); + details.put("candidate_count", matches.size()); + throw new AdapterException("function_selector_ambiguous", + "Function Selector resolves to more than one Function", details); + } + return matches.get(0); + } + + private Function resolveAddress(Map selector) throws AdapterException { + expectKeys(selector, Set.of("space", "offset"), "selector address"); + String spaceName = string(selector.get("space"), "address space"); + String offsetText = string(selector.get("offset"), "address offset"); + AddressFactory factory = currentProgram.getAddressFactory(); + AddressSpace selectedSpace = null; + List spaces = new ArrayList<>(); + for (AddressSpace space : factory.getAllAddressSpaces()) { + spaces.add(space.getName()); + if (space.getName().equals(spaceName)) { + selectedSpace = space; + } + } + if (selectedSpace == null) { + spaces.sort(GhidrAdapter::compareUtf8); + Map details = object(); + details.put("valid_address_spaces", spaces.subList(0, Math.min(100, spaces.size()))); + throw new AdapterException( + "address_space_not_found", "address space does not exist", details); + } + int width = Math.max(2, (selectedSpace.getSize() + 3) / 4); + if (!offsetText.matches("0x[0-9a-f]+") || offsetText.length() != width + 2) { + throw new AdapterException( + "protocol_violation", "address offset is not canonical for its space"); + } + BigInteger offset = new BigInteger(offsetText.substring(2), 16); + if (offset.bitLength() > 64) { + throw new AdapterException( + "protocol_violation", "address offset exceeds adapter representation"); + } + Address requested; + try { + requested = selectedSpace.getAddress(offset.longValue()); + } catch (RuntimeException error) { + throw new AdapterException("function_not_found", "address is outside its address space"); + } + Function exact = currentProgram.getFunctionManager().getFunctionAt(requested); + if (exact != null) { + return exact; + } + Function containing = currentProgram.getFunctionManager().getFunctionContaining(requested); + if (containing != null) { + Map details = object(); + details.put("containing_function_entry", address(containing.getEntryPoint())); + throw new AdapterException( + "function_entry_required", "address is inside a Function but is not its entry", details); + } + throw new AdapterException( + "function_not_found", "no Function exists at the requested entry Address"); + } + + private List allFunctions() { + LinkedHashSet unique = new LinkedHashSet<>(); + FunctionIterator memory = currentProgram.getFunctionManager().getFunctions(true); + while (memory.hasNext()) { + unique.add(memory.next()); + } + FunctionIterator external = currentProgram.getFunctionManager().getExternalFunctions(); + while (external.hasNext()) { + unique.add(external.next()); + } + List functions = new ArrayList<>(unique); + functions.sort(Comparator.comparingInt((Function function) -> function.isExternal() ? 1 : 0) + .thenComparing(function + -> function.getEntryPoint().getAddressSpace().getName(), + GhidrAdapter::compareUtf8) + .thenComparing( + function -> unsigned(function.getEntryPoint().getOffset()), BigInteger::compareTo)); + return functions; + } + + private Map functionItem(Function function) { + Map item = object(); + item.put("name", function.getName(false)); + item.put("qualified_name", function.getName(true)); + item.put("entry", address(function.getEntryPoint())); + item.put("body_address_count", function.getBody().getNumAddresses()); + item.put("location", function.isExternal() ? "external" : "memory"); + item.put("is_external", function.isExternal()); + item.put("is_thunk", function.isThunk()); + Function thunk = function.getThunkedFunction(false); + item.put("thunk_target_entry", thunk == null ? null : address(thunk.getEntryPoint())); + item.put("decompilable", !function.isExternal()); + return item; + } + + private Map target() { + Map target = object(); + 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 queryResult(Object query) { + Map 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 result = object(); + result.put("context", context); + result.put("query", query); + return result; + } + + private List analysisOptions() { + Map current = getCurrentAnalysisOptionsAndValues(currentProgram); + List names = new ArrayList<>(current.keySet()); + names.sort(GhidrAdapter::compareUtf8); + List result = new ArrayList<>(); + for (String name : names) { + Map value = object(); + value.put("type", "string"); + value.put("value", current.get(name)); + Map option = object(); + option.put("name", name); + option.put("value", value); + result.add(option); + } + return result; + } + + private static Map address(Address value) { + if (value == null || value == Address.NO_ADDRESS) { + return null; + } + AddressSpace space = value.getAddressSpace(); + int width = Math.max(2, (space.getSize() + 3) / 4); + String digits = Long.toUnsignedString(value.getOffset(), 16); + Map result = object(); + result.put("space", space.getName()); + result.put("offset", + "0x" + + "0".repeat(Math.max(0, width - digits.length())) + digits); + return result; + } + + private static Request parseRequest(byte[] bytes) throws AdapterException { + Object parsed = new JsonParser(decodeUtf8(bytes)).parse(); + Map root = asObject(parsed, "request"); + expectKeys(root, + Set.of("protocol_version", "invocation_id", "operation", "staged_sample", "analysis_path", + "limits", "arguments"), + "request"); + long version = nonNegativeLong(root.get("protocol_version"), "protocol_version"); + if (version != PROTOCOL_VERSION) { + throw new AdapterException("protocol_violation", "unsupported adapter protocol version"); + } + String invocation = string(root.get("invocation_id"), "invocation_id"); + if (!invocation.matches("[0-9a-f]{32}")) { + throw new AdapterException("protocol_violation", "invalid invocation ID"); + } + String operation = string(root.get("operation"), "operation"); + if (!DISPATCH.contains(operation)) { + throw new AdapterException("protocol_violation", "operation is not in Java dispatch set"); + } + 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 limits = asObject(root.get("limits"), "limits"); + expectKeys(limits, + Set.of("max_heap_mib", "max_cpu", "analysis_timeout_seconds", "decompile_timeout_seconds", + "child_watchdog_seconds", "max_sample_bytes", "max_inline_bytes"), + "limits"); + positiveLong(limits.get("max_heap_mib"), "max_heap_mib"); + positiveLong(limits.get("max_cpu"), "max_cpu"); + positiveLong(limits.get("analysis_timeout_seconds"), "analysis_timeout_seconds"); + Object decompileTimeout = limits.get("decompile_timeout_seconds"); + long timeout = + decompileTimeout == null ? 60 : positiveLong(decompileTimeout, "decompile_timeout_seconds"); + positiveLong(limits.get("child_watchdog_seconds"), "child_watchdog_seconds"); + positiveLong(limits.get("max_sample_bytes"), "max_sample_bytes"); + positiveLong(limits.get("max_inline_bytes"), "max_inline_bytes"); + Map arguments = asObject(root.get("arguments"), "arguments"); + if (!string(arguments.get("kind"), "argument kind").equals(operation)) { + throw new AdapterException("protocol_violation", "operation and argument kind differ"); + } + return new Request(invocation, operation, arguments, timeout); + } + + private static Map response( + Request request, String status, Object data, AdapterError error) { + Map root = object(); + root.put("protocol_version", PROTOCOL_VERSION); + root.put("invocation_id", request.invocationId); + root.put("operation", request.operation); + Map result = object(); + result.put("status", status); + if (error == null) { + result.put("data", data); + } else { + result.put("code", error.code); + result.put("message", error.message); + result.put("details", error.details); + } + root.put("result", result); + return root; + } + + private static byte[] readBounded(Path path) throws IOException, AdapterException { + try (InputStream input = Files.newInputStream(path); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int remaining = MAX_REQUEST_BYTES + 1; + while (remaining > 0) { + int count = input.read(buffer, 0, Math.min(buffer.length, remaining)); + if (count < 0) { + break; + } + output.write(buffer, 0, count); + remaining -= count; + } + if (output.size() > MAX_REQUEST_BYTES) { + throw new AdapterException("protocol_violation", "request exceeds 1 MiB"); + } + return output.toByteArray(); + } + } + + private static String decodeUtf8(byte[] bytes) throws AdapterException { + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException error) { + throw new AdapterException("protocol_violation", "request is not valid UTF-8"); + } + } + + private static void writeResponse(Path temporary, Path response, Object value) + throws IOException, ResponseTooLargeException { + Files.deleteIfExists(temporary); + try (OutputStream raw = Files.newOutputStream( + temporary, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + BoundedOutputStream bounded = new BoundedOutputStream(raw, MAX_RESPONSE_BYTES)) { + JsonWriter.write(value, bounded); + bounded.flush(); + } catch (ResponseTooLargeException error) { + Files.deleteIfExists(temporary); + throw error; + } + try { + Files.move(temporary, response, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException error) { + Files.deleteIfExists(temporary); + throw new IOException("response filesystem does not support atomic rename", error); + } + } + + private void expectProgram() throws AdapterException { + if (currentProgram == null) { + throw new AdapterException("analysis_failed", "operation requires an imported Program"); + } + } + + private static void requireKind(Map arguments, String expected) + throws AdapterException { + if (!string(arguments.get("kind"), "argument kind").equals(expected)) { + throw new AdapterException("protocol_violation", "unexpected argument kind"); + } + } + + private static Long parseLimit(Object value) throws AdapterException { + if (value instanceof String string && string.equals("all")) { + return null; + } + Map tagged = asObject(value, "limit"); + expectKeys(tagged, Set.of("bounded"), "bounded limit"); + return positiveLong(tagged.get("bounded"), "limit"); + } + + private static void nullableAbsolutePath(Object value, String name) throws AdapterException { + if (value == null) { + return; + } + String path = string(value, name); + try { + if (!Path.of(path).isAbsolute()) { + throw new AdapterException("protocol_violation", name + " must be absolute"); + } + } catch (java.nio.file.InvalidPathException error) { + throw new AdapterException("protocol_violation", name + " is not a valid worker path"); + } + } + + private static long positiveLong(Object value, String name) throws AdapterException { + long result = nonNegativeLong(value, name); + if (result == 0) { + throw new AdapterException("protocol_violation", name + " must be positive"); + } + return result; + } + + private static long nonNegativeLong(Object value, String name) throws AdapterException { + if (!(value instanceof Long number) || number < 0) { + throw new AdapterException("protocol_violation", name + " must be a non-negative integer"); + } + return number; + } + + @SuppressWarnings("unchecked") + private static Map asObject(Object value, String name) throws AdapterException { + if (!(value instanceof Map)) { + throw new AdapterException("protocol_violation", name + " must be an object"); + } + return (Map) value; + } + + private static String string(Object value, String name) throws AdapterException { + if (!(value instanceof String text)) { + throw new AdapterException("protocol_violation", name + " must be a string"); + } + return text; + } + + private static void expectKeys(Map object, Set expected, String name) + throws AdapterException { + if (!object.keySet().equals(expected)) { + throw new AdapterException("protocol_violation", name + " has missing or unknown fields"); + } + } + + private static long saturatingAdd(long left, long right) { + return Long.MAX_VALUE - left < right ? Long.MAX_VALUE : left + right; + } + + private static BigInteger unsigned(long value) { + return new BigInteger(Long.toUnsignedString(value)); + } + + private static int compareUtf8(String left, String right) { + byte[] a = left.getBytes(StandardCharsets.UTF_8); + byte[] b = right.getBytes(StandardCharsets.UTF_8); + int length = Math.min(a.length, b.length); + for (int index = 0; index < length; index++) { + int compared = Integer.compare(Byte.toUnsignedInt(a[index]), Byte.toUnsignedInt(b[index])); + if (compared != 0) { + return compared; + } + } + return Integer.compare(a.length, b.length); + } + + private static String normalizeLineEndings(String input) { + return input.replace("\r\n", "\n").replace('\r', '\n'); + } + + private static Map object() { + return new LinkedHashMap<>(); + } + + private record Request(String invocationId, String operation, Map arguments, + long decompileTimeoutSeconds) {} + + private record AdapterError(String code, String message, Map details) {} + + private static final class AdapterException extends Exception { + private static final long serialVersionUID = 1L; + + final String code; + final transient Map details; + + AdapterException(String code, String message) { + this(code, message, object()); + } + + AdapterException(String code, String message, Map details) { + super(message); + this.code = code; + this.details = details; + } + } + + private static final class ResponseTooLargeException extends IOException { + private static final long serialVersionUID = 1L; + + final long observedAtLeast; + + ResponseTooLargeException(long observedAtLeast) { + super("response exceeds fixed bound"); + this.observedAtLeast = observedAtLeast; + } + } + + private static final class BoundedOutputStream extends OutputStream { + private final OutputStream delegate; + private final long limit; + private long written; + + BoundedOutputStream(OutputStream delegate, long limit) { + this.delegate = delegate; + this.limit = limit; + } + + @Override + public void write(int value) throws IOException { + ensure(1); + delegate.write(value); + written++; + } + + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + ensure(length); + delegate.write(bytes, offset, length); + written += length; + } + + private void ensure(int additional) throws ResponseTooLargeException { + if (additional > limit - written) { + throw new ResponseTooLargeException(written + 1); + } + } + + @Override + public void flush() throws IOException { + delegate.flush(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } + + private static final class JsonWriter { + private JsonWriter() {} + + static void write(Object value, OutputStream output) throws IOException { + if (value == null) { + bytes(output, "null"); + } else if (value instanceof String string) { + string(output, string); + } else if (value instanceof Boolean || value instanceof Number) { + bytes(output, value.toString()); + } else if (value instanceof Map map) { + output.write('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + output.write(','); + } + first = false; + string(output, entry.getKey().toString()); + output.write(':'); + write(entry.getValue(), output); + } + output.write('}'); + } else if (value instanceof Iterable iterable) { + output.write('['); + boolean first = true; + for (Object item : iterable) { + if (!first) { + output.write(','); + } + first = false; + write(item, output); + } + output.write(']'); + } else { + throw new IOException("unsupported JSON value"); + } + } + + private static void string(OutputStream output, String value) throws IOException { + output.write('"'); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + switch (character) { + case '"' -> bytes(output, "\\\""); + case '\\' -> bytes(output, "\\\\"); + case '\b' -> bytes(output, "\\b"); + case '\f' -> bytes(output, "\\f"); + case '\n' -> bytes(output, "\\n"); + case '\r' -> bytes(output, "\\r"); + case '\t' -> bytes(output, "\\t"); + default -> { + if (character < 0x20) { + bytes(output, String.format("\\u%04x", (int) character)); + } else { + int codePoint = value.codePointAt(index); + byte[] encoded = + new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8); + output.write(encoded); + if (Character.isSupplementaryCodePoint(codePoint)) { + index++; + } + } + } + } + } + output.write('"'); + } + + private static void bytes(OutputStream output, String value) throws IOException { + output.write(value.getBytes(StandardCharsets.UTF_8)); + } + } + + private static final class JsonParser { + private final String input; + private int offset; + + JsonParser(String input) { + this.input = input; + } + + Object parse() throws AdapterException { + Object value = value(); + whitespace(); + if (offset != input.length()) { + fail(); + } + return value; + } + + private Object value() throws AdapterException { + whitespace(); + if (offset >= input.length()) { + return fail(); + } + return switch (input.charAt(offset)) { + case '{' -> objectValue(); + case '[' -> arrayValue(); + case '"' -> stringValue(); + case 't' -> literal("true", Boolean.TRUE); + case 'f' -> literal("false", Boolean.FALSE); + case 'n' -> literal("null", null); + default -> numberValue(); + }; + } + + private Map objectValue() throws AdapterException { + offset++; + Map result = object(); + whitespace(); + if (consume('}')) { + return result; + } + while (true) { + whitespace(); + if (offset >= input.length() || input.charAt(offset) != '"') { + return fail(); + } + String key = stringValue(); + if (result.containsKey(key)) { + throw new AdapterException("protocol_violation", "duplicate JSON object key"); + } + whitespace(); + if (!consume(':')) { + return fail(); + } + result.put(key, value()); + whitespace(); + if (consume('}')) { + return result; + } + if (!consume(',')) { + return fail(); + } + } + } + + private List arrayValue() throws AdapterException { + offset++; + List result = new ArrayList<>(); + whitespace(); + if (consume(']')) { + return result; + } + while (true) { + result.add(value()); + whitespace(); + if (consume(']')) { + return result; + } + if (!consume(',')) { + return fail(); + } + } + } + + private String stringValue() throws AdapterException { + offset++; + StringBuilder result = new StringBuilder(); + while (offset < input.length()) { + char character = input.charAt(offset++); + if (character == '"') { + return validString(result.toString()); + } + if (character == '\\') { + if (offset >= input.length()) { + return fail(); + } + char escaped = input.charAt(offset++); + switch (escaped) { + case '"', '\\', '/' -> result.append(escaped); + case 'b' -> result.append('\b'); + case 'f' -> result.append('\f'); + case 'n' -> result.append('\n'); + case 'r' -> result.append('\r'); + case 't' -> result.append('\t'); + case 'u' -> result.append(unicodeEscape()); + default -> throw new AdapterException("protocol_violation", "invalid JSON escape"); + } + } else if (character < 0x20) { + return fail(); + } else { + result.append(character); + } + } + return fail(); + } + + private String validString(String value) throws AdapterException { + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (Character.isHighSurrogate(character)) { + if (index + 1 >= value.length() || !Character.isLowSurrogate(value.charAt(index + 1))) { + throw new AdapterException( + "protocol_violation", "JSON string has an unpaired surrogate"); + } + index++; + } else if (Character.isLowSurrogate(character)) { + throw new AdapterException("protocol_violation", "JSON string has an unpaired surrogate"); + } + } + return value; + } + + private char unicodeEscape() throws AdapterException { + if (offset + 4 > input.length()) { + return fail(); + } + try { + char value = (char) Integer.parseInt(input.substring(offset, offset + 4), 16); + offset += 4; + return value; + } catch (NumberFormatException error) { + return fail(); + } + } + + private Object numberValue() throws AdapterException { + int start = offset; + if (offset < input.length() && input.charAt(offset) == '-') { + offset++; + } + if (offset >= input.length() || !Character.isDigit(input.charAt(offset))) { + return fail(); + } + if (input.charAt(offset) == '0') { + offset++; + } else { + while (offset < input.length() && Character.isDigit(input.charAt(offset))) { + offset++; + } + } + if (offset < input.length() && ".eE+".indexOf(input.charAt(offset)) >= 0) { + throw new AdapterException("protocol_violation", "floating-point JSON is not accepted"); + } + try { + return Long.valueOf(input.substring(start, offset)); + } catch (NumberFormatException error) { + throw new AdapterException("protocol_violation", "JSON integer is out of range"); + } + } + + private Object literal(String text, Object value) throws AdapterException { + if (!input.startsWith(text, offset)) { + return fail(); + } + offset += text.length(); + return value; + } + + private boolean consume(char expected) { + if (offset < input.length() && input.charAt(offset) == expected) { + offset++; + return true; + } + return false; + } + + private void whitespace() { + while (offset < input.length() && " \n\r\t".indexOf(input.charAt(offset)) >= 0) { + offset++; + } + } + + private T fail() throws AdapterException { + throw new AdapterException("protocol_violation", "malformed JSON request"); + } + } +} diff --git a/java/dispatch.txt b/java/dispatch.txt new file mode 100644 index 0000000..784d0f8 --- /dev/null +++ b/java/dispatch.txt @@ -0,0 +1,8 @@ +# Java accepts only operations which require a Ghidra/JVM worker. +doctor +inspect +functions +decompile + +# clean is deliberately absent: cleanup is a trusted Rust Analysis Store +# transaction and is never delegated into the capability-sandboxed worker. diff --git a/schemas/v1/cleanup.schema.json b/schemas/v1/cleanup.schema.json index c3b5d8b..91737a5 100644 --- a/schemas/v1/cleanup.schema.json +++ b/schemas/v1/cleanup.schema.json @@ -6,7 +6,7 @@ "properties": { "data": { "description": "Operation-specific result.", - "$ref": "#/$defs/CleanupData" + "$ref": "#/$defs/InlineOrArtifact" }, "kind": { "type": "string", @@ -54,6 +54,41 @@ "source" ] }, + "ArtifactDescriptor": { + "description": "Artifact descriptor returned instead of oversized inline data.", + "type": "object", + "properties": { + "bytes": { + "description": "Exact file bytes including trailing LF.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "contains": { + "description": "Always `complete_success_response`.", + "type": "string" + }, + "media_type": { + "description": "Always `application/json`.", + "type": "string" + }, + "path": { + "description": "Tagged absolute Artifact path.", + "$ref": "#/$defs/TaggedPath" + }, + "sha256": { + "description": "SHA-256 of exact file bytes.", + "$ref": "#/$defs/Digest" + } + }, + "required": [ + "path", + "bytes", + "sha256", + "media_type", + "contains" + ] + }, "CleanupData": { "description": "Cleanup result data; detailed count contracts are carried as typed snapshots.", "type": "object", @@ -215,6 +250,19 @@ "description": "A lowercase SHA-256 digest.", "type": "string" }, + "InlineOrArtifact": { + "description": "Successful command data emitted inline or represented by a complete Artifact.", + "anyOf": [ + { + "description": "Complete command-specific data.", + "$ref": "#/$defs/CleanupData" + }, + { + "description": "Bounded descriptor for the complete stored success response.", + "$ref": "#/$defs/SpilledData" + } + ] + }, "SandboxBackend": { "description": "Sandbox backend.", "oneOf": [ @@ -273,6 +321,24 @@ } ] }, + "SpilledData": { + "description": "Public data shape returned when a complete success exceeds the inline byte budget.", + "type": "object", + "properties": { + "artifact": { + "description": "Immutable complete-success Artifact.", + "$ref": "#/$defs/ArtifactDescriptor" + }, + "spilled": { + "description": "Always true for this variant.", + "type": "boolean" + } + }, + "required": [ + "spilled", + "artifact" + ] + }, "StorageUsage": { "description": "Filesystem usage represented without implying freed space.", "type": "object", diff --git a/schemas/v1/decompilation.schema.json b/schemas/v1/decompilation.schema.json index 880e037..ba2019c 100644 --- a/schemas/v1/decompilation.schema.json +++ b/schemas/v1/decompilation.schema.json @@ -6,7 +6,7 @@ "properties": { "data": { "description": "Operation-specific result.", - "$ref": "#/$defs/DecompilationData" + "$ref": "#/$defs/InlineOrArtifact" }, "kind": { "type": "string", @@ -72,6 +72,41 @@ "source" ] }, + "ArtifactDescriptor": { + "description": "Artifact descriptor returned instead of oversized inline data.", + "type": "object", + "properties": { + "bytes": { + "description": "Exact file bytes including trailing LF.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "contains": { + "description": "Always `complete_success_response`.", + "type": "string" + }, + "media_type": { + "description": "Always `application/json`.", + "type": "string" + }, + "path": { + "description": "Tagged absolute Artifact path.", + "$ref": "#/$defs/TaggedPath" + }, + "sha256": { + "description": "SHA-256 of exact file bytes.", + "$ref": "#/$defs/Digest" + } + }, + "required": [ + "path", + "bytes", + "sha256", + "media_type", + "contains" + ] + }, "DecompilationData": { "description": "Successful targeted decompilation data.", "type": "object", @@ -166,6 +201,19 @@ } ] }, + "InlineOrArtifact": { + "description": "Successful command data emitted inline or represented by a complete Artifact.", + "anyOf": [ + { + "description": "Complete command-specific data.", + "$ref": "#/$defs/DecompilationData" + }, + { + "description": "Bounded descriptor for the complete stored success response.", + "$ref": "#/$defs/SpilledData" + } + ] + }, "Limits": { "description": "Resource policy selected for one invocation.", "type": "object", @@ -367,6 +415,24 @@ "entry" ] }, + "SpilledData": { + "description": "Public data shape returned when a complete success exceeds the inline byte budget.", + "type": "object", + "properties": { + "artifact": { + "description": "Immutable complete-success Artifact.", + "$ref": "#/$defs/ArtifactDescriptor" + }, + "spilled": { + "description": "Always true for this variant.", + "type": "boolean" + } + }, + "required": [ + "spilled", + "artifact" + ] + }, "StoreSource": { "description": "Analysis Store resolution source.", "oneOf": [ diff --git a/schemas/v1/doctor.schema.json b/schemas/v1/doctor.schema.json index 11297de..c5b6bf4 100644 --- a/schemas/v1/doctor.schema.json +++ b/schemas/v1/doctor.schema.json @@ -6,7 +6,7 @@ "properties": { "data": { "description": "Operation-specific result.", - "$ref": "#/$defs/DoctorData" + "$ref": "#/$defs/InlineOrArtifact" }, "kind": { "type": "string", @@ -36,6 +36,34 @@ "warnings" ], "$defs": { + "AnalysisStoreComponent": { + "description": "Diagnosed Analysis Store.", + "type": "object", + "properties": { + "path": { + "description": "Resolved absolute store path.", + "$ref": "#/$defs/TaggedPath" + }, + "source": { + "description": "Resolution precedence source.", + "$ref": "#/$defs/StoreSource" + }, + "status": { + "description": "Stable readiness classification.", + "$ref": "#/$defs/ComponentStatus" + }, + "usage": { + "description": "Complete non-following store usage scan.", + "$ref": "#/$defs/StorageUsage" + } + }, + "required": [ + "status", + "path", + "source", + "usage" + ] + }, "AnalysisStoreProvenance": { "description": "Analysis Store provenance.", "type": "object", @@ -54,23 +82,39 @@ "source" ] }, - "ComponentReport": { - "description": "One diagnosed component.", + "ArtifactDescriptor": { + "description": "Artifact descriptor returned instead of oversized inline data.", "type": "object", "properties": { - "details": { - "description": "Component-specific facts.", - "type": "object", - "additionalProperties": true + "bytes": { + "description": "Exact file bytes including trailing LF.", + "type": "integer", + "format": "uint64", + "minimum": 0 }, - "status": { - "description": "Stable readiness classification.", - "$ref": "#/$defs/ComponentStatus" + "contains": { + "description": "Always `complete_success_response`.", + "type": "string" + }, + "media_type": { + "description": "Always `application/json`.", + "type": "string" + }, + "path": { + "description": "Tagged absolute Artifact path.", + "$ref": "#/$defs/TaggedPath" + }, + "sha256": { + "description": "SHA-256 of exact file bytes.", + "$ref": "#/$defs/Digest" } }, "required": [ - "status", - "details" + "path", + "bytes", + "sha256", + "media_type", + "contains" ] }, "ComponentStatus": { @@ -103,16 +147,45 @@ } ] }, + "Digest": { + "description": "A lowercase SHA-256 digest.", + "type": "string" + }, + "DoctorComponents": { + "description": "Fixed installation components checked safely by `doctor`.", + "type": "object", + "properties": { + "analysis_store": { + "description": "Analysis Store.", + "$ref": "#/$defs/AnalysisStoreComponent" + }, + "ghidra": { + "description": "Ghidra launcher and adapter.", + "$ref": "#/$defs/GhidraComponent" + }, + "java": { + "description": "Java runtime.", + "$ref": "#/$defs/JavaComponent" + }, + "sandbox": { + "description": "Worker isolation backend.", + "$ref": "#/$defs/SandboxComponent" + } + }, + "required": [ + "ghidra", + "java", + "analysis_store", + "sandbox" + ] + }, "DoctorData": { "description": "Installation diagnosis data.", "type": "object", "properties": { "components": { - "description": "Typed component reports keyed by stable component name.", - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/ComponentReport" - } + "description": "Complete fixed component report.", + "$ref": "#/$defs/DoctorComponents" }, "ready": { "description": "True only when every required component is ready.", @@ -124,6 +197,81 @@ "components" ] }, + "GhidraComponent": { + "description": "Diagnosed Ghidra installation.", + "type": "object", + "properties": { + "launcher": { + "description": "Configured official launcher.", + "anyOf": [ + { + "$ref": "#/$defs/TaggedPath" + }, + { + "type": "null" + } + ] + }, + "status": { + "description": "Stable readiness classification.", + "$ref": "#/$defs/ComponentStatus" + }, + "version": { + "description": "Observed version when the probe ran.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status" + ] + }, + "InlineOrArtifact": { + "description": "Successful command data emitted inline or represented by a complete Artifact.", + "anyOf": [ + { + "description": "Complete command-specific data.", + "$ref": "#/$defs/DoctorData" + }, + { + "description": "Bounded descriptor for the complete stored success response.", + "$ref": "#/$defs/SpilledData" + } + ] + }, + "JavaComponent": { + "description": "Diagnosed Java installation.", + "type": "object", + "properties": { + "executable": { + "description": "Configured Java executable.", + "anyOf": [ + { + "$ref": "#/$defs/TaggedPath" + }, + { + "type": "null" + } + ] + }, + "status": { + "description": "Stable readiness classification.", + "$ref": "#/$defs/ComponentStatus" + }, + "version": { + "description": "Observed feature version.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status" + ] + }, "SandboxBackend": { "description": "Sandbox backend.", "oneOf": [ @@ -144,6 +292,29 @@ } ] }, + "SandboxComponent": { + "description": "Diagnosed sandbox backend.", + "type": "object", + "properties": { + "backend": { + "description": "Explicit selected backend.", + "$ref": "#/$defs/SandboxBackend" + }, + "status": { + "description": "Stable readiness classification.", + "$ref": "#/$defs/ComponentStatus" + }, + "verification": { + "description": "Verification state.", + "$ref": "#/$defs/SandboxVerification" + } + }, + "required": [ + "status", + "backend", + "verification" + ] + }, "SandboxProvenance": { "description": "Worker sandbox provenance.", "type": "object", @@ -182,6 +353,46 @@ } ] }, + "SpilledData": { + "description": "Public data shape returned when a complete success exceeds the inline byte budget.", + "type": "object", + "properties": { + "artifact": { + "description": "Immutable complete-success Artifact.", + "$ref": "#/$defs/ArtifactDescriptor" + }, + "spilled": { + "description": "Always true for this variant.", + "type": "boolean" + } + }, + "required": [ + "spilled", + "artifact" + ] + }, + "StorageUsage": { + "description": "Filesystem usage represented without implying freed space.", + "type": "object", + "properties": { + "allocated_bytes": { + "description": "Sum of Linux allocated blocks including directories.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "logical_bytes": { + "description": "Sum of file lengths.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "logical_bytes", + "allocated_bytes" + ] + }, "StoreSource": { "description": "Analysis Store resolution source.", "oneOf": [ diff --git a/schemas/v1/error.schema.json b/schemas/v1/error.schema.json index 30d1f0f..b85ffe4 100644 --- a/schemas/v1/error.schema.json +++ b/schemas/v1/error.schema.json @@ -120,11 +120,6 @@ "type": "string", "const": "java_incompatible" }, - { - "description": "A later integration layer has not connected execution yet.", - "type": "string", - "const": "internal_not_implemented" - }, { "description": "Rust/Java request or response validation failed.", "type": "string", diff --git a/schemas/v1/functions.schema.json b/schemas/v1/functions.schema.json index 873f9df..376ed35 100644 --- a/schemas/v1/functions.schema.json +++ b/schemas/v1/functions.schema.json @@ -6,7 +6,7 @@ "properties": { "data": { "description": "Operation-specific result.", - "$ref": "#/$defs/FunctionsData" + "$ref": "#/$defs/InlineOrArtifact" }, "kind": { "type": "string", @@ -72,6 +72,41 @@ "source" ] }, + "ArtifactDescriptor": { + "description": "Artifact descriptor returned instead of oversized inline data.", + "type": "object", + "properties": { + "bytes": { + "description": "Exact file bytes including trailing LF.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "contains": { + "description": "Always `complete_success_response`.", + "type": "string" + }, + "media_type": { + "description": "Always `application/json`.", + "type": "string" + }, + "path": { + "description": "Tagged absolute Artifact path.", + "$ref": "#/$defs/TaggedPath" + }, + "sha256": { + "description": "SHA-256 of exact file bytes.", + "$ref": "#/$defs/Digest" + } + }, + "required": [ + "path", + "bytes", + "sha256", + "media_type", + "contains" + ] + }, "Digest": { "description": "A lowercase SHA-256 digest.", "type": "string" @@ -173,6 +208,19 @@ "items" ] }, + "InlineOrArtifact": { + "description": "Successful command data emitted inline or represented by a complete Artifact.", + "anyOf": [ + { + "description": "Complete command-specific data.", + "$ref": "#/$defs/FunctionsData" + }, + { + "description": "Bounded descriptor for the complete stored success response.", + "$ref": "#/$defs/SpilledData" + } + ] + }, "Limits": { "description": "Resource policy selected for one invocation.", "type": "object", @@ -399,6 +447,24 @@ } ] }, + "SpilledData": { + "description": "Public data shape returned when a complete success exceeds the inline byte budget.", + "type": "object", + "properties": { + "artifact": { + "description": "Immutable complete-success Artifact.", + "$ref": "#/$defs/ArtifactDescriptor" + }, + "spilled": { + "description": "Always true for this variant.", + "type": "boolean" + } + }, + "required": [ + "spilled", + "artifact" + ] + }, "StoreSource": { "description": "Analysis Store resolution source.", "oneOf": [ diff --git a/schemas/v1/inspection.schema.json b/schemas/v1/inspection.schema.json index 6c8f046..f8c00ee 100644 --- a/schemas/v1/inspection.schema.json +++ b/schemas/v1/inspection.schema.json @@ -6,7 +6,7 @@ "properties": { "data": { "description": "Operation-specific result.", - "$ref": "#/$defs/InspectionData" + "$ref": "#/$defs/InlineOrArtifact" }, "kind": { "type": "string", @@ -105,10 +105,58 @@ "disposition" ] }, + "ArtifactDescriptor": { + "description": "Artifact descriptor returned instead of oversized inline data.", + "type": "object", + "properties": { + "bytes": { + "description": "Exact file bytes including trailing LF.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "contains": { + "description": "Always `complete_success_response`.", + "type": "string" + }, + "media_type": { + "description": "Always `application/json`.", + "type": "string" + }, + "path": { + "description": "Tagged absolute Artifact path.", + "$ref": "#/$defs/TaggedPath" + }, + "sha256": { + "description": "SHA-256 of exact file bytes.", + "$ref": "#/$defs/Digest" + } + }, + "required": [ + "path", + "bytes", + "sha256", + "media_type", + "contains" + ] + }, "Digest": { "description": "A lowercase SHA-256 digest.", "type": "string" }, + "InlineOrArtifact": { + "description": "Successful command data emitted inline or represented by a complete Artifact.", + "anyOf": [ + { + "description": "Complete command-specific data.", + "$ref": "#/$defs/InspectionData" + }, + { + "description": "Bounded descriptor for the complete stored success response.", + "$ref": "#/$defs/SpilledData" + } + ] + }, "InspectionData": { "description": "Sample inspection data.", "type": "object", @@ -378,6 +426,24 @@ } ] }, + "SpilledData": { + "description": "Public data shape returned when a complete success exceeds the inline byte budget.", + "type": "object", + "properties": { + "artifact": { + "description": "Immutable complete-success Artifact.", + "$ref": "#/$defs/ArtifactDescriptor" + }, + "spilled": { + "description": "Always true for this variant.", + "type": "boolean" + } + }, + "required": [ + "spilled", + "artifact" + ] + }, "StoreSource": { "description": "Analysis Store resolution source.", "oneOf": [ diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs new file mode 100644 index 0000000..60f48f4 --- /dev/null +++ b/src/commands/doctor.rs @@ -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 { + 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 { + 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, +} + +fn probe_failure(error: impl std::fmt::Display) -> ProbeFailure { + ProbeFailure { + message: error.to_string(), + diagnostic: None, + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..580a44b --- /dev/null +++ b/src/commands/mod.rs @@ -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 { + cancellation: C, +} + +impl Default for ApplicationExecutor { + fn default() -> Self { + Self { + cancellation: NeverCancel, + } + } +} + +impl ApplicationExecutor { + /// Creates an executor wired to one frontend cancellation source. + pub const fn new(cancellation: C) -> Self { + Self { cancellation } + } +} + +impl Executor for ApplicationExecutor { + fn execute(&self, cli: &Cli) -> Result { + 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::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!({})) +} diff --git a/src/commands/query.rs b/src/commands/query.rs new file mode 100644 index 0000000..99fe4a5 --- /dev/null +++ b/src/commands/query.rs @@ -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 { + 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, +) -> Result, AppError> { + let root = store.root().join("analyses").join(sample.as_str()); + let mut entries = match fs::read_dir(&root) { + Ok(entries) => entries + .collect::, _>>() + .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 { + 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 { + 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) -> 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( + value: AdapterQueryData, +) -> 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( + cli: &Cli, + store: &AnalysisStore, + sample: &Digest, + profile: &Digest, + envelope: SuccessEnvelope, + human: String, +) -> Result { + 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 { + 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 { + 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 { + 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 { + 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) -> 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::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 { + 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") +} diff --git a/src/domain/identifier.rs b/src/domain/identifier.rs index b3d0b16..cece428 100644 --- a/src/domain/identifier.rs +++ b/src/domain/identifier.rs @@ -40,6 +40,13 @@ impl fmt::Display for Digest { pub struct InvocationId(String); impl InvocationId { + /// Generates a cryptographically random 128-bit invocation identifier. + pub fn generate() -> Result { + let mut bytes = [0_u8; 16]; + getrandom::fill(&mut bytes)?; + Ok(Self(hex::encode(bytes))) + } + /// Parses a canonical invocation identifier. pub fn parse(value: &str) -> Result { if value.len() != 32 @@ -51,6 +58,18 @@ impl InvocationId { } Ok(Self(value.to_owned())) } + + /// Returns the canonical lowercase hexadecimal spelling. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for InvocationId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } } /// Identifier validation failure. diff --git a/src/error.rs b/src/error.rs index a68fcfb..5dfa56c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -37,8 +37,6 @@ pub enum ErrorCode { JavaMissing, /// The installed Java version is incompatible. JavaIncompatible, - /// A later integration layer has not connected execution yet. - InternalNotImplemented, /// Rust/Java request or response validation failed. ProtocolViolation, /// The child exceeded a native or watchdog timeout. @@ -157,22 +155,6 @@ impl AppError { } } - /// Clear failure used until the store/process/Java execution layer is attached. - #[must_use] - pub fn execution_not_implemented(operation: &str) -> Self { - let mut error = Self::new( - ErrorCode::InternalNotImplemented, - format!("operation '{operation}' is not connected to the Ghidra execution layer"), - false, - ); - error - .envelope - .error - .details - .insert("operation".to_owned(), Value::String(operation.to_owned())); - error - } - /// Constructs an invalid-invocation failure from clap context. #[must_use] pub fn invalid_arguments(message: impl Into) -> Self { diff --git a/src/lib.rs b/src/lib.rs index d5e838a..a1caf4b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,11 +2,15 @@ #![doc = "Public contracts and synchronous orchestration seams for `ghidr`."] pub mod cli; +pub mod commands; pub mod domain; pub mod error; pub mod operation; pub mod output; +pub mod process; pub mod protocol; +pub mod runtime; +pub mod sandbox; pub mod schema; pub mod store; diff --git a/src/main.rs b/src/main.rs index 57658a0..7637782 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,17 +3,45 @@ use std::{io, process::ExitCode}; -use ghidra_cli::output::{UnimplementedExecutor, run_from}; +use ghidra_cli::{ + AppError, ErrorCode, + cli::Cli, + commands::ApplicationExecutor, + output::{Executor, RenderedSuccess, run_from}, + process::InterruptCounter, +}; + +struct StartupFailure(AppError); + +impl Executor for StartupFailure { + fn execute(&self, _cli: &Cli) -> Result { + Err(self.0.clone()) + } +} fn main() -> ExitCode { let mut stdout = io::stdout().lock(); let mut stderr = io::stderr().lock(); - match run_from( - std::env::args_os(), - &UnimplementedExecutor, - &mut stdout, - &mut stderr, - ) { + let arguments: Vec<_> = std::env::args_os().collect(); + let result = match InterruptCounter::install() { + Ok(cancellation) => run_from( + arguments, + &ApplicationExecutor::new(cancellation), + &mut stdout, + &mut stderr, + ), + Err(error) => run_from( + arguments, + &StartupFailure(AppError::new( + ErrorCode::Internal, + format!("SIGINT handler installation failed: {error}"), + false, + )), + &mut stdout, + &mut stderr, + ), + }; + match result { Ok(status) => ExitCode::from(u8::try_from(status.code()).unwrap_or(1)), Err(_) => ExitCode::FAILURE, } diff --git a/src/operation.rs b/src/operation.rs index 090814d..863d92e 100644 --- a/src/operation.rs +++ b/src/operation.rs @@ -293,17 +293,67 @@ pub enum SandboxVerification { pub struct DoctorData { /// True only when every required component is ready. pub ready: bool, - /// Typed component reports keyed by stable component name. - pub components: BTreeMap, + /// Complete fixed component report. + pub components: DoctorComponents, } -/// One diagnosed component. +/// Fixed installation components checked safely by `doctor`. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct ComponentReport { +pub struct DoctorComponents { + /// Ghidra launcher and adapter. + pub ghidra: GhidraComponent, + /// Java runtime. + pub java: JavaComponent, + /// Analysis Store. + pub analysis_store: AnalysisStoreComponent, + /// Worker isolation backend. + pub sandbox: SandboxComponent, +} + +/// Diagnosed Ghidra installation. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct GhidraComponent { /// Stable readiness classification. pub status: ComponentStatus, - /// Component-specific facts. - pub details: BTreeMap, + /// Observed version when the probe ran. + pub version: Option, + /// Configured official launcher. + pub launcher: Option, +} + +/// 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, + /// Configured Java executable. + pub executable: Option, +} + +/// Diagnosed Analysis Store. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AnalysisStoreComponent { + /// Stable readiness classification. + pub status: ComponentStatus, + /// Resolved absolute store path. + pub path: TaggedPath, + /// Resolution precedence source. + pub source: StoreSource, + /// Complete non-following store usage scan. + pub usage: StorageUsage, +} + +/// Diagnosed sandbox backend. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SandboxComponent { + /// Stable readiness classification. + pub status: ComponentStatus, + /// Explicit selected backend. + pub backend: SandboxBackend, + /// Verification state. + pub verification: SandboxVerification, } /// Doctor component readiness classification. @@ -556,6 +606,25 @@ pub struct ArtifactDescriptor { pub contains: String, } +/// Successful command data emitted inline or represented by a complete Artifact. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum InlineOrArtifact { + /// Complete command-specific data. + Inline(D), + /// Bounded descriptor for the complete stored success response. + Spilled(SpilledData), +} + +/// Public data shape returned when a complete success exceeds the inline byte budget. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SpilledData { + /// Always true for this variant. + pub spilled: bool, + /// Immutable complete-success Artifact. + pub artifact: ArtifactDescriptor, +} + /// Canonical Analysis Profile document whose exact JSON bytes are hashed. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] diff --git a/src/output.rs b/src/output.rs index cfd1093..94a7f33 100644 --- a/src/output.rs +++ b/src/output.rs @@ -10,7 +10,7 @@ use serde_json::Value; use crate::{ AppError, ErrorCode, ExitStatus, - cli::{Cli, Command, OutputFormat}, + cli::{Cli, OutputFormat}, protocol::requests_human_format, }; @@ -32,18 +32,6 @@ pub trait Executor { fn execute(&self, cli: &Cli) -> Result; } -/// 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 { - Err(AppError::execution_not_implemented(operation_name( - &cli.command, - ))) - } -} - /// Parses, executes, and writes exactly the stream framing selected by the caller. pub fn run_from( arguments: I, @@ -133,16 +121,6 @@ fn write_error_json(error: &AppError, writer: &mut impl Write) -> io::Result<()> writer.write_all(&bytes) } -fn operation_name(command: &Command) -> &'static str { - match command { - Command::Doctor => "doctor", - Command::Inspect(_) => "inspect", - Command::Functions(_) => "functions", - Command::Decompile(_) => "decompile", - Command::Clean(_) => "clean", - } -} - #[cfg(test)] #[allow(clippy::expect_used)] mod tests { diff --git a/src/process/capture.rs b/src/process/capture.rs new file mode 100644 index 0000000..ff98b73 --- /dev/null +++ b/src/process/capture.rs @@ -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), + Segments { first: Vec, last: Vec }, +} + +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 { + 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, + 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, 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()); + } +} diff --git a/src/process/diagnostics.rs b/src/process/diagnostics.rs new file mode 100644 index 0000000..29b6d3c --- /dev/null +++ b/src/process/diagnostics.rs @@ -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; +} + +/// 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, +} + +/// 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 { + 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, + streams: BTreeMap, +} + +#[derive(Serialize)] +struct StreamManifest { + total_bytes: u64, + captured_bytes: u64, + truncated: bool, + encoding: StreamEncoding, + segments: Vec, +} + +#[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>) { + 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()); + } +} diff --git a/src/process/lifecycle.rs b/src/process/lifecycle.rs new file mode 100644 index 0000000..2b5dd51 --- /dev/null +++ b/src/process/lifecycle.rs @@ -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, + /// Targeted decompilation phase, absent for non-decompilation Queries. + pub decompile_seconds: Option, +} + +impl NativePhaseTimeouts { + /// Sum of enabled phase bounds plus the fixed 120-second harness overhead. + pub fn watchdog(self) -> Result { + 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 { + 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, +} + +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 { + 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, + cancellation: &(impl Cancellation + ?Sized), + stdout: Option<&DrainThread>, + stderr: Option<&DrainThread>, +) -> (Option, 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, + 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, signal: Signal) { + if let Some(process_group) = process_group { + let _ignored = kill_process_group(process_group, signal); + } +} + +type DrainThread = thread::JoinHandle>; + +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, + stderr: Option, +) -> (CapturedStream, CapturedStream, Option) { + 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, name: &str) -> (CapturedStream, Option) { + 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 { + 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), + ); + } +} diff --git a/src/process/mod.rs b/src/process/mod.rs new file mode 100644 index 0000000..2ec9b26 --- /dev/null +++ b/src/process/mod.rs @@ -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; diff --git a/src/process/signals.rs b/src/process/signals.rs new file mode 100644 index 0000000..5128468 --- /dev/null +++ b/src/process/signals.rs @@ -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, +} + +impl InterruptCounter { + /// Installs a safe SIGINT iterator and detached counter thread. + pub fn install() -> io::Result { + 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) + } +} diff --git a/src/protocol.rs b/src/protocol.rs index 1c5dfbc..0d9bdb5 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -2,7 +2,9 @@ use std::{ ffi::OsStr, - io::{self, Read}, + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + os::unix::fs::OpenOptionsExt as _, path::{Path, PathBuf}, }; @@ -12,14 +14,95 @@ use thiserror::Error; use crate::{ domain::{FunctionSelector, InvocationId, Limits, PageRequest}, - operation::{ADAPTER_PROTOCOL_VERSION, Operation}, + operation::{ + ADAPTER_PROTOCOL_VERSION, DecompilationData, FunctionsData, Operation, ProfileOption, + ProgramSummary, TargetProvenance, + }, }; +/// Engine facts returned by the Java installation probe. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AdapterDoctorData { + /// Probe result. + pub ready: bool, + /// Exact Ghidra version. + pub ghidra_version: String, + /// Java feature version. + pub java_version: u64, + /// Adapter protocol implementation version. + pub adapter_protocol_version: u32, +} + +/// Engine metadata common to every Sample-backed worker result. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AdapterContext { + /// Exact Ghidra version. + pub ghidra_version: String, + /// Java feature version. + pub java_version: u64, + /// Resolved Target Specification without harness verification policy. + pub target: AdapterTarget, + /// Fully resolved loader options; empty when the selected loader exposes none. + pub loader_options: Vec, + /// Fully resolved current analysis option values. + pub analyzer_options: Vec, +} + +/// 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 { + /// Current Program/engine context. + pub context: AdapterContext, + /// Operation-specific facts. + pub query: T, +} + /// Maximum exact serialized `request.json` size (1 MiB). pub const MAX_REQUEST_BYTES: usize = 1_048_576; /// Maximum exact serialized `response.json` size (256 MiB). pub const MAX_RESPONSE_BYTES: usize = 268_435_456; +/// Fixed names inside one private invocation directory. +pub const REQUEST_FILE_NAME: &str = "request.json"; +/// Java's incomplete response, never accepted by Rust. +pub const TEMP_RESPONSE_FILE_NAME: &str = "response.json.tmp"; +/// Java's only accepted final response name. +pub const RESPONSE_FILE_NAME: &str = "response.json"; + /// A validated UTF-8 absolute path owned by the trusted harness. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] @@ -164,6 +247,127 @@ pub fn validate_response_echo( Ok(()) } +/// Strictly decoded operation data after echo and schema validation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ValidatedAdapterData { + /// Installation probe result. + Doctor(AdapterDoctorData), + /// Program inspection result. + Inspect(AdapterQueryData), + /// Stable Function page. + Functions(AdapterQueryData), + /// Complete decompilation. + Decompile(AdapterQueryData), +} + +/// 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 { + 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, Inspect), + Operation::Functions => exact!(AdapterQueryData, Functions), + Operation::Decompile => exact!(AdapterQueryData, 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 { + 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 { + 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( value: &T, limit: usize, @@ -238,6 +442,21 @@ pub enum ProtocolCodecError { /// Operation echo differs. #[error("adapter operation mismatch")] OperationMismatch, + /// Operation data did not round-trip through its exact typed schema. + #[error("adapter operation data failed strict schema validation")] + OperationSchemaMismatch, + /// The private invocation directory is not a real directory. + #[error("invocation path must name a real directory")] + InvalidInvocationDirectory, + /// A protocol path unexpectedly existed before launch. + #[error("protocol file already exists before worker launch")] + ProtocolFileAlreadyExists, + /// No atomically published final response exists. + #[error("adapter did not publish response.json")] + MissingFinalResponse, + /// The final response is a symlink, directory, or special file. + #[error("response.json must be a regular non-symlink file")] + InvalidFinalResponseType, /// Platform cannot represent the protocol bound. #[error("platform cannot represent protocol size bound")] PlatformLimit, @@ -260,10 +479,45 @@ pub fn requests_human_format(arguments: &[impl AsRef]) -> bool { } #[cfg(test)] +#[allow(clippy::expect_used)] mod tests { - use std::io::Cursor; + use std::{io::Cursor, path::PathBuf}; - use super::{ProtocolCodecError, decode_bounded, requests_human_format}; + use tempfile::tempdir; + + use super::{ + AdapterRequest, InvocationFiles, ProtocolCodecError, RequestArguments, decode_bounded, + requests_human_format, validate_operation_data, + }; + use crate::{ + domain::{FunctionSelector, InvocationId, Limits, PositiveU64}, + operation::{ADAPTER_PROTOCOL_VERSION, Operation}, + }; + + fn positive(value: u64) -> PositiveU64 { + PositiveU64::try_from(value).expect("positive") + } + + fn request(arguments: RequestArguments) -> AdapterRequest { + AdapterRequest { + protocol_version: ADAPTER_PROTOCOL_VERSION, + invocation_id: InvocationId::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .expect("identifier"), + operation: arguments.operation(), + staged_sample: None, + analysis_path: None, + limits: Limits { + max_heap_mib: positive(2048), + max_cpu: positive(2), + analysis_timeout_seconds: positive(600), + decompile_timeout_seconds: None, + child_watchdog_seconds: positive(120), + max_sample_bytes: positive(1_073_741_824), + max_inline_bytes: positive(65_536), + }, + arguments, + } + } #[test] fn response_reader_stops_at_bound_plus_one() { @@ -302,4 +556,40 @@ mod tests { Err(ProtocolCodecError::RequestTooLarge { .. }) )); } + + #[test] + fn oversized_typed_request_is_rejected_before_any_file_is_published() { + let root = tempdir().expect("temporary root"); + let invocation = root.path().join("invocation"); + std::fs::create_dir(&invocation).expect("invocation directory"); + let files = InvocationFiles::new(invocation).expect("protocol paths"); + let request = request(RequestArguments::Decompile { + selector: FunctionSelector::Name("x".repeat(super::MAX_REQUEST_BYTES)), + }); + assert!(matches!( + files.write_request(&request), + Err(ProtocolCodecError::RequestTooLarge { .. }) + )); + assert!(!files.request_path().exists()); + } + + #[test] + fn operation_schema_rejects_unknown_fields() { + let data = serde_json::json!({ + "ready": true, + "ghidra_version": "12.1.2", + "java_version": 21, + "adapter_protocol_version": 1, + "unexpected": true + }); + assert!(validate_operation_data(Operation::Doctor, data).is_err()); + } + + #[test] + fn invocation_directory_must_be_absolute() { + assert!(matches!( + InvocationFiles::new(PathBuf::from("relative")), + Err(ProtocolCodecError::InvalidWorkerPath) + )); + } } diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..a9d20ba --- /dev/null +++ b/src/runtime.rs @@ -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, + /// JDK root containing `bin/java`. + pub java_home: Option, + /// Directory containing the Java adapter source/classes. + pub adapter_path: Option, + /// Bubblewrap executable used only in explicit Bubblewrap mode. + pub bubblewrap: Option, +} + +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 { + 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, name: &str) -> Result { + 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 { + 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 { + 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 { + 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 { + 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)) +} diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs new file mode 100644 index 0000000..411a5ab --- /dev/null +++ b/src/sandbox/mod.rs @@ -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, + /// 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, +} + +/// 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, + /// Exact pinned Nix closure roots visible read-only at their host paths. + pub readonly_paths: Vec, + /// Staged Sample exposed read-only, when applicable. + pub staged_sample: Option, + /// Invocation and project/staging paths exposed read-write. + pub writable_paths: Vec, + /// 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, + bubblewrap: Option, + ) -> Result { + 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) -> 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 { + 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, + 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, 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(values: [&str; N]) -> Vec { + 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 { + 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) -> 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); + } + } +} diff --git a/src/schema.rs b/src/schema.rs index 3b7c6d4..c30cab9 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -8,8 +8,8 @@ use serde_json::{Value, json}; use crate::{ error::ErrorEnvelope, operation::{ - AnalysisProfile, CleanupData, DecompilationData, DoctorData, FunctionsData, InspectionData, - QueryProvenance, SuccessEnvelope, ToolProvenance, + AnalysisProfile, CleanupData, DecompilationData, DoctorData, FunctionsData, + InlineOrArtifact, InspectionData, QueryProvenance, SuccessEnvelope, ToolProvenance, }, protocol::{AdapterRequest, AdapterResponse}, }; @@ -25,21 +25,24 @@ pub fn v1_schemas() -> BTreeMap<&'static str, Schema> { ( "cleanup.schema.json", with_kind( - schema_for!(SuccessEnvelope), + schema_for!(SuccessEnvelope>), "cleanup", ), ), ( "decompilation.schema.json", with_kind( - schema_for!(SuccessEnvelope), + schema_for!(SuccessEnvelope< + QueryProvenance, + InlineOrArtifact, + >), "decompilation", ), ), ( "doctor.schema.json", with_kind( - schema_for!(SuccessEnvelope), + schema_for!(SuccessEnvelope>), "doctor", ), ), @@ -54,14 +57,14 @@ pub fn v1_schemas() -> BTreeMap<&'static str, Schema> { ( "functions.schema.json", with_kind( - schema_for!(SuccessEnvelope), + schema_for!(SuccessEnvelope>), "functions", ), ), ( "inspection.schema.json", with_kind( - schema_for!(SuccessEnvelope), + schema_for!(SuccessEnvelope>), "inspection", ), ), diff --git a/src/store/analysis.rs b/src/store/analysis.rs index 732f5d8..1887d03 100644 --- a/src/store/analysis.rs +++ b/src/store/analysis.rs @@ -236,7 +236,7 @@ impl AnalysisStore { profile: &Digest, lock: &AnalysisLock, ) -> Result { - if !lock.guards(sample, profile) { + if !lock.guards_exclusive(sample, profile) { return Err(StoreError::new( StoreErrorKind::AnalysisBusy, "lock token does not guard the promoted Analysis identity", @@ -267,7 +267,7 @@ impl AnalysisStore { lock: &AnalysisLock, rebuild_already_attempted: bool, ) -> Result { - if !lock.guards(sample, profile) { + if !lock.guards_exclusive(sample, profile) { return Err(StoreError::new( StoreErrorKind::AnalysisBusy, "lock token does not guard the quarantined Analysis identity", diff --git a/src/store/cleanup_command.rs b/src/store/cleanup_command.rs index 24a71c7..02d5e31 100644 --- a/src/store/cleanup_command.rs +++ b/src/store/cleanup_command.rs @@ -57,6 +57,10 @@ pub fn execute_cleanup( "store-wide cleanup requires explicit confirmation", false, ) + .with_detail( + "matched", + serde_json::to_value(plan.snapshot()).unwrap_or(Value::Null), + ) .with_detail("analyses", Value::from(plan.snapshot().analyses)) .with_detail("artifacts", Value::from(plan.snapshot().artifacts)) .with_detail( diff --git a/src/store/filesystem.rs b/src/store/filesystem.rs index b94172e..74de179 100644 --- a/src/store/filesystem.rs +++ b/src/store/filesystem.rs @@ -1,10 +1,11 @@ use std::{ fs::{self, File, OpenOptions}, - io::Write as _, + io::{self, Write as _}, os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}, path::{Path, PathBuf}, }; +use rustix::fs::{Mode, OFlags}; use serde::{Deserialize, Serialize}; use crate::operation::{StorageUsage, StoreSource}; @@ -123,6 +124,77 @@ pub(crate) fn create_private_directory(path: &Path) -> Result<(), StoreError> { .map_err(|error| StoreError::io(path, error)) } +/// Copies a validated project into private disposable storage without following +/// symlinks or accepting special files. +pub(crate) fn copy_private_tree(source: &Path, destination: &Path) -> Result<(), StoreError> { + let metadata = fs::symlink_metadata(source).map_err(|error| StoreError::io(source, error))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(StoreError::at( + StoreErrorKind::CorruptStore, + source, + "project snapshot source is not an ordinary directory", + )); + } + create_private_directory(destination)?; + let mut entries = fs::read_dir(source) + .map_err(|error| StoreError::io(source, error))? + .collect::, _>>() + .map_err(|error| StoreError::io(source, error))?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + let metadata = fs::symlink_metadata(&source_path) + .map_err(|error| StoreError::io(&source_path, error))?; + if metadata.file_type().is_symlink() { + return Err(StoreError::at( + StoreErrorKind::CorruptStore, + &source_path, + "symlink found while copying cached Analysis", + )); + } + if metadata.is_dir() { + copy_private_tree(&source_path, &destination_path)?; + } else if metadata.is_file() { + copy_private_file(&source_path, &destination_path)?; + } else { + return Err(StoreError::at( + StoreErrorKind::CorruptStore, + &source_path, + "special file found while copying cached Analysis", + )); + } + } + sync_directory(destination) +} + +fn copy_private_file(source: &Path, destination: &Path) -> Result<(), StoreError> { + let source_fd = rustix::fs::open( + source, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::empty(), + ) + .map_err(|error| StoreError::io(source, io::Error::from(error)))?; + let mut source_file = File::from(source_fd); + if !source_file + .metadata() + .map_err(|error| StoreError::io(source, error))? + .is_file() + { + return Err(StoreError::at( + StoreErrorKind::CorruptStore, + source, + "cached Analysis entry changed type while copying", + )); + } + let mut destination_file = open_new_private_file(destination)?; + io::copy(&mut source_file, &mut destination_file) + .map_err(|error| StoreError::io(destination, error))?; + destination_file + .sync_all() + .map_err(|error| StoreError::io(destination, error)) +} + fn reject_symlink_ancestors(path: &Path) -> Result<(), StoreError> { for ancestor in path.ancestors().collect::>().into_iter().rev() { match fs::symlink_metadata(ancestor) { @@ -280,7 +352,7 @@ mod tests { use tempfile::TempDir; - use super::AnalysisStore; + use super::{AnalysisStore, copy_private_tree}; use crate::store::{StoreEnvironment, resolve_store}; fn store(temp: &TempDir) -> AnalysisStore { @@ -329,4 +401,23 @@ mod tests { symlink(&outside, store.root().join("artifacts/escape")).expect("symlink"); assert!(store.usage().is_err()); } + + #[test] + fn private_tree_copy_is_exact_and_rejects_symlinks() { + let temp = TempDir::new().expect("temp"); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + fs::create_dir_all(source.join("nested")).expect("source"); + fs::write(source.join("nested/data"), b"exact bytes").expect("data"); + copy_private_tree(&source, &destination).expect("copy"); + assert_eq!( + fs::read(destination.join("nested/data")).expect("copied data"), + b"exact bytes" + ); + + let outside = temp.path().join("outside"); + fs::write(&outside, b"outside").expect("outside"); + symlink(&outside, source.join("escape")).expect("symlink"); + assert!(copy_private_tree(&source, &temp.path().join("rejected")).is_err()); + } } diff --git a/src/store/lock.rs b/src/store/lock.rs index c08f0c6..ca777ab 100644 --- a/src/store/lock.rs +++ b/src/store/lock.rs @@ -9,13 +9,14 @@ use crate::domain::Digest; use super::{AnalysisStore, StoreError, StoreErrorKind, filesystem::create_private_directory}; -/// Held nonblocking exclusive per-Analysis OS lock. +/// Held nonblocking shared or exclusive per-Analysis OS lock. #[derive(Debug)] pub struct AnalysisLock { _file: File, path: PathBuf, sample: Digest, profile: Digest, + exclusive: bool, } impl AnalysisLock { @@ -24,6 +25,37 @@ impl AnalysisLock { store: &AnalysisStore, sample: &Digest, profile: &Digest, + ) -> Result { + Self::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::acquire_with_operation( + store, + sample, + profile, + FlockOperation::NonBlockingLockShared, + false, + ) + } + + fn acquire_with_operation( + store: &AnalysisStore, + sample: &Digest, + profile: &Digest, + operation: FlockOperation, + exclusive: bool, ) -> Result { let directory = store.path("locks").join(sample.as_str()); create_private_directory(&directory)?; @@ -41,12 +73,13 @@ impl AnalysisLock { ) })?; let file = File::from(descriptor); - match rustix::fs::flock(&file, FlockOperation::NonBlockingLockExclusive) { + match rustix::fs::flock(&file, operation) { Ok(()) => Ok(Self { _file: file, path, sample: sample.clone(), profile: profile.clone(), + exclusive, }), Err(error) if error == rustix::io::Errno::WOULDBLOCK => Err(StoreError::at( StoreErrorKind::AnalysisBusy, @@ -78,6 +111,12 @@ impl AnalysisLock { pub fn guards(&self, sample: &Digest, profile: &Digest) -> bool { &self.sample == sample && &self.profile == profile } + + /// Returns whether the token permits promotion, quarantine, or cleanup. + #[must_use] + pub fn guards_exclusive(&self, sample: &Digest, profile: &Digest) -> bool { + self.exclusive && self.guards(sample, profile) + } } #[cfg(test)] @@ -109,4 +148,25 @@ mod tests { drop(first); assert!(AnalysisLock::acquire(&store, &sample, &profile).is_ok()); } + + #[test] + fn immutable_queries_share_locks_but_exclude_cleanup() { + let temp = TempDir::new().expect("temp"); + let environment = StoreEnvironment { + ghidr_store: Some(OsString::from(temp.path().join("store"))), + ..StoreEnvironment::default() + }; + let store = AnalysisStore::initialize(resolve_store(None, &environment).expect("resolve")) + .expect("store"); + let sample = Digest::from_str(&"a".repeat(64)).expect("digest"); + let profile = Digest::from_str(&"b".repeat(64)).expect("digest"); + let first = + AnalysisLock::acquire_shared(&store, &sample, &profile).expect("first shared lock"); + let second = + AnalysisLock::acquire_shared(&store, &sample, &profile).expect("second shared lock"); + assert!(AnalysisLock::acquire(&store, &sample, &profile).is_err()); + drop(first); + drop(second); + assert!(AnalysisLock::acquire(&store, &sample, &profile).is_ok()); + } } diff --git a/src/store/mod.rs b/src/store/mod.rs index 66e6544..7a2984b 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -20,6 +20,7 @@ pub use cleanup::{CleanupPlan, CleanupScope, CleanupTransaction, plan_cleanup}; pub use cleanup_command::execute_cleanup; pub use error::{StoreError, StoreErrorKind}; pub use filesystem::{AnalysisStore, STORE_LAYOUT_VERSION, StoreUsage}; +pub(crate) use filesystem::{copy_private_tree, create_private_directory}; pub use lock::AnalysisLock; pub use resolution::{ResolvedStore, StoreEnvironment, resolve_store}; pub use sample::{DEFAULT_STORE_RESERVE_BYTES, SampleSnapshot, SnapshotOptions, snapshot_sample}; diff --git a/src/store/sample.rs b/src/store/sample.rs index 4e41540..5d57529 100644 --- a/src/store/sample.rs +++ b/src/store/sample.rs @@ -72,6 +72,12 @@ impl SampleSnapshot { &self.path } + /// Private staging root used to assemble a complete Analysis. + #[must_use] + pub fn staging_directory(&self) -> &Path { + &self.staging_directory + } + /// Exact caller-supplied path retained only as provenance. #[must_use] pub const fn source_path(&self) -> &TaggedPath { diff --git a/tests/adapter_contract.rs b/tests/adapter_contract.rs new file mode 100644 index 0000000..759f588 --- /dev/null +++ b/tests/adapter_contract.rs @@ -0,0 +1,48 @@ +#![allow(clippy::expect_used)] +#![doc = "Java dispatch, protocol-bound, and capability-surface regressions."] + +use std::{collections::BTreeSet, fs, path::PathBuf}; + +use ghidra_cli::operation::OPERATIONS; + +fn root(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative) +} + +#[test] +fn java_dispatch_matches_only_worker_owned_registry_operations() { + let dispatch = fs::read_to_string(root("java/dispatch.txt")).expect("read Java dispatch"); + let java: BTreeSet<&str> = dispatch + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .collect(); + let rust_worker: BTreeSet<&str> = OPERATIONS + .iter() + .map(|descriptor| descriptor.operation.as_str()) + .filter(|operation| *operation != "clean") + .collect(); + assert_eq!(java, rust_worker); + assert!(!java.contains("clean")); +} + +#[test] +fn java_source_has_protocol_bounds_and_no_egress_or_mutation_surface() { + let source = fs::read_to_string(root("java/GhidrAdapter.java")).expect("read adapter"); + assert!(source.contains("MAX_REQUEST_BYTES = 1_048_576")); + assert!(source.contains("MAX_RESPONSE_BYTES = 268_435_456L")); + for forbidden in [ + "ServerSocket", + "HttpServer", + "java.net.", + "setName(", + "startTransaction(", + "runScript(", + "executeScript(", + ] { + assert!( + !source.contains(forbidden), + "forbidden Java surface: {forbidden}" + ); + } +} diff --git a/tests/bubblewrap_probe.rs b/tests/bubblewrap_probe.rs new file mode 100644 index 0000000..f1f9495 --- /dev/null +++ b/tests/bubblewrap_probe.rs @@ -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, + 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 { + env::var_os("GHIDR_TEST_BWRAP") + .map(PathBuf::from) + .or_else(|| find_in_path("bwrap")) +} + +fn find_in_path(executable: &str) -> Option { + env::var_os("PATH").and_then(|path| { + env::split_paths(&path) + .map(|directory| directory.join(executable)) + .find(|candidate| candidate.is_file()) + }) +} diff --git a/tests/cli.rs b/tests/cli.rs index 69a78a8..ace59a9 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,20 +1,237 @@ #![allow(clippy::expect_used)] #![doc = "Black-box CLI framing and exit-status tests."] -use std::process::Command; +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + process::{Command, Stdio}, + thread, + time::Duration, +}; +use rustix::process::{Pid, Signal, kill_process}; #[test] -fn unimplemented_operation_is_a_typed_json_runtime_failure() { +fn doctor_reports_ready_with_closed_runtime_paths() { + let temp = tempfile::tempdir().expect("temp"); + let runtime = temp.path().join("runtime"); + let java_home = runtime.join("jdk"); + let adapter = runtime.join("adapter"); + fs::create_dir_all(java_home.join("bin")).expect("java directory"); + fs::create_dir_all(&adapter).expect("adapter directory"); + for path in [runtime.join("bwrap"), adapter.join("GhidrAdapter.java")] { + fs::write(path, b"fixture").expect("runtime fixture"); + } + let java = java_home.join("bin/java"); + fs::write(&java, b"#!/bin/sh\necho 'openjdk version \"21\"' >&2\n").expect("java fixture"); + fs::set_permissions(&java, fs::Permissions::from_mode(0o755)).expect("java mode"); let output = Command::new(env!("CARGO_BIN_EXE_ghidr")) - .arg("doctor") + .args([ + "--store", + temp.path().join("store").to_str().expect("UTF-8"), + "--sandbox", + "off", + "doctor", + ]) + .env("GHIDR_GHIDRA_VERSION", "12.1.2") + .env( + "GHIDR_ANALYZE_HEADLESS", + env!("CARGO_BIN_EXE_ghidr-fake-child"), + ) + .env("GHIDR_JAVA_HOME", java_home) + .env("GHIDR_ADAPTER_PATH", adapter) + .env("GHIDR_BUBBLEWRAP", runtime.join("bwrap")) .output() .expect("run ghidr"); - assert_eq!(output.status.code(), Some(1)); - assert!(output.stdout.is_empty()); + assert_eq!(output.status.code(), Some(0), "{:?}", output.stderr); + assert!(output.stderr.is_empty()); + let success: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("valid JSON success"); + assert_eq!(success["kind"], "doctor"); + assert_eq!(success["data"]["ready"], true); + assert!( + !temp + .path() + .join("store/staging/doctor-write-probe") + .exists() + ); +} + +#[test] +fn clean_dry_run_and_execution_are_real_store_transactions() { + let temp = tempfile::tempdir().expect("temp"); + let store = temp.path().join("store"); + let sample = "a".repeat(64); + let profile = "b".repeat(64); + let artifact = store + .join("artifacts") + .join(&sample) + .join(&profile) + .join(format!("{}.json", "c".repeat(64))); + fs::create_dir_all(artifact.parent().expect("parent")).expect("artifact directory"); + fs::write(&artifact, b"{}\n").expect("artifact"); + + let invoke = |dry_run: bool| { + let mut command = Command::new(env!("CARGO_BIN_EXE_ghidr")); + command.args([ + "--store", + store.to_str().expect("UTF-8"), + "clean", + "--digest", + &sample, + ]); + if dry_run { + command.arg("--dry-run"); + } + command.output().expect("run clean") + }; + let dry_run = invoke(true); + assert_eq!(dry_run.status.code(), Some(0), "{:?}", dry_run.stderr); + let report: serde_json::Value = serde_json::from_slice(&dry_run.stdout).expect("dry-run JSON"); + assert_eq!(report["data"]["mode"], "dry_run"); + assert_eq!(report["data"]["matched"]["artifacts"], 1); + assert!(artifact.exists()); + + let executed = invoke(false); + assert_eq!(executed.status.code(), Some(0), "{:?}", executed.stderr); + let report: serde_json::Value = + serde_json::from_slice(&executed.stdout).expect("execution JSON"); + assert_eq!(report["data"]["mode"], "executed"); + assert_eq!(report["data"]["removed"]["artifacts"], 1); + assert!(!artifact.exists()); + + let confirmation = Command::new(env!("CARGO_BIN_EXE_ghidr")) + .args(["--store", store.to_str().expect("UTF-8"), "clean", "--all"]) + .output() + .expect("run clean all"); + assert_eq!(confirmation.status.code(), Some(1)); + assert!(confirmation.stdout.is_empty()); let error: serde_json::Value = - serde_json::from_slice(&output.stderr).expect("valid JSON error"); - assert_eq!(error["error"]["code"], "internal_not_implemented"); - assert_eq!(error["error"]["details"]["operation"], "doctor"); + serde_json::from_slice(&confirmation.stderr).expect("confirmation JSON"); + assert_eq!(error["error"]["code"], "confirmation_required"); + assert!(error["error"]["details"]["matched"]["usage"].is_object()); +} + +#[test] +fn fake_worker_drives_inspect_reuse_functions_decompile_and_typed_error() { + let temp = tempfile::tempdir().expect("temp"); + let store = temp.path().join("store"); + let adapter = temp.path().join("adapter"); + fs::create_dir(&adapter).expect("adapter"); + fs::write(adapter.join("GhidrAdapter.java"), b"fixture").expect("adapter source"); + let sample = temp.path().join("sample"); + fs::write(&sample, b"controllable sample").expect("sample"); + + let invoke = |arguments: &[&str], error: Option<&str>| { + let mut command = Command::new(env!("CARGO_BIN_EXE_ghidr")); + command + .args([ + "--sandbox", + "off", + "--store", + store.to_str().expect("UTF-8"), + ]) + .args(arguments) + .env("GHIDR_GHIDRA_VERSION", "12.1.2") + .env( + "GHIDR_ANALYZE_HEADLESS", + env!("CARGO_BIN_EXE_ghidr-fake-child"), + ) + .env("GHIDR_ADAPTER_PATH", &adapter); + if let Some(code) = error { + command.env("GHIDR_FAKE_WORKER_ERROR", code); + } + command.output().expect("run Query") + }; + + let inspected = invoke(&["inspect", sample.to_str().expect("UTF-8")], None); + assert_eq!(inspected.status.code(), Some(0), "{:?}", inspected.stderr); + let inspection: serde_json::Value = + serde_json::from_slice(&inspected.stdout).expect("inspection"); + assert_eq!(inspection["data"]["analysis"]["disposition"], "created"); + assert_eq!(inspection["provenance"]["sandbox"]["backend"], "off"); + let profile = inspection["provenance"]["analysis_profile_sha256"].clone(); + + let functions = invoke( + &["functions", sample.to_str().expect("UTF-8"), "--limit", "1"], + None, + ); + assert_eq!(functions.status.code(), Some(0), "{:?}", functions.stderr); + let functions: serde_json::Value = + serde_json::from_slice(&functions.stdout).expect("functions"); + assert_eq!(functions["data"]["items"][0]["name"], "main"); + assert_eq!(functions["provenance"]["analysis_profile_sha256"], profile); + assert_eq!( + fs::read_dir(store.join("analyses")) + .expect("analyses") + .flat_map(|entry| fs::read_dir(entry.expect("sample entry").path()).expect("profiles")) + .count(), + 1 + ); + + let decompiled = invoke( + &[ + "decompile", + sample.to_str().expect("UTF-8"), + "--name", + "main", + ], + None, + ); + assert_eq!(decompiled.status.code(), Some(0), "{:?}", decompiled.stderr); + let decompiled: serde_json::Value = + serde_json::from_slice(&decompiled.stdout).expect("decompilation"); + assert_eq!(decompiled["data"]["requested_selector"]["value"], "main"); + assert!( + decompiled["data"]["decompilation"]["text"] + .as_str() + .expect("text") + .contains("return 0") + ); + + let spilled = invoke( + &[ + "decompile", + sample.to_str().expect("UTF-8"), + "--name", + "large", + "--max-inline-bytes", + "4096", + ], + None, + ); + assert_eq!(spilled.status.code(), Some(0), "{:?}", spilled.stderr); + let spilled: serde_json::Value = + serde_json::from_slice(&spilled.stdout).expect("spill descriptor"); + assert_eq!(spilled["data"]["spilled"], true); + let artifact = spilled["data"]["artifact"]["path"]["value"] + .as_str() + .expect("Artifact path"); + let complete = fs::read(artifact).expect("complete Artifact"); + assert_eq!(complete.last(), Some(&b'\n')); + let complete: serde_json::Value = + serde_json::from_slice(&complete).expect("complete success response"); + assert_eq!( + complete["data"]["decompilation"]["text"] + .as_str() + .expect("large text") + .len(), + 100_000 + ); + + let other = temp.path().join("other"); + fs::copy("tests/fixtures/fake-worker-error.sample", &other).expect("other"); + let failed = invoke( + &["inspect", other.to_str().expect("UTF-8")], + Some("analysis_failed"), + ); + assert_eq!(failed.status.code(), Some(1)); + assert!(failed.stdout.is_empty()); + let error: serde_json::Value = serde_json::from_slice(&failed.stderr).expect("error JSON"); + assert_eq!(error["error"]["code"], "analysis_failed"); + let diagnostic = error["error"]["details"]["diagnostic_log"]["path"]["value"] + .as_str() + .expect("diagnostic path"); + assert!(std::path::Path::new(diagnostic).is_file()); } #[test] @@ -41,3 +258,51 @@ fn explicit_human_parse_failure_is_not_json() { assert!(output.stdout.is_empty()); assert!(serde_json::from_slice::(&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 + ); +} diff --git a/tests/data/adapter-doctor-request.json b/tests/data/adapter-doctor-request.json new file mode 100644 index 0000000..4b88c94 --- /dev/null +++ b/tests/data/adapter-doctor-request.json @@ -0,0 +1,19 @@ +{ + "protocol_version": 1, + "invocation_id": "fedcba9876543210fedcba9876543210", + "operation": "doctor", + "staged_sample": null, + "analysis_path": null, + "limits": { + "max_heap_mib": 2048, + "max_cpu": 2, + "analysis_timeout_seconds": 60, + "decompile_timeout_seconds": null, + "child_watchdog_seconds": 120, + "max_sample_bytes": 1073741824, + "max_inline_bytes": 65536 + }, + "arguments": { + "kind": "doctor" + } +} diff --git a/tests/data/adapter-inspect-request.json b/tests/data/adapter-inspect-request.json new file mode 100644 index 0000000..c4f6fcf --- /dev/null +++ b/tests/data/adapter-inspect-request.json @@ -0,0 +1,19 @@ +{ + "protocol_version": 1, + "invocation_id": "0123456789abcdef0123456789abcdef", + "operation": "inspect", + "staged_sample": "@SAMPLE@", + "analysis_path": "@ANALYSIS@", + "limits": { + "max_heap_mib": 2048, + "max_cpu": 2, + "analysis_timeout_seconds": 60, + "decompile_timeout_seconds": null, + "child_watchdog_seconds": 180, + "max_sample_bytes": 1073741824, + "max_inline_bytes": 65536 + }, + "arguments": { + "kind": "inspect" + } +} diff --git a/tests/fixtures/fake-worker-error.sample b/tests/fixtures/fake-worker-error.sample new file mode 100644 index 0000000..f910a2d --- /dev/null +++ b/tests/fixtures/fake-worker-error.sample @@ -0,0 +1 @@ +other sample diff --git a/tests/real_ghidra_adapter.sh b/tests/real_ghidra_adapter.sh new file mode 100644 index 0000000..feff857 --- /dev/null +++ b/tests/real_ghidra_adapter.sh @@ -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" diff --git a/tests/support/fake_child.rs b/tests/support/fake_child.rs new file mode 100644 index 0000000..0ad1704 --- /dev/null +++ b/tests/support/fake_child.rs @@ -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> { + 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::()?; + 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::()?; + 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, +) -> Result<(), Box> { + 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> { + 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}) +} diff --git a/tests/worker_lifecycle.rs b/tests/worker_lifecycle.rs new file mode 100644 index 0000000..8d2939d --- /dev/null +++ b/tests/worker_lifecycle.rs @@ -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() + ); +}